Skip to main content

mati_core/analysis/
deps.rs

1//! Dependency parsing — Layer 0 manifest extraction.
2//!
3//! Reads `Cargo.toml`, `package.json`, and `go.mod` from walked files to produce
4//! `dep:*` records. No tree-sitter needed — just `serde_json` (package.json) and
5//! line parsing (Cargo.toml, go.mod).
6//!
7//! # Performance
8//!
9//! Pure I/O + string parsing. Typical projects: <2ms for ~20 deps.
10//!
11//! # Graceful degradation (P9)
12//!
13//! All errors degrade silently — unreadable or malformed manifests are skipped
14//! with a `warn!`. Never fatal.
15
16use std::collections::HashSet;
17use std::path::Path;
18
19use anyhow::Result;
20use tracing::warn;
21
22use super::walker::WalkedFile;
23
24// ── Public types ────────────────────────────────────────────────────────────
25
26/// A single dependency extracted from a manifest file.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DepEntry {
29    /// Dependency ecosystem — used as part of the canonical dep:* key.
30    pub ecosystem: DepEcosystem,
31    /// Dependency name (crate name, npm package, Go module path).
32    pub name: String,
33    /// Version resolution — explicit string or workspace-inherited.
34    pub version: DepVersion,
35    /// Which manifest declared this dependency.
36    pub manifest: ManifestKind,
37    /// Whether this is a dev/test dependency.
38    pub dev: bool,
39}
40
41/// Canonical dependency ecosystem.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum DepEcosystem {
44    Cargo,
45    Npm,
46    Go,
47}
48
49impl DepEcosystem {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Cargo => "cargo",
53            Self::Npm => "npm",
54            Self::Go => "go",
55        }
56    }
57}
58
59/// How a dependency version is declared.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DepVersion {
62    /// Explicit version string (may be range, "*", or exact).
63    Declared(String),
64    /// Inherited from `[workspace.dependencies]` via `dep.workspace = true`.
65    Workspace,
66}
67
68/// Kind of manifest file.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ManifestKind {
71    CargoToml,
72    PackageJson,
73    GoMod,
74}
75
76/// All dependencies discovered in a repository.
77#[derive(Debug, Clone)]
78pub struct DepSignals {
79    /// Deduplicated dependency entries.
80    pub deps: Vec<DepEntry>,
81    /// Which manifest files were found and parsed.
82    pub manifests_found: Vec<(ManifestKind, String)>,
83}
84
85/// Build the canonical `dep:*` record key for a dependency.
86pub fn dep_record_key(dep: &DepEntry) -> String {
87    format!("dep:{}:{}", dep.ecosystem.as_str(), dep.name)
88}
89
90/// Extract the display name from a `dep:*` key.
91///
92/// Supports both the new `dep:<ecosystem>:<name>` format and the legacy
93/// `dep:<name>` form so read paths stay compatible with old stores.
94pub fn dep_display_name_from_key(key: &str) -> &str {
95    let Some(rest) = key.strip_prefix("dep:") else {
96        return key;
97    };
98    match rest.split_once(':') {
99        Some(("cargo" | "npm" | "go", name)) => name,
100        _ => rest,
101    }
102}
103
104/// Extract ecosystem + display name from a `dep:*` key when available.
105pub fn parse_dep_key(key: &str) -> Option<(Option<DepEcosystem>, &str)> {
106    let rest = key.strip_prefix("dep:")?;
107    match rest.split_once(':') {
108        Some(("cargo", name)) => Some((Some(DepEcosystem::Cargo), name)),
109        Some(("npm", name)) => Some((Some(DepEcosystem::Npm), name)),
110        Some(("go", name)) => Some((Some(DepEcosystem::Go), name)),
111        _ => Some((None, rest)),
112    }
113}
114
115impl DepSignals {
116    /// Empty result — used when no manifests are found.
117    pub fn empty() -> Self {
118        Self {
119            deps: Vec::new(),
120            manifests_found: Vec::new(),
121        }
122    }
123}
124
125// ── Public API ──────────────────────────────────────────────────────────────
126
127/// Parse all manifest files found by the walker.
128///
129/// Looks for `Cargo.toml`, `package.json`, `go.mod` in `walked_files`.
130/// Sync, pure I/O. Returns `DepSignals::empty()` on any systemic error (P9).
131///
132/// Deduplication: if the same dep identity appears from multiple manifests,
133/// the entry from the shallowest (fewest path separators) manifest wins.
134pub fn parse_dependencies(repo_path: &Path, walked_files: &[WalkedFile]) -> Result<DepSignals> {
135    // Discover manifests, sorted by depth (shallowest first for dedup priority).
136    let mut manifests: Vec<(ManifestKind, &str)> = walked_files
137        .iter()
138        .filter_map(|f| {
139            filename_to_manifest_kind(&f.rel_path).map(|kind| (kind, f.rel_path.as_str()))
140        })
141        .collect();
142
143    if manifests.is_empty() {
144        return Ok(DepSignals::empty());
145    }
146
147    // Sort by depth (number of '/' separators) so root manifests come first.
148    manifests.sort_by_key(|(_, path)| path.matches('/').count());
149
150    let mut all_deps: Vec<DepEntry> = Vec::new();
151    let mut manifests_found: Vec<(ManifestKind, String)> = Vec::new();
152
153    for (kind, rel_path) in &manifests {
154        let abs_path = repo_path.join(rel_path);
155        let content = match std::fs::read_to_string(&abs_path) {
156            Ok(c) => c,
157            Err(e) => {
158                warn!("deps: cannot read {rel_path}: {e}");
159                continue;
160            }
161        };
162
163        let entries = match kind {
164            ManifestKind::CargoToml => parse_cargo_toml(&content),
165            ManifestKind::PackageJson => parse_package_json(&content),
166            ManifestKind::GoMod => parse_go_mod(&content),
167        };
168
169        all_deps.extend(entries);
170        manifests_found.push((*kind, rel_path.to_string()));
171    }
172
173    // Dedup by canonical dependency identity — first occurrence wins
174    // (shallowest manifest due to sort).
175    let mut seen = HashSet::new();
176    let mut deduped: Vec<DepEntry> = Vec::with_capacity(all_deps.len());
177
178    for dep in all_deps {
179        if seen.insert((dep.ecosystem, dep.name.clone())) {
180            deduped.push(dep);
181        }
182    }
183
184    // Sort by name for deterministic output.
185    deduped.sort_unstable_by(|a, b| a.name.cmp(&b.name));
186
187    Ok(DepSignals {
188        deps: deduped,
189        manifests_found,
190    })
191}
192
193// ── Internal parsers ────────────────────────────────────────────────────────
194
195/// Determine manifest kind from a repo-relative path.
196fn filename_to_manifest_kind(rel_path: &str) -> Option<ManifestKind> {
197    let filename = rel_path.rsplit('/').next().unwrap_or(rel_path);
198    match filename {
199        "Cargo.toml" => Some(ManifestKind::CargoToml),
200        "package.json" => Some(ManifestKind::PackageJson),
201        "go.mod" => Some(ManifestKind::GoMod),
202        _ => None,
203    }
204}
205
206/// Parse `Cargo.toml` via line-based section parsing.
207///
208/// Handles `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`,
209/// and dotted table forms like `[dependencies.serde]`.
210/// Supports: `name = "version"`, `name = { version = "..." }`, `name.workspace = true`.
211fn parse_cargo_toml(content: &str) -> Vec<DepEntry> {
212    let mut deps = Vec::new();
213
214    #[derive(Clone, Copy)]
215    enum Section {
216        None,
217        Dependencies,
218        DevDependencies,
219        BuildDependencies,
220    }
221
222    let mut section = Section::None;
223    // When inside a `[dependencies.X]` table, the dep name from the header.
224    let mut table_dep_name: Option<String> = None;
225    let mut table_dev = false;
226
227    for line in content.lines() {
228        let trimmed = line.trim();
229
230        // Detect section headers — strip exactly one bracket from each end.
231        if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
232            // Skip TOML array-of-tables `[[...]]`
233            let header =
234                if let Some(inner2) = inner.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
235                    // Flush any pending table dep before switching sections.
236                    if let Some(name) = table_dep_name.take() {
237                        deps.push(DepEntry {
238                            name,
239                            ecosystem: DepEcosystem::Cargo,
240                            version: DepVersion::Declared(String::new()),
241                            manifest: ManifestKind::CargoToml,
242                            dev: table_dev,
243                        });
244                    }
245                    section = Section::None;
246                    let _ = inner2;
247                    continue;
248                } else {
249                    inner.trim()
250                };
251
252            // Flush any pending table dep before switching sections.
253            if let Some(name) = table_dep_name.take() {
254                deps.push(DepEntry {
255                    ecosystem: DepEcosystem::Cargo,
256                    name,
257                    version: DepVersion::Declared(String::new()),
258                    manifest: ManifestKind::CargoToml,
259                    dev: table_dev,
260                });
261            }
262
263            // Check for dotted table form: `[dependencies.serde]`
264            if let Some(dep_name) = header.strip_prefix("dependencies.") {
265                section = Section::Dependencies;
266                table_dep_name = Some(dep_name.to_string());
267                table_dev = false;
268                continue;
269            }
270            if let Some(dep_name) = header.strip_prefix("dev-dependencies.") {
271                section = Section::DevDependencies;
272                table_dep_name = Some(dep_name.to_string());
273                table_dev = true;
274                continue;
275            }
276            if let Some(dep_name) = header.strip_prefix("build-dependencies.") {
277                section = Section::BuildDependencies;
278                table_dep_name = Some(dep_name.to_string());
279                table_dev = true;
280                continue;
281            }
282
283            section = match header {
284                "dependencies" => Section::Dependencies,
285                "dev-dependencies" => Section::DevDependencies,
286                "build-dependencies" => Section::BuildDependencies,
287                _ => Section::None,
288            };
289            continue;
290        }
291
292        // Skip outside dependency sections
293        let dev = match section {
294            Section::None => continue,
295            Section::Dependencies => false,
296            Section::DevDependencies => true,
297            Section::BuildDependencies => true,
298        };
299
300        // Skip comments and empty lines
301        if trimmed.is_empty() || trimmed.starts_with('#') {
302            continue;
303        }
304
305        // Inside a `[dependencies.X]` table — look for `version = "..."`.
306        if let Some(ref dep_name) = table_dep_name {
307            if let Some((key, val)) = trimmed.split_once('=') {
308                let key = key.trim();
309                let val = val.trim();
310                if key == "version" {
311                    if let Some(version) = extract_quoted_string(val) {
312                        deps.push(DepEntry {
313                            ecosystem: DepEcosystem::Cargo,
314                            name: dep_name.clone(),
315                            version: DepVersion::Declared(version),
316                            manifest: ManifestKind::CargoToml,
317                            dev,
318                        });
319                        table_dep_name = None;
320                    }
321                } else if key == "workspace" && val.trim() == "true" {
322                    deps.push(DepEntry {
323                        ecosystem: DepEcosystem::Cargo,
324                        name: dep_name.clone(),
325                        version: DepVersion::Workspace,
326                        manifest: ManifestKind::CargoToml,
327                        dev,
328                    });
329                    table_dep_name = None;
330                }
331            }
332            continue;
333        }
334
335        // Parse: name = "version" | name = { version = "..." } | name.workspace = true
336        if let Some((name_part, value_part)) = trimmed.split_once('=') {
337            let name = name_part.trim();
338            let value = value_part.trim();
339
340            // Handle `name.workspace = true` — extract dep name
341            if let Some((dep_name, sub_key)) = name.split_once('.') {
342                let dep_name = dep_name.trim();
343                let sub_key = sub_key.trim();
344                if sub_key == "workspace" && !dep_name.is_empty() {
345                    deps.push(DepEntry {
346                        ecosystem: DepEcosystem::Cargo,
347                        name: dep_name.to_string(),
348                        version: DepVersion::Workspace,
349                        manifest: ManifestKind::CargoToml,
350                        dev,
351                    });
352                }
353                continue;
354            }
355
356            if name.is_empty() {
357                continue;
358            }
359
360            let version = if value.starts_with('"') {
361                // Simple form: name = "version"
362                extract_quoted_string(value)
363            } else if value.starts_with('{') {
364                // Inline table: name = { version = "...", ... }
365                extract_version_from_inline_table(value)
366            } else {
367                // Unknown form — skip
368                continue;
369            };
370
371            if let Some(version) = version {
372                deps.push(DepEntry {
373                    ecosystem: DepEcosystem::Cargo,
374                    name: name.to_string(),
375                    version: DepVersion::Declared(version),
376                    manifest: ManifestKind::CargoToml,
377                    dev,
378                });
379            }
380        }
381    }
382
383    // Flush any trailing table dep (file ended inside a `[dependencies.X]` block).
384    if let Some(name) = table_dep_name {
385        deps.push(DepEntry {
386            name,
387            ecosystem: DepEcosystem::Cargo,
388            version: DepVersion::Declared(String::new()),
389            manifest: ManifestKind::CargoToml,
390            dev: table_dev,
391        });
392    }
393
394    deps
395}
396
397/// Parse `package.json` via serde_json.
398fn parse_package_json(content: &str) -> Vec<DepEntry> {
399    let parsed: serde_json::Value = match serde_json::from_str(content) {
400        Ok(v) => v,
401        Err(_) => return Vec::new(),
402    };
403
404    let mut deps = Vec::new();
405
406    if let Some(obj) = parsed.get("dependencies").and_then(|v| v.as_object()) {
407        for (name, version) in obj {
408            deps.push(DepEntry {
409                ecosystem: DepEcosystem::Npm,
410                name: name.clone(),
411                version: DepVersion::Declared(version.as_str().unwrap_or("*").to_string()),
412                manifest: ManifestKind::PackageJson,
413                dev: false,
414            });
415        }
416    }
417
418    if let Some(obj) = parsed.get("devDependencies").and_then(|v| v.as_object()) {
419        for (name, version) in obj {
420            deps.push(DepEntry {
421                ecosystem: DepEcosystem::Npm,
422                name: name.clone(),
423                version: DepVersion::Declared(version.as_str().unwrap_or("*").to_string()),
424                manifest: ManifestKind::PackageJson,
425                dev: true,
426            });
427        }
428    }
429
430    deps
431}
432
433/// Parse `go.mod` via line-based parsing.
434///
435/// Handles both multi-line `require ( ... )` blocks and single-line `require module version`.
436fn parse_go_mod(content: &str) -> Vec<DepEntry> {
437    let mut deps = Vec::new();
438    let mut in_require_block = false;
439
440    for line in content.lines() {
441        let trimmed = line.trim();
442
443        if trimmed.starts_with("require (") || trimmed == "require(" {
444            in_require_block = true;
445            continue;
446        }
447
448        if in_require_block {
449            if trimmed == ")" {
450                in_require_block = false;
451                continue;
452            }
453
454            // Lines inside require block: `module/path v1.2.3 // indirect`
455            if let Some(dep) = parse_go_require_line(trimmed) {
456                deps.push(dep);
457            }
458            continue;
459        }
460
461        // Single-line require: `require module/path v1.2.3`
462        if let Some(rest) = trimmed.strip_prefix("require ") {
463            let rest = rest.trim();
464            if let Some(dep) = parse_go_require_line(rest) {
465                deps.push(dep);
466            }
467        }
468    }
469
470    deps
471}
472
473// ── String extraction helpers ───────────────────────────────────────────────
474
475/// Extract a quoted string: `"value"` → `Some("value")`.
476fn extract_quoted_string(s: &str) -> Option<String> {
477    let s = s.trim();
478    if s.starts_with('"') && s.len() > 1 {
479        if let Some(end) = s[1..].find('"') {
480            return Some(s[1..1 + end].to_string());
481        }
482    }
483    None
484}
485
486/// Extract `version` from an inline TOML table: `{ version = "1.0", features = [...] }`.
487fn extract_version_from_inline_table(s: &str) -> Option<String> {
488    // Find `version = "..."` inside the braces.
489    let inner = s.trim().trim_start_matches('{').trim_end_matches('}');
490    for part in inner.split(',') {
491        let part = part.trim();
492        if let Some((key, val)) = part.split_once('=') {
493            if key.trim() == "version" {
494                return extract_quoted_string(val);
495            }
496        }
497    }
498    None
499}
500
501/// Parse a single go.mod require line: `module/path v1.2.3` or `module/path v1.2.3 // indirect`.
502fn parse_go_require_line(line: &str) -> Option<DepEntry> {
503    let line = line.trim();
504    if line.is_empty() || line.starts_with("//") {
505        return None;
506    }
507
508    // Strip trailing comment
509    let without_comment = if let Some(idx) = line.find("//") {
510        line[..idx].trim()
511    } else {
512        line
513    };
514
515    let mut parts = without_comment.split_whitespace();
516    let module = parts.next()?;
517    let version = parts.next().unwrap_or("").to_string();
518
519    Some(DepEntry {
520        ecosystem: DepEcosystem::Go,
521        name: module.to_string(),
522        version: DepVersion::Declared(version),
523        manifest: ManifestKind::GoMod,
524        dev: false,
525    })
526}
527
528// ── Tests ───────────────────────────────────────────────────────────────────
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::fs;
534    use std::path::PathBuf;
535    use tempfile::TempDir;
536
537    // ── Helpers ─────────────────────────────────────────────────────────────
538
539    fn find_dep<'a>(deps: &'a [DepEntry], name: &str) -> Option<&'a DepEntry> {
540        deps.iter().find(|d| d.name == name)
541    }
542
543    fn write(dir: &Path, rel: &str, content: &str) {
544        let full = dir.join(rel);
545        if let Some(parent) = full.parent() {
546            fs::create_dir_all(parent).unwrap();
547        }
548        fs::write(full, content).unwrap();
549    }
550
551    fn walked_file(rel_path: &str) -> WalkedFile {
552        WalkedFile {
553            abs_path: PathBuf::from(rel_path),
554            rel_path: rel_path.to_string(),
555            language: super::super::walker::Language::Unknown,
556            size_bytes: 0,
557            mtime_secs: 0,
558        }
559    }
560
561    // ── Cargo.toml ──────────────────────────────────────────────────────────
562
563    #[test]
564    fn cargo_toml_basic() {
565        let deps = parse_cargo_toml(
566            r#"
567[package]
568name = "my-crate"
569version = "0.1.0"
570
571[dependencies]
572serde = "1.0"
573anyhow = "1.0"
574tokio = "1.40"
575"#,
576        );
577
578        assert_eq!(deps.len(), 3);
579        let serde = find_dep(&deps, "serde").unwrap();
580        assert_eq!(serde.ecosystem, DepEcosystem::Cargo);
581        assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
582        assert_eq!(serde.manifest, ManifestKind::CargoToml);
583        assert!(!serde.dev);
584    }
585
586    #[test]
587    fn cargo_toml_inline_table() {
588        let deps = parse_cargo_toml(
589            r#"
590[dependencies]
591serde = { version = "1.0", features = ["derive"] }
592tokio = { version = "1.40", features = ["full"] }
593"#,
594        );
595
596        assert_eq!(deps.len(), 2);
597        let serde = find_dep(&deps, "serde").unwrap();
598        assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
599        let tokio = find_dep(&deps, "tokio").unwrap();
600        assert_eq!(tokio.version, DepVersion::Declared("1.40".into()));
601    }
602
603    #[test]
604    fn cargo_toml_dev_deps() {
605        let deps = parse_cargo_toml(
606            r#"
607[dependencies]
608serde = "1.0"
609
610[dev-dependencies]
611tempfile = "3.10"
612criterion = "0.5"
613"#,
614        );
615
616        assert_eq!(deps.len(), 3);
617        let serde = find_dep(&deps, "serde").unwrap();
618        assert!(!serde.dev);
619        let tempfile = find_dep(&deps, "tempfile").unwrap();
620        assert!(tempfile.dev);
621        let criterion = find_dep(&deps, "criterion").unwrap();
622        assert!(criterion.dev);
623    }
624
625    #[test]
626    fn cargo_toml_build_deps() {
627        let deps = parse_cargo_toml(
628            r#"
629[build-dependencies]
630cc = "1.0"
631"#,
632        );
633
634        assert_eq!(deps.len(), 1);
635        let cc = find_dep(&deps, "cc").unwrap();
636        assert!(cc.dev, "build-dependencies should be flagged as dev");
637    }
638
639    #[test]
640    fn cargo_toml_workspace_dep() {
641        let deps = parse_cargo_toml(
642            r#"
643[dependencies]
644serde.workspace = true
645tokio.workspace = true
646"#,
647        );
648
649        assert_eq!(deps.len(), 2);
650        let serde = find_dep(&deps, "serde").unwrap();
651        assert_eq!(serde.version, DepVersion::Workspace);
652    }
653
654    #[test]
655    fn cargo_toml_table_form() {
656        let deps = parse_cargo_toml(
657            r#"
658[dependencies.serde]
659version = "1.0"
660features = ["derive"]
661
662[dependencies.tokio]
663version = "1.40"
664features = ["full"]
665
666[dev-dependencies.tempfile]
667version = "3.10"
668"#,
669        );
670
671        assert_eq!(deps.len(), 3);
672        let serde = find_dep(&deps, "serde").unwrap();
673        assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
674        assert!(!serde.dev);
675        let tokio = find_dep(&deps, "tokio").unwrap();
676        assert_eq!(tokio.version, DepVersion::Declared("1.40".into()));
677        let tempfile = find_dep(&deps, "tempfile").unwrap();
678        assert!(tempfile.dev);
679    }
680
681    #[test]
682    fn cargo_toml_empty() {
683        let deps = parse_cargo_toml(
684            r#"
685[package]
686name = "empty"
687version = "0.1.0"
688"#,
689        );
690
691        assert!(deps.is_empty());
692    }
693
694    // ── package.json ────────────────────────────────────────────────────────
695
696    #[test]
697    fn package_json_basic() {
698        let deps = parse_package_json(
699            r#"{
700  "name": "my-app",
701  "dependencies": {
702    "react": "^18.0.0",
703    "express": "~4.18.0"
704  },
705  "devDependencies": {
706    "jest": "^29.0.0",
707    "typescript": "^5.0.0"
708  }
709}"#,
710        );
711
712        assert_eq!(deps.len(), 4);
713        let react = find_dep(&deps, "react").unwrap();
714        assert_eq!(react.ecosystem, DepEcosystem::Npm);
715        assert_eq!(react.version, DepVersion::Declared("^18.0.0".into()));
716        assert!(!react.dev);
717        assert_eq!(react.manifest, ManifestKind::PackageJson);
718
719        let jest = find_dep(&deps, "jest").unwrap();
720        assert!(jest.dev);
721    }
722
723    #[test]
724    fn package_json_no_deps() {
725        let deps = parse_package_json(r#"{"name": "empty-app", "version": "1.0.0"}"#);
726        assert!(deps.is_empty());
727    }
728
729    #[test]
730    fn package_json_malformed() {
731        let deps = parse_package_json("{ this is not json }");
732        assert!(
733            deps.is_empty(),
734            "malformed JSON should return empty, not error"
735        );
736    }
737
738    // ── go.mod ──────────────────────────────────────────────────────────────
739
740    #[test]
741    fn go_mod_basic() {
742        let deps = parse_go_mod(
743            r#"
744module github.com/example/myapp
745
746go 1.21
747
748require (
749	github.com/gin-gonic/gin v1.9.1
750	github.com/lib/pq v1.10.9
751	golang.org/x/sync v0.5.0
752)
753"#,
754        );
755
756        assert_eq!(deps.len(), 3);
757        let gin = find_dep(&deps, "github.com/gin-gonic/gin").unwrap();
758        assert_eq!(gin.ecosystem, DepEcosystem::Go);
759        assert_eq!(gin.version, DepVersion::Declared("v1.9.1".into()));
760        assert_eq!(gin.manifest, ManifestKind::GoMod);
761        assert!(!gin.dev);
762    }
763
764    #[test]
765    fn go_mod_single_require() {
766        let deps = parse_go_mod(
767            r#"
768module github.com/example/myapp
769
770go 1.21
771
772require github.com/lib/pq v1.10.9
773"#,
774        );
775
776        assert_eq!(deps.len(), 1);
777        assert_eq!(deps[0].name, "github.com/lib/pq");
778        assert_eq!(deps[0].version, DepVersion::Declared("v1.10.9".into()));
779    }
780
781    #[test]
782    fn go_mod_indirect() {
783        let deps = parse_go_mod(
784            r#"
785require (
786	github.com/direct/dep v1.0.0
787	github.com/indirect/dep v2.0.0 // indirect
788)
789"#,
790        );
791
792        assert_eq!(deps.len(), 2, "indirect deps should still be included");
793        assert!(find_dep(&deps, "github.com/indirect/dep").is_some());
794    }
795
796    #[test]
797    fn go_mod_empty() {
798        let deps = parse_go_mod(
799            r#"
800module github.com/example/myapp
801
802go 1.21
803"#,
804        );
805
806        assert!(deps.is_empty());
807    }
808
809    // ── Integration tests ───────────────────────────────────────────────────
810
811    #[test]
812    fn parse_dependencies_integration() {
813        let dir = TempDir::new().unwrap();
814
815        write(
816            dir.path(),
817            "Cargo.toml",
818            r#"
819[dependencies]
820serde = "1.0"
821anyhow = "1.0"
822"#,
823        );
824
825        write(
826            dir.path(),
827            "package.json",
828            r#"{"dependencies": {"react": "^18.0.0"}}"#,
829        );
830
831        write(
832            dir.path(),
833            "go.mod",
834            r#"
835module example.com/app
836
837require github.com/gin-gonic/gin v1.9.1
838"#,
839        );
840
841        let walked = vec![
842            walked_file("Cargo.toml"),
843            walked_file("package.json"),
844            walked_file("go.mod"),
845        ];
846
847        let signals = parse_dependencies(dir.path(), &walked).unwrap();
848
849        assert_eq!(signals.manifests_found.len(), 3);
850        assert_eq!(signals.deps.len(), 4);
851        assert!(find_dep(&signals.deps, "serde").is_some());
852        assert!(find_dep(&signals.deps, "react").is_some());
853        assert!(find_dep(&signals.deps, "github.com/gin-gonic/gin").is_some());
854    }
855
856    #[test]
857    fn no_manifests_returns_empty() {
858        let dir = TempDir::new().unwrap();
859        write(dir.path(), "src/main.rs", "fn main() {}");
860
861        let walked = vec![walked_file("src/main.rs")];
862        let signals = parse_dependencies(dir.path(), &walked).unwrap();
863
864        assert!(signals.deps.is_empty());
865        assert!(signals.manifests_found.is_empty());
866    }
867
868    #[test]
869    fn dedup_across_manifests() {
870        let dir = TempDir::new().unwrap();
871
872        // Root Cargo.toml has serde 1.0
873        write(
874            dir.path(),
875            "Cargo.toml",
876            r#"
877[dependencies]
878serde = "1.0"
879"#,
880        );
881
882        // Nested crate also has serde but different version
883        write(
884            dir.path(),
885            "subcrate/Cargo.toml",
886            r#"
887[dependencies]
888serde = "1.1"
889anyhow = "1.0"
890"#,
891        );
892
893        let walked = vec![
894            walked_file("Cargo.toml"),
895            walked_file("subcrate/Cargo.toml"),
896        ];
897
898        let signals = parse_dependencies(dir.path(), &walked).unwrap();
899
900        // serde should appear only once, from root (shallowest)
901        let serde_entries: Vec<&DepEntry> =
902            signals.deps.iter().filter(|d| d.name == "serde").collect();
903        assert_eq!(serde_entries.len(), 1, "serde should be deduplicated");
904        assert_eq!(
905            serde_entries[0].version,
906            DepVersion::Declared("1.0".into()),
907            "root manifest should win"
908        );
909
910        // anyhow only in subcrate — should still be included
911        assert!(find_dep(&signals.deps, "anyhow").is_some());
912    }
913
914    #[test]
915    fn same_name_in_different_ecosystems_do_not_collapse() {
916        let dir = TempDir::new().unwrap();
917
918        write(
919            dir.path(),
920            "Cargo.toml",
921            r#"
922[dependencies]
923react = "1.0"
924"#,
925        );
926
927        write(
928            dir.path(),
929            "package.json",
930            r#"{"dependencies": {"react": "^18.0.0"}}"#,
931        );
932
933        let walked = vec![walked_file("Cargo.toml"), walked_file("package.json")];
934        let signals = parse_dependencies(dir.path(), &walked).unwrap();
935
936        let react_entries: Vec<&DepEntry> =
937            signals.deps.iter().filter(|d| d.name == "react").collect();
938        assert_eq!(
939            react_entries.len(),
940            2,
941            "cross-ecosystem names must not collapse"
942        );
943        assert!(react_entries
944            .iter()
945            .any(|d| d.ecosystem == DepEcosystem::Cargo));
946        assert!(react_entries
947            .iter()
948            .any(|d| d.ecosystem == DepEcosystem::Npm));
949    }
950
951    #[test]
952    fn dep_key_helpers_support_new_and_legacy_formats() {
953        let dep = DepEntry {
954            ecosystem: DepEcosystem::Cargo,
955            name: "serde".into(),
956            version: DepVersion::Declared("1.0".into()),
957            manifest: ManifestKind::CargoToml,
958            dev: false,
959        };
960
961        assert_eq!(dep_record_key(&dep), "dep:cargo:serde");
962        assert_eq!(dep_display_name_from_key("dep:cargo:serde"), "serde");
963        assert_eq!(dep_display_name_from_key("dep:serde"), "serde");
964        assert_eq!(
965            parse_dep_key("dep:npm:react"),
966            Some((Some(DepEcosystem::Npm), "react"))
967        );
968        assert_eq!(parse_dep_key("dep:serde"), Some((None, "serde")));
969    }
970}