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