feat: Added new memory functions for deleting and renaming memory files, as well as new lints for memory expiration dates and staleness of memories to improve the memory system
This commit is contained in:
@@ -118,6 +118,14 @@ pub struct MemoryFrontmatter {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(default, rename = "type")]
|
#[serde(default, rename = "type")]
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub superseded_by: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -545,6 +553,7 @@ mod tests {
|
|||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
description: Some("a test".into()),
|
description: Some("a test".into()),
|
||||||
kind: Some("user".into()),
|
kind: Some("user".into()),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: "Hello world\nmore text".into(),
|
body: "Hello world\nmore text".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-2
@@ -18,10 +18,16 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
|||||||
- `memory__read(name)`: Read a specific drill file's full content.
|
- `memory__read(name)`: Read a specific drill file's full content.
|
||||||
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
||||||
The MEMORY.md index is appended automatically; do not also update the index by hand.
|
The MEMORY.md index is appended automatically; do not also update the index by hand.
|
||||||
|
Optional `superseded_by` / `expires` (YYYY-MM-DD) mark a memory as stale for later cleanup.
|
||||||
|
- `memory__rename(name, new_name, scope)`: Rename a drill file. Its index entry and every
|
||||||
|
[[wikilink]] to it are rewritten automatically.
|
||||||
|
- `memory__delete(name, scope)`: Delete a drill file and its index entry. Reports any
|
||||||
|
[[wikilinks]] left dangling in other files.
|
||||||
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
|
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
|
||||||
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
|
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
|
||||||
- `memory__list()`: See all known drill files and their metadata.
|
- `memory__list()`: See all known drill files and their metadata.
|
||||||
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files.
|
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files,
|
||||||
|
stale (superseded/expired) files, and index descriptions that drifted from the files.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
||||||
@@ -29,7 +35,11 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
|||||||
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
|
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
|
||||||
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
|
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
|
||||||
MEMORY.md outside the managed path is invisible to memory.
|
MEMORY.md outside the managed path is invisible to memory.
|
||||||
- All drill files MUST go through `memory__write`. The index updates itself.
|
- All drill files MUST go through `memory__write`. The index updates itself. Renames and
|
||||||
|
deletions MUST go through `memory__rename` / `memory__delete` so links stay intact.
|
||||||
|
- When a fact becomes outdated, update it in place, delete it, or mark the old file with
|
||||||
|
`superseded_by`/`expires` so `memory__lint` flags it later. Never leave contradictory
|
||||||
|
memories side by side.
|
||||||
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
||||||
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
||||||
Use coyote's Vault for secrets.
|
Use coyote's Vault for secrets.
|
||||||
|
|||||||
+721
-46
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use chrono::Local;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -97,6 +98,32 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"superseded_by".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Optional `name:` slug of the memory that replaces this one. \
|
||||||
|
`memory__lint` flags superseded files for cleanup. Omitting this \
|
||||||
|
on overwrite clears any previous value."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expires".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Optional ISO date (YYYY-MM-DD) after which this memory is stale. \
|
||||||
|
`memory__lint` flags expired files. Omitting this on overwrite \
|
||||||
|
clears any previous value."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
])),
|
])),
|
||||||
required: Some(vec![
|
required: Some(vec![
|
||||||
"name".to_string(),
|
"name".to_string(),
|
||||||
@@ -164,6 +191,90 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
},
|
},
|
||||||
agent: false,
|
agent: false,
|
||||||
},
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{MEMORY_FUNCTION_PREFIX}rename"),
|
||||||
|
description:
|
||||||
|
"Rename a memory file. Its MEMORY.md index entry and every [[wikilink]] to it in \
|
||||||
|
other memory files are rewritten automatically."
|
||||||
|
.to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::from([
|
||||||
|
(
|
||||||
|
"name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some("Current `name:` slug of the memory file".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"New kebab-case slug for the file (no extension)".into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scope".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
required: Some(vec![
|
||||||
|
"name".to_string(),
|
||||||
|
"new_name".to_string(),
|
||||||
|
"scope".to_string(),
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{MEMORY_FUNCTION_PREFIX}delete"),
|
||||||
|
description:
|
||||||
|
"Delete a memory file and remove its MEMORY.md index entry. Reports any \
|
||||||
|
[[wikilinks]] in other memory files left dangling by the deletion."
|
||||||
|
.to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::from([
|
||||||
|
(
|
||||||
|
"name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"The `name:` slug of the memory file to delete".into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scope".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
required: Some(vec!["name".to_string(), "scope".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,47 +325,13 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
"workspace": store.workspace.as_ref().map(workspace_label),
|
"workspace": store.workspace.as_ref().map(workspace_label),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"write" => {
|
"write" => write_memory(&store, &cwd, args),
|
||||||
let name = arg_str(args, "name")?;
|
"rename" => rename_memory(&store, &cwd, args),
|
||||||
let description = arg_str(args, "description")?;
|
"delete" => delete_memory(&store, &cwd, args),
|
||||||
let content = arg_str(args, "content")?;
|
|
||||||
let scope = arg_str(args, "scope")?;
|
|
||||||
let kind = args.get("type").and_then(Value::as_str).map(String::from);
|
|
||||||
|
|
||||||
let target_dir = match scope.as_str() {
|
|
||||||
"global" => paths::global_memory_dir(),
|
|
||||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
|
||||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
|
||||||
};
|
|
||||||
let file = MemoryFile {
|
|
||||||
path: target_dir.join(format!("{name}.md")),
|
|
||||||
frontmatter: MemoryFrontmatter {
|
|
||||||
name: name.clone(),
|
|
||||||
description: Some(description.clone()),
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
body: content,
|
|
||||||
};
|
|
||||||
file.save()?;
|
|
||||||
|
|
||||||
let index_path = target_dir.join("MEMORY.md");
|
|
||||||
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"status": "ok",
|
|
||||||
"path": file.path.display().to_string(),
|
|
||||||
"index_path": index_path.display().to_string(),
|
|
||||||
"index_updated": index_updated,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"edit_index" => {
|
"edit_index" => {
|
||||||
let scope = arg_str(args, "scope")?;
|
let scope = arg_str(args, "scope")?;
|
||||||
let content = arg_str(args, "content")?;
|
let content = arg_str(args, "content")?;
|
||||||
let target_dir = match scope.as_str() {
|
let target_dir = scope_dir(&store, &cwd, &scope)?;
|
||||||
"global" => paths::global_memory_dir(),
|
|
||||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
|
||||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
|
||||||
};
|
|
||||||
let index_path = write_memory_index(&target_dir, &content)?;
|
let index_path = write_memory_index(&target_dir, &content)?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -267,19 +344,229 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let description = arg_str(args, "description")?;
|
||||||
|
let content = arg_str(args, "content")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
let kind = args.get("type").and_then(Value::as_str).map(String::from);
|
||||||
|
let superseded_by = args
|
||||||
|
.get("superseded_by")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(String::from);
|
||||||
|
let expires = args
|
||||||
|
.get("expires")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let path = target_dir.join(format!("{name}.md"));
|
||||||
|
let previous = if path.exists() {
|
||||||
|
MemoryFile::load(&path).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let today = today_string();
|
||||||
|
let created = previous
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.frontmatter.created.clone())
|
||||||
|
.unwrap_or_else(|| today.clone());
|
||||||
|
|
||||||
|
let file = MemoryFile {
|
||||||
|
path,
|
||||||
|
frontmatter: MemoryFrontmatter {
|
||||||
|
name: name.clone(),
|
||||||
|
description: Some(description.clone()),
|
||||||
|
kind,
|
||||||
|
created: Some(created),
|
||||||
|
updated: Some(today),
|
||||||
|
superseded_by,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
};
|
||||||
|
file.save()?;
|
||||||
|
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"path": file.path.display().to_string(),
|
||||||
|
"index_path": index_path.display().to_string(),
|
||||||
|
"index_updated": index_updated,
|
||||||
|
"replaced": previous.is_some(),
|
||||||
|
"previous_description": previous.and_then(|p| p.frontmatter.description),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let new_name = arg_str(args, "new_name")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
if new_name.is_empty()
|
||||||
|
|| !new_name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"invalid new_name '{}': use a kebab-case slug (alphanumeric, hyphens, underscores)",
|
||||||
|
new_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == new_name {
|
||||||
|
bail!("new_name matches the current name");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let files = store.list_files()?;
|
||||||
|
let file = files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
|
||||||
|
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if target_dir.join(format!("{new_name}.md")).exists()
|
||||||
|
|| files
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == new_name)
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"memory file '{}' already exists in scope '{}'",
|
||||||
|
new_name,
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let needle = format!("[[{name}]]");
|
||||||
|
let replacement = format!("[[{new_name}]]");
|
||||||
|
|
||||||
|
let mut renamed = file.clone();
|
||||||
|
renamed.path = target_dir.join(format!("{new_name}.md"));
|
||||||
|
renamed.frontmatter.name = new_name.clone();
|
||||||
|
renamed.frontmatter.updated = Some(today_string());
|
||||||
|
renamed.body = renamed.body.replace(&needle, &replacement);
|
||||||
|
renamed.save()?;
|
||||||
|
fs::remove_file(&file.path).with_context(|| format!("remove {}", file.path.display()))?;
|
||||||
|
|
||||||
|
let mut rewritten = Vec::new();
|
||||||
|
for f in &files {
|
||||||
|
if f.path == file.path || !f.body.contains(&needle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut updated = f.clone();
|
||||||
|
updated.body = updated.body.replace(&needle, &replacement);
|
||||||
|
updated.save()?;
|
||||||
|
rewritten.push(f.frontmatter.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own-scope index: rewrite the wikilink, drop any leftover references to the
|
||||||
|
// old name, and guarantee the new name is present.
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
if let Ok(existing) = fs::read_to_string(&index_path)
|
||||||
|
&& existing.contains(&needle)
|
||||||
|
{
|
||||||
|
fs::write(&index_path, existing.replace(&needle, &replacement))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_index_entry(&index_path, &name)?;
|
||||||
|
let description = renamed.frontmatter.description.clone().unwrap_or_default();
|
||||||
|
ensure_index_entry(&index_path, &new_name, &description)?;
|
||||||
|
|
||||||
|
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only.
|
||||||
|
for other_index in other_index_paths(store, &target_dir) {
|
||||||
|
if let Ok(existing) = fs::read_to_string(&other_index)
|
||||||
|
&& existing.contains(&needle)
|
||||||
|
{
|
||||||
|
fs::write(&other_index, existing.replace(&needle, &replacement))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"old_path": file.path.display().to_string(),
|
||||||
|
"new_path": renamed.path.display().to_string(),
|
||||||
|
"rewritten_references": rewritten,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let files = store.list_files()?;
|
||||||
|
let file = files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
|
||||||
|
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?;
|
||||||
|
let deleted_path = file.path.clone();
|
||||||
|
fs::remove_file(&deleted_path).with_context(|| format!("delete {}", deleted_path.display()))?;
|
||||||
|
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
let index_updated = remove_index_entry(&index_path, &name)?;
|
||||||
|
|
||||||
|
let dangling: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.path != deleted_path && extract_wikilinks(&f.body).iter().any(|l| l == &name))
|
||||||
|
.map(|f| f.frontmatter.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"deleted_path": deleted_path.display().to_string(),
|
||||||
|
"index_updated": index_updated,
|
||||||
|
"dangling_references": dangling,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_dir(store: &MemoryStore, cwd: &Path, scope: &str) -> Result<PathBuf> {
|
||||||
|
match scope {
|
||||||
|
"global" => Ok(paths::global_memory_dir()),
|
||||||
|
"workspace" => workspace_write_dir(store, cwd),
|
||||||
|
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today_string() -> String {
|
||||||
|
Local::now().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let global_index = store.global_dir.join("MEMORY.md");
|
||||||
|
if store.global_dir.as_path() != own_dir && global_index.exists() {
|
||||||
|
out.push(global_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
match &store.workspace {
|
||||||
|
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
||||||
|
let index = dir.join("MEMORY.md");
|
||||||
|
if dir.as_path() != own_dir && index.exists() {
|
||||||
|
out.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
|
||||||
|
out.push(file.clone());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
|
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
|
||||||
fs::create_dir_all(target_dir)?;
|
fs::create_dir_all(target_dir)?;
|
||||||
let index_path = target_dir.join("MEMORY.md");
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
|
||||||
fs::write(&index_path, content)?;
|
fs::write(&index_path, content)?;
|
||||||
|
|
||||||
Ok(index_path)
|
Ok(index_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
|
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
|
||||||
let existing = fs::read_to_string(index_path).unwrap_or_default();
|
let existing = fs::read_to_string(index_path).unwrap_or_default();
|
||||||
let already_referenced =
|
if index_references(&existing, name) {
|
||||||
existing.contains(&format!("[[{name}]]")) || existing.contains(&format!("{name}.md"));
|
|
||||||
|
|
||||||
if already_referenced {
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +584,40 @@ fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs::write(index_path, new_content)?;
|
fs::write(index_path, new_content)?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_references(line: &str, name: &str) -> bool {
|
||||||
|
let file_name = format!("{name}.md");
|
||||||
|
line.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_' || c == '.'))
|
||||||
|
.any(|token| token == file_name || token.trim_matches('.') == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_references(index: &str, name: &str) -> bool {
|
||||||
|
index.lines().any(|line| line_references(line, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_index_entry(index_path: &Path, name: &str) -> Result<bool> {
|
||||||
|
let Ok(existing) = fs::read_to_string(index_path) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let kept: Vec<&str> = existing
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line_references(line, name))
|
||||||
|
.collect();
|
||||||
|
let mut new_content = kept.join("\n");
|
||||||
|
|
||||||
|
if existing.ends_with('\n') && !new_content.is_empty() {
|
||||||
|
new_content.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content == existing {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(index_path, new_content)?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,9 +671,11 @@ fn workspace_label(w: &WorkspaceMemory) -> Value {
|
|||||||
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
||||||
let files = store.list_files()?;
|
let files = store.list_files()?;
|
||||||
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
|
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
|
||||||
|
let today = today_string();
|
||||||
|
|
||||||
let mut oversized = Vec::new();
|
let mut oversized = Vec::new();
|
||||||
let mut broken_links = Vec::new();
|
let mut broken_links = Vec::new();
|
||||||
|
let mut stale = Vec::new();
|
||||||
for f in &files {
|
for f in &files {
|
||||||
if f.char_len() > PER_FILE_SOFT_CAP {
|
if f.char_len() > PER_FILE_SOFT_CAP {
|
||||||
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
|
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
|
||||||
@@ -362,16 +685,54 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
|||||||
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
|
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(target) = &f.frontmatter.superseded_by {
|
||||||
|
stale.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"reason": "superseded",
|
||||||
|
"superseded_by": target,
|
||||||
|
"target_exists": names.contains(target.as_str()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expires) = &f.frontmatter.expires
|
||||||
|
&& expires.as_str() < today.as_str()
|
||||||
|
{
|
||||||
|
stale.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"reason": "expired",
|
||||||
|
"expires": expires,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let index_content = store
|
let global_index = store.load_global_index()?.unwrap_or_default();
|
||||||
.load_global_index()?
|
let workspace_index = store
|
||||||
.or_else(|| store.load_workspace_index().ok().flatten())
|
.load_workspace_index()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut orphans = Vec::new();
|
let mut orphans = Vec::new();
|
||||||
|
let mut description_drift = Vec::new();
|
||||||
|
|
||||||
for f in &files {
|
for f in &files {
|
||||||
if !index_content.contains(&f.frontmatter.name) {
|
let index = if f.path.starts_with(&store.global_dir) {
|
||||||
|
&global_index
|
||||||
|
} else {
|
||||||
|
&workspace_index
|
||||||
|
};
|
||||||
|
|
||||||
|
if !index_references(index, &f.frontmatter.name) {
|
||||||
orphans.push(f.frontmatter.name.clone());
|
orphans.push(f.frontmatter.name.clone());
|
||||||
|
} else if let (Some(index_desc), Some(file_desc)) = (
|
||||||
|
index_description(index, &f.frontmatter.name),
|
||||||
|
f.frontmatter.description.as_deref(),
|
||||||
|
) && index_desc != file_desc
|
||||||
|
{
|
||||||
|
description_drift.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"index_description": index_desc,
|
||||||
|
"file_description": file_desc,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +741,26 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
|||||||
"oversized": oversized,
|
"oversized": oversized,
|
||||||
"broken_wikilinks": broken_links,
|
"broken_wikilinks": broken_links,
|
||||||
"orphans": orphans,
|
"orphans": orphans,
|
||||||
|
"stale": stale,
|
||||||
|
"description_drift": description_drift,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn index_description(index: &str, name: &str) -> Option<String> {
|
||||||
|
let marker = format!("[[{name}]]");
|
||||||
|
index.lines().find_map(|line| {
|
||||||
|
let pos = line.find(&marker)?;
|
||||||
|
let rest = line[pos + marker.len()..].trim_start();
|
||||||
|
let desc = rest.strip_prefix(':')?.trim();
|
||||||
|
(!desc.is_empty()).then(|| desc.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_wikilinks(body: &str) -> Vec<String> {
|
fn extract_wikilinks(body: &str) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let bytes = body.as_bytes();
|
let bytes = body.as_bytes();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
||||||
while i + 1 < bytes.len() {
|
while i + 1 < bytes.len() {
|
||||||
if bytes[i] == b'['
|
if bytes[i] == b'['
|
||||||
&& bytes[i + 1] == b'['
|
&& bytes[i + 1] == b'['
|
||||||
@@ -676,4 +1050,305 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_references_requires_exact_token_match() {
|
||||||
|
assert!(line_references("- [[auth]]: description", "auth"));
|
||||||
|
assert!(line_references("- auth.md is here", "auth"));
|
||||||
|
assert!(line_references("- referenced", "referenced"));
|
||||||
|
assert!(line_references("see auth.", "auth"));
|
||||||
|
assert!(!line_references("- [[auth-flow]]: description", "auth"));
|
||||||
|
assert!(!line_references("- oauth.md legacy", "auth"));
|
||||||
|
assert!(!line_references("- preauth notes", "auth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_index_entry_drops_only_matching_lines() {
|
||||||
|
let root = temp_root("index_remove");
|
||||||
|
let index = root.join("MEMORY.md");
|
||||||
|
fs::write(
|
||||||
|
&index,
|
||||||
|
"# Memory Index\n\n- [[keep]]: stays\n- [[gone]]: removed\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(remove_index_entry(&index, "gone").unwrap());
|
||||||
|
let content = fs::read_to_string(&index).unwrap();
|
||||||
|
assert!(content.contains("[[keep]]"));
|
||||||
|
assert!(!content.contains("[[gone]]"));
|
||||||
|
|
||||||
|
assert!(!remove_index_entry(&index, "gone").unwrap());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lint_checks_orphans_against_own_scope_index() {
|
||||||
|
let root = temp_root("lint_scopes");
|
||||||
|
let global = root.join("global");
|
||||||
|
fs::create_dir_all(&global).unwrap();
|
||||||
|
fs::write(global.join("MEMORY.md"), "- [[global-note]]: g\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
global.join("global-note.md"),
|
||||||
|
"---\nname: global-note\n---\ng\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "- [[ws-note]]: w\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("ws-note.md"),
|
||||||
|
"---\nname: ws-note\n---\nw\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: global,
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = lint_memory(&store).unwrap();
|
||||||
|
assert!(
|
||||||
|
report["orphans"].as_array().unwrap().is_empty(),
|
||||||
|
"expected no orphans, got: {report}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lint_flags_stale_and_description_drift() {
|
||||||
|
let root = temp_root("lint_stale");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"- [[old-plan]]: old\n- [[bygone]]: e\n- [[drifted]]: index says this\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("old-plan.md"),
|
||||||
|
"---\nname: old-plan\nsuperseded_by: new-plan\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("bygone.md"),
|
||||||
|
"---\nname: bygone\nexpires: 2000-01-01\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("drifted.md"),
|
||||||
|
"---\nname: drifted\ndescription: file says that\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("nonexistent_global"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = lint_memory(&store).unwrap();
|
||||||
|
let stale = report["stale"].as_array().unwrap();
|
||||||
|
let reasons: Vec<(&str, &str)> = stale
|
||||||
|
.iter()
|
||||||
|
.map(|v| (v["name"].as_str().unwrap(), v["reason"].as_str().unwrap()))
|
||||||
|
.collect();
|
||||||
|
assert!(reasons.contains(&("old-plan", "superseded")));
|
||||||
|
assert!(reasons.contains(&("bygone", "expired")));
|
||||||
|
let superseded = stale.iter().find(|v| v["name"] == "old-plan").unwrap();
|
||||||
|
assert_eq!(superseded["target_exists"], false);
|
||||||
|
|
||||||
|
let drift = report["description_drift"].as_array().unwrap();
|
||||||
|
assert_eq!(drift.len(), 1);
|
||||||
|
assert_eq!(drift[0]["name"], "drifted");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_memory_removes_file_index_entry_and_reports_dangling() {
|
||||||
|
let root = temp_root("delete");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"# Memory Index\n\n- [[doomed]]: bye\n- [[linker]]: links\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("doomed.md"),
|
||||||
|
"---\nname: doomed\n---\nbye\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("linker.md"),
|
||||||
|
"---\nname: linker\n---\nsee [[doomed]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = json!({"name": "doomed", "scope": "workspace"});
|
||||||
|
let result = delete_memory(&store, &workspace, &args).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
assert_eq!(result["index_updated"], true);
|
||||||
|
assert!(!structured.join("doomed.md").exists());
|
||||||
|
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
|
||||||
|
assert!(!index.contains("doomed"));
|
||||||
|
assert!(index.contains("[[linker]]"));
|
||||||
|
assert_eq!(
|
||||||
|
result["dangling_references"].as_array().unwrap(),
|
||||||
|
&vec![json!("linker")]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_memory_moves_file_and_rewrites_references() {
|
||||||
|
let root = temp_root("rename");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"# Memory Index\n\n- [[old-name]]: the plan\n- [[linker]]: links\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("old-name.md"),
|
||||||
|
"---\nname: old-name\ndescription: the plan\n---\nself link [[old-name]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("linker.md"),
|
||||||
|
"---\nname: linker\n---\nsee [[old-name]] and [[old-name-extended]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = json!({"name": "old-name", "new_name": "new-name", "scope": "workspace"});
|
||||||
|
let result = rename_memory(&store, &workspace, &args).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
assert!(!structured.join("old-name.md").exists());
|
||||||
|
let renamed = MemoryFile::load(&structured.join("new-name.md")).unwrap();
|
||||||
|
assert_eq!(renamed.frontmatter.name, "new-name");
|
||||||
|
assert!(renamed.body.contains("[[new-name]]"));
|
||||||
|
|
||||||
|
let linker = fs::read_to_string(structured.join("linker.md")).unwrap();
|
||||||
|
assert!(linker.contains("[[new-name]]"));
|
||||||
|
assert!(
|
||||||
|
linker.contains("[[old-name-extended]]"),
|
||||||
|
"unrelated links must be untouched: {linker}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
|
||||||
|
assert!(index.contains("- [[new-name]]: the plan"));
|
||||||
|
assert!(!index.contains("[[old-name]]"));
|
||||||
|
assert!(index.contains("[[linker]]"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["rewritten_references"].as_array().unwrap(),
|
||||||
|
&vec![json!("linker")]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_memory_rejects_collisions_and_bad_slugs() {
|
||||||
|
let root = temp_root("rename_guard");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "- [[a]]: a\n- [[b]]: b\n").unwrap();
|
||||||
|
fs::write(structured.join("a.md"), "---\nname: a\n---\nx\n").unwrap();
|
||||||
|
fs::write(structured.join("b.md"), "---\nname: b\n---\nx\n").unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let collision = json!({"name": "a", "new_name": "b", "scope": "workspace"});
|
||||||
|
let err = rename_memory(&store, &workspace, &collision).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("already exists"));
|
||||||
|
|
||||||
|
let bad_slug = json!({"name": "a", "new_name": "bad name!", "scope": "workspace"});
|
||||||
|
let err = rename_memory(&store, &workspace, &bad_slug).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invalid new_name"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_memory_stamps_timestamps_and_reports_replacement() {
|
||||||
|
let root = temp_root("write_stamps");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "# Memory Index\n").unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = json!({
|
||||||
|
"name": "fact",
|
||||||
|
"description": "first version",
|
||||||
|
"content": "body v1",
|
||||||
|
"scope": "workspace",
|
||||||
|
"expires": "2099-01-01",
|
||||||
|
});
|
||||||
|
let before = today_string();
|
||||||
|
let result = write_memory(&store, &workspace, &first).unwrap();
|
||||||
|
let after = today_string();
|
||||||
|
assert_eq!(result["replaced"], false);
|
||||||
|
assert_eq!(result["previous_description"], Value::Null);
|
||||||
|
|
||||||
|
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
|
||||||
|
let created = saved.frontmatter.created.clone().expect("created stamped");
|
||||||
|
assert!(
|
||||||
|
created == before || created == after,
|
||||||
|
"created '{created}' should be stamped with today's date"
|
||||||
|
);
|
||||||
|
assert_eq!(saved.frontmatter.updated, Some(created.clone()));
|
||||||
|
assert_eq!(saved.frontmatter.expires.as_deref(), Some("2099-01-01"));
|
||||||
|
assert_eq!(saved.frontmatter.superseded_by, None);
|
||||||
|
|
||||||
|
let second = json!({
|
||||||
|
"name": "fact",
|
||||||
|
"description": "second version",
|
||||||
|
"content": "body v2",
|
||||||
|
"scope": "workspace",
|
||||||
|
});
|
||||||
|
let result = write_memory(&store, &workspace, &second).unwrap();
|
||||||
|
assert_eq!(result["replaced"], true);
|
||||||
|
assert_eq!(result["previous_description"], "first version");
|
||||||
|
|
||||||
|
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
saved.frontmatter.created,
|
||||||
|
Some(created),
|
||||||
|
"creation date must be preserved across overwrites"
|
||||||
|
);
|
||||||
|
assert!(saved.frontmatter.updated.is_some());
|
||||||
|
assert_eq!(saved.frontmatter.expires, None);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user