use std::collections::HashMap;
use std::path::Path;
use toml::Value;
#[derive(Debug, Clone)]
pub struct CrateManifest {
pub name: String,
pub version: String,
pub description: Option<String>,
pub edition: String,
pub dependencies: Vec<CrateDependency>,
pub dev_dependencies: Vec<CrateDependency>,
pub build_dependencies: Vec<CrateDependency>,
pub workspace_member: bool,
}
#[derive(Debug, Clone)]
pub struct CrateDependency {
pub name: String,
pub version_req: String,
pub features: Vec<String>,
pub optional: bool,
pub dev: bool,
pub build: bool,
pub source: DependencySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencySource {
Workspace,
CratesIo,
Git { url: String },
Path { path: String },
}
pub fn parse_workspace_dependencies(
workspace_toml: &Path,
) -> Result<HashMap<String, String>, String> {
let raw = std::fs::read_to_string(workspace_toml)
.map_err(|e| format!("cannot read {}: {e}", workspace_toml.display()))?;
let doc: Value = raw
.parse::<Value>()
.map_err(|e| format!("TOML parse error in {}: {e}", workspace_toml.display()))?;
let mut map = HashMap::new();
let Some(ws_deps) = doc
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(|d| d.as_table())
else {
return Ok(map);
};
for (name, spec) in ws_deps {
let version = match spec {
Value::String(v) => v.clone(),
Value::Table(t) => {
if let Some(v) = t.get("version").and_then(|v| v.as_str()) {
v.to_string()
} else {
continue; }
}
_ => continue,
};
map.insert(name.clone(), version);
}
Ok(map)
}
impl CrateManifest {
pub fn from_path(cargo_toml: &Path) -> Result<Self, String> {
let workspace_deps = cargo_toml
.parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .map(|root| root.join("Cargo.toml"))
.and_then(|ws| parse_workspace_dependencies(&ws).ok())
.unwrap_or_default();
Self::from_path_with_workspace(cargo_toml, &workspace_deps, false)
}
pub fn from_path_with_workspace(
cargo_toml: &Path,
workspace_deps: &HashMap<String, String>,
workspace_member: bool,
) -> Result<Self, String> {
let raw = std::fs::read_to_string(cargo_toml)
.map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?;
let doc: Value = raw
.parse::<Value>()
.map_err(|e| format!("TOML parse error in {}: {e}", cargo_toml.display()))?;
let pkg = doc
.get("package")
.and_then(|p| p.as_table())
.ok_or_else(|| format!("missing [package] in {}", cargo_toml.display()))?;
let name = pkg
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("missing package.name in {}", cargo_toml.display()))?
.to_string();
let version = resolve_package_version(pkg, workspace_deps, cargo_toml)?;
let description = pkg
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let edition = pkg
.get("edition")
.and_then(|v| v.as_str())
.unwrap_or("2021")
.to_string();
let dependencies = parse_dep_table(&doc, "dependencies", false, false, workspace_deps);
let dev_dependencies =
parse_dep_table(&doc, "dev-dependencies", true, false, workspace_deps);
let build_dependencies =
parse_dep_table(&doc, "build-dependencies", false, true, workspace_deps);
Ok(CrateManifest {
name,
version,
description,
edition,
dependencies,
dev_dependencies,
build_dependencies,
workspace_member,
})
}
pub fn all_dependencies(&self) -> impl Iterator<Item = &CrateDependency> {
self.dependencies
.iter()
.chain(self.dev_dependencies.iter())
.chain(self.build_dependencies.iter())
}
}
fn resolve_package_version(
pkg: &toml::value::Table,
workspace_deps: &HashMap<String, String>,
cargo_toml: &Path,
) -> Result<String, String> {
let Some(v) = pkg.get("version") else {
return Ok("0.0.0".to_string()); };
match v {
Value::String(s) => Ok(s.clone()),
Value::Table(t) => {
if t.get("workspace")
.and_then(|w| w.as_bool())
.unwrap_or(false)
{
let crate_name = pkg.get("name").and_then(|n| n.as_str()).unwrap_or("");
let _ = crate_name;
workspace_deps
.get("")
.or_else(|| workspace_deps.values().next())
.cloned()
.ok_or_else(|| {
format!(
"version.workspace = true in {} but no workspace version found",
cargo_toml.display()
)
})
} else {
Ok("0.0.0".to_string())
}
}
_ => Ok("0.0.0".to_string()),
}
}
fn parse_dep_table(
doc: &Value,
table_key: &str,
dev: bool,
build: bool,
workspace_deps: &HashMap<String, String>,
) -> Vec<CrateDependency> {
let Some(table) = doc.get(table_key).and_then(|t| t.as_table()) else {
return Vec::new();
};
table
.iter()
.map(|(key, spec)| parse_dep_entry(key, spec, dev, build, workspace_deps))
.collect()
}
fn parse_dep_entry(
key: &str,
spec: &Value,
dev: bool,
build: bool,
workspace_deps: &HashMap<String, String>,
) -> CrateDependency {
match spec {
Value::String(ver) => CrateDependency {
name: key.to_string(),
version_req: ver.clone(),
features: Vec::new(),
optional: false,
dev,
build,
source: DependencySource::CratesIo,
},
Value::Table(t) => {
if t.get("workspace")
.and_then(|w| w.as_bool())
.unwrap_or(false)
{
let version_req = workspace_deps
.get(key)
.cloned()
.unwrap_or_else(|| "*".to_string());
let features = extract_features(t);
let optional = t.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
return CrateDependency {
name: key.to_string(),
version_req,
features,
optional,
dev,
build,
source: DependencySource::Workspace,
};
}
let source = if let Some(git_url) = t.get("git").and_then(|v| v.as_str()) {
DependencySource::Git {
url: git_url.to_string(),
}
} else if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
DependencySource::Path {
path: p.to_string(),
}
} else {
DependencySource::CratesIo
};
let version_req = t
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string();
let features = extract_features(t);
let optional = t.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
let name = t
.get("package")
.and_then(|v| v.as_str())
.unwrap_or(key)
.to_string();
CrateDependency {
name,
version_req,
features,
optional,
dev,
build,
source,
}
}
_ => CrateDependency {
name: key.to_string(),
version_req: "*".to_string(),
features: Vec::new(),
optional: false,
dev,
build,
source: DependencySource::CratesIo,
},
}
}
fn extract_features(t: &toml::value::Table) -> Vec<String> {
t.get("features")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| f.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn crate_path(name: &str) -> PathBuf {
workspace_root()
.join("crates")
.join(name)
.join("Cargo.toml")
}
#[test]
fn test_parse_nusy_arrow_core_cargo_toml() {
let path = crate_path("nusy-arrow-core");
let manifest = CrateManifest::from_path(&path)
.unwrap_or_else(|e| panic!("Failed to parse nusy-arrow-core/Cargo.toml: {e}"));
assert_eq!(manifest.name, "nusy-arrow-core");
assert!(!manifest.version.is_empty(), "version should not be empty");
assert!(
!manifest.dependencies.is_empty(),
"nusy-arrow-core should have dependencies"
);
}
#[test]
fn test_workspace_dependency_inheritance_resolves_version() {
let ws_toml = workspace_root().join("Cargo.toml");
let ws_deps =
parse_workspace_dependencies(&ws_toml).expect("should parse workspace Cargo.toml");
assert!(
ws_deps.contains_key("arrow"),
"workspace should have arrow dep"
);
assert!(
ws_deps.contains_key("chrono"),
"workspace should have chrono dep"
);
let arrow_version = &ws_deps["arrow"];
assert!(!arrow_version.is_empty(), "arrow should have a version");
let path = crate_path("nusy-arrow-core");
let manifest = CrateManifest::from_path_with_workspace(&path, &ws_deps, true)
.expect("parse nusy-arrow-core with workspace deps");
let arrow_dep = manifest
.dependencies
.iter()
.find(|d| d.name == "arrow")
.expect("should have arrow dep");
assert_eq!(
arrow_dep.source,
DependencySource::Workspace,
"arrow dep should be Workspace"
);
assert_eq!(
arrow_dep.version_req, *arrow_version,
"arrow version should be resolved from workspace"
);
}
#[test]
fn test_path_dependency_source() {
let path = crate_path("nusy-codegraph");
let manifest = CrateManifest::from_path(&path).expect("parse nusy-codegraph/Cargo.toml");
let core_dep = manifest
.dependencies
.iter()
.find(|d| d.name == "nusy-arrow-core")
.expect("nusy-codegraph should dep on nusy-arrow-core");
assert!(
matches!(core_dep.source, DependencySource::Path { .. }),
"nusy-arrow-core dep should be Path, got {:?}",
core_dep.source
);
}
#[test]
fn test_dev_dependencies_flagged() {
let path = crate_path("nusy-codegraph");
let manifest = CrateManifest::from_path(&path).expect("parse nusy-codegraph/Cargo.toml");
let tempfile_dep = manifest
.dev_dependencies
.iter()
.find(|d| d.name == "tempfile")
.expect("nusy-codegraph should have tempfile as dev dep");
assert!(tempfile_dep.dev, "tempfile should have dev=true");
assert!(!tempfile_dep.build, "tempfile should have build=false");
for dep in &manifest.dependencies {
assert!(
!dep.dev,
"runtime dep {} should not be flagged as dev",
dep.name
);
}
}
#[test]
fn test_external_crate_io_dependency_source() {
let toml_src = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1.0"
"#;
let doc: Value = toml_src.parse().unwrap();
let ws_deps = HashMap::new();
let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "serde");
assert_eq!(deps[0].version_req, "1.0");
assert_eq!(deps[0].source, DependencySource::CratesIo);
assert!(!deps[0].dev);
assert!(!deps[0].build);
}
#[test]
fn test_git_dependency_source() {
let toml_src = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
my-lib = { git = "https://github.com/example/my-lib", branch = "main" }
"#;
let doc: Value = toml_src.parse().unwrap();
let ws_deps = HashMap::new();
let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "my-lib");
assert_eq!(
deps[0].source,
DependencySource::Git {
url: "https://github.com/example/my-lib".to_string()
}
);
}
#[test]
fn test_optional_dependency() {
let toml_src = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
optional-dep = { version = "1.0", optional = true }
required-dep = "2.0"
"#;
let doc: Value = toml_src.parse().unwrap();
let ws_deps = HashMap::new();
let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
let opt = deps.iter().find(|d| d.name == "optional-dep").unwrap();
assert!(opt.optional, "optional-dep should be optional");
let req = deps.iter().find(|d| d.name == "required-dep").unwrap();
assert!(!req.optional, "required-dep should not be optional");
}
#[test]
fn test_features_are_parsed() {
let toml_src = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full", "rt-multi-thread"] }
"#;
let doc: Value = toml_src.parse().unwrap();
let ws_deps = HashMap::new();
let deps = super::parse_dep_table(&doc, "dependencies", false, false, &ws_deps);
let tokio = deps.iter().find(|d| d.name == "tokio").unwrap();
assert_eq!(tokio.features, vec!["full", "rt-multi-thread"]);
}
#[test]
fn test_build_dependencies_flagged() {
let toml_src = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[build-dependencies]
cc = "1.0"
"#;
let doc: Value = toml_src.parse().unwrap();
let ws_deps = HashMap::new();
let deps = super::parse_dep_table(&doc, "build-dependencies", false, true, &ws_deps);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "cc");
assert!(!deps[0].dev, "build dep should not be dev");
assert!(deps[0].build, "cc should be flagged as build");
}
#[test]
fn test_parse_workspace_cargo_toml_round_trip() {
let ws_toml = workspace_root().join("Cargo.toml");
let ws_deps = parse_workspace_dependencies(&ws_toml).expect("parse workspace Cargo.toml");
assert!(
ws_deps.len() > 3,
"expected >3 workspace deps, got {}",
ws_deps.len()
);
for (name, ver) in &ws_deps {
assert!(!ver.is_empty(), "workspace dep {name} has empty version");
}
}
}