1use std::collections::HashMap;
21use std::path::Path;
22use toml::Value;
23
24#[derive(Debug, Clone)]
26pub struct CrateManifest {
27 pub name: String,
29 pub version: String,
31 pub description: Option<String>,
33 pub edition: String,
35 pub dependencies: Vec<CrateDependency>,
37 pub dev_dependencies: Vec<CrateDependency>,
39 pub build_dependencies: Vec<CrateDependency>,
41 pub workspace_member: bool,
43}
44
45#[derive(Debug, Clone)]
47pub struct CrateDependency {
48 pub name: String,
50 pub version_req: String,
53 pub features: Vec<String>,
55 pub optional: bool,
57 pub dev: bool,
59 pub build: bool,
61 pub source: DependencySource,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum DependencySource {
68 Workspace,
70 CratesIo,
72 Git { url: String },
74 Path { path: String },
76}
77
78pub 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; }
114 }
115 _ => continue,
116 };
117 map.insert(name.clone(), version);
118 }
119
120 Ok(map)
121}
122
123impl CrateManifest {
126 pub fn from_path(cargo_toml: &Path) -> Result<Self, String> {
133 let workspace_deps = cargo_toml
135 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .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 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 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 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 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 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
215fn 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()); };
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 let crate_name = pkg.get("name").and_then(|n| n.as_str()).unwrap_or("");
241 let _ = crate_name;
245 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
265fn 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
283fn 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 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 Value::Table(t) => {
304 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 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 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 _ => 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
376fn 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#[cfg(test)]
392mod tests {
393 use super::*;
394 use std::path::PathBuf;
395
396 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]
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 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]
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 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 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 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]
477 fn test_path_dependency_source() {
478 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]
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 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 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]
525 fn test_external_crate_io_dependency_source() {
526 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]
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]
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]
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]
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]
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 assert!(
653 ws_deps.len() > 3,
654 "expected >3 workspace deps, got {}",
655 ws_deps.len()
656 );
657
658 for (name, ver) in &ws_deps {
660 assert!(!ver.is_empty(), "workspace dep {name} has empty version");
661 }
662 }
663}