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, value_name = "NAME", value_delimiter = ',')]
581 allow_duplicate: Vec<String>,
582 #[arg(long)]
583 acyclic: bool,
584 #[arg(long)]
585 members: bool,
586 #[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
587 layers: Vec<String>,
588 #[arg(long)]
589 layers_closed: bool,
590 #[arg(long, value_enum, value_delimiter = ',')]
591 edges: Vec<EdgeKind>,
592}
593
594pub struct FlagSpec {
599 pub name: String,
600 pub kind: &'static str,
601 pub required: bool,
602 pub values: Vec<String>,
603}
604
605pub struct Grammar {
611 pub flags: Vec<FlagSpec>,
612 pub required: Vec<String>,
613}
614
615pub(crate) fn grammar(command: clap::Command) -> Grammar {
618 let mut flags = Vec::new();
619 let mut required = Vec::new();
620 for arg in command.get_arguments() {
621 let id = arg.get_id().as_str();
622 if matches!(id, "help" | "version" | "explain") {
623 continue;
624 }
625 let name = arg
628 .get_long()
629 .map(String::from)
630 .unwrap_or_else(|| id.to_string());
631 if arg.is_required_set() {
632 required.push(name.clone());
633 }
634 if arg.get_long().is_some() {
635 let kind = match arg.get_action() {
636 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "boolean",
637 clap::ArgAction::Append => "array",
638 _ => "string",
639 };
640 let values = arg
641 .get_possible_values()
642 .iter()
643 .map(|v| v.get_name().to_string())
644 .collect();
645 flags.push(FlagSpec {
646 name,
647 kind,
648 required: arg.is_required_set(),
649 values,
650 });
651 }
652 }
653 Grammar { flags, required }
654}
655
656pub fn check_grammar() -> Grammar {
658 grammar(DepsCheck::command())
659}
660
661pub fn check(
667 args: &[String],
668 root: &Path,
669 timeout: Option<Duration>,
670) -> (ProbeOutcome, String, String) {
671 let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
672 let cli = match DepsCheck::try_parse_from(args.iter().map(String::as_str)) {
673 Ok(c) => c,
674 Err(e) => {
675 let valid = check_grammar()
676 .flags
677 .iter()
678 .map(|s| format!("--{}", s.name))
679 .collect::<Vec<_>>()
680 .join(" ");
681 return broken(format!(
682 "deps: {} (valid flags: {valid})",
683 e.to_string().lines().next().unwrap_or("bad arguments")
684 ));
685 }
686 };
687 if cli.layers_closed && cli.layers.is_empty() {
688 return broken("deps: --layers-closed requires --layers".to_string());
689 }
690 if cli.members && !cli.acyclic {
691 return broken("deps: --members applies to --acyclic".to_string());
692 }
693 if cli.deny.is_empty()
694 && cli.forbid.is_empty()
695 && !cli.duplicates
696 && !cli.acyclic
697 && cli.layers.is_empty()
698 {
699 return broken(
700 "deps: nothing to assert (--deny/--forbid/--duplicates/--acyclic/--layers)".to_string(),
701 );
702 }
703 let allowed: HashSet<EdgeKind> = if cli.edges.is_empty() {
704 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
705 .into_iter()
706 .collect()
707 } else {
708 cli.edges.iter().copied().collect()
709 };
710 let forbids: Vec<(String, String)> = match cli
711 .forbid
712 .iter()
713 .map(|spec| {
714 spec.split_once("=>")
715 .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
716 .filter(|(a, b)| !a.is_empty() && !b.is_empty())
717 .ok_or_else(|| format!("deps: --forbid needs 'A=>B', got '{spec}'"))
718 })
719 .collect()
720 {
721 Ok(f) => f,
722 Err(e) => return broken(e),
723 };
724
725 let mut command = Command::new("cargo");
726 command
727 .args(["metadata", "--format-version", "1", "--locked", "--offline"])
728 .current_dir(root);
729 let outcome = match supervise::run_captured(command, None, timeout) {
730 Ok(o) => o,
731 Err(e) => return broken(format!("deps: cargo metadata: {e}")),
732 };
733 if outcome.timed_out {
734 return broken("deps: cargo metadata timed out".to_string());
735 }
736 if !outcome.status.is_some_and(|s| s.success()) {
737 return broken(format!(
738 "deps: cargo metadata failed: {}",
739 outcome.stderr.lines().last().unwrap_or("(no output)")
740 ));
741 }
742 let graph = match parse_metadata(&outcome.stdout) {
743 Ok(g) => g,
744 Err(e) => return broken(format!("deps: {e}")),
745 };
746
747 let mut violations: Vec<Violation> = Vec::new();
748 for name in &cli.deny {
749 violations.extend(deny_paths(&graph, name, &allowed));
750 }
751 for (from, to) in &forbids {
752 match forbid_path(&graph, from, to, &allowed) {
753 Ok(v) => violations.extend(v),
754 Err(e) => return broken(format!("deps: {e}")),
755 }
756 }
757 if cli.duplicates {
758 let allowed_dups: HashSet<&str> = cli.allow_duplicate.iter().map(String::as_str).collect();
759 for (name, versions) in graph.duplicates() {
760 if allowed_dups.contains(name.as_str()) {
761 continue; }
763 violations.push(Violation {
764 check: "duplicates".to_string(),
765 subject: name,
766 evidence: versions.join(", "),
767 });
768 }
769 }
770 if cli.acyclic {
771 violations.extend(cycles(&graph, &allowed, cli.members));
772 }
773 if !cli.layers.is_empty() {
774 let compiled = match cli
775 .layers
776 .iter()
777 .map(|p| pattern::compile_anchored(p))
778 .collect::<Result<Vec<_>, _>>()
779 {
780 Ok(c) => c,
781 Err(e) => return broken(format!("deps: --layers invalid pattern: {e}")),
782 };
783 let (layers, unassigned) =
784 match assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
785 Ok(r) => r,
786 Err(e) => return broken(format!("deps: --layers: {e}")),
787 };
788 violations.extend(layer_violations(&graph, &layers, &allowed));
789 if cli.layers_closed {
790 violations.extend(unassigned.into_iter().map(|name| Violation {
791 check: "layers-closed".to_string(),
792 subject: name,
793 evidence: "matches no layer".to_string(),
794 }));
795 }
796 }
797
798 report_outcome("deps", violations)
799}
800
801pub(crate) fn report_outcome(
804 kind: &str,
805 violations: Vec<Violation>,
806) -> (ProbeOutcome, String, String) {
807 let report = violations
808 .iter()
809 .map(|v| format!("{}: {}: {}", v.check, v.subject, v.evidence))
810 .collect::<Vec<_>>()
811 .join("\n");
812 if violations.is_empty() {
813 (
814 ProbeOutcome::Holds,
815 format!("{kind}: all assertions hold"),
816 report,
817 )
818 } else {
819 (
820 ProbeOutcome::Violated,
821 format!("{kind}: {} violation(s)", violations.len()),
822 report,
823 )
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 fn sample() -> Graph {
834 let json = r#"{
835 "packages": [
836 {"id": "app-id", "name": "app", "version": "0.1.0"},
837 {"id": "lib-id", "name": "lib", "version": "0.1.0"},
838 {"id": "leaf1-id", "name": "leaf", "version": "1.0.0"},
839 {"id": "leaf2-id", "name": "leaf", "version": "2.0.0"}
840 ],
841 "workspace_members": ["app-id"],
842 "resolve": {"nodes": [
843 {"id": "app-id", "deps": [
844 {"name": "lib", "pkg": "lib-id", "dep_kinds": [{"kind": null}]},
845 {"name": "leaf", "pkg": "leaf2-id", "dep_kinds": [{"kind": "dev"}]}
846 ]},
847 {"id": "lib-id", "deps": [
848 {"name": "leaf", "pkg": "leaf1-id", "dep_kinds": [{"kind": null}]}
849 ]},
850 {"id": "leaf1-id", "deps": []},
851 {"id": "leaf2-id", "deps": []}
852 ]}
853 }"#;
854 parse_metadata(json).unwrap()
855 }
856
857 fn all_edges() -> HashSet<EdgeKind> {
858 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev]
859 .into_iter()
860 .collect()
861 }
862
863 #[test]
864 fn deny_reports_an_evidence_path() {
865 let g = sample();
866 let v = deny_paths(&g, "leaf", &all_edges()).expect("leaf is reachable");
867 assert_eq!(v.check, "deny");
868 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
870 assert!(deny_paths(&g, "absent", &all_edges()).is_none());
871 }
872
873 #[test]
874 fn edge_kind_filter_changes_reachability() {
875 let g = sample();
876 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
877 let v = deny_paths(&g, "leaf", &normal).unwrap();
879 assert_eq!(v.evidence, "app v0.1.0 -> lib v0.1.0 -> leaf v1.0.0");
880 let dev_only: HashSet<EdgeKind> = [EdgeKind::Dev].into_iter().collect();
882 let v = deny_paths(&g, "leaf", &dev_only).unwrap();
883 assert_eq!(v.evidence, "app v0.1.0 -> leaf v2.0.0");
884 }
885
886 #[test]
887 fn forbid_requires_the_source_to_exist() {
888 let g = sample();
889 let v = forbid_path(&g, "lib", "leaf", &all_edges())
890 .unwrap()
891 .unwrap();
892 assert_eq!(v.subject, "lib=>leaf");
893 assert_eq!(v.evidence, "lib v0.1.0 -> leaf v1.0.0");
894 assert!(
895 forbid_path(&g, "lib", "app", &all_edges())
896 .unwrap()
897 .is_none()
898 );
899 assert!(forbid_path(&g, "ghost", "leaf", &all_edges()).is_err());
900 }
901
902 #[test]
903 fn duplicates_lists_versions() {
904 let g = sample();
905 let d = g.duplicates();
906 assert_eq!(d.len(), 1);
907 assert_eq!(d[0].0, "leaf");
908 assert_eq!(d[0].1, ["1.0.0", "2.0.0"]);
909 }
910
911 fn cyclic() -> Graph {
913 let json = r#"{
914 "packages": [
915 {"id": "a", "name": "a", "version": "1.0.0"},
916 {"id": "b", "name": "b", "version": "1.0.0"},
917 {"id": "c", "name": "c", "version": "1.0.0"},
918 {"id": "d", "name": "d", "version": "1.0.0"}
919 ],
920 "workspace_members": ["a"],
921 "resolve": {"nodes": [
922 {"id": "a", "deps": [{"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]}]},
923 {"id": "b", "deps": [{"name": "c", "pkg": "c", "dep_kinds": [{"kind": null}]}]},
924 {"id": "c", "deps": [
925 {"name": "a", "pkg": "a", "dep_kinds": [{"kind": null}]},
926 {"name": "d", "pkg": "d", "dep_kinds": [{"kind": null}]}
927 ]},
928 {"id": "d", "deps": []}
929 ]}
930 }"#;
931 parse_metadata(json).unwrap()
932 }
933
934 #[test]
935 fn cycles_report_a_concrete_loop() {
936 let g = cyclic();
937 let v = cycles(&g, &all_edges(), false);
938 assert_eq!(v.len(), 1);
939 assert_eq!(v[0].check, "acyclic");
940 assert_eq!(v[0].subject, "a, b, c");
941 assert_eq!(
942 v[0].evidence,
943 "a v1.0.0 -> b v1.0.0 -> c v1.0.0 -> a v1.0.0"
944 );
945 }
946
947 #[test]
948 fn acyclic_graph_has_no_cycles() {
949 assert!(cycles(&sample(), &all_edges(), false).is_empty());
951 }
952
953 fn cyclic_via_dev() -> Graph {
955 let json = r#"{
956 "packages": [
957 {"id": "x", "name": "x", "version": "1.0.0"},
958 {"id": "y", "name": "y", "version": "1.0.0"}
959 ],
960 "workspace_members": ["x"],
961 "resolve": {"nodes": [
962 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
963 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": "dev"}]}]}
964 ]}
965 }"#;
966 parse_metadata(json).unwrap()
967 }
968
969 #[test]
970 fn cycles_respect_edge_kinds() {
971 let g = cyclic_via_dev();
972 let normal: HashSet<EdgeKind> = [EdgeKind::Normal].into_iter().collect();
973 assert!(cycles(&g, &normal, false).is_empty());
974 assert_eq!(cycles(&g, &all_edges(), false).len(), 1);
975 }
976
977 fn layered() -> Graph {
979 let json = r#"{
980 "packages": [
981 {"id": "api", "name": "api", "version": "1.0.0"},
982 {"id": "svc", "name": "svc", "version": "1.0.0"},
983 {"id": "db", "name": "db", "version": "1.0.0"}
984 ],
985 "workspace_members": ["api", "svc", "db"],
986 "resolve": {"nodes": [
987 {"id": "api", "deps": []},
988 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
989 {"id": "db", "deps": []}
990 ]}
991 }"#;
992 parse_metadata(json).unwrap()
993 }
994
995 #[test]
996 fn layers_flag_a_lower_layer_reaching_a_higher_one() {
997 let g = layered();
998 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
999 let (layers, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1001 assert!(unassigned.is_empty());
1002 let v = layer_violations(&g, &layers, &all_edges());
1003 assert_eq!(v.len(), 1);
1004 assert_eq!(v[0].check, "layers");
1005 assert_eq!(v[0].subject, "svc => api");
1006 assert_eq!(v[0].evidence, "svc v1.0.0 -> api v1.0.0");
1007 }
1008
1009 #[test]
1010 fn layers_allow_top_down_dependencies() {
1011 let g = layered();
1013 let labels = vec!["svc".to_string(), "api".to_string(), "db".to_string()];
1014 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1015 assert!(layer_violations(&g, &layers, &all_edges()).is_empty());
1016 }
1017
1018 #[test]
1019 fn assign_layers_reports_unassigned_and_ambiguous() {
1020 let g = layered();
1021 let labels = vec!["api".to_string(), "svc".to_string()];
1023 let (_, unassigned) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1024 assert_eq!(unassigned, ["db"]);
1025 let two = vec!["api".to_string(), "api-again".to_string()];
1027 let err = assign_layers(&g, &two, |_, name| name == "api").unwrap_err();
1028 assert!(err.contains("multiple layers"), "got: {err}");
1029 }
1030
1031 #[test]
1032 fn assign_layers_rejects_an_empty_layer() {
1033 let g = layered();
1034 let labels = vec!["api".to_string(), "ghostlayer".to_string()];
1036 let err = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap_err();
1037 assert!(err.contains("matches nothing"), "got: {err}");
1038 }
1039
1040 fn mixed_cycles() -> Graph {
1043 let json = r#"{
1044 "packages": [
1045 {"id": "a", "name": "a", "version": "1.0.0"},
1046 {"id": "b", "name": "b", "version": "1.0.0"},
1047 {"id": "x", "name": "x", "version": "1.0.0"},
1048 {"id": "y", "name": "y", "version": "1.0.0"}
1049 ],
1050 "workspace_members": ["a", "b"],
1051 "resolve": {"nodes": [
1052 {"id": "a", "deps": [
1053 {"name": "b", "pkg": "b", "dep_kinds": [{"kind": null}]},
1054 {"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}
1055 ]},
1056 {"id": "b", "deps": [{"name": "a", "pkg": "a", "dep_kinds": [{"kind": "dev"}]}]},
1057 {"id": "x", "deps": [{"name": "y", "pkg": "y", "dep_kinds": [{"kind": null}]}]},
1058 {"id": "y", "deps": [{"name": "x", "pkg": "x", "dep_kinds": [{"kind": null}]}]}
1059 ]}
1060 }"#;
1061 parse_metadata(json).unwrap()
1062 }
1063
1064 #[test]
1065 fn cycles_members_only_scopes_to_workspace() {
1066 let g = mixed_cycles();
1067 let all = cycles(&g, &all_edges(), false);
1069 assert_eq!(all.len(), 2);
1070 assert_eq!(all[0].subject, "a, b");
1071 assert_eq!(all[1].subject, "x, y");
1072 let members = cycles(&g, &all_edges(), true);
1074 assert_eq!(members.len(), 1);
1075 assert_eq!(members[0].subject, "a, b");
1076 }
1077
1078 fn layered_transitive() -> Graph {
1080 let json = r#"{
1081 "packages": [
1082 {"id": "api", "name": "api", "version": "1.0.0"},
1083 {"id": "svc", "name": "svc", "version": "1.0.0"},
1084 {"id": "db", "name": "db", "version": "1.0.0"}
1085 ],
1086 "workspace_members": ["api", "svc", "db"],
1087 "resolve": {"nodes": [
1088 {"id": "api", "deps": []},
1089 {"id": "svc", "deps": [{"name": "api", "pkg": "api", "dep_kinds": [{"kind": null}]}]},
1090 {"id": "db", "deps": [{"name": "svc", "pkg": "svc", "dep_kinds": [{"kind": null}]}]}
1091 ]}
1092 }"#;
1093 parse_metadata(json).unwrap()
1094 }
1095
1096 #[test]
1097 fn layers_report_transitive_violations_per_member() {
1098 let g = layered_transitive();
1099 let labels = vec!["api".to_string(), "svc".to_string(), "db".to_string()];
1100 let (layers, _) = assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
1101 let v = layer_violations(&g, &layers, &all_edges());
1102 let by: BTreeMap<&str, &str> = v
1103 .iter()
1104 .map(|x| (x.subject.as_str(), x.evidence.as_str()))
1105 .collect();
1106 assert_eq!(by["db => api"], "db v1.0.0 -> svc v1.0.0 -> api v1.0.0");
1108 assert_eq!(by["svc => api"], "svc v1.0.0 -> api v1.0.0");
1109 assert_eq!(by["db => svc"], "db v1.0.0 -> svc v1.0.0");
1110 assert_eq!(v.len(), 3);
1111 }
1112}