Skip to main content

nusy_codegraph/
cargo_parser.rs

1//! Parse Cargo.toml manifests into [`CrateManifest`] structs.
2//!
3//! Handles:
4//! - `[package]` metadata (name, version, description, edition)
5//! - `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`
6//! - Workspace inheritance: `version.workspace = true` resolved from workspace root
7//! - Dependency sources: workspace, path, git, crates.io
8//! - Inline table form: `dep = "1.0"` and table form: `dep = { version = "1.0" }`
9//!
10//! # Example
11//!
12//! ```no_run
13//! use nusy_codegraph::cargo_parser::CrateManifest;
14//! use std::path::Path;
15//!
16//! let manifest = CrateManifest::from_path(Path::new("Cargo.toml")).unwrap();
17//! println!("{} {}", manifest.name, manifest.version);
18//! ```
19
20use std::collections::HashMap;
21use std::path::Path;
22use toml::Value;
23
24/// A parsed Cargo.toml manifest.
25#[derive(Debug, Clone)]
26pub struct CrateManifest {
27    /// Crate name from `[package].name`
28    pub name: String,
29    /// Resolved version string (workspace inheritance resolved)
30    pub version: String,
31    /// Optional description from `[package].description`
32    pub description: Option<String>,
33    /// Edition, e.g. `"2024"` (defaults to `"2021"` if missing)
34    pub edition: String,
35    /// Runtime dependencies from `[dependencies]`
36    pub dependencies: Vec<CrateDependency>,
37    /// Dev-only dependencies from `[dev-dependencies]`
38    pub dev_dependencies: Vec<CrateDependency>,
39    /// Build-script dependencies from `[build-dependencies]`
40    pub build_dependencies: Vec<CrateDependency>,
41    /// True when the crate is listed in `[workspace].members`
42    pub workspace_member: bool,
43}
44
45/// A single dependency entry parsed from a Cargo.toml dependency table.
46#[derive(Debug, Clone)]
47pub struct CrateDependency {
48    /// Dependency crate name (the key in the table, or `package` override if set)
49    pub name: String,
50    /// Resolved version requirement string, e.g. `"1.0"`, `">=0.14, <1"`, or `"*"` for
51    /// path/git deps without an explicit version
52    pub version_req: String,
53    /// Cargo features enabled for this dependency
54    pub features: Vec<String>,
55    /// Whether the dependency is `optional = true`
56    pub optional: bool,
57    /// True when this dep came from `[dev-dependencies]`
58    pub dev: bool,
59    /// True when this dep came from `[build-dependencies]`
60    pub build: bool,
61    /// Where the dependency lives
62    pub source: DependencySource,
63}
64
65/// Provenance of a dependency.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum DependencySource {
68    /// `dep.workspace = true` — version managed by the workspace root
69    Workspace,
70    /// `dep = "1.0"` or `dep = { version = "1.0" }` — fetched from crates.io
71    CratesIo,
72    /// `dep = { git = "https://..." }` — fetched from a git repository
73    Git { url: String },
74    /// `dep = { path = "../other-crate" }` — local path dependency
75    Path { path: String },
76}
77
78// ─── Workspace dependency cache ─────────────────────────────────────────────
79
80/// Parse the workspace root `Cargo.toml` and return a map of `name → version` for
81/// all entries under `[workspace.dependencies]`.
82///
83/// Only entries that specify a `version` field are included (path/git workspace
84/// deps without versions are omitted).
85pub fn parse_workspace_dependencies(
86    workspace_toml: &Path,
87) -> Result<HashMap<String, String>, String> {
88    let raw = std::fs::read_to_string(workspace_toml)
89        .map_err(|e| format!("cannot read {}: {e}", workspace_toml.display()))?;
90
91    let doc: Value = raw
92        .parse::<Value>()
93        .map_err(|e| format!("TOML parse error in {}: {e}", workspace_toml.display()))?;
94
95    let mut map = HashMap::new();
96
97    let Some(ws_deps) = doc
98        .get("workspace")
99        .and_then(|w| w.get("dependencies"))
100        .and_then(|d| d.as_table())
101    else {
102        return Ok(map);
103    };
104
105    for (name, spec) in ws_deps {
106        let version = match spec {
107            Value::String(v) => v.clone(),
108            Value::Table(t) => {
109                if let Some(v) = t.get("version").and_then(|v| v.as_str()) {
110                    v.to_string()
111                } else {
112                    continue; // path/git dep with no version
113                }
114            }
115            _ => continue,
116        };
117        map.insert(name.clone(), version);
118    }
119
120    Ok(map)
121}
122
123// ─── CrateManifest impl ──────────────────────────────────────────────────────
124
125impl CrateManifest {
126    /// Parse a single `Cargo.toml` at `cargo_toml`.
127    ///
128    /// If the package version uses workspace inheritance (`version.workspace = true`),
129    /// we look for the workspace root at `../../../Cargo.toml` (three levels up from
130    /// `crates/<name>/Cargo.toml`).  The caller can supply pre-parsed workspace deps
131    /// by calling [`CrateManifest::from_path_with_workspace`] directly.
132    pub fn from_path(cargo_toml: &Path) -> Result<Self, String> {
133        // Try to auto-locate the workspace root Cargo.toml
134        let workspace_deps = cargo_toml
135            .parent() // crates/nusy-foo/
136            .and_then(|p| p.parent()) // crates/
137            .and_then(|p| p.parent()) // workspace root
138            .map(|root| root.join("Cargo.toml"))
139            .and_then(|ws| parse_workspace_dependencies(&ws).ok())
140            .unwrap_or_default();
141
142        Self::from_path_with_workspace(cargo_toml, &workspace_deps, false)
143    }
144
145    /// Parse a `Cargo.toml` with an explicit pre-parsed workspace dependency map.
146    ///
147    /// `workspace_member` is set to the supplied value (the workspace root parser sets
148    /// this after checking `[workspace].members`).
149    pub fn from_path_with_workspace(
150        cargo_toml: &Path,
151        workspace_deps: &HashMap<String, String>,
152        workspace_member: bool,
153    ) -> Result<Self, String> {
154        let raw = std::fs::read_to_string(cargo_toml)
155            .map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?;
156
157        let doc: Value = raw
158            .parse::<Value>()
159            .map_err(|e| format!("TOML parse error in {}: {e}", cargo_toml.display()))?;
160
161        // ── [package] ────────────────────────────────────────────────────────
162        let pkg = doc
163            .get("package")
164            .and_then(|p| p.as_table())
165            .ok_or_else(|| format!("missing [package] in {}", cargo_toml.display()))?;
166
167        let name = pkg
168            .get("name")
169            .and_then(|v| v.as_str())
170            .ok_or_else(|| format!("missing package.name in {}", cargo_toml.display()))?
171            .to_string();
172
173        // version may be `"1.0"` or `{ workspace = true }`
174        let version = resolve_package_version(pkg, workspace_deps, cargo_toml)?;
175
176        let description = pkg
177            .get("description")
178            .and_then(|v| v.as_str())
179            .map(|s| s.to_string());
180
181        let edition = pkg
182            .get("edition")
183            .and_then(|v| v.as_str())
184            .unwrap_or("2021")
185            .to_string();
186
187        // ── Dependencies ──────────────────────────────────────────────────────
188        let dependencies = parse_dep_table(&doc, "dependencies", false, false, workspace_deps);
189        let dev_dependencies =
190            parse_dep_table(&doc, "dev-dependencies", true, false, workspace_deps);
191        let build_dependencies =
192            parse_dep_table(&doc, "build-dependencies", false, true, workspace_deps);
193
194        Ok(CrateManifest {
195            name,
196            version,
197            description,
198            edition,
199            dependencies,
200            dev_dependencies,
201            build_dependencies,
202            workspace_member,
203        })
204    }
205
206    /// All dependencies (runtime + dev + build) as a flat iterator.
207    pub fn all_dependencies(&self) -> impl Iterator<Item = &CrateDependency> {
208        self.dependencies
209            .iter()
210            .chain(self.dev_dependencies.iter())
211            .chain(self.build_dependencies.iter())
212    }
213}
214
215// ─── Helpers ─────────────────────────────────────────────────────────────────
216
217/// Resolve the version from `[package]`.
218///
219/// Handles:
220/// - `version = "1.0"` → `"1.0"`
221/// - `version = { workspace = true }` → look up name in workspace_deps
222/// - `version.workspace = true` → TOML dotted-key form (same as above)
223fn resolve_package_version(
224    pkg: &toml::value::Table,
225    workspace_deps: &HashMap<String, String>,
226    cargo_toml: &Path,
227) -> Result<String, String> {
228    let Some(v) = pkg.get("version") else {
229        return Ok("0.0.0".to_string()); // virtual manifest or missing version
230    };
231
232    match v {
233        Value::String(s) => Ok(s.clone()),
234        Value::Table(t) => {
235            if t.get("workspace")
236                .and_then(|w| w.as_bool())
237                .unwrap_or(false)
238            {
239                // Find the crate name — it's the "name" entry we already parsed or will parse
240                let crate_name = pkg.get("name").and_then(|n| n.as_str()).unwrap_or("");
241                // Workspace version is typically global, not per-crate; check workspace.package
242                // We'll fall back to any entry in workspace_deps or "workspace" key
243                // In practice the workspace root has [workspace.package].version
244                let _ = crate_name;
245                // Try to find the workspace package version from the deps map
246                // It's stored under a sentinel "" key if we parse [workspace.package].version
247                workspace_deps
248                    .get("")
249                    .or_else(|| workspace_deps.values().next())
250                    .cloned()
251                    .ok_or_else(|| {
252                        format!(
253                            "version.workspace = true in {} but no workspace version found",
254                            cargo_toml.display()
255                        )
256                    })
257            } else {
258                Ok("0.0.0".to_string())
259            }
260        }
261        _ => Ok("0.0.0".to_string()),
262    }
263}
264
265/// Parse one dependency section (e.g. `[dependencies]`) into a `Vec<CrateDependency>`.
266fn parse_dep_table(
267    doc: &Value,
268    table_key: &str,
269    dev: bool,
270    build: bool,
271    workspace_deps: &HashMap<String, String>,
272) -> Vec<CrateDependency> {
273    let Some(table) = doc.get(table_key).and_then(|t| t.as_table()) else {
274        return Vec::new();
275    };
276
277    table
278        .iter()
279        .map(|(key, spec)| parse_dep_entry(key, spec, dev, build, workspace_deps))
280        .collect()
281}
282
283/// Convert one key-value pair in a dependency table to a [`CrateDependency`].
284fn parse_dep_entry(
285    key: &str,
286    spec: &Value,
287    dev: bool,
288    build: bool,
289    workspace_deps: &HashMap<String, String>,
290) -> CrateDependency {
291    match spec {
292        // Short form: `dep = "1.0"`
293        Value::String(ver) => CrateDependency {
294            name: key.to_string(),
295            version_req: ver.clone(),
296            features: Vec::new(),
297            optional: false,
298            dev,
299            build,
300            source: DependencySource::CratesIo,
301        },
302        // Table form: `dep = { version = "1.0", features = [...], ... }`
303        Value::Table(t) => {
304            // Check for workspace inheritance
305            if t.get("workspace")
306                .and_then(|w| w.as_bool())
307                .unwrap_or(false)
308            {
309                let version_req = workspace_deps
310                    .get(key)
311                    .cloned()
312                    .unwrap_or_else(|| "*".to_string());
313                let features = extract_features(t);
314                let optional = t.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
315                return CrateDependency {
316                    name: key.to_string(),
317                    version_req,
318                    features,
319                    optional,
320                    dev,
321                    build,
322                    source: DependencySource::Workspace,
323                };
324            }
325
326            // Determine source from table fields
327            let source = if let Some(git_url) = t.get("git").and_then(|v| v.as_str()) {
328                DependencySource::Git {
329                    url: git_url.to_string(),
330                }
331            } else if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
332                DependencySource::Path {
333                    path: p.to_string(),
334                }
335            } else {
336                DependencySource::CratesIo
337            };
338
339            let version_req = t
340                .get("version")
341                .and_then(|v| v.as_str())
342                .unwrap_or("*")
343                .to_string();
344            let features = extract_features(t);
345            let optional = t.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
346            // Allow `package` key to override the dependency name
347            let name = t
348                .get("package")
349                .and_then(|v| v.as_str())
350                .unwrap_or(key)
351                .to_string();
352
353            CrateDependency {
354                name,
355                version_req,
356                features,
357                optional,
358                dev,
359                build,
360                source,
361            }
362        }
363        // Unexpected form — produce a stub
364        _ => CrateDependency {
365            name: key.to_string(),
366            version_req: "*".to_string(),
367            features: Vec::new(),
368            optional: false,
369            dev,
370            build,
371            source: DependencySource::CratesIo,
372        },
373    }
374}
375
376/// Extract the `features = [...]` list from a dependency table, returning an empty
377/// vec if missing or malformed.
378fn extract_features(t: &toml::value::Table) -> Vec<String> {
379    t.get("features")
380        .and_then(|v| v.as_array())
381        .map(|arr| {
382            arr.iter()
383                .filter_map(|f| f.as_str().map(|s| s.to_string()))
384                .collect()
385        })
386        .unwrap_or_default()
387}
388
389// ─── Tests ───────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::path::PathBuf;
395
396    /// Path to the workspace root (from crate root, go up two dirs).
397    fn workspace_root() -> PathBuf {
398        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
399            .parent()
400            .unwrap()
401            .parent()
402            .unwrap()
403            .to_path_buf()
404    }
405
406    fn crate_path(name: &str) -> PathBuf {
407        workspace_root()
408            .join("crates")
409            .join(name)
410            .join("Cargo.toml")
411    }
412
413    // ── Test 1: parse a real workspace crate ─────────────────────────────────
414
415    #[test]
416    fn test_parse_nusy_arrow_core_cargo_toml() {
417        let path = crate_path("nusy-arrow-core");
418        let manifest = CrateManifest::from_path(&path)
419            .unwrap_or_else(|e| panic!("Failed to parse nusy-arrow-core/Cargo.toml: {e}"));
420
421        assert_eq!(manifest.name, "nusy-arrow-core");
422        // Version is workspace-inherited so it should be non-empty
423        assert!(!manifest.version.is_empty(), "version should not be empty");
424        assert!(
425            !manifest.dependencies.is_empty(),
426            "nusy-arrow-core should have dependencies"
427        );
428    }
429
430    // ── Test 2: workspace dependency inheritance ──────────────────────────────
431
432    #[test]
433    fn test_workspace_dependency_inheritance_resolves_version() {
434        let ws_toml = workspace_root().join("Cargo.toml");
435        let ws_deps =
436            parse_workspace_dependencies(&ws_toml).expect("should parse workspace Cargo.toml");
437
438        // arrow and chrono are in [workspace.dependencies]
439        assert!(
440            ws_deps.contains_key("arrow"),
441            "workspace should have arrow dep"
442        );
443        assert!(
444            ws_deps.contains_key("chrono"),
445            "workspace should have chrono dep"
446        );
447
448        let arrow_version = &ws_deps["arrow"];
449        assert!(!arrow_version.is_empty(), "arrow should have a version");
450
451        // Now parse a crate that uses { workspace = true }
452        let path = crate_path("nusy-arrow-core");
453        let manifest = CrateManifest::from_path_with_workspace(&path, &ws_deps, true)
454            .expect("parse nusy-arrow-core with workspace deps");
455
456        // arrow should appear as Workspace source
457        let arrow_dep = manifest
458            .dependencies
459            .iter()
460            .find(|d| d.name == "arrow")
461            .expect("should have arrow dep");
462
463        assert_eq!(
464            arrow_dep.source,
465            DependencySource::Workspace,
466            "arrow dep should be Workspace"
467        );
468        assert_eq!(
469            arrow_dep.version_req, *arrow_version,
470            "arrow version should be resolved from workspace"
471        );
472    }
473
474    // ── Test 3: path dependencies ─────────────────────────────────────────────
475
476    #[test]
477    fn test_path_dependency_source() {
478        // nusy-codegraph depends on nusy-arrow-core via path
479        let path = crate_path("nusy-codegraph");
480        let manifest = CrateManifest::from_path(&path).expect("parse nusy-codegraph/Cargo.toml");
481
482        let core_dep = manifest
483            .dependencies
484            .iter()
485            .find(|d| d.name == "nusy-arrow-core")
486            .expect("nusy-codegraph should dep on nusy-arrow-core");
487
488        assert!(
489            matches!(core_dep.source, DependencySource::Path { .. }),
490            "nusy-arrow-core dep should be Path, got {:?}",
491            core_dep.source
492        );
493    }
494
495    // ── Test 4: dev dependencies are flagged ──────────────────────────────────
496
497    #[test]
498    fn test_dev_dependencies_flagged() {
499        let path = crate_path("nusy-codegraph");
500        let manifest = CrateManifest::from_path(&path).expect("parse nusy-codegraph/Cargo.toml");
501
502        // tempfile is a dev dep
503        let tempfile_dep = manifest
504            .dev_dependencies
505            .iter()
506            .find(|d| d.name == "tempfile")
507            .expect("nusy-codegraph should have tempfile as dev dep");
508
509        assert!(tempfile_dep.dev, "tempfile should have dev=true");
510        assert!(!tempfile_dep.build, "tempfile should have build=false");
511
512        // Runtime deps should NOT be flagged as dev
513        for dep in &manifest.dependencies {
514            assert!(
515                !dep.dev,
516                "runtime dep {} should not be flagged as dev",
517                dep.name
518            );
519        }
520    }
521
522    // ── Test 5: external (crates.io) deps are flagged ─────────────────────────
523
524    #[test]
525    fn test_external_crate_io_dependency_source() {
526        // Parse a Cargo.toml in-memory that has a plain crates.io dep
527        let toml_src = r#"
528[package]
529name = "test-crate"
530version = "0.1.0"
531edition = "2021"
532
533[dependencies]
534serde = "1.0"
535"#;
536        let doc: Value = toml_src.parse().unwrap();
537        let ws_deps = HashMap::new();
538        let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
539
540        assert_eq!(deps.len(), 1);
541        assert_eq!(deps[0].name, "serde");
542        assert_eq!(deps[0].version_req, "1.0");
543        assert_eq!(deps[0].source, DependencySource::CratesIo);
544        assert!(!deps[0].dev);
545        assert!(!deps[0].build);
546    }
547
548    // ── Test 6: git dependency ────────────────────────────────────────────────
549
550    #[test]
551    fn test_git_dependency_source() {
552        let toml_src = r#"
553[package]
554name = "test-crate"
555version = "0.1.0"
556edition = "2021"
557
558[dependencies]
559my-lib = { git = "https://github.com/example/my-lib", branch = "main" }
560"#;
561        let doc: Value = toml_src.parse().unwrap();
562        let ws_deps = HashMap::new();
563        let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
564
565        assert_eq!(deps.len(), 1);
566        assert_eq!(deps[0].name, "my-lib");
567        assert_eq!(
568            deps[0].source,
569            DependencySource::Git {
570                url: "https://github.com/example/my-lib".to_string()
571            }
572        );
573    }
574
575    // ── Test 7: optional dependency ───────────────────────────────────────────
576
577    #[test]
578    fn test_optional_dependency() {
579        let toml_src = r#"
580[package]
581name = "test-crate"
582version = "0.1.0"
583edition = "2021"
584
585[dependencies]
586optional-dep = { version = "1.0", optional = true }
587required-dep = "2.0"
588"#;
589        let doc: Value = toml_src.parse().unwrap();
590        let ws_deps = HashMap::new();
591        let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
592
593        let opt = deps.iter().find(|d| d.name == "optional-dep").unwrap();
594        assert!(opt.optional, "optional-dep should be optional");
595
596        let req = deps.iter().find(|d| d.name == "required-dep").unwrap();
597        assert!(!req.optional, "required-dep should not be optional");
598    }
599
600    // ── Test 8: features are parsed ───────────────────────────────────────────
601
602    #[test]
603    fn test_features_are_parsed() {
604        let toml_src = r#"
605[package]
606name = "test-crate"
607version = "0.1.0"
608edition = "2021"
609
610[dependencies]
611tokio = { version = "1", features = ["full", "rt-multi-thread"] }
612"#;
613        let doc: Value = toml_src.parse().unwrap();
614        let ws_deps = HashMap::new();
615        let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
616
617        let tokio = deps.iter().find(|d| d.name == "tokio").unwrap();
618        assert_eq!(tokio.features, vec!["full", "rt-multi-thread"]);
619    }
620
621    // ── Test 9: build dependencies ────────────────────────────────────────────
622
623    #[test]
624    fn test_build_dependencies_flagged() {
625        let toml_src = r#"
626[package]
627name = "test-crate"
628version = "0.1.0"
629edition = "2021"
630
631[build-dependencies]
632cc = "1.0"
633"#;
634        let doc: Value = toml_src.parse().unwrap();
635        let ws_deps = HashMap::new();
636        let deps = super::parse_dep_table(&doc, "build-dependencies", false, true, &ws_deps);
637
638        assert_eq!(deps.len(), 1);
639        assert_eq!(deps[0].name, "cc");
640        assert!(!deps[0].dev, "build dep should not be dev");
641        assert!(deps[0].build, "cc should be flagged as build");
642    }
643
644    // ── Test 10: parse workspace Cargo.toml ───────────────────────────────────
645
646    #[test]
647    fn test_parse_workspace_cargo_toml_round_trip() {
648        let ws_toml = workspace_root().join("Cargo.toml");
649        let ws_deps = parse_workspace_dependencies(&ws_toml).expect("parse workspace Cargo.toml");
650
651        // Should have multiple entries
652        assert!(
653            ws_deps.len() > 3,
654            "expected >3 workspace deps, got {}",
655            ws_deps.len()
656        );
657
658        // Every value should be a non-empty string
659        for (name, ver) in &ws_deps {
660            assert!(!ver.is_empty(), "workspace dep {name} has empty version");
661        }
662    }
663}