use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defregistry-format")]
pub struct RegistryFormat {
pub name: String,
pub version: u32,
pub scope: RegistryScope,
pub precedence: u32,
#[serde(rename = "defaultPath")]
pub default_path: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RegistryScope {
FlakeLocal,
User,
System,
Global,
}
pub const CANONICAL_REGISTRY_LISP: &str =
include_str!("../specs/registry.lisp");
pub fn load_canonical() -> Result<Vec<RegistryFormat>, SpecError> {
crate::loader::load_all::<RegistryFormat>(CANONICAL_REGISTRY_LISP)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryEntry {
pub from: String,
pub to: String,
pub exact: bool,
}
pub type Registries = Vec<(RegistryScope, Vec<RegistryEntry>)>;
pub fn resolve(
registries: &Registries,
flake_ref: &str,
) -> Result<RegistryEntry, SpecError> {
let mut sorted: Vec<&(RegistryScope, Vec<RegistryEntry>)> =
registries.iter().collect();
sorted.sort_by_key(|(scope, _)| scope_precedence(*scope));
for (_, entries) in sorted {
for entry in entries {
if entry.from == flake_ref {
return Ok(entry.clone());
}
}
}
Err(SpecError::Interp {
phase: "registry-unresolved".into(),
message: format!("no registry entry for `{flake_ref}` across any scope"),
})
}
fn scope_precedence(scope: RegistryScope) -> u32 {
match scope {
RegistryScope::FlakeLocal => 0,
RegistryScope::User => 1,
RegistryScope::System => 2,
RegistryScope::Global => 3,
}
}
pub fn parse_entries(text: &str) -> Result<Vec<RegistryEntry>, SpecError> {
let doc: serde_json::Value = serde_json::from_str(text)
.map_err(|e| SpecError::Interp {
phase: "registry-parse".into(),
message: format!("invalid JSON: {e}"),
})?;
let version = doc.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
if version != 2 {
return Err(SpecError::Interp {
phase: "registry-version".into(),
message: format!("expected version 2, got {version}"),
});
}
let flakes = doc.get("flakes").and_then(|v| v.as_array())
.ok_or_else(|| SpecError::Interp {
phase: "registry-parse".into(),
message: "missing `flakes` array".into(),
})?;
let mut out = Vec::with_capacity(flakes.len());
for (i, entry) in flakes.iter().enumerate() {
let from = entry.get("from").ok_or_else(|| SpecError::Interp {
phase: "registry-parse".into(),
message: format!("flake #{i}: missing `from`"),
})?;
let to = entry.get("to").ok_or_else(|| SpecError::Interp {
phase: "registry-parse".into(),
message: format!("flake #{i}: missing `to`"),
})?;
let exact = entry.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
out.push(RegistryEntry {
from: flatten_ref(from),
to: flatten_ref(to),
exact,
});
}
Ok(out)
}
fn flatten_ref(v: &serde_json::Value) -> String {
let kind = v.get("type").and_then(|x| x.as_str()).unwrap_or("?");
match kind {
"indirect" => v.get("id").and_then(|x| x.as_str())
.unwrap_or("?").to_string(),
"github" => {
let owner = v.get("owner").and_then(|x| x.as_str()).unwrap_or("?");
let repo = v.get("repo").and_then(|x| x.as_str()).unwrap_or("?");
let r#ref = v.get("ref").and_then(|x| x.as_str());
match r#ref {
Some(r) => format!("github:{owner}/{repo}/{r}"),
None => format!("github:{owner}/{repo}"),
}
}
"git" => {
let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("?");
format!("git:{url}")
}
"path" => {
let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("?");
format!("path:{path}")
}
"tarball" => {
let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("?");
format!("tarball:{url}")
}
other => format!("{other}:?"),
}
}
pub fn load_entries_from_disk(path: &std::path::Path)
-> Result<Vec<RegistryEntry>, SpecError>
{
match std::fs::read_to_string(path) {
Ok(text) => parse_entries(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(SpecError::Interp {
phase: "registry-read".into(),
message: format!("reading {}: {e}", path.display()),
}),
}
}
pub fn discover_disk_registries() -> Result<Registries, SpecError> {
let formats = load_canonical()?;
let mut out: Registries = Vec::new();
for f in &formats {
if matches!(f.scope, RegistryScope::FlakeLocal | RegistryScope::Global) {
continue;
}
let path = expand_home(&f.default_path);
let entries = load_entries_from_disk(&path)?;
out.push((f.scope, entries));
}
Ok(out)
}
fn expand_home(p: &str) -> std::path::PathBuf {
if let Some(suffix) = p.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home).join(suffix);
}
}
std::path::PathBuf::from(p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_registry_parses() {
let formats = load_canonical().unwrap();
assert!(!formats.is_empty());
}
#[test]
fn all_four_scopes_present() {
let formats = load_canonical().unwrap();
let scopes: std::collections::HashSet<RegistryScope> =
formats.iter().map(|f| f.scope).collect();
for required in [
RegistryScope::FlakeLocal,
RegistryScope::User,
RegistryScope::System,
RegistryScope::Global,
] {
assert!(
scopes.contains(&required),
"missing registry scope {required:?}",
);
}
}
#[test]
fn precedence_order_matches_cppnix() {
let formats = load_canonical().unwrap();
let prec = |s: RegistryScope| -> u32 {
formats.iter().find(|f| f.scope == s).unwrap().precedence
};
assert!(prec(RegistryScope::FlakeLocal) < prec(RegistryScope::User));
assert!(prec(RegistryScope::User) < prec(RegistryScope::System));
assert!(prec(RegistryScope::System) < prec(RegistryScope::Global));
}
fn entry(from: &str, to: &str) -> RegistryEntry {
RegistryEntry { from: from.into(), to: to.into(), exact: false }
}
#[test]
fn resolve_finds_lowest_precedence_match() {
let registries: Registries = vec![
(RegistryScope::Global, vec![entry("nixpkgs", "github:NixOS/nixpkgs/global")]),
(RegistryScope::User, vec![entry("nixpkgs", "github:NixOS/nixpkgs/user")]),
(RegistryScope::FlakeLocal, vec![entry("nixpkgs", "github:NixOS/nixpkgs/local")]),
];
let resolved = resolve(®istries, "nixpkgs").unwrap();
assert_eq!(resolved.to, "github:NixOS/nixpkgs/local");
}
#[test]
fn resolve_falls_through_to_system_when_local_absent() {
let registries: Registries = vec![
(RegistryScope::User, vec![entry("home-manager", "github:nix-community/home-manager")]),
(RegistryScope::System, vec![entry("home-manager", "github:nix-community/system-hm")]),
];
let resolved = resolve(®istries, "home-manager").unwrap();
assert_eq!(resolved.to, "github:nix-community/home-manager");
}
#[test]
fn resolve_errors_on_unknown_input() {
let registries: Registries = vec![
(RegistryScope::Global, vec![entry("known", "github:x/y")]),
];
let err = resolve(®istries, "unknown").unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-unresolved"),
_ => panic!("expected registry-unresolved"),
}
}
const SAMPLE_REGISTRY_JSON: &str = r#"{
"version": 2,
"flakes": [
{
"from": {"type": "indirect", "id": "nixpkgs"},
"to": {"type": "github", "owner": "NixOS", "repo": "nixpkgs"},
"exact": false
},
{
"from": {"type": "indirect", "id": "home-manager"},
"to": {"type": "github", "owner": "nix-community", "repo": "home-manager", "ref": "master"},
"exact": true
},
{
"from": {"type": "indirect", "id": "local-flake"},
"to": {"type": "path", "path": "/Users/me/code/some-flake"}
}
]
}"#;
#[test]
fn parse_entries_handles_canonical_shape() {
let entries = parse_entries(SAMPLE_REGISTRY_JSON).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].from, "nixpkgs");
assert_eq!(entries[0].to, "github:NixOS/nixpkgs");
assert!(!entries[0].exact);
assert_eq!(entries[1].to, "github:nix-community/home-manager/master");
assert!(entries[1].exact);
assert_eq!(entries[2].to, "path:/Users/me/code/some-flake");
}
#[test]
fn parse_entries_errors_on_wrong_version() {
let bad = r#"{"version": 1, "flakes": []}"#;
let err = parse_entries(bad).unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-version"),
_ => panic!("expected registry-version"),
}
}
#[test]
fn parse_entries_errors_on_garbage() {
let err = parse_entries("not json at all").unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "registry-parse"),
_ => panic!("expected registry-parse"),
}
}
#[test]
fn load_entries_from_missing_file_returns_empty() {
let path = std::path::Path::new("/nonexistent/path/registry.json");
let entries = load_entries_from_disk(path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn load_entries_from_disk_parses_real_file() {
let tmp = std::env::temp_dir().join("sui-spec-test-registry.json");
std::fs::write(&tmp, SAMPLE_REGISTRY_JSON).unwrap();
let entries = load_entries_from_disk(&tmp).unwrap();
assert_eq!(entries.len(), 3);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn flatten_ref_handles_all_known_types() {
let v = serde_json::json!({"type": "github", "owner": "x", "repo": "y"});
assert_eq!(flatten_ref(&v), "github:x/y");
let v = serde_json::json!({"type": "github", "owner": "x", "repo": "y", "ref": "main"});
assert_eq!(flatten_ref(&v), "github:x/y/main");
let v = serde_json::json!({"type": "git", "url": "https://example.com/x.git"});
assert_eq!(flatten_ref(&v), "git:https://example.com/x.git");
let v = serde_json::json!({"type": "path", "path": "/x/y"});
assert_eq!(flatten_ref(&v), "path:/x/y");
let v = serde_json::json!({"type": "tarball", "url": "https://x/y.tar.gz"});
assert_eq!(flatten_ref(&v), "tarball:https://x/y.tar.gz");
let v = serde_json::json!({"type": "indirect", "id": "nixpkgs"});
assert_eq!(flatten_ref(&v), "nixpkgs");
let v = serde_json::json!({"type": "unknown-type"});
assert_eq!(flatten_ref(&v), "unknown-type:?");
}
}