Skip to main content

robonix_cli/
output.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Output Module
3//
4// Output formatting and display utilities for robonix-cli
5
6use colored::*;
7use std::io::{self, Write};
8use std::sync::OnceLock;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::Instant;
11
12/// Monotonic origin for `[ssss.mmm]` boot-line timestamps. Initialised on
13/// the first call to `boot_now()` (i.e. when the user actually starts a
14/// boot run); subsequent calls measure from that origin so the log reads
15/// like a kernel ring buffer / dmesg trace.
16static BOOT_T0: OnceLock<Instant> = OnceLock::new();
17static BOOT_VERBOSE: AtomicBool = AtomicBool::new(false);
18
19pub fn set_boot_verbose(verbose: bool) {
20    BOOT_VERBOSE.store(verbose, Ordering::Relaxed);
21}
22
23pub fn boot_verbose() -> bool {
24    BOOT_VERBOSE.load(Ordering::Relaxed)
25}
26
27fn clear_progress_prefix() -> &'static str {
28    if boot_verbose() { "" } else { "\r\x1b[K" }
29}
30
31/// Formatted `[ssss.mmm]` prefix relative to BOOT_T0. Width is fixed at
32/// 12 chars (`[1234.567]`) — five-digit boots are unrealistic and we'd
33/// rather wrap than re-jitter the column on long deploys.
34fn boot_now() -> String {
35    let t0 = BOOT_T0.get_or_init(Instant::now);
36    let elapsed = t0.elapsed().as_secs_f64();
37    format!("[{elapsed:>8.3}]")
38}
39
40/// Force-init BOOT_T0 to "now" so subsequent boot lines time from this
41/// instant. Call once at the top of a deploy/boot run, before any
42/// `boot_*` line, otherwise the first such call lazily wins.
43pub fn boot_reset_clock() {
44    let _ = BOOT_T0.set(Instant::now());
45}
46
47/// Print a main action header (e.g., "Installing", "Registering")
48pub fn action(action: &str, target: &str) {
49    println!("{} {}", format!("[{}]", action).green().bold(), target);
50}
51
52/// Print a success completion message
53pub fn success(message: &str) {
54    println!("{} {}", "✓".green().bold(), message.green());
55}
56
57/// Print an info message
58pub fn info(message: &str) {
59    println!("{}", message);
60}
61
62/// Print a warning message
63pub fn warning(message: &str) {
64    println!("{} {}", "⚠".yellow().bold(), message.yellow());
65}
66
67/// Print an error message
68pub fn error(message: &str) {
69    eprintln!("{} {}", "✗".red().bold(), message.red());
70}
71
72/// Print a step message (like "Validating", "Processing", etc.)
73pub fn step(action: &str, target: &str) {
74    println!("  {} {}", format!("-> {}", action).cyan(), target);
75}
76
77/// Print a sub-step message (indented detail)
78pub fn sub_step(message: &str) {
79    println!("    {}", message);
80}
81
82/// Print a checkmark success message for individual items
83pub fn check(message: &str) {
84    println!("  {} {}", "✓".green(), message);
85}
86
87/// Print a cross error message for individual items
88pub fn cross(message: &str) {
89    eprintln!("  {} {}", "✗".red(), message);
90}
91
92/// Print a summary line
93pub fn summary(message: &str) {
94    println!("\n{}", message.dimmed());
95}
96
97// ── Boot-log helpers (FreeBSD / dmesg style) ────────────────────────
98//
99// Each boot line carries a monotonic `[ssss.mmm]` timestamp prefix and
100// a fixed-width status badge — same layout the BSD/Linux kernels print
101// at boot, so a robonix bring-up reads like a real OS init log instead
102// of a generic "deploying ..." trace.
103//
104// IMPORTANT: rust's `{:<N}` formatter pads to N BYTES, not visible
105// chars. `colored` returns strings with ANSI escape sequences embedded
106// (`"[ OK ]".green()` is ~13 bytes for 6 visible chars), so a naive
107// `{:<7}` against a colored string produces no padding (already past
108// 7 bytes). Every badge below is exactly 6 visible chars (`[ OK ]`,
109// `[FAIL]`, `[SKIP]`, `[ →  ]`, `[ ⠙ ]`, …) so no badge-side
110// alignment is needed; two spaces separate it from the name, and the
111// padding lives between the (uncolored) name and the detail.
112
113const W_NAME: usize = 18;
114
115/// Print the ASCII banner at the very top of a boot run. Called once
116/// from `rbnx boot` before any `[ ssss.mmm ]` line. Layout mimics the
117/// kernel's "Linux version ..." splash: name + version + git sha,
118/// builder, build time, compiler, target. All facts come from build.rs
119/// via compile-time env vars; missing values gracefully render as
120/// "unknown" so a tarball / sandboxed build still prints a banner.
121pub fn boot_banner() {
122    let version = env!("CARGO_PKG_VERSION");
123    let sha = option_env!("ROBONIX_GIT_SHA").unwrap_or("dev");
124    let builder = option_env!("ROBONIX_BUILDER").unwrap_or("unknown");
125    let build_time = option_env!("ROBONIX_BUILD_TIME").unwrap_or("unknown");
126    let rustc = option_env!("ROBONIX_RUSTC").unwrap_or("rustc unknown");
127    let target = option_env!("ROBONIX_TARGET").unwrap_or("unknown");
128
129    let lines = [
130        "    ____        __                 _      ",
131        "   / __ \\____  / /_  ____  ____  (_)  __ ",
132        "  / /_/ / __ \\/ __ \\/ __ \\/ __ \\/ / |/_/",
133        " / _, _/ /_/ / /_/ / /_/ / / / / />  <   ",
134        "/_/ |_|\\____/_.___/\\____/_/ /_/_/_/|_|   ",
135    ];
136    println!();
137    for line in &lines {
138        println!("{}", line.cyan().bold());
139    }
140    println!("{}", "        Embodied AI Operating System".dimmed(),);
141    println!();
142    // Body block. Bullet-aligned, dimmed value column — readable but
143    // visually distinct from the per-component boot lines below.
144    let label_w = 9;
145    let row = |k: &str, v: &str| {
146        println!(
147            "  {:label_w$} {}",
148            format!("{k}:").bold(),
149            v.dimmed(),
150            label_w = label_w
151        );
152    };
153    row("version", &format!("v{version} ({sha})"));
154    row("built", &format!("{build_time} on {builder}"));
155    row("compiler", rustc);
156    row("target", target);
157    println!();
158}
159
160/// Single-line "we are now booting X" announcement, the dmesg-style
161/// banner that follows `boot_banner` and precedes the per-component
162/// `[ ssss.mmm ]` log. Resets the boot clock so timestamps below count
163/// from this point.
164pub fn boot_start(deploy_name: &str, manifest_path: &str) {
165    boot_reset_clock();
166    println!(
167        "{} booting {}",
168        boot_now().cyan(),
169        deploy_name.bold().green(),
170    );
171    println!("{} manifest {}", boot_now().cyan(), manifest_path.dimmed(),);
172}
173
174/// `[  ssss.mmm] [ OK ] name              detail` — component came up.
175/// Leading `\r\x1b[K` clears any in-place spinner line so the final
176/// result lands cleanly without a trailing fragment of "registering…".
177pub fn boot_ok(name: &str, detail: &str) {
178    println!(
179        "{}{} {}  {:<width$}  {}",
180        clear_progress_prefix(),
181        boot_now().cyan(),
182        "[ OK ]".green().bold(),
183        name,
184        detail.dimmed(),
185        width = W_NAME,
186    );
187}
188
189/// `[  ssss.mmm] [FAIL] name              detail` — failed to come up.
190pub fn boot_fail(name: &str, detail: &str) {
191    eprintln!(
192        "{}{} {}  {:<width$}  {}",
193        clear_progress_prefix(),
194        boot_now().cyan(),
195        "[FAIL]".red().bold(),
196        name,
197        detail.red(),
198        width = W_NAME,
199    );
200}
201
202/// `[  ssss.mmm] [SKIP] name              detail` — declared in manifest
203/// but skipped (not installed / disabled / out-of-scope on this host).
204pub fn boot_skip(name: &str, detail: &str) {
205    println!(
206        "{}{} {}  {:<width$}  {}",
207        clear_progress_prefix(),
208        boot_now().cyan(),
209        "[SKIP]".yellow(),
210        name,
211        detail.dimmed(),
212        width = W_NAME,
213    );
214}
215
216/// `[  ssss.mmm] [ →  ] name              detail` — informational note
217/// (cache hit, fetched a remote package, …). Neither up nor skipped.
218pub fn boot_note(name: &str, detail: &str) {
219    println!(
220        "{}{} {}  {:<width$}  {}",
221        clear_progress_prefix(),
222        boot_now().cyan(),
223        "[ →  ]".cyan(),
224        name,
225        detail.dimmed(),
226        width = W_NAME,
227    );
228}
229
230/// `[  ssss.mmm] [ ↑ ] name              detail` — an update is available.
231/// Leading `\r\x1b[K` clears the in-place `boot_progress` spinner so the
232/// verdict lands cleanly. Yellow `↑` distinguishes "needs update" from the
233/// green `[ OK ]` "up to date" verdict.
234pub fn boot_update_avail(name: &str, detail: &str) {
235    println!(
236        "{}{} {}  {:<width$}  {}",
237        clear_progress_prefix(),
238        boot_now().cyan(),
239        "[ ↑  ]".yellow().bold(),
240        name,
241        detail.dimmed(),
242        width = W_NAME,
243    );
244}
245
246/// `[  ssss.mmm] [....] name              detail` — component is starting.
247pub fn boot_wait(name: &str, detail: &str) {
248    println!(
249        "{}{} {}  {:<width$}  {}",
250        clear_progress_prefix(),
251        boot_now().cyan(),
252        "[....]".cyan(),
253        name,
254        detail.dimmed(),
255        width = W_NAME,
256    );
257}
258
259/// Group header above a run of boot lines. FreeBSD-rc-style ":: stage ::".
260pub fn boot_section(label: &str) {
261    println!(
262        "{}\n{} {} {} {}",
263        clear_progress_prefix(),
264        boot_now().cyan(),
265        "::".dimmed(),
266        label.bold().yellow(),
267        "::".dimmed(),
268    );
269}
270
271/// In-place spinner frame. Renders to stdout with `\r` (no newline) and
272/// flushes; caller overwrites with boot_ok / boot_fail to finalize.
273/// Frames cycle through Braille dots — the systemd spinner most users
274/// recognise from Linux init logs.
275pub fn boot_progress(name: &str, detail: &str, frame: usize) {
276    if boot_verbose() {
277        return;
278    }
279    const GLYPHS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
280    let g = GLYPHS[frame % GLYPHS.len()];
281    print!(
282        "\r\x1b[K{} {}  {:<width$}  {}",
283        boot_now().cyan(),
284        format!("[ {g} ]").cyan(),
285        name,
286        detail.dimmed(),
287        width = W_NAME,
288    );
289    let _ = io::stdout().flush();
290}
291
292/// Final boot summary line — total elapsed + component tally.
293pub fn boot_summary(ok: usize, total: usize, hint: &str) {
294    let elapsed = BOOT_T0
295        .get()
296        .map(|t| t.elapsed().as_secs_f64())
297        .unwrap_or(0.0);
298    let badge = if ok == total {
299        "OK".green().bold()
300    } else {
301        "DEGRADED".yellow().bold()
302    };
303    println!();
304    println!(
305        "[ {badge} ] robonix up — {ok}/{total} components in {elapsed:.3}s   {}",
306        hint.dimmed()
307    );
308}
309
310/// Spinner for animated progress indication
311pub struct Spinner {
312    message: String,
313    frames: Vec<char>,
314    handle: Option<tokio::task::JoinHandle<()>>,
315}
316
317impl Spinner {
318    /// Create a new spinner with a message
319    pub fn new(message: String) -> Self {
320        Self {
321            message,
322            frames: vec!['|', '/', '-', '\\'],
323            handle: None,
324        }
325    }
326
327    /// Start the spinner animation (spawns background task)
328    pub fn start(&mut self) {
329        let message = self.message.clone();
330        let mut frame = 0;
331        let frames = self.frames.clone();
332
333        let handle = tokio::spawn(async move {
334            loop {
335                let spinner_char = frames[frame % frames.len()];
336                let line = format!("  {} {}", spinner_char, message);
337                print!("\r{}", line);
338                let _ = io::stdout().flush();
339                frame += 1;
340                tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
341            }
342        });
343
344        self.handle = Some(handle);
345    }
346
347    /// Stop the spinner and show success message
348    pub fn finish_success(&mut self, final_message: &str) {
349        if let Some(handle) = self.handle.take() {
350            handle.abort();
351        }
352        // Clear the line first (using ANSI escape code)
353        print!("\r\x1b[K");
354        let line = format!("  {} {}", "✓".green(), final_message.green());
355        println!("{}", line);
356        let _ = io::stdout().flush();
357    }
358
359    /// Stop the spinner and show error message
360    pub fn finish_error(&mut self, final_message: &str) {
361        if let Some(handle) = self.handle.take() {
362            handle.abort();
363        }
364        // Clear the line first (using ANSI escape code)
365        print!("\r\x1b[K");
366        let line = format!("  {} {}", "✗".red(), final_message.red());
367        println!("{}", line);
368        let _ = io::stdout().flush();
369    }
370}
371
372impl Drop for Spinner {
373    fn drop(&mut self) {
374        if let Some(handle) = self.handle.take() {
375            handle.abort();
376        }
377    }
378}