Skip to main content

sui_spec/
catalog.rs

1//! Substrate self-description — the typed catalog of every authored
2//! sui-spec domain.
3//!
4//! Sui-spec has grown to 18 typed domains.  Operators + tooling
5//! benefit from a typed inventory: "what does this substrate
6//! cover?" becomes a typed query rather than a doc-string search.
7//! Per the CSE pattern, the catalog is itself a typed Lisp spec —
8//! reflection-as-spec.
9//!
10//! Adding a new domain to sui-spec means landing one
11//! `(defsubstrate-domain ...)` form alongside the domain's own
12//! module + spec.  Consumers (the future `sui spec list` CLI,
13//! generated docs, drift detectors) iterate this catalog
14//! mechanically.
15
16use serde::{Deserialize, Serialize};
17use tatara_lisp::DeriveTataraDomain;
18
19use crate::SpecError;
20
21/// One substrate domain — a typed border + Lisp spec inside
22/// sui-spec.  Catalog entries name the domain itself, not the
23/// types it owns.
24#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
25#[tatara(keyword = "defsubstrate-domain")]
26pub struct SubstrateDomain {
27    /// Module name (`"derivation"`, `"fetcher"`, ...).
28    pub name: String,
29    /// Lisp authoring keyword(s) the domain exposes.
30    #[serde(rename = "authoringKeywords")]
31    pub authoring_keywords: Vec<String>,
32    /// Implementation maturity gate (M0..M5).
33    #[serde(rename = "gate")]
34    pub gate: MaturityGate,
35    /// What this domain covers in one short phrase.
36    pub purpose: String,
37    /// Which cppnix subsystem the domain mirrors.
38    #[serde(rename = "cppnixMirror")]
39    pub cppnix_mirror: String,
40    /// Other domains this one depends on (by name).  Forms the
41    /// substrate dependency graph — `activation_script` depends
42    /// on `module_system`; `fetcher` depends on `derivation` (FOD
43    /// variant); etc.  Adding a new domain means declaring its
44    /// dependencies here.
45    #[serde(default, rename = "dependsOn")]
46    pub depends_on: Vec<String>,
47}
48
49/// Implementation maturity level.
50#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum MaturityGate {
52    /// Substrate primitive + interpreter — production-ready.
53    Working,
54    /// Typed border + canonical Lisp authored; interpreter is
55    /// scoped to M2 (module system).
56    M2TypedOnly,
57    /// Typed border + canonical Lisp authored; interpreter is
58    /// scoped to M3 (everything depending on the module system).
59    M3TypedOnly,
60    /// Typed border + canonical Lisp authored; interpreter is
61    /// scoped to M4 (CA-derivations + dependent flow).
62    M4TypedOnly,
63    /// Informational only — no interpreter planned (e.g. format
64    /// declarations, layout conventions).
65    Informational,
66}
67
68pub const CANONICAL_CATALOG_LISP: &str = include_str!("../specs/catalog.lisp");
69
70/// Compile the substrate catalog.
71///
72/// # Errors
73///
74/// Returns an error if the Lisp source fails to parse.
75pub fn load_canonical() -> Result<Vec<SubstrateDomain>, SpecError> {
76    crate::loader::load_all::<SubstrateDomain>(CANONICAL_CATALOG_LISP)
77}
78
79/// Find a domain entry by name.
80///
81/// # Errors
82///
83/// Returns an error if the catalog fails to parse or no entry matches.
84pub fn lookup(name: &str) -> Result<SubstrateDomain, SpecError> {
85    load_canonical()?
86        .into_iter()
87        .find(|d| d.name == name)
88        .ok_or_else(|| SpecError::Load(format!(
89            "no (defsubstrate-domain) with :name {name:?}",
90        )))
91}
92
93/// Count of domains by maturity gate.
94///
95/// # Errors
96///
97/// Returns an error if the catalog fails to parse.
98pub fn maturity_histogram() -> Result<std::collections::BTreeMap<&'static str, usize>, SpecError> {
99    let cat = load_canonical()?;
100    let mut h: std::collections::BTreeMap<&'static str, usize> =
101        std::collections::BTreeMap::new();
102    for d in &cat {
103        let key = match d.gate {
104            MaturityGate::Working => "Working",
105            MaturityGate::M2TypedOnly => "M2TypedOnly",
106            MaturityGate::M3TypedOnly => "M3TypedOnly",
107            MaturityGate::M4TypedOnly => "M4TypedOnly",
108            MaturityGate::Informational => "Informational",
109        };
110        *h.entry(key).or_default() += 1;
111    }
112    Ok(h)
113}
114
115/// Topologically sort the catalog so leaves (no dependencies)
116/// come first.  This is the order an M2/M3/M4 implementer should
117/// land interpreters in — implementing `derivation` requires
118/// `hash` and `store_layout` to be working first.
119///
120/// # Errors
121///
122/// Returns `SpecError::Interp` with phase `dependency-cycle` if
123/// the substrate graph isn't a DAG.
124pub fn topological_order() -> Result<Vec<SubstrateDomain>, SpecError> {
125    let cat = load_canonical()?;
126    let by_name: std::collections::HashMap<String, SubstrateDomain> =
127        cat.iter().cloned().map(|d| (d.name.clone(), d)).collect();
128    let names: Vec<&str> = cat.iter().map(|d| d.name.as_str()).collect();
129
130    // Kahn's algorithm — start with zero-in-degree nodes, peel.
131    let mut indeg: std::collections::HashMap<&str, usize> =
132        names.iter().map(|n| (*n, 0)).collect();
133    for d in &cat {
134        for dep in &d.depends_on {
135            // d depends on dep → there's an edge dep → d, so d's
136            // in-degree increases.
137            *indeg.entry(d.name.as_str()).or_default() += 1;
138            // touch the dep so it's in the map even if no other
139            // domain depends on it
140            indeg.entry(dep.as_str()).or_default();
141        }
142    }
143
144    // Take zero-in-degree nodes, sorted by name for determinism.
145    let mut frontier: Vec<&str> = indeg
146        .iter()
147        .filter(|(_, deg)| **deg == 0)
148        .map(|(name, _)| *name)
149        .collect();
150    frontier.sort();
151
152    let mut out: Vec<SubstrateDomain> = Vec::new();
153    while let Some(name) = frontier.pop() {
154        let domain = by_name.get(name).cloned().ok_or_else(|| {
155            SpecError::Load(format!("internal: topological_order saw unknown `{name}`"))
156        })?;
157        out.push(domain);
158        // For each child that depends on `name`, decrement its
159        // in-degree; if it hits zero, add to frontier.
160        let children: Vec<&str> = cat
161            .iter()
162            .filter(|d| d.depends_on.iter().any(|x| x == name))
163            .map(|d| d.name.as_str())
164            .collect();
165        for child in children {
166            let entry = indeg.entry(child).or_default();
167            *entry = entry.saturating_sub(1);
168            if *entry == 0 {
169                frontier.push(child);
170                frontier.sort();
171            }
172        }
173    }
174
175    if out.len() != cat.len() {
176        return Err(SpecError::Interp {
177            phase: "dependency-cycle".into(),
178            message: format!(
179                "topological_order produced {} of {} entries — cycle present",
180                out.len(),
181                cat.len(),
182            ),
183        });
184    }
185    Ok(out)
186}
187
188/// Compute the transitive dependency closure of one domain (the
189/// set of all domains reachable via `depends_on` edges, including
190/// the domain itself).
191///
192/// # Errors
193///
194/// Returns `SpecError::Load` if the catalog fails to parse or
195/// `name` is missing.  Returns `SpecError::Interp` with phase
196/// `dependency-cycle` if the graph contains a cycle.
197pub fn transitive_dependencies(name: &str) -> Result<std::collections::BTreeSet<String>, SpecError> {
198    let cat = load_canonical()?;
199    let by_name: std::collections::HashMap<&str, &SubstrateDomain> =
200        cat.iter().map(|d| (d.name.as_str(), d)).collect();
201    if !by_name.contains_key(name) {
202        return Err(SpecError::Load(format!("domain `{name}` not in catalog")));
203    }
204    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
205    let mut stack: Vec<String> = vec![name.to_string()];
206    let mut in_path: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
207    while let Some(current) = stack.pop() {
208        if !seen.insert(current.clone()) {
209            continue;
210        }
211        if !in_path.insert(current.clone()) {
212            return Err(SpecError::Interp {
213                phase: "dependency-cycle".into(),
214                message: format!("cycle detected involving `{current}`"),
215            });
216        }
217        let Some(domain) = by_name.get(current.as_str()) else {
218            return Err(SpecError::Load(format!(
219                "domain `{current}` referenced but not in catalog",
220            )));
221        };
222        for dep in &domain.depends_on {
223            stack.push(dep.clone());
224        }
225    }
226    Ok(seen)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::collections::HashSet;
233
234    #[test]
235    fn catalog_parses() {
236        let cat = load_canonical().expect("catalog must compile");
237        assert!(
238            cat.len() >= 15,
239            "catalog must enumerate at least 15 domains, got {}",
240            cat.len(),
241        );
242    }
243
244    #[test]
245    fn every_authored_domain_is_in_catalog() {
246        let cat = load_canonical().unwrap();
247        let names: HashSet<&str> = cat.iter().map(|d| d.name.as_str()).collect();
248        // The 18 domains today.  If a new domain lands without a
249        // catalog entry, this test fires.
250        for required in [
251            "derivation",
252            "realisation",
253            "module_system",
254            "activation_script",
255            "flake",
256            "lock_file",
257            "registry",
258            "fetcher",
259            "substituter",
260            "sandbox",
261            "store_layout",
262            "gc",
263            "hash",
264            "nar",
265            "narinfo",
266            "eval_cache",
267            "profile",
268            "trust_model",
269            "worker_protocol",
270            "dockerfile",
271        ] {
272            assert!(
273                names.contains(required),
274                "catalog missing domain `{required}` — sui-spec/src/{required}.rs \
275                 exists but its catalog entry doesn't",
276            );
277        }
278    }
279
280    #[test]
281    fn maturity_histogram_sums_to_catalog_size() {
282        let cat = load_canonical().unwrap();
283        let h = maturity_histogram().unwrap();
284        let total: usize = h.values().sum();
285        assert_eq!(total, cat.len());
286    }
287
288    #[test]
289    fn working_domains_include_the_known_three() {
290        let cat = load_canonical().unwrap();
291        let working: HashSet<&str> = cat
292            .iter()
293            .filter(|d| d.gate == MaturityGate::Working)
294            .map(|d| d.name.as_str())
295            .collect();
296        // These three have full implementations on the substrate.
297        for required in ["derivation", "flake"] {
298            assert!(
299                working.contains(required),
300                "catalog: {required} should be Working — substrate has the impl today",
301            );
302        }
303    }
304
305    #[test]
306    fn lookup_finds_known_domain() {
307        let d = lookup("derivation").expect("derivation must be in catalog");
308        assert_eq!(d.name, "derivation");
309        assert!(d.purpose.len() > 10);
310    }
311
312    #[test]
313    fn lookup_errors_on_missing() {
314        let err = lookup("nonexistent-domain").expect_err("must error on unknown");
315        match err {
316            SpecError::Load(msg) => assert!(msg.contains("nonexistent-domain")),
317            _ => panic!("expected SpecError::Load"),
318        }
319    }
320
321    #[test]
322    fn transitive_dependencies_of_activation_includes_module_system() {
323        let deps = transitive_dependencies("activation_script").unwrap();
324        // activation_script depends on module_system (M2 gate) +
325        // derivation; derivation depends on hash + store_layout.
326        // Closure must contain all five.
327        for required in [
328            "activation_script", // self
329            "module_system",
330            "derivation",
331            "hash",
332            "store_layout",
333        ] {
334            assert!(
335                deps.contains(required),
336                "transitive deps missing `{required}`: {deps:?}",
337            );
338        }
339    }
340
341    #[test]
342    fn transitive_dependencies_of_hash_is_just_itself() {
343        let deps = transitive_dependencies("hash").unwrap();
344        assert_eq!(deps.len(), 1);
345        assert!(deps.contains("hash"));
346    }
347
348    #[test]
349    fn every_declared_dependency_exists_in_catalog() {
350        let cat = load_canonical().unwrap();
351        let names: std::collections::HashSet<&str> =
352            cat.iter().map(|d| d.name.as_str()).collect();
353        for d in &cat {
354            for dep in &d.depends_on {
355                assert!(
356                    names.contains(dep.as_str()),
357                    "domain `{}` declares dependency on `{dep}`, \
358                     which is not in the catalog",
359                    d.name,
360                );
361            }
362        }
363    }
364
365    #[test]
366    fn substrate_graph_has_no_cycles() {
367        let cat = load_canonical().unwrap();
368        for d in &cat {
369            let _ = transitive_dependencies(&d.name)
370                .unwrap_or_else(|e| panic!("cycle starting from `{}`: {e:?}", d.name));
371        }
372    }
373
374    #[test]
375    fn topological_order_covers_catalog() {
376        let topo = topological_order().unwrap();
377        let cat = load_canonical().unwrap();
378        assert_eq!(topo.len(), cat.len(), "topo skipped some domains");
379        let names: std::collections::HashSet<&str> =
380            topo.iter().map(|d| d.name.as_str()).collect();
381        for c in &cat {
382            assert!(names.contains(c.name.as_str()), "missing `{}` in topo", c.name);
383        }
384    }
385
386    #[test]
387    fn topological_order_respects_dependencies() {
388        let topo = topological_order().unwrap();
389        let pos: std::collections::HashMap<&str, usize> = topo
390            .iter()
391            .enumerate()
392            .map(|(i, d)| (d.name.as_str(), i))
393            .collect();
394        for d in &topo {
395            for dep in &d.depends_on {
396                assert!(
397                    pos[dep.as_str()] < pos[d.name.as_str()],
398                    "topo violates: `{}` (pos {}) depends on `{dep}` (pos {})",
399                    d.name,
400                    pos[d.name.as_str()],
401                    pos[dep.as_str()],
402                );
403            }
404        }
405    }
406}