Skip to main content

relay_knowledge/paths/
repository_root.rs

1//! Repository-root discovery for repository-local contracts.
2//!
3//! This module owns path-based discovery for repository-scoped files such as
4//! `.knowledge/knowledge-map.yaml`. Callers provide the starting directory;
5//! process cwd lookup belongs to bootstrap.
6
7use std::{
8    error::Error,
9    fmt, fs, io,
10    path::{Path, PathBuf},
11};
12
13use crate::project::AGENT_CONTRACT_DIR_NAME;
14
15/// Error raised before repository-root discovery can walk ancestors.
16#[derive(Debug)]
17pub enum RepositoryRootDiscoveryError {
18    StartUnavailable { path: PathBuf, source: io::Error },
19    StartNotDirectory { path: PathBuf },
20    MarkerProbeFailed { path: PathBuf, source: io::Error },
21}
22
23impl fmt::Display for RepositoryRootDiscoveryError {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::StartUnavailable { path, source } => {
27                write!(
28                    formatter,
29                    "failed to inspect start directory '{}': {source}",
30                    path.display()
31                )
32            }
33            Self::StartNotDirectory { path } => {
34                write!(
35                    formatter,
36                    "repository root search must start from a directory, got '{}'",
37                    path.display()
38                )
39            }
40            Self::MarkerProbeFailed { path, source } => {
41                write!(
42                    formatter,
43                    "failed to inspect repository marker '{}': {source}",
44                    path.display()
45                )
46            }
47        }
48    }
49}
50
51impl Error for RepositoryRootDiscoveryError {
52    fn source(&self) -> Option<&(dyn Error + 'static)> {
53        match self {
54            Self::StartUnavailable { source, .. } | Self::MarkerProbeFailed { source, .. } => {
55                Some(source)
56            }
57            Self::StartNotDirectory { .. } => None,
58        }
59    }
60}
61
62/// Finds the repository root that owns repository-local knowledge contracts.
63///
64/// Discovery starts at `start` and walks ancestors. A `.git` directory/file or
65/// `.knowledge` directory wins immediately. If neither exists, the nearest
66/// `AGENTS.md` ancestor is used as a compatibility fallback.
67pub fn discover_repository_root(
68    start: &Path,
69) -> Result<Option<PathBuf>, RepositoryRootDiscoveryError> {
70    let metadata =
71        fs::metadata(start).map_err(|source| RepositoryRootDiscoveryError::StartUnavailable {
72            path: start.to_path_buf(),
73            source,
74        })?;
75    if !metadata.is_dir() {
76        return Err(RepositoryRootDiscoveryError::StartNotDirectory {
77            path: start.to_path_buf(),
78        });
79    }
80
81    let mut agents_root = None;
82    for path in start.ancestors() {
83        if marker_exists(path.join(".git"))? || marker_exists(path.join(AGENT_CONTRACT_DIR_NAME))? {
84            return Ok(Some(path.to_path_buf()));
85        }
86        if agents_root.is_none() && marker_exists(path.join("AGENTS.md"))? {
87            agents_root = Some(path.to_path_buf());
88        }
89    }
90
91    Ok(agents_root)
92}
93
94fn marker_exists(path: PathBuf) -> Result<bool, RepositoryRootDiscoveryError> {
95    path.try_exists()
96        .map_err(|source| RepositoryRootDiscoveryError::MarkerProbeFailed { path, source })
97}
98
99#[cfg(test)]
100mod tests {
101    use std::time::{SystemTime, UNIX_EPOCH};
102
103    use super::*;
104
105    #[test]
106    fn root_search_walks_up_to_git_marker() {
107        let root = temp_root("git-marker");
108        let nested = root.join("src").join("module");
109        fs::create_dir_all(root.join(".git")).expect("git marker should create");
110        fs::create_dir_all(&nested).expect("nested dir should create");
111
112        let discovered = discover_repository_root(&nested)
113            .expect("search should succeed")
114            .expect("root should be found");
115
116        assert_eq!(discovered, root);
117        let _ = fs::remove_dir_all(discovered);
118    }
119
120    #[test]
121    fn root_search_walks_up_to_knowledge_contract_directory() {
122        let root = temp_root("knowledge-marker");
123        let nested = root.join("docs").join("architecture");
124        fs::create_dir_all(root.join(AGENT_CONTRACT_DIR_NAME))
125            .expect("knowledge marker should create");
126        fs::create_dir_all(&nested).expect("nested dir should create");
127
128        let discovered = discover_repository_root(&nested)
129            .expect("search should succeed")
130            .expect("root should be found");
131
132        assert_eq!(discovered, root);
133        let _ = fs::remove_dir_all(discovered);
134    }
135
136    #[test]
137    fn root_search_falls_back_to_nearest_agents_file() {
138        let root = temp_root("agents-marker");
139        let nested = root.join("src").join("module");
140        fs::create_dir_all(&nested).expect("nested dir should create");
141        fs::write(
142            root.join("AGENTS.md"),
143            "Knowledge map: .knowledge/knowledge-map.yaml",
144        )
145        .expect("agents should write");
146
147        let discovered = discover_repository_root(&nested)
148            .expect("search should succeed")
149            .expect("root should be found");
150
151        assert_eq!(discovered, root);
152        let _ = fs::remove_dir_all(discovered);
153    }
154
155    #[test]
156    fn nested_agents_file_fallback_keeps_nearest_scope() {
157        let root = temp_root("nested-agents-marker");
158        let scoped = root.join("src");
159        let nested = scoped.join("module");
160        fs::create_dir_all(&nested).expect("nested dir should create");
161        fs::write(root.join("AGENTS.md"), "Workspace instructions.").expect("root agents write");
162        fs::write(scoped.join("AGENTS.md"), "Scoped instructions.").expect("scoped agents write");
163
164        let discovered = discover_repository_root(&nested)
165            .expect("search should succeed")
166            .expect("root should be found");
167
168        assert_eq!(discovered, scoped);
169        let _ = fs::remove_dir_all(root);
170    }
171
172    #[test]
173    fn scoped_agents_file_does_not_override_git_root() {
174        let root = temp_root("scoped-agents");
175        let nested = root.join("src").join("module");
176        fs::create_dir_all(root.join(".git")).expect("git marker should create");
177        fs::create_dir_all(&nested).expect("nested dir should create");
178        fs::write(nested.join("AGENTS.md"), "Scoped instructions.")
179            .expect("scoped agents should write");
180
181        let discovered = discover_repository_root(&nested)
182            .expect("search should succeed")
183            .expect("root should be found");
184
185        assert_eq!(discovered, root);
186        let _ = fs::remove_dir_all(discovered);
187    }
188
189    #[test]
190    fn missing_markers_return_none() {
191        let root = temp_root("missing-marker");
192        let nested = root.join("src");
193        fs::create_dir_all(&nested).expect("nested dir should create");
194
195        let discovered = discover_repository_root(&nested).expect("search should succeed");
196
197        assert_eq!(discovered, None);
198        let _ = fs::remove_dir_all(root);
199    }
200
201    #[test]
202    fn missing_start_directory_returns_error() {
203        let root = temp_root("missing-start");
204        let missing = root.join("missing");
205        fs::create_dir_all(&root).expect("root should create");
206
207        let error = discover_repository_root(&missing).expect_err("missing start should fail");
208
209        assert!(matches!(
210            error,
211            RepositoryRootDiscoveryError::StartUnavailable { .. }
212        ));
213        let _ = fs::remove_dir_all(root);
214    }
215
216    fn temp_root(label: &str) -> PathBuf {
217        std::env::temp_dir().join(format!(
218            "relay-knowledge-root-{label}-{}",
219            SystemTime::now()
220                .duration_since(UNIX_EPOCH)
221                .expect("time should work")
222                .as_nanos()
223        ))
224    }
225}