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(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 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
600pub fn check_flags() -> Vec<(String, &'static str)> {
604 flag_kinds(DepsCheck::command())
605}
606
607pub 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
720pub(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 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 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 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 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 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 assert!(cycles(&sample(), &all_edges(), false).is_empty());
850 }
851
852 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 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 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 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 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 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 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 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 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 let members = cycles(&g, &all_edges(), true);
974 assert_eq!(members.len(), 1);
975 assert_eq!(members[0].subject, "a, b");
976 }
977
978 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 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}