Skip to main content

sui_spec/
registry.rs

1//! Typed border for the flake registry — the lookup that resolves
2//! short input refs (`nixpkgs`, `github:owner/repo`) to concrete
3//! `from` → `to` mappings.
4//!
5//! Cppnix has three registries (system, user, flake-local), with
6//! precedence order.  Each is a JSON file with `version` + `flakes`
7//! (list of entries: `from`, `to`, `exact`).
8
9use 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    /// Precedence rank — lower = checked first.  cppnix:
21    /// flake-local 0, user 1, system 2.
22    pub precedence: u32,
23    /// Path the registry conventionally lives at.
24    #[serde(rename = "defaultPath")]
25    pub default_path: String,
26}
27
28#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum RegistryScope {
30    /// Per-flake `nixConfig.flake-registry` entries.
31    FlakeLocal,
32    /// `~/.config/nix/registry.json`.
33    User,
34    /// `/etc/nix/registry.json`.
35    System,
36    /// The upstream flake registry from `flake-registry` setting
37    /// (typically `https://channels.nixos.org/flake-registry.json`).
38    Global,
39}
40
41pub const CANONICAL_REGISTRY_LISP: &str =
42    include_str!("../specs/registry.lisp");
43
44/// Compile every authored registry format.
45///
46/// # Errors
47///
48/// Returns an error if the Lisp source fails to parse.
49pub fn load_canonical() -> Result<Vec<RegistryFormat>, SpecError> {
50    crate::loader::load_all::<RegistryFormat>(CANONICAL_REGISTRY_LISP)
51}
52
53// ── M3.0 registry resolver ─────────────────────────────────────────
54
55/// One registry entry — a `from` → `to` mapping with optional
56/// `exact` flag (cppnix lockfile semantics).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct RegistryEntry {
59    pub from: String,
60    pub to: String,
61    pub exact: bool,
62}
63
64/// Resolved registry entries grouped by scope, in precedence
65/// order (lowest first wins).
66pub type Registries = Vec<(RegistryScope, Vec<RegistryEntry>)>;
67
68/// Resolve a flake reference through the precedence chain.
69/// Returns the FIRST match (lowest-precedence scope wins per
70/// cppnix convention).
71///
72/// # Errors
73///
74/// `registry-unresolved` if no scope has a matching entry.
75pub 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
104// ── M3.1 disk loader ───────────────────────────────────────────────
105
106/// Parse a registry JSON document into `Vec<RegistryEntry>`.
107///
108/// cppnix registry shape (v2):
109/// ```json
110/// {
111///   "version": 2,
112///   "flakes": [
113///     { "from": {"type": "indirect", "id": "nixpkgs"},
114///       "to":   {"type": "github", "owner": "NixOS", "repo": "nixpkgs"},
115///       "exact": false }
116///   ]
117/// }
118/// ```
119///
120/// Returns entries with `from` flattened to its `id` (per cppnix
121/// `indirect` semantics) and `to` flattened to a typed ref string.
122///
123/// # Errors
124///
125/// - `registry-parse` if the JSON shape is invalid.
126/// - `registry-version` if the version isn't 2.
127pub 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
169/// Convert a registry input/output ref object into a string.
170///
171/// cppnix encodes refs as objects with a `type` discriminator
172/// and per-type fields.  We flatten them into the human-readable
173/// shorthand operators see (e.g. `nixpkgs`, `github:NixOS/nixpkgs`).
174fn 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
204/// Load registry entries from a JSON file on disk.
205///
206/// Returns an empty Vec when the file is missing (cppnix:
207/// missing-registry-is-empty), or a typed error when the file
208/// exists but is malformed.
209///
210/// # Errors
211///
212/// - `registry-read` for I/O errors other than NotFound.
213/// - Returns from `parse_entries` for parse / version errors.
214pub 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
227/// Discover every canonical registry path on this system, returning
228/// `(scope, entries)` pairs for every scope whose file exists.  System
229/// + user paths come from the canonical Lisp spec; flake-local +
230/// global aren't disk-loadable in the same way (flake-local is per-
231/// flake; global is fetched from the upstream URL).
232///
233/// HOME expansion mirrors cppnix: `~/` → `$HOME/`.
234///
235/// # Errors
236///
237/// Returns the first per-scope load error encountered.
238pub 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        // Skip scopes that don't live in one fixed disk file.
243        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        // cppnix order: flake-local < user < system < global
297        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    // ── M3.0 resolver tests ────────────────────────────────────
303
304    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(&registries, "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(&registries, "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(&registries, "unknown").unwrap_err();
335        match err {
336            SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-unresolved"),
337            _ => panic!("expected registry-unresolved"),
338        }
339    }
340
341    // ── M3.1 disk-loader tests ──────────────────────────────────
342
343    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}