1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
29pub enum EdgeKind {
30 Normal,
32 Build,
34 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), Some("build") => Some(EdgeKind::Build),
43 Some("dev") => Some(EdgeKind::Dev),
44 Some(_) => None,
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
51pub struct Package {
52 pub name: String,
54 pub version: String,
56}
57
58pub struct Graph {
60 pub packages: HashMap<String, Package>,
62 pub edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>>,
64 pub members: Vec<String>,
66}
67
68pub 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 pub fn ids_named(&self, name: &str) -> Vec<&str> {
120 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 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 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 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 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 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 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 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 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#[derive(Debug)]
398pub struct Violation {
399 pub check: String,
401 pub subject: String,
403 pub evidence: String,
405}
406
407pub 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
418pub 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
438pub 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
480pub type Layer = (String, Vec<String>);
483
484pub 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 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
519pub 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#[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
575pub struct FlagSpec {
580 pub name: String,
581 pub kind: &'static str,
582 pub required: bool,
583 pub values: Vec<String>,
584}
585
586pub struct Grammar {
592 pub flags: Vec<FlagSpec>,
593 pub required: Vec<String>,
594}
595
596pub(crate) fn grammar(command: clap::Command) -> Grammar {
599 let mut flags = Vec::new();
600 let mut required = Vec::new();
601 for arg in command.get_arguments() {
602 let id = arg.get_id().as_str();
603 if matches!(id, "help" | "version" | "explain") {
604 continue;
605 }
606 let name = arg.get_long().map(String::from).unwrap_or_else(|| id.to_string());
609 if arg.is_required_set() {
610 required.push(name.clone());
611 }
612 if arg.get_long().is_some() {
613 let kind = match arg.get_action() {
614 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "boolean",
615 clap::ArgAction::Append => "array",
616 _ => "string",
617 };
618 let values = arg.get_possible_values().iter().map(|v| v.get_name().to_string()).collect();
619 flags.push(FlagSpec { name, kind, required: arg.is_required_set(), values });
620 }
621 }
622 Grammar { flags, required }
623}
624
625pub fn check_grammar() -> Grammar {
627 grammar(DepsCheck::command())
628}
629
630pub fn check(args: &[String], root: &Path, timeout: Option<Duration>) -> (ProbeOutcome, String, String) {
636 let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
637 let cli = match DepsCheck::try_parse_from(args.iter().map(String::as_str)) {
638 Ok(c) => c,
639 Err(e) => {
640 let valid = check_grammar().flags.iter().map(|s| format!("--{}", s.name)).collect::<Vec<_>>().join(" ");
641 return broken(format!(
642 "deps: {} (valid flags: {valid})",
643 e.to_string().lines().next().unwrap_or("bad arguments")
644 ));
645 }
646 };
647 if cli.layers_closed && cli.layers.is_empty() {
648 return broken("deps: --layers-closed requires --layers".to_string());
649 }
650 if cli.members && !cli.acyclic {
651 return broken("deps: --members applies to --acyclic".to_string());
652 }
653 if cli.deny.is_empty() && cli.forbid.is_empty() && !cli.duplicates && !cli.acyclic && cli.layers.is_empty() {
654 return broken("deps: nothing to assert (--deny/--forbid/--duplicates/--acyclic/--layers)".to_string());
655 }
656 let allowed: HashSet<EdgeKind> = if cli.edges.is_empty() {
657 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect()
658 } else {
659 cli.edges.iter().copied().collect()
660 };
661 let forbids: Vec<(String, String)> = match cli
662 .forbid
663 .iter()
664 .map(|spec| {
665 spec.split_once("=>")
666 .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
667 .filter(|(a, b)| !a.is_empty() && !b.is_empty())
668 .ok_or_else(|| format!("deps: --forbid needs 'A=>B', got '{spec}'"))
669 })
670 .collect()
671 {
672 Ok(f) => f,
673 Err(e) => return broken(e),
674 };
675
676 let mut command = Command::new("cargo");
677 command
678 .args(["metadata", "--format-version", "1", "--locked", "--offline"])
679 .current_dir(root);
680 let outcome = match supervise::run_captured(command, None, timeout) {
681 Ok(o) => o,
682 Err(e) => return broken(format!("deps: cargo metadata: {e}")),
683 };
684 if outcome.timed_out {
685 return broken("deps: cargo metadata timed out".to_string());
686 }
687 if !outcome.status.is_some_and(|s| s.success()) {
688 return broken(format!(
689 "deps: cargo metadata failed: {}",
690 outcome.stderr.lines().last().unwrap_or("(no output)")
691 ));
692 }
693 let graph = match parse_metadata(&outcome.stdout) {
694 Ok(g) => g,
695 Err(e) => return broken(format!("deps: {e}")),
696 };
697
698 let mut violations: Vec<Violation> = Vec::new();
699 for name in &cli.deny {
700 violations.extend(deny_paths(&graph, name, &allowed));
701 }
702 for (from, to) in &forbids {
703 match forbid_path(&graph, from, to, &allowed) {
704 Ok(v) => violations.extend(v),
705 Err(e) => return broken(format!("deps: {e}")),
706 }
707 }
708 if cli.duplicates {
709 for (name, versions) in graph.duplicates() {
710 violations.push(Violation {
711 check: "duplicates".to_string(),
712 subject: name,
713 evidence: versions.join(", "),
714 });
715 }
716 }
717 if cli.acyclic {
718 violations.extend(cycles(&graph, &allowed, cli.members));
719 }
720 if !cli.layers.is_empty() {
721 let compiled = match cli.layers.iter().map(|p| pattern::compile_anchored(p)).collect::<Result<Vec<_>, _>>() {
722 Ok(c) => c,
723 Err(e) => return broken(format!("deps: --layers invalid pattern: {e}")),
724 };
725 let (layers, unassigned) =
726 match assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
727 Ok(r) => r,
728 Err(e) => return broken(format!("deps: --layers: {e}")),
729 };
730 violations.extend(layer_violations(&graph, &layers, &allowed));
731 if cli.layers_closed {
732 violations.extend(unassigned.into_iter().map(|name| Violation {
733 check: "layers-closed".to_string(),
734 subject: name,
735 evidence: "matches no layer".to_string(),
736 }));
737 }
738 }
739
740 report_outcome("deps", violations)
741}
742
743pub(crate) fn report_outcome(kind: &str, violations: Vec<Violation>) -> (ProbeOutcome, String, String) {
746 let report = violations
747 .iter()
748 .map(|v| format!("{}: {}: {}", v.check, v.subject, v.evidence))
749 .collect::<Vec<_>>()
750 .join("\n");
751 if violations.is_empty() {
752 (ProbeOutcome::Holds, format!("{kind}: all assertions hold"), report)
753 } else {
754 (ProbeOutcome::Violated, format!("{kind}: {} violation(s)", violations.len()), report)
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 fn sample() -> Graph {
765 let json = r#"{
766 "packages": [
767 {"id": "app-id", "name": "app", "version": "0.1.0"},
768 {"id": "lib-id", "name": "lib", "version": "0.1.0"},
769 {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
770 {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
771 ],
772 "workspace_members": ["app-id"],
773 "resolve": {"nodes": [
774 {"id": "app-id", "deps": [
775 {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
776 {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
777 ]},
778 {"id": "lib-id", "deps": [
779 {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
780 ]},
781 {"id": "leaf1-id", "deps": []},
782 {"id": "leaf2-id", "deps": []}
783 ]}
784 }"#;
785 parse_metadata(json).unwrap()
786 }
787
788 fn all_edges() -> HashSet<EdgeKind> {
789 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
790 .into_iter()
791 .collect()
792 }
793
794 #[test]
795 fn deny_reports_an_evidence_path() {
796 let g = sample();
797 let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
798 assert_eq!(v.check, "deny");
799 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
801 assert!(deny_paths(&g, "absent", &all_edges()).is_none());
802 }
803
804 #[test]
805 fn edge_kind_filter_changes_reachability() {
806 let g = sample();
807 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
808 let v = deny_paths(&g, "leaf", &normal).unwrap();
810 assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
811 let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
813 let v = deny_paths(&g, "leaf", &dev_only).unwrap();
814 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
815 }
816
817 #[test]
818 fn forbid_requires_the_source_to_exist() {
819 let g = sample();
820 let v = forbid_path(&g, "lib", "leaf", &all_edges()).unwrap().unwrap();
821 assert_eq!(v.subject, "lib=>leaf");
822 assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
823 assert!(forbid_path(&g, "lib", "app", &all_edges()).unwrap().is_none());
824 assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
825 }
826
827 #[test]
828 fn duplicates_lists_versions() {
829 let g = sample();
830 let d = g.duplicates();
831 assert_eq!(d.len(), 1);
832 assert_eq!(d[0].0, "leaf");
833 assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
834 }
835
836 fn cyclic() -> Graph {
838 let json = r#"{
839 "packages": [
840 {"id": "a", "name": "a", "version": "1.0.0"},
841 {"id": "b", "name": "b", "version": "1.0.0"},
842 {"id": "c", "name": "c", "version": "1.0.0"},
843 {"id": "d", "name": "d", "version": "1.0.0"}
844 ],
845 "workspace_members": ["a"],
846 "resolve": {"nodes": [
847 {"id": "a", "deps": [{"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]}]},
848 {"id": "b", "deps": [{"name": "c", "pkg": "c", "dep_kinds": [{"kind": null}]}]},
849 {"id": "c", "deps": [
850 {"name": "a", "pkg": "a", "dep_kinds": [{"kind": null}]},
851 {"name": "d", "pkg": "d", "dep_kinds": [{"kind": null}]}
852 ]},
853 {"id": "d", "deps": []}
854 ]}
855 }"#;
856 parse_metadata(json).unwrap()
857 }
858
859 #[test]
860 fn cycles_report_a_concrete_loop() {
861 let g = cyclic();
862 let v = cycles(&g, &all_edges(), false);
863 assert_eq!(v.len(), 1);
864 assert_eq!(v[0].check, "acyclic");
865 assert_eq!(v[0].subject, "a, b, c");
866 assert_eq!(v[0].evidence, "a v1.0.0 -> b v1.0.0 -> c v1.0.0 -> a v1.0.0");
867 }
868
869 #[test]
870 fn acyclic_graph_has_no_cycles() {
871 assert!(cycles(&sample(), &all_edges(), false).is_empty());
873 }
874
875 fn cyclic_via_dev() -> Graph {
877 let json = r#"{
878 "packages": [
879 {"id": "x", "name": "x", "version": "1.0.0"},
880 {"id": "y", "name": "y", "version": "1.0.0"}
881 ],
882 "workspace_members": ["x"],
883 "resolve": {"nodes": [
884 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
885 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": "dev"}]}]}
886 ]}
887 }"#;
888 parse_metadata(json).unwrap()
889 }
890
891 #[test]
892 fn cycles_respect_edge_kinds() {
893 let g = cyclic_via_dev();
894 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
895 assert!(cycles(&g, &normal, false).is_empty());
896 assert_eq!(cycles(&g, &all_edges(), false).len(), 1);
897 }
898
899 fn layered() -> Graph {
901 let json = r#"{
902 "packages": [
903 {"id": "api", "name": "api", "version": "1.0.0"},
904 {"id": "svc", "name": "svc", "version": "1.0.0"},
905 {"id": "db", "name": "db", "version": "1.0.0"}
906 ],
907 "workspace_members": ["api", "svc", "db"],
908 "resolve": {"nodes": [
909 {"id": "api", "deps": []},
910 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
911 {"id": "db", "deps": []}
912 ]}
913 }"#;
914 parse_metadata(json).unwrap()
915 }
916
917 #[test]
918 fn layers_flag_a_lower_layer_reaching_a_higher_one() {
919 let g = layered();
920 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
921 let (layers, unassigned) =
923 assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
924 assert!(unassigned.is_empty());
925 let v = layer_violations(&g, &layers, &all_edges());
926 assert_eq!(v.len(), 1);
927 assert_eq!(v[0].check, "layers");
928 assert_eq!(v[0].subject, "svc => api");
929 assert_eq!(v[0].evidence, "svc v1.0.0 -> api v1.0.0");
930 }
931
932 #[test]
933 fn layers_allow_top_down_dependencies() {
934 let g = layered();
936 let labels = vec!["svc".to_string(), "api".to_string(), "db".to_string()];
937 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
938 assert!(layer_violations(&g, &layers, &all_edges()).is_empty());
939 }
940
941 #[test]
942 fn assign_layers_reports_unassigned_and_ambiguous() {
943 let g = layered();
944 let labels = vec!["api".to_string(), "svc".to_string()];
946 let (_, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
947 assert_eq!(unassigned, ["db"]);
948 let two = vec!["api".to_string(), "api-again".to_string()];
950 let err = assign_layers(&g, &two, |_, name| name == "api").unwrap_err();
951 assert!(err.contains("multiple layers"), "got: {err}");
952 }
953
954 #[test]
955 fn assign_layers_rejects_an_empty_layer() {
956 let g = layered();
957 let labels = vec!["api".to_string(), "ghostlayer".to_string()];
959 let err = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap_err();
960 assert!(err.contains("matches nothing"), "got: {err}");
961 }
962
963 fn mixed_cycles() -> Graph {
966 let json = r#"{
967 "packages": [
968 {"id": "a", "name": "a", "version": "1.0.0"},
969 {"id": "b", "name": "b", "version": "1.0.0"},
970 {"id": "x", "name": "x", "version": "1.0.0"},
971 {"id": "y", "name": "y", "version": "1.0.0"}
972 ],
973 "workspace_members": ["a", "b"],
974 "resolve": {"nodes": [
975 {"id": "a", "deps": [
976 {"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]},
977 {"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}
978 ]},
979 {"id": "b", "deps": [{"name": "a", "pkg": "a", "dep_kinds": [{"kind": "dev"}]}]},
980 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
981 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}]}
982 ]}
983 }"#;
984 parse_metadata(json).unwrap()
985 }
986
987 #[test]
988 fn cycles_members_only_scopes_to_workspace() {
989 let g = mixed_cycles();
990 let all = cycles(&g, &all_edges(), false);
992 assert_eq!(all.len(), 2);
993 assert_eq!(all[0].subject, "a, b");
994 assert_eq!(all[1].subject, "x, y");
995 let members = cycles(&g, &all_edges(), true);
997 assert_eq!(members.len(), 1);
998 assert_eq!(members[0].subject, "a, b");
999 }
1000
1001 fn layered_transitive() -> Graph {
1003 let json = r#"{
1004 "packages": [
1005 {"id": "api", "name": "api", "version": "1.0.0"},
1006 {"id": "svc", "name": "svc", "version": "1.0.0"},
1007 {"id": "db", "name": "db", "version": "1.0.0"}
1008 ],
1009 "workspace_members": ["api", "svc", "db"],
1010 "resolve": {"nodes": [
1011 {"id": "api", "deps": []},
1012 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
1013 {"id": "db", "deps": [{"name": "svc", "pkg": "svc", "dep_kinds": [{"kind": null}]}]}
1014 ]}
1015 }"#;
1016 parse_metadata(json).unwrap()
1017 }
1018
1019 #[test]
1020 fn layers_report_transitive_violations_per_member() {
1021 let g = layered_transitive();
1022 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
1023 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1024 let v = layer_violations(&g, &layers, &all_edges());
1025 let by: BTreeMap<&str, &str> =
1026 v.iter().map(|x| (x.subject.as_str(), x.evidence.as_str())).collect();
1027 assert_eq!(by["db => api"], "db v1.0.0 -> svc v1.0.0 -> api v1.0.0");
1029 assert_eq!(by["svc => api"], "svc v1.0.0 -> api v1.0.0");
1030 assert_eq!(by["db => svc"], "db v1.0.0 -> svc v1.0.0");
1031 assert_eq!(v.len(), 3);
1032 }
1033}