Skip to content

Commit 78dabf7

Browse files
authored
feat(cargo-mono): improve diagnostics and bump to 0.5.3 (#303)
## Summary - improve cargo-mono dependency-cycle diagnostics with SCC-based cycle package detection - add unresolved/cycle/dependency-scope context fields and structured warn logs for ordering conflicts - enrich cargo metadata load failures with working directory and metadata command context - add coverage for dev-dependency cycle diagnostics and metadata error context - bump cargo-mono patch version from 0.5.2 to 0.5.3 and sync Cargo.lock ## Testing - cargo test -p cargo-mono - cargo test
1 parent 4e1d016 commit 78dabf7

7 files changed

Lines changed: 322 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cargo-mono/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cargo-mono"
3-
version = "0.5.2"
3+
version = "0.5.3"
44
edition = "2021"
55
license = "MIT"
66
description = "Cargo subcommand for Rust monorepo management"

crates/cargo-mono/src/errors.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,21 @@ impl From<io::Error> for CargoMonoError {
102102

103103
impl From<cargo_metadata::Error> for CargoMonoError {
104104
fn from(value: cargo_metadata::Error) -> Self {
105+
let working_directory = std::env::current_dir()
106+
.map(|path| path.display().to_string())
107+
.unwrap_or_else(|error| format!("<unavailable:{error}>"));
108+
105109
Self::with_details(
106110
ErrorKind::Cargo,
107111
"Failed to load workspace metadata via cargo.",
108-
vec![("error", value.to_string())],
112+
vec![
113+
("working_directory", working_directory),
114+
(
115+
"metadata_command",
116+
"cargo metadata --format-version 1".to_string(),
117+
),
118+
("error", value.to_string()),
119+
],
109120
"Run `cargo metadata` in this workspace to reproduce and inspect the root cause.",
110121
)
111122
}

crates/cargo-mono/src/workspace.rs

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ use cargo_metadata::{MetadataCommand, PackageId};
77
use globset::{Glob, GlobSet, GlobSetBuilder};
88
use semver::Version;
99
use serde::Serialize;
10+
use tracing::warn;
1011

1112
use crate::errors::{CargoMonoError, ErrorKind, Result};
1213

1314
pub const GLOBAL_IMPACT_FILES: [&str; 3] = ["Cargo.toml", "Cargo.lock", "rust-toolchain"];
15+
const DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS: &str = "all-cargo-metadata-kinds";
1416

1517
#[derive(Debug, Clone, Serialize)]
1618
pub struct WorkspacePackage {
@@ -302,21 +304,165 @@ impl Workspace {
302304
if ordered.len() != selected.len() {
303305
let selected_names = selected.iter().cloned().collect::<Vec<_>>();
304306
let selected_sample = format_string_sample(&selected_names, 8);
307+
let unresolved_names = indegree
308+
.iter()
309+
.filter_map(|(name, degree)| {
310+
if *degree > 0 {
311+
Some(name.clone())
312+
} else {
313+
None
314+
}
315+
})
316+
.collect::<Vec<_>>();
317+
let unresolved_sample = format_string_sample(&unresolved_names, 8);
318+
let unresolved = unresolved_names.iter().cloned().collect::<BTreeSet<_>>();
319+
let cycle_packages = self.cycle_packages_for_nodes(&unresolved);
320+
let cycle_packages_sample = format_string_sample(&cycle_packages, 8);
321+
322+
warn!(
323+
command_path = "cargo-mono.workspace",
324+
workspace_root = %self.root.display(),
325+
action = "build-topological-order",
326+
outcome = "failed-cycle",
327+
selected_count = selected.len(),
328+
unresolved_count = unresolved_names.len(),
329+
cycle_package_count = cycle_packages.len(),
330+
dependency_scope = DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS,
331+
selected_sample = %selected_sample,
332+
unresolved_sample = %unresolved_sample,
333+
cycle_packages = %cycle_packages_sample,
334+
"Failed to build package order due to a dependency cycle in selected packages"
335+
);
336+
305337
return Err(CargoMonoError::with_details(
306338
ErrorKind::Conflict,
307339
"Failed to build package order due to a dependency cycle in selected packages.",
308340
vec![
309341
("selected_count", selected.len().to_string()),
310342
("selected_sample", selected_sample),
343+
("unresolved_count", unresolved_names.len().to_string()),
344+
("unresolved_sample", unresolved_sample),
345+
("cycle_package_count", cycle_packages.len().to_string()),
346+
("cycle_packages", cycle_packages_sample),
347+
(
348+
"dependency_scope",
349+
DEPENDENCY_SCOPE_ALL_CARGO_METADATA_KINDS.to_string(),
350+
),
311351
],
312352
"Break the cycle between selected packages, or narrow the target set with \
313-
`--package`/`--changed`.",
353+
`--package`/`--changed`. The strict dependency scope includes normal, build, and \
354+
dev dependencies from cargo metadata.",
314355
));
315356
}
316357

317358
Ok(ordered)
318359
}
319360

361+
fn cycle_packages_for_nodes(&self, nodes: &BTreeSet<String>) -> Vec<String> {
362+
let mut detector = CycleDetector::default();
363+
for node in nodes {
364+
if detector.indices.contains_key(node) {
365+
continue;
366+
}
367+
368+
self.strong_connect_cycle_node(node, nodes, &mut detector);
369+
}
370+
371+
detector.cycle_packages.into_iter().collect()
372+
}
373+
374+
fn strong_connect_cycle_node(
375+
&self,
376+
node: &str,
377+
nodes: &BTreeSet<String>,
378+
detector: &mut CycleDetector,
379+
) {
380+
let node = node.to_string();
381+
detector.indices.insert(node.clone(), detector.next_index);
382+
detector.lowlinks.insert(node.clone(), detector.next_index);
383+
detector.next_index += 1;
384+
detector.stack.push(node.clone());
385+
detector.on_stack.insert(node.clone());
386+
387+
if let Some(dependencies) = self.dependencies.get(&node) {
388+
for dependency in dependencies {
389+
if !nodes.contains(dependency) {
390+
continue;
391+
}
392+
393+
if !detector.indices.contains_key(dependency) {
394+
self.strong_connect_cycle_node(dependency, nodes, detector);
395+
let dependency_lowlink = detector
396+
.lowlinks
397+
.get(dependency)
398+
.copied()
399+
.expect("dependency lowlink must exist after DFS traversal");
400+
let node_lowlink = detector
401+
.lowlinks
402+
.get(&node)
403+
.copied()
404+
.expect("node lowlink must exist");
405+
if dependency_lowlink < node_lowlink {
406+
detector.lowlinks.insert(node.clone(), dependency_lowlink);
407+
}
408+
continue;
409+
}
410+
411+
if detector.on_stack.contains(dependency) {
412+
let dependency_index = detector
413+
.indices
414+
.get(dependency)
415+
.copied()
416+
.expect("dependency index must exist while on stack");
417+
let node_lowlink = detector
418+
.lowlinks
419+
.get(&node)
420+
.copied()
421+
.expect("node lowlink must exist");
422+
if dependency_index < node_lowlink {
423+
detector.lowlinks.insert(node.clone(), dependency_index);
424+
}
425+
}
426+
}
427+
}
428+
429+
let node_lowlink = detector
430+
.lowlinks
431+
.get(&node)
432+
.copied()
433+
.expect("node lowlink must exist");
434+
let node_index = detector
435+
.indices
436+
.get(&node)
437+
.copied()
438+
.expect("node index must exist");
439+
if node_lowlink != node_index {
440+
return;
441+
}
442+
443+
let mut component = Vec::new();
444+
while let Some(current) = detector.stack.pop() {
445+
detector.on_stack.remove(&current);
446+
component.push(current.clone());
447+
if current == node {
448+
break;
449+
}
450+
}
451+
452+
let contains_cycle = component.len() > 1
453+
|| component
454+
.first()
455+
.and_then(|current| {
456+
self.dependencies
457+
.get(current)
458+
.map(|deps| deps.contains(current))
459+
})
460+
.unwrap_or(false);
461+
if contains_cycle {
462+
detector.cycle_packages.extend(component);
463+
}
464+
}
465+
320466
fn normalize_relative_path(&self, path: &Path) -> Option<PathBuf> {
321467
if path.is_absolute() {
322468
return path.strip_prefix(&self.root).ok().map(Path::to_path_buf);
@@ -355,6 +501,16 @@ impl Workspace {
355501
}
356502
}
357503

504+
#[derive(Debug, Default)]
505+
struct CycleDetector {
506+
next_index: usize,
507+
indices: HashMap<String, usize>,
508+
lowlinks: HashMap<String, usize>,
509+
stack: Vec<String>,
510+
on_stack: BTreeSet<String>,
511+
cycle_packages: BTreeSet<String>,
512+
}
513+
358514
fn compile_globset(patterns: &[String], flag: &str) -> Result<Option<GlobSet>> {
359515
if patterns.is_empty() {
360516
return Ok(None);
@@ -470,6 +626,29 @@ mod tests {
470626
Workspace::from_parts(root, packages, dependencies, dependents)
471627
}
472628

629+
fn cycle_fixture_workspace() -> Workspace {
630+
let root = PathBuf::from("/repo");
631+
let packages = ["a", "b", "c"]
632+
.into_iter()
633+
.map(|name| (name.to_string(), package(name, &root)))
634+
.collect::<BTreeMap<_, _>>();
635+
636+
let mut dependencies = BTreeMap::<String, BTreeSet<String>>::new();
637+
dependencies.insert("a".to_string(), BTreeSet::from(["b".to_string()]));
638+
dependencies.insert("b".to_string(), BTreeSet::from(["a".to_string()]));
639+
dependencies.insert("c".to_string(), BTreeSet::from(["a".to_string()]));
640+
641+
let mut dependents = BTreeMap::<String, BTreeSet<String>>::new();
642+
dependents.insert(
643+
"a".to_string(),
644+
BTreeSet::from(["b".to_string(), "c".to_string()]),
645+
);
646+
dependents.insert("b".to_string(), BTreeSet::from(["a".to_string()]));
647+
dependents.insert("c".to_string(), BTreeSet::new());
648+
649+
Workspace::from_parts(root, packages, dependencies, dependents)
650+
}
651+
473652
#[test]
474653
fn changed_packages_maps_direct_paths() {
475654
let workspace = fixture_workspace();
@@ -600,4 +779,30 @@ mod tests {
600779
assert!(core_index < app_index);
601780
assert!(core_index < cli_index);
602781
}
782+
783+
#[test]
784+
fn topological_order_cycle_error_reports_cycle_packages_and_scope() {
785+
let workspace = cycle_fixture_workspace();
786+
let selected = BTreeSet::from(["a".to_string(), "b".to_string(), "c".to_string()]);
787+
788+
let error = workspace
789+
.topological_order(&selected)
790+
.expect_err("expected dependency cycle conflict");
791+
792+
assert_eq!(error.kind, ErrorKind::Conflict);
793+
assert!(error.message.contains(
794+
"Failed to build package order due to a dependency cycle in selected packages."
795+
));
796+
assert!(error.message.contains("selected_count=3"));
797+
assert!(error.message.contains("unresolved_count=3"));
798+
assert!(error.message.contains("cycle_package_count=2"));
799+
assert!(error.message.contains("cycle_packages=a|b"));
800+
assert!(error
801+
.message
802+
.contains("dependency_scope=all-cargo-metadata-kinds"));
803+
assert!(error.message.contains(
804+
"strict dependency scope includes normal, build, and dev dependencies from cargo \
805+
metadata"
806+
));
807+
}
603808
}

0 commit comments

Comments
 (0)