use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::{self, ConfigScope};
use crate::ecosystem::Ecosystem;
use crate::image::detect_from_image;
use crate::process::{ProcessRule, find_process_rule_by_names};
use crate::project::{Walk, accepts_root, has_marker, path_starts_with, project_root_candidates};
use crate::{ProjectInput, StackInput, StackKind, StackLabel};
#[derive(Debug)]
pub struct StackDetector {
home: Option<PathBuf>,
project_cache: HashMap<PathBuf, Option<PathBuf>>,
config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
}
type ConfigCacheEntry = (ConfigScope, Option<StackLabel>);
impl Default for StackDetector {
fn default() -> Self {
Self::new()
}
}
impl StackDetector {
#[must_use]
pub fn new() -> Self {
Self::with_home(crate::home_dir())
}
#[must_use]
pub fn with_home(home: Option<PathBuf>) -> Self {
Self {
home,
project_cache: HashMap::new(),
config_cache: HashMap::new(),
}
}
#[must_use]
pub fn home(&self) -> Option<&Path> {
self.home.as_deref()
}
pub fn clear(&mut self) {
self.project_cache.clear();
self.config_cache.clear();
}
#[must_use]
pub fn detect_project_root(&mut self, input: ProjectInput<'_>) -> Option<PathBuf> {
project_root_candidates(input).find_map(|(start, from_exe)| {
let root = self.cached_project_root(start)?;
accepts_root(&root, from_exe, self.home.as_deref()).then_some(root)
})
}
#[must_use]
pub fn detect_stack(&mut self, input: StackInput<'_>) -> Option<StackLabel> {
if let Some(image) = input.image
&& let Some(label) = detect_from_image(image)
{
return Some(label);
}
let process_rule = find_process_rule_by_names(input.process_name, input.exe_name);
if let Some(project_root) = input.project_root
&& let Some(scope) = self.config_scope(process_rule, input.exe_path, project_root)
&& let Some(label) = self.cached_config_stack(project_root, scope)
{
return Some(label);
}
process_rule.map(|(_, label, _)| label.clone())
}
fn cached_project_root(&mut self, start: &Path) -> Option<PathBuf> {
let mut visited = Vec::new();
let mut walk = Walk::new(start, self.home.as_deref());
let result = walk
.by_ref()
.find_map(|dir| {
if let Some(cached) = self.project_cache.get(dir) {
return Some(cached.clone());
}
visited.push(dir);
has_marker(dir).then(|| Some(dir.to_path_buf()))
})
.flatten();
if result.is_none() && walk.hit_depth_cap() {
return None;
}
for path in visited {
self.project_cache
.insert(path.to_path_buf(), result.clone());
}
result
}
fn cached_config_stack(
&mut self,
project_root: &Path,
scope: ConfigScope,
) -> Option<StackLabel> {
if let Some((_, cached)) = self
.config_cache
.get(project_root)
.and_then(|entries| entries.iter().find(|(seen, _)| *seen == scope))
{
return cached.clone();
}
let result = config::detect_for_scope(project_root, scope);
self.config_cache
.entry(project_root.to_path_buf())
.or_default()
.push((scope, result.clone()));
result
}
fn config_scope(
&self,
process_rule: Option<&ProcessRule>,
exe_path: Option<&Path>,
project_root: &Path,
) -> Option<ConfigScope> {
if let Some((_, label, ecosystem)) = process_rule {
return accepts_config_override(label.kind())
.then_some(ConfigScope::Ecosystem(*ecosystem));
}
let exe_path = exe_path?;
if path_starts_with(exe_path, project_root) {
if is_in_node_modules(exe_path, project_root) {
Some(ConfigScope::NodeFirst)
} else {
Some(ConfigScope::CompiledFirst)
}
} else if is_go_build_binary(exe_path) {
Some(ConfigScope::Ecosystem(Ecosystem::Go))
} else if self.is_cargo_workspace_binary(exe_path, project_root) {
Some(ConfigScope::Ecosystem(Ecosystem::Rust))
} else {
None
}
}
fn is_cargo_workspace_binary(&self, exe_path: &Path, project_root: &Path) -> bool {
Walk::new(project_root, self.home.as_deref())
.skip(1)
.any(|workspace| {
path_starts_with(exe_path, &workspace.join("target"))
&& config::declares_cargo_workspace(workspace)
})
}
}
fn is_in_node_modules(exe_path: &Path, project_root: &Path) -> bool {
exe_path
.components()
.skip(project_root.components().count())
.any(|component| component.as_os_str().eq_ignore_ascii_case("node_modules"))
}
fn is_go_build_binary(exe_path: &Path) -> bool {
exe_path.components().any(|component| {
component
.as_os_str()
.to_str()
.and_then(|name| name.strip_prefix("go-build"))
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit()))
})
}
const fn accepts_config_override(kind: StackKind) -> bool {
matches!(kind, StackKind::Runtime | StackKind::Tool)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn write_marker(dir: &Path, name: &str) {
fs::create_dir_all(dir).expect("create marker directory");
fs::write(dir.join(name), "").expect("write marker");
}
fn assert_cached_root(detector: &StackDetector, path: &Path, expected: &Path, message: &str) {
assert_eq!(
detector.project_cache.get(path).and_then(Option::as_deref),
Some(expected),
"{message}"
);
}
#[test]
fn project_root_cache_learns_visited_ancestors() {
let root = TempDir::new().expect("temp dir");
write_marker(root.path(), "Cargo.toml");
let first = root.path().join("src").join("db");
let second = root.path().join("src").join("utils");
fs::create_dir_all(&first).expect("create first dir");
fs::create_dir_all(&second).expect("create second dir");
let mut detector = StackDetector::new();
let first_result = detector.detect_project_root(ProjectInput::new().cwd(first.as_path()));
assert_eq!(first_result.as_deref(), Some(root.path()));
assert_cached_root(
&detector,
&first,
root.path(),
"the original cwd should be cached",
);
assert_cached_root(
&detector,
first.parent().expect("first has parent"),
root.path(),
"visited ancestors should also be cached",
);
let second_result = detector.detect_project_root(ProjectInput::new().cwd(second.as_path()));
assert_eq!(second_result.as_deref(), Some(root.path()));
assert_cached_root(
&detector,
&second,
root.path(),
"sibling directories should learn from the cached ancestor",
);
}
#[test]
fn project_root_cache_does_not_poison_unrelated_ancestors() {
let home = TempDir::new().expect("fake home");
let workspace = TempDir::new_in(home.path()).expect("temp dir");
let outer = workspace.path().join("workspace");
let project_root = outer.join("app");
let inside = project_root.join("src").join("db");
let unrelated = outer.join("services").join("worker");
fs::create_dir_all(&inside).expect("create inside dir");
fs::create_dir_all(&unrelated).expect("create unrelated dir");
write_marker(&project_root, "Cargo.toml");
let mut detector = StackDetector::with_home(Some(home.path().to_path_buf()));
let first_result = detector.detect_project_root(ProjectInput::new().cwd(inside.as_path()));
assert_eq!(first_result.as_deref(), Some(project_root.as_path()));
assert!(
!detector.project_cache.contains_key(outer.as_path()),
"ancestors above the discovered project root must not be cached as project hits"
);
let unrelated_result =
detector.detect_project_root(ProjectInput::new().cwd(unrelated.as_path()));
assert!(
unrelated_result.is_none(),
"an unrelated path under the same ancestor must not inherit another project's root"
);
}
#[test]
fn clear_drops_cached_results_but_keeps_home() {
let project = TempDir::new().expect("temp dir");
write_marker(project.path(), "Cargo.toml");
let home = PathBuf::from("/not/a/real/home");
let mut detector = StackDetector::with_home(Some(home.clone()));
let root = detector.detect_project_root(ProjectInput::new().cwd(project.path()));
assert_eq!(root.as_deref(), Some(project.path()));
let stack = detector.detect_stack(StackInput::new("cargo").project_root(project.path()));
assert_eq!(stack.expect("rust project"), "Rust");
assert!(!detector.project_cache.is_empty());
assert!(!detector.config_cache.is_empty());
detector.clear();
assert!(detector.project_cache.is_empty());
assert!(detector.config_cache.is_empty());
assert_eq!(detector.home(), Some(home.as_path()));
}
#[test]
fn only_runtime_and_tool_kinds_accept_config_override() {
assert!(accepts_config_override(StackKind::Runtime));
assert!(accepts_config_override(StackKind::Tool));
assert!(!accepts_config_override(StackKind::Framework));
assert!(!accepts_config_override(StackKind::Database));
assert!(!accepts_config_override(StackKind::Service));
}
}