Skip to main content

callisto_graph/
napi.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use callisto_model::{Diagnostic, DiagnosticCode, DiagnosticSeverity, ManifestRole, StrictFlag};
5
6use crate::config::groups::GroupMember;
7use crate::config::{GroupDef, GroupTable};
8
9#[derive(Clone, Debug, Default)]
10pub struct NapiTargetsIndex {
11    declared: BTreeMap<callisto_model::GroupName, Vec<String>>,
12}
13
14impl NapiTargetsIndex {
15    pub fn load(groups: &GroupTable, root: &Path) -> Result<Self, callisto_model::ManifestError> {
16        let mut declared = BTreeMap::new();
17        for g in groups.fixed.values() {
18            // Find the first Package member — it is the napi main package.
19            let main_id = g.members.iter().find_map(|m| match m {
20                GroupMember::Package(id) => Some(id),
21                _ => None,
22            });
23            let Some(main_id) = main_id else {
24                continue;
25            };
26
27            // Derive the expected package.json path from the package name.
28            // Convention: root/<package-name>/package.json
29            let pkg_json_path = root.join(main_id.name()).join("package.json");
30            if !pkg_json_path.exists() {
31                // Group has no napi package.json — skip, not an error.
32                continue;
33            }
34
35            let content = std::fs::read_to_string(&pkg_json_path).map_err(|e| callisto_model::ManifestError::Read {
36                path: pkg_json_path.clone(),
37                message: e.to_string(),
38            })?;
39
40            let val: serde_json::Value =
41                serde_json::from_str(&content).map_err(|e| callisto_model::ManifestError::Parse {
42                    path: pkg_json_path.clone(),
43                    format: callisto_model::ManifestFormat::PackageJson,
44                    message: e.to_string(),
45                })?;
46
47            // Only insert when the "napi" key is present.
48            if let Some(targets) = val
49                .get("napi")
50                .and_then(|n| n.get("targets"))
51                .and_then(|t| t.as_array())
52            {
53                let triples: Vec<String> = targets.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
54                declared.insert(g.name.clone(), triples);
55            }
56        }
57        Ok(NapiTargetsIndex { declared })
58    }
59
60    pub fn declared_for(&self, group: &callisto_model::GroupName) -> Option<&[String]> {
61        self.declared.get(group).map(|v| v.as_slice())
62    }
63}
64
65pub fn napi_drift(group: &GroupDef, declared: &[String], root: &Path) -> Vec<Diagnostic> {
66    use crate::config::groups::GroupMemberKind;
67
68    let declared_triples: BTreeSet<String> = declared.iter().map(|s| s.trim().to_string()).collect();
69
70    let member_triples: BTreeSet<String> = group
71        .members(GroupMemberKind::PlatformManifest)
72        .filter_map(|m| match m {
73            GroupMember::PlatformManifest { role, .. } => role_to_triple(role),
74            _ => None,
75        })
76        .collect();
77
78    let mut diagnostics = Vec::new();
79
80    // Declared in napi.targets but no corresponding group member.
81    for t in declared_triples.difference(&member_triples) {
82        diagnostics.push(Diagnostic {
83            code: DiagnosticCode::NapiTargetAddedNotInMembers,
84            severity: DiagnosticSeverity::Warning,
85            message: format!(
86                "`napi.targets` declares `{t}`, which is not in fixed group `{}`'s members; accept it with `callisto init`",
87                group.name
88            ),
89            package: None,
90            path: None,
91            governed_by: None,
92            escalated_by: Some(StrictFlag::Strict),
93        });
94    }
95
96    // Present in group members but removed from napi.targets — only warn if
97    // the physical manifest file still exists on disk.
98    for t in member_triples.difference(&declared_triples) {
99        // Find the PlatformManifest whose triple matches t.
100        let manifest_path = group.members(GroupMemberKind::PlatformManifest).find_map(|m| match m {
101            GroupMember::PlatformManifest { role, path, .. } => {
102                if role_to_triple(role).as_deref() == Some(t.as_str()) {
103                    Some(root.join(path))
104                } else {
105                    None
106                }
107            }
108            _ => None,
109        });
110
111        if let Some(abs_path) = manifest_path {
112            if abs_path.exists() {
113                diagnostics.push(Diagnostic {
114                    code: DiagnosticCode::NapiTargetRemovedStillOnDisk,
115                    severity: DiagnosticSeverity::Warning,
116                    message: format!(
117                        "fixed group `{}` member `{t}` is no longer in `napi.targets` but its manifest still exists on disk; run `callisto init` to reconcile",
118                        group.name
119                    ),
120                    package: None,
121                    path: Some(abs_path),
122                    governed_by: None,
123                    escalated_by: Some(StrictFlag::Strict),
124                });
125            }
126        }
127    }
128
129    diagnostics
130}
131
132/// Maps a napi-rs target triple to a `ManifestRole::Platform` describing its
133/// platform, architecture, and (for Linux) ABI.
134///
135/// Returns `None` for any triple not in the known napi-rs target table.
136pub fn triple_to_role(triple: &str) -> Option<ManifestRole> {
137    let (platform, arch, abi) = match triple {
138        "aarch64-apple-darwin" => ("darwin", "arm64", None),
139        "x86_64-apple-darwin" => ("darwin", "x64", None),
140        "x86_64-unknown-linux-gnu" => ("linux", "x64", Some("gnu")),
141        "x86_64-unknown-linux-musl" => ("linux", "x64", Some("musl")),
142        "aarch64-unknown-linux-gnu" => ("linux", "arm64", Some("gnu")),
143        "aarch64-unknown-linux-musl" => ("linux", "arm64", Some("musl")),
144        "x86_64-pc-windows-msvc" => ("win32", "x64", None),
145        "i686-pc-windows-msvc" => ("win32", "ia32", None),
146        "aarch64-pc-windows-msvc" => ("win32", "arm64", None),
147        "armv7-unknown-linux-gnueabihf" => ("linux", "arm", Some("gnueabihf")),
148        "x86_64-unknown-freebsd" => ("freebsd", "x64", None),
149        "aarch64-linux-android" => ("android", "arm64", None),
150        "armv7-linux-androideabi" => ("android", "arm", None),
151        "riscv64gc-unknown-linux-gnu" => ("linux", "riscv64", Some("gnu")),
152        "powerpc64le-unknown-linux-gnu" => ("linux", "ppc64", Some("gnu")),
153        "s390x-unknown-linux-gnu" => ("linux", "s390x", Some("gnu")),
154        "wasm32-wasip1" => ("wasi", "wasm32", None),
155        "wasm32-unknown-unknown" => ("unknown", "wasm32", None),
156        _ => return None,
157    };
158    Some(ManifestRole::Platform {
159        platform: platform.to_string(),
160        arch: arch.to_string(),
161        abi: abi.map(str::to_string),
162    })
163}
164
165/// Reverse of `triple_to_role`: maps a `ManifestRole::Platform` back to the
166/// canonical napi-rs target triple. Returns `None` for roles not in the table
167/// (including `Canonical` and `Lockfile` roles).
168pub fn role_to_triple(role: &ManifestRole) -> Option<String> {
169    let ManifestRole::Platform { platform, arch, abi } = role else {
170        return None;
171    };
172    let triple = match (platform.as_str(), arch.as_str(), abi.as_deref()) {
173        ("darwin", "arm64", None) => "aarch64-apple-darwin",
174        ("darwin", "x64", None) => "x86_64-apple-darwin",
175        ("linux", "x64", Some("gnu")) => "x86_64-unknown-linux-gnu",
176        ("linux", "x64", Some("musl")) => "x86_64-unknown-linux-musl",
177        ("linux", "arm64", Some("gnu")) => "aarch64-unknown-linux-gnu",
178        ("linux", "arm64", Some("musl")) => "aarch64-unknown-linux-musl",
179        ("win32", "x64", None) => "x86_64-pc-windows-msvc",
180        ("win32", "ia32", None) => "i686-pc-windows-msvc",
181        ("win32", "arm64", None) => "aarch64-pc-windows-msvc",
182        ("linux", "arm", Some("gnueabihf")) => "armv7-unknown-linux-gnueabihf",
183        ("freebsd", "x64", None) => "x86_64-unknown-freebsd",
184        ("android", "arm64", None) => "aarch64-linux-android",
185        ("android", "arm", None) => "armv7-linux-androideabi",
186        ("linux", "riscv64", Some("gnu")) => "riscv64gc-unknown-linux-gnu",
187        ("linux", "ppc64", Some("gnu")) => "powerpc64le-unknown-linux-gnu",
188        ("linux", "s390x", Some("gnu")) => "s390x-unknown-linux-gnu",
189        ("wasi", "wasm32", None) => "wasm32-wasip1",
190        ("unknown", "wasm32", None) => "wasm32-unknown-unknown",
191        _ => return None,
192    };
193    Some(triple.to_string())
194}
195
196#[cfg(test)]
197mod tests {
198    use std::path::PathBuf;
199
200    use callisto_model::{DiagnosticCode, GroupKind, GroupName, ManifestRole, PackageId};
201
202    use super::*;
203    use crate::config::groups::{GroupDef, GroupMember, GroupTable};
204
205    const KNOWN_TRIPLES: &[&str] = &[
206        "aarch64-apple-darwin",
207        "x86_64-apple-darwin",
208        "x86_64-unknown-linux-gnu",
209        "x86_64-unknown-linux-musl",
210        "aarch64-unknown-linux-gnu",
211        "aarch64-unknown-linux-musl",
212        "x86_64-pc-windows-msvc",
213        "i686-pc-windows-msvc",
214        "aarch64-pc-windows-msvc",
215        "armv7-unknown-linux-gnueabihf",
216        "x86_64-unknown-freebsd",
217        "aarch64-linux-android",
218        "armv7-linux-androideabi",
219        "riscv64gc-unknown-linux-gnu",
220        "powerpc64le-unknown-linux-gnu",
221        "s390x-unknown-linux-gnu",
222        "wasm32-wasip1",
223        "wasm32-unknown-unknown",
224    ];
225
226    #[test]
227    fn triple_to_role_known_triples_round_trip() {
228        for &t in KNOWN_TRIPLES {
229            let role =
230                triple_to_role(t).unwrap_or_else(|| panic!("triple_to_role returned None for known triple `{t}`"));
231            let back = role_to_triple(&role)
232                .unwrap_or_else(|| panic!("role_to_triple returned None for role derived from `{t}`"));
233            assert_eq!(back, t, "round-trip failed for `{t}`: role_to_triple produced `{back}`");
234        }
235    }
236
237    #[test]
238    fn triple_to_role_unknown_returns_none() {
239        assert!(
240            triple_to_role("x86_64-unknown-openbsd").is_none(),
241            "expected None for unrecognized triple"
242        );
243    }
244
245    fn make_platform_role(platform: &str, arch: &str, abi: Option<&str>) -> ManifestRole {
246        ManifestRole::Platform {
247            platform: platform.to_string(),
248            arch: arch.to_string(),
249            abi: abi.map(str::to_string),
250        }
251    }
252
253    fn make_group(name: &str, main_pkg: &str, platform_members: Vec<(&str, ManifestRole, PathBuf)>) -> GroupDef {
254        let mut members = vec![GroupMember::Package(PackageId::Bare(main_pkg.to_string()))];
255        for (pm_name, role, path) in platform_members {
256            members.push(GroupMember::PlatformManifest {
257                owner: PackageId::Bare(main_pkg.to_string()),
258                role,
259                path,
260                name: pm_name.to_string(),
261            });
262        }
263        GroupDef {
264            name: GroupName(name.to_string()),
265            kind: GroupKind::Fixed,
266            members,
267        }
268    }
269
270    #[test]
271    fn napi_targets_index_loads_targets_from_package_json() {
272        let tmp = tempfile::tempdir().expect("tempdir");
273        let root = tmp.path();
274
275        // Create a package directory named "my-lib" with package.json
276        let pkg_dir = root.join("my-lib");
277        std::fs::create_dir_all(&pkg_dir).unwrap();
278        std::fs::write(
279            pkg_dir.join("package.json"),
280            r#"{"name":"my-lib","napi":{"targets":["aarch64-apple-darwin"]}}"#,
281        )
282        .unwrap();
283
284        let group_name = GroupName("my-lib-group".to_string());
285        let group = GroupDef {
286            name: group_name.clone(),
287            kind: GroupKind::Fixed,
288            members: vec![GroupMember::Package(PackageId::Bare("my-lib".to_string()))],
289        };
290        let groups = GroupTable::from_groups(vec![group], vec![]);
291
292        let index = NapiTargetsIndex::load(&groups, root).expect("load should succeed");
293        let declared = index
294            .declared_for(&group_name)
295            .expect("declared_for should return Some");
296        assert_eq!(declared, &["aarch64-apple-darwin"]);
297    }
298
299    #[test]
300    fn napi_drift_no_drift_produces_no_diagnostics() {
301        let tmp = tempfile::tempdir().expect("tempdir");
302        let root = tmp.path();
303
304        // Create a platform manifest file on disk.
305        let pm_path = PathBuf::from("platform/darwin-arm64/package.json");
306        let abs_pm = root.join(&pm_path);
307        std::fs::create_dir_all(abs_pm.parent().unwrap()).unwrap();
308        std::fs::write(&abs_pm, r#"{"name":"my-lib-darwin-arm64"}"#).unwrap();
309
310        let group = make_group(
311            "my-lib",
312            "my-lib",
313            vec![(
314                "my-lib.darwin-arm64",
315                make_platform_role("darwin", "arm64", None),
316                pm_path,
317            )],
318        );
319
320        let declared = vec!["aarch64-apple-darwin".to_string()];
321        let diags = napi_drift(&group, &declared, root);
322        assert!(
323            diags.is_empty(),
324            "expected no diagnostics for matching declared and members, got: {diags:?}"
325        );
326    }
327
328    #[test]
329    fn napi_drift_added_target_produces_added_diagnostic() {
330        let tmp = tempfile::tempdir().expect("tempdir");
331        let root = tmp.path();
332
333        // Group has no platform members for this triple.
334        let group = make_group("my-lib", "my-lib", vec![]);
335
336        let declared = vec!["aarch64-apple-darwin".to_string()];
337        let diags = napi_drift(&group, &declared, root);
338        assert_eq!(diags.len(), 1, "expected one diagnostic, got: {diags:?}");
339        assert_eq!(
340            diags[0].code,
341            DiagnosticCode::NapiTargetAddedNotInMembers,
342            "expected NapiTargetAddedNotInMembers"
343        );
344    }
345
346    #[test]
347    fn napi_drift_removed_target_with_file_produces_removed_diagnostic() {
348        let tmp = tempfile::tempdir().expect("tempdir");
349        let root = tmp.path();
350
351        // Member triple exists in group but NOT in declared.
352        let pm_path = PathBuf::from("platform/darwin-arm64/package.json");
353        let abs_pm = root.join(&pm_path);
354        std::fs::create_dir_all(abs_pm.parent().unwrap()).unwrap();
355        std::fs::write(&abs_pm, r#"{"name":"my-lib-darwin-arm64"}"#).unwrap();
356
357        let group = make_group(
358            "my-lib",
359            "my-lib",
360            vec![(
361                "my-lib.darwin-arm64",
362                make_platform_role("darwin", "arm64", None),
363                pm_path,
364            )],
365        );
366
367        // declared is empty — the member triple is "removed"
368        let declared: Vec<String> = vec![];
369        let diags = napi_drift(&group, &declared, root);
370        assert_eq!(diags.len(), 1, "expected one diagnostic, got: {diags:?}");
371        assert_eq!(
372            diags[0].code,
373            DiagnosticCode::NapiTargetRemovedStillOnDisk,
374            "expected NapiTargetRemovedStillOnDisk"
375        );
376    }
377}