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