//! AGENTS.md / Claude.md / rules directory discovery and loading. //! //! Searches from cwd to repo root, plus `~/.kigi/`. Also discovers //! `*.md` files in `.kigi/rules/` and `.claude/rules/` directories. use std::path::{Path, PathBuf}; use crate::prompt::ignore::{build_gitignore, is_ignored}; use kigi_tools::types::compat::CompatConfig; /// Represents an agent config file with its path and content. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AgentConfigFile { /// The filename (e.g., "AGENTS.md", "Claude.md") pub file_name: String, /// The full absolute path to the config file pub file_path: String, /// The content of the config file pub content: String, } /// Find matching agent config files in a directory. /// /// `filenames` is the (compat-gated) recognized list, precomputed once by the /// caller so the cwd→root walk doesn't re-allocate it per directory. When all /// cells are on it equals the legacy `AGENT_FILENAMES` list exactly. fn find_agent_files(dir: &Path, filenames: &[&str]) -> Vec { filenames .iter() .filter_map(|name| { let path = dir.join(name); path.exists().then_some(path) }) .collect() } /// Find `*.md` files in `.kigi/rules/`, `.claude/rules/`, and `.cursor/rules/`, /// sorted alphabetically. `rules_subdirs` is the (compat-gated) list, precomputed /// once by the caller so the walk doesn't re-allocate it per directory. fn find_rules_files(dir: &Path, rules_subdirs: &[&str]) -> Vec { let mut results = Vec::new(); for rules_subdir in rules_subdirs { let rules_dir = dir.join(rules_subdir); if !rules_dir.is_dir() { continue; } let mut entries: Vec = match std::fs::read_dir(&rules_dir) { Ok(iter) => iter .filter_map(|entry| entry.ok()) .map(|e| e.path()) .filter(|p| { p.extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) }) .collect(), Err(_) => continue, }; entries.sort_by(|a, b| a.file_name().cmp(&b.file_name())); results.extend(entries); } results } /// Read Agents.md from ~/.kigi/, git repo root, and session cwd. /// Returns a list of AgentConfigFile with their file names, full paths, and contents. /// /// `compat` gates which vendor (`.claude`/`.cursor`) surfaces are scanned for /// rules / project-instruction files; pass `CompatConfig::default()` to /// preserve the historical all-vendors behavior. pub async fn read_agents_config_with_paths( working_directory: &str, compat: CompatConfig, ) -> Vec { let workspace_user_dir = crate::prompt::workspace_user::optional_workspace_user_dir(); read_agents_config_with_options(working_directory, workspace_user_dir.as_deref(), compat).await } /// Inner implementation that accepts an optional workspace user dir as a /// parameter, making it testable without environment variable mutation. async fn read_agents_config_with_options( working_directory: &str, workspace_user_dir: Option<&Path>, compat: CompatConfig, ) -> Vec { let cwd = PathBuf::from(working_directory); let global_dir = kigi_tools::util::kigi_home::kigi_home(); let git_root = git2::Repository::discover(&cwd) .ok() .and_then(|repo| repo.workdir().map(|p| p.to_path_buf())); let gitignore = build_gitignore(git_root.as_deref()); // Always include kigi_home (~/.kigi/) first, then ~/.claude/ and ~/.cursor/ // for compat — each gated by the resolved `agents` compat cell. let mut dirs = vec![global_dir]; if let Some(home) = dirs::home_dir() { for compat_dir in compat.agents_home_dirs() { let dir = home.join(compat_dir); if dir.is_dir() { dirs.push(dir); } } } // Walk from cwd up to git root to pick up agent files in intermediate directories if let Some(ref root) = git_root { let mut current = Some(cwd.as_path()); let mut chain: Vec = Vec::new(); while let Some(dir) = current { let dir_buf = dir.to_path_buf(); if !chain.contains(&dir_buf) { chain.push(dir_buf); } if dir == root.as_path() { break; } current = dir.parent(); } // CRITICAL: Reverse to get root → CWD order (deeper files come later) chain.reverse(); // Inject optional workspace user dir if not already in the chain. // Insert after repo root (index 0 after reverse) so it's higher priority // than repo root AGENTS.md but lower priority than intermediate dirs and cwd. if let Some(user_dir) = workspace_user_dir { let user_dir_canonical = dunce::canonicalize(user_dir).unwrap_or_else(|_| user_dir.to_path_buf()); let already_in_chain = chain.iter().any(|d| { dunce::canonicalize(d).unwrap_or_else(|_| d.clone()) == user_dir_canonical }); if !already_in_chain { // chain[0] is repo root after reverse; insert right after it. let insert_pos = 1.min(chain.len()); chain.insert(insert_pos, user_dir.to_path_buf()); } } dirs.extend(chain); } else if !dirs.contains(&cwd) { dirs.push(cwd.clone()); } // Compute the gated lists once (constant across all scanned dirs) so the // per-directory scan below doesn't re-allocate them. let agent_filenames = compat.agent_filenames(); let rules_dirs = compat.rules_dirs(); let files: Vec = dirs .into_iter() .flat_map(|dir| { let mut combined = find_agent_files(&dir, &agent_filenames); combined.extend(find_rules_files(&dir, &rules_dirs)); combined }) .filter(|path| !is_ignored(path, gitignore.as_ref(), git_root.as_deref())) .collect(); // Deduplicate by canonical path to handle case-insensitive filesystems // and symlink-resolved tmpdir paths. let mut seen_canonical = std::collections::HashSet::new(); files .into_iter() .filter(|path| { let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone()); seen_canonical.insert(canonical) }) .filter_map(|file_path| { let content = std::fs::read_to_string(&file_path).ok()?; let file_name = file_path .file_name() .and_then(|f| f.to_str()) .unwrap_or("AGENTS.md") .to_string(); let full_path = file_path.display().to_string(); Some(AgentConfigFile { file_name, file_path: full_path, content, }) }) .collect() } /// Format AGENTS.md configs into a `` block for user message injection. pub fn format_agents_md_section(configs: &[AgentConfigFile]) -> Option { render_agents_md(configs) } /// Verbatim leading bytes [`render_agents_md`] emits for every reminder block. /// Used by `kigi-shell` to structurally detect legacy untagged AGENTS.md /// copies (pre-`SyntheticReason::ProjectInstructions`) on resumed sessions. pub const LEGACY_AGENTS_MD_REMINDER_PREFIX: &str = "\n\n\nAs you answer the user's questions, you can use the following context"; fn render_agents_md(configs: &[AgentConfigFile]) -> Option { if configs.is_empty() { return None; } let mut section = String::new(); section.push_str(LEGACY_AGENTS_MD_REMINDER_PREFIX); section.push_str( " (ordered from repo root to current directory - deeper files take precedence on conflicts):\n", ); for config in configs { section.push_str(&format!("\n## From: {}\n", config.file_path)); // Strip YAML frontmatter from rules files (e.g. .claude/rules/*.md, // .kigi/rules/*.md) so globs/paths metadata doesn't leak into the // system prompt as raw YAML. let is_rules_file = config.file_path.contains("/.kigi/rules/") || config.file_path.contains("/.claude/rules/"); let content = if is_rules_file { kigi_tools::implementations::skills::skill::extract_skill_body(&config.content) } else { config.content.clone() }; section.push_str(&content); section.push('\n'); } section.push_str("\nFollow these instructions exactly. When working in subdirectories not listed above, check for additional project instruction files (AGENTS.md, Claude.md, etc.)."); section.push_str("\n"); Some(section) } #[cfg(test)] mod tests { use super::*; use std::fs; /// Helper: initialize a git repo at `path` so git2::Repository::discover works. fn init_git_repo(path: &Path) { git2::Repository::init(path).unwrap(); } // find_agent_files unit tests #[test] fn find_agent_files_finds_agents_md() { let tmp = tempfile::tempdir().unwrap(); fs::write(tmp.path().join("AGENTS.md"), "# Instructions").unwrap(); let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames()); // On case-insensitive filesystems (macOS), both "Agents.md" and "AGENTS.md" // resolve to the same file, so we may get more than 1 result. assert!(!files.is_empty()); assert!( files .iter() .any(|f| f.to_string_lossy().contains("AGENTS.md") || f.to_string_lossy().contains("Agents.md")) ); } #[test] fn find_agent_files_finds_all_variants() { let tmp = tempfile::tempdir().unwrap(); let filenames = CompatConfig::default().agent_filenames(); for name in &filenames { let path = tmp.path().join(name); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(&path, format!("# {name}")).unwrap(); } let files = find_agent_files(tmp.path(), &filenames); assert_eq!(files.len(), filenames.len()); } #[test] fn find_agent_files_empty_dir() { let tmp = tempfile::tempdir().unwrap(); let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames()); assert!(files.is_empty()); } #[test] fn find_agent_files_nonexistent_dir() { let files = find_agent_files( Path::new("/nonexistent/dir"), &CompatConfig::default().agent_filenames(), ); assert!(files.is_empty()); } #[test] fn find_agent_files_discovers_claude_subdir() { let tmp = tempfile::tempdir().unwrap(); let claude_dir = tmp.path().join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("CLAUDE.md"), "# Project instructions").unwrap(); let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames()); assert!( files .iter() .any(|f| f.to_string_lossy().contains(".claude/CLAUDE.md")), "Should discover .claude/CLAUDE.md, got: {files:?}" ); } #[test] fn find_rules_files_discovers_claude_rules() { let tmp = tempfile::tempdir().unwrap(); let rules_dir = tmp.path().join(".claude").join("rules"); fs::create_dir_all(&rules_dir).unwrap(); fs::write(rules_dir.join("style.md"), "# Style rules").unwrap(); fs::write(rules_dir.join("safety.md"), "# Safety rules").unwrap(); let files = find_rules_files(tmp.path(), &CompatConfig::default().rules_dirs()); assert_eq!(files.len(), 2); assert!(files[0].to_string_lossy().contains("safety.md")); assert!(files[1].to_string_lossy().contains("style.md")); } // format_agents_md_section tests #[test] fn format_agents_md_section_empty_returns_none() { assert!(format_agents_md_section(&[]).is_none()); } #[test] fn format_agents_md_section_includes_all_configs() { let configs = vec![ AgentConfigFile { file_name: "AGENTS.md".to_string(), file_path: "/repo/AGENTS.md".to_string(), content: "Repo-level instructions".to_string(), }, AgentConfigFile { file_name: "AGENTS.md".to_string(), file_path: "/repo/x/user/AGENTS.md".to_string(), content: "User-level instructions".to_string(), }, ]; let section = format_agents_md_section(&configs).unwrap(); assert!(section.contains("Repo-level instructions")); assert!(section.contains("User-level instructions")); assert!(section.contains("/repo/AGENTS.md")); assert!(section.contains("/repo/x/user/AGENTS.md")); assert!(section.contains("")); } #[test] fn format_agents_md_section_delivers_full_content() { let long_content = "A".repeat(5000); let configs = vec![AgentConfigFile { file_name: "AGENTS.md".to_string(), file_path: "/repo/AGENTS.md".to_string(), content: long_content, }]; let section = format_agents_md_section(&configs).unwrap(); // No cap: the full content is delivered verbatim, with no truncation marker. assert!( section.contains(&"A".repeat(5000)), "full content must be preserved" ); assert!( !section.contains("truncated"), "content must not be truncated" ); } // Feature 2: Workspace user AGENTS.md via read_agents_config #[tokio::test] async fn read_agents_config_includes_workspace_user_agents_md() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // Create user AGENTS.md let user_dir = repo_root.join("x").join("testuser"); fs::create_dir_all(&user_dir).unwrap(); fs::write( user_dir.join("AGENTS.md"), "# User-specific instructions\nAlways use tabs.", ) .unwrap(); // cwd = repo root (user dir is NOT in the walk path) let configs = read_agents_config_with_options( repo_root.to_str().unwrap(), Some(&user_dir), CompatConfig::default(), ) .await; let contents: Vec<&str> = configs.iter().map(|c| c.content.as_str()).collect(); assert!( contents.iter().any(|c| c.contains("Always use tabs")), "Workspace user AGENTS.md should be included, got: {contents:?}" ); } #[tokio::test] async fn read_agents_config_workspace_user_dedup_when_cwd_inside_user_dir() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // User dir with AGENTS.md let user_dir = repo_root.join("x").join("testuser"); fs::create_dir_all(&user_dir).unwrap(); fs::write(user_dir.join("AGENTS.md"), "# Dedup test instructions").unwrap(); // cwd IS the user dir — the walk already includes it let configs = read_agents_config_with_options( user_dir.to_str().unwrap(), Some(&user_dir), CompatConfig::default(), ) .await; // "Dedup test instructions" should appear exactly once let count = configs .iter() .filter(|c| c.content.contains("Dedup test instructions")) .count(); assert_eq!( count, 1, "User AGENTS.md should appear exactly once, got {count}" ); } #[tokio::test] async fn read_agents_config_no_workspace_user_dir_no_user_agents_md() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // User dir with AGENTS.md (should NOT be found) let user_dir = repo_root.join("x").join("ghost"); fs::create_dir_all(&user_dir).unwrap(); fs::write(user_dir.join("AGENTS.md"), "# Ghost instructions").unwrap(); // Pass None — simulates env vars not set let configs = read_agents_config_with_options( repo_root.to_str().unwrap(), None, CompatConfig::default(), ) .await; let has_ghost = configs .iter() .any(|c| c.content.contains("Ghost instructions")); assert!( !has_ghost, "Without optional workspace user dir, ghost AGENTS.md should not be found" ); } /// Regression: running outside a git repo must not panic. #[tokio::test] async fn regression_no_panic_outside_git_repo() { let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path().join("not_a_repo"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("AGENTS.md"), "# outside git").unwrap(); let configs = read_agents_config_with_options(dir.to_str().unwrap(), None, CompatConfig::default()) .await; assert!(configs.iter().any(|c| c.content.contains("outside git"))); } #[tokio::test] async fn read_agents_config_workspace_user_and_repo_root_both_found() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // Repo root AGENTS.md fs::write(repo_root.join("AGENTS.md"), "# XYZZY_REPO_ROOT_MARKER").unwrap(); // User AGENTS.md let user_dir = repo_root.join("x").join("testuser"); fs::create_dir_all(&user_dir).unwrap(); fs::write(user_dir.join("AGENTS.md"), "# XYZZY_USER_SPECIFIC_MARKER").unwrap(); let configs = read_agents_config_with_options( repo_root.to_str().unwrap(), Some(&user_dir), CompatConfig::default(), ) .await; // Both should be found let has_repo = configs .iter() .any(|c| c.content.contains("XYZZY_REPO_ROOT_MARKER")); let has_user = configs .iter() .any(|c| c.content.contains("XYZZY_USER_SPECIFIC_MARKER")); assert!( has_repo, "Repo root AGENTS.md not found in: {:?}", configs .iter() .map(|c| (&c.file_path, &c.content)) .collect::>() ); assert!( has_user, "User AGENTS.md not found in: {:?}", configs .iter() .map(|c| (&c.file_path, &c.content)) .collect::>() ); } #[test] fn render_strips_frontmatter_from_rules_files() { let configs = vec![AgentConfigFile { file_name: "style.md".to_string(), file_path: "/repo/.claude/rules/style.md".to_string(), content: "---\nglobs: [\"*.rs\"]\n---\n# Use snake_case".to_string(), }]; let section = format_agents_md_section(&configs).unwrap(); assert!(section.contains("# Use snake_case")); assert!(!section.contains("globs:")); } // .claude/CLAUDE.md integration tests #[tokio::test] async fn read_agents_config_discovers_claude_subdir_claude_md() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // .claude/CLAUDE.md at repo root let claude_dir = repo_root.join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_CLAUDE_SUBDIR_MARKER").unwrap(); let configs = read_agents_config_with_options( repo_root.to_str().unwrap(), None, CompatConfig::default(), ) .await; assert!( configs .iter() .any(|c| c.content.contains("XYZZY_CLAUDE_SUBDIR_MARKER")), ".claude/CLAUDE.md should be discovered, got: {:?}", configs .iter() .map(|c| (&c.file_path, &c.content)) .collect::>() ); } #[tokio::test] async fn read_agents_config_claude_subdir_and_direct_both_found() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(&repo_root).unwrap(); init_git_repo(&repo_root); // Direct CLAUDE.md fs::write(repo_root.join("CLAUDE.md"), "# XYZZY_DIRECT_MARKER").unwrap(); // .claude/CLAUDE.md let claude_dir = repo_root.join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_SUBDIR_MARKER").unwrap(); let configs = read_agents_config_with_options( repo_root.to_str().unwrap(), None, CompatConfig::default(), ) .await; let has_direct = configs .iter() .any(|c| c.content.contains("XYZZY_DIRECT_MARKER")); let has_subdir = configs .iter() .any(|c| c.content.contains("XYZZY_SUBDIR_MARKER")); assert!(has_direct, "Direct CLAUDE.md should be found"); assert!(has_subdir, ".claude/CLAUDE.md should be found"); } }