Skip to main content

sui_eval/
flake_lock.rs

1//! Native flake lock management — update, check, and write `flake.lock`.
2//!
3//! Provides `update_input` / `update_all_inputs` to resolve latest revisions
4//! for locked flake inputs (replacing `nix flake update`), and `check_flake`
5//! to validate a flake directory (replacing `nix flake check`).
6//!
7//! Network-dependent operations (GitHub API, `git ls-remote`) are gated
8//! behind the `SUI_TEST_ONLINE=1` environment variable in tests.
9
10use std::path::Path;
11
12use sui_compat::flake::{FlakeLock, OriginalInput};
13
14// ── Error type ────────────────────────────────────────────────
15
16/// Errors that can occur during flake lock operations.
17#[derive(Debug, thiserror::Error)]
18pub enum FlakeLockUpdateError {
19    #[error("I/O error: {0}")]
20    Io(#[from] std::io::Error),
21    #[error("JSON error: {0}")]
22    Json(#[from] serde_json::Error),
23    #[error("invalid flake.lock format")]
24    InvalidFormat,
25    #[error("input not found: {0}")]
26    InputNotFound(String),
27    #[error("unsupported input type: {0}")]
28    UnsupportedType(String),
29    #[error("fetch failed: {0}")]
30    FetchFailed(String),
31    #[error("flake parse error: {0}")]
32    FlakeParse(String),
33}
34
35// ── Flake check ───────────────────────────────────────────────
36
37/// Result of validating a flake directory.
38#[derive(Debug)]
39pub struct FlakeCheckResult {
40    /// Whether the flake is structurally valid.
41    pub valid: bool,
42    /// Non-fatal warnings.
43    pub warnings: Vec<String>,
44    /// Fatal errors that prevent evaluation.
45    pub errors: Vec<String>,
46}
47
48/// Validate a flake directory's structure and lock file.
49///
50/// Checks:
51/// 1. `flake.nix` exists
52/// 2. `flake.lock` (if present) is valid JSON and parseable
53/// 3. The flake can be evaluated by the native evaluator
54pub fn check_flake(flake_dir: &Path) -> Result<FlakeCheckResult, FlakeLockUpdateError> {
55    let mut warnings = Vec::new();
56    let mut errors = Vec::new();
57
58    // 1. Verify flake.nix exists.
59    let flake_nix = flake_dir.join("flake.nix");
60    if !flake_nix.exists() {
61        return Ok(FlakeCheckResult {
62            valid: false,
63            warnings,
64            errors: vec!["flake.nix not found".to_string()],
65        });
66    }
67
68    // 2. Verify flake.lock exists and is valid.
69    let lock_path = flake_dir.join("flake.lock");
70    if lock_path.exists() {
71        let content = std::fs::read_to_string(&lock_path)?;
72        match FlakeLock::parse(&content) {
73            Ok(lock) => {
74                // Check that all root inputs resolve.
75                if let Err(e) = lock.root_inputs() {
76                    warnings.push(format!("unresolvable root inputs: {e}"));
77                }
78            }
79            Err(e) => {
80                errors.push(format!("flake.lock parse error: {e}"));
81            }
82        }
83    } else {
84        warnings.push("flake.lock not found (flake has no locked inputs)".to_string());
85    }
86
87    // 3. Try to evaluate the flake.
88    let source = std::fs::read_to_string(&flake_nix)?;
89    match crate::eval::eval(&source) {
90        Ok(_value) => {
91            // Basic structural check: a flake should evaluate to an attrset
92            // with at least an `outputs` attribute.
93        }
94        Err(e) => {
95            errors.push(format!("evaluation error: {e}"));
96        }
97    }
98
99    Ok(FlakeCheckResult {
100        valid: errors.is_empty(),
101        warnings,
102        errors,
103    })
104}
105
106// ── Flake lock update ─────────────────────────────────────────
107
108/// Update a single input in a `flake.lock` file to its latest revision.
109///
110/// Reads the lock file, resolves the latest commit for the named input,
111/// updates the `locked` section, and writes the file back.
112pub fn update_input(flake_dir: &Path, input_name: &str) -> Result<(), FlakeLockUpdateError> {
113    let lock_path = flake_dir.join("flake.lock");
114    let content = std::fs::read_to_string(&lock_path)?;
115    let mut lock = FlakeLock::parse(&content)
116        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
117
118    // Find which node the root's input points to.
119    let root_node = lock.root_node()
120        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
121
122    let input_ref = root_node
123        .inputs
124        .get(input_name)
125        .ok_or_else(|| FlakeLockUpdateError::InputNotFound(input_name.to_string()))?;
126
127    let node_name = lock
128        .resolve_ref(&lock.root.clone(), input_ref)
129        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
130
131    let node = lock
132        .nodes
133        .get(&node_name)
134        .ok_or_else(|| FlakeLockUpdateError::InputNotFound(node_name.clone()))?;
135
136    let original = node
137        .original
138        .as_ref()
139        .ok_or(FlakeLockUpdateError::InvalidFormat)?;
140
141    // Resolve the latest revision from the original reference.
142    let new_locked = resolve_latest(original)?;
143
144    // Mutate the node in place.
145    let node_mut = lock
146        .nodes
147        .get_mut(&node_name)
148        .ok_or_else(|| FlakeLockUpdateError::InputNotFound(node_name.clone()))?;
149    node_mut.locked = Some(new_locked);
150
151    // Write back.
152    let output = lock
153        .to_json()
154        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
155    std::fs::write(&lock_path, output)?;
156
157    Ok(())
158}
159
160/// Update all root-level inputs in a `flake.lock` file.
161///
162/// Returns the list of input names that were successfully updated.
163pub fn update_all_inputs(flake_dir: &Path) -> Result<Vec<String>, FlakeLockUpdateError> {
164    let lock_path = flake_dir.join("flake.lock");
165    let content = std::fs::read_to_string(&lock_path)?;
166    let lock = FlakeLock::parse(&content)
167        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
168
169    let root_node = lock.root_node()
170        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
171
172    let input_names: Vec<String> = root_node.inputs.keys().cloned().collect();
173    let mut updated = Vec::new();
174
175    for name in &input_names {
176        match update_input(flake_dir, name) {
177            Ok(()) => updated.push(name.clone()),
178            Err(e) => {
179                tracing::warn!("failed to update input {name}: {e}");
180            }
181        }
182    }
183
184    Ok(updated)
185}
186
187/// List all root-level input names from a flake.lock.
188pub fn list_inputs(flake_dir: &Path) -> Result<Vec<String>, FlakeLockUpdateError> {
189    let lock_path = flake_dir.join("flake.lock");
190    let content = std::fs::read_to_string(&lock_path)?;
191    let lock = FlakeLock::parse(&content)
192        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
193
194    let root_node = lock.root_node()
195        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
196
197    Ok(root_node.inputs.keys().cloned().collect())
198}
199
200/// Get the locked revision for a specific input.
201pub fn get_input_rev(
202    flake_dir: &Path,
203    input_name: &str,
204) -> Result<Option<String>, FlakeLockUpdateError> {
205    let lock_path = flake_dir.join("flake.lock");
206    let content = std::fs::read_to_string(&lock_path)?;
207    let lock = FlakeLock::parse(&content)
208        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
209
210    let node = lock
211        .resolve_input(&[input_name])
212        .map_err(|e| FlakeLockUpdateError::FlakeParse(e.to_string()))?;
213
214    Ok(node.locked.as_ref().and_then(|l| l.rev.clone()))
215}
216
217// ── Resolution ────────────────────────────────────────────────
218
219/// Resolve the latest revision for an original input reference.
220///
221/// Supported types: `github`, `git`. Other types return
222/// [`FlakeLockUpdateError::UnsupportedType`].
223fn resolve_latest(
224    original: &OriginalInput,
225) -> Result<sui_compat::flake::LockedInput, FlakeLockUpdateError> {
226    match original.source_type.as_str() {
227        "github" => resolve_github(original),
228        "git" => resolve_git(original),
229        other => Err(FlakeLockUpdateError::UnsupportedType(other.to_string())),
230    }
231}
232
233/// Resolve latest commit for a GitHub input via the GitHub API.
234fn resolve_github(
235    original: &OriginalInput,
236) -> Result<sui_compat::flake::LockedInput, FlakeLockUpdateError> {
237    let owner = original
238        .owner
239        .as_deref()
240        .ok_or(FlakeLockUpdateError::InvalidFormat)?;
241    let repo = original
242        .repo
243        .as_deref()
244        .ok_or(FlakeLockUpdateError::InvalidFormat)?;
245    let ref_name = original.git_ref.as_deref().unwrap_or("main");
246
247    let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{ref_name}");
248
249    let mut request = ureq::get(&url)
250        .header("User-Agent", "sui/0.1")
251        .header("Accept", "application/vnd.github.v3+json");
252
253    // Use GITHUB_TOKEN if available for rate limiting.
254    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
255        request = request.header("Authorization", &format!("token {token}"));
256    }
257
258    let resp = request
259        .call()
260        .map_err(|e| FlakeLockUpdateError::FetchFailed(e.to_string()))?;
261
262    if !resp.status().is_success() {
263        return Err(FlakeLockUpdateError::FetchFailed(format!(
264            "GitHub API returned {}",
265            resp.status().as_u16()
266        )));
267    }
268
269    let body = resp
270        .into_body()
271        .read_to_string()
272        .map_err(|e| FlakeLockUpdateError::FetchFailed(e.to_string()))?;
273
274    let commit: serde_json::Value = serde_json::from_str(&body)
275        .map_err(|e| FlakeLockUpdateError::FetchFailed(e.to_string()))?;
276
277    let sha = commit
278        .get("sha")
279        .and_then(|s| s.as_str())
280        .ok_or_else(|| FlakeLockUpdateError::FetchFailed("no sha in response".into()))?;
281
282    Ok(sui_compat::flake::LockedInput {
283        source_type: "github".to_string(),
284        owner: Some(owner.to_string()),
285        repo: Some(repo.to_string()),
286        rev: Some(sha.to_string()),
287        nar_hash: None, // Must be recomputed on first fetch.
288        last_modified: None,
289        path: None,
290        url: None,
291        git_ref: original.git_ref.clone(),
292        dir: original.dir.clone(),
293        host: None,
294        extra: std::collections::BTreeMap::new(),
295    })
296}
297
298/// Resolve latest commit for a git input via `git ls-remote`.
299fn resolve_git(
300    original: &OriginalInput,
301) -> Result<sui_compat::flake::LockedInput, FlakeLockUpdateError> {
302    let url = original
303        .url
304        .as_deref()
305        .ok_or(FlakeLockUpdateError::InvalidFormat)?;
306    let ref_name = original.git_ref.as_deref().unwrap_or("main");
307
308    let sha = crate::git::ls_remote(url, ref_name)
309        .map_err(|e| FlakeLockUpdateError::FetchFailed(format!("git ls-remote: {e}")))?;
310
311    Ok(sui_compat::flake::LockedInput {
312        source_type: "git".to_string(),
313        owner: None,
314        repo: None,
315        rev: Some(sha.to_string()),
316        nar_hash: None,
317        last_modified: None,
318        path: None,
319        url: Some(url.to_string()),
320        git_ref: original.git_ref.clone(),
321        dir: original.dir.clone(),
322        host: None,
323        extra: std::collections::BTreeMap::new(),
324    })
325}
326
327// ── Tests ─────────────────────────────────────────────────────
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// Helper: write a minimal flake.nix.
334    fn write_flake_nix(dir: &Path) {
335        std::fs::write(
336            dir.join("flake.nix"),
337            r#"{ outputs = { self }: { }; }"#,
338        )
339        .unwrap();
340    }
341
342    /// Helper: minimal valid flake.lock JSON.
343    fn minimal_lock_json() -> String {
344        serde_json::json!({
345            "nodes": {
346                "nixpkgs": {
347                    "locked": {
348                        "lastModified": 1700000000,
349                        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
350                        "owner": "nixos",
351                        "repo": "nixpkgs",
352                        "rev": "abc123def456abc123def456abc123def456abc1",
353                        "type": "github"
354                    },
355                    "original": {
356                        "owner": "nixos",
357                        "ref": "nixos-unstable",
358                        "repo": "nixpkgs",
359                        "type": "github"
360                    }
361                },
362                "root": {
363                    "inputs": {
364                        "nixpkgs": "nixpkgs"
365                    }
366                }
367            },
368            "root": "root",
369            "version": 7
370        })
371        .to_string()
372    }
373
374    /// Helper: flake.lock with two inputs.
375    fn two_input_lock_json() -> String {
376        serde_json::json!({
377            "nodes": {
378                "nixpkgs": {
379                    "locked": {
380                        "lastModified": 1700000000,
381                        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
382                        "owner": "nixos",
383                        "repo": "nixpkgs",
384                        "rev": "abc123def456abc123def456abc123def456abc1",
385                        "type": "github"
386                    },
387                    "original": {
388                        "owner": "nixos",
389                        "ref": "nixos-unstable",
390                        "repo": "nixpkgs",
391                        "type": "github"
392                    }
393                },
394                "utils": {
395                    "locked": {
396                        "lastModified": 1699999998,
397                        "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
398                        "owner": "numtide",
399                        "repo": "flake-utils",
400                        "rev": "ccccccccccccccccccccccccccccccccccccccc1",
401                        "type": "github"
402                    },
403                    "original": {
404                        "owner": "numtide",
405                        "repo": "flake-utils",
406                        "type": "github"
407                    }
408                },
409                "root": {
410                    "inputs": {
411                        "nixpkgs": "nixpkgs",
412                        "utils": "utils"
413                    }
414                }
415            },
416            "root": "root",
417            "version": 7
418        })
419        .to_string()
420    }
421
422    // ── check_flake ──────────────────────────────────────────
423
424    #[test]
425    fn check_flake_missing_flake_nix() {
426        let tmp = tempfile::tempdir().unwrap();
427        let result = check_flake(tmp.path()).unwrap();
428        assert!(!result.valid);
429        assert!(result.errors.iter().any(|e| e.contains("flake.nix not found")));
430    }
431
432    #[test]
433    fn check_flake_valid_minimal() {
434        let tmp = tempfile::tempdir().unwrap();
435        write_flake_nix(tmp.path());
436        let result = check_flake(tmp.path()).unwrap();
437        assert!(result.valid, "errors: {:?}", result.errors);
438        // Should warn about missing flake.lock.
439        assert!(result.warnings.iter().any(|w| w.contains("flake.lock not found")));
440    }
441
442    #[test]
443    fn check_flake_with_valid_lock() {
444        let tmp = tempfile::tempdir().unwrap();
445        write_flake_nix(tmp.path());
446        std::fs::write(tmp.path().join("flake.lock"), minimal_lock_json()).unwrap();
447        let result = check_flake(tmp.path()).unwrap();
448        assert!(result.valid, "errors: {:?}", result.errors);
449        assert!(result.warnings.is_empty(), "warnings: {:?}", result.warnings);
450    }
451
452    #[test]
453    fn check_flake_with_invalid_lock_json() {
454        let tmp = tempfile::tempdir().unwrap();
455        write_flake_nix(tmp.path());
456        std::fs::write(tmp.path().join("flake.lock"), "not json at all").unwrap();
457        let result = check_flake(tmp.path()).unwrap();
458        assert!(!result.valid);
459        assert!(result.errors.iter().any(|e| e.contains("parse error")));
460    }
461
462    #[test]
463    fn check_flake_with_bad_version() {
464        let tmp = tempfile::tempdir().unwrap();
465        write_flake_nix(tmp.path());
466        let bad_lock = serde_json::json!({
467            "nodes": { "root": { "inputs": {} } },
468            "root": "root",
469            "version": 99
470        })
471        .to_string();
472        std::fs::write(tmp.path().join("flake.lock"), bad_lock).unwrap();
473        let result = check_flake(tmp.path()).unwrap();
474        assert!(!result.valid);
475        assert!(result.errors.iter().any(|e| e.contains("parse error")));
476    }
477
478    // ── list_inputs ──────────────────────────────────────────
479
480    #[test]
481    fn list_inputs_returns_root_input_names() {
482        let tmp = tempfile::tempdir().unwrap();
483        std::fs::write(tmp.path().join("flake.lock"), two_input_lock_json()).unwrap();
484        let mut inputs = list_inputs(tmp.path()).unwrap();
485        inputs.sort();
486        assert_eq!(inputs, vec!["nixpkgs".to_string(), "utils".to_string()]);
487    }
488
489    // ── get_input_rev ────────────────────────────────────────
490
491    #[test]
492    fn get_input_rev_returns_locked_rev() {
493        let tmp = tempfile::tempdir().unwrap();
494        std::fs::write(tmp.path().join("flake.lock"), minimal_lock_json()).unwrap();
495        let rev = get_input_rev(tmp.path(), "nixpkgs").unwrap();
496        assert_eq!(rev, Some("abc123def456abc123def456abc123def456abc1".to_string()));
497    }
498
499    #[test]
500    fn get_input_rev_not_found() {
501        let tmp = tempfile::tempdir().unwrap();
502        std::fs::write(tmp.path().join("flake.lock"), minimal_lock_json()).unwrap();
503        let result = get_input_rev(tmp.path(), "nonexistent");
504        assert!(result.is_err());
505    }
506
507    // ── update_input (offline — missing network) ─────────────
508
509    #[test]
510    fn update_input_not_found_errors() {
511        let tmp = tempfile::tempdir().unwrap();
512        std::fs::write(tmp.path().join("flake.lock"), minimal_lock_json()).unwrap();
513        let result = update_input(tmp.path(), "does-not-exist");
514        assert!(matches!(
515            result.unwrap_err(),
516            FlakeLockUpdateError::InputNotFound(_)
517        ));
518    }
519
520    #[test]
521    fn update_input_missing_lock_file_errors() {
522        let tmp = tempfile::tempdir().unwrap();
523        let result = update_input(tmp.path(), "nixpkgs");
524        assert!(matches!(result.unwrap_err(), FlakeLockUpdateError::Io(_)));
525    }
526
527    // ── resolve_latest with unsupported type ─────────────────
528
529    #[test]
530    fn resolve_unsupported_type_errors() {
531        let original = OriginalInput {
532            source_type: "mercurial".to_string(),
533            owner: None,
534            repo: None,
535            git_ref: None,
536            url: None,
537            dir: None,
538            id: None,
539            extra: std::collections::BTreeMap::new(),
540        };
541        let result = resolve_latest(&original);
542        assert!(matches!(
543            result.unwrap_err(),
544            FlakeLockUpdateError::UnsupportedType(_)
545        ));
546    }
547
548    // ── round-trip: parse -> to_json -> parse ────────────────
549
550    #[test]
551    fn lock_file_round_trips() {
552        let json = minimal_lock_json();
553        let lock = FlakeLock::parse(&json).unwrap();
554        let serialized = lock.to_json().unwrap();
555        let lock2 = FlakeLock::parse(&serialized).unwrap();
556        assert_eq!(lock.version, lock2.version);
557        assert_eq!(lock.root, lock2.root);
558        assert_eq!(lock.nodes.len(), lock2.nodes.len());
559    }
560
561    // ── online tests (gated behind SUI_TEST_ONLINE=1) ────────
562
563    #[test]
564    fn update_input_github_online() {
565        if std::env::var("SUI_TEST_ONLINE").as_deref() != Ok("1") {
566            eprintln!("skipping online test (set SUI_TEST_ONLINE=1)");
567            return;
568        }
569
570        let tmp = tempfile::tempdir().unwrap();
571        // Use a small, stable repo for the test.
572        let lock = serde_json::json!({
573            "nodes": {
574                "systems": {
575                    "locked": {
576                        "lastModified": 1681028828,
577                        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309Q9mB/Cg=",
578                        "owner": "nix-systems",
579                        "repo": "default",
580                        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
581                        "type": "github"
582                    },
583                    "original": {
584                        "owner": "nix-systems",
585                        "repo": "default",
586                        "type": "github"
587                    }
588                },
589                "root": {
590                    "inputs": {
591                        "systems": "systems"
592                    }
593                }
594            },
595            "root": "root",
596            "version": 7
597        })
598        .to_string();
599        std::fs::write(tmp.path().join("flake.lock"), &lock).unwrap();
600
601        update_input(tmp.path(), "systems").unwrap();
602
603        // Verify the rev was updated (it should now be a 40-char hex string).
604        let new_rev = get_input_rev(tmp.path(), "systems").unwrap().unwrap();
605        assert_eq!(new_rev.len(), 40, "expected 40-char SHA, got: {new_rev}");
606    }
607}