use anyhow::{bail, Context, Result}; use clap::Parser; use console::{style, Color}; use indicatif::{ProgressBar, ProgressStyle}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; const SUBDIVISIONS: u64 = 100; const MIN_STEP_DURATION: Duration = Duration::from_millis(450); const ANIMATE_DURATION: Duration = Duration::from_millis(350); const ANIMATE_FRAMES: u32 = 30; const ICON_PULL: &str = ""; const ICON_REMOTE: &str = "󰳆"; const ICON_LOCAL: &str = ""; const ICON_UPDATE: &str = "󰚰"; const ICON_FORMAT: &str = "󰉨"; const ICON_LINT: &str = ""; const ICON_CHECK: &str = ""; const ICON_PAUSE: &str = "󰏦"; const ICON_REBUILD: &str = "󱨸"; const ICON_CLOCK: &str = "󰅒"; const FILL_CHAR: &str = "█"; const EMPTY_CHAR: &str = "░"; const ICON_BRACKET_OPEN: &str = ""; const ICON_BRACKET_CLOSE: &str = ""; const TICK_FRAMES: &[&str] = &["", "", "", "", "", "", "󰄳"]; #[derive(Parser)] #[command( name = "flake-runner", about = "Pull/update/format/rebuild pipeline for a NixOS flake repo" )] struct Args { /// jj git fetch, hard gate on unresolved conflicts #[arg(short, long)] pull: bool, /// nix flake update --flake #[arg(short, long)] update: bool, /// format .nix files, then statix + deadnix (hard gates) #[arg(short, long)] format: bool, /// nh os switch, prints resulting generation #[arg(short, long)] rebuild: bool, /// Absolute path to the flake repo #[arg(long)] repo: PathBuf, /// Hostname passed to `nh os switch -H` #[arg(long)] host: String, /// Use pedantix (per-file alejandra wrapper) instead of plain alejandra #[arg(long)] pedantix: bool, } struct Stage<'a> { name: &'a str, run: fn(&Args, &ProgressBar) -> Result<()>, } fn stage_color(name: &str) -> Color { match name { "pull" => Color::Blue, "update" => Color::Magenta, "format" => Color::Yellow, "rebuild" => Color::Green, _ => Color::Cyan, } } fn main() -> Result<()> { let args = Args::parse(); let mut stages: Vec = Vec::new(); if args.pull { stages.push(Stage { name: "pull", run: stage_pull, }); } if args.update { stages.push(Stage { name: "update", run: stage_update, }); } if args.format { stages.push(Stage { name: "format", run: stage_format, }); } if args.rebuild { stages.push(Stage { name: "rebuild", run: stage_rebuild, }); } if stages.is_empty() { println!("⚠️ No stage flags given — nothing to do. Try --help."); return Ok(()); } let total = stages.len(); let bracket_open = style(ICON_BRACKET_OPEN).cyan(); let bracket_close = style(ICON_BRACKET_CLOSE).cyan(); let template = format!( "{{spinner:.cyan.bold}} {bracket_open}{{bar:40.cyan/black}}{bracket_close} {{percent:>3}}% {{msg}} (󰅒 {{elapsed_precise}})" ); let pb = ProgressBar::new((total as u64) * SUBDIVISIONS); pb.set_style( ProgressStyle::with_template(&template) .unwrap() .tick_strings(TICK_FRAMES) .progress_chars(&format!("{FILL_CHAR}{EMPTY_CHAR}")), ); pb.enable_steady_tick(Duration::from_millis(80)); let mut current_units: u64 = 0; for (idx, stage) in stages.iter().enumerate() { announce_stage(&pb, stage.name)?; pb.set_message(format!("{} ({}/{total})", stage.name, idx + 1)); let start = Instant::now(); if let Err(e) = (stage.run)(&args, &pb) { pb.abandon_with_message(format!("❌ {} failed", stage.name)); return Err(e); } let elapsed = start.elapsed(); if elapsed < MIN_STEP_DURATION { thread::sleep(MIN_STEP_DURATION - elapsed); } let target = ((idx + 1) as u64) * SUBDIVISIONS; animate_to(&pb, &mut current_units, target, ANIMATE_DURATION); } pb.finish_with_message("🏁"); Ok(()) } fn format_elapsed(d: Duration) -> String { let secs = d.as_secs(); format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60) } fn frozen_bar_line(pb: &ProgressBar, icon: &str, msg: &str) -> String { let width = 40usize; let pos = pb.position(); let len = pb.length().unwrap_or(1).max(1); let ratio = (pos as f64 / len as f64).clamp(0.0, 1.0); let filled = (ratio * width as f64).round() as usize; let bar_str = format!( "{}{}", FILL_CHAR.repeat(filled), EMPTY_CHAR.repeat(width - filled) ); let pct = (ratio * 100.0).round() as u64; format!( "{} {}{}{} {:>3}% {} ({ICON_CLOCK} {})", style(icon).yellow().bold(), style(ICON_BRACKET_OPEN).cyan(), style(bar_str).cyan(), style(ICON_BRACKET_CLOSE).cyan(), pct, msg, format_elapsed(pb.elapsed()) ) } fn announce_stage(pb: &ProgressBar, name: &str) -> Result<()> { let color = stage_color(name); let rule = style("─".repeat(50)).fg(color).dim(); pb.println(rule.to_string()); let output = Command::new("figlet") .args(["-f", "cybermedium", &name.to_uppercase()]) .output() .context("failed to run figlet")?; for line in String::from_utf8_lossy(&output.stdout).lines() { pb.println(style(line).fg(color).bold().to_string()); } Ok(()) } fn animate_to(pb: &ProgressBar, current: &mut u64, target: u64, duration: Duration) { let start_val = *current as f64; let delta = target as f64 - start_val; let frame_time = duration / ANIMATE_FRAMES; for i in 1..=ANIMATE_FRAMES { let t = i as f64 / ANIMATE_FRAMES as f64; let eased = 1.0 - (1.0 - t).powi(3); pb.set_position((start_val + delta * eased).round() as u64); thread::sleep(frame_time); } *current = target; pb.set_position(target); } fn run_command_capture(program: &str, cmd_args: &[&str], cwd: &Path) -> Result { let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?; if !output.status.success() { bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn run_command_capture_combined(program: &str, cmd_args: &[&str], cwd: &Path) -> Result { let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?; if !output.status.success() { bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); } let mut combined = String::from_utf8_lossy(&output.stdout).to_string(); combined.push_str(&String::from_utf8_lossy(&output.stderr)); Ok(combined) } fn run_tool( pb: &ProgressBar, icon: &str, name: &str, detail: Option<&str>, program: &str, cmd_args: &[&str], cwd: &Path, ) -> Result<()> { let plain_label = match detail { Some(d) => format!("{name} {d}"), None => name.to_string(), }; pb.set_message(format!("{icon} {plain_label}")); let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?; if !output.status.success() { let combined = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); if !combined.trim().is_empty() { for line in combined.trim_end().lines() { pb.println(format!(" {}", style(line).red())); } } bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); } let colored = match detail { Some(d) => format!("{} {}", style(name).green(), style(d).dim()), None => style(name).green().to_string(), }; pb.println(format!( " 󰶻 {} {colored}", style(ICON_CHECK).green().bold() )); Ok(()) } fn stage_pull(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; let remotes = run_command_capture("jj", &["-R", repo, "git", "remote", "list"], &args.repo)?; for line in remotes.lines().filter(|l| !l.trim().is_empty()) { pb.println(format!( " {ICON_REMOTE} Pulling newest changes from {}", style(line.trim()).cyan().bold() )); } pb.set_message(format!("{ICON_PULL} fetching")); let fetch_output = run_command_capture_combined("jj", &["-R", repo, "git", "fetch"], &args.repo)?; for line in fetch_output.lines() { let line = line.trim(); if line.is_empty() { continue; } if line == "Nothing changed." { pb.println(format!( " 󱞩 {ICON_CHECK} {}", style("no remote changes to pull").green() )); } else { pb.println(format!(" {}", style(line).yellow())); } } let local_head = run_command_capture( "jj", &[ "-R", repo, "log", "-r", "@-", "--no-graph", "-T", "change_id.shortest(8) ++ \" \" ++ commit_id.shortest(8) ++ \" \" ++ description.first_line()", ], &args.repo, )?; pb.println(format!( " {ICON_LOCAL} {} {}", style("Local Head:").blue().bold(), style(local_head.trim()).white() )); let bookmark_status = run_command_capture( "jj", &["-R", repo, "bookmark", "list", "--all-remotes"], &args.repo, )?; if !bookmark_status.trim().is_empty() { pb.println(format!( " {ICON_REMOTE} {}", style("Bookmark Status:").blue().bold() )); for line in bookmark_status.lines().filter(|l| !l.trim().is_empty()) { pb.println(format!(" {}", style(line).dim())); } } let conflicted = run_command_capture( "jj", &[ "-R", repo, "log", "-r", "conflicts()", "--no-graph", "-T", "change_id ++ \"\\n\"", ], &args.repo, )?; if !conflicted.trim().is_empty() { bail!( "jj fetch produced conflicts, resolve before continuing:\n{}", conflicted.trim() ); } Ok(()) } fn stage_update(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; pb.set_message(format!("{ICON_UPDATE} nix flake update")); let output = run_command_capture_combined("nix", &["flake", "update", "--flake", repo], &args.repo)?; let mut any_changed = false; for line in output.lines() { let line = line.trim(); if line.is_empty() { continue; } if line.starts_with('•') || line.contains("Updated input") { any_changed = true; pb.println(format!(" {}", style(line).yellow())); } else { pb.println(format!(" {}", style(line).dim())); } } if any_changed { pb.println(format!( " {ICON_CHECK} {}", style("flake.lock updated").green().bold() )); } else { pb.println(format!( " {ICON_CHECK} {}", style("flake.lock already up to date").green() )); } Ok(()) } fn stage_format(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; if args.pedantix { pb.println(format!( " {ICON_FORMAT} {} {} {}", style("Formatter:").blue().bold(), style("PEDANTIX").cyan(), style("(per-file pedantic alejandra wrapper)").dim() )); let files = run_command_capture("fd", &[".nix", repo], &args.repo)?; for file in files.lines().filter(|l| !l.trim().is_empty()) { run_tool( pb, ICON_FORMAT, "pedantix", Some(file), "pedantix", &[file], &args.repo, )?; } } else { pb.println(format!( " {ICON_FORMAT} {} {}", style("Formatter:").blue().bold(), style("ALEJANDRA").cyan() )); run_tool( pb, ICON_FORMAT, "alejandra", None, "alejandra", &[repo], &args.repo, )?; } pb.println(format!( " {ICON_LINT} {}", style("Running lints:").blue().bold() )); run_tool( pb, ICON_LINT, "statix check", None, "statix", &["check", repo], &args.repo, )?; run_tool( pb, ICON_LINT, "deadnix --fail", None, "deadnix", &["--fail", repo], &args.repo, )?; Ok(()) } fn stage_rebuild(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; pb.println(format!( " {ICON_REBUILD} {}", style("Handing over to nh for rebuild...").blue().bold() )); pb.println(frozen_bar_line(pb, ICON_PAUSE, "nh os switch")); pb.println(format!("{}", style("".repeat(50)).blue().dim())); let status = pb.suspend(|| { Command::new("nh") .args([ "os", "switch", repo, "-H", &args.host, "--quiet", "--elevation-strategy", "sudo", ]) .current_dir(&args.repo) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to spawn `nh`") })?; pb.println(format!("{}", style("".repeat(50)).blue().dim())); if !status.success() { bail!("`nh os switch` exited with {status}"); } let generations = run_command_capture("nixos-rebuild", &["list-generations"], &args.repo)?; let latest = generations .lines() .nth(1) .and_then(|l| l.split_whitespace().next()) .unwrap_or("unknown"); pb.println(format!( " 󱓏 {} {}", style("Successfully built Generation").blue().bold(), style(latest).blue().bold() )); Ok(()) }