what-stack 0.1.1

Detect project roots and technology stacks from container image names, project config files, and process names
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Cached high-level stack and project detection.
//!
//! [`StackDetector`] is the API to use when enriching many process entries from
//! the same scan. It caches project-root walks and config-file results so
//! repeated processes in the same project do not repeat filesystem work.

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};

/// Cache-owning detector for repeated stack and project lookups.
///
/// A detector is intentionally stateful. Cache entries are retained until the
/// detector is dropped or [`clear`](Self::clear) is called, so it is best used
/// for one coherent scan of process or project metadata. Call `clear` between
/// scans when filesystem changes should be observed.
///
/// # Home Ceiling
///
/// Upward project walks stop before testing the detector's home directory, so
/// stray marker files directly in a user's home do not claim unrelated
/// processes. [`new`](Self::new) and [`Default`] use [`crate::home_dir`];
/// [`with_home`](Self::with_home) overrides it.
///
/// # Stack Priority
///
/// [`detect_stack`](Self::detect_stack) resolves a label in this order:
///
/// 1. Image name via [`crate::detect_from_image`].
/// 2. Process or executable name via [`crate::detect_from_process_names`],
///    when that label is final: [`StackKind::Framework`],
///    [`StackKind::Database`], [`StackKind::Service`], or any future kind.
/// 3. Project config, when the process label is a [`StackKind::Runtime`] or
///    [`StackKind::Tool`], or when the process is unknown but its executable
///    belongs to the project: it lies inside the project root, it was built
///    by `go run` or `go test` into a temporary `go-build*` directory (Go
///    config only), or it lies in the `target` directory of a Cargo
///    workspace that contains the root (Rust config only).
/// 4. The process label, if any.
///
/// Config detection is ecosystem-aware. A known runtime or tool accepts only
/// config labels from its own ecosystem: a `php` or `php-fpm` process in a
/// Laravel project that also has `vite.config.js` is `Laravel`, a `node` or
/// `vite` process there is `Vite`, and a `python` process in a Next.js project
/// stays `Python`. Deno config has its own ecosystem, so a `node` or `bun`
/// process next to `deno.json` keeps its label, while a `deno` process also
/// accepts Node config. A Python process takes only framework labels from
/// config, so `gunicorn` in a Python project with no recognized framework
/// stays `Gunicorn`. An unknown process uses every rule, in the order of
/// [`crate::detect_from_config`], except that when its executable lies inside
/// the project root, Rust, Go, .NET, and JVM config are tried first: a binary
/// at `tmp/main` in a repo with `go.mod`, `package.json`, and `vite.config.js`
/// is `Go`, not `Vite`. An executable under the project's `node_modules`
/// (`node_modules/@esbuild/linux-x64/bin/esbuild`) is a Node build tool, so
/// Node config is tried first instead and the same repo gives `Vite`.
///
/// # Examples
///
/// ```
/// use what_stack::{StackDetector, StackInput};
///
/// let mut detector = StackDetector::new();
/// let label = detector.detect_stack(StackInput::new("postgres").image("postgres:16"));
///
/// assert_eq!(label.expect("known image"), "PostgreSQL");
/// ```
#[derive(Debug)]
pub struct StackDetector {
    home: Option<PathBuf>,
    project_cache: HashMap<PathBuf, Option<PathBuf>>,
    /// Config results per project root, one entry per config scope seen.
    config_cache: HashMap<PathBuf, Vec<ConfigCacheEntry>>,
}

type ConfigCacheEntry = (ConfigScope, Option<StackLabel>);

impl Default for StackDetector {
    /// Same as [`StackDetector::new`].
    fn default() -> Self {
        Self::new()
    }
}

impl StackDetector {
    /// Create a detector whose home ceiling is the current user's home
    /// directory, as returned by [`crate::home_dir`].
    #[must_use]
    pub fn new() -> Self {
        Self::with_home(crate::home_dir())
    }

    /// Create a detector with an explicit home ceiling.
    ///
    /// `None` disables the ceiling, so upward walks may reach the file system
    /// root (bounded by [`crate::MAX_WALK_DEPTH`]).
    #[must_use]
    pub fn with_home(home: Option<PathBuf>) -> Self {
        Self {
            home,
            project_cache: HashMap::new(),
            config_cache: HashMap::new(),
        }
    }

    /// Return the configured home ceiling.
    #[must_use]
    pub fn home(&self) -> Option<&Path> {
        self.home.as_deref()
    }

    /// Drop all cached project-root and config results.
    ///
    /// The home ceiling is kept. Call this between scans when one detector is
    /// reused and filesystem changes should be observed.
    pub fn clear(&mut self) {
        self.project_cache.clear();
        self.config_cache.clear();
    }

    /// Detect a project root from process-like path inputs.
    ///
    /// Uses the same fallback order as [`crate::resolve_project_root`] with the
    /// detector's home ceiling. Results are cached by visited directory.
    /// Positive hits cache the visited directories from the start up to the
    /// discovered root; negative walks cache the visited directories as misses,
    /// except when the walk stopped at [`crate::MAX_WALK_DEPTH`], because a
    /// walk from a shallower visited directory can reach further up.
    /// This mirrors the process-enrichment hot path where many entries share a
    /// working directory or project ancestor.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::Path;
    /// use what_stack::{ProjectInput, StackDetector};
    ///
    /// let mut detector = StackDetector::new();
    /// let root = detector.detect_project_root(ProjectInput::new().cwd(Path::new(".")));
    /// println!("{root:?}");
    /// ```
    #[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)
        })
    }

    /// Detect a stack label from image, process, and project metadata.
    ///
    /// Image and process matching are pure string operations. Config matching
    /// reads the given project-root directory on the first lookup and caches
    /// the result for future calls with the same path. See the type-level
    /// documentation for the priority and config guard.
    #[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();

        // A miss caused by the depth cap is only a miss for the deepest start:
        // a walk from a shallower visited directory can reach further up.
        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
    }

    /// Which config rules may replace (or supply) the process label, or
    /// `None` when config must not be used.
    ///
    /// A known process uses its own ecosystem when its label is a runtime or
    /// tool. An unknown process uses config only when its executable belongs
    /// to the project: it lies inside the project root, it was built by
    /// `go run` or `go test` into a `go-build*` temporary directory, or it lies
    /// in the `target` directory of a Cargo workspace that contains the root.
    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
        }
    }

    /// Whether `exe_path` lies in `<workspace>/target` for a Cargo workspace
    /// root above `project_root`, as a workspace member's binary does.
    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)
            })
    }
}

/// Whether `exe_path`, which lies inside `project_root`, is under a
/// `node_modules` directory of the project, as the native binaries of esbuild,
/// turbo, Biome, and SWC are.
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"))
}

/// Whether `exe_path` was built by `go run` or `go test`, which place the
/// binary under a temporary `go-build<digits>` directory. At least one digit
/// is required, so a directory named plain `go-build` does not count.
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()))
    })
}

/// Runtime and tool labels are generic hosts for project code; every other
/// kind, including kinds added later, is final.
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() {
        // The fixture lives in a fake home that is also the walk ceiling, so a
        // marker file above the system temp directory (on Windows `%TEMP%` is
        // under the user profile) cannot turn the expected miss into a hit.
        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));
    }
}