use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use callisto_model::{Ecosystem, ManifestFormat, ManifestRole, PackageId};
use crate::error::GraphError;
pub struct IdentityResolver {
workspace_root: PathBuf,
}
impl IdentityResolver {
pub fn new(workspace_root: &Path) -> Result<Self, GraphError> {
Ok(IdentityResolver {
workspace_root: workspace_root.to_path_buf(),
})
}
pub fn resolve(&self, project_root: &Path, ecosystem: Ecosystem) -> Result<PackageId, GraphError> {
let abs = self.workspace_root.join(project_root);
let name = match ecosystem {
Ecosystem::Cargo => {
let cargo_toml = abs.join("Cargo.toml");
let content =
std::fs::read_to_string(&cargo_toml).map_err(|e| callisto_model::ManifestError::Read {
path: project_root.join("Cargo.toml"),
message: e.to_string(),
})?;
let doc: toml_edit::DocumentMut =
content
.parse()
.map_err(|e: toml_edit::TomlError| callisto_model::ManifestError::Parse {
path: project_root.join("Cargo.toml"),
format: ManifestFormat::CargoToml,
message: e.to_string(),
})?;
callisto_manifests::cargo_package_name(&doc)
.ok_or_else(|| callisto_model::ManifestError::MissingField {
path: project_root.join("Cargo.toml"),
field: "package.name",
})?
.to_string()
}
Ecosystem::Npm => {
let pkg_json = abs.join("package.json");
let content = std::fs::read_to_string(&pkg_json).map_err(|e| callisto_model::ManifestError::Read {
path: project_root.join("package.json"),
message: e.to_string(),
})?;
let doc: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&content).map_err(|e| callisto_model::ManifestError::Parse {
path: project_root.join("package.json"),
format: ManifestFormat::PackageJson,
message: e.to_string(),
})?;
callisto_manifests::npm_package_name(&doc)
.ok_or_else(|| callisto_model::ManifestError::MissingField {
path: project_root.join("package.json"),
field: "name",
})?
.to_string()
}
Ecosystem::Pypi => {
let pyproject_toml = abs.join("pyproject.toml");
let content =
std::fs::read_to_string(&pyproject_toml).map_err(|e| callisto_model::ManifestError::Read {
path: project_root.join("pyproject.toml"),
message: e.to_string(),
})?;
let doc: toml_edit::DocumentMut =
content
.parse()
.map_err(|e: toml_edit::TomlError| callisto_model::ManifestError::Parse {
path: project_root.join("pyproject.toml"),
format: ManifestFormat::PyprojectToml,
message: e.to_string(),
})?;
callisto_manifests::python_package_name(&doc)
.ok_or_else(|| callisto_model::ManifestError::MissingField {
path: project_root.join("pyproject.toml"),
field: "project.name / tool.poetry.name / tool.flit.metadata.module",
})?
.to_string()
}
_ => {
return Err(GraphError::AmbiguousName {
name: "unsupported ecosystem".to_string(),
candidates: Vec::new(),
});
}
};
PackageId::parse(&name).map_err(|_err| GraphError::AmbiguousName {
name: name.clone(),
candidates: Vec::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_cargo_package_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo).unwrap();
assert_eq!(id.name(), "my-crate");
}
#[test]
fn resolves_cargo_package_name_with_workspace_inherited_version() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"inheriting-crate\"\nversion.workspace = true\nedition.workspace = true\n",
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo).unwrap();
assert_eq!(id.name(), "inheriting-crate");
}
#[test]
fn resolves_npm_package_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"my-pkg","version":"1.0.0"}"#,
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Npm).unwrap();
assert_eq!(id.name(), "my-pkg");
}
#[test]
fn resolves_pypi_package_name_pep621() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"my-lib\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
assert_eq!(id.name(), "my-lib");
}
#[test]
fn resolves_pypi_package_name_poetry_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[tool.poetry]\nname = \"my-poetry-lib\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
assert_eq!(id.name(), "my-poetry-lib");
}
#[test]
fn resolves_pypi_package_name_flit_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[tool.flit.metadata]\nmodule = \"my_flit_lib\"\n",
)
.unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
assert_eq!(id.name(), "my_flit_lib");
}
#[test]
fn resolve_errors_when_manifest_file_missing() {
let dir = tempfile::tempdir().unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let result = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo);
assert!(result.is_err());
}
#[test]
fn resolve_errors_when_name_field_missing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let resolver = IdentityResolver::new(dir.path()).unwrap();
let result = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo);
assert!(result.is_err());
}
#[test]
fn resolve_human_finds_prefixed_entry_for_unpromoted_package() {
let mut index = IdentityIndex::default();
let id = PackageId::Bare("foo".to_string());
index.bare.insert("foo".to_string(), id.clone());
index.native.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
index.prefixed.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
let resolved = index
.resolve_human("cargo:foo", &[])
.expect("cargo:foo must resolve via prefixed map");
assert_eq!(resolved, id);
}
#[test]
fn resolve_human_unknown_ecosystem_prefix_falls_through_to_unknown() {
let mut index = IdentityIndex::default();
let id = PackageId::Bare("foo".to_string());
index.bare.insert("foo".to_string(), id.clone());
index.native.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
index.prefixed.insert((Ecosystem::Cargo, "foo".to_string()), id);
let err = index.resolve_human("npm:foo", &[]).unwrap_err();
assert!(
matches!(err, GraphError::UnknownPackage { .. }),
"expected UnknownPackage, got {err:?}"
);
}
#[test]
fn resolve_native_with_fallback_returns_none_on_single_cross_ecosystem_candidate() {
let mut index = IdentityIndex::default();
let cargo_serde = PackageId::Bare("serde".to_string());
index
.native
.insert((Ecosystem::Cargo, "serde".to_string()), cargo_serde);
let mut diagnostics = Vec::new();
let result = index.resolve_native_with_fallback(Ecosystem::Npm, "serde", &mut diagnostics);
assert!(
result.is_none(),
"a same-ecosystem miss must never silently fall back to a cross-ecosystem match"
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, callisto_model::DiagnosticCode::UnknownPackage);
assert_eq!(diagnostics[0].severity, callisto_model::DiagnosticSeverity::Warning);
}
#[test]
fn resolve_native_with_fallback_returns_none_and_one_diagnostic_for_two_candidates() {
let mut index = IdentityIndex::default();
index.native.insert(
(Ecosystem::Cargo, "ambiguous-lib".to_string()),
PackageId::Bare("ambiguous-lib".to_string()),
);
index.native.insert(
(Ecosystem::Pypi, "ambiguous-lib".to_string()),
PackageId::Prefixed {
ecosystem: Ecosystem::Pypi,
name: "ambiguous-lib".to_string(),
},
);
let mut diagnostics = Vec::new();
let result = index.resolve_native_with_fallback(Ecosystem::Npm, "ambiguous-lib", &mut diagnostics);
assert!(result.is_none());
assert_eq!(diagnostics.len(), 1, "exactly one diagnostic, not zero and not two");
assert!(
diagnostics[0].message.contains("cargo:ambiguous-lib")
&& diagnostics[0].message.contains("pypi:ambiguous-lib")
);
}
#[test]
fn resolve_native_unchanged_still_returns_cross_ecosystem_match() {
let mut index = IdentityIndex::default();
let cargo_serde = PackageId::Bare("serde".to_string());
index
.native
.insert((Ecosystem::Cargo, "serde".to_string()), cargo_serde.clone());
let result = index.resolve_native(Ecosystem::Npm, "serde");
assert_eq!(
result,
Some(&cargo_serde),
"resolve_native's own permissive contract is unchanged by this spec"
);
}
}
#[derive(Clone, Debug, Default)]
pub struct IdentityIndex {
pub bare: BTreeMap<String, PackageId>,
pub prefixed: BTreeMap<(Ecosystem, String), PackageId>,
pub native: BTreeMap<(Ecosystem, String), PackageId>,
pub platform: BTreeMap<String, (PackageId, PathBuf, ManifestRole)>,
}
impl IdentityIndex {
pub fn resolve_human(&self, name: &str, siblings: &[PackageId]) -> Result<PackageId, GraphError> {
if let Ok(PackageId::Prefixed { ecosystem, name: n }) = PackageId::parse(name) {
if let Some(id) = self.prefixed.get(&(ecosystem, n)) {
return Ok(id.clone());
}
}
if let Some(id) = self.bare.get(name) {
return Ok(id.clone());
}
let mut candidates = Vec::new();
for ((_eco, n), id) in &self.prefixed {
if n == name {
candidates.push(id.clone());
}
}
if candidates.len() == 1 {
return Ok(candidates[0].clone());
}
if candidates.len() > 1 && !siblings.is_empty() {
for sib in siblings {
for cand in &candidates {
if cand.ecosystem() == sib.ecosystem() {
return Ok(cand.clone());
}
}
}
}
if candidates.is_empty() {
Err(GraphError::UnknownPackage {
id: PackageId::parse(name).unwrap_or_else(|_| PackageId::Bare(name.to_string())),
})
} else {
Err(GraphError::AmbiguousName {
name: name.to_string(),
candidates,
})
}
}
pub fn resolve_native(&self, eco: Ecosystem, name: &str) -> Option<&PackageId> {
if let Some(id) = self.native.get(&(eco, name.to_string())) {
return Some(id);
}
let mut candidates: Vec<&PackageId> = self
.native
.iter()
.filter(|((e, n), _)| *e != eco && n == name)
.map(|(_, id)| id)
.collect();
candidates.dedup_by(|a, b| a == b);
match candidates.len() {
1 => Some(candidates[0]),
_ => None,
}
}
pub fn resolve_native_with_fallback<'a>(
&'a self,
eco: Ecosystem,
name: &str,
diagnostics: &mut Vec<callisto_model::Diagnostic>,
) -> Option<&'a PackageId> {
if let Some(id) = self.native.get(&(eco, name.to_string())) {
return Some(id);
}
let mut candidates: Vec<(Ecosystem, &PackageId)> = self
.native
.iter()
.filter(|((e, n), _)| *e != eco && n == name)
.map(|((e, _), id)| (*e, id))
.collect();
candidates.dedup_by(|a, b| a.1 == b.1);
match candidates.len() {
0 => None,
_ => {
let candidate_names: Vec<String> = candidates
.iter()
.map(|(e, id)| format!("{}:{}", e.prefix(), id.name()))
.collect();
diagnostics.push(callisto_model::Diagnostic {
code: callisto_model::DiagnosticCode::UnknownPackage,
severity: callisto_model::DiagnosticSeverity::Warning,
message: format!(
"dependency name `{}` is ambiguous across ecosystems: {}; \
add an ecosystem prefix (e.g. `cargo:{}`) to disambiguate",
name,
candidate_names.join(", "),
name,
),
package: None,
path: None,
escalated_by: None,
governed_by: None,
});
None
}
}
}
pub fn native_name(&self, id: &PackageId, eco: Ecosystem) -> Option<&str> {
for ((e, name), registered_id) in &self.native {
if e == &eco && registered_id == id {
return Some(name.as_str());
}
}
None
}
pub fn native_names(&self, id: &PackageId) -> impl Iterator<Item = (Ecosystem, &str)> {
let mut results = Vec::new();
for ((e, name), registered_id) in &self.native {
if registered_id == id {
results.push((*e, name.as_str()));
}
}
results.into_iter()
}
pub fn display_form(&self, id: &PackageId) -> String {
id.display_name()
}
pub fn platforms_of(&self, owner: &PackageId) -> impl Iterator<Item = (&str, &Path)> {
let mut results = Vec::new();
for (name, (plat_owner, path, _role)) in &self.platform {
if plat_owner == owner {
results.push((name.as_str(), path.as_path()));
}
}
results.into_iter()
}
}