//! Project config-file discovery: locating repo-local `.mcp.json` and //! `.kigi/config.toml` files by walking from `cwd` up to the git root. //! //! These pure `git2` + filesystem walks are shared by the shell's config //! loaders and the folder-trust gate's `repo_configs_present`. use std::path::{Path, PathBuf}; use kigi_agent::repo::RepoDirChain; /// Filename of the project-local MCP server config. pub const MCP_JSON_FILENAME: &str = ".mcp.json"; /// Candidate `.mcp.json` paths from repo root to `cwd`, whether or not they exist. /// Useful for file watching so newly created files are detected after startup. pub fn mcp_json_candidate_paths(cwd: &Path) -> Vec { mcp_json_candidate_paths_in(&RepoDirChain::resolve(cwd).dirs) } /// [`mcp_json_candidate_paths`] over a precomputed cwd→git-root dir chain /// ([`RepoDirChain`]), repo-root-first. Private: only this file's two callers. fn mcp_json_candidate_paths_in(chain_dirs: &[PathBuf]) -> Vec { chain_dirs .iter() .rev() .map(|dir| dir.join(MCP_JSON_FILENAME)) .collect() } /// Find existing `.mcp.json` files from `cwd` up to the git root (repo-root-first order). pub fn find_mcp_json_files(cwd: &Path) -> Vec { find_mcp_json_files_in(&RepoDirChain::resolve(cwd).dirs) } /// [`find_mcp_json_files`] over a precomputed dir chain. See [`RepoDirChain`]. /// `pub(crate)` — the gate (`repo_configs_present`) reaches it within this crate. pub(crate) fn find_mcp_json_files_in(chain_dirs: &[PathBuf]) -> Vec { mcp_json_candidate_paths_in(chain_dirs) .into_iter() .filter(|path| path.is_file()) .collect() } /// True when `config_path` is `$KIGI_SHARE_DIR/config.toml` (user tier, not project). fn is_user_kigi_config_file(config_path: &Path) -> bool { let Some(user_home) = kigi_config::user_kigi_home() else { return false; }; let user_config = user_home.join("config.toml"); if config_path == user_config.as_path() { return true; } let Ok(canonical_config) = dunce::canonicalize(config_path) else { return false; }; let canonical_user = dunce::canonicalize(&user_config).unwrap_or(user_config); canonical_config == canonical_user } /// Find all `.kigi/config.toml` files from `cwd` upward to the git repo root. /// Returns paths ordered from repo root (lowest priority) to cwd (highest priority), /// matching the convention used by skills and AGENTS.md discovery. /// /// If no git repo is found, only checks `cwd/.kigi/config.toml`. Excludes the /// user-global config so `cwd == $HOME` does not treat `~/.kigi/config.toml` as /// a project overlay. pub fn find_project_configs(cwd: &Path) -> Vec { find_project_configs_in(&RepoDirChain::resolve(cwd).dirs) } /// [`find_project_configs`] over a precomputed cwd→git-root dir chain /// ([`RepoDirChain`]), repo-root-first. Excludes the user-global config so /// `cwd == $HOME` does not treat `~/.kigi/config.toml` as a project overlay. /// `pub(crate)` — the gate (`repo_configs_present`) reaches it within this crate. pub(crate) fn find_project_configs_in(chain_dirs: &[PathBuf]) -> Vec { // `dirs` is cwd-first; reverse so repo root comes first (lowest priority) // and cwd last (highest), matching skills/AGENTS.md discovery order. chain_dirs .iter() .rev() .map(|dir| dir.join(".kigi").join("config.toml")) .filter(|config_path| config_path.is_file() && !is_user_kigi_config_file(config_path)) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn find_project_configs_excludes_user_kigi_config_file() { let Some(user_home) = kigi_config::user_kigi_home() else { return; }; let user_config = user_home.join("config.toml"); if user_config.is_file() { #[allow(deprecated)] let home = std::env::home_dir().expect("home dir"); let from_home = find_project_configs(&home); assert!( !from_home.iter().any(|p| is_user_kigi_config_file(p)), "user config leaked into project configs: {from_home:?}" ); assert!(is_user_kigi_config_file(&user_config)); } let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("repo"); std::fs::create_dir_all(project.join(".kigi")).unwrap(); std::fs::write(project.join(".kigi/config.toml"), "# project\n").unwrap(); let found = find_project_configs(&project); assert_eq!(found.len(), 1); assert!(!is_user_kigi_config_file(&found[0])); } }