Skip to main content

sui_spec/
nix_replacement_coverage.rs

1//! Nix-replacement coverage catalog — one row per Nix workload sui
2//! must cover. Authored as Lisp forms in
3//! `specs/nix_replacement_coverage.lisp`, projected to typed Rust
4//! records here.
5//!
6//! ## Why this exists
7//!
8//! `cli_coverage.lisp` catalogs sui's **argv surface** — every
9//! subcommand the binary accepts and whether it routes to a Working
10//! implementation. This catalog complements it with the **workload
11//! surface** — every behavior real operators run (link-in-place, gc,
12//! sandboxed builds, substituter push/pull, module-system fixed
13//! point, etc.), independent of which command name they reach it by.
14//!
15//! Together the two catalogs are sui's complete answer to "what's
16//! left to be a full nix replacement?"
17//!
18//! ## Wire shape
19//!
20//! ```lisp
21//! (defnix-replacement-surface
22//!   :name     "lockfile-graph"
23//!   :category SubstrateL1
24//!   :status   Done
25//!   :owns     "sui-spec::lockfile_graph"
26//!   :notes    "Parsed + follows-resolved + content-addressed lockfile.")
27//! ```
28//!
29//! Adding a row is the canonical way to declare a new must-cover
30//! workload. Marking `:status Done` is a *verifiable* claim — the
31//! `owns` field points at the typed Rust piece that implements the
32//! surface, so reviewers can check.
33
34use serde::{Deserialize, Serialize};
35use tatara_lisp::DeriveTataraDomain;
36
37use crate::SpecError;
38
39/// One workload sui must cover to fully replace cppnix.
40#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
41#[tatara(keyword = "defnix-replacement-surface")]
42pub struct NixReplacementSurface {
43    /// Stable name. Used as the catalog key.
44    pub name: String,
45    /// Which layer this workload belongs to.
46    pub category: WorkloadCategory,
47    /// Coverage status today.
48    pub status: CoverageStatus,
49    /// The typed Rust piece that owns (or will own) the implementation.
50    /// Free-form `crate::module::Type` reference; reviewers grep for it.
51    pub owns: String,
52    /// Operator-readable description.
53    pub notes: String,
54}
55
56/// Stable workload categories.
57#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum WorkloadCategory {
59    /// `/nix/store` layout, gc, optimize, add-path, verify.
60    Storage,
61    /// L1 typed graph substrate (lockfile / AST / module / derivation).
62    SubstrateL1,
63    /// Nix language evaluator (builtins, flake eval, module system).
64    EvalEngine,
65    /// Derivation hash + build sandbox.
66    Derivation,
67    /// Build execution (sandboxed builder, output realization).
68    Build,
69    /// Source fetcher (github, git, tarball, path, ...).
70    Fetcher,
71    /// Binary cache (narinfo pull/push, typed-closure stream).
72    Substituter,
73    /// Daemon protocols (cppnix worker, sui-native graph, fleet
74    /// work-stealing).
75    Daemon,
76    /// System rebuild (nixos / darwin / home-manager).
77    SystemRebuild,
78    /// CLI convenience surfaces operators rely on (`nix-shell`,
79    /// `nix-channel`, `nixos-rebuild` host script).
80    Convenience,
81}
82
83/// Stable coverage statuses. Ordered worst → best for sort purposes.
84#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
85pub enum CoverageStatus {
86    /// No code yet; row exists to claim the gap.
87    NotStarted,
88    /// In the backlog with an owner identified but no code yet.
89    Queued,
90    /// Some implementation exists; gaps documented in `notes`.
91    InProgress,
92    /// Operator can use sui for this workload today without behavior
93    /// loss vs cppnix.
94    Done,
95}
96
97impl CoverageStatus {
98    /// Display name for dashboards.
99    #[must_use]
100    pub fn name(self) -> &'static str {
101        match self {
102            Self::NotStarted => "NotStarted",
103            Self::Queued => "Queued",
104            Self::InProgress => "InProgress",
105            Self::Done => "Done",
106        }
107    }
108}
109
110pub const CANONICAL_NIX_REPLACEMENT_COVERAGE_LISP: &str =
111    include_str!("../specs/nix_replacement_coverage.lisp");
112
113/// Load the full canonical coverage catalog.
114///
115/// # Errors
116///
117/// Returns an error if the Lisp source fails to parse.
118pub fn load_canonical() -> Result<Vec<NixReplacementSurface>, SpecError> {
119    crate::loader::load_all::<NixReplacementSurface>(CANONICAL_NIX_REPLACEMENT_COVERAGE_LISP)
120}
121
122/// Coverage histogram — number of surfaces in each status bucket.
123#[derive(Debug, Default, Clone, Copy)]
124pub struct CoverageHistogram {
125    pub done: u32,
126    pub in_progress: u32,
127    pub queued: u32,
128    pub not_started: u32,
129    pub total: u32,
130}
131
132impl CoverageHistogram {
133    /// Build a histogram from a slice of surfaces.
134    #[must_use]
135    pub fn from_surfaces(surfaces: &[NixReplacementSurface]) -> Self {
136        let mut h = Self::default();
137        for s in surfaces {
138            h.total += 1;
139            match s.status {
140                CoverageStatus::Done => h.done += 1,
141                CoverageStatus::InProgress => h.in_progress += 1,
142                CoverageStatus::Queued => h.queued += 1,
143                CoverageStatus::NotStarted => h.not_started += 1,
144            }
145        }
146        h
147    }
148
149    /// Fraction of surfaces marked Done. 0.0..=1.0.
150    #[must_use]
151    pub fn done_fraction(&self) -> f32 {
152        if self.total == 0 {
153            0.0
154        } else {
155            self.done as f32 / self.total as f32
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use pretty_assertions::assert_eq;
164
165    #[test]
166    fn catalog_loads() {
167        let cat = load_canonical().expect("catalog parses");
168        // Make sure we have at least one row per category we declared.
169        let categories: std::collections::HashSet<_> = cat.iter().map(|s| s.category).collect();
170        assert!(categories.contains(&WorkloadCategory::Storage));
171        assert!(categories.contains(&WorkloadCategory::SubstrateL1));
172        assert!(categories.contains(&WorkloadCategory::EvalEngine));
173        assert!(categories.contains(&WorkloadCategory::Derivation));
174        assert!(categories.contains(&WorkloadCategory::Fetcher));
175        assert!(categories.contains(&WorkloadCategory::Substituter));
176        assert!(categories.contains(&WorkloadCategory::Daemon));
177        assert!(categories.contains(&WorkloadCategory::SystemRebuild));
178    }
179
180    #[test]
181    fn histogram_sums_to_total() {
182        let cat = load_canonical().unwrap();
183        let h = CoverageHistogram::from_surfaces(&cat);
184        assert_eq!(h.total, cat.len() as u32);
185        assert_eq!(
186            h.done + h.in_progress + h.queued + h.not_started,
187            h.total
188        );
189    }
190
191    #[test]
192    fn done_fraction_in_range() {
193        let cat = load_canonical().unwrap();
194        let h = CoverageHistogram::from_surfaces(&cat);
195        let f = h.done_fraction();
196        assert!((0.0..=1.0).contains(&f));
197    }
198
199    #[test]
200    fn every_surface_has_owns_pointer() {
201        let cat = load_canonical().unwrap();
202        for s in &cat {
203            assert!(!s.owns.is_empty(), "row {} missing :owns", s.name);
204        }
205    }
206
207    #[test]
208    fn lockfile_graph_is_done() {
209        let cat = load_canonical().unwrap();
210        let row = cat
211            .iter()
212            .find(|s| s.name == "lockfile-graph")
213            .expect("lockfile-graph row");
214        assert!(matches!(row.status, CoverageStatus::Done));
215    }
216
217    #[test]
218    fn daemon_graph_protocol_is_done() {
219        let cat = load_canonical().unwrap();
220        let row = cat
221            .iter()
222            .find(|s| s.name == "daemon-graph-protocol-native")
223            .expect("daemon-graph-protocol-native row");
224        assert!(matches!(row.status, CoverageStatus::Done));
225    }
226}