1use 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 JsonText,
47 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 ParseJson,
64 ValidateVersion,
66 ValidateNodeGraph,
70 ResolveTransitiveInputs,
73 EmitCanonicalJson,
76}
77
78pub const CANONICAL_LOCK_FILE_LISP: &str =
79 include_str!("../specs/lock_file.lisp");
80
81pub fn load_canonical() -> Result<Vec<LockFileFormat>, SpecError> {
87 crate::loader::load_all::<LockFileFormat>(CANONICAL_LOCK_FILE_LISP)
88}
89
90#[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
102pub 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 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
173pub 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()), };
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 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}