1use serde::{Deserialize, Serialize};
10use tatara_lisp::DeriveTataraDomain;
11
12use crate::SpecError;
13
14#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
15#[tatara(keyword = "defregistry-format")]
16pub struct RegistryFormat {
17 pub name: String,
18 pub version: u32,
19 pub scope: RegistryScope,
20 pub precedence: u32,
23 #[serde(rename = "defaultPath")]
25 pub default_path: String,
26}
27
28#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum RegistryScope {
30 FlakeLocal,
32 User,
34 System,
36 Global,
39}
40
41pub const CANONICAL_REGISTRY_LISP: &str =
42 include_str!("../specs/registry.lisp");
43
44pub fn load_canonical() -> Result<Vec<RegistryFormat>, SpecError> {
50 crate::loader::load_all::<RegistryFormat>(CANONICAL_REGISTRY_LISP)
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct RegistryEntry {
59 pub from: String,
60 pub to: String,
61 pub exact: bool,
62}
63
64pub type Registries = Vec<(RegistryScope, Vec<RegistryEntry>)>;
67
68pub fn resolve(
76 registries: &Registries,
77 flake_ref: &str,
78) -> Result<RegistryEntry, SpecError> {
79 let mut sorted: Vec<&(RegistryScope, Vec<RegistryEntry>)> =
80 registries.iter().collect();
81 sorted.sort_by_key(|(scope, _)| scope_precedence(*scope));
82 for (_, entries) in sorted {
83 for entry in entries {
84 if entry.from == flake_ref {
85 return Ok(entry.clone());
86 }
87 }
88 }
89 Err(SpecError::Interp {
90 phase: "registry-unresolved".into(),
91 message: format!("no registry entry for `{flake_ref}` across any scope"),
92 })
93}
94
95fn scope_precedence(scope: RegistryScope) -> u32 {
96 match scope {
97 RegistryScope::FlakeLocal => 0,
98 RegistryScope::User => 1,
99 RegistryScope::System => 2,
100 RegistryScope::Global => 3,
101 }
102}
103
104pub fn parse_entries(text: &str) -> Result<Vec<RegistryEntry>, SpecError> {
128 let doc: serde_json::Value = serde_json::from_str(text)
129 .map_err(|e| SpecError::Interp {
130 phase: "registry-parse".into(),
131 message: format!("invalid JSON: {e}"),
132 })?;
133
134 let version = doc.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
135 if version != 2 {
136 return Err(SpecError::Interp {
137 phase: "registry-version".into(),
138 message: format!("expected version 2, got {version}"),
139 });
140 }
141
142 let flakes = doc.get("flakes").and_then(|v| v.as_array())
143 .ok_or_else(|| SpecError::Interp {
144 phase: "registry-parse".into(),
145 message: "missing `flakes` array".into(),
146 })?;
147
148 let mut out = Vec::with_capacity(flakes.len());
149 for (i, entry) in flakes.iter().enumerate() {
150 let from = entry.get("from").ok_or_else(|| SpecError::Interp {
151 phase: "registry-parse".into(),
152 message: format!("flake #{i}: missing `from`"),
153 })?;
154 let to = entry.get("to").ok_or_else(|| SpecError::Interp {
155 phase: "registry-parse".into(),
156 message: format!("flake #{i}: missing `to`"),
157 })?;
158 let exact = entry.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
159
160 out.push(RegistryEntry {
161 from: flatten_ref(from),
162 to: flatten_ref(to),
163 exact,
164 });
165 }
166 Ok(out)
167}
168
169fn flatten_ref(v: &serde_json::Value) -> String {
175 let kind = v.get("type").and_then(|x| x.as_str()).unwrap_or("?");
176 match kind {
177 "indirect" => v.get("id").and_then(|x| x.as_str())
178 .unwrap_or("?").to_string(),
179 "github" => {
180 let owner = v.get("owner").and_then(|x| x.as_str()).unwrap_or("?");
181 let repo = v.get("repo").and_then(|x| x.as_str()).unwrap_or("?");
182 let r#ref = v.get("ref").and_then(|x| x.as_str());
183 match r#ref {
184 Some(r) => format!("github:{owner}/{repo}/{r}"),
185 None => format!("github:{owner}/{repo}"),
186 }
187 }
188 "git" => {
189 let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("?");
190 format!("git:{url}")
191 }
192 "path" => {
193 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("?");
194 format!("path:{path}")
195 }
196 "tarball" => {
197 let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("?");
198 format!("tarball:{url}")
199 }
200 other => format!("{other}:?"),
201 }
202}
203
204pub fn load_entries_from_disk(path: &std::path::Path)
215 -> Result<Vec<RegistryEntry>, SpecError>
216{
217 match std::fs::read_to_string(path) {
218 Ok(text) => parse_entries(&text),
219 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
220 Err(e) => Err(SpecError::Interp {
221 phase: "registry-read".into(),
222 message: format!("reading {}: {e}", path.display()),
223 }),
224 }
225}
226
227pub fn discover_disk_registries() -> Result<Registries, SpecError> {
239 let formats = load_canonical()?;
240 let mut out: Registries = Vec::new();
241 for f in &formats {
242 if matches!(f.scope, RegistryScope::FlakeLocal | RegistryScope::Global) {
244 continue;
245 }
246 let path = expand_home(&f.default_path);
247 let entries = load_entries_from_disk(&path)?;
248 out.push((f.scope, entries));
249 }
250 Ok(out)
251}
252
253fn expand_home(p: &str) -> std::path::PathBuf {
254 if let Some(suffix) = p.strip_prefix("~/") {
255 if let Some(home) = std::env::var_os("HOME") {
256 return std::path::PathBuf::from(home).join(suffix);
257 }
258 }
259 std::path::PathBuf::from(p)
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn canonical_registry_parses() {
268 let formats = load_canonical().unwrap();
269 assert!(!formats.is_empty());
270 }
271
272 #[test]
273 fn all_four_scopes_present() {
274 let formats = load_canonical().unwrap();
275 let scopes: std::collections::HashSet<RegistryScope> =
276 formats.iter().map(|f| f.scope).collect();
277 for required in [
278 RegistryScope::FlakeLocal,
279 RegistryScope::User,
280 RegistryScope::System,
281 RegistryScope::Global,
282 ] {
283 assert!(
284 scopes.contains(&required),
285 "missing registry scope {required:?}",
286 );
287 }
288 }
289
290 #[test]
291 fn precedence_order_matches_cppnix() {
292 let formats = load_canonical().unwrap();
293 let prec = |s: RegistryScope| -> u32 {
294 formats.iter().find(|f| f.scope == s).unwrap().precedence
295 };
296 assert!(prec(RegistryScope::FlakeLocal) < prec(RegistryScope::User));
298 assert!(prec(RegistryScope::User) < prec(RegistryScope::System));
299 assert!(prec(RegistryScope::System) < prec(RegistryScope::Global));
300 }
301
302 fn entry(from: &str, to: &str) -> RegistryEntry {
305 RegistryEntry { from: from.into(), to: to.into(), exact: false }
306 }
307
308 #[test]
309 fn resolve_finds_lowest_precedence_match() {
310 let registries: Registries = vec![
311 (RegistryScope::Global, vec![entry("nixpkgs", "github:NixOS/nixpkgs/global")]),
312 (RegistryScope::User, vec![entry("nixpkgs", "github:NixOS/nixpkgs/user")]),
313 (RegistryScope::FlakeLocal, vec![entry("nixpkgs", "github:NixOS/nixpkgs/local")]),
314 ];
315 let resolved = resolve(®istries, "nixpkgs").unwrap();
316 assert_eq!(resolved.to, "github:NixOS/nixpkgs/local");
317 }
318
319 #[test]
320 fn resolve_falls_through_to_system_when_local_absent() {
321 let registries: Registries = vec![
322 (RegistryScope::User, vec![entry("home-manager", "github:nix-community/home-manager")]),
323 (RegistryScope::System, vec![entry("home-manager", "github:nix-community/system-hm")]),
324 ];
325 let resolved = resolve(®istries, "home-manager").unwrap();
326 assert_eq!(resolved.to, "github:nix-community/home-manager");
327 }
328
329 #[test]
330 fn resolve_errors_on_unknown_input() {
331 let registries: Registries = vec![
332 (RegistryScope::Global, vec![entry("known", "github:x/y")]),
333 ];
334 let err = resolve(®istries, "unknown").unwrap_err();
335 match err {
336 SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-unresolved"),
337 _ => panic!("expected registry-unresolved"),
338 }
339 }
340
341 const SAMPLE_REGISTRY_JSON: &str = r#"{
344 "version": 2,
345 "flakes": [
346 {
347 "from": {"type": "indirect", "id": "nixpkgs"},
348 "to": {"type": "github", "owner": "NixOS", "repo": "nixpkgs"},
349 "exact": false
350 },
351 {
352 "from": {"type": "indirect", "id": "home-manager"},
353 "to": {"type": "github", "owner": "nix-community", "repo": "home-manager", "ref": "master"},
354 "exact": true
355 },
356 {
357 "from": {"type": "indirect", "id": "local-flake"},
358 "to": {"type": "path", "path": "/Users/me/code/some-flake"}
359 }
360 ]
361 }"#;
362
363 #[test]
364 fn parse_entries_handles_canonical_shape() {
365 let entries = parse_entries(SAMPLE_REGISTRY_JSON).unwrap();
366 assert_eq!(entries.len(), 3);
367 assert_eq!(entries[0].from, "nixpkgs");
368 assert_eq!(entries[0].to, "github:NixOS/nixpkgs");
369 assert!(!entries[0].exact);
370 assert_eq!(entries[1].to, "github:nix-community/home-manager/master");
371 assert!(entries[1].exact);
372 assert_eq!(entries[2].to, "path:/Users/me/code/some-flake");
373 }
374
375 #[test]
376 fn parse_entries_errors_on_wrong_version() {
377 let bad = r#"{"version": 1, "flakes": []}"#;
378 let err = parse_entries(bad).unwrap_err();
379 match err {
380 SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-version"),
381 _ => panic!("expected registry-version"),
382 }
383 }
384
385 #[test]
386 fn parse_entries_errors_on_garbage() {
387 let err = parse_entries("not json at all").unwrap_err();
388 match err {
389 SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-parse"),
390 _ => panic!("expected registry-parse"),
391 }
392 }
393
394 #[test]
395 fn load_entries_from_missing_file_returns_empty() {
396 let path = std::path::Path::new("/nonexistent/path/registry.json");
397 let entries = load_entries_from_disk(path).unwrap();
398 assert!(entries.is_empty());
399 }
400
401 #[test]
402 fn load_entries_from_disk_parses_real_file() {
403 let tmp = std::env::temp_dir().join("sui-spec-test-registry.json");
404 std::fs::write(&tmp, SAMPLE_REGISTRY_JSON).unwrap();
405 let entries = load_entries_from_disk(&tmp).unwrap();
406 assert_eq!(entries.len(), 3);
407 let _ = std::fs::remove_file(&tmp);
408 }
409
410 #[test]
411 fn flatten_ref_handles_all_known_types() {
412 let v = serde_json::json!({"type": "github", "owner": "x", "repo": "y"});
413 assert_eq!(flatten_ref(&v), "github:x/y");
414
415 let v = serde_json::json!({"type": "github", "owner": "x", "repo": "y", "ref": "main"});
416 assert_eq!(flatten_ref(&v), "github:x/y/main");
417
418 let v = serde_json::json!({"type": "git", "url": "https://example.com/x.git"});
419 assert_eq!(flatten_ref(&v), "git:https://example.com/x.git");
420
421 let v = serde_json::json!({"type": "path", "path": "/x/y"});
422 assert_eq!(flatten_ref(&v), "path:/x/y");
423
424 let v = serde_json::json!({"type": "tarball", "url": "https://x/y.tar.gz"});
425 assert_eq!(flatten_ref(&v), "tarball:https://x/y.tar.gz");
426
427 let v = serde_json::json!({"type": "indirect", "id": "nixpkgs"});
428 assert_eq!(flatten_ref(&v), "nixpkgs");
429
430 let v = serde_json::json!({"type": "unknown-type"});
431 assert_eq!(flatten_ref(&v), "unknown-type:?");
432 }
433}