1use serde::{Deserialize, Serialize};
17use tatara_lisp::DeriveTataraDomain;
18
19use crate::SpecError;
20
21#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
25#[tatara(keyword = "defsubstrate-domain")]
26pub struct SubstrateDomain {
27 pub name: String,
29 #[serde(rename = "authoringKeywords")]
31 pub authoring_keywords: Vec<String>,
32 #[serde(rename = "gate")]
34 pub gate: MaturityGate,
35 pub purpose: String,
37 #[serde(rename = "cppnixMirror")]
39 pub cppnix_mirror: String,
40 #[serde(default, rename = "dependsOn")]
46 pub depends_on: Vec<String>,
47}
48
49#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum MaturityGate {
52 Working,
54 M2TypedOnly,
57 M3TypedOnly,
60 M4TypedOnly,
63 Informational,
66}
67
68pub const CANONICAL_CATALOG_LISP: &str = include_str!("../specs/catalog.lisp");
69
70pub fn load_canonical() -> Result<Vec<SubstrateDomain>, SpecError> {
76 crate::loader::load_all::<SubstrateDomain>(CANONICAL_CATALOG_LISP)
77}
78
79pub 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
93pub 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
115pub 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 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 *indeg.entry(d.name.as_str()).or_default() += 1;
138 indeg.entry(dep.as_str()).or_default();
141 }
142 }
143
144 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 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
188pub 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 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 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 for required in [
328 "activation_script", "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}