Skip to main content

standarbuild_detect/
detector.rs

1//! [`Detector`] trait + [`DetectorRegistry`] for composing project-AND-workspace
2//! detection. Built-ins live in [`crate::builtin`]; downstream crates
3//! implement `Detector` for any extra kind (project or workspace, or both).
4
5use std::path::{Path, PathBuf};
6
7use crate::kind::KindId;
8use crate::workspace::WorkspaceKindId;
9
10/// Result of probing a directory with a single [`Detector`].
11///
12/// - [`DetectorHit::Project`] — the dir is a project of some kind.
13/// - [`DetectorHit::Workspace`] — the dir is a workspace manifest declaring
14///   a set of member project roots.
15/// - [`DetectorHit::Both`] — the same manifest declares both (e.g. a
16///   `Cargo.toml` with `[package]` AND `[workspace]`).
17#[derive(Debug, Clone)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize))]
19#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "lowercase"))]
20pub enum DetectorHit {
21    /// A standalone project — no workspace manifest detected by this
22    /// detector at this dir.
23    Project {
24        /// What kind of project (Rust, Node, …).
25        kind: KindId,
26        /// Files / patterns that triggered detection.
27        signals: Vec<String>,
28    },
29    /// A workspace manifest at this dir, declaring member project roots.
30    Workspace {
31        /// What kind of workspace organizer (Cargo, Npm, …).
32        kind: WorkspaceKindId,
33        /// Absolute paths to member project roots, in declared order.
34        /// Empty when the manifest declares a workspace but enumerates
35        /// no members (rare; the workspace then "contains itself" via
36        /// the project hit at the same path).
37        members: Vec<PathBuf>,
38        /// Files / patterns that triggered detection.
39        signals: Vec<String>,
40    },
41    /// Combined: the same on-disk artifact declares both project and
42    /// workspace responsibilities. Typical for a Cargo workspace whose
43    /// root is also a crate, or a Node monorepo whose root has a
44    /// `package.json` with both `name` and `workspaces`.
45    Both {
46        /// Project facet kind.
47        project_kind: KindId,
48        /// Workspace facet kind.
49        workspace_kind: WorkspaceKindId,
50        /// Absolute paths to member project roots.
51        members: Vec<PathBuf>,
52        /// Shared signals (since both facets come from the same manifest).
53        signals: Vec<String>,
54    },
55}
56
57impl DetectorHit {
58    /// Borrow the project-facet kind, if this hit carries one.
59    pub fn project_kind(&self) -> Option<&KindId> {
60        match self {
61            Self::Project { kind, .. } | Self::Both { project_kind: kind, .. } => Some(kind),
62            Self::Workspace { .. } => None,
63        }
64    }
65
66    /// Borrow the workspace-facet kind, if this hit carries one.
67    pub fn workspace_kind(&self) -> Option<&WorkspaceKindId> {
68        match self {
69            Self::Workspace { kind, .. } | Self::Both { workspace_kind: kind, .. } => Some(kind),
70            Self::Project { .. } => None,
71        }
72    }
73
74    /// Borrow the declared member paths (empty for project-only hits).
75    pub fn members(&self) -> &[PathBuf] {
76        match self {
77            Self::Workspace { members, .. } | Self::Both { members, .. } => members,
78            Self::Project { .. } => &[],
79        }
80    }
81
82    /// Borrow the signals that triggered this hit.
83    pub fn signals(&self) -> &[String] {
84        match self {
85            Self::Project { signals, .. }
86            | Self::Workspace { signals, .. }
87            | Self::Both { signals, .. } => signals,
88        }
89    }
90}
91
92/// Detection contract — implement for any custom kind / workspace.
93pub trait Detector: Send + Sync {
94    /// Stable identifier used for diagnostics and [`DetectorRegistry::remove`].
95    /// Conventionally lowercase; e.g. `"rust"`, `"cargo-workspace"`,
96    /// `"wgsl"`. Need not match any [`KindId`] / [`WorkspaceKindId`] slug.
97    fn name(&self) -> &str;
98
99    /// Probe `dir`. Return `Some(_)` if this detector recognises something
100    /// at this directory; `None` otherwise.
101    fn detect(&self, dir: &Path) -> Option<DetectorHit>;
102
103    /// Tiebreaker when two detectors hit the SAME facet at the SAME dir
104    /// (e.g. NodeDetector vs BunDetector both claim the project facet on
105    /// a dir that has both `package.json` and `bun.lock`). Higher wins.
106    /// Detectors with disjoint facets don't compete — both hits land in
107    /// the final result.
108    ///
109    /// Built-in priorities (project facet): Rust=100, Bun=80, Deno=70,
110    /// Node=50, Python=40, Lua=30, Cpp=20, C=10. Workspace facet uses
111    /// similar shape: BunWs=80, PnpmWs=70, YarnWs=60, NpmWs=50, …
112    fn priority(&self) -> i32 {
113        0
114    }
115
116    /// Declare the project [`KindId`] this detector advertises, if any.
117    /// Used by registry introspection (e.g. schema validation) to warn
118    /// when a user declares a project `type` that no registered detector
119    /// recognises. Workspace-only detectors return `None`. Default
120    /// returns `None` — dynamic detectors that emit different kinds
121    /// based on runtime conditions can leave it that way.
122    fn declared_project_kind(&self) -> Option<KindId> {
123        None
124    }
125
126    /// Declare the workspace [`WorkspaceKindId`] this detector
127    /// advertises, if any. Used by registry introspection and the
128    /// `DetectionResult` consumers that want to query the set of
129    /// workspace kinds the registry knows about.
130    fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> {
131        None
132    }
133}
134
135/// Registry of registered [`Detector`]s. Use [`DetectorRegistry::with_builtins`]
136/// for the default set, or [`DetectorRegistry::empty`] to build from scratch.
137pub struct DetectorRegistry {
138    detectors: Vec<Box<dyn Detector>>,
139}
140
141impl DetectorRegistry {
142    /// New registry with no detectors. Use `add` to populate.
143    pub fn empty() -> Self {
144        Self { detectors: Vec::new() }
145    }
146
147    /// New registry preloaded with every detector this crate ships.
148    pub fn with_builtins() -> Self {
149        let mut r = Self::empty();
150        crate::builtin::register_all(&mut r);
151        r
152    }
153
154    /// Append a detector.
155    pub fn add(&mut self, d: impl Detector + 'static) -> &mut Self {
156        self.detectors.push(Box::new(d));
157        self
158    }
159
160    /// Remove every detector whose `name()` equals `name`. Returns the
161    /// number of detectors removed.
162    pub fn remove(&mut self, name: &str) -> usize {
163        let before = self.detectors.len();
164        self.detectors.retain(|d| d.name() != name);
165        before - self.detectors.len()
166    }
167
168    /// List the names of registered detectors, in registration order.
169    pub fn names(&self) -> Vec<&str> {
170        self.detectors.iter().map(|d| d.name()).collect()
171    }
172
173    /// Collect the project [`KindId`]s advertised by registered detectors.
174    /// Returns deduplicated set in registration order. Detectors that
175    /// don't advertise a kind (workspace-only or dynamic) are skipped.
176    pub fn project_kinds(&self) -> Vec<KindId> {
177        let mut seen: std::collections::HashSet<KindId> = std::collections::HashSet::new();
178        let mut out = Vec::new();
179        for d in &self.detectors {
180            if let Some(k) = d.declared_project_kind() {
181                if seen.insert(k.clone()) {
182                    out.push(k);
183                }
184            }
185        }
186        out
187    }
188
189    /// Collect the [`WorkspaceKindId`]s advertised by registered detectors.
190    pub fn workspace_kinds(&self) -> Vec<WorkspaceKindId> {
191        let mut seen: std::collections::HashSet<WorkspaceKindId> =
192            std::collections::HashSet::new();
193        let mut out = Vec::new();
194        for d in &self.detectors {
195            if let Some(k) = d.declared_workspace_kind() {
196                if seen.insert(k.clone()) {
197                    out.push(k);
198                }
199            }
200        }
201        out
202    }
203
204    /// Run every detector against `dir` and collect the hits.
205    ///
206    /// Per-facet disambiguation rules:
207    /// - **Project facet**: at most ONE project hit per directory wins —
208    ///   the highest-priority detector across ALL project kinds. Two
209    ///   detectors firing for the same dir (e.g. NodeDetector +
210    ///   BunDetector when both `package.json` and `bun.lock` are present)
211    ///   collapse to the higher-priority one (Bun=80 > Node=50).
212    /// - **Workspace facet**: multiple workspace kinds CAN coexist at
213    ///   the same root (Cargo + Npm in a Tauri-style repo). Within the
214    ///   same `WorkspaceKindId`, the higher-priority detector wins.
215    ///
216    /// A `DetectorHit::Both` competes on both facets simultaneously.
217    pub fn detect(&self, dir: &Path) -> Vec<DetectorHit> {
218        if !dir.is_dir() {
219            return Vec::new();
220        }
221
222        // Collect raw hits with their priority.
223        let mut raw: Vec<(i32, DetectorHit)> = Vec::new();
224        for d in &self.detectors {
225            if let Some(h) = d.detect(dir) {
226                raw.push((d.priority(), h));
227            }
228        }
229
230        // Project facet: pick the single highest-priority across all kinds.
231        let mut best_project: Option<(i32, usize)> = None;
232        for (idx, (prio, hit)) in raw.iter().enumerate() {
233            if hit.project_kind().is_some() {
234                match best_project {
235                    None => best_project = Some((*prio, idx)),
236                    Some((p, _)) if *prio > p => best_project = Some((*prio, idx)),
237                    _ => {}
238                }
239            }
240        }
241
242        // Workspace facet: pick the highest-priority PER WorkspaceKindId.
243        let mut best_workspace: std::collections::HashMap<WorkspaceKindId, (i32, usize)> =
244            std::collections::HashMap::new();
245        for (idx, (prio, hit)) in raw.iter().enumerate() {
246            if let Some(wk) = hit.workspace_kind() {
247                match best_workspace.get(wk) {
248                    None => {
249                        best_workspace.insert(wk.clone(), (*prio, idx));
250                    }
251                    Some((p, _)) if prio > p => {
252                        best_workspace.insert(wk.clone(), (*prio, idx));
253                    }
254                    _ => {}
255                }
256            }
257        }
258
259        // Construct the kept set, then rewrite hits so a Both whose project
260        // facet lost to a higher-priority Project (rare) downgrades to
261        // Workspace-only — and vice versa.
262        let project_winner = best_project.map(|(_, idx)| idx);
263        let workspace_winners: std::collections::HashSet<usize> =
264            best_workspace.values().map(|(_, idx)| *idx).collect();
265
266        let mut out = Vec::new();
267        for (idx, (_, hit)) in raw.into_iter().enumerate() {
268            let project_wins_here = project_winner == Some(idx);
269            let workspace_wins_here = workspace_winners.contains(&idx);
270            match hit {
271                DetectorHit::Project { .. } if project_wins_here => out.push(hit),
272                DetectorHit::Workspace { .. } if workspace_wins_here => out.push(hit),
273                DetectorHit::Both {
274                    project_kind,
275                    workspace_kind,
276                    members,
277                    signals,
278                } => match (project_wins_here, workspace_wins_here) {
279                    (true, true) => out.push(DetectorHit::Both {
280                        project_kind,
281                        workspace_kind,
282                        members,
283                        signals,
284                    }),
285                    (true, false) => out.push(DetectorHit::Project {
286                        kind: project_kind,
287                        signals,
288                    }),
289                    (false, true) => out.push(DetectorHit::Workspace {
290                        kind: workspace_kind,
291                        members,
292                        signals,
293                    }),
294                    (false, false) => {}
295                },
296                _ => {}
297            }
298        }
299        out
300    }
301}
302
303impl Default for DetectorRegistry {
304    fn default() -> Self {
305        Self::with_builtins()
306    }
307}