Files
RedBear-OS/src/cook/status.rs
T
vasilito ffbe098ef8 config: add tlc to redbear-mini and redbear-full; create recipe symlink
TLC (Twilight Commander) was missing from both ISO configs. Added
tlc = {} to [packages] in redbear-mini.toml and redbear-full.toml.
Created missing symlink: recipes/tui/tlc -> ../../local/recipes/tui/tlc.
2026-06-19 11:47:25 +03:00

198 lines
6.1 KiB
Rust

//! StatusReporter — one-line cook progress for the non-TUI path.
//!
//! When the cookbook runs without its ratatui TUI (e.g. `CI=1 repo cook`
//! in a background shell, or via SSH), the only progress output is the
//! per-recipe tail of the build script. There's no aggregate
//! "5/15 done" view, no per-phase signal (fetch vs build vs package),
//! and no elapsed time. StatusReporter fills that gap with one-line
//! status updates to stderr.
//!
//! Activated automatically by the cookbook CLI when the TUI is off
//! AND log capture is off (i.e., `CI=1` mode). When the TUI is on,
//! the user already sees aggregate progress via ratatui; when log
//! capture is on, the per-recipe log file in `build/logs/` provides
//! the per-recipe context. In both of those cases StatusReporter is
//! a no-op so it never duplicates the existing UX.
//!
//! Output format:
//! `[05/15] kf6-kio: starting`
//! `[05/15] kf6-kio: fetched (3.2s)`
//! `[05/15] kf6-kio: built (4m 18s)`
//! `[05/15] kf6-kio: done (total 4m 23s)`
//!
//! All output is `eprintln!` so it never gets mixed with the
//! captured log output of the build script.
//!
//! Per build-system improvement #4 in
//! `local/docs/BUILD-SYSTEM-IMPROVEMENTS.md`.
use std::io::IsTerminal;
use std::time::Instant;
/// Returns true if the cook status reporter should emit progress
/// lines. Auto-enables when stdin AND stderr are both TTYs AND
/// neither the TUI nor log capture is wanted. (The TUI is the
/// ratatui dashboard; log capture writes per-recipe build logs to
/// `build/logs/<recipe>.log` for postmortem review.)
pub fn status_reporter_enabled(tui: bool, logs: bool) -> bool {
!tui && !logs && std::io::stderr().is_terminal()
}
pub struct StatusReporter {
enabled: bool,
index: usize,
total: usize,
name: String,
start: Instant,
phase_start: Instant,
last_phase: String,
}
impl StatusReporter {
/// Create a per-recipe status reporter. `index` is 1-based.
/// If `enabled` is false, all methods are no-ops and the
/// reporter can be dropped without effect.
pub fn new(enabled: bool, index: usize, total: usize, name: &str) -> Self {
Self {
enabled,
index,
total,
name: name.to_string(),
start: Instant::now(),
phase_start: Instant::now(),
last_phase: String::new(),
}
}
/// Emit a "starting" line. Call once per recipe.
pub fn start(&mut self) {
if !self.enabled {
return;
}
self.phase_start = Instant::now();
eprintln!(
"[{:02}/{:02}] {}: starting",
self.index, self.total, self.name,
);
}
/// Emit a phase transition. The `phase` arg is a short label
/// like "fetched", "building", "built", "packaging", "done".
/// Elapsed time printed is from the previous phase boundary
/// (or the recipe start for the first phase).
pub fn phase(&mut self, phase: &str) {
if !self.enabled {
return;
}
let phase_elapsed = self.phase_start.elapsed();
eprintln!(
"[{:02}/{:02}] {}: {} ({})",
self.index,
self.total,
self.name,
phase,
format_elapsed(phase_elapsed),
);
self.last_phase = phase.to_string();
self.phase_start = Instant::now();
}
/// Emit the final "done" line with total elapsed time.
pub fn done(&mut self) {
if !self.enabled {
return;
}
eprintln!(
"[{:02}/{:02}] {}: done (total {})",
self.index,
self.total,
self.name,
format_elapsed(self.start.elapsed()),
);
self.last_phase = "done".to_string();
}
pub fn cached(&mut self) {
if !self.enabled {
return;
}
eprintln!(
"[{:02}/{:02}] {}: cached",
self.index, self.total, self.name,
);
}
pub fn last_phase(&self) -> &str {
&self.last_phase
}
}
fn format_elapsed(d: std::time::Duration) -> String {
let total = d.as_secs();
if total < 60 {
format!("{}s", total)
} else if total < 3600 {
format!("{}m {:02}s", total / 60, total % 60)
} else {
format!("{}h {:02}m {:02}s", total / 3600, (total / 60) % 60, total % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_elapsed_under_minute() {
assert_eq!(format_elapsed(std::time::Duration::from_secs(5)), "5s");
assert_eq!(format_elapsed(std::time::Duration::from_secs(59)), "59s");
}
#[test]
fn format_elapsed_under_hour() {
assert_eq!(format_elapsed(std::time::Duration::from_secs(60)), "1m 00s");
assert_eq!(format_elapsed(std::time::Duration::from_secs(125)), "2m 05s");
}
#[test]
fn format_elapsed_over_hour() {
assert_eq!(
format_elapsed(std::time::Duration::from_secs(3725)),
"1h 02m 05s"
);
}
#[test]
fn disabled_reporter_is_noop() {
let mut r = StatusReporter::new(false, 1, 3, "kf6-kio");
r.start();
r.phase("fetched");
r.phase("built");
r.done();
// No panic, no output, no state mutation when disabled.
assert_eq!(r.last_phase(), "");
}
#[test]
fn enabled_reporter_tracks_phases() {
let mut r = StatusReporter::new(true, 5, 15, "kf6-kio");
assert!(r.enabled);
r.start();
r.phase("fetched");
assert_eq!(r.last_phase(), "fetched");
r.phase("built");
assert_eq!(r.last_phase(), "built");
r.done();
assert_eq!(r.last_phase(), "done");
}
#[test]
fn status_reporter_enabled_logic() {
// All false: enabled (no TUI, no logs, stderr is a tty in tests... well, maybe not)
// In unit tests stderr is typically a tty capture. We just verify the
// boolean logic is correct for the in-process check.
assert!(!status_reporter_enabled(true, false)); // TUI on
assert!(!status_reporter_enabled(false, true)); // Logs on
}
}