Skip to main content

coding_tools/
deps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `deps` built-in check's crate-graph queries.
5//!
6//! The resolved dependency graph comes from `cargo metadata --format-version
7//! 1 --locked --offline` (hermetic by construction); this module parses that
8//! JSON into a [`Graph`] and answers the crate-level invariant questions:
9//! *is crate X anywhere in the tree* ([`deny_paths`]), *does workspace member
10//! A reach crate B* ([`forbid_path`]), *do any crates resolve at more than one
11//! version* ([`Graph::duplicates`]), *is the graph free of cycles* ([`cycles`]),
12//! and *does it respect a declared layer order* ([`layer_violations`], with
13//! membership from [`assign_layers`]). Every violation carries its evidence —
14//! a dependency path, a concrete cycle, or the duplicated version list — so a
15//! red answer is never just a name.
16
17use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
18use std::path::Path;
19use std::process::Command;
20use std::time::Duration;
21
22use clap::{CommandFactory, Parser};
23
24use crate::rules::ProbeOutcome;
25use crate::{pattern, supervise};
26
27/// A dependency edge kind, as cargo models it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
29pub enum EdgeKind {
30    /// Ordinary `[dependencies]`.
31    Normal,
32    /// `[build-dependencies]`.
33    Build,
34    /// `[dev-dependencies]`.
35    Dev,
36}
37
38impl EdgeKind {
39    fn from_metadata(kind: &serde_json::Value) -> Option<EdgeKind> {
40        match kind.as_str() {
41            None => Some(EdgeKind::Normal), // null == normal in cargo metadata
42            Some("build") => Some(EdgeKind::Build),
43            Some("dev") => Some(EdgeKind::Dev),
44            Some(_) => None,
45        }
46    }
47}
48
49/// One resolved package.
50#[derive(Debug, Clone)]
51pub struct Package {
52    /// Crate name.
53    pub name: String,
54    /// Resolved version.
55    pub version: String,
56}
57
58/// The resolved crate graph.
59pub struct Graph {
60    /// Package id → name/version.
61    pub packages: HashMap<String, Package>,
62    /// Package id → outgoing edges (dependency package id, edge kinds).
63    pub edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>>,
64    /// Workspace member package ids.
65    pub members: Vec<String>,
66}
67
68/// Parse `cargo metadata --format-version 1` JSON into a [`Graph`].
69pub fn parse_metadata(text: &str) -> Result<Graph, String> {
70    let v: serde_json::Value =
71        serde_json::from_str(text).map_err(|e| format!("cargo metadata JSON: {e}"))?;
72    let mut packages = HashMap::new();
73    for p in v["packages"].as_array().ok_or("metadata missing packages")? {
74        let id = p["id"].as_str().ok_or("package missing id")?.to_string();
75        packages.insert(
76            id,
77            Package {
78                name: p["name"].as_str().unwrap_or("").to_string(),
79                version: p["version"].as_str().unwrap_or("").to_string(),
80            },
81        );
82    }
83    let members: Vec<String> = v["workspace_members"]
84        .as_array()
85        .ok_or("metadata missing workspace_members")?
86        .iter()
87        .filter_map(|m| m.as_str().map(String::from))
88        .collect();
89    let mut edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>> = HashMap::new();
90    let nodes = v["resolve"]["nodes"]
91        .as_array()
92        .ok_or("metadata missing resolve.nodes (was --no-deps used?)")?;
93    for node in nodes {
94        let id = node["id"].as_str().ok_or("node missing id")?.to_string();
95        let mut out = Vec::new();
96        for dep in node["deps"].as_array().unwrap_or(&Vec::new()) {
97            let pkg = dep["pkg"].as_str().unwrap_or("").to_string();
98            let kinds: Vec<EdgeKind> = dep["dep_kinds"]
99                .as_array()
100                .map(|ks| {
101                    ks.iter()
102                        .filter_map(|k| EdgeKind::from_metadata(&k["kind"]))
103                        .collect()
104                })
105                .unwrap_or_else(|| vec![EdgeKind::Normal]);
106            out.push((pkg, kinds));
107        }
108        edges.insert(id, out);
109    }
110    Ok(Graph {
111        packages,
112        edges,
113        members,
114    })
115}
116
117impl Graph {
118    /// Package ids whose crate name is exactly `name`.
119    pub fn ids_named(&self, name: &str) -> Vec<&str> {
120        // Only ids present in the resolve graph count as "in the tree".
121        let mut ids: Vec<&str> = self
122            .edges
123            .keys()
124            .filter(|id| self.packages.get(*id).is_some_and(|p| p.name == name))
125            .map(String::as_str)
126            .collect();
127        ids.sort();
128        ids
129    }
130
131    fn label(&self, id: &str) -> String {
132        match self.packages.get(id) {
133            // A versionless node (the module graph) labels by name alone.
134            Some(p) if p.version.is_empty() => p.name.clone(),
135            Some(p) => format!("{} v{}", p.name, p.version),
136            None => id.to_string(),
137        }
138    }
139
140    /// The dependency ids reachable from `node` over `allowed` edges, sorted
141    /// and de-duplicated for deterministic traversal. When `restrict` is set,
142    /// only ids within that node set are returned (the induced subgraph).
143    fn successors<'a>(
144        &'a self,
145        node: &str,
146        allowed: &HashSet<EdgeKind>,
147        restrict: Option<&HashSet<&str>>,
148    ) -> Vec<&'a str> {
149        let mut s: Vec<&str> = self
150            .edges
151            .get(node)
152            .map(Vec::as_slice)
153            .unwrap_or(&[])
154            .iter()
155            .filter(|(_, kinds)| kinds.iter().any(|k| allowed.contains(k)))
156            .map(|(dep, _)| dep.as_str())
157            .filter(|dep| restrict.is_none_or(|r| r.contains(*dep)))
158            .collect();
159        s.sort_unstable();
160        s.dedup();
161        s
162    }
163
164    /// BFS from `starts` to the first node satisfying `is_target` (the starts
165    /// themselves never count), traversing only `allowed` edge kinds. Returns
166    /// the evidence path as `name vX` labels.
167    fn bfs_path(
168        &self,
169        starts: &[&str],
170        is_target: impl Fn(&str) -> bool,
171        allowed: &HashSet<EdgeKind>,
172    ) -> Option<Vec<String>> {
173        let mut parent: HashMap<&str, &str> = HashMap::new();
174        let mut queue: VecDeque<&str> = VecDeque::new();
175        let mut seen: HashSet<&str> = HashSet::new();
176        for s in starts {
177            seen.insert(s);
178            queue.push_back(s);
179        }
180        while let Some(id) = queue.pop_front() {
181            if !starts.contains(&id) && is_target(id) {
182                let mut path = vec![id];
183                let mut cur = id;
184                while let Some(&p) = parent.get(cur) {
185                    path.push(p);
186                    cur = p;
187                }
188                path.reverse();
189                return Some(path.iter().map(|i| self.label(i)).collect());
190            }
191            for (dep, kinds) in self.edges.get(id).map(Vec::as_slice).unwrap_or(&[]) {
192                if !kinds.iter().any(|k| allowed.contains(k)) {
193                    continue;
194                }
195                let dep: &str = dep.as_str();
196                if seen.insert(dep) {
197                    parent.insert(dep, id);
198                    queue.push_back(dep);
199                }
200            }
201        }
202        None
203    }
204
205    /// BFS from `starts` to the first package named `target`, traversing only
206    /// `allowed` edge kinds. Returns the evidence path as `name vX` labels.
207    pub fn path_to(
208        &self,
209        starts: &[&str],
210        target: &str,
211        allowed: &HashSet<EdgeKind>,
212    ) -> Option<Vec<String>> {
213        self.bfs_path(
214            starts,
215            |id| self.packages.get(id).is_some_and(|p| p.name == target),
216            allowed,
217        )
218    }
219
220    /// BFS from `starts` to the first package whose id is in `targets`.
221    fn path_to_ids(
222        &self,
223        starts: &[&str],
224        targets: &HashSet<&str>,
225        allowed: &HashSet<EdgeKind>,
226    ) -> Option<Vec<String>> {
227        self.bfs_path(starts, |id| targets.contains(id), allowed)
228    }
229
230    /// Tarjan's strongly-connected components over `allowed` edges, each as a
231    /// list of package ids. Iterative, so a deep graph cannot overflow the
232    /// stack; node iteration is sorted, so the output is deterministic. When
233    /// `restrict` is set, only nodes in that set (and edges between them) are
234    /// considered — the workspace-member-induced subgraph.
235    fn strongly_connected<'a>(
236        &'a self,
237        allowed: &HashSet<EdgeKind>,
238        restrict: Option<&HashSet<&str>>,
239    ) -> Vec<Vec<String>> {
240        struct Frame<'f> {
241            node: &'f str,
242            succ: Vec<&'f str>,
243            next: usize,
244        }
245        let mut index: HashMap<&str, usize> = HashMap::new();
246        let mut low: HashMap<&str, usize> = HashMap::new();
247        let mut on_stack: HashSet<&str> = HashSet::new();
248        let mut stack: Vec<&str> = Vec::new();
249        let mut counter = 0usize;
250        let mut sccs: Vec<Vec<String>> = Vec::new();
251
252        let mut roots: Vec<&str> = self
253            .edges
254            .keys()
255            .map(String::as_str)
256            .filter(|id| restrict.is_none_or(|r| r.contains(*id)))
257            .collect();
258        roots.sort_unstable();
259
260        for root in roots {
261            if index.contains_key(root) {
262                continue;
263            }
264            index.insert(root, counter);
265            low.insert(root, counter);
266            counter += 1;
267            stack.push(root);
268            on_stack.insert(root);
269            let mut work: Vec<Frame<'a>> = vec![Frame {
270                node: root,
271                succ: self.successors(root, allowed, restrict),
272                next: 0,
273            }];
274            while let Some(top) = work.len().checked_sub(1) {
275                let (node, next, len) = {
276                    let f = &work[top];
277                    (f.node, f.next, f.succ.len())
278                };
279                if next < len {
280                    let w = work[top].succ[next];
281                    work[top].next += 1;
282                    if !index.contains_key(w) {
283                        index.insert(w, counter);
284                        low.insert(w, counter);
285                        counter += 1;
286                        stack.push(w);
287                        on_stack.insert(w);
288                        let succ = self.successors(w, allowed, restrict);
289                        work.push(Frame { node: w, succ, next: 0 });
290                    } else if on_stack.contains(w) {
291                        let lw = index[w];
292                        let e = low.get_mut(node).unwrap();
293                        *e = (*e).min(lw);
294                    }
295                } else {
296                    let node_low = low[node];
297                    if node_low == index[node] {
298                        let mut comp = Vec::new();
299                        loop {
300                            let w = stack.pop().unwrap();
301                            on_stack.remove(w);
302                            comp.push(w.to_string());
303                            if w == node {
304                                break;
305                            }
306                        }
307                        sccs.push(comp);
308                    }
309                    work.pop();
310                    if let Some(parent) = work.last() {
311                        let p = parent.node;
312                        let e = low.get_mut(p).unwrap();
313                        *e = (*e).min(node_low);
314                    }
315                }
316            }
317        }
318        sccs
319    }
320
321    /// A concrete shortest cycle through `start`, restricted to ids in `scc`
322    /// and `allowed` edges, as `name vX` labels that close back to `start`.
323    fn shortest_cycle_through(
324        &self,
325        start: &str,
326        scc: &HashSet<&str>,
327        allowed: &HashSet<EdgeKind>,
328    ) -> Vec<String> {
329        let neighbors = |id: &str| -> Vec<String> {
330            self.edges
331                .get(id)
332                .map(Vec::as_slice)
333                .unwrap_or(&[])
334                .iter()
335                .filter(|(dep, kinds)| {
336                    scc.contains(dep.as_str()) && kinds.iter().any(|k| allowed.contains(k))
337                })
338                .map(|(dep, _)| dep.clone())
339                .collect()
340        };
341        if neighbors(start).iter().any(|n| n == start) {
342            return vec![self.label(start), self.label(start)];
343        }
344        let mut parent: HashMap<String, String> = HashMap::new();
345        let mut seen: HashSet<String> = HashSet::new();
346        let mut queue: VecDeque<String> = VecDeque::new();
347        seen.insert(start.to_string());
348        for n in neighbors(start) {
349            if seen.insert(n.clone()) {
350                parent.insert(n.clone(), start.to_string());
351                queue.push_back(n);
352            }
353        }
354        while let Some(u) = queue.pop_front() {
355            for n in neighbors(&u) {
356                if n == start {
357                    let mut chain = vec![u.clone()];
358                    let mut cur = u.clone();
359                    while let Some(p) = parent.get(&cur) {
360                        chain.push(p.clone());
361                        cur = p.clone();
362                    }
363                    chain.reverse();
364                    chain.push(start.to_string());
365                    return chain.iter().map(|i| self.label(i)).collect();
366                }
367                if seen.insert(n.clone()) {
368                    parent.insert(n.clone(), u.clone());
369                    queue.push_back(n);
370                }
371            }
372        }
373        vec![self.label(start)]
374    }
375
376    /// Crate names that resolve at more than one version, with the versions.
377    pub fn duplicates(&self) -> Vec<(String, Vec<String>)> {
378        let mut by_name: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
379        for id in self.edges.keys() {
380            if let Some(p) = self.packages.get(id) {
381                by_name.entry(&p.name).or_default().insert(&p.version);
382            }
383        }
384        by_name
385            .into_iter()
386            .filter(|(_, versions)| versions.len() > 1)
387            .map(|(name, versions)| {
388                let mut v: Vec<String> = versions.into_iter().map(String::from).collect();
389                v.sort();
390                (name.to_string(), v)
391            })
392            .collect()
393    }
394}
395
396/// Evidence for one violated assertion.
397#[derive(Debug)]
398pub struct Violation {
399    /// Which assertion fired: `deny`, `forbid`, or `duplicates`.
400    pub check: String,
401    /// The offending crate (or `A=>B` spec).
402    pub subject: String,
403    /// Human evidence: a dependency path, or the duplicated versions.
404    pub evidence: String,
405}
406
407/// Evaluate `--deny NAME`: a violation when `name` resolves anywhere reachable
408/// from the workspace members over `allowed` edges.
409pub fn deny_paths(graph: &Graph, name: &str, allowed: &HashSet<EdgeKind>) -> Option<Violation> {
410    let members: Vec<&str> = graph.members.iter().map(String::as_str).collect();
411    graph.path_to(&members, name, allowed).map(|path| Violation {
412        check: "deny".to_string(),
413        subject: name.to_string(),
414        evidence: path.join(" -> "),
415    })
416}
417
418/// Evaluate `--forbid 'A=>B'`: a violation when any package named `from`
419/// reaches a package named `to` over `allowed` edges. `Err` when `from` is
420/// not in the graph at all (a defective assertion, not a clean pass).
421pub fn forbid_path(
422    graph: &Graph,
423    from: &str,
424    to: &str,
425    allowed: &HashSet<EdgeKind>,
426) -> Result<Option<Violation>, String> {
427    let starts = graph.ids_named(from);
428    if starts.is_empty() {
429        return Err(format!("--forbid: no package named '{from}' in the graph"));
430    }
431    Ok(graph.path_to(&starts, to, allowed).map(|path| Violation {
432        check: "forbid".to_string(),
433        subject: format!("{from}=>{to}"),
434        evidence: path.join(" -> "),
435    }))
436}
437
438/// Evaluate `--acyclic`: one [`Violation`] per dependency cycle — a
439/// strongly-connected component of two or more crates, or a self-loop — over
440/// `allowed` edges. Evidence is a concrete cycle path; the subject lists the
441/// crate names in the cycle. Output is sorted for determinism. When
442/// `members_only` is set, only cycles among workspace members are reported
443/// (the actionable form: crate cycles among third-party deps are almost always
444/// dev-dependency noise you cannot fix).
445pub fn cycles(graph: &Graph, allowed: &HashSet<EdgeKind>, members_only: bool) -> Vec<Violation> {
446    let member_set: HashSet<&str> = graph.members.iter().map(String::as_str).collect();
447    let restrict = members_only.then_some(&member_set);
448    let mut out = Vec::new();
449    for scc in graph.strongly_connected(allowed, restrict) {
450        let self_loop = scc.len() == 1
451            && graph
452                .edges
453                .get(&scc[0])
454                .map(Vec::as_slice)
455                .unwrap_or(&[])
456                .iter()
457                .any(|(dep, kinds)| dep == &scc[0] && kinds.iter().any(|k| allowed.contains(k)));
458        if scc.len() < 2 && !self_loop {
459            continue;
460        }
461        let set: HashSet<&str> = scc.iter().map(String::as_str).collect();
462        let start = scc.iter().min().map(String::as_str).expect("non-empty scc");
463        let evidence = graph.shortest_cycle_through(start, &set, allowed).join(" -> ");
464        let mut names: Vec<&str> = scc
465            .iter()
466            .filter_map(|id| graph.packages.get(id).map(|p| p.name.as_str()))
467            .collect();
468        names.sort_unstable();
469        names.dedup();
470        out.push(Violation {
471            check: "acyclic".to_string(),
472            subject: names.join(", "),
473            evidence,
474        });
475    }
476    out.sort_by(|a, b| a.subject.cmp(&b.subject));
477    out
478}
479
480/// A layer in a `--layers` stack: its label paired with the ids of the members
481/// assigned to it, in input order.
482pub type Layer = (String, Vec<String>);
483
484/// Assign each workspace member to a layer by the `matches(index, name)`
485/// predicate. Returns the layers (label + member ids, input order preserved)
486/// and the names of members matching no layer. Errors when a member matches
487/// more than one layer — an ambiguous spec, not a clean pass.
488pub fn assign_layers(
489    graph: &Graph,
490    labels: &[String],
491    matches: impl Fn(usize, &str) -> bool,
492) -> Result<(Vec<Layer>, Vec<String>), String> {
493    let mut layers: Vec<Layer> =
494        labels.iter().map(|l| (l.clone(), Vec::new())).collect();
495    let mut unassigned = Vec::new();
496    for id in &graph.members {
497        let name = graph.packages.get(id).map(|p| p.name.as_str()).unwrap_or("");
498        let hit: Vec<usize> = (0..labels.len()).filter(|&i| matches(i, name)).collect();
499        match hit.as_slice() {
500            [] => unassigned.push(name.to_string()),
501            [one] => layers[*one].1.push(id.clone()),
502            many => {
503                return Err(format!(
504                    "crate '{name}' matches multiple layers ({})",
505                    many.iter().map(|&m| labels[m].as_str()).collect::<Vec<_>>().join(", ")
506                ));
507            }
508        }
509    }
510    // A layer that captures no member is almost always a typo (the silent
511    // footgun: its intended constraints would vanish). Refuse it, like
512    // `--forbid` refuses an absent source package.
513    if let Some((label, _)) = layers.iter().find(|(_, ids)| ids.is_empty()) {
514        return Err(format!("layer '{label}' matches nothing"));
515    }
516    Ok((layers, unassigned))
517}
518
519/// Evaluate `--layers`: `layers` are ordered **highest first** — a layer may
520/// depend on layers listed after it, never before. One [`Violation`] per
521/// offending **member** of a lower layer that reaches a higher layer over
522/// `allowed` edges (so every violator is named, not just the first), each
523/// carrying its dependency path.
524pub fn layer_violations(
525    graph: &Graph,
526    layers: &[Layer],
527    allowed: &HashSet<EdgeKind>,
528) -> Vec<Violation> {
529    let mut out = Vec::new();
530    for lower in 0..layers.len() {
531        for higher in 0..lower {
532            let targets: HashSet<&str> = layers[higher].1.iter().map(String::as_str).collect();
533            if targets.is_empty() {
534                continue;
535            }
536            for start in &layers[lower].1 {
537                if let Some(path) = graph.path_to_ids(&[start.as_str()], &targets, allowed) {
538                    out.push(Violation {
539                        check: "layers".to_string(),
540                        subject: format!("{} => {}", layers[lower].0, layers[higher].0),
541                        evidence: path.join(" -> "),
542                    });
543                }
544            }
545        }
546    }
547    out
548}
549
550// ----- The `deps` built-in check -------------------------------------------------
551
552/// The assertion flags of a `deps` built-in check. No framing
553/// (question / emit / json): the rule layer owns the verdict; this only asserts.
554#[derive(Parser, Debug)]
555#[command(no_binary_name = true, disable_help_flag = true)]
556struct DepsCheck {
557    #[arg(long, value_name = "NAME")]
558    deny: Vec<String>,
559    #[arg(long, value_name = "A=>B")]
560    forbid: Vec<String>,
561    #[arg(long)]
562    duplicates: bool,
563    #[arg(long)]
564    acyclic: bool,
565    #[arg(long)]
566    members: bool,
567    #[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
568    layers: Vec<String>,
569    #[arg(long)]
570    layers_closed: bool,
571    #[arg(long, value_enum, value_delimiter = ',')]
572    edges: Vec<EdgeKind>,
573}
574
575/// One long-flagged argument, read from the clap grammar: its `--name`, value
576/// `kind` (`"boolean"` / `"array"` / `"string"`, the `docs/explain` `type`
577/// vocabulary), whether clap requires it, and its enum `values` (empty when the
578/// value is free-form).
579pub struct FlagSpec {
580    pub name: String,
581    pub kind: &'static str,
582    pub required: bool,
583    pub values: Vec<String>,
584}
585
586/// The introspected grammar of a tool or built-in check: every long flag's
587/// spec, plus the names of all clap-required arguments — flags *and* positionals
588/// (named by their field id, e.g. `path`/`probe`). The single source of truth
589/// behind the published `docs/explain` schema (a test reconciles the two) and
590/// the valid-flags hint on a bad argument.
591pub struct Grammar {
592    pub flags: Vec<FlagSpec>,
593    pub required: Vec<String>,
594}
595
596/// Read a command's [`Grammar`]. Skips the auto help/version args and the
597/// `--explain` documentation flag, none of which are tool inputs.
598pub(crate) fn grammar(command: clap::Command) -> Grammar {
599    let mut flags = Vec::new();
600    let mut required = Vec::new();
601    for arg in command.get_arguments() {
602        let id = arg.get_id().as_str();
603        if matches!(id, "help" | "version" | "explain") {
604            continue;
605        }
606        // Positionals (path/probe/command/args) have no long flag; name them by
607        // field id, matching the schema property key.
608        let name = arg.get_long().map(String::from).unwrap_or_else(|| id.to_string());
609        if arg.is_required_set() {
610            required.push(name.clone());
611        }
612        if arg.get_long().is_some() {
613            let kind = match arg.get_action() {
614                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "boolean",
615                clap::ArgAction::Append => "array",
616                _ => "string",
617            };
618            let values = arg.get_possible_values().iter().map(|v| v.get_name().to_string()).collect();
619            flags.push(FlagSpec { name, kind, required: arg.is_required_set(), values });
620        }
621    }
622    Grammar { flags, required }
623}
624
625/// The `deps` check's introspected grammar (see [`grammar`]).
626pub fn check_grammar() -> Grammar {
627    grammar(DepsCheck::command())
628}
629
630/// Run a `deps` built-in check over the crate graph rooted at `root` (one
631/// hermetic `cargo metadata` invocation). Returns the probe outcome, a one-line
632/// reason, and the violation report (one `check: subject: evidence` line each).
633/// Argument, spec, and cargo errors are [`ProbeOutcome::Broken`] — a defective
634/// probe, never a silent pass.
635pub fn check(args: &[String], root: &Path, timeout: Option<Duration>) -> (ProbeOutcome, String, String) {
636    let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
637    let cli = match DepsCheck::try_parse_from(args.iter().map(String::as_str)) {
638        Ok(c) => c,
639        Err(e) => {
640            let valid = check_grammar().flags.iter().map(|s| format!("--{}", s.name)).collect::<Vec<_>>().join(" ");
641            return broken(format!(
642                "deps: {} (valid flags: {valid})",
643                e.to_string().lines().next().unwrap_or("bad arguments")
644            ));
645        }
646    };
647    if cli.layers_closed && cli.layers.is_empty() {
648        return broken("deps: --layers-closed requires --layers".to_string());
649    }
650    if cli.members && !cli.acyclic {
651        return broken("deps: --members applies to --acyclic".to_string());
652    }
653    if cli.deny.is_empty() && cli.forbid.is_empty() && !cli.duplicates && !cli.acyclic && cli.layers.is_empty() {
654        return broken("deps: nothing to assert (--deny/--forbid/--duplicates/--acyclic/--layers)".to_string());
655    }
656    let allowed: HashSet<EdgeKind> = if cli.edges.is_empty() {
657        [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect()
658    } else {
659        cli.edges.iter().copied().collect()
660    };
661    let forbids: Vec<(String, String)> = match cli
662        .forbid
663        .iter()
664        .map(|spec| {
665            spec.split_once("=>")
666                .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
667                .filter(|(a, b)| !a.is_empty() && !b.is_empty())
668                .ok_or_else(|| format!("deps: --forbid needs 'A=>B', got '{spec}'"))
669        })
670        .collect()
671    {
672        Ok(f) => f,
673        Err(e) => return broken(e),
674    };
675
676    let mut command = Command::new("cargo");
677    command
678        .args(["metadata", "--format-version", "1", "--locked", "--offline"])
679        .current_dir(root);
680    let outcome = match supervise::run_captured(command, None, timeout) {
681        Ok(o) => o,
682        Err(e) => return broken(format!("deps: cargo metadata: {e}")),
683    };
684    if outcome.timed_out {
685        return broken("deps: cargo metadata timed out".to_string());
686    }
687    if !outcome.status.is_some_and(|s| s.success()) {
688        return broken(format!(
689            "deps: cargo metadata failed: {}",
690            outcome.stderr.lines().last().unwrap_or("(no output)")
691        ));
692    }
693    let graph = match parse_metadata(&outcome.stdout) {
694        Ok(g) => g,
695        Err(e) => return broken(format!("deps: {e}")),
696    };
697
698    let mut violations: Vec<Violation> = Vec::new();
699    for name in &cli.deny {
700        violations.extend(deny_paths(&graph, name, &allowed));
701    }
702    for (from, to) in &forbids {
703        match forbid_path(&graph, from, to, &allowed) {
704            Ok(v) => violations.extend(v),
705            Err(e) => return broken(format!("deps: {e}")),
706        }
707    }
708    if cli.duplicates {
709        for (name, versions) in graph.duplicates() {
710            violations.push(Violation {
711                check: "duplicates".to_string(),
712                subject: name,
713                evidence: versions.join(", "),
714            });
715        }
716    }
717    if cli.acyclic {
718        violations.extend(cycles(&graph, &allowed, cli.members));
719    }
720    if !cli.layers.is_empty() {
721        let compiled = match cli.layers.iter().map(|p| pattern::compile_anchored(p)).collect::<Result<Vec<_>, _>>() {
722            Ok(c) => c,
723            Err(e) => return broken(format!("deps: --layers invalid pattern: {e}")),
724        };
725        let (layers, unassigned) =
726            match assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
727                Ok(r) => r,
728                Err(e) => return broken(format!("deps: --layers: {e}")),
729            };
730        violations.extend(layer_violations(&graph, &layers, &allowed));
731        if cli.layers_closed {
732            violations.extend(unassigned.into_iter().map(|name| Violation {
733                check: "layers-closed".to_string(),
734                subject: name,
735                evidence: "matches no layer".to_string(),
736            }));
737        }
738    }
739
740    report_outcome("deps", violations)
741}
742
743/// The `(outcome, reason, report)` triple shared by both built-in checks: a
744/// newline-joined `check: subject: evidence` report, `Holds` when empty.
745pub(crate) fn report_outcome(kind: &str, violations: Vec<Violation>) -> (ProbeOutcome, String, String) {
746    let report = violations
747        .iter()
748        .map(|v| format!("{}: {}: {}", v.check, v.subject, v.evidence))
749        .collect::<Vec<_>>()
750        .join("\n");
751    if violations.is_empty() {
752        (ProbeOutcome::Holds, format!("{kind}: all assertions hold"), report)
753    } else {
754        (ProbeOutcome::Violated, format!("{kind}: {} violation(s)", violations.len()), report)
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    /// A tiny metadata document: member `app` -> `lib` -> `leaf v1`, plus a
763    /// dev-only edge `app -(dev)-> leaf v2` (a duplicate version).
764    fn sample() -> Graph {
765        let json = r#"{
766          "packages": [
767            {"id": "app-id", "name": "app", "version": "0.1.0"},
768            {"id": "lib-id", "name": "lib", "version": "0.1.0"},
769            {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
770            {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
771          ],
772          "workspace_members": ["app-id"],
773          "resolve": {"nodes": [
774            {"id": "app-id", "deps": [
775              {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
776              {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
777            ]},
778            {"id": "lib-id", "deps": [
779              {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
780            ]},
781            {"id": "leaf1-id", "deps": []},
782            {"id": "leaf2-id", "deps": []}
783          ]}
784        }"#;
785        parse_metadata(json).unwrap()
786    }
787
788    fn all_edges() -> HashSet<EdgeKind> {
789        [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
790            .into_iter()
791            .collect()
792    }
793
794    #[test]
795    fn deny_reports_an_evidence_path() {
796        let g = sample();
797        let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
798        assert_eq!(v.check, "deny");
799        // BFS finds the shortest route: the direct dev edge to leaf v2.
800        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
801        assert!(deny_paths(&g, "absent", &all_edges()).is_none());
802    }
803
804    #[test]
805    fn edge_kind_filter_changes_reachability() {
806        let g = sample();
807        let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
808        // Over normal edges only, the route goes through lib.
809        let v = deny_paths(&g, "leaf", &normal).unwrap();
810        assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
811        // A dev-only target disappears when dev edges are excluded.
812        let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
813        let v = deny_paths(&g, "leaf", &dev_only).unwrap();
814        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
815    }
816
817    #[test]
818    fn forbid_requires_the_source_to_exist() {
819        let g = sample();
820        let v = forbid_path(&g, "lib", "leaf", &all_edges()).unwrap().unwrap();
821        assert_eq!(v.subject, "lib=>leaf");
822        assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
823        assert!(forbid_path(&g, "lib", "app", &all_edges()).unwrap().is_none());
824        assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
825    }
826
827    #[test]
828    fn duplicates_lists_versions() {
829        let g = sample();
830        let d = g.duplicates();
831        assert_eq!(d.len(), 1);
832        assert_eq!(d[0].0, "leaf");
833        assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
834    }
835
836    /// `a -> b -> c -> a` (a 3-cycle) plus an acyclic `c -> d`.
837    fn cyclic() -> Graph {
838        let json = r#"{
839          "packages": [
840            {"id": "a", "name": "a", "version": "1.0.0"},
841            {"id": "b", "name": "b", "version": "1.0.0"},
842            {"id": "c", "name": "c", "version": "1.0.0"},
843            {"id": "d", "name": "d", "version": "1.0.0"}
844          ],
845          "workspace_members": ["a"],
846          "resolve": {"nodes": [
847            {"id": "a", "deps": [{"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]}]},
848            {"id": "b", "deps": [{"name": "c", "pkg": "c", "dep_kinds": [{"kind": null}]}]},
849            {"id": "c", "deps": [
850              {"name": "a", "pkg": "a", "dep_kinds": [{"kind": null}]},
851              {"name": "d", "pkg": "d", "dep_kinds": [{"kind": null}]}
852            ]},
853            {"id": "d", "deps": []}
854          ]}
855        }"#;
856        parse_metadata(json).unwrap()
857    }
858
859    #[test]
860    fn cycles_report_a_concrete_loop() {
861        let g = cyclic();
862        let v = cycles(&g, &all_edges(), false);
863        assert_eq!(v.len(), 1);
864        assert_eq!(v[0].check, "acyclic");
865        assert_eq!(v[0].subject, "a, b, c");
866        assert_eq!(v[0].evidence, "a v1.0.0 -> b v1.0.0 -> c v1.0.0 -> a v1.0.0");
867    }
868
869    #[test]
870    fn acyclic_graph_has_no_cycles() {
871        // app -> lib -> leaf, plus a dev edge to a second leaf: no cycle.
872        assert!(cycles(&sample(), &all_edges(), false).is_empty());
873    }
874
875    /// `x -> y` (normal) and `y -> x` (dev): a cycle only when dev edges count.
876    fn cyclic_via_dev() -> Graph {
877        let json = r#"{
878          "packages": [
879            {"id": "x", "name": "x", "version": "1.0.0"},
880            {"id": "y", "name": "y", "version": "1.0.0"}
881          ],
882          "workspace_members": ["x"],
883          "resolve": {"nodes": [
884            {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
885            {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": "dev"}]}]}
886          ]}
887        }"#;
888        parse_metadata(json).unwrap()
889    }
890
891    #[test]
892    fn cycles_respect_edge_kinds() {
893        let g = cyclic_via_dev();
894        let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
895        assert!(cycles(&g, &normal, false).is_empty());
896        assert_eq!(cycles(&g, &all_edges(), false).len(), 1);
897    }
898
899    /// Three workspace members; `svc -> api` is an upward (illegal) edge.
900    fn layered() -> Graph {
901        let json = r#"{
902          "packages": [
903            {"id": "api", "name": "api", "version": "1.0.0"},
904            {"id": "svc", "name": "svc", "version": "1.0.0"},
905            {"id": "db", "name": "db", "version": "1.0.0"}
906          ],
907          "workspace_members": ["api", "svc", "db"],
908          "resolve": {"nodes": [
909            {"id": "api", "deps": []},
910            {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
911            {"id": "db", "deps": []}
912          ]}
913        }"#;
914        parse_metadata(json).unwrap()
915    }
916
917    #[test]
918    fn layers_flag_a_lower_layer_reaching_a_higher_one() {
919        let g = layered();
920        let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
921        // Exact-name membership for the test.
922        let (layers, unassigned) =
923            assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
924        assert!(unassigned.is_empty());
925        let v = layer_violations(&g, &layers, &all_edges());
926        assert_eq!(v.len(), 1);
927        assert_eq!(v[0].check, "layers");
928        assert_eq!(v[0].subject, "svc => api");
929        assert_eq!(v[0].evidence, "svc v1.0.0 -> api v1.0.0");
930    }
931
932    #[test]
933    fn layers_allow_top_down_dependencies() {
934        // Same crates, but ordered svc above api: svc -> api is now legal.
935        let g = layered();
936        let labels = vec!["svc".to_string(), "api".to_string(), "db".to_string()];
937        let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
938        assert!(layer_violations(&g, &layers, &all_edges()).is_empty());
939    }
940
941    #[test]
942    fn assign_layers_reports_unassigned_and_ambiguous() {
943        let g = layered();
944        // db matches no layer -> unassigned (the --layers-closed signal).
945        let labels = vec!["api".to_string(), "svc".to_string()];
946        let (_, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
947        assert_eq!(unassigned, ["db"]);
948        // A predicate that matches `api` in two layers is an ambiguous spec.
949        let two = vec!["api".to_string(), "api-again".to_string()];
950        let err = assign_layers(&g, &two, |_, name| name == "api").unwrap_err();
951        assert!(err.contains("multiple layers"), "got: {err}");
952    }
953
954    #[test]
955    fn assign_layers_rejects_an_empty_layer() {
956        let g = layered();
957        // `ghostlayer` matches no member: a typo that would silently drop its rules.
958        let labels = vec!["api".to_string(), "ghostlayer".to_string()];
959        let err = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap_err();
960        assert!(err.contains("matches nothing"), "got: {err}");
961    }
962
963    /// A member cycle (`a <-> b` via a dev back-edge) and a separate external
964    /// cycle (`x -> y -> x`) reachable from a member.
965    fn mixed_cycles() -> Graph {
966        let json = r#"{
967          "packages": [
968            {"id": "a", "name": "a", "version": "1.0.0"},
969            {"id": "b", "name": "b", "version": "1.0.0"},
970            {"id": "x", "name": "x", "version": "1.0.0"},
971            {"id": "y", "name": "y", "version": "1.0.0"}
972          ],
973          "workspace_members": ["a", "b"],
974          "resolve": {"nodes": [
975            {"id": "a", "deps": [
976              {"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]},
977              {"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}
978            ]},
979            {"id": "b", "deps": [{"name": "a", "pkg": "a", "dep_kinds": [{"kind": "dev"}]}]},
980            {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
981            {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}]}
982          ]}
983        }"#;
984        parse_metadata(json).unwrap()
985    }
986
987    #[test]
988    fn cycles_members_only_scopes_to_workspace() {
989        let g = mixed_cycles();
990        // Whole graph: both the member cycle and the external one, sorted.
991        let all = cycles(&g, &all_edges(), false);
992        assert_eq!(all.len(), 2);
993        assert_eq!(all[0].subject, "a, b");
994        assert_eq!(all[1].subject, "x, y");
995        // Members only: just the actionable a<->b cycle.
996        let members = cycles(&g, &all_edges(), true);
997        assert_eq!(members.len(), 1);
998        assert_eq!(members[0].subject, "a, b");
999    }
1000
1001    /// `api <- svc <- db` chain: db reaches api only transitively.
1002    fn layered_transitive() -> Graph {
1003        let json = r#"{
1004          "packages": [
1005            {"id": "api", "name": "api", "version": "1.0.0"},
1006            {"id": "svc", "name": "svc", "version": "1.0.0"},
1007            {"id": "db", "name": "db", "version": "1.0.0"}
1008          ],
1009          "workspace_members": ["api", "svc", "db"],
1010          "resolve": {"nodes": [
1011            {"id": "api", "deps": []},
1012            {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
1013            {"id": "db", "deps": [{"name": "svc", "pkg": "svc", "dep_kinds": [{"kind": null}]}]}
1014          ]}
1015        }"#;
1016        parse_metadata(json).unwrap()
1017    }
1018
1019    #[test]
1020    fn layers_report_transitive_violations_per_member() {
1021        let g = layered_transitive();
1022        let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
1023        let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1024        let v = layer_violations(&g, &layers, &all_edges());
1025        let by: BTreeMap<&str, &str> =
1026            v.iter().map(|x| (x.subject.as_str(), x.evidence.as_str())).collect();
1027        // db reaches api only through svc — the path proves the transitive hop.
1028        assert_eq!(by["db => api"], "db v1.0.0 -> svc v1.0.0 -> api v1.0.0");
1029        assert_eq!(by["svc => api"], "svc v1.0.0 -> api v1.0.0");
1030        assert_eq!(by["db => svc"], "db v1.0.0 -> svc v1.0.0");
1031        assert_eq!(v.len(), 3);
1032    }
1033}