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)]
581    acyclic: bool,
582    #[arg(long)]
583    members: bool,
584    #[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
585    layers: Vec<String>,
586    #[arg(long)]
587    layers_closed: bool,
588    #[arg(long, value_enum, value_delimiter = ',')]
589    edges: Vec<EdgeKind>,
590}
591
592/// One long-flagged argument, read from the clap grammar: its `--name`, value
593/// `kind` (`"boolean"` / `"array"` / `"string"`, the `docs/explain` `type`
594/// vocabulary), whether clap requires it, and its enum `values` (empty when the
595/// value is free-form).
596pub struct FlagSpec {
597    pub name: String,
598    pub kind: &'static str,
599    pub required: bool,
600    pub values: Vec<String>,
601}
602
603/// The introspected grammar of a tool or built-in check: every long flag's
604/// spec, plus the names of all clap-required arguments — flags *and* positionals
605/// (named by their field id, e.g. `path`/`probe`). The single source of truth
606/// behind the published `docs/explain` schema (a test reconciles the two) and
607/// the valid-flags hint on a bad argument.
608pub struct Grammar {
609    pub flags: Vec<FlagSpec>,
610    pub required: Vec<String>,
611}
612
613/// Read a command's [`Grammar`]. Skips the auto help/version args and the
614/// `--explain` documentation flag, none of which are tool inputs.
615pub(crate) fn grammar(command: clap::Command) -> Grammar {
616    let mut flags = Vec::new();
617    let mut required = Vec::new();
618    for arg in command.get_arguments() {
619        let id = arg.get_id().as_str();
620        if matches!(id, "help" | "version" | "explain") {
621            continue;
622        }
623        // Positionals (path/probe/command/args) have no long flag; name them by
624        // field id, matching the schema property key.
625        let name = arg
626            .get_long()
627            .map(String::from)
628            .unwrap_or_else(|| id.to_string());
629        if arg.is_required_set() {
630            required.push(name.clone());
631        }
632        if arg.get_long().is_some() {
633            let kind = match arg.get_action() {
634                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "boolean",
635                clap::ArgAction::Append => "array",
636                _ => "string",
637            };
638            let values = arg
639                .get_possible_values()
640                .iter()
641                .map(|v| v.get_name().to_string())
642                .collect();
643            flags.push(FlagSpec {
644                name,
645                kind,
646                required: arg.is_required_set(),
647                values,
648            });
649        }
650    }
651    Grammar { flags, required }
652}
653
654/// The `deps` check's introspected grammar (see [`grammar`]).
655pub fn check_grammar() -> Grammar {
656    grammar(DepsCheck::command())
657}
658
659/// Run a `deps` built-in check over the crate graph rooted at `root` (one
660/// hermetic `cargo metadata` invocation). Returns the probe outcome, a one-line
661/// reason, and the violation report (one `check: subject: evidence` line each).
662/// Argument, spec, and cargo errors are [`ProbeOutcome::Broken`] — a defective
663/// probe, never a silent pass.
664pub fn check(
665    args: &[String],
666    root: &Path,
667    timeout: Option<Duration>,
668) -> (ProbeOutcome, String, String) {
669    let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
670    let cli = match DepsCheck::try_parse_from(args.iter().map(String::as_str)) {
671        Ok(c) => c,
672        Err(e) => {
673            let valid = check_grammar()
674                .flags
675                .iter()
676                .map(|s| format!("--{}", s.name))
677                .collect::<Vec<_>>()
678                .join(" ");
679            return broken(format!(
680                "deps: {} (valid flags: {valid})",
681                e.to_string().lines().next().unwrap_or("bad arguments")
682            ));
683        }
684    };
685    if cli.layers_closed && cli.layers.is_empty() {
686        return broken("deps: --layers-closed requires --layers".to_string());
687    }
688    if cli.members && !cli.acyclic {
689        return broken("deps: --members applies to --acyclic".to_string());
690    }
691    if cli.deny.is_empty()
692        && cli.forbid.is_empty()
693        && !cli.duplicates
694        && !cli.acyclic
695        && cli.layers.is_empty()
696    {
697        return broken(
698            "deps: nothing to assert (--deny/--forbid/--duplicates/--acyclic/--layers)".to_string(),
699        );
700    }
701    let allowed: HashSet<EdgeKind> = if cli.edges.is_empty() {
702        [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
703            .into_iter()
704            .collect()
705    } else {
706        cli.edges.iter().copied().collect()
707    };
708    let forbids: Vec<(String, String)> = match cli
709        .forbid
710        .iter()
711        .map(|spec| {
712            spec.split_once("=>")
713                .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
714                .filter(|(a, b)| !a.is_empty() && !b.is_empty())
715                .ok_or_else(|| format!("deps: --forbid needs 'A=>B', got '{spec}'"))
716        })
717        .collect()
718    {
719        Ok(f) => f,
720        Err(e) => return broken(e),
721    };
722
723    let mut command = Command::new("cargo");
724    command
725        .args(["metadata", "--format-version", "1", "--locked", "--offline"])
726        .current_dir(root);
727    let outcome = match supervise::run_captured(command, None, timeout) {
728        Ok(o) => o,
729        Err(e) => return broken(format!("deps: cargo metadata: {e}")),
730    };
731    if outcome.timed_out {
732        return broken("deps: cargo metadata timed out".to_string());
733    }
734    if !outcome.status.is_some_and(|s| s.success()) {
735        return broken(format!(
736            "deps: cargo metadata failed: {}",
737            outcome.stderr.lines().last().unwrap_or("(no output)")
738        ));
739    }
740    let graph = match parse_metadata(&outcome.stdout) {
741        Ok(g) => g,
742        Err(e) => return broken(format!("deps: {e}")),
743    };
744
745    let mut violations: Vec<Violation> = Vec::new();
746    for name in &cli.deny {
747        violations.extend(deny_paths(&graph, name, &allowed));
748    }
749    for (from, to) in &forbids {
750        match forbid_path(&graph, from, to, &allowed) {
751            Ok(v) => violations.extend(v),
752            Err(e) => return broken(format!("deps: {e}")),
753        }
754    }
755    if cli.duplicates {
756        for (name, versions) in graph.duplicates() {
757            violations.push(Violation {
758                check: "duplicates".to_string(),
759                subject: name,
760                evidence: versions.join(", "),
761            });
762        }
763    }
764    if cli.acyclic {
765        violations.extend(cycles(&graph, &allowed, cli.members));
766    }
767    if !cli.layers.is_empty() {
768        let compiled = match cli
769            .layers
770            .iter()
771            .map(|p| pattern::compile_anchored(p))
772            .collect::<Result<Vec<_>, _>>()
773        {
774            Ok(c) => c,
775            Err(e) => return broken(format!("deps: --layers invalid pattern: {e}")),
776        };
777        let (layers, unassigned) =
778            match assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
779                Ok(r) => r,
780                Err(e) => return broken(format!("deps: --layers: {e}")),
781            };
782        violations.extend(layer_violations(&graph, &layers, &allowed));
783        if cli.layers_closed {
784            violations.extend(unassigned.into_iter().map(|name| Violation {
785                check: "layers-closed".to_string(),
786                subject: name,
787                evidence: "matches no layer".to_string(),
788            }));
789        }
790    }
791
792    report_outcome("deps", violations)
793}
794
795/// The `(outcome, reason, report)` triple shared by both built-in checks: a
796/// newline-joined `check: subject: evidence` report, `Holds` when empty.
797pub(crate) fn report_outcome(
798    kind: &str,
799    violations: Vec<Violation>,
800) -> (ProbeOutcome, String, String) {
801    let report = violations
802        .iter()
803        .map(|v| format!("{}: {}: {}", v.check, v.subject, v.evidence))
804        .collect::<Vec<_>>()
805        .join("\n");
806    if violations.is_empty() {
807        (
808            ProbeOutcome::Holds,
809            format!("{kind}: all assertions hold"),
810            report,
811        )
812    } else {
813        (
814            ProbeOutcome::Violated,
815            format!("{kind}: {} violation(s)", violations.len()),
816            report,
817        )
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    /// A tiny metadata document: member `app` -> `lib` -> `leaf v1`, plus a
826    /// dev-only edge `app -(dev)-> leaf v2` (a duplicate version).
827    fn sample() -> Graph {
828        let json = r#"{
829          "packages": [
830            {"id": "app-id", "name": "app", "version": "0.1.0"},
831            {"id": "lib-id", "name": "lib", "version": "0.1.0"},
832            {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
833            {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
834          ],
835          "workspace_members": ["app-id"],
836          "resolve": {"nodes": [
837            {"id": "app-id", "deps": [
838              {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
839              {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
840            ]},
841            {"id": "lib-id", "deps": [
842              {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
843            ]},
844            {"id": "leaf1-id", "deps": []},
845            {"id": "leaf2-id", "deps": []}
846          ]}
847        }"#;
848        parse_metadata(json).unwrap()
849    }
850
851    fn all_edges() -> HashSet<EdgeKind> {
852        [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
853            .into_iter()
854            .collect()
855    }
856
857    #[test]
858    fn deny_reports_an_evidence_path() {
859        let g = sample();
860        let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
861        assert_eq!(v.check, "deny");
862        // BFS finds the shortest route: the direct dev edge to leaf v2.
863        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
864        assert!(deny_paths(&g, "absent", &all_edges()).is_none());
865    }
866
867    #[test]
868    fn edge_kind_filter_changes_reachability() {
869        let g = sample();
870        let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
871        // Over normal edges only, the route goes through lib.
872        let v = deny_paths(&g, "leaf", &normal).unwrap();
873        assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
874        // A dev-only target disappears when dev edges are excluded.
875        let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
876        let v = deny_paths(&g, "leaf", &dev_only).unwrap();
877        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
878    }
879
880    #[test]
881    fn forbid_requires_the_source_to_exist() {
882        let g = sample();
883        let v = forbid_path(&g, "lib", "leaf", &all_edges())
884            .unwrap()
885            .unwrap();
886        assert_eq!(v.subject, "lib=>leaf");
887        assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
888        assert!(
889            forbid_path(&g, "lib", "app", &all_edges())
890                .unwrap()
891                .is_none()
892        );
893        assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
894    }
895
896    #[test]
897    fn duplicates_lists_versions() {
898        let g = sample();
899        let d = g.duplicates();
900        assert_eq!(d.len(), 1);
901        assert_eq!(d[0].0, "leaf");
902        assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
903    }
904
905    /// `a -> b -> c -> a` (a 3-cycle) plus an acyclic `c -> d`.
906    fn cyclic() -> Graph {
907        let json = r#"{
908          "packages": [
909            {"id": "a", "name": "a", "version": "1.0.0"},
910            {"id": "b", "name": "b", "version": "1.0.0"},
911            {"id": "c", "name": "c", "version": "1.0.0"},
912            {"id": "d", "name": "d", "version": "1.0.0"}
913          ],
914          "workspace_members": ["a"],
915          "resolve": {"nodes": [
916            {"id": "a", "deps": [{"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]}]},
917            {"id": "b", "deps": [{"name": "c", "pkg": "c", "dep_kinds": [{"kind": null}]}]},
918            {"id": "c", "deps": [
919              {"name": "a", "pkg": "a", "dep_kinds": [{"kind": null}]},
920              {"name": "d", "pkg": "d", "dep_kinds": [{"kind": null}]}
921            ]},
922            {"id": "d", "deps": []}
923          ]}
924        }"#;
925        parse_metadata(json).unwrap()
926    }
927
928    #[test]
929    fn cycles_report_a_concrete_loop() {
930        let g = cyclic();
931        let v = cycles(&g, &all_edges(), false);
932        assert_eq!(v.len(), 1);
933        assert_eq!(v[0].check, "acyclic");
934        assert_eq!(v[0].subject, "a, b, c");
935        assert_eq!(
936            v[0].evidence,
937            "a v1.0.0 -> b v1.0.0 -> c v1.0.0 -> a v1.0.0"
938        );
939    }
940
941    #[test]
942    fn acyclic_graph_has_no_cycles() {
943        // app -> lib -> leaf, plus a dev edge to a second leaf: no cycle.
944        assert!(cycles(&sample(), &all_edges(), false).is_empty());
945    }
946
947    /// `x -> y` (normal) and `y -> x` (dev): a cycle only when dev edges count.
948    fn cyclic_via_dev() -> Graph {
949        let json = r#"{
950          "packages": [
951            {"id": "x", "name": "x", "version": "1.0.0"},
952            {"id": "y", "name": "y", "version": "1.0.0"}
953          ],
954          "workspace_members": ["x"],
955          "resolve": {"nodes": [
956            {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
957            {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": "dev"}]}]}
958          ]}
959        }"#;
960        parse_metadata(json).unwrap()
961    }
962
963    #[test]
964    fn cycles_respect_edge_kinds() {
965        let g = cyclic_via_dev();
966        let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
967        assert!(cycles(&g, &normal, false).is_empty());
968        assert_eq!(cycles(&g, &all_edges(), false).len(), 1);
969    }
970
971    /// Three workspace members; `svc -> api` is an upward (illegal) edge.
972    fn layered() -> Graph {
973        let json = r#"{
974          "packages": [
975            {"id": "api", "name": "api", "version": "1.0.0"},
976            {"id": "svc", "name": "svc", "version": "1.0.0"},
977            {"id": "db", "name": "db", "version": "1.0.0"}
978          ],
979          "workspace_members": ["api", "svc", "db"],
980          "resolve": {"nodes": [
981            {"id": "api", "deps": []},
982            {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
983            {"id": "db", "deps": []}
984          ]}
985        }"#;
986        parse_metadata(json).unwrap()
987    }
988
989    #[test]
990    fn layers_flag_a_lower_layer_reaching_a_higher_one() {
991        let g = layered();
992        let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
993        // Exact-name membership for the test.
994        let (layers, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
995        assert!(unassigned.is_empty());
996        let v = layer_violations(&g, &layers, &all_edges());
997        assert_eq!(v.len(), 1);
998        assert_eq!(v[0].check, "layers");
999        assert_eq!(v[0].subject, "svc => api");
1000        assert_eq!(v[0].evidence, "svc v1.0.0 -> api v1.0.0");
1001    }
1002
1003    #[test]
1004    fn layers_allow_top_down_dependencies() {
1005        // Same crates, but ordered svc above api: svc -> api is now legal.
1006        let g = layered();
1007        let labels = vec!["svc".to_string(), "api".to_string(), "db".to_string()];
1008        let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1009        assert!(layer_violations(&g, &layers, &all_edges()).is_empty());
1010    }
1011
1012    #[test]
1013    fn assign_layers_reports_unassigned_and_ambiguous() {
1014        let g = layered();
1015        // db matches no layer -> unassigned (the --layers-closed signal).
1016        let labels = vec!["api".to_string(), "svc".to_string()];
1017        let (_, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1018        assert_eq!(unassigned, ["db"]);
1019        // A predicate that matches `api` in two layers is an ambiguous spec.
1020        let two = vec!["api".to_string(), "api-again".to_string()];
1021        let err = assign_layers(&g, &two, |_, name| name == "api").unwrap_err();
1022        assert!(err.contains("multiple layers"), "got: {err}");
1023    }
1024
1025    #[test]
1026    fn assign_layers_rejects_an_empty_layer() {
1027        let g = layered();
1028        // `ghostlayer` matches no member: a typo that would silently drop its rules.
1029        let labels = vec!["api".to_string(), "ghostlayer".to_string()];
1030        let err = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap_err();
1031        assert!(err.contains("matches nothing"), "got: {err}");
1032    }
1033
1034    /// A member cycle (`a <-> b` via a dev back-edge) and a separate external
1035    /// cycle (`x -> y -> x`) reachable from a member.
1036    fn mixed_cycles() -> Graph {
1037        let json = r#"{
1038          "packages": [
1039            {"id": "a", "name": "a", "version": "1.0.0"},
1040            {"id": "b", "name": "b", "version": "1.0.0"},
1041            {"id": "x", "name": "x", "version": "1.0.0"},
1042            {"id": "y", "name": "y", "version": "1.0.0"}
1043          ],
1044          "workspace_members": ["a", "b"],
1045          "resolve": {"nodes": [
1046            {"id": "a", "deps": [
1047              {"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]},
1048              {"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}
1049            ]},
1050            {"id": "b", "deps": [{"name": "a", "pkg": "a", "dep_kinds": [{"kind": "dev"}]}]},
1051            {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
1052            {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}]}
1053          ]}
1054        }"#;
1055        parse_metadata(json).unwrap()
1056    }
1057
1058    #[test]
1059    fn cycles_members_only_scopes_to_workspace() {
1060        let g = mixed_cycles();
1061        // Whole graph: both the member cycle and the external one, sorted.
1062        let all = cycles(&g, &all_edges(), false);
1063        assert_eq!(all.len(), 2);
1064        assert_eq!(all[0].subject, "a, b");
1065        assert_eq!(all[1].subject, "x, y");
1066        // Members only: just the actionable a<->b cycle.
1067        let members = cycles(&g, &all_edges(), true);
1068        assert_eq!(members.len(), 1);
1069        assert_eq!(members[0].subject, "a, b");
1070    }
1071
1072    /// `api <- svc <- db` chain: db reaches api only transitively.
1073    fn layered_transitive() -> Graph {
1074        let json = r#"{
1075          "packages": [
1076            {"id": "api", "name": "api", "version": "1.0.0"},
1077            {"id": "svc", "name": "svc", "version": "1.0.0"},
1078            {"id": "db", "name": "db", "version": "1.0.0"}
1079          ],
1080          "workspace_members": ["api", "svc", "db"],
1081          "resolve": {"nodes": [
1082            {"id": "api", "deps": []},
1083            {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
1084            {"id": "db", "deps": [{"name": "svc", "pkg": "svc", "dep_kinds": [{"kind": null}]}]}
1085          ]}
1086        }"#;
1087        parse_metadata(json).unwrap()
1088    }
1089
1090    #[test]
1091    fn layers_report_transitive_violations_per_member() {
1092        let g = layered_transitive();
1093        let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
1094        let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1095        let v = layer_violations(&g, &layers, &all_edges());
1096        let by: BTreeMap<&str, &str> = v
1097            .iter()
1098            .map(|x| (x.subject.as_str(), x.evidence.as_str()))
1099            .collect();
1100        // db reaches api only through svc — the path proves the transitive hop.
1101        assert_eq!(by["db => api"], "db v1.0.0 -> svc v1.0.0 -> api v1.0.0");
1102        assert_eq!(by["svc => api"], "svc v1.0.0 -> api v1.0.0");
1103        assert_eq!(by["db => svc"], "db v1.0.0 -> svc v1.0.0");
1104        assert_eq!(v.len(), 3);
1105    }
1106}