use anyhow::{Context, Result};
use colored::Colorize;
use serde_json::Value;
use std::fs;
use crate::nix::run_nix_command;
use flk::utils::{backup, visual::with_spinner};
pub fn run_update(packages: Vec<String>, show: bool) -> Result<()> {
if !packages.is_empty() {
anyhow::bail!(
"Updating specific packages requires version pinning (see issue #7). Use 'flk update' to update all packages."
);
}
if show {
show_update_preview()?;
} else {
perform_update()?;
}
Ok(())
}
fn show_update_preview() -> Result<()> {
println!("{}", "Checking for updates...".bold().cyan());
println!();
if !std::path::Path::new("flake.lock").exists() {
anyhow::bail!("flake.lock not found. Run 'nix flake lock' first.");
}
let current_lock = read_lock_file()?;
fs::copy("flake.lock", "flake.lock.tmp")?;
let (_, stderr, success) =
run_nix_command(&["flake", "update"]).context("Failed to check for updates")?;
if !success {
fs::rename("flake.lock.tmp", "flake.lock")?;
anyhow::bail!("Failed to check for updates: {}", stderr);
}
let updated_lock = read_lock_file()?;
fs::rename("flake.lock.tmp", "flake.lock")?;
display_update_diff(¤t_lock, &updated_lock)?;
println!();
println!(
"{}",
"No changes were made. Run 'flk update' to apply these updates.".dimmed()
);
Ok(())
}
fn perform_update() -> Result<()> {
println!("{}", "Updating flake inputs...".bold().cyan());
backup::ensure_flk_dir()?;
if std::path::Path::new("flake.lock").exists() {
let backup_path = backup::create_backup(std::path::Path::new("flake.lock"))?;
println!(
"{} Created backup: {}",
"→".blue().bold(),
backup_path.file_name().unwrap().to_string_lossy().dimmed()
);
}
let (stdout, stderr, success) = with_spinner("Updating flake...", || {
run_nix_command(&["flake", "update"]).context("Failed to execute nix flake update")
})?;
if !success {
anyhow::bail!("Failed to update flake: {}", stderr);
}
if !stdout.trim().is_empty() {
println!("{}", stdout);
}
println!("{}", "✓ Flake updated successfully!".green().bold());
println!("\n{}", "Next steps:".bold());
println!(
" • Run {} to see the updated configuration",
"flk show".cyan()
);
println!(
" • Run {} to see lock file details",
"flk lock show".cyan()
);
println!(
" • Run {} if you need to rollback",
"flk lock restore latest".cyan()
);
Ok(())
}
fn read_lock_file() -> Result<Value> {
let lock_content = fs::read_to_string("flake.lock").context("Failed to read flake.lock")?;
let lock_data: Value =
serde_json::from_str(&lock_content).context("Failed to parse flake.lock")?;
Ok(lock_data)
}
fn display_update_diff(current: &Value, updated: &Value) -> Result<()> {
println!("{}", "═══════════════════════════════════════".cyan());
println!("{}", "Update Preview".bold().cyan());
println!("{}", "═══════════════════════════════════════".cyan());
println!();
let current_nodes = ¤t["nodes"];
let updated_nodes = &updated["nodes"];
if let (Some(current_obj), Some(updated_obj)) =
(current_nodes.as_object(), updated_nodes.as_object())
{
let mut changes_found = false;
for (input_name, _) in current_obj.iter() {
if input_name == "root" {
continue;
}
let current_info = ¤t_obj[input_name]["locked"];
let updated_info = &updated_obj[input_name]["locked"];
if current_info != updated_info && !current_info.is_null() && !updated_info.is_null() {
changes_found = true;
display_input_change(input_name, current_info, updated_info);
}
}
if !changes_found {
println!(
"{}",
" No updates available. All inputs are up to date! ✓".green()
);
}
} else {
println!("{}", " Unable to compare lock files".yellow());
}
println!();
println!("{}", "═══════════════════════════════════════".cyan());
Ok(())
}
fn display_input_change(name: &str, current: &Value, updated: &Value) {
println!("{} {}", "Input:".bold(), name.cyan());
if let Some(input_type) = current["type"].as_str() {
println!(" {} {}", "Type:".dimmed(), input_type);
}
if let (Some(current_rev), Some(updated_rev)) =
(current["rev"].as_str(), updated["rev"].as_str())
{
if current_rev != updated_rev {
let current_short = if current_rev.len() >= 12 {
¤t_rev[..12]
} else {
current_rev
};
let updated_short = if updated_rev.len() >= 12 {
&updated_rev[..12]
} else {
updated_rev
};
println!(" {} {}", "From:".dimmed(), current_short.yellow());
println!(" {} {}", "To: ".dimmed(), updated_short.green());
}
}
if let (Some(current_modified), Some(updated_modified)) = (
current["lastModified"].as_i64(),
updated["lastModified"].as_i64(),
) {
if current_modified != updated_modified {
println!(" {} {}", "Last Modified:".dimmed(), "updated".green());
}
}
if let (Some(current_hash), Some(updated_hash)) =
(current["narHash"].as_str(), updated["narHash"].as_str())
{
if current_hash != updated_hash {
println!(" {} {}", "Content:".dimmed(), "changed ✓".green());
}
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::cwd_test_guard;
use crate::nix::with_nix_runner;
use tempfile::TempDir;
const LOCK_BEFORE: &str = r#"{
"nodes": {
"root": { "inputs": { "nixpkgs": "nixpkgs" } },
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-aaa",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0123456789abcdef00000000000000000000aaaa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
"#;
const LOCK_AFTER: &str = r#"{
"nodes": {
"root": { "inputs": { "nixpkgs": "nixpkgs" } },
"nixpkgs": {
"locked": {
"lastModified": 1800000000,
"narHash": "sha256-bbb",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fedcba987654321000000000000000000000bbbb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
"#;
fn setup_lock(lock: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("flake.lock"), lock).unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
tmp
}
#[test]
fn rejects_specific_packages() {
let err = run_update(vec!["ripgrep".into()], false).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("version pinning"),
"got: {err}"
);
}
#[test]
fn preview_errors_when_lock_missing() {
let _guard = cwd_test_guard();
let tmp = TempDir::new().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let err = run_update(vec![], true).unwrap_err();
assert!(
err.to_string().contains("flake.lock not found"),
"got: {err}"
);
}
#[test]
fn preview_success_displays_diff_and_restores_original() {
let _guard = cwd_test_guard();
let tmp = setup_lock(LOCK_BEFORE);
let lock_path = tmp.path().join("flake.lock");
let updated = LOCK_AFTER.to_string();
with_nix_runner(
move |args| {
assert_eq!(args, &["flake", "update"]);
std::fs::write("flake.lock", &updated).unwrap();
Ok((String::new(), String::new(), true))
},
|| run_update(vec![], true).unwrap(),
);
assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), LOCK_BEFORE);
assert!(!tmp.path().join("flake.lock.tmp").exists());
}
#[test]
fn preview_no_changes_when_lock_unchanged() {
let _guard = cwd_test_guard();
let tmp = setup_lock(LOCK_BEFORE);
with_nix_runner(
|_| Ok((String::new(), String::new(), true)),
|| run_update(vec![], true).unwrap(),
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("flake.lock")).unwrap(),
LOCK_BEFORE
);
}
#[test]
fn preview_restores_lock_when_nix_fails() {
let _guard = cwd_test_guard();
let tmp = setup_lock(LOCK_BEFORE);
let err = with_nix_runner(
|_| Ok((String::new(), "boom".into(), false)),
|| run_update(vec![], true).unwrap_err(),
);
assert!(err.to_string().contains("boom"), "got: {err}");
assert_eq!(
std::fs::read_to_string(tmp.path().join("flake.lock")).unwrap(),
LOCK_BEFORE,
"lock must be unchanged after failure"
);
assert!(
!tmp.path().join("flake.lock.tmp").exists(),
"tmp must be cleaned up (renamed onto flake.lock) on failure"
);
}
#[test]
fn update_creates_backup_and_succeeds() {
let _guard = cwd_test_guard();
let tmp = setup_lock(LOCK_BEFORE);
with_nix_runner(
|args| {
assert_eq!(args, &["flake", "update"]);
Ok(("updated nixpkgs\n".into(), String::new(), true))
},
|| run_update(vec![], false).unwrap(),
);
let backups: Vec<_> = std::fs::read_dir(tmp.path().join(".flk/backups"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert!(
backups.iter().any(|n| n.starts_with("flake.lock.")),
"expected a flake.lock.* backup; found {backups:?}"
);
}
#[test]
fn update_errors_when_nix_fails() {
let _guard = cwd_test_guard();
let _tmp = setup_lock(LOCK_BEFORE);
let err = with_nix_runner(
|_| Ok((String::new(), "network down".into(), false)),
|| run_update(vec![], false).unwrap_err(),
);
assert!(err.to_string().contains("network down"), "got: {err}");
}
}