Skip to main content

what_stack/
detector.rs

1//! Cached high-level stack and project detection.
2//!
3//! [`StackDetector`] is the API to use when enriching many process entries from
4//! the same scan. It caches project-root walks and config-file results so
5//! repeated processes in the same project do not repeat filesystem work.
6
7use 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/// Cache-owning detector for repeated stack and project lookups.
18///
19/// A detector is intentionally stateful. Cache entries are retained until the
20/// detector is dropped or [`clear`](Self::clear) is called, so it is best used
21/// for one coherent scan of process or project metadata. Call `clear` between
22/// scans when filesystem changes should be observed.
23///
24/// # Home Ceiling
25///
26/// Upward project walks stop before testing the detector's home directory, so
27/// stray marker files directly in a user's home do not claim unrelated
28/// processes. [`new`](Self::new) and [`Default`] use [`crate::home_dir`];
29/// [`with_home`](Self::with_home) overrides it.
30///
31/// # Stack Priority
32///
33/// [`detect_stack`](Self::detect_stack) resolves a label in this order:
34///
35/// 1. Image name via [`crate::detect_from_image`].
36/// 2. Process or executable name via [`crate::detect_from_process_names`],
37///    when that label is final: [`StackKind::Framework`],
38///    [`StackKind::Database`], [`StackKind::Service`], or any future kind.
39/// 3. Project config, when the process label is a [`StackKind::Runtime`] or
40///    [`StackKind::Tool`], or when the process is unknown but its executable
41///    belongs to the project: it lies inside the project root, it was built
42///    by `go run` or `go test` into a temporary `go-build*` directory (Go
43///    config only), or it lies in the `target` directory of a Cargo
44///    workspace that contains the root (Rust config only).
45/// 4. The process label, if any.
46///
47/// Config detection is ecosystem-aware. A known runtime or tool accepts only
48/// config labels from its own ecosystem: a `php` or `php-fpm` process in a
49/// Laravel project that also has `vite.config.js` is `Laravel`, a `node` or
50/// `vite` process there is `Vite`, and a `python` process in a Next.js project
51/// stays `Python`. Deno config has its own ecosystem, so a `node` or `bun`
52/// process next to `deno.json` keeps its label, while a `deno` process also
53/// accepts Node config. A Python process takes only framework labels from
54/// config, so `gunicorn` in a Python project with no recognized framework
55/// stays `Gunicorn`. An unknown process uses every rule, in the order of
56/// [`crate::detect_from_config`], except that when its executable lies inside
57/// the project root, Rust, Go, .NET, and JVM config are tried first: a binary
58/// at `tmp/main` in a repo with `go.mod`, `package.json`, and `vite.config.js`
59/// is `Go`, not `Vite`. An executable under the project's `node_modules`
60/// (`node_modules/@esbuild/linux-x64/bin/esbuild`) is a Node build tool, so
61/// Node config is tried first instead and the same repo gives `Vite`.
62///
63/// # Examples
64///
65/// ```
66/// use what_stack::{StackDetector, StackInput};
67///
68/// let mut detector = StackDetector::new();
69/// let label = detector.detect_stack(StackInput::new("postgres").image("postgres:16"));
70///
71/// assert_eq!(label.expect("known image"), "PostgreSQL");
72/// ```
73#[derive(Debug)]
74pub struct StackDetector {
75    home: Option<PathBuf>,
76    project_cache: HashMap<PathBuf, Option<PathBuf>>,
77    /// Config results per project root, one entry per config scope seen.
78    config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
79}
80
81type ConfigCacheEntry = (ConfigScope, Option<StackLabel>);
82
83impl Default for StackDetector {
84    /// Same as [`StackDetector::new`].
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl StackDetector {
91    /// Create a detector whose home ceiling is the current user's home
92    /// directory, as returned by [`crate::home_dir`].
93    #[must_use]
94    pub fn new() -> Self {
95        Self::with_home(crate::home_dir())
96    }
97
98    /// Create a detector with an explicit home ceiling.
99    ///
100    /// `None` disables the ceiling, so upward walks may reach the file system
101    /// root (bounded by [`crate::MAX_WALK_DEPTH`]).
102    #[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    /// Return the configured home ceiling.
112    #[must_use]
113    pub fn home(&self) -> Option<&Path> {
114        self.home.as_deref()
115    }
116
117    /// Drop all cached project-root and config results.
118    ///
119    /// The home ceiling is kept. Call this between scans when one detector is
120    /// reused and filesystem changes should be observed.
121    pub fn clear(&mut self) {
122        self.project_cache.clear();
123        self.config_cache.clear();
124    }
125
126    /// Detect a project root from process-like path inputs.
127    ///
128    /// Uses the same fallback order as [`crate::resolve_project_root`] with the
129    /// detector's home ceiling. Results are cached by visited directory.
130    /// Positive hits cache the visited directories from the start up to the
131    /// discovered root; negative walks cache the visited directories as misses,
132    /// except when the walk stopped at [`crate::MAX_WALK_DEPTH`], because a
133    /// walk from a shallower visited directory can reach further up.
134    /// This mirrors the process-enrichment hot path where many entries share a
135    /// working directory or project ancestor.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use std::path::Path;
141    /// use what_stack::{ProjectInput, StackDetector};
142    ///
143    /// let mut detector = StackDetector::new();
144    /// let root = detector.detect_project_root(ProjectInput::new().cwd(Path::new(".")));
145    /// println!("{root:?}");
146    /// ```
147    #[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    /// Detect a stack label from image, process, and project metadata.
156    ///
157    /// Image and process matching are pure string operations. Config matching
158    /// reads the given project-root directory on the first lookup and caches
159    /// the result for future calls with the same path. See the type-level
160    /// documentation for the priority and config guard.
161    #[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        // A miss caused by the depth cap is only a miss for the deepest start:
196        // a walk from a shallower visited directory can reach further up.
197        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    /// Which config rules may replace (or supply) the process label, or
231    /// `None` when config must not be used.
232    ///
233    /// A known process uses its own ecosystem when its label is a runtime or
234    /// tool. An unknown process uses config only when its executable belongs
235    /// to the project: it lies inside the project root, it was built by
236    /// `go run` or `go test` into a `go-build*` temporary directory, or it lies
237    /// in the `target` directory of a Cargo workspace that contains the root.
238    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    /// Whether `exe_path` lies in `<workspace>/target` for a Cargo workspace
266    /// root above `project_root`, as a workspace member's binary does.
267    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
277/// Whether `exe_path`, which lies inside `project_root`, is under a
278/// `node_modules` directory of the project, as the native binaries of esbuild,
279/// turbo, Biome, and SWC are.
280fn 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
287/// Whether `exe_path` was built by `go run` or `go test`, which place the
288/// binary under a temporary `go-build<digits>` directory. At least one digit
289/// is required, so a directory named plain `go-build` does not count.
290fn 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
300/// Runtime and tool labels are generic hosts for project code; every other
301/// kind, including kinds added later, is final.
302const 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        // The fixture lives in a fake home that is also the walk ceiling, so a
367        // marker file above the system temp directory (on Windows `%TEMP%` is
368        // under the user profile) cannot turn the expected miss into a hit.
369        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}