Skip to main content

callisto_graph/
identity.rs

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    /// A `Cargo.toml` using `version.workspace = true` (real-world common
118    /// case) must still resolve by name alone -- a package's *name* is
119    /// never workspace-inherited in Cargo, so this must succeed without
120    /// any `WorkspaceInheritance` context, which `IdentityResolver` (used
121    /// from `callisto-moon`'s WASM PDK entry points, which have no such
122    /// context available) deliberately never builds.
123    #[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    /// Before the shared-extractor refactor, IdentityResolver's Pypi branch
176    /// only checked PEP 621 then Poetry -- unlike
177    /// `PyprojectToml::package_name()`, which also falls back to Flit's
178    /// `[tool.flit.metadata].module`. A Flit-based Python package could be
179    /// resolved via the Manifest trait but not via IdentityResolver. The
180    /// shared `python_package_name` extractor closes this gap as a
181    /// side effect of removing the duplication.
182    #[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    /// Keyed by a platform npm manifest's own package name (e.g.
303    /// `"@myorg/my-crate-linux-x64-gnu"`) -- distinct from `owner`'s name
304    /// when the owning package's primary identity comes from a
305    /// higher-priority ecosystem sharing the same directory (a Cargo+npm
306    /// Case D project, §M.6.1 M7: platform manifests belong to the owning
307    /// `Package`, they are never independently-registered `Package`s of
308    /// their own). The `ManifestRole::Platform` is carried alongside so
309    /// `GroupTable::resolve` doesn't need a second disk read to recover it.
310    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    /// Look up a package by its native (manifest-declared) name.
359    ///
360    /// First tries the exact `(eco, name)` key. If that misses -- a
361    /// package in one ecosystem depending on a different ecosystem's
362    /// package by bare name (e.g. an npm package listing a cargo crate)
363    /// -- falls back to scanning all ecosystems for `name`.
364    ///
365    /// Fallback finds exactly one match: returns it. Finds more than one
366    /// (two ecosystems both have `name`): returns `None` to prevent
367    /// silent misresolution -- callers holding a `&mut Vec<Diagnostic>`
368    /// should use [`Self::resolve_native_with_fallback`] instead, to get
369    /// a diagnostic.
370    pub fn resolve_native(&self, eco: Ecosystem, name: &str) -> Option<&PackageId> {
371        // Fast path: exact ecosystem match.
372        if let Some(id) = self.native.get(&(eco, name.to_string())) {
373            return Some(id);
374        }
375
376        // Cross-ecosystem fallback.
377        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        // Deduplicate by pointer identity (multiple entries for the same ID
385        // across different ecosystems, e.g. a package that is both cargo and
386        // npm, should not be treated as ambiguous).
387        candidates.dedup_by(|a, b| a == b);
388
389        match candidates.len() {
390            1 => Some(candidates[0]),
391            // 0 = not found; >1 = true ambiguity → caller should diagnose.
392            _ => None,
393        }
394    }
395
396    /// Like [`Self::resolve_native`] but pushes a [`callisto_model::Diagnostic`] when
397    /// cross-ecosystem ambiguity is detected (two packages with the same bare
398    /// name in different ecosystems).
399    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        // Fast path: exact ecosystem match.
406        if let Some(id) = self.native.get(&(eco, name.to_string())) {
407            return Some(id);
408        }
409
410        // Cross-ecosystem fallback: collect unique IDs from other ecosystems.
411        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}