1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::config::{self, ConfigScope};
11use crate::ecosystem::Ecosystem;
12use crate::image::detect_from_image;
13use crate::process::{ProcessRule, find_process_rule_by_names};
14use crate::project::{Walk, accepts_root, has_marker, path_starts_with, project_root_candidates};
15use crate::{ProjectInput, StackInput, StackKind, StackLabel};
16
17#[derive(Debug)]
74pub struct StackDetector {
75 home: Option<PathBuf>,
76 project_cache: HashMap<PathBuf, Option<PathBuf>>,
77 config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
79}
80
81type ConfigCacheEntry = (ConfigScope, Option<StackLabel>);
82
83impl Default for StackDetector {
84 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl StackDetector {
91 #[must_use]
94 pub fn new() -> Self {
95 Self::with_home(crate::home_dir())
96 }
97
98 #[must_use]
103 pub fn with_home(home: Option<PathBuf>) -> Self {
104 Self {
105 home,
106 project_cache: HashMap::new(),
107 config_cache: HashMap::new(),
108 }
109 }
110
111 #[must_use]
113 pub fn home(&self) -> Option<&Path> {
114 self.home.as_deref()
115 }
116
117 pub fn clear(&mut self) {
122 self.project_cache.clear();
123 self.config_cache.clear();
124 }
125
126 #[must_use]
148 pub fn detect_project_root(&mut self, input: ProjectInput<'_>) -> Option<PathBuf> {
149 project_root_candidates(input).find_map(|(start, from_exe)| {
150 let root = self.cached_project_root(start)?;
151 accepts_root(&root, from_exe, self.home.as_deref()).then_some(root)
152 })
153 }
154
155 #[must_use]
162 pub fn detect_stack(&mut self, input: StackInput<'_>) -> Option<StackLabel> {
163 if let Some(image) = input.image
164 && let Some(label) = detect_from_image(image)
165 {
166 return Some(label);
167 }
168
169 let process_rule = find_process_rule_by_names(input.process_name, input.exe_name);
170
171 if let Some(project_root) = input.project_root
172 && let Some(scope) = self.config_scope(process_rule, input.exe_path, project_root)
173 && let Some(label) = self.cached_config_stack(project_root, scope)
174 {
175 return Some(label);
176 }
177
178 process_rule.map(|(_, label, _)| label.clone())
179 }
180
181 fn cached_project_root(&mut self, start: &Path) -> Option<PathBuf> {
182 let mut visited = Vec::new();
183 let mut walk = Walk::new(start, self.home.as_deref());
184 let result = walk
185 .by_ref()
186 .find_map(|dir| {
187 if let Some(cached) = self.project_cache.get(dir) {
188 return Some(cached.clone());
189 }
190 visited.push(dir);
191 has_marker(dir).then(|| Some(dir.to_path_buf()))
192 })
193 .flatten();
194
195 if result.is_none() && walk.hit_depth_cap() {
198 return None;
199 }
200
201 for path in visited {
202 self.project_cache
203 .insert(path.to_path_buf(), result.clone());
204 }
205
206 result
207 }
208
209 fn cached_config_stack(
210 &mut self,
211 project_root: &Path,
212 scope: ConfigScope,
213 ) -> Option<StackLabel> {
214 if let Some((_, cached)) = self
215 .config_cache
216 .get(project_root)
217 .and_then(|entries| entries.iter().find(|(seen, _)| *seen == scope))
218 {
219 return cached.clone();
220 }
221
222 let result = config::detect_for_scope(project_root, scope);
223 self.config_cache
224 .entry(project_root.to_path_buf())
225 .or_default()
226 .push((scope, result.clone()));
227 result
228 }
229
230 fn config_scope(
239 &self,
240 process_rule: Option<&ProcessRule>,
241 exe_path: Option<&Path>,
242 project_root: &Path,
243 ) -> Option<ConfigScope> {
244 if let Some((_, label, ecosystem)) = process_rule {
245 return accepts_config_override(label.kind())
246 .then_some(ConfigScope::Ecosystem(*ecosystem));
247 }
248
249 let exe_path = exe_path?;
250 if path_starts_with(exe_path, project_root) {
251 if is_in_node_modules(exe_path, project_root) {
252 Some(ConfigScope::NodeFirst)
253 } else {
254 Some(ConfigScope::CompiledFirst)
255 }
256 } else if is_go_build_binary(exe_path) {
257 Some(ConfigScope::Ecosystem(Ecosystem::Go))
258 } else if self.is_cargo_workspace_binary(exe_path, project_root) {
259 Some(ConfigScope::Ecosystem(Ecosystem::Rust))
260 } else {
261 None
262 }
263 }
264
265 fn is_cargo_workspace_binary(&self, exe_path: &Path, project_root: &Path) -> bool {
268 Walk::new(project_root, self.home.as_deref())
269 .skip(1)
270 .any(|workspace| {
271 path_starts_with(exe_path, &workspace.join("target"))
272 && config::declares_cargo_workspace(workspace)
273 })
274 }
275}
276
277fn is_in_node_modules(exe_path: &Path, project_root: &Path) -> bool {
281 exe_path
282 .components()
283 .skip(project_root.components().count())
284 .any(|component| component.as_os_str().eq_ignore_ascii_case("node_modules"))
285}
286
287fn is_go_build_binary(exe_path: &Path) -> bool {
291 exe_path.components().any(|component| {
292 component
293 .as_os_str()
294 .to_str()
295 .and_then(|name| name.strip_prefix("go-build"))
296 .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit()))
297 })
298}
299
300const fn accepts_config_override(kind: StackKind) -> bool {
303 matches!(kind, StackKind::Runtime | StackKind::Tool)
304}
305
306#[cfg(test)]
307mod tests {
308 use std::fs;
309
310 use tempfile::TempDir;
311
312 use super::*;
313
314 fn write_marker(dir: &Path, name: &str) {
315 fs::create_dir_all(dir).expect("create marker directory");
316 fs::write(dir.join(name), "").expect("write marker");
317 }
318
319 fn assert_cached_root(detector: &StackDetector, path: &Path, expected: &Path, message: &str) {
320 assert_eq!(
321 detector.project_cache.get(path).and_then(Option::as_deref),
322 Some(expected),
323 "{message}"
324 );
325 }
326
327 #[test]
328 fn project_root_cache_learns_visited_ancestors() {
329 let root = TempDir::new().expect("temp dir");
330 write_marker(root.path(), "Cargo.toml");
331
332 let first = root.path().join("src").join("db");
333 let second = root.path().join("src").join("utils");
334 fs::create_dir_all(&first).expect("create first dir");
335 fs::create_dir_all(&second).expect("create second dir");
336
337 let mut detector = StackDetector::new();
338
339 let first_result = detector.detect_project_root(ProjectInput::new().cwd(first.as_path()));
340 assert_eq!(first_result.as_deref(), Some(root.path()));
341 assert_cached_root(
342 &detector,
343 &first,
344 root.path(),
345 "the original cwd should be cached",
346 );
347 assert_cached_root(
348 &detector,
349 first.parent().expect("first has parent"),
350 root.path(),
351 "visited ancestors should also be cached",
352 );
353
354 let second_result = detector.detect_project_root(ProjectInput::new().cwd(second.as_path()));
355 assert_eq!(second_result.as_deref(), Some(root.path()));
356 assert_cached_root(
357 &detector,
358 &second,
359 root.path(),
360 "sibling directories should learn from the cached ancestor",
361 );
362 }
363
364 #[test]
365 fn project_root_cache_does_not_poison_unrelated_ancestors() {
366 let home = TempDir::new().expect("fake home");
370 let workspace = TempDir::new_in(home.path()).expect("temp dir");
371 let outer = workspace.path().join("workspace");
372 let project_root = outer.join("app");
373 let inside = project_root.join("src").join("db");
374 let unrelated = outer.join("services").join("worker");
375
376 fs::create_dir_all(&inside).expect("create inside dir");
377 fs::create_dir_all(&unrelated).expect("create unrelated dir");
378 write_marker(&project_root, "Cargo.toml");
379
380 let mut detector = StackDetector::with_home(Some(home.path().to_path_buf()));
381
382 let first_result = detector.detect_project_root(ProjectInput::new().cwd(inside.as_path()));
383 assert_eq!(first_result.as_deref(), Some(project_root.as_path()));
384 assert!(
385 !detector.project_cache.contains_key(outer.as_path()),
386 "ancestors above the discovered project root must not be cached as project hits"
387 );
388
389 let unrelated_result =
390 detector.detect_project_root(ProjectInput::new().cwd(unrelated.as_path()));
391 assert!(
392 unrelated_result.is_none(),
393 "an unrelated path under the same ancestor must not inherit another project's root"
394 );
395 }
396
397 #[test]
398 fn clear_drops_cached_results_but_keeps_home() {
399 let project = TempDir::new().expect("temp dir");
400 write_marker(project.path(), "Cargo.toml");
401 let home = PathBuf::from("/not/a/real/home");
402
403 let mut detector = StackDetector::with_home(Some(home.clone()));
404 let root = detector.detect_project_root(ProjectInput::new().cwd(project.path()));
405 assert_eq!(root.as_deref(), Some(project.path()));
406 let stack = detector.detect_stack(StackInput::new("cargo").project_root(project.path()));
407 assert_eq!(stack.expect("rust project"), "Rust");
408 assert!(!detector.project_cache.is_empty());
409 assert!(!detector.config_cache.is_empty());
410
411 detector.clear();
412
413 assert!(detector.project_cache.is_empty());
414 assert!(detector.config_cache.is_empty());
415 assert_eq!(detector.home(), Some(home.as_path()));
416 }
417
418 #[test]
419 fn only_runtime_and_tool_kinds_accept_config_override() {
420 assert!(accepts_config_override(StackKind::Runtime));
421 assert!(accepts_config_override(StackKind::Tool));
422 assert!(!accepts_config_override(StackKind::Framework));
423 assert!(!accepts_config_override(StackKind::Database));
424 assert!(!accepts_config_override(StackKind::Service));
425 }
426}