Skip to main content

sui_spec/
lock_file.rs

1//! Typed border for `flake.lock` — the on-disk representation of
2//! resolved flake inputs.
3//!
4//! Every flake-using consumer ships a `flake.lock` next to its
5//! `flake.nix`.  Cppnix's format is JSON with `version`, `root`,
6//! `nodes` (one entry per input, keyed by name) and edges between
7//! nodes for transitive resolution.  This module names the format
8//! as a typed Lisp spec.
9//!
10//! ## Authoring surface
11//!
12//! ```lisp
13//! (deflock-file-format
14//!   :name "cppnix-flake-lock-v7"
15//!   :version 7
16//!   :encoding JsonText
17//!   :required-fields ("version" "root" "nodes")
18//!   :node-fields ("inputs" "locked" "original")
19//!   :phases ((:kind ParseJson)
20//!            (:kind ValidateVersion)
21//!            (:kind ValidateNodeGraph)
22//!            (:kind ResolveTransitiveInputs)))
23//! ```
24
25use serde::{Deserialize, Serialize};
26use tatara_lisp::DeriveTataraDomain;
27
28use crate::SpecError;
29
30#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
31#[tatara(keyword = "deflock-file-format")]
32pub struct LockFileFormat {
33    pub name: String,
34    pub version: u32,
35    pub encoding: LockFileEncoding,
36    #[serde(rename = "requiredFields")]
37    pub required_fields: Vec<String>,
38    #[serde(rename = "nodeFields")]
39    pub node_fields: Vec<String>,
40    pub phases: Vec<LockFilePhase>,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LockFileEncoding {
45    /// Top-level JSON object on disk.  cppnix.
46    JsonText,
47    /// Hypothetical CBOR/MsgPack variant.
48    Binary,
49}
50
51#[derive(Serialize, Deserialize, Debug, Clone)]
52pub struct LockFilePhase {
53    pub kind: LockFilePhaseKind,
54    #[serde(default)]
55    pub bind: Option<String>,
56    #[serde(default)]
57    pub from: Option<String>,
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LockFilePhaseKind {
62    /// Parse the JSON envelope.
63    ParseJson,
64    /// Verify `version` matches the spec.
65    ValidateVersion,
66    /// Walk the `nodes` graph: every edge points at a known node,
67    /// no cycles except through `root`, every node has a
68    /// `locked` field with a narHash (except `root`).
69    ValidateNodeGraph,
70    /// Compute transitive input closure from `root` outward,
71    /// surfacing the same node-set every consumer should see.
72    ResolveTransitiveInputs,
73    /// Emit the lock file back to canonical JSON form
74    /// (round-trip clean property).
75    EmitCanonicalJson,
76}
77
78pub const CANONICAL_LOCK_FILE_LISP: &str =
79    include_str!("../specs/lock_file.lisp");
80
81/// Compile every authored lock-file format.
82///
83/// # Errors
84///
85/// Returns an error if the Lisp source fails to parse.
86pub fn load_canonical() -> Result<Vec<LockFileFormat>, SpecError> {
87    crate::loader::load_all::<LockFileFormat>(CANONICAL_LOCK_FILE_LISP)
88}
89
90// ── M3.0 lock-file parser ──────────────────────────────────────────
91
92/// Parsed flake.lock — typed envelope.  Individual node entries
93/// stay as `serde_json::Value` for M3.0 since their shape varies
94/// across versions; M3.1 will lift each variant to a typed struct.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ParsedLockFile {
97    pub version: u32,
98    pub root: String,
99    pub nodes: serde_json::Map<String, serde_json::Value>,
100}
101
102/// Parse a `flake.lock` JSON payload against a format spec.
103///
104/// # Errors
105///
106/// - `lockfile-parse` for malformed JSON.
107/// - `lockfile-version-mismatch` if the file's `version` field
108///   doesn't match the spec's declared version.
109/// - `lockfile-missing-required` if a Required field is absent.
110pub fn parse(text: &str, format: &LockFileFormat) -> Result<ParsedLockFile, SpecError> {
111    let value: serde_json::Value = serde_json::from_str(text).map_err(|e| SpecError::Interp {
112        phase: "lockfile-parse".into(),
113        message: format!("malformed JSON: {e}"),
114    })?;
115    let obj = value.as_object().ok_or_else(|| SpecError::Interp {
116        phase: "lockfile-parse".into(),
117        message: "top-level value is not a JSON object".into(),
118    })?;
119
120    // Required-field check per the format spec.
121    for field in &format.required_fields {
122        if !obj.contains_key(field) {
123            return Err(SpecError::Interp {
124                phase: "lockfile-missing-required".into(),
125                message: format!(
126                    "flake.lock missing required field `{field}` per format `{}`",
127                    format.name,
128                ),
129            });
130        }
131    }
132
133    let version = obj
134        .get("version")
135        .and_then(|v| v.as_u64())
136        .map(|v| v as u32)
137        .ok_or_else(|| SpecError::Interp {
138            phase: "lockfile-parse".into(),
139            message: "`version` field is missing or not an integer".into(),
140        })?;
141
142    if version != format.version {
143        return Err(SpecError::Interp {
144            phase: "lockfile-version-mismatch".into(),
145            message: format!(
146                "flake.lock declares version {version}, format `{}` expects {}",
147                format.name, format.version,
148            ),
149        });
150    }
151
152    let root = obj
153        .get("root")
154        .and_then(|v| v.as_str())
155        .map(String::from)
156        .ok_or_else(|| SpecError::Interp {
157            phase: "lockfile-parse".into(),
158            message: "`root` field is missing or not a string".into(),
159        })?;
160
161    let nodes = obj
162        .get("nodes")
163        .and_then(|v| v.as_object())
164        .cloned()
165        .ok_or_else(|| SpecError::Interp {
166            phase: "lockfile-parse".into(),
167            message: "`nodes` field is missing or not an object".into(),
168        })?;
169
170    Ok(ParsedLockFile { version, root, nodes })
171}
172
173/// Names of every input that's a direct edge from `root` in the
174/// node graph.  Helpful for `nix flake metadata`-style introspection
175/// without reading the whole flake.
176///
177/// # Errors
178///
179/// Returns `lockfile-no-root-node` if the lockfile's declared
180/// `root` doesn't appear in the `nodes` map.
181pub fn root_inputs(parsed: &ParsedLockFile) -> Result<Vec<String>, SpecError> {
182    let root_node = parsed.nodes.get(&parsed.root).ok_or_else(|| SpecError::Interp {
183        phase: "lockfile-no-root-node".into(),
184        message: format!(
185            "lockfile declares root `{}` but no node by that name exists",
186            parsed.root,
187        ),
188    })?;
189    let inputs = match root_node.get("inputs") {
190        Some(serde_json::Value::Object(o)) => o,
191        _ => return Ok(Vec::new()),  // root with no inputs is valid
192    };
193    Ok(inputs.keys().cloned().collect())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn canonical_lock_file_parses() {
202        let formats = load_canonical().unwrap();
203        assert!(!formats.is_empty());
204    }
205
206    // ── M3.0 parser tests ──────────────────────────────────────
207
208    const CANONICAL_LOCK_FILE: &str = r#"
209    {
210      "version": 7,
211      "root": "root",
212      "nodes": {
213        "root": {
214          "inputs": { "nixpkgs": "nixpkgs", "tatara": "tatara" }
215        },
216        "nixpkgs": {
217          "locked": {
218            "narHash": "sha256:abc",
219            "rev": "deadbeef",
220            "type": "github"
221          },
222          "original": { "owner": "NixOS", "repo": "nixpkgs", "type": "github" }
223        },
224        "tatara": {
225          "locked": { "narHash": "sha256:def", "rev": "cafef00d", "type": "github" }
226        }
227      }
228    }"#;
229
230    fn fmt() -> LockFileFormat {
231        load_canonical().unwrap().into_iter()
232            .find(|f| f.name == "cppnix-flake-lock-v7").unwrap()
233    }
234
235    #[test]
236    fn parse_canonical_lock_file() {
237        let parsed = parse(CANONICAL_LOCK_FILE, &fmt()).unwrap();
238        assert_eq!(parsed.version, 7);
239        assert_eq!(parsed.root, "root");
240        assert_eq!(parsed.nodes.len(), 3);
241        assert!(parsed.nodes.contains_key("nixpkgs"));
242    }
243
244    #[test]
245    fn root_inputs_returns_direct_edges() {
246        let parsed = parse(CANONICAL_LOCK_FILE, &fmt()).unwrap();
247        let inputs = root_inputs(&parsed).unwrap();
248        assert!(inputs.iter().any(|s| s == "nixpkgs"));
249        assert!(inputs.iter().any(|s| s == "tatara"));
250        assert_eq!(inputs.len(), 2);
251    }
252
253    #[test]
254    fn version_mismatch_errors() {
255        let v5 = r#"{ "version": 5, "root": "root", "nodes": {} }"#;
256        let err = parse(v5, &fmt()).unwrap_err();
257        match err {
258            SpecError::Interp { phase, .. } => assert_eq!(phase, "lockfile-version-mismatch"),
259            _ => panic!("expected version-mismatch"),
260        }
261    }
262
263    #[test]
264    fn malformed_json_errors() {
265        let bad = "not json at all";
266        let err = parse(bad, &fmt()).unwrap_err();
267        match err {
268            SpecError::Interp { phase, .. } => assert_eq!(phase, "lockfile-parse"),
269            _ => panic!("expected lockfile-parse"),
270        }
271    }
272
273    #[test]
274    fn missing_required_field_errors() {
275        let no_root = r#"{ "version": 7, "nodes": {} }"#;
276        let err = parse(no_root, &fmt()).unwrap_err();
277        match err {
278            SpecError::Interp { phase, message } => {
279                assert_eq!(phase, "lockfile-missing-required");
280                assert!(message.contains("root"));
281            }
282            _ => panic!("expected missing-required"),
283        }
284    }
285
286    #[test]
287    fn missing_root_node_errors() {
288        let dangling = r#"{
289            "version": 7,
290            "root": "nonexistent",
291            "nodes": { "other": {} }
292        }"#;
293        let parsed = parse(dangling, &fmt()).unwrap();
294        let err = root_inputs(&parsed).unwrap_err();
295        match err {
296            SpecError::Interp { phase, .. } => assert_eq!(phase, "lockfile-no-root-node"),
297            _ => panic!("expected no-root-node"),
298        }
299    }
300
301    #[test]
302    fn v7_format_has_canonical_fields() {
303        let formats = load_canonical().unwrap();
304        let v7 = formats
305            .iter()
306            .find(|f| f.name == "cppnix-flake-lock-v7")
307            .unwrap();
308        assert_eq!(v7.version, 7);
309        for required in ["version", "root", "nodes"] {
310            assert!(
311                v7.required_fields.iter().any(|f| f == required),
312                "v7 missing required field {required}",
313            );
314        }
315        for node_field in ["inputs", "locked", "original"] {
316            assert!(
317                v7.node_fields.iter().any(|f| f == node_field),
318                "v7 missing node-field {node_field}",
319            );
320        }
321    }
322}