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 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 let pkg_json_path = root.join(main_id.name()).join("package.json");
30 if !pkg_json_path.exists() {
31 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 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 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 for t in member_triples.difference(&declared_triples) {
99 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
132pub 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
165pub 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 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 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 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 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 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}