Skip to main content

what_stack/config/
rules.rs

1use std::path::Path;
2
3use self::ConfigMatch::{AllOf, Exact, Extension, Prefix};
4use super::files::ProjectFiles;
5use super::python;
6use crate::StackLabel;
7use crate::ecosystem::Ecosystem as E;
8
9/// How a config rule recognizes its project files.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum ConfigMatch {
12    /// A root entry with exactly this name.
13    Exact(&'static str),
14    /// A root entry named like the prefix plus a common config suffix, such as
15    /// `next.config.mjs`.
16    Prefix(&'static str),
17    /// Every listed path exists. Nested paths such as `bin/rails` use `/`.
18    AllOf(&'static [&'static str]),
19    /// A root entry with this file extension.
20    Extension(&'static str),
21}
22
23/// One config rule: matcher, label, and the ecosystem of the label.
24pub type ConfigRule = (ConfigMatch, StackLabel, E);
25
26/// Rules checked before Python detection, in priority order.
27pub const CONFIG_RULES: &[ConfigRule] = &[
28    (
29        Prefix("next.config"),
30        StackLabel::framework("Next.js"),
31        E::Node,
32    ),
33    (
34        Prefix("nuxt.config"),
35        StackLabel::framework("Nuxt"),
36        E::Node,
37    ),
38    (
39        Exact("angular.json"),
40        StackLabel::framework("Angular"),
41        E::Node,
42    ),
43    (
44        Prefix("svelte.config"),
45        StackLabel::framework("SvelteKit"),
46        E::Node,
47    ),
48    (
49        Prefix("astro.config"),
50        StackLabel::framework("Astro"),
51        E::Node,
52    ),
53    (Prefix("vite.config"), StackLabel::tool("Vite"), E::Node),
54    (
55        Prefix("remix.config"),
56        StackLabel::framework("Remix"),
57        E::Node,
58    ),
59    (
60        Prefix("gatsby-config"),
61        StackLabel::framework("Gatsby"),
62        E::Node,
63    ),
64    (Prefix("vue.config"), StackLabel::tool("Vue CLI"), E::Node),
65    (
66        Prefix("webpack.config"),
67        StackLabel::tool("Webpack"),
68        E::Node,
69    ),
70    (Exact("Cargo.toml"), StackLabel::runtime("Rust"), E::Rust),
71    (Exact("go.mod"), StackLabel::runtime("Go"), E::Go),
72    (Exact("go.work"), StackLabel::runtime("Go"), E::Go),
73    (Exact("pom.xml"), StackLabel::tool("Java (Maven)"), E::Jvm),
74    (
75        Exact("build.gradle.kts"),
76        StackLabel::tool("Kotlin (Gradle)"),
77        E::Jvm,
78    ),
79    (
80        Exact("build.gradle"),
81        StackLabel::tool("Java (Gradle)"),
82        E::Jvm,
83    ),
84    (
85        AllOf(&["artisan", "composer.json"]),
86        StackLabel::framework("Laravel"),
87        E::Php,
88    ),
89    (Exact("composer.json"), StackLabel::runtime("PHP"), E::Php),
90    (Exact("mix.exs"), StackLabel::runtime("Elixir"), E::Beam),
91    (Exact("deno.json"), StackLabel::runtime("Deno"), E::Deno),
92    (Exact("deno.jsonc"), StackLabel::runtime("Deno"), E::Deno),
93];
94
95/// Rules checked after Python detection, in priority order.
96pub const LATE_CONFIG_RULES: &[ConfigRule] = &[
97    (
98        AllOf(&["Gemfile", "config.ru", "bin/rails"]),
99        StackLabel::framework("Rails"),
100        E::Ruby,
101    ),
102    (AllOf(&["Gemfile", "config.ru"]), RACK_LABEL, E::Ruby),
103    (Extension("csproj"), StackLabel::runtime(".NET"), E::DotNet),
104    (
105        Extension("fsproj"),
106        StackLabel::runtime(".NET (F#)"),
107        E::DotNet,
108    ),
109];
110
111/// Label for Ruby projects with both `Gemfile` and `config.ru` but no
112/// `bin/rails`.
113pub const RACK_LABEL: StackLabel = StackLabel::framework("Ruby (Rack)");
114
115const COMMON_CONFIG_SUFFIXES: &[&str] = &["", ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts"];
116
117/// Detect a stack label from configuration files in a project root.
118///
119/// The function scans only `project_root` and checks built-in rules in a fixed
120/// priority order. More specific frontend framework config files are evaluated
121/// before generic runtime markers such as `Cargo.toml` or `go.mod`. Python
122/// projects get a second pass that can identify `Django`, `Flask`, `FastAPI`,
123/// `Starlette`, and `Litestar` from entry files or dependency files. Ruby
124/// projects need `Gemfile` and `config.ru` (`Ruby (Rack)`), plus `bin/rails`
125/// for `Rails`. PHP projects with `artisan` next to `composer.json` are
126/// `Laravel`.
127///
128/// This function knows nothing about the process, so it uses the same rules
129/// [`StackDetector`](crate::StackDetector) applies to an unknown process.
130/// Python entry files such as `server.py` alone do not make a Python project
131/// when `package.json` is present without a Python dependency file. The
132/// detector narrows the rules to the process's ecosystem: a `php` process in a
133/// Laravel project with `vite.config.js` is `Laravel`, while a `node` process
134/// there is `Vite`.
135///
136/// # Examples
137///
138/// ```no_run
139/// use std::path::Path;
140/// use what_stack::detect_from_config;
141///
142/// let label = detect_from_config(Path::new("/workspace/api"));
143/// println!("{label:?}");
144/// ```
145///
146/// # Performance
147///
148/// Directory entries are read once into a small in-memory set. Source and
149/// dependency files used for Python detection are capped to the first 64 KiB.
150#[must_use]
151pub fn detect_from_config(project_root: &Path) -> Option<StackLabel> {
152    detect_for_ecosystem(project_root, None)
153}
154
155/// Config detection for a process of a known ecosystem.
156///
157/// `None` means the process is unknown: every rule applies in priority order,
158/// and Python entry files alone do not make a Python project when
159/// `package.json` is present. `Some(ecosystem)` limits detection to that
160/// ecosystem's rules (a `deno` process also accepts Node rules), so a `php` process in a Laravel project with
161/// `vite.config.js` is `Laravel` and a `node` process next to a stray
162/// `server.py` is not `Python`. A Python process gets only framework labels:
163/// the generic `Python` fallback adds nothing to it, and would replace a more
164/// specific `Gunicorn` or `Uvicorn` label.
165pub fn detect_for_ecosystem(project_root: &Path, scope: Option<E>) -> Option<StackLabel> {
166    let files = ProjectFiles::read(project_root)?;
167    let in_scope = |ecosystem: E| scope.is_none_or(|scope| scope.accepts_config(ecosystem));
168    let python_process = scope == Some(E::Python);
169
170    detect_from_rules(project_root, &files, CONFIG_RULES, in_scope)
171        .or_else(|| {
172            in_scope(E::Python)
173                .then(|| python::detect_python_project(project_root, &files, python_process))
174                .flatten()
175        })
176        .or_else(|| detect_from_rules(project_root, &files, LATE_CONFIG_RULES, in_scope))
177}
178
179fn detect_from_rules(
180    project_root: &Path,
181    files: &ProjectFiles,
182    rules: &[ConfigRule],
183    in_scope: impl Fn(E) -> bool,
184) -> Option<StackLabel> {
185    rules
186        .iter()
187        .filter(|(_, _, ecosystem)| in_scope(*ecosystem))
188        .find(|(matcher, _, _)| rule_matches(project_root, files, *matcher))
189        .map(|(_, label, _)| label.clone())
190}
191
192fn rule_matches(project_root: &Path, files: &ProjectFiles, matcher: ConfigMatch) -> bool {
193    match matcher {
194        Exact(name) => files.contains_exact(name),
195        Prefix(prefix) => files.contains_prefix(prefix),
196        AllOf(paths) => paths
197            .iter()
198            .all(|path| files.contains_path(project_root, path)),
199        Extension(extension) => files.contains_extension(extension),
200    }
201}
202
203pub(super) fn matches_config_name_prefix(name: &str, pattern: &str) -> bool {
204    name.strip_prefix(pattern)
205        .is_some_and(|suffix| COMMON_CONFIG_SUFFIXES.contains(&suffix))
206}