1use 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
19pub 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 let mut node_map: HashMap<(String, String), NodeIndex> = HashMap::new();
35
36 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 assert_eq!(graph.node_count(), 2);
410 }
411
412 #[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 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 #[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 let deps = GenericDependencyResolver::parse_rust_deps(&cargo_toml).unwrap();
582
583 assert!(deps.is_empty());
584 }
585}