From d1e3e92215a37b86bae0bec07f3cc1dead421a30 Mon Sep 17 00:00:00 2001 From: MasterEnderman Date: Sun, 9 Aug 2026 23:25:41 +0200 Subject: [PATCH] improve my flake-runner Signed-off-by: MasterEnderman --- home/console/nix-flake.nix | 2 +- pkgs/flake-runner/Cargo.lock | 1 + pkgs/flake-runner/Cargo.toml | 1 + pkgs/flake-runner/src/main.rs | 381 +++++++++++++++++++++++++++++----- 4 files changed, 336 insertions(+), 49 deletions(-) diff --git a/home/console/nix-flake.nix b/home/console/nix-flake.nix index 56d774a..d86de75 100644 --- a/home/console/nix-flake.nix +++ b/home/console/nix-flake.nix @@ -38,7 +38,7 @@ ++ lib.optional config.modules.terminal.pedantix.enable "--pedantix" ); in '' - ef flake-banner [] { + def flake-banner [] { clear figlet -w (term size | get columns) -f graffiti "flake-runner" } diff --git a/pkgs/flake-runner/Cargo.lock b/pkgs/flake-runner/Cargo.lock index dadf05b..f7d8bd1 100644 --- a/pkgs/flake-runner/Cargo.lock +++ b/pkgs/flake-runner/Cargo.lock @@ -140,6 +140,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "console", "indicatif", ] diff --git a/pkgs/flake-runner/Cargo.toml b/pkgs/flake-runner/Cargo.toml index 3e1dc24..439d10c 100644 --- a/pkgs/flake-runner/Cargo.toml +++ b/pkgs/flake-runner/Cargo.toml @@ -6,4 +6,5 @@ edition = "2024" [dependencies] anyhow = "1.0.104" clap = { version = "4.6.6", features = ["derive"] } +console = "0.16.4" indicatif = "0.18.6" diff --git a/pkgs/flake-runner/src/main.rs b/pkgs/flake-runner/src/main.rs index d26e844..189f987 100644 --- a/pkgs/flake-runner/src/main.rs +++ b/pkgs/flake-runner/src/main.rs @@ -1,17 +1,34 @@ +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}; -use anyhow::{bail, Context, Result}; -use clap::Parser; -use indicatif::{ProgressBar, ProgressStyle}; - 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", @@ -52,6 +69,16 @@ struct Stage<'a> { 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(); @@ -87,19 +114,26 @@ fn main() -> Result<()> { } 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("{spinner:.cyan.bold} [{bar:40.cyan/black}] {msg}") + ProgressStyle::with_template(&template) .unwrap() - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]) - .progress_chars("█░"), + .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(); @@ -117,7 +151,53 @@ fn main() -> Result<()> { animate_to(&pb, &mut current_units, target, ANIMATE_DURATION); } - pb.finish_with_message("✅ done"); + 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(()) } @@ -137,25 +217,24 @@ fn animate_to(pb: &ProgressBar, current: &mut u64, target: u64, duration: Durati pb.set_position(target); } -fn run_command(pb: &ProgressBar, program: &str, cmd_args: &[&str], cwd: &Path) -> Result<()> { - pb.suspend(|| { - let status = Command::new(program) - .args(cmd_args) - .current_dir(cwd) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .with_context(|| format!("failed to spawn `{program}`"))?; +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 !status.success() { - bail!("`{program} {}` exited with {status}", cmd_args.join(" ")); - } - Ok(()) - }) + 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(program: &str, cmd_args: &[&str], cwd: &Path) -> Result { +fn run_command_capture_combined(program: &str, cmd_args: &[&str], cwd: &Path) -> Result { let output = Command::new(program) .args(cmd_args) .current_dir(cwd) @@ -169,12 +248,121 @@ fn run_command_capture(program: &str, cmd_args: &[&str], cwd: &Path) -> Result, + 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")?; - run_command(pb, "jj", &["-R", repo, "git", "fetch"], &args.repo)?; + + 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", @@ -201,44 +389,137 @@ fn stage_pull(args: &Args, pb: &ProgressBar) -> Result<()> { fn stage_update(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; - run_command(pb, "nix", &["flake", "update", "--flake", repo], &args.repo) + 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_command(pb, "pedantix", &[file], &args.repo)?; + run_tool( + pb, + ICON_FORMAT, + "pedantix", + Some(file), + "pedantix", + &[file], + &args.repo, + )?; } } else { - run_command(pb, "alejandra", &[repo], &args.repo)?; + pb.println(format!( + " {ICON_FORMAT} {} {}", + style("Formatter:").blue().bold(), + style("ALEJANDRA").cyan() + )); + run_tool( + pb, + ICON_FORMAT, + "alejandra", + None, + "alejandra", + &[repo], + &args.repo, + )?; } - run_command(pb, "statix", &["check", repo], &args.repo)?; - run_command(pb, "deadnix", &["--fail", 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())); - run_command( - pb, - "nh", - &[ - "os", - "switch", - repo, - "-H", - &args.host, - "--quiet", - "--elevation-strategy", - "sudo", - ], - &args.repo, - )?; + if !status.success() { + bail!("`nh os switch` exited with {status}"); + } let generations = run_command_capture("nixos-rebuild", &["list-generations"], &args.repo)?; let latest = generations @@ -247,6 +528,10 @@ fn stage_rebuild(args: &Args, pb: &ProgressBar) -> Result<()> { .and_then(|l| l.split_whitespace().next()) .unwrap_or("unknown"); - pb.println(format!("✅ Successfully built Generation {latest}")); + pb.println(format!( + " 󱓏 {} {}", + style("Successfully built Generation").blue().bold(), + style(latest).blue().bold() + )); Ok(()) } -- 2.51.2