Skip to content

Commit 9e7ee01

Browse files
authored
feat: disambiguate duplicate crates across workspaces (#41)
* feat: disambiguate duplicate crates across workspaces - replace analyzer's crate→workspace map with canonical multi-maps and expose crate-path lookups - resolve workspace dependency edges using metadata paths instead of substring heuristics - reindex ripple analysis on stable crate ids and update reports/tests for duplicate names - refresh README guidance * fix clippies * fix lints
1 parent 73025f6 commit 9e7ee01

File tree

15 files changed

+848
-371
lines changed

15 files changed

+848
-371
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,11 @@ The `ripples` command is the star of our CI circus! This precision tool determin
165165
**What it does:**
166166

167167
- Maps changed files to their containing crates
168+
- Canonicalizes crates by their manifest paths so duplicate package names stay unique across workspaces
168169
- Traces dependencies to find all affected components
169170
- Distinguishes between directly and indirectly affected crates
170171
- Provides both workspace and crate-level impact analysis
172+
- Resolves workspace dependencies via Cargo metadata rather than directory-name heuristics
171173
- Outputs machine-readable formats for CI integration
172174

173175
**When to use it:**
@@ -182,7 +184,9 @@ The `ripples` command is the star of our CI circus! This precision tool determin
182184
Unlike naive approaches that rebuild everything or guess based on directory names, `ripples` understands your actual dependency graph. It precisely identifies affected components, even when:
183185

184186
- Multiple crates exist in a single workspace
187+
- Different workspaces reuse the same crate name
185188
- Changes affect shared dependencies
189+
- Workspace directories diverge from their package names
186190
- File moves or renames occur
187191
- Only test files are modified
188192

src/analyzer/analyzer_impl.rs

Lines changed: 164 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{BTreeSet, HashMap};
22
use std::path::{Path, PathBuf};
33

44
use console::style;
@@ -29,13 +29,17 @@ pub enum CrateMemberBuilderError {
2929
}
3030

3131
// Type aliases to reduce complexity
32-
type WorkspaceProcessResult = (PathBuf, WorkspaceInfo, Vec<(String, PathBuf)>);
32+
pub type CrateWorkspaceMap = HashMap<String, BTreeSet<PathBuf>>;
33+
pub type CratePathToWorkspaceMap = HashMap<PathBuf, PathBuf>;
34+
35+
type WorkspaceProcessResult = (PathBuf, WorkspaceInfo);
3336
type ParallelProcessResults = Vec<WorkspaceProcessResult>;
3437

3538
#[derive(Debug, Clone)]
3639
pub struct WorkspaceAnalyzer {
3740
workspaces: HashMap<PathBuf, WorkspaceInfo>,
38-
crate_to_workspace: HashMap<String, PathBuf>,
41+
crate_to_workspaces: CrateWorkspaceMap,
42+
crate_path_to_workspace: CratePathToWorkspaceMap,
3943
crate_to_paths: HashMap<String, Vec<PathBuf>>,
4044
}
4145

@@ -226,6 +230,8 @@ impl CrateMemberBuilder {
226230
pub struct Dependency {
227231
name: String,
228232
target: Option<String>,
233+
path: Option<PathBuf>,
234+
is_workspace: bool,
229235
}
230236

231237
impl Dependency {
@@ -240,12 +246,22 @@ impl Dependency {
240246
pub fn target(&self) -> Option<&str> {
241247
self.target.as_deref()
242248
}
249+
250+
pub fn path(&self) -> Option<&PathBuf> {
251+
self.path.as_ref()
252+
}
253+
254+
pub fn is_workspace(&self) -> bool {
255+
self.is_workspace
256+
}
243257
}
244258

245259
#[derive(Default)]
246260
pub struct DependencyBuilder {
247261
name: Option<String>,
248262
target: Option<String>,
263+
path: Option<PathBuf>,
264+
is_workspace: bool,
249265
}
250266

251267
#[derive(Error, Debug, Diagnostic)]
@@ -263,6 +279,8 @@ impl From<&Dependency> for DependencyBuilder {
263279
Self {
264280
name: Some(dep.name().to_string()),
265281
target: dep.target().map(|t| t.to_string()),
282+
path: dep.path().cloned(),
283+
is_workspace: dep.is_workspace(),
266284
}
267285
}
268286
}
@@ -278,10 +296,22 @@ impl DependencyBuilder {
278296
self
279297
}
280298

299+
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
300+
self.path = Some(path.into());
301+
self
302+
}
303+
304+
pub fn with_is_workspace(mut self, is_workspace: bool) -> Self {
305+
self.is_workspace = is_workspace;
306+
self
307+
}
308+
281309
pub fn build(self) -> Result<Dependency, DependencyBuilderError> {
282310
Ok(Dependency {
283311
name: self.name.ok_or(DependencyBuilderError::MissingName)?,
284312
target: self.target,
313+
path: self.path,
314+
is_workspace: self.is_workspace,
285315
})
286316
}
287317
}
@@ -296,7 +326,8 @@ impl WorkspaceAnalyzer {
296326
pub fn new() -> Self {
297327
Self {
298328
workspaces: HashMap::new(),
299-
crate_to_workspace: HashMap::new(),
329+
crate_to_workspaces: HashMap::new(),
330+
crate_path_to_workspace: HashMap::new(),
300331
crate_to_paths: HashMap::new(),
301332
}
302333
}
@@ -305,8 +336,12 @@ impl WorkspaceAnalyzer {
305336
&self.workspaces
306337
}
307338

308-
pub fn crate_to_workspace(&self) -> &HashMap<String, PathBuf> {
309-
&self.crate_to_workspace
339+
pub fn crate_to_workspace(&self) -> &CrateWorkspaceMap {
340+
&self.crate_to_workspaces
341+
}
342+
343+
pub fn crate_path_to_workspace(&self) -> &CratePathToWorkspaceMap {
344+
&self.crate_path_to_workspace
310345
}
311346

312347
pub fn crate_to_paths(&self) -> &HashMap<String, Vec<PathBuf>> {
@@ -395,19 +430,41 @@ impl WorkspaceAnalyzer {
395430
}
396431

397432
fn merge_results(&mut self, results: ParallelProcessResults) {
398-
for (path, info, crate_mappings) in results {
399-
// Populate crate_to_paths mapping from the workspace info
400-
for member in &info.members {
401-
self.crate_to_paths
433+
for (workspace_path, mut info) in results {
434+
let workspace_key = workspace_path
435+
.canonicalize()
436+
.unwrap_or_else(|_| workspace_path.clone());
437+
438+
// Populate crate lookups from the workspace info
439+
for member in &mut info.members {
440+
let crate_path = member
441+
.path
442+
.canonicalize()
443+
.unwrap_or_else(|_| member.path.clone());
444+
445+
member.path = crate_path.clone();
446+
447+
if let Some(entry) = self.crate_to_paths.get_mut(&member.name) {
448+
if !entry.iter().any(|existing| existing == &crate_path) {
449+
entry.push(crate_path.clone());
450+
}
451+
} else {
452+
self.crate_to_paths
453+
.entry(member.name.clone())
454+
.or_default()
455+
.push(crate_path.clone());
456+
}
457+
458+
self.crate_to_workspaces
402459
.entry(member.name.clone())
403460
.or_default()
404-
.push(member.path.clone());
405-
}
461+
.insert(workspace_key.clone());
406462

407-
self.workspaces.insert(path, info);
408-
for (crate_name, workspace_path) in crate_mappings {
409-
self.crate_to_workspace.insert(crate_name, workspace_path);
463+
self.crate_path_to_workspace
464+
.insert(crate_path, workspace_key.clone());
410465
}
466+
467+
self.workspaces.insert(workspace_key, info);
411468
}
412469
}
413470

@@ -445,7 +502,7 @@ impl WorkspaceAnalyzer {
445502
root: WorkspaceRoot,
446503
) -> Result<WorkspaceProcessResult> {
447504
// Process members in parallel and collect both results and errors
448-
let results: Vec<Result<(CrateMember, String)>> = root
505+
let results: Vec<Result<CrateMember>> = root
449506
.members()
450507
.par_iter()
451508
.map(|member| {
@@ -456,18 +513,17 @@ impl WorkspaceAnalyzer {
456513
root.workspace_dependencies(),
457514
root.path(),
458515
)
459-
.map(|crate_member| (crate_member, member.name().to_string()))
460516
.wrap_err_with(|| format!("Failed to analyze crate '{}'", member.name()))
461517
})
462518
.collect();
463519

464520
// Separate successful results from errors
465-
let mut members_with_mappings = Vec::new();
521+
let mut members = Vec::new();
466522
let mut crate_errors = Vec::new();
467523

468524
for result in results {
469525
match result {
470-
Ok(data) => members_with_mappings.push(data),
526+
Ok(member) => members.push(member),
471527
Err(e) => crate_errors.push(e),
472528
}
473529
}
@@ -477,23 +533,13 @@ impl WorkspaceAnalyzer {
477533
eprintln!("{} {}", style("⚠").yellow(), error);
478534
}
479535

480-
let members: Vec<CrateMember> = members_with_mappings
481-
.iter()
482-
.map(|(m, _)| m.clone())
483-
.collect();
484-
485-
let crate_mappings: Vec<(String, PathBuf)> = members_with_mappings
486-
.into_iter()
487-
.map(|(_, name)| (name, root.path().clone()))
488-
.collect();
489-
490536
let workspace_info = WorkspaceInfo {
491537
name: root.name().to_string(),
492538
members,
493539
is_standalone: root.is_standalone(),
494540
};
495541

496-
Ok((root.path().clone(), workspace_info, crate_mappings))
542+
Ok((root.path().clone(), workspace_info))
497543
}
498544

499545
fn analyze_crate_member(
@@ -520,7 +566,9 @@ impl WorkspaceAnalyzer {
520566

521567
#[cfg(test)]
522568
mod tests {
569+
use std::collections::BTreeSet;
523570
use std::fs;
571+
use std::path::PathBuf;
524572

525573
use tempfile::TempDir;
526574

@@ -601,4 +649,90 @@ crate-a = { path = "../crate-a" }
601649
let crate_b = ws.members.iter().find(|m| m.name == "crate-b").unwrap();
602650
assert_eq!(crate_b.dev_dependencies.len(), 1); // crate-a
603651
}
652+
653+
#[test]
654+
fn test_duplicate_crate_names_map_to_multiple_workspaces() {
655+
let temp = TempDir::new().unwrap();
656+
let root = temp.path();
657+
658+
let workspace_a = root.join("workspace-a");
659+
let workspace_b = root.join("workspace-b");
660+
661+
fs::create_dir_all(workspace_a.join("shared/src")).unwrap();
662+
fs::create_dir_all(workspace_b.join("shared/src")).unwrap();
663+
664+
fs::write(
665+
workspace_a.join("Cargo.toml"),
666+
r#"
667+
[workspace]
668+
members = ["shared"]
669+
"#,
670+
)
671+
.unwrap();
672+
fs::write(
673+
workspace_a.join("shared/Cargo.toml"),
674+
"[package]\nname = \"shared\"\n",
675+
)
676+
.unwrap();
677+
fs::write(workspace_a.join("shared/src/lib.rs"), "pub fn a() {}").unwrap();
678+
679+
fs::write(
680+
workspace_b.join("Cargo.toml"),
681+
r#"
682+
[workspace]
683+
members = ["shared"]
684+
"#,
685+
)
686+
.unwrap();
687+
fs::write(
688+
workspace_b.join("shared/Cargo.toml"),
689+
"[package]\nname = \"shared\"\n",
690+
)
691+
.unwrap();
692+
fs::write(workspace_b.join("shared/src/lib.rs"), "pub fn b() {}").unwrap();
693+
694+
let mut analyzer = WorkspaceAnalyzer::new();
695+
analyzer
696+
.discover_workspaces(&[root.to_path_buf()], None)
697+
.unwrap();
698+
699+
let shared_entries = analyzer
700+
.crate_to_workspace()
701+
.get("shared")
702+
.expect("shared crate should be indexed");
703+
704+
let expected_ws_paths: BTreeSet<PathBuf> = [
705+
workspace_a.canonicalize().unwrap(),
706+
workspace_b.canonicalize().unwrap(),
707+
]
708+
.into_iter()
709+
.collect();
710+
711+
let actual_ws_paths: BTreeSet<PathBuf> = shared_entries
712+
.iter()
713+
.map(|p| p.canonicalize().unwrap())
714+
.collect();
715+
716+
assert_eq!(actual_ws_paths, expected_ws_paths);
717+
718+
let crate_paths = analyzer
719+
.crate_to_paths()
720+
.get("shared")
721+
.expect("crate paths should be tracked");
722+
assert_eq!(crate_paths.len(), 2);
723+
724+
for crate_path in crate_paths {
725+
let resolved = crate_path.canonicalize().unwrap();
726+
let ws = analyzer
727+
.crate_path_to_workspace()
728+
.get(crate_path)
729+
.expect("crate path should map to workspace");
730+
let ws_abs = ws.canonicalize().unwrap();
731+
assert!(expected_ws_paths.contains(&ws_abs));
732+
assert!(
733+
resolved.starts_with(&ws_abs),
734+
"crate {resolved:?} should live under workspace {ws_abs:?}"
735+
);
736+
}
737+
}
604738
}

0 commit comments

Comments
 (0)