Skip to main content

coding_tools/
deps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! `ct-deps`'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`]), and *do any crates resolve at more
11//! than one version* ([`duplicates`]). Every violation carries its evidence —
12//! a dependency path or the duplicated version list — so a red answer is
13//! never just a name.
14
15use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16
17/// A dependency edge kind, as cargo models it.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
19pub enum EdgeKind {
20    /// Ordinary `[dependencies]`.
21    Normal,
22    /// `[build-dependencies]`.
23    Build,
24    /// `[dev-dependencies]`.
25    Dev,
26}
27
28impl EdgeKind {
29    fn from_metadata(kind: &serde_json::Value) -> Option<EdgeKind> {
30        match kind.as_str() {
31            None => Some(EdgeKind::Normal), // null == normal in cargo metadata
32            Some("build") => Some(EdgeKind::Build),
33            Some("dev") => Some(EdgeKind::Dev),
34            Some(_) => None,
35        }
36    }
37}
38
39/// One resolved package.
40#[derive(Debug, Clone)]
41pub struct Package {
42    /// Crate name.
43    pub name: String,
44    /// Resolved version.
45    pub version: String,
46}
47
48/// The resolved crate graph.
49pub struct Graph {
50    /// Package id → name/version.
51    pub packages: HashMap<String, Package>,
52    /// Package id → outgoing edges (dependency package id, edge kinds).
53    pub edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>>,
54    /// Workspace member package ids.
55    pub members: Vec<String>,
56}
57
58/// Parse `cargo metadata --format-version 1` JSON into a [`Graph`].
59pub fn parse_metadata(text: &str) -> Result<Graph, String> {
60    let v: serde_json::Value =
61        serde_json::from_str(text).map_err(|e| format!("cargo metadata JSON: {e}"))?;
62    let mut packages = HashMap::new();
63    for p in v["packages"].as_array().ok_or("metadata missing packages")? {
64        let id = p["id"].as_str().ok_or("package missing id")?.to_string();
65        packages.insert(
66            id,
67            Package {
68                name: p["name"].as_str().unwrap_or("").to_string(),
69                version: p["version"].as_str().unwrap_or("").to_string(),
70            },
71        );
72    }
73    let members: Vec<String> = v["workspace_members"]
74        .as_array()
75        .ok_or("metadata missing workspace_members")?
76        .iter()
77        .filter_map(|m| m.as_str().map(String::from))
78        .collect();
79    let mut edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>> = HashMap::new();
80    let nodes = v["resolve"]["nodes"]
81        .as_array()
82        .ok_or("metadata missing resolve.nodes (was --no-deps used?)")?;
83    for node in nodes {
84        let id = node["id"].as_str().ok_or("node missing id")?.to_string();
85        let mut out = Vec::new();
86        for dep in node["deps"].as_array().unwrap_or(&Vec::new()) {
87            let pkg = dep["pkg"].as_str().unwrap_or("").to_string();
88            let kinds: Vec<EdgeKind> = dep["dep_kinds"]
89                .as_array()
90                .map(|ks| {
91                    ks.iter()
92                        .filter_map(|k| EdgeKind::from_metadata(&k["kind"]))
93                        .collect()
94                })
95                .unwrap_or_else(|| vec![EdgeKind::Normal]);
96            out.push((pkg, kinds));
97        }
98        edges.insert(id, out);
99    }
100    Ok(Graph {
101        packages,
102        edges,
103        members,
104    })
105}
106
107impl Graph {
108    /// Package ids whose crate name is exactly `name`.
109    pub fn ids_named(&self, name: &str) -> Vec<&str> {
110        // Only ids present in the resolve graph count as "in the tree".
111        let mut ids: Vec<&str> = self
112            .edges
113            .keys()
114            .filter(|id| self.packages.get(*id).is_some_and(|p| p.name == name))
115            .map(String::as_str)
116            .collect();
117        ids.sort();
118        ids
119    }
120
121    fn label(&self, id: &str) -> String {
122        match self.packages.get(id) {
123            Some(p) => format!("{} v{}", p.name, p.version),
124            None => id.to_string(),
125        }
126    }
127
128    /// BFS from `starts` to the first package named `target`, traversing only
129    /// `allowed` edge kinds. Returns the evidence path as `name vX` labels.
130    pub fn path_to(
131        &self,
132        starts: &[&str],
133        target: &str,
134        allowed: &HashSet<EdgeKind>,
135    ) -> Option<Vec<String>> {
136        let mut parent: HashMap<&str, &str> = HashMap::new();
137        let mut queue: VecDeque<&str> = VecDeque::new();
138        let mut seen: HashSet<&str> = HashSet::new();
139        for s in starts {
140            seen.insert(s);
141            queue.push_back(s);
142        }
143        while let Some(id) = queue.pop_front() {
144            if self.packages.get(id).is_some_and(|p| p.name == target)
145                && !starts.contains(&id)
146            {
147                let mut path = vec![id];
148                let mut cur = id;
149                while let Some(&p) = parent.get(cur) {
150                    path.push(p);
151                    cur = p;
152                }
153                path.reverse();
154                return Some(path.iter().map(|i| self.label(i)).collect());
155            }
156            for (dep, kinds) in self.edges.get(id).map(Vec::as_slice).unwrap_or(&[]) {
157                if !kinds.iter().any(|k| allowed.contains(k)) {
158                    continue;
159                }
160                let dep: &str = dep.as_str();
161                if seen.insert(dep) {
162                    parent.insert(dep, id);
163                    queue.push_back(dep);
164                }
165            }
166        }
167        None
168    }
169
170    /// Crate names that resolve at more than one version, with the versions.
171    pub fn duplicates(&self) -> Vec<(String, Vec<String>)> {
172        let mut by_name: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
173        for id in self.edges.keys() {
174            if let Some(p) = self.packages.get(id) {
175                by_name.entry(&p.name).or_default().insert(&p.version);
176            }
177        }
178        by_name
179            .into_iter()
180            .filter(|(_, versions)| versions.len() > 1)
181            .map(|(name, versions)| {
182                let mut v: Vec<String> = versions.into_iter().map(String::from).collect();
183                v.sort();
184                (name.to_string(), v)
185            })
186            .collect()
187    }
188}
189
190/// Evidence for one violated assertion.
191#[derive(Debug)]
192pub struct Violation {
193    /// Which assertion fired: `deny`, `forbid`, or `duplicates`.
194    pub check: String,
195    /// The offending crate (or `A=>B` spec).
196    pub subject: String,
197    /// Human evidence: a dependency path, or the duplicated versions.
198    pub evidence: String,
199}
200
201/// Evaluate `--deny NAME`: a violation when `name` resolves anywhere reachable
202/// from the workspace members over `allowed` edges.
203pub fn deny_paths(graph: &Graph, name: &str, allowed: &HashSet<EdgeKind>) -> Option<Violation> {
204    let members: Vec<&str> = graph.members.iter().map(String::as_str).collect();
205    graph.path_to(&members, name, allowed).map(|path| Violation {
206        check: "deny".to_string(),
207        subject: name.to_string(),
208        evidence: path.join(" -> "),
209    })
210}
211
212/// Evaluate `--forbid 'A=>B'`: a violation when any package named `from`
213/// reaches a package named `to` over `allowed` edges. `Err` when `from` is
214/// not in the graph at all (a defective assertion, not a clean pass).
215pub fn forbid_path(
216    graph: &Graph,
217    from: &str,
218    to: &str,
219    allowed: &HashSet<EdgeKind>,
220) -> Result<Option<Violation>, String> {
221    let starts = graph.ids_named(from);
222    if starts.is_empty() {
223        return Err(format!("--forbid: no package named '{from}' in the graph"));
224    }
225    Ok(graph.path_to(&starts, to, allowed).map(|path| Violation {
226        check: "forbid".to_string(),
227        subject: format!("{from}=>{to}"),
228        evidence: path.join(" -> "),
229    }))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    /// A tiny metadata document: member `app` -> `lib` -> `leaf v1`, plus a
237    /// dev-only edge `app -(dev)-> leaf v2` (a duplicate version).
238    fn sample() -> Graph {
239        let json = r#"{
240          "packages": [
241            {"id": "app-id", "name": "app", "version": "0.1.0"},
242            {"id": "lib-id", "name": "lib", "version": "0.1.0"},
243            {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
244            {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
245          ],
246          "workspace_members": ["app-id"],
247          "resolve": {"nodes": [
248            {"id": "app-id", "deps": [
249              {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
250              {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
251            ]},
252            {"id": "lib-id", "deps": [
253              {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
254            ]},
255            {"id": "leaf1-id", "deps": []},
256            {"id": "leaf2-id", "deps": []}
257          ]}
258        }"#;
259        parse_metadata(json).unwrap()
260    }
261
262    fn all_edges() -> HashSet<EdgeKind> {
263        [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
264            .into_iter()
265            .collect()
266    }
267
268    #[test]
269    fn deny_reports_an_evidence_path() {
270        let g = sample();
271        let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
272        assert_eq!(v.check, "deny");
273        // BFS finds the shortest route: the direct dev edge to leaf v2.
274        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
275        assert!(deny_paths(&g, "absent", &all_edges()).is_none());
276    }
277
278    #[test]
279    fn edge_kind_filter_changes_reachability() {
280        let g = sample();
281        let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
282        // Over normal edges only, the route goes through lib.
283        let v = deny_paths(&g, "leaf", &normal).unwrap();
284        assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
285        // A dev-only target disappears when dev edges are excluded.
286        let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
287        let v = deny_paths(&g, "leaf", &dev_only).unwrap();
288        assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
289    }
290
291    #[test]
292    fn forbid_requires_the_source_to_exist() {
293        let g = sample();
294        let v = forbid_path(&g, "lib", "leaf", &all_edges()).unwrap().unwrap();
295        assert_eq!(v.subject, "lib=>leaf");
296        assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
297        assert!(forbid_path(&g, "lib", "app", &all_edges()).unwrap().is_none());
298        assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
299    }
300
301    #[test]
302    fn duplicates_lists_versions() {
303        let g = sample();
304        let d = g.duplicates();
305        assert_eq!(d.len(), 1);
306        assert_eq!(d[0].0, "leaf");
307        assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
308    }
309}