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;
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/// 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///    path lies inside the project root.
42/// 4. The process label, if any.
43///
44/// Config detection is ecosystem-aware. A known runtime or tool accepts only
45/// config labels from its own ecosystem: a `php` or `php-fpm` process in a
46/// Laravel project that also has `vite.config.js` is `Laravel`, a `node` or
47/// `vite` process there is `Vite`, and a `python` process in a Next.js project
48/// stays `Python`. Deno config has its own ecosystem, so a `node` or `bun`
49/// process next to `deno.json` keeps its label, while a `deno` process also
50/// accepts Node config. A Python process takes only framework labels from
51/// config, so `gunicorn` in a Python project with no recognized framework
52/// stays `Gunicorn`. An unknown process uses every rule, in the order of
53/// [`crate::detect_from_config`].
54///
55/// # Examples
56///
57/// ```
58/// use what_stack::{StackDetector, StackInput};
59///
60/// let mut detector = StackDetector::new();
61/// let label = detector.detect_stack(StackInput::new("postgres").image("postgres:16"));
62///
63/// assert_eq!(label.expect("known image"), "PostgreSQL");
64/// ```
65#[derive(Debug)]
66pub struct StackDetector {
67    home: Option<PathBuf>,
68    project_cache: HashMap<PathBuf, Option<PathBuf>>,
69    /// Config results per project root, one entry per process ecosystem seen
70    /// (`None` for unknown processes).
71    config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
72}
73
74type ConfigCacheEntry = (Option<Ecosystem>, Option<StackLabel>);
75
76impl Default for StackDetector {
77    /// Same as [`StackDetector::new`].
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl StackDetector {
84    /// Create a detector whose home ceiling is the current user's home
85    /// directory, as returned by [`crate::home_dir`].
86    #[must_use]
87    pub fn new() -> Self {
88        Self::with_home(crate::home_dir())
89    }
90
91    /// Create a detector with an explicit home ceiling.
92    ///
93    /// `None` disables the ceiling, so upward walks may reach the file system
94    /// root (bounded by [`crate::MAX_WALK_DEPTH`]).
95    #[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    /// Return the configured home ceiling.
105    #[must_use]
106    pub fn home(&self) -> Option<&Path> {
107        self.home.as_deref()
108    }
109
110    /// Drop all cached project-root and config results.
111    ///
112    /// The home ceiling is kept. Call this between scans when one detector is
113    /// reused and filesystem changes should be observed.
114    pub fn clear(&mut self) {
115        self.project_cache.clear();
116        self.config_cache.clear();
117    }
118
119    /// Detect a project root from process-like path inputs.
120    ///
121    /// Uses the same fallback order as [`crate::resolve_project_root`] with the
122    /// detector's home ceiling. Results are cached by visited directory.
123    /// Positive hits cache the visited directories from the start up to the
124    /// discovered root; negative walks cache the visited directories as misses.
125    /// This mirrors the process-enrichment hot path where many entries share a
126    /// working directory or project ancestor.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use std::path::Path;
132    /// use what_stack::{ProjectInput, StackDetector};
133    ///
134    /// let mut detector = StackDetector::new();
135    /// let root = detector.detect_project_root(ProjectInput::new().cwd(Path::new(".")));
136    /// println!("{root:?}");
137    /// ```
138    #[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    /// Detect a stack label from image, process, and project metadata.
144    ///
145    /// Image and process matching are pure string operations. Config matching
146    /// reads the given project-root directory on the first lookup and caches
147    /// the result for future calls with the same path. See the type-level
148    /// documentation for the priority and config guard.
149    #[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
222/// Whether a project config label may replace (or supply) the process label.
223fn 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
234/// Runtime and tool labels are generic hosts for project code; every other
235/// kind, including kinds added later, is final.
236const 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}