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"]
74 .as_array()
75 .ok_or("metadata missing packages")?
76 {
77 let id = p["id"].as_str().ok_or("package missing id")?.to_string();
78 packages.insert(
79 id,
80 Package {
81 name: p["name"].as_str().unwrap_or("").to_string(),
82 version: p["version"].as_str().unwrap_or("").to_string(),
83 },
84 );
85 }
86 let members: Vec<String> = v["workspace_members"]
87 .as_array()
88 .ok_or("metadata missing workspace_members")?
89 .iter()
90 .filter_map(|m| m.as_str().map(String::from))
91 .collect();
92 let mut edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>> = HashMap::new();
93 let nodes = v["resolve"]["nodes"]
94 .as_array()
95 .ok_or("metadata missing resolve.nodes (was --no-deps used?)")?;
96 for node in nodes {
97 let id = node["id"].as_str().ok_or("node missing id")?.to_string();
98 let mut out = Vec::new();
99 for dep in node["deps"].as_array().unwrap_or(&Vec::new()) {
100 let pkg = dep["pkg"].as_str().unwrap_or("").to_string();
101 let kinds: Vec<EdgeKind> = dep["dep_kinds"]
102 .as_array()
103 .map(|ks| {
104 ks.iter()
105 .filter_map(|k| EdgeKind::from_metadata(&k["kind"]))
106 .collect()
107 })
108 .unwrap_or_else(|| vec![EdgeKind::Normal]);
109 out.push((pkg, kinds));
110 }
111 edges.insert(id, out);
112 }
113 Ok(Graph {
114 packages,
115 edges,
116 members,
117 })
118}
119
120impl Graph {
121 pub fn ids_named(&self, name: &str) -> Vec<&str> {
123 let mut ids: Vec<&str> = self
125 .edges
126 .keys()
127 .filter(|id| self.packages.get(*id).is_some_and(|p| p.name == name))
128 .map(String::as_str)
129 .collect();
130 ids.sort();
131 ids
132 }
133
134 fn label(&self, id: &str) -> String {
135 match self.packages.get(id) {
136 Some(p) if p.version.is_empty() => p.name.clone(),
138 Some(p) => format!("{} v{}", p.name, p.version),
139 None => id.to_string(),
140 }
141 }
142
143 fn successors<'a>(
147 &'a self,
148 node: &str,
149 allowed: &HashSet<EdgeKind>,
150 restrict: Option<&HashSet<&str>>,
151 ) -> Vec<&'a str> {
152 let mut s: Vec<&str> = self
153 .edges
154 .get(node)
155 .map(Vec::as_slice)
156 .unwrap_or(&[])
157 .iter()
158 .filter(|(_, kinds)| kinds.iter().any(|k| allowed.contains(k)))
159 .map(|(dep, _)| dep.as_str())
160 .filter(|dep| restrict.is_none_or(|r| r.contains(*dep)))
161 .collect();
162 s.sort_unstable();
163 s.dedup();
164 s
165 }
166
167 fn bfs_path(
171 &self,
172 starts: &[&str],
173 is_target: impl Fn(&str) -> bool,
174 allowed: &HashSet<EdgeKind>,
175 ) -> Option<Vec<String>> {
176 let mut parent: HashMap<&str, &str> = HashMap::new();
177 let mut queue: VecDeque<&str> = VecDeque::new();
178 let mut seen: HashSet<&str> = HashSet::new();
179 for s in starts {
180 seen.insert(s);
181 queue.push_back(s);
182 }
183 while let Some(id) = queue.pop_front() {
184 if !starts.contains(&id) && is_target(id) {
185 let mut path = vec![id];
186 let mut cur = id;
187 while let Some(&p) = parent.get(cur) {
188 path.push(p);
189 cur = p;
190 }
191 path.reverse();
192 return Some(path.iter().map(|i| self.label(i)).collect());
193 }
194 for (dep, kinds) in self.edges.get(id).map(Vec::as_slice).unwrap_or(&[]) {
195 if !kinds.iter().any(|k| allowed.contains(k)) {
196 continue;
197 }
198 let dep: &str = dep.as_str();
199 if seen.insert(dep) {
200 parent.insert(dep, id);
201 queue.push_back(dep);
202 }
203 }
204 }
205 None
206 }
207
208 pub fn path_to(
211 &self,
212 starts: &[&str],
213 target: &str,
214 allowed: &HashSet<EdgeKind>,
215 ) -> Option<Vec<String>> {
216 self.bfs_path(
217 starts,
218 |id| self.packages.get(id).is_some_and(|p| p.name == target),
219 allowed,
220 )
221 }
222
223 fn path_to_ids(
225 &self,
226 starts: &[&str],
227 targets: &HashSet<&str>,
228 allowed: &HashSet<EdgeKind>,
229 ) -> Option<Vec<String>> {
230 self.bfs_path(starts, |id| targets.contains(id), allowed)
231 }
232
233 fn strongly_connected<'a>(
239 &'a self,
240 allowed: &HashSet<EdgeKind>,
241 restrict: Option<&HashSet<&str>>,
242 ) -> Vec<Vec<String>> {
243 struct Frame<'f> {
244 node: &'f str,
245 succ: Vec<&'f str>,
246 next: usize,
247 }
248 let mut index: HashMap<&str, usize> = HashMap::new();
249 let mut low: HashMap<&str, usize> = HashMap::new();
250 let mut on_stack: HashSet<&str> = HashSet::new();
251 let mut stack: Vec<&str> = Vec::new();
252 let mut counter = 0usize;
253 let mut sccs: Vec<Vec<String>> = Vec::new();
254
255 let mut roots: Vec<&str> = self
256 .edges
257 .keys()
258 .map(String::as_str)
259 .filter(|id| restrict.is_none_or(|r| r.contains(*id)))
260 .collect();
261 roots.sort_unstable();
262
263 for root in roots {
264 if index.contains_key(root) {
265 continue;
266 }
267 index.insert(root, counter);
268 low.insert(root, counter);
269 counter += 1;
270 stack.push(root);
271 on_stack.insert(root);
272 let mut work: Vec<Frame<'a>> = vec![Frame {
273 node: root,
274 succ: self.successors(root, allowed, restrict),
275 next: 0,
276 }];
277 while let Some(top) = work.len().checked_sub(1) {
278 let (node, next, len) = {
279 let f = &work[top];
280 (f.node, f.next, f.succ.len())
281 };
282 if next < len {
283 let w = work[top].succ[next];
284 work[top].next += 1;
285 if !index.contains_key(w) {
286 index.insert(w, counter);
287 low.insert(w, counter);
288 counter += 1;
289 stack.push(w);
290 on_stack.insert(w);
291 let succ = self.successors(w, allowed, restrict);
292 work.push(Frame {
293 node: w,
294 succ,
295 next: 0,
296 });
297 } else if on_stack.contains(w) {
298 let lw = index[w];
299 let e = low.get_mut(node).unwrap();
300 *e = (*e).min(lw);
301 }
302 } else {
303 let node_low = low[node];
304 if node_low == index[node] {
305 let mut comp = Vec::new();
306 loop {
307 let w = stack.pop().unwrap();
308 on_stack.remove(w);
309 comp.push(w.to_string());
310 if w == node {
311 break;
312 }
313 }
314 sccs.push(comp);
315 }
316 work.pop();
317 if let Some(parent) = work.last() {
318 let p = parent.node;
319 let e = low.get_mut(p).unwrap();
320 *e = (*e).min(node_low);
321 }
322 }
323 }
324 }
325 sccs
326 }
327
328 fn shortest_cycle_through(
331 &self,
332 start: &str,
333 scc: &HashSet<&str>,
334 allowed: &HashSet<EdgeKind>,
335 ) -> Vec<String> {
336 let neighbors = |id: &str| -> Vec<String> {
337 self.edges
338 .get(id)
339 .map(Vec::as_slice)
340 .unwrap_or(&[])
341 .iter()
342 .filter(|(dep, kinds)| {
343 scc.contains(dep.as_str()) && kinds.iter().any(|k| allowed.contains(k))
344 })
345 .map(|(dep, _)| dep.clone())
346 .collect()
347 };
348 if neighbors(start).iter().any(|n| n == start) {
349 return vec![self.label(start), self.label(start)];
350 }
351 let mut parent: HashMap<String, String> = HashMap::new();
352 let mut seen: HashSet<String> = HashSet::new();
353 let mut queue: VecDeque<String> = VecDeque::new();
354 seen.insert(start.to_string());
355 for n in neighbors(start) {
356 if seen.insert(n.clone()) {
357 parent.insert(n.clone(), start.to_string());
358 queue.push_back(n);
359 }
360 }
361 while let Some(u) = queue.pop_front() {
362 for n in neighbors(&u) {
363 if n == start {
364 let mut chain = vec![u.clone()];
365 let mut cur = u.clone();
366 while let Some(p) = parent.get(&cur) {
367 chain.push(p.clone());
368 cur = p.clone();
369 }
370 chain.reverse();
371 chain.push(start.to_string());
372 return chain.iter().map(|i| self.label(i)).collect();
373 }
374 if seen.insert(n.clone()) {
375 parent.insert(n.clone(), u.clone());
376 queue.push_back(n);
377 }
378 }
379 }
380 vec![self.label(start)]
381 }
382
383 pub fn duplicates(&self) -> Vec<(String, Vec<String>)> {
385 let mut by_name: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
386 for id in self.edges.keys() {
387 if let Some(p) = self.packages.get(id) {
388 by_name.entry(&p.name).or_default().insert(&p.version);
389 }
390 }
391 by_name
392 .into_iter()
393 .filter(|(_, versions)| versions.len() > 1)
394 .map(|(name, versions)| {
395 let mut v: Vec<String> = versions.into_iter().map(String::from).collect();
396 v.sort();
397 (name.to_string(), v)
398 })
399 .collect()
400 }
401}
402
403#[derive(Debug)]
405pub struct Violation {
406 pub check: String,
408 pub subject: String,
410 pub evidence: String,
412}
413
414pub fn deny_paths(graph: &Graph, name: &str, allowed: &HashSet<EdgeKind>) -> Option<Violation> {
417 let members: Vec<&str> = graph.members.iter().map(String::as_str).collect();
418 graph
419 .path_to(&members, name, allowed)
420 .map(|path| Violation {
421 check: "deny".to_string(),
422 subject: name.to_string(),
423 evidence: path.join(" -> "),
424 })
425}
426
427pub fn forbid_path(
431 graph: &Graph,
432 from: &str,
433 to: &str,
434 allowed: &HashSet<EdgeKind>,
435) -> Result<Option<Violation>, String> {
436 let starts = graph.ids_named(from);
437 if starts.is_empty() {
438 return Err(format!("--forbid: no package named '{from}' in the graph"));
439 }
440 Ok(graph.path_to(&starts, to, allowed).map(|path| Violation {
441 check: "forbid".to_string(),
442 subject: format!("{from}=>{to}"),
443 evidence: path.join(" -> "),
444 }))
445}
446
447pub fn cycles(graph: &Graph, allowed: &HashSet<EdgeKind>, members_only: bool) -> Vec<Violation> {
455 let member_set: HashSet<&str> = graph.members.iter().map(String::as_str).collect();
456 let restrict = members_only.then_some(&member_set);
457 let mut out = Vec::new();
458 for scc in graph.strongly_connected(allowed, restrict) {
459 let self_loop = scc.len() == 1
460 && graph
461 .edges
462 .get(&scc[0])
463 .map(Vec::as_slice)
464 .unwrap_or(&[])
465 .iter()
466 .any(|(dep, kinds)| dep == &scc[0] && kinds.iter().any(|k| allowed.contains(k)));
467 if scc.len() < 2 && !self_loop {
468 continue;
469 }
470 let set: HashSet<&str> = scc.iter().map(String::as_str).collect();
471 let start = scc.iter().min().map(String::as_str).expect("non-empty scc");
472 let evidence = graph
473 .shortest_cycle_through(start, &set, allowed)
474 .join(" -> ");
475 let mut names: Vec<&str> = scc
476 .iter()
477 .filter_map(|id| graph.packages.get(id).map(|p| p.name.as_str()))
478 .collect();
479 names.sort_unstable();
480 names.dedup();
481 out.push(Violation {
482 check: "acyclic".to_string(),
483 subject: names.join(", "),
484 evidence,
485 });
486 }
487 out.sort_by(|a, b| a.subject.cmp(&b.subject));
488 out
489}
490
491pub type Layer = (String, Vec<String>);
494
495pub fn assign_layers(
500 graph: &Graph,
501 labels: &[String],
502 matches: impl Fn(usize, &str) -> bool,
503) -> Result<(Vec<Layer>, Vec<String>), String> {
504 let mut layers: Vec<Layer> = labels.iter().map(|l| (l.clone(), Vec::new())).collect();
505 let mut unassigned = Vec::new();
506 for id in &graph.members {
507 let name = graph
508 .packages
509 .get(id)
510 .map(|p| p.name.as_str())
511 .unwrap_or("");
512 let hit: Vec<usize> = (0..labels.len()).filter(|&i| matches(i, name)).collect();
513 match hit.as_slice() {
514 [] => unassigned.push(name.to_string()),
515 [one] => layers[*one].1.push(id.clone()),
516 many => {
517 return Err(format!(
518 "crate '{name}' matches multiple layers ({})",
519 many.iter()
520 .map(|&m| labels[m].as_str())
521 .collect::<Vec<_>>()
522 .join(", ")
523 ));
524 }
525 }
526 }
527 if let Some((label, _)) = layers.iter().find(|(_, ids)| ids.is_empty()) {
531 return Err(format!("layer '{label}' matches nothing"));
532 }
533 Ok((layers, unassigned))
534}
535
536pub fn layer_violations(
542 graph: &Graph,
543 layers: &[Layer],
544 allowed: &HashSet<EdgeKind>,
545) -> Vec<Violation> {
546 let mut out = Vec::new();
547 for lower in 0..layers.len() {
548 for higher in 0..lower {
549 let targets: HashSet<&str> = layers[higher].1.iter().map(String::as_str).collect();
550 if targets.is_empty() {
551 continue;
552 }
553 for start in &layers[lower].1 {
554 if let Some(path) = graph.path_to_ids(&[start.as_str()], &targets, allowed) {
555 out.push(Violation {
556 check: "layers".to_string(),
557 subject: format!("{} => {}", layers[lower].0, layers[higher].0),
558 evidence: path.join(" -> "),
559 });
560 }
561 }
562 }
563 }
564 out
565}
566
567#[derive(Parser, Debug)]
572#[command(no_binary_name = true, disable_help_flag = true)]
573struct DepsCheck {
574 #[arg(long, value_name = "NAME")]
575 deny: Vec<String>,
576 #[arg(long, value_name = "A=>B")]
577 forbid: Vec<String>,
578 #[arg(long)]
579 duplicates: bool,
580 #[arg(long)]
581 acyclic: bool,
582 #[arg(long)]
583 members: bool,
584 #[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
585 layers: Vec<String>,
586 #[arg(long)]
587 layers_closed: bool,
588 #[arg(long, value_enum, value_delimiter = ',')]
589 edges: Vec<EdgeKind>,
590}
591
592pub struct FlagSpec {
597 pub name: String,
598 pub kind: &'static str,
599 pub required: bool,
600 pub values: Vec<String>,
601}
602
603pub struct Grammar {
609 pub flags: Vec<FlagSpec>,
610 pub required: Vec<String>,
611}
612
613pub(crate) fn grammar(command: clap::Command) -> Grammar {
616 let mut flags = Vec::new();
617 let mut required = Vec::new();
618 for arg in command.get_arguments() {
619 let id = arg.get_id().as_str();
620 if matches!(id, "help" | "version" | "explain") {
621 continue;
622 }
623 let name = arg
626 .get_long()
627 .map(String::from)
628 .unwrap_or_else(|| id.to_string());
629 if arg.is_required_set() {
630 required.push(name.clone());
631 }
632 if arg.get_long().is_some() {
633 let kind = match arg.get_action() {
634 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "boolean",
635 clap::ArgAction::Append => "array",
636 _ => "string",
637 };
638 let values = arg
639 .get_possible_values()
640 .iter()
641 .map(|v| v.get_name().to_string())
642 .collect();
643 flags.push(FlagSpec {
644 name,
645 kind,
646 required: arg.is_required_set(),
647 values,
648 });
649 }
650 }
651 Grammar { flags, required }
652}
653
654pub fn check_grammar() -> Grammar {
656 grammar(DepsCheck::command())
657}
658
659pub fn check(
665 args: &[String],
666 root: &Path,
667 timeout: Option<Duration>,
668) -> (ProbeOutcome, String, String) {
669 let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
670 let cli = match DepsCheck::try_parse_from(args.iter().map(String::as_str)) {
671 Ok(c) => c,
672 Err(e) => {
673 let valid = check_grammar()
674 .flags
675 .iter()
676 .map(|s| format!("--{}", s.name))
677 .collect::<Vec<_>>()
678 .join(" ");
679 return broken(format!(
680 "deps: {} (valid flags: {valid})",
681 e.to_string().lines().next().unwrap_or("bad arguments")
682 ));
683 }
684 };
685 if cli.layers_closed && cli.layers.is_empty() {
686 return broken("deps: --layers-closed requires --layers".to_string());
687 }
688 if cli.members && !cli.acyclic {
689 return broken("deps: --members applies to --acyclic".to_string());
690 }
691 if cli.deny.is_empty()
692 && cli.forbid.is_empty()
693 && !cli.duplicates
694 && !cli.acyclic
695 && cli.layers.is_empty()
696 {
697 return broken(
698 "deps: nothing to assert (--deny/--forbid/--duplicates/--acyclic/--layers)".to_string(),
699 );
700 }
701 let allowed: HashSet<EdgeKind> = if cli.edges.is_empty() {
702 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
703 .into_iter()
704 .collect()
705 } else {
706 cli.edges.iter().copied().collect()
707 };
708 let forbids: Vec<(String, String)> = match cli
709 .forbid
710 .iter()
711 .map(|spec| {
712 spec.split_once("=>")
713 .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
714 .filter(|(a, b)| !a.is_empty() && !b.is_empty())
715 .ok_or_else(|| format!("deps: --forbid needs 'A=>B', got '{spec}'"))
716 })
717 .collect()
718 {
719 Ok(f) => f,
720 Err(e) => return broken(e),
721 };
722
723 let mut command = Command::new("cargo");
724 command
725 .args(["metadata", "--format-version", "1", "--locked", "--offline"])
726 .current_dir(root);
727 let outcome = match supervise::run_captured(command, None, timeout) {
728 Ok(o) => o,
729 Err(e) => return broken(format!("deps: cargo metadata: {e}")),
730 };
731 if outcome.timed_out {
732 return broken("deps: cargo metadata timed out".to_string());
733 }
734 if !outcome.status.is_some_and(|s| s.success()) {
735 return broken(format!(
736 "deps: cargo metadata failed: {}",
737 outcome.stderr.lines().last().unwrap_or("(no output)")
738 ));
739 }
740 let graph = match parse_metadata(&outcome.stdout) {
741 Ok(g) => g,
742 Err(e) => return broken(format!("deps: {e}")),
743 };
744
745 let mut violations: Vec<Violation> = Vec::new();
746 for name in &cli.deny {
747 violations.extend(deny_paths(&graph, name, &allowed));
748 }
749 for (from, to) in &forbids {
750 match forbid_path(&graph, from, to, &allowed) {
751 Ok(v) => violations.extend(v),
752 Err(e) => return broken(format!("deps: {e}")),
753 }
754 }
755 if cli.duplicates {
756 for (name, versions) in graph.duplicates() {
757 violations.push(Violation {
758 check: "duplicates".to_string(),
759 subject: name,
760 evidence: versions.join(", "),
761 });
762 }
763 }
764 if cli.acyclic {
765 violations.extend(cycles(&graph, &allowed, cli.members));
766 }
767 if !cli.layers.is_empty() {
768 let compiled = match cli
769 .layers
770 .iter()
771 .map(|p| pattern::compile_anchored(p))
772 .collect::<Result<Vec<_>, _>>()
773 {
774 Ok(c) => c,
775 Err(e) => return broken(format!("deps: --layers invalid pattern: {e}")),
776 };
777 let (layers, unassigned) =
778 match assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
779 Ok(r) => r,
780 Err(e) => return broken(format!("deps: --layers: {e}")),
781 };
782 violations.extend(layer_violations(&graph, &layers, &allowed));
783 if cli.layers_closed {
784 violations.extend(unassigned.into_iter().map(|name| Violation {
785 check: "layers-closed".to_string(),
786 subject: name,
787 evidence: "matches no layer".to_string(),
788 }));
789 }
790 }
791
792 report_outcome("deps", violations)
793}
794
795pub(crate) fn report_outcome(
798 kind: &str,
799 violations: Vec<Violation>,
800) -> (ProbeOutcome, String, String) {
801 let report = violations
802 .iter()
803 .map(|v| format!("{}: {}: {}", v.check, v.subject, v.evidence))
804 .collect::<Vec<_>>()
805 .join("\n");
806 if violations.is_empty() {
807 (
808 ProbeOutcome::Holds,
809 format!("{kind}: all assertions hold"),
810 report,
811 )
812 } else {
813 (
814 ProbeOutcome::Violated,
815 format!("{kind}: {} violation(s)", violations.len()),
816 report,
817 )
818 }
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824
825 fn sample() -> Graph {
828 let json = r#"{
829 "packages": [
830 {"id": "app-id", "name": "app", "version": "0.1.0"},
831 {"id": "lib-id", "name": "lib", "version": "0.1.0"},
832 {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
833 {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
834 ],
835 "workspace_members": ["app-id"],
836 "resolve": {"nodes": [
837 {"id": "app-id", "deps": [
838 {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
839 {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
840 ]},
841 {"id": "lib-id", "deps": [
842 {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
843 ]},
844 {"id": "leaf1-id", "deps": []},
845 {"id": "leaf2-id", "deps": []}
846 ]}
847 }"#;
848 parse_metadata(json).unwrap()
849 }
850
851 fn all_edges() -> HashSet<EdgeKind> {
852 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
853 .into_iter()
854 .collect()
855 }
856
857 #[test]
858 fn deny_reports_an_evidence_path() {
859 let g = sample();
860 let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
861 assert_eq!(v.check, "deny");
862 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
864 assert!(deny_paths(&g, "absent", &all_edges()).is_none());
865 }
866
867 #[test]
868 fn edge_kind_filter_changes_reachability() {
869 let g = sample();
870 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
871 let v = deny_paths(&g, "leaf", &normal).unwrap();
873 assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
874 let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
876 let v = deny_paths(&g, "leaf", &dev_only).unwrap();
877 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
878 }
879
880 #[test]
881 fn forbid_requires_the_source_to_exist() {
882 let g = sample();
883 let v = forbid_path(&g, "lib", "leaf", &all_edges())
884 .unwrap()
885 .unwrap();
886 assert_eq!(v.subject, "lib=>leaf");
887 assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
888 assert!(
889 forbid_path(&g, "lib", "app", &all_edges())
890 .unwrap()
891 .is_none()
892 );
893 assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
894 }
895
896 #[test]
897 fn duplicates_lists_versions() {
898 let g = sample();
899 let d = g.duplicates();
900 assert_eq!(d.len(), 1);
901 assert_eq!(d[0].0, "leaf");
902 assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
903 }
904
905 fn cyclic() -> Graph {
907 let json = r#"{
908 "packages": [
909 {"id": "a", "name": "a", "version": "1.0.0"},
910 {"id": "b", "name": "b", "version": "1.0.0"},
911 {"id": "c", "name": "c", "version": "1.0.0"},
912 {"id": "d", "name": "d", "version": "1.0.0"}
913 ],
914 "workspace_members": ["a"],
915 "resolve": {"nodes": [
916 {"id": "a", "deps": [{"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]}]},
917 {"id": "b", "deps": [{"name": "c", "pkg": "c", "dep_kinds": [{"kind": null}]}]},
918 {"id": "c", "deps": [
919 {"name": "a", "pkg": "a", "dep_kinds": [{"kind": null}]},
920 {"name": "d", "pkg": "d", "dep_kinds": [{"kind": null}]}
921 ]},
922 {"id": "d", "deps": []}
923 ]}
924 }"#;
925 parse_metadata(json).unwrap()
926 }
927
928 #[test]
929 fn cycles_report_a_concrete_loop() {
930 let g = cyclic();
931 let v = cycles(&g, &all_edges(), false);
932 assert_eq!(v.len(), 1);
933 assert_eq!(v[0].check, "acyclic");
934 assert_eq!(v[0].subject, "a, b, c");
935 assert_eq!(
936 v[0].evidence,
937 "a v1.0.0 -> b v1.0.0 -> c v1.0.0 -> a v1.0.0"
938 );
939 }
940
941 #[test]
942 fn acyclic_graph_has_no_cycles() {
943 assert!(cycles(&sample(), &all_edges(), false).is_empty());
945 }
946
947 fn cyclic_via_dev() -> Graph {
949 let json = r#"{
950 "packages": [
951 {"id": "x", "name": "x", "version": "1.0.0"},
952 {"id": "y", "name": "y", "version": "1.0.0"}
953 ],
954 "workspace_members": ["x"],
955 "resolve": {"nodes": [
956 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
957 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": "dev"}]}]}
958 ]}
959 }"#;
960 parse_metadata(json).unwrap()
961 }
962
963 #[test]
964 fn cycles_respect_edge_kinds() {
965 let g = cyclic_via_dev();
966 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
967 assert!(cycles(&g, &normal, false).is_empty());
968 assert_eq!(cycles(&g, &all_edges(), false).len(), 1);
969 }
970
971 fn layered() -> Graph {
973 let json = r#"{
974 "packages": [
975 {"id": "api", "name": "api", "version": "1.0.0"},
976 {"id": "svc", "name": "svc", "version": "1.0.0"},
977 {"id": "db", "name": "db", "version": "1.0.0"}
978 ],
979 "workspace_members": ["api", "svc", "db"],
980 "resolve": {"nodes": [
981 {"id": "api", "deps": []},
982 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
983 {"id": "db", "deps": []}
984 ]}
985 }"#;
986 parse_metadata(json).unwrap()
987 }
988
989 #[test]
990 fn layers_flag_a_lower_layer_reaching_a_higher_one() {
991 let g = layered();
992 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
993 let (layers, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
995 assert!(unassigned.is_empty());
996 let v = layer_violations(&g, &layers, &all_edges());
997 assert_eq!(v.len(), 1);
998 assert_eq!(v[0].check, "layers");
999 assert_eq!(v[0].subject, "svc => api");
1000 assert_eq!(v[0].evidence, "svc v1.0.0 -> api v1.0.0");
1001 }
1002
1003 #[test]
1004 fn layers_allow_top_down_dependencies() {
1005 let g = layered();
1007 let labels = vec!["svc".to_string(), "api".to_string(), "db".to_string()];
1008 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1009 assert!(layer_violations(&g, &layers, &all_edges()).is_empty());
1010 }
1011
1012 #[test]
1013 fn assign_layers_reports_unassigned_and_ambiguous() {
1014 let g = layered();
1015 let labels = vec!["api".to_string(), "svc".to_string()];
1017 let (_, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1018 assert_eq!(unassigned, ["db"]);
1019 let two = vec!["api".to_string(), "api-again".to_string()];
1021 let err = assign_layers(&g, &two, |_, name| name == "api").unwrap_err();
1022 assert!(err.contains("multiple layers"), "got: {err}");
1023 }
1024
1025 #[test]
1026 fn assign_layers_rejects_an_empty_layer() {
1027 let g = layered();
1028 let labels = vec!["api".to_string(), "ghostlayer".to_string()];
1030 let err = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap_err();
1031 assert!(err.contains("matches nothing"), "got: {err}");
1032 }
1033
1034 fn mixed_cycles() -> Graph {
1037 let json = r#"{
1038 "packages": [
1039 {"id": "a", "name": "a", "version": "1.0.0"},
1040 {"id": "b", "name": "b", "version": "1.0.0"},
1041 {"id": "x", "name": "x", "version": "1.0.0"},
1042 {"id": "y", "name": "y", "version": "1.0.0"}
1043 ],
1044 "workspace_members": ["a", "b"],
1045 "resolve": {"nodes": [
1046 {"id": "a", "deps": [
1047 {"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]},
1048 {"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}
1049 ]},
1050 {"id": "b", "deps": [{"name": "a", "pkg": "a", "dep_kinds": [{"kind": "dev"}]}]},
1051 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
1052 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}]}
1053 ]}
1054 }"#;
1055 parse_metadata(json).unwrap()
1056 }
1057
1058 #[test]
1059 fn cycles_members_only_scopes_to_workspace() {
1060 let g = mixed_cycles();
1061 let all = cycles(&g, &all_edges(), false);
1063 assert_eq!(all.len(), 2);
1064 assert_eq!(all[0].subject, "a, b");
1065 assert_eq!(all[1].subject, "x, y");
1066 let members = cycles(&g, &all_edges(), true);
1068 assert_eq!(members.len(), 1);
1069 assert_eq!(members[0].subject, "a, b");
1070 }
1071
1072 fn layered_transitive() -> Graph {
1074 let json = r#"{
1075 "packages": [
1076 {"id": "api", "name": "api", "version": "1.0.0"},
1077 {"id": "svc", "name": "svc", "version": "1.0.0"},
1078 {"id": "db", "name": "db", "version": "1.0.0"}
1079 ],
1080 "workspace_members": ["api", "svc", "db"],
1081 "resolve": {"nodes": [
1082 {"id": "api", "deps": []},
1083 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
1084 {"id": "db", "deps": [{"name": "svc", "pkg": "svc", "dep_kinds": [{"kind": null}]}]}
1085 ]}
1086 }"#;
1087 parse_metadata(json).unwrap()
1088 }
1089
1090 #[test]
1091 fn layers_report_transitive_violations_per_member() {
1092 let g = layered_transitive();
1093 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
1094 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1095 let v = layer_violations(&g, &layers, &all_edges());
1096 let by: BTreeMap<&str, &str> = v
1097 .iter()
1098 .map(|x| (x.subject.as_str(), x.evidence.as_str()))
1099 .collect();
1100 assert_eq!(by["db => api"], "db v1.0.0 -> svc v1.0.0 -> api v1.0.0");
1102 assert_eq!(by["svc => api"], "svc v1.0.0 -> api v1.0.0");
1103 assert_eq!(by["db => svc"], "db v1.0.0 -> svc v1.0.0");
1104 assert_eq!(v.len(), 3);
1105 }
1106}