relay_knowledge/paths/
repository_root.rs1use std::{
8 error::Error,
9 fmt, fs, io,
10 path::{Path, PathBuf},
11};
12
13use crate::project::AGENT_CONTRACT_DIR_NAME;
14
15#[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
62pub 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}