1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use callisto_model::{Ecosystem, ManifestFormat, ManifestRole, PackageId};
5
6use crate::error::GraphError;
7
8pub struct IdentityResolver {
9 workspace_root: PathBuf,
10}
11
12impl IdentityResolver {
13 pub fn new(workspace_root: &Path) -> Result<Self, GraphError> {
14 Ok(IdentityResolver {
15 workspace_root: workspace_root.to_path_buf(),
16 })
17 }
18
19 pub fn resolve(&self, project_root: &Path, ecosystem: Ecosystem) -> Result<PackageId, GraphError> {
20 let abs = self.workspace_root.join(project_root);
21 let name = match ecosystem {
22 Ecosystem::Cargo => {
23 let cargo_toml = abs.join("Cargo.toml");
24 let content =
25 std::fs::read_to_string(&cargo_toml).map_err(|e| callisto_model::ManifestError::Read {
26 path: project_root.join("Cargo.toml"),
27 message: e.to_string(),
28 })?;
29 let doc: toml_edit::DocumentMut =
30 content
31 .parse()
32 .map_err(|e: toml_edit::TomlError| callisto_model::ManifestError::Parse {
33 path: project_root.join("Cargo.toml"),
34 format: ManifestFormat::CargoToml,
35 message: e.to_string(),
36 })?;
37 callisto_manifests::cargo_package_name(&doc)
38 .ok_or_else(|| callisto_model::ManifestError::MissingField {
39 path: project_root.join("Cargo.toml"),
40 field: "package.name",
41 })?
42 .to_string()
43 }
44 Ecosystem::Npm => {
45 let pkg_json = abs.join("package.json");
46 let content = std::fs::read_to_string(&pkg_json).map_err(|e| callisto_model::ManifestError::Read {
47 path: project_root.join("package.json"),
48 message: e.to_string(),
49 })?;
50 let doc: serde_json::Map<String, serde_json::Value> =
51 serde_json::from_str(&content).map_err(|e| callisto_model::ManifestError::Parse {
52 path: project_root.join("package.json"),
53 format: ManifestFormat::PackageJson,
54 message: e.to_string(),
55 })?;
56 callisto_manifests::npm_package_name(&doc)
57 .ok_or_else(|| callisto_model::ManifestError::MissingField {
58 path: project_root.join("package.json"),
59 field: "name",
60 })?
61 .to_string()
62 }
63 Ecosystem::Pypi => {
64 let pyproject_toml = abs.join("pyproject.toml");
65 let content =
66 std::fs::read_to_string(&pyproject_toml).map_err(|e| callisto_model::ManifestError::Read {
67 path: project_root.join("pyproject.toml"),
68 message: e.to_string(),
69 })?;
70 let doc: toml_edit::DocumentMut =
71 content
72 .parse()
73 .map_err(|e: toml_edit::TomlError| callisto_model::ManifestError::Parse {
74 path: project_root.join("pyproject.toml"),
75 format: ManifestFormat::PyprojectToml,
76 message: e.to_string(),
77 })?;
78 callisto_manifests::python_package_name(&doc)
79 .ok_or_else(|| callisto_model::ManifestError::MissingField {
80 path: project_root.join("pyproject.toml"),
81 field: "project.name / tool.poetry.name / tool.flit.metadata.module",
82 })?
83 .to_string()
84 }
85 _ => {
86 return Err(GraphError::AmbiguousName {
87 name: "unsupported ecosystem".to_string(),
88 candidates: Vec::new(),
89 });
90 }
91 };
92
93 PackageId::parse(&name).map_err(|_err| GraphError::AmbiguousName {
94 name: name.clone(),
95 candidates: Vec::new(),
96 })
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn resolves_cargo_package_name() {
106 let dir = tempfile::tempdir().unwrap();
107 std::fs::write(
108 dir.path().join("Cargo.toml"),
109 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
110 )
111 .unwrap();
112 let resolver = IdentityResolver::new(dir.path()).unwrap();
113 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo).unwrap();
114 assert_eq!(id.name(), "my-crate");
115 }
116
117 #[test]
124 fn resolves_cargo_package_name_with_workspace_inherited_version() {
125 let dir = tempfile::tempdir().unwrap();
126 std::fs::write(
127 dir.path().join("Cargo.toml"),
128 "[package]\nname = \"inheriting-crate\"\nversion.workspace = true\nedition.workspace = true\n",
129 )
130 .unwrap();
131 let resolver = IdentityResolver::new(dir.path()).unwrap();
132 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo).unwrap();
133 assert_eq!(id.name(), "inheriting-crate");
134 }
135
136 #[test]
137 fn resolves_npm_package_name() {
138 let dir = tempfile::tempdir().unwrap();
139 std::fs::write(
140 dir.path().join("package.json"),
141 r#"{"name":"my-pkg","version":"1.0.0"}"#,
142 )
143 .unwrap();
144 let resolver = IdentityResolver::new(dir.path()).unwrap();
145 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Npm).unwrap();
146 assert_eq!(id.name(), "my-pkg");
147 }
148
149 #[test]
150 fn resolves_pypi_package_name_pep621() {
151 let dir = tempfile::tempdir().unwrap();
152 std::fs::write(
153 dir.path().join("pyproject.toml"),
154 "[project]\nname = \"my-lib\"\nversion = \"1.0.0\"\n",
155 )
156 .unwrap();
157 let resolver = IdentityResolver::new(dir.path()).unwrap();
158 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
159 assert_eq!(id.name(), "my-lib");
160 }
161
162 #[test]
163 fn resolves_pypi_package_name_poetry_fallback() {
164 let dir = tempfile::tempdir().unwrap();
165 std::fs::write(
166 dir.path().join("pyproject.toml"),
167 "[tool.poetry]\nname = \"my-poetry-lib\"\nversion = \"1.0.0\"\n",
168 )
169 .unwrap();
170 let resolver = IdentityResolver::new(dir.path()).unwrap();
171 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
172 assert_eq!(id.name(), "my-poetry-lib");
173 }
174
175 #[test]
183 fn resolves_pypi_package_name_flit_fallback() {
184 let dir = tempfile::tempdir().unwrap();
185 std::fs::write(
186 dir.path().join("pyproject.toml"),
187 "[tool.flit.metadata]\nmodule = \"my_flit_lib\"\n",
188 )
189 .unwrap();
190 let resolver = IdentityResolver::new(dir.path()).unwrap();
191 let id = resolver.resolve(std::path::Path::new("."), Ecosystem::Pypi).unwrap();
192 assert_eq!(id.name(), "my_flit_lib");
193 }
194
195 #[test]
196 fn resolve_errors_when_manifest_file_missing() {
197 let dir = tempfile::tempdir().unwrap();
198 let resolver = IdentityResolver::new(dir.path()).unwrap();
199 let result = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo);
200 assert!(result.is_err());
201 }
202
203 #[test]
204 fn resolve_errors_when_name_field_missing() {
205 let dir = tempfile::tempdir().unwrap();
206 std::fs::write(dir.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
207 let resolver = IdentityResolver::new(dir.path()).unwrap();
208 let result = resolver.resolve(std::path::Path::new("."), Ecosystem::Cargo);
209 assert!(result.is_err());
210 }
211
212 #[test]
213 fn resolve_human_finds_prefixed_entry_for_unpromoted_package() {
214 let mut index = IdentityIndex::default();
215 let id = PackageId::Bare("foo".to_string());
216 index.bare.insert("foo".to_string(), id.clone());
217 index.native.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
218 index.prefixed.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
219 let resolved = index
220 .resolve_human("cargo:foo", &[])
221 .expect("cargo:foo must resolve via prefixed map");
222 assert_eq!(resolved, id);
223 }
224
225 #[test]
226 fn resolve_human_unknown_ecosystem_prefix_falls_through_to_unknown() {
227 let mut index = IdentityIndex::default();
228 let id = PackageId::Bare("foo".to_string());
229 index.bare.insert("foo".to_string(), id.clone());
230 index.native.insert((Ecosystem::Cargo, "foo".to_string()), id.clone());
231 index.prefixed.insert((Ecosystem::Cargo, "foo".to_string()), id);
232 let err = index.resolve_human("npm:foo", &[]).unwrap_err();
233 assert!(
234 matches!(err, GraphError::UnknownPackage { .. }),
235 "expected UnknownPackage, got {err:?}"
236 );
237 }
238
239 #[test]
240 fn resolve_native_with_fallback_returns_none_on_single_cross_ecosystem_candidate() {
241 let mut index = IdentityIndex::default();
242 let cargo_serde = PackageId::Bare("serde".to_string());
243 index
244 .native
245 .insert((Ecosystem::Cargo, "serde".to_string()), cargo_serde);
246 let mut diagnostics = Vec::new();
247 let result = index.resolve_native_with_fallback(Ecosystem::Npm, "serde", &mut diagnostics);
248 assert!(
249 result.is_none(),
250 "a same-ecosystem miss must never silently fall back to a cross-ecosystem match"
251 );
252 assert_eq!(diagnostics.len(), 1);
253 assert_eq!(diagnostics[0].code, callisto_model::DiagnosticCode::UnknownPackage);
254 assert_eq!(diagnostics[0].severity, callisto_model::DiagnosticSeverity::Warning);
255 }
256
257 #[test]
258 fn resolve_native_with_fallback_returns_none_and_one_diagnostic_for_two_candidates() {
259 let mut index = IdentityIndex::default();
260 index.native.insert(
261 (Ecosystem::Cargo, "ambiguous-lib".to_string()),
262 PackageId::Bare("ambiguous-lib".to_string()),
263 );
264 index.native.insert(
265 (Ecosystem::Pypi, "ambiguous-lib".to_string()),
266 PackageId::Prefixed {
267 ecosystem: Ecosystem::Pypi,
268 name: "ambiguous-lib".to_string(),
269 },
270 );
271 let mut diagnostics = Vec::new();
272 let result = index.resolve_native_with_fallback(Ecosystem::Npm, "ambiguous-lib", &mut diagnostics);
273 assert!(result.is_none());
274 assert_eq!(diagnostics.len(), 1, "exactly one diagnostic, not zero and not two");
275 assert!(
276 diagnostics[0].message.contains("cargo:ambiguous-lib")
277 && diagnostics[0].message.contains("pypi:ambiguous-lib")
278 );
279 }
280
281 #[test]
282 fn resolve_native_unchanged_still_returns_cross_ecosystem_match() {
283 let mut index = IdentityIndex::default();
284 let cargo_serde = PackageId::Bare("serde".to_string());
285 index
286 .native
287 .insert((Ecosystem::Cargo, "serde".to_string()), cargo_serde.clone());
288 let result = index.resolve_native(Ecosystem::Npm, "serde");
289 assert_eq!(
290 result,
291 Some(&cargo_serde),
292 "resolve_native's own permissive contract is unchanged by this spec"
293 );
294 }
295}
296
297#[derive(Clone, Debug, Default)]
298pub struct IdentityIndex {
299 pub bare: BTreeMap<String, PackageId>,
300 pub prefixed: BTreeMap<(Ecosystem, String), PackageId>,
301 pub native: BTreeMap<(Ecosystem, String), PackageId>,
302 pub platform: BTreeMap<String, (PackageId, PathBuf, ManifestRole)>,
311}
312
313impl IdentityIndex {
314 pub fn resolve_human(&self, name: &str, siblings: &[PackageId]) -> Result<PackageId, GraphError> {
315 if let Ok(PackageId::Prefixed { ecosystem, name: n }) = PackageId::parse(name) {
316 if let Some(id) = self.prefixed.get(&(ecosystem, n)) {
317 return Ok(id.clone());
318 }
319 }
320
321 if let Some(id) = self.bare.get(name) {
322 return Ok(id.clone());
323 }
324
325 let mut candidates = Vec::new();
326 for ((_eco, n), id) in &self.prefixed {
327 if n == name {
328 candidates.push(id.clone());
329 }
330 }
331
332 if candidates.len() == 1 {
333 return Ok(candidates[0].clone());
334 }
335
336 if candidates.len() > 1 && !siblings.is_empty() {
337 for sib in siblings {
338 for cand in &candidates {
339 if cand.ecosystem() == sib.ecosystem() {
340 return Ok(cand.clone());
341 }
342 }
343 }
344 }
345
346 if candidates.is_empty() {
347 Err(GraphError::UnknownPackage {
348 id: PackageId::parse(name).unwrap_or_else(|_| PackageId::Bare(name.to_string())),
349 })
350 } else {
351 Err(GraphError::AmbiguousName {
352 name: name.to_string(),
353 candidates,
354 })
355 }
356 }
357
358 pub fn resolve_native(&self, eco: Ecosystem, name: &str) -> Option<&PackageId> {
371 if let Some(id) = self.native.get(&(eco, name.to_string())) {
373 return Some(id);
374 }
375
376 let mut candidates: Vec<&PackageId> = self
378 .native
379 .iter()
380 .filter(|((e, n), _)| *e != eco && n == name)
381 .map(|(_, id)| id)
382 .collect();
383
384 candidates.dedup_by(|a, b| a == b);
388
389 match candidates.len() {
390 1 => Some(candidates[0]),
391 _ => None,
393 }
394 }
395
396 pub fn resolve_native_with_fallback<'a>(
400 &'a self,
401 eco: Ecosystem,
402 name: &str,
403 diagnostics: &mut Vec<callisto_model::Diagnostic>,
404 ) -> Option<&'a PackageId> {
405 if let Some(id) = self.native.get(&(eco, name.to_string())) {
407 return Some(id);
408 }
409
410 let mut candidates: Vec<(Ecosystem, &PackageId)> = self
412 .native
413 .iter()
414 .filter(|((e, n), _)| *e != eco && n == name)
415 .map(|((e, _), id)| (*e, id))
416 .collect();
417 candidates.dedup_by(|a, b| a.1 == b.1);
418
419 match candidates.len() {
420 0 => None,
421 _ => {
422 let candidate_names: Vec<String> = candidates
423 .iter()
424 .map(|(e, id)| format!("{}:{}", e.prefix(), id.name()))
425 .collect();
426 diagnostics.push(callisto_model::Diagnostic {
427 code: callisto_model::DiagnosticCode::UnknownPackage,
428 severity: callisto_model::DiagnosticSeverity::Warning,
429 message: format!(
430 "dependency name `{}` is ambiguous across ecosystems: {}; \
431 add an ecosystem prefix (e.g. `cargo:{}`) to disambiguate",
432 name,
433 candidate_names.join(", "),
434 name,
435 ),
436 package: None,
437 path: None,
438 escalated_by: None,
439 governed_by: None,
440 });
441 None
442 }
443 }
444 }
445
446 pub fn native_name(&self, id: &PackageId, eco: Ecosystem) -> Option<&str> {
447 for ((e, name), registered_id) in &self.native {
448 if e == &eco && registered_id == id {
449 return Some(name.as_str());
450 }
451 }
452 None
453 }
454
455 pub fn native_names(&self, id: &PackageId) -> impl Iterator<Item = (Ecosystem, &str)> {
456 let mut results = Vec::new();
457 for ((e, name), registered_id) in &self.native {
458 if registered_id == id {
459 results.push((*e, name.as_str()));
460 }
461 }
462 results.into_iter()
463 }
464
465 pub fn display_form(&self, id: &PackageId) -> String {
466 id.display_name()
467 }
468
469 pub fn platforms_of(&self, owner: &PackageId) -> impl Iterator<Item = (&str, &Path)> {
470 let mut results = Vec::new();
471 for (name, (plat_owner, path, _role)) in &self.platform {
472 if plat_owner == owner {
473 results.push((name.as_str(), path.as_path()));
474 }
475 }
476 results.into_iter()
477 }
478}