1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
19pub enum EdgeKind {
20 Normal,
22 Build,
24 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), Some("build") => Some(EdgeKind::Build),
33 Some("dev") => Some(EdgeKind::Dev),
34 Some(_) => None,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct Package {
42 pub name: String,
44 pub version: String,
46}
47
48pub struct Graph {
50 pub packages: HashMap<String, Package>,
52 pub edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>>,
54 pub members: Vec<String>,
56}
57
58pub 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 pub fn ids_named(&self, name: &str) -> Vec<&str> {
110 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 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 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#[derive(Debug)]
192pub struct Violation {
193 pub check: String,
195 pub subject: String,
197 pub evidence: String,
199}
200
201pub 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
212pub 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 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 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 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 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}