1use std::path::Path;
11
12use sui_compat::flake::{FlakeLock, OriginalInput};
13
14#[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#[derive(Debug)]
39pub struct FlakeCheckResult {
40 pub valid: bool,
42 pub warnings: Vec<String>,
44 pub errors: Vec<String>,
46}
47
48pub fn check_flake(flake_dir: &Path) -> Result<FlakeCheckResult, FlakeLockUpdateError> {
55 let mut warnings = Vec::new();
56 let mut errors = Vec::new();
57
58 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 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 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 let source = std::fs::read_to_string(&flake_nix)?;
89 match crate::eval::eval(&source) {
90 Ok(_value) => {
91 }
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
106pub 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 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 let new_locked = resolve_latest(original)?;
143
144 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 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
160pub 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
187pub 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
200pub 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
217fn 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
233fn 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 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, 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
298fn 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#[cfg(test)]
330mod tests {
331 use super::*;
332
333 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}