Skip to main content

cuenv_workspaces/
resolver.rs

1//! Dependency resolution implementation for workspaces.
2//!
3//! This module provides the [`GenericDependencyResolver`] which implements the
4//! [`DependencyResolver`] trait. It builds dependency graphs by combining
5//! workspace configuration (manifests) with resolved lockfile data.
6
7use crate::core::traits::{DependencyGraph, DependencyResolver};
8use crate::core::types::{DependencyRef, LockfileEntry, PackageManager, Workspace};
9use crate::discovery::read_json_file;
10use crate::error::Result;
11use petgraph::graph::NodeIndex;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15
16#[cfg(feature = "toml")]
17use crate::discovery::read_toml_file;
18
19/// A generic dependency resolver that works across supported package managers.
20///
21/// This resolver implements a strategy-based approach to handle differences between
22/// package managers (e.g., npm vs. Cargo) while providing a unified interface
23/// for dependency resolution.
24pub struct GenericDependencyResolver;
25
26impl DependencyResolver for GenericDependencyResolver {
27    fn resolve_dependencies(
28        &self,
29        _workspace: &Workspace,
30        lockfile: &[LockfileEntry],
31    ) -> Result<DependencyGraph> {
32        let mut graph = DependencyGraph::new();
33        // Key by (name, version) to handle multiple versions of the same package
34        let mut node_map: HashMap<(String, String), NodeIndex> = HashMap::new();
35
36        // 1. Create nodes for all lockfile entries
37        for entry in lockfile {
38            let dep_ref = DependencyRef {
39                name: entry.name.clone(),
40                version_req: entry.version.clone(),
41            };
42            let idx = graph.add_node(dep_ref);
43            node_map.insert((entry.name.clone(), entry.version.clone()), idx);
44        }
45
46        // 2. Add edges based on dependencies declared in lockfile entries
47        for entry in lockfile {
48            if let Some(&source_idx) = node_map.get(&(entry.name.clone(), entry.version.clone())) {
49                for dep in &entry.dependencies {
50                    // Find the dependency in the graph
51                    // We look up by (name, version_req). This assumes that the lockfile parser
52                    // populates `version_req` with the *resolved* version for that dependency,
53                    // or that the version requirement exactly matches one of the present versions.
54                    //
55                    // For many lockfiles (like Cargo.lock), the dependency list contains the
56                    // concrete version that was resolved.
57                    if let Some(&target_idx) =
58                        node_map.get(&(dep.name.clone(), dep.version_req.clone()))
59                    {
60                        graph.add_edge(source_idx, target_idx, ());
61                    } else {
62                        // Fallback: If strict lookup fails (e.g. version_req is a range like "^1.0.0"),
63                        // we would ideally perform semver resolution against available nodes.
64                        // For now, we log a trace if we can't find the exact match.
65                        // This keeps the graph correct for well-formed resolved lockfiles.
66                        tracing::trace!(
67                            "Could not find exact match for dependency {} {} -> {} {}",
68                            entry.name,
69                            entry.version,
70                            dep.name,
71                            dep.version_req
72                        );
73                    }
74                }
75            }
76        }
77
78        Ok(graph)
79    }
80
81    fn resolve_workspace_deps(&self, workspace: &Workspace) -> Result<Vec<DependencyRef>> {
82        let mut workspace_deps = Vec::new();
83
84        for member in &workspace.members {
85            let deps = match workspace.manager {
86                PackageManager::Npm
87                | PackageManager::Bun
88                | PackageManager::Pnpm
89                | PackageManager::YarnClassic
90                | PackageManager::YarnModern
91                | PackageManager::Deno => self.parse_js_deps(&member.manifest_path)?,
92                PackageManager::Cargo => Self::parse_rust_deps(&member.manifest_path)?,
93            };
94
95            workspace_deps.extend(deps);
96        }
97
98        Ok(workspace_deps)
99    }
100
101    fn resolve_external_deps(&self, lockfile: &[LockfileEntry]) -> Result<Vec<DependencyRef>> {
102        Ok(lockfile
103            .iter()
104            .filter(|entry| !entry.is_workspace_member)
105            .map(|entry| DependencyRef {
106                name: entry.name.clone(),
107                version_req: entry.version.clone(),
108            })
109            .collect())
110    }
111
112    fn detect_workspace_protocol(&self, spec: &str) -> bool {
113        // JS: "workspace:*" or "workspace:^1.2.3"
114        // Rust: we map { workspace = true } to "workspace" version requirement (internal convention)
115        spec.starts_with("workspace:") || spec == "workspace"
116    }
117}
118
119impl GenericDependencyResolver {
120    fn parse_js_deps(&self, path: &Path) -> Result<Vec<DependencyRef>> {
121        #[derive(Deserialize)]
122        struct PackageJsonDeps {
123            dependencies: Option<HashMap<String, String>>,
124            #[serde(rename = "devDependencies")]
125            dev_dependencies: Option<HashMap<String, String>>,
126        }
127
128        let pkg: PackageJsonDeps = read_json_file(path)?;
129        let mut result = Vec::new();
130
131        let mut add_deps = |deps: HashMap<String, String>| {
132            for (name, version) in deps {
133                if self.detect_workspace_protocol(&version) {
134                    result.push(DependencyRef {
135                        name,
136                        version_req: version,
137                    });
138                }
139            }
140        };
141
142        if let Some(deps) = pkg.dependencies {
143            add_deps(deps);
144        }
145        if let Some(deps) = pkg.dev_dependencies {
146            add_deps(deps);
147        }
148
149        Ok(result)
150    }
151
152    fn parse_rust_deps(path: &Path) -> Result<Vec<DependencyRef>> {
153        #[cfg(feature = "toml")]
154        {
155            #[derive(Deserialize)]
156            struct CargoTomlDeps {
157                dependencies: Option<HashMap<String, toml::Value>>,
158                #[serde(rename = "dev-dependencies")]
159                dev_dependencies: Option<HashMap<String, toml::Value>>,
160            }
161
162            // If toml is not available, we can't parse. But if manager is Cargo, toml should be available.
163            // We'll return empty if toml is missing but this code block is cfg-gated.
164            let pkg: CargoTomlDeps = read_toml_file(path)?;
165            let mut result = Vec::new();
166
167            let mut add_deps = |deps: HashMap<String, toml::Value>| {
168                for (name, value) in deps {
169                    let is_workspace = if let toml::Value::Table(t) = &value {
170                        t.get("workspace")
171                            .and_then(toml::Value::as_bool)
172                            .unwrap_or(false)
173                    } else {
174                        false
175                    };
176
177                    if is_workspace {
178                        result.push(DependencyRef {
179                            name,
180                            version_req: "workspace".to_string(),
181                        });
182                    }
183                }
184            };
185
186            if let Some(deps) = pkg.dependencies {
187                add_deps(deps);
188            }
189            if let Some(deps) = pkg.dev_dependencies {
190                add_deps(deps);
191            }
192
193            Ok(result)
194        }
195        #[cfg(not(feature = "toml"))]
196        {
197            // Should not happen if features are configured correctly for Cargo
198            let _ = path;
199            Ok(Vec::new())
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::DependencySource;
208    use std::fs;
209    use std::path::PathBuf;
210    use tempfile::TempDir;
211
212    /// Helper to create a `LockfileEntry` for tests
213    fn make_entry(name: &str, version: &str, is_workspace: bool) -> LockfileEntry {
214        LockfileEntry {
215            name: name.to_string(),
216            version: version.to_string(),
217            source: if is_workspace {
218                DependencySource::Workspace(PathBuf::from(name))
219            } else {
220                DependencySource::Registry("https://registry.npmjs.org".to_string())
221            },
222            checksum: None,
223            dependencies: vec![],
224            is_workspace_member: is_workspace,
225        }
226    }
227
228    /// Helper to create a `LockfileEntry` with dependencies
229    fn make_entry_with_deps(name: &str, version: &str, deps: Vec<DependencyRef>) -> LockfileEntry {
230        LockfileEntry {
231            name: name.to_string(),
232            version: version.to_string(),
233            source: DependencySource::Registry("https://registry.npmjs.org".to_string()),
234            checksum: None,
235            dependencies: deps,
236            is_workspace_member: false,
237        }
238    }
239
240    // ==========================================================================
241    // GenericDependencyResolver::detect_workspace_protocol tests
242    // ==========================================================================
243
244    #[test]
245    fn test_detect_workspace_protocol_js_workspace_star() {
246        let resolver = GenericDependencyResolver;
247        assert!(resolver.detect_workspace_protocol("workspace:*"));
248    }
249
250    #[test]
251    fn test_detect_workspace_protocol_js_workspace_version() {
252        let resolver = GenericDependencyResolver;
253        assert!(resolver.detect_workspace_protocol("workspace:^1.2.3"));
254        assert!(resolver.detect_workspace_protocol("workspace:~1.0.0"));
255    }
256
257    #[test]
258    fn test_detect_workspace_protocol_rust_workspace() {
259        let resolver = GenericDependencyResolver;
260        assert!(resolver.detect_workspace_protocol("workspace"));
261    }
262
263    #[test]
264    fn test_detect_workspace_protocol_not_workspace() {
265        let resolver = GenericDependencyResolver;
266        assert!(!resolver.detect_workspace_protocol("^1.0.0"));
267        assert!(!resolver.detect_workspace_protocol("1.2.3"));
268        assert!(!resolver.detect_workspace_protocol("latest"));
269        assert!(!resolver.detect_workspace_protocol(""));
270    }
271
272    // ==========================================================================
273    // GenericDependencyResolver::resolve_external_deps tests
274    // ==========================================================================
275
276    #[test]
277    fn test_resolve_external_deps_filters_workspace_members() {
278        let resolver = GenericDependencyResolver;
279        let lockfile = vec![
280            make_entry("external-pkg", "1.0.0", false),
281            make_entry("workspace-pkg", "0.1.0", true),
282        ];
283
284        let result = resolver.resolve_external_deps(&lockfile).unwrap();
285
286        assert_eq!(result.len(), 1);
287        assert_eq!(result[0].name, "external-pkg");
288    }
289
290    #[test]
291    fn test_resolve_external_deps_empty_lockfile() {
292        let resolver = GenericDependencyResolver;
293        let lockfile: Vec<LockfileEntry> = vec![];
294
295        let result = resolver.resolve_external_deps(&lockfile).unwrap();
296
297        assert!(result.is_empty());
298    }
299
300    #[test]
301    fn test_resolve_external_deps_all_workspace_members() {
302        let resolver = GenericDependencyResolver;
303        let lockfile = vec![
304            make_entry("pkg-a", "0.1.0", true),
305            make_entry("pkg-b", "0.2.0", true),
306        ];
307
308        let result = resolver.resolve_external_deps(&lockfile).unwrap();
309
310        assert!(result.is_empty());
311    }
312
313    // ==========================================================================
314    // GenericDependencyResolver::resolve_dependencies tests
315    // ==========================================================================
316
317    #[test]
318    fn test_resolve_dependencies_creates_graph() {
319        let resolver = GenericDependencyResolver;
320        let workspace = Workspace::new(PathBuf::from("/project"), PackageManager::Npm);
321        let lockfile = vec![
322            make_entry("pkg-a", "1.0.0", false),
323            make_entry("pkg-b", "2.0.0", false),
324        ];
325
326        let graph = resolver
327            .resolve_dependencies(&workspace, &lockfile)
328            .unwrap();
329
330        assert_eq!(graph.node_count(), 2);
331    }
332
333    #[test]
334    fn test_resolve_dependencies_with_edges() {
335        let resolver = GenericDependencyResolver;
336        let workspace = Workspace::new(PathBuf::from("/project"), PackageManager::Npm);
337        let lockfile = vec![
338            make_entry_with_deps(
339                "pkg-a",
340                "1.0.0",
341                vec![DependencyRef {
342                    name: "pkg-b".to_string(),
343                    version_req: "2.0.0".to_string(),
344                }],
345            ),
346            make_entry("pkg-b", "2.0.0", false),
347        ];
348
349        let graph = resolver
350            .resolve_dependencies(&workspace, &lockfile)
351            .unwrap();
352
353        assert_eq!(graph.node_count(), 2);
354        assert_eq!(graph.edge_count(), 1);
355    }
356
357    #[test]
358    fn test_resolve_dependencies_empty_lockfile() {
359        let resolver = GenericDependencyResolver;
360        let workspace = Workspace::new(PathBuf::from("/project"), PackageManager::Npm);
361        let lockfile: Vec<LockfileEntry> = vec![];
362
363        let graph = resolver
364            .resolve_dependencies(&workspace, &lockfile)
365            .unwrap();
366
367        assert_eq!(graph.node_count(), 0);
368        assert_eq!(graph.edge_count(), 0);
369    }
370
371    #[test]
372    fn test_resolve_dependencies_missing_dep_skipped() {
373        // When a dependency references a package not in the lockfile, it should be skipped
374        let resolver = GenericDependencyResolver;
375        let workspace = Workspace::new(PathBuf::from("/project"), PackageManager::Npm);
376        let lockfile = vec![make_entry_with_deps(
377            "pkg-a",
378            "1.0.0",
379            vec![DependencyRef {
380                name: "missing-pkg".to_string(),
381                version_req: "1.0.0".to_string(),
382            }],
383        )];
384
385        let graph = resolver
386            .resolve_dependencies(&workspace, &lockfile)
387            .unwrap();
388
389        // Node for pkg-a, but no edge to missing-pkg
390        assert_eq!(graph.node_count(), 1);
391        assert_eq!(graph.edge_count(), 0);
392    }
393
394    #[test]
395    fn test_resolve_dependencies_multiple_versions() {
396        // Multiple versions of the same package should be separate nodes
397        let resolver = GenericDependencyResolver;
398        let workspace = Workspace::new(PathBuf::from("/project"), PackageManager::Npm);
399        let lockfile = vec![
400            make_entry("lodash", "4.0.0", false),
401            make_entry("lodash", "3.0.0", false),
402        ];
403
404        let graph = resolver
405            .resolve_dependencies(&workspace, &lockfile)
406            .unwrap();
407
408        // Two separate nodes for different versions
409        assert_eq!(graph.node_count(), 2);
410    }
411
412    // ==========================================================================
413    // GenericDependencyResolver::parse_js_deps tests
414    // ==========================================================================
415
416    #[test]
417    fn test_parse_js_deps_workspace_dependencies() {
418        let temp_dir = TempDir::new().unwrap();
419        let pkg_json = temp_dir.path().join("package.json");
420        fs::write(
421            &pkg_json,
422            r#"{
423            "dependencies": {
424                "external": "^1.0.0",
425                "workspace-pkg": "workspace:*"
426            },
427            "devDependencies": {
428                "dev-workspace": "workspace:^1.0.0"
429            }
430        }"#,
431        )
432        .unwrap();
433
434        let resolver = GenericDependencyResolver;
435        let deps = resolver.parse_js_deps(&pkg_json).unwrap();
436
437        // Only workspace deps should be returned
438        assert_eq!(deps.len(), 2);
439        let names: Vec<&str> = deps.iter().map(|d| d.name.as_str()).collect();
440        assert!(names.contains(&"workspace-pkg"));
441        assert!(names.contains(&"dev-workspace"));
442    }
443
444    #[test]
445    fn test_parse_js_deps_no_workspace_deps() {
446        let temp_dir = TempDir::new().unwrap();
447        let pkg_json = temp_dir.path().join("package.json");
448        fs::write(
449            &pkg_json,
450            r#"{
451            "dependencies": {
452                "lodash": "^4.0.0",
453                "react": "^18.0.0"
454            }
455        }"#,
456        )
457        .unwrap();
458
459        let resolver = GenericDependencyResolver;
460        let deps = resolver.parse_js_deps(&pkg_json).unwrap();
461
462        assert!(deps.is_empty());
463    }
464
465    #[test]
466    fn test_parse_js_deps_empty_deps() {
467        let temp_dir = TempDir::new().unwrap();
468        let pkg_json = temp_dir.path().join("package.json");
469        fs::write(&pkg_json, r"{}").unwrap();
470
471        let resolver = GenericDependencyResolver;
472        let deps = resolver.parse_js_deps(&pkg_json).unwrap();
473
474        assert!(deps.is_empty());
475    }
476
477    // ==========================================================================
478    // GenericDependencyResolver::parse_rust_deps tests
479    // ==========================================================================
480
481    #[cfg(feature = "toml")]
482    #[test]
483    fn test_parse_rust_deps_workspace_dependencies() {
484        let temp_dir = TempDir::new().unwrap();
485        let cargo_toml = temp_dir.path().join("Cargo.toml");
486        fs::write(
487            &cargo_toml,
488            r#"
489[dependencies]
490serde = "1.0"
491my-lib = { workspace = true }
492
493[dev-dependencies]
494test-helper = { workspace = true }
495"#,
496        )
497        .unwrap();
498
499        let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
500
501        assert_eq!(deps.len(), 2);
502        let names: Vec<&str> = deps.iter().map(|d| d.name.as_str()).collect();
503        assert!(names.contains(&"my-lib"));
504        assert!(names.contains(&"test-helper"));
505    }
506
507    #[cfg(feature = "toml")]
508    #[test]
509    fn test_parse_rust_deps_no_workspace_deps() {
510        let temp_dir = TempDir::new().unwrap();
511        let cargo_toml = temp_dir.path().join("Cargo.toml");
512        fs::write(
513            &cargo_toml,
514            r#"
515[dependencies]
516serde = "1.0"
517tokio = { version = "1.0", features = ["full"] }
518"#,
519        )
520        .unwrap();
521
522        let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
523
524        assert!(deps.is_empty());
525    }
526
527    #[cfg(feature = "toml")]
528    #[test]
529    fn test_parse_rust_deps_workspace_false_ignored() {
530        let temp_dir = TempDir::new().unwrap();
531        let cargo_toml = temp_dir.path().join("Cargo.toml");
532        fs::write(
533            &cargo_toml,
534            r#"
535[dependencies]
536my-lib = { workspace = false, version = "1.0" }
537"#,
538        )
539        .unwrap();
540
541        let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
542
543        assert!(deps.is_empty());
544    }
545
546    #[cfg(feature = "toml")]
547    #[test]
548    fn test_parse_rust_deps_empty() {
549        let temp_dir = TempDir::new().unwrap();
550        let cargo_toml = temp_dir.path().join("Cargo.toml");
551        fs::write(
552            &cargo_toml,
553            r#"
554[package]
555name = "my-crate"
556version = "0.1.0"
557"#,
558        )
559        .unwrap();
560
561        let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
562
563        assert!(deps.is_empty());
564    }
565
566    #[cfg(feature = "toml")]
567    #[test]
568    fn test_parse_rust_deps_string_version_ignored() {
569        let temp_dir = TempDir::new().unwrap();
570        let cargo_toml = temp_dir.path().join("Cargo.toml");
571        fs::write(
572            &cargo_toml,
573            r#"
574[dependencies]
575serde = "1.0"
576"#,
577        )
578        .unwrap();
579
580        // String version deps should not be treated as workspace deps
581        let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
582
583        assert!(deps.is_empty());
584    }
585}