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}