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