1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::config;
11use crate::ecosystem::Ecosystem;
12use crate::image::detect_from_image;
13use crate::process::find_process_rule_by_names;
14use crate::project::{has_marker, path_starts_with, project_root_candidates, walk_ancestors};
15use crate::{ProjectInput, StackInput, StackKind, StackLabel};
16
17#[derive(Debug)]
66pub struct StackDetector {
67 home: Option<PathBuf>,
68 project_cache: HashMap<PathBuf, Option<PathBuf>>,
69 config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
72}
73
74type ConfigCacheEntry = (Option<Ecosystem>, Option<StackLabel>);
75
76impl Default for StackDetector {
77 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl StackDetector {
84 #[must_use]
87 pub fn new() -> Self {
88 Self::with_home(crate::home_dir())
89 }
90
91 #[must_use]
96 pub fn with_home(home: Option<PathBuf>) -> Self {
97 Self {
98 home,
99 project_cache: HashMap::new(),
100 config_cache: HashMap::new(),
101 }
102 }
103
104 #[must_use]
106 pub fn home(&self) -> Option<&Path> {
107 self.home.as_deref()
108 }
109
110 pub fn clear(&mut self) {
115 self.project_cache.clear();
116 self.config_cache.clear();
117 }
118
119 #[must_use]
139 pub fn detect_project_root(&mut self, input: ProjectInput<'_>) -> Option<PathBuf> {
140 project_root_candidates(input).find_map(|start| self.cached_project_root(start))
141 }
142
143 #[must_use]
150 pub fn detect_stack(&mut self, input: StackInput<'_>) -> Option<StackLabel> {
151 if let Some(image) = input.image
152 && let Some(label) = detect_from_image(image)
153 {
154 return Some(label);
155 }
156
157 let process_rule = find_process_rule_by_names(input.process_name, input.exe_name);
158 let process_stack = process_rule.map(|(_, label, _)| label);
159
160 if let Some(project_root) = input.project_root
161 && config_detection_allowed(process_stack, input.exe_path, project_root)
162 && let Some(label) =
163 self.cached_config_stack(project_root, process_rule.map(|(_, _, eco)| *eco))
164 {
165 return Some(label);
166 }
167
168 process_stack.cloned()
169 }
170
171 fn cached_project_root(&mut self, start: &Path) -> Option<PathBuf> {
172 let mut visited = Vec::new();
173
174 for dir in walk_ancestors(start, self.home.as_deref()) {
175 if let Some(cached) = self.project_cache.get(&dir).cloned() {
176 for path in visited {
177 self.project_cache.insert(path, cached.clone());
178 }
179 return cached;
180 }
181
182 visited.push(dir.clone());
183
184 if has_marker(&dir) {
185 let result = Some(dir);
186 for path in visited {
187 self.project_cache.insert(path, result.clone());
188 }
189 return result;
190 }
191 }
192
193 for path in visited {
194 self.project_cache.insert(path, None);
195 }
196
197 None
198 }
199
200 fn cached_config_stack(
201 &mut self,
202 project_root: &Path,
203 ecosystem: Option<Ecosystem>,
204 ) -> Option<StackLabel> {
205 if let Some((_, cached)) = self
206 .config_cache
207 .get(project_root)
208 .and_then(|entries| entries.iter().find(|(seen, _)| *seen == ecosystem))
209 {
210 return cached.clone();
211 }
212
213 let result = config::detect_for_ecosystem(project_root, ecosystem);
214 self.config_cache
215 .entry(project_root.to_path_buf())
216 .or_default()
217 .push((ecosystem, result.clone()));
218 result
219 }
220}
221
222fn config_detection_allowed(
224 process_stack: Option<&StackLabel>,
225 exe_path: Option<&Path>,
226 project_root: &Path,
227) -> bool {
228 process_stack.map_or_else(
229 || exe_path.is_some_and(|path| path_starts_with(path, project_root)),
230 |label| accepts_config_override(label.kind()),
231 )
232}
233
234const fn accepts_config_override(kind: StackKind) -> bool {
237 matches!(kind, StackKind::Runtime | StackKind::Tool)
238}
239
240#[cfg(test)]
241mod tests {
242 use std::fs;
243
244 use tempfile::TempDir;
245
246 use super::*;
247
248 fn write_marker(dir: &Path, name: &str) {
249 fs::create_dir_all(dir).expect("create marker directory");
250 fs::write(dir.join(name), "").expect("write marker");
251 }
252
253 fn assert_cached_root(detector: &StackDetector, path: &Path, expected: &Path, message: &str) {
254 assert_eq!(
255 detector.project_cache.get(path).and_then(Option::as_deref),
256 Some(expected),
257 "{message}"
258 );
259 }
260
261 #[test]
262 fn project_root_cache_learns_visited_ancestors() {
263 let root = TempDir::new().expect("temp dir");
264 write_marker(root.path(), "Cargo.toml");
265
266 let first = root.path().join("src").join("db");
267 let second = root.path().join("src").join("utils");
268 fs::create_dir_all(&first).expect("create first dir");
269 fs::create_dir_all(&second).expect("create second dir");
270
271 let mut detector = StackDetector::new();
272
273 let first_result = detector.detect_project_root(ProjectInput::new().cwd(first.as_path()));
274 assert_eq!(first_result.as_deref(), Some(root.path()));
275 assert_cached_root(
276 &detector,
277 &first,
278 root.path(),
279 "the original cwd should be cached",
280 );
281 assert_cached_root(
282 &detector,
283 first.parent().expect("first has parent"),
284 root.path(),
285 "visited ancestors should also be cached",
286 );
287
288 let second_result = detector.detect_project_root(ProjectInput::new().cwd(second.as_path()));
289 assert_eq!(second_result.as_deref(), Some(root.path()));
290 assert_cached_root(
291 &detector,
292 &second,
293 root.path(),
294 "sibling directories should learn from the cached ancestor",
295 );
296 }
297
298 #[test]
299 fn project_root_cache_does_not_poison_unrelated_ancestors() {
300 let workspace = TempDir::new().expect("temp dir");
301 let outer = workspace.path().join("workspace");
302 let project_root = outer.join("app");
303 let inside = project_root.join("src").join("db");
304 let unrelated = outer.join("services").join("worker");
305
306 fs::create_dir_all(&inside).expect("create inside dir");
307 fs::create_dir_all(&unrelated).expect("create unrelated dir");
308 write_marker(&project_root, "Cargo.toml");
309
310 let mut detector = StackDetector::new();
311
312 let first_result = detector.detect_project_root(ProjectInput::new().cwd(inside.as_path()));
313 assert_eq!(first_result.as_deref(), Some(project_root.as_path()));
314 assert!(
315 !detector.project_cache.contains_key(outer.as_path()),
316 "ancestors above the discovered project root must not be cached as project hits"
317 );
318
319 let unrelated_result =
320 detector.detect_project_root(ProjectInput::new().cwd(unrelated.as_path()));
321 assert!(
322 unrelated_result.is_none(),
323 "an unrelated path under the same ancestor must not inherit another project's root"
324 );
325 }
326
327 #[test]
328 fn clear_drops_cached_results_but_keeps_home() {
329 let project = TempDir::new().expect("temp dir");
330 write_marker(project.path(), "Cargo.toml");
331 let home = PathBuf::from("/not/a/real/home");
332
333 let mut detector = StackDetector::with_home(Some(home.clone()));
334 let root = detector.detect_project_root(ProjectInput::new().cwd(project.path()));
335 assert_eq!(root.as_deref(), Some(project.path()));
336 let stack = detector.detect_stack(StackInput::new("cargo").project_root(project.path()));
337 assert_eq!(stack.expect("rust project"), "Rust");
338 assert!(!detector.project_cache.is_empty());
339 assert!(!detector.config_cache.is_empty());
340
341 detector.clear();
342
343 assert!(detector.project_cache.is_empty());
344 assert!(detector.config_cache.is_empty());
345 assert_eq!(detector.home(), Some(home.as_path()));
346 }
347
348 #[test]
349 fn only_runtime_and_tool_kinds_accept_config_override() {
350 assert!(accepts_config_override(StackKind::Runtime));
351 assert!(accepts_config_override(StackKind::Tool));
352 assert!(!accepts_config_override(StackKind::Framework));
353 assert!(!accepts_config_override(StackKind::Database));
354 assert!(!accepts_config_override(StackKind::Service));
355 }
356}