use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use log::warn; use serde::{Deserialize, Serialize}; use crate::config::{ GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, }; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000; #[derive(Debug, Clone)] pub enum WorkspaceMemory { Structured { workspace_root: PathBuf, dir: PathBuf, }, Lite { workspace_root: PathBuf, file: PathBuf, }, } pub fn discover_workspace_memory(start: &Path) -> Option { for dir in start.ancestors() { let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME); if structured.join(MEMORY_INDEX_FILE_NAME).exists() { return Some(WorkspaceMemory::Structured { workspace_root: dir.to_path_buf(), dir: structured, }); } let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME); if lite.exists() { return Some(WorkspaceMemory::Lite { workspace_root: dir.to_path_buf(), file: lite, }); } } None } pub fn find_git_root(start: &Path) -> Option { for dir in start.ancestors() { if dir.join(GIT_DIR_NAME).exists() { return Some(dir.to_path_buf()); } } None } pub fn bootstrap_workspace_memory(git_root: &Path) -> Result { let mem_dir = paths::workspace_memory_dir_for(git_root); fs::create_dir_all(&mem_dir) .with_context(|| format!("create memory dir {}", mem_dir.display()))?; let index_path = mem_dir.join(MEMORY_INDEX_FILE_NAME); if !index_path.exists() { fs::write(&index_path, "# Workspace Memory Index\n\n") .with_context(|| format!("write {}", index_path.display()))?; } let gitignore_appended = append_gitignore_entry(git_root)?; let suffix = if gitignore_appended { " (appended .coyote/memory/ to .gitignore)" } else { "" }; warn!( "auto-bootstrapped workspace memory at {}{}", mem_dir.display(), suffix ); Ok(mem_dir) } fn append_gitignore_entry(git_root: &Path) -> Result { let gitignore = git_root.join(GITIGNORE_FILE_NAME); let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/"); let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}"); let existing = fs::read_to_string(&gitignore).unwrap_or_default(); let already_present = existing.lines().any(|line| { let trimmed = line.trim(); trimmed == entry || trimmed == entry_no_slash }); if already_present { return Ok(false); } let new_content = if existing.is_empty() { format!("{entry}\n") } else if existing.ends_with('\n') { format!("{existing}{entry}\n") } else { format!("{existing}\n{entry}\n") }; fs::write(&gitignore, new_content).with_context(|| format!("write {}", gitignore.display()))?; Ok(true) } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MemoryFrontmatter { #[serde(default)] pub name: String, #[serde(default)] pub description: Option, #[serde(default, rename = "type")] pub kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub created: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub updated: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub superseded_by: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub expires: Option, } #[derive(Debug, Clone)] pub struct MemoryFile { pub path: PathBuf, pub frontmatter: MemoryFrontmatter, pub body: String, } impl MemoryFile { pub fn load(path: &Path) -> Result { let raw = fs::read_to_string(path) .with_context(|| format!("read memory file {}", path.display()))?; let (frontmatter, body) = parse_frontmatter(&raw) .with_context(|| format!("parse frontmatter in {}", path.display()))?; Ok(Self { path: path.to_path_buf(), frontmatter, body, }) } pub fn save(&self) -> Result<()> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; } let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)?; let content = format!("---\n{}---\n\n{}", frontmatter_yaml, self.body); fs::write(&self.path, content)?; Ok(()) } pub fn char_len(&self) -> usize { self.body.chars().count() } } fn parse_frontmatter(raw: &str) -> Result<(MemoryFrontmatter, String)> { let trimmed = raw.trim_start(); if !trimmed.starts_with("---") { return Ok((MemoryFrontmatter::default(), raw.to_string())); } let after = &trimmed[3..]; let Some(end) = after.find("\n---") else { return Ok((MemoryFrontmatter::default(), raw.to_string())); }; let yaml = &after[..end]; let body = after[end + 4..].trim_start_matches('\n').to_string(); let frontmatter: MemoryFrontmatter = serde_yaml::from_str(yaml.trim()).context("parse YAML frontmatter")?; Ok((frontmatter, body)) } #[derive(Debug, Clone)] pub struct MemoryStore { pub global_dir: PathBuf, pub workspace: Option, } impl MemoryStore { pub fn new(cwd: &Path) -> Self { Self { global_dir: paths::global_memory_dir(), workspace: discover_workspace_memory(cwd), } } pub fn load_global_index(&self) -> Result> { let path = self.global_dir.join(MEMORY_INDEX_FILE_NAME); if path.exists() { Ok(Some(fs::read_to_string(path)?)) } else { Ok(None) } } pub fn load_workspace_index(&self) -> Result> { match &self.workspace { None => Ok(None), Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)), Some(WorkspaceMemory::Structured { dir, .. }) => { let index = dir.join(MEMORY_INDEX_FILE_NAME); if index.exists() { Ok(Some(fs::read_to_string(index)?)) } else { Ok(None) } } } } pub fn list_files(&self) -> Result> { let mut out = Vec::new(); if self.global_dir.exists() { collect_md_files(&self.global_dir, &mut out)?; } if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace { collect_md_files(dir, &mut out)?; } Ok(out) } } pub fn build_memory_section( store: &MemoryStore, with_tools: bool, cap: usize, ) -> Result> { let global_index = store.load_global_index()?; let workspace_index = store.load_workspace_index()?; if global_index.is_none() && workspace_index.is_none() { return Ok(None); } let mut buf = String::from("\n"); let mut consumed = 0usize; if let Some(s) = &global_index { buf.push_str("\n"); buf.push_str(s); buf.push_str("\n\n"); consumed += s.chars().count(); } if let Some(s) = &workspace_index { buf.push_str("\n"); buf.push_str(s); buf.push_str("\n\n"); consumed += s.chars().count(); } if consumed > cap { warn!( "memory indexes ({} chars) exceed cap ({} chars); injecting fully - \ consider raising memory_cap_* in config or shrinking MEMORY.md", consumed, cap ); } if !with_tools { let mut budget = cap.saturating_sub(consumed); let mut files = store.list_files()?; files.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name)); let mut omitted = 0usize; for f in files { let needed = f.body.chars().count() + 50; if needed > budget { omitted += 1; continue; } buf.push_str(&format!("\n", f.frontmatter.name)); buf.push_str(&f.body); buf.push_str("\n\n"); budget = budget.saturating_sub(needed); } if omitted > 0 { buf.push_str(&format!( "\n", omitted )); } } buf.push_str(""); Ok(Some(buf)) } fn collect_md_files(dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if path.file_name().and_then(|n| n.to_str()) == Some(MEMORY_INDEX_FILE_NAME) { continue; } match MemoryFile::load(&path) { Ok(f) => out.push(f), Err(e) => warn!("skip malformed memory file {}: {}", path.display(), e), } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::{env, time}; use time::SystemTime; fn temp_root(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(time::UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-memory-{label}-{unique}")); fs::create_dir_all(&root).unwrap(); root } #[test] fn loads_global_and_workspace_indexes_from_test_dirs() { let root = temp_root("phase1"); let workspace = root.join("workspace"); let workspace_memory_dir = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&workspace_memory_dir).unwrap(); fs::write( workspace_memory_dir.join(MEMORY_INDEX_FILE_NAME), "workspace-content", ) .unwrap(); let global = root.join("global"); fs::create_dir_all(&global).unwrap(); fs::write(global.join(MEMORY_INDEX_FILE_NAME), "global-content").unwrap(); let store = MemoryStore { global_dir: global, workspace: discover_workspace_memory(&workspace), }; assert_eq!( store.load_global_index().unwrap().as_deref(), Some("global-content") ); assert_eq!( store.load_workspace_index().unwrap().as_deref(), Some("workspace-content") ); let _ = fs::remove_dir_all(&root); } #[test] fn workspace_discovery_prefers_structured_over_lite() { let root = temp_root("prefer"); let workspace = root.join("ws"); let structured = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap(); fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap(); let found = discover_workspace_memory(&workspace); assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); let _ = fs::remove_dir_all(&root); } #[test] fn build_memory_section_returns_none_when_no_memory_exists() { let root = temp_root("none"); let workspace = root.join("ws"); fs::create_dir_all(&workspace).unwrap(); let store = MemoryStore { global_dir: root.join("global"), workspace: discover_workspace_memory(&workspace), }; assert!(build_memory_section(&store, true, 6_000).unwrap().is_none()); let _ = fs::remove_dir_all(&root); } #[test] fn build_memory_section_injects_only_indexes_with_tools_on() { let root = temp_root("indexes_only"); let workspace = root.join("ws"); let structured = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write( structured.join(MEMORY_INDEX_FILE_NAME), "workspace-index-content", ) .unwrap(); fs::write( structured.join("foo.md"), "---\nname: foo\n---\nfoo body that should not appear\n", ) .unwrap(); let store = MemoryStore { global_dir: root.join("global"), workspace: discover_workspace_memory(&workspace), }; let section = build_memory_section(&store, true, 6_000) .unwrap() .expect("memory section should exist"); assert!(section.contains("workspace-index-content")); assert!(!section.contains("foo body that should not appear")); assert!(section.starts_with("")); assert!(section.ends_with("")); let _ = fs::remove_dir_all(&root); } #[test] fn build_memory_section_injects_drill_bodies_alphabetically_without_tools() { let root = temp_root("drill_bodies"); let workspace = root.join("ws"); let structured = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write( structured.join("zebra.md"), "---\nname: zebra\n---\nzebra body\n", ) .unwrap(); fs::write( structured.join("alpha.md"), "---\nname: alpha\n---\nalpha body\n", ) .unwrap(); let store = MemoryStore { global_dir: root.join("global"), workspace: discover_workspace_memory(&workspace), }; let section = build_memory_section(&store, false, 6_000) .unwrap() .expect("memory section should exist"); let alpha_pos = section.find("alpha body").expect("alpha body missing"); let zebra_pos = section.find("zebra body").expect("zebra body missing"); assert!(alpha_pos < zebra_pos, "drill bodies must be alphabetical"); let _ = fs::remove_dir_all(&root); } #[test] fn build_memory_section_omits_drill_bodies_when_cap_exceeded() { let root = temp_root("cap"); let workspace = root.join("ws"); let structured = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&structured).unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); let big_body = "x".repeat(200); fs::write( structured.join("big.md"), format!("---\nname: big\n---\n{}\n", big_body), ) .unwrap(); let store = MemoryStore { global_dir: root.join("global"), workspace: discover_workspace_memory(&workspace), }; let section = build_memory_section(&store, false, 100) .unwrap() .expect("memory section should exist"); assert!(!section.contains(&big_body)); assert!(section.contains("memory file(s) omitted")); let _ = fs::remove_dir_all(&root); } #[test] fn parse_frontmatter_extracts_yaml() { let raw = "---\nname: foo\ndescription: a thing\ntype: user\n---\nBody text\n"; let (fm, body) = parse_frontmatter(raw).unwrap(); assert_eq!(fm.name, "foo"); assert_eq!(fm.description.as_deref(), Some("a thing")); assert_eq!(fm.kind.as_deref(), Some("user")); assert_eq!(body, "Body text\n"); } #[test] fn parse_frontmatter_handles_missing_block() { let raw = "# Just markdown, no frontmatter\nbody"; let (fm, body) = parse_frontmatter(raw).unwrap(); assert_eq!(fm.name, ""); assert!(fm.kind.is_none()); assert_eq!(body, raw); } #[test] fn parse_frontmatter_handles_unterminated_block() { let raw = "---\nname: oops\nno closing delimiter\n# rest of doc"; let (fm, body) = parse_frontmatter(raw).unwrap(); assert_eq!(fm.name, ""); assert_eq!(body, raw); } #[test] fn memory_file_save_and_load_roundtrip() { let root = temp_root("roundtrip"); let path = root.join("test.md"); let file = MemoryFile { path: path.clone(), frontmatter: MemoryFrontmatter { name: "test".into(), description: Some("a test".into()), kind: Some("user".into()), ..Default::default() }, body: "Hello world\nmore text".into(), }; file.save().unwrap(); let loaded = MemoryFile::load(&path).unwrap(); assert_eq!(loaded.frontmatter.name, "test"); assert_eq!(loaded.frontmatter.description.as_deref(), Some("a test")); assert_eq!(loaded.frontmatter.kind.as_deref(), Some("user")); assert_eq!(loaded.body, "Hello world\nmore text"); let raw = fs::read_to_string(&path).unwrap(); assert!(raw.contains("type: user"), "kind must serialize as 'type:'"); let _ = fs::remove_dir_all(&root); } #[test] fn discover_walks_up_from_nested_dir() { let root = temp_root("walk_up"); let workspace = root.join("ws"); let mem_dir = workspace .join(WORKSPACE_MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME); fs::create_dir_all(&mem_dir).unwrap(); fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); let nested = workspace.join("src").join("deep").join("path"); fs::create_dir_all(&nested).unwrap(); let found = discover_workspace_memory(&nested); assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); let _ = fs::remove_dir_all(&root); } #[test] fn find_git_root_returns_dir_containing_git_dir() { let root = temp_root("git_root"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); assert_eq!(find_git_root(&repo), Some(repo.clone())); let _ = fs::remove_dir_all(&root); } #[test] fn find_git_root_walks_up_from_nested_dir() { let root = temp_root("git_root_walk"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); let nested = repo.join("a").join("b").join("c"); fs::create_dir_all(&nested).unwrap(); assert_eq!(find_git_root(&nested), Some(repo)); let _ = fs::remove_dir_all(&root); } #[test] fn find_git_root_treats_git_file_as_repo_marker() { let root = temp_root("git_root_worktree"); let worktree = root.join("worktree"); fs::create_dir_all(&worktree).unwrap(); fs::write( worktree.join(GIT_DIR_NAME), "gitdir: /elsewhere/.git/worktrees/wt\n", ) .unwrap(); assert_eq!(find_git_root(&worktree), Some(worktree)); let _ = fs::remove_dir_all(&root); } #[test] fn find_git_root_returns_none_when_no_git() { let root = temp_root("git_root_missing"); let bare = root.join("bare"); fs::create_dir_all(&bare).unwrap(); assert_eq!(find_git_root(&bare), None); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_creates_structured_layout_and_index() { let root = temp_root("bootstrap_layout"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); let mem_dir = bootstrap_workspace_memory(&repo).unwrap(); assert_eq!(mem_dir, paths::workspace_memory_dir_for(&repo)); assert!(mem_dir.is_dir()); let index = mem_dir.join(MEMORY_INDEX_FILE_NAME); assert!(index.exists()); let body = fs::read_to_string(&index).unwrap(); assert!(body.starts_with("# Workspace Memory Index")); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_creates_gitignore_when_absent() { let root = temp_root("bootstrap_gi_new"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); bootstrap_workspace_memory(&repo).unwrap(); let gi = repo.join(GITIGNORE_FILE_NAME); assert!(gi.exists()); let body = fs::read_to_string(&gi).unwrap(); assert!(body.contains(".coyote/memory/")); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_appends_to_existing_gitignore_without_trailing_newline() { let root = temp_root("bootstrap_gi_append"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); fs::write(repo.join(GITIGNORE_FILE_NAME), "target/").unwrap(); bootstrap_workspace_memory(&repo).unwrap(); let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); assert!(body.contains("target/")); assert!(body.contains(".coyote/memory/")); assert!(body.ends_with('\n')); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_is_idempotent_on_gitignore_entry() { let root = temp_root("bootstrap_gi_idempotent"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); let original = "target/\n.coyote/memory/\n"; fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap(); bootstrap_workspace_memory(&repo).unwrap(); let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); assert_eq!(body, original, "gitignore must be untouched"); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_treats_entry_without_trailing_slash_as_present() { let root = temp_root("bootstrap_gi_no_slash"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); let original = ".coyote/memory\n"; fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap(); bootstrap_workspace_memory(&repo).unwrap(); let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap(); assert_eq!(body, original); let _ = fs::remove_dir_all(&root); } #[test] fn bootstrap_does_not_clobber_existing_index() { let root = temp_root("bootstrap_existing_index"); let repo = root.join("repo"); fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap(); let mem_dir = paths::workspace_memory_dir_for(&repo); fs::create_dir_all(&mem_dir).unwrap(); let preserved = "# Custom Index\n\n- [[foo]]: keep me\n"; fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), preserved).unwrap(); bootstrap_workspace_memory(&repo).unwrap(); let body = fs::read_to_string(mem_dir.join(MEMORY_INDEX_FILE_NAME)).unwrap(); assert_eq!(body, preserved); let _ = fs::remove_dir_all(&root); } }