Skip to main content

torvyn_cli/output/
mod.rs

1//! Output formatting for the Torvyn CLI.
2//!
3//! All commands produce structured result types that implement [`serde::Serialize`].
4//! This module provides [`OutputContext`] which renders these results in the
5//! user's selected format (human or JSON).
6
7pub mod json;
8pub mod table;
9pub mod terminal;
10
11use crate::cli::{ColorChoice, GlobalOpts, OutputFormat};
12use console::Term;
13use serde::Serialize;
14
15/// Output context carrying terminal capabilities and user preferences.
16///
17/// ## Invariants
18/// - `format` matches the user's `--format` flag.
19/// - `color_enabled` accounts for `--color`, `NO_COLOR` env, and terminal detection.
20/// - `is_tty` is true only when stdout is an interactive terminal.
21/// - `verbose` and `quiet` are never both true.
22#[derive(Debug, Clone)]
23pub struct OutputContext {
24    /// Selected output format.
25    pub format: OutputFormat,
26    /// Whether color output is enabled (resolved from flag + env + terminal).
27    pub color_enabled: bool,
28    /// Whether stdout is an interactive terminal.
29    #[allow(dead_code)]
30    pub is_tty: bool,
31    /// Terminal width in columns. Falls back to 80.
32    #[allow(dead_code)]
33    pub term_width: u16,
34    /// Whether verbose output is requested.
35    pub verbose: bool,
36    /// Whether quiet mode is requested (errors only).
37    #[allow(dead_code)]
38    pub quiet: bool,
39}
40
41impl OutputContext {
42    /// Construct an [`OutputContext`] from the global CLI options.
43    ///
44    /// COLD PATH — called once per invocation.
45    pub fn from_global_opts(opts: &GlobalOpts) -> Self {
46        let term = Term::stdout();
47        let is_tty = term.is_term();
48
49        let color_enabled = match opts.color {
50            ColorChoice::Always => true,
51            ColorChoice::Never => false,
52            ColorChoice::Auto => {
53                is_tty
54                    && std::env::var("NO_COLOR").is_err()
55                    && std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
56            }
57        };
58
59        let term_width = if is_tty { term.size().1.max(40) } else { 80 };
60
61        Self {
62            format: opts.format,
63            color_enabled,
64            is_tty,
65            term_width,
66            verbose: opts.verbose,
67            quiet: opts.quiet,
68        }
69    }
70
71    /// Print a structured result in the selected format.
72    ///
73    /// COLD PATH — called once per command result.
74    pub fn render<T: Serialize + HumanRenderable>(&self, result: &T) {
75        match self.format {
76            OutputFormat::Json => {
77                json::print_json(result);
78            }
79            OutputFormat::Human => {
80                result.render_human(self);
81            }
82        }
83    }
84
85    /// Print a progress message (suppressed in quiet mode and JSON mode).
86    #[allow(dead_code)]
87    pub fn print_status(&self, symbol: &str, message: &str) {
88        if self.quiet || self.format == OutputFormat::Json {
89            return;
90        }
91        if self.color_enabled {
92            let styled_symbol = console::style(symbol).green().bold();
93            eprintln!("{styled_symbol} {message}");
94        } else {
95            eprintln!("{symbol} {message}");
96        }
97    }
98
99    /// Print a warning message.
100    pub fn print_warning(&self, message: &str) {
101        if self.format == OutputFormat::Json {
102            return;
103        }
104        if self.color_enabled {
105            let prefix = console::style("warning:").yellow().bold();
106            eprintln!("{prefix} {message}");
107        } else {
108            eprintln!("warning: {message}");
109        }
110    }
111
112    /// Print a fatal error message (always shown, even in quiet mode).
113    pub fn print_fatal(&self, message: &str) {
114        if self.color_enabled {
115            let prefix = console::style("error:").red().bold();
116            eprintln!("{prefix} {message}");
117        } else {
118            eprintln!("error: {message}");
119        }
120    }
121
122    /// Print a debug message (only in verbose mode).
123    pub fn print_debug(&self, message: &str) {
124        if !self.verbose || self.format == OutputFormat::Json {
125            return;
126        }
127        if self.color_enabled {
128            let prefix = console::style("debug:").dim();
129            eprintln!("{prefix} {message}");
130        } else {
131            eprintln!("debug: {message}");
132        }
133    }
134
135    /// Create an `indicatif` progress bar. Returns `None` if inappropriate.
136    #[allow(dead_code)]
137    pub fn progress_bar(&self, total: u64, message: &str) -> Option<indicatif::ProgressBar> {
138        if !self.is_tty || self.quiet || self.format == OutputFormat::Json {
139            return None;
140        }
141        let pb = indicatif::ProgressBar::new(total);
142        pb.set_style(
143            indicatif::ProgressStyle::default_bar()
144                .template(
145                    "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}",
146                )
147                .expect("valid progress bar template")
148                .progress_chars("=>-"),
149        );
150        pb.set_message(message.to_string());
151        Some(pb)
152    }
153
154    /// Create a spinner for unbounded operations. Returns `None` if inappropriate.
155    #[allow(dead_code)]
156    pub fn spinner(&self, message: &str) -> Option<indicatif::ProgressBar> {
157        if !self.is_tty || self.quiet || self.format == OutputFormat::Json {
158            return None;
159        }
160        let sp = indicatif::ProgressBar::new_spinner();
161        sp.set_style(
162            indicatif::ProgressStyle::default_spinner()
163                .template("{spinner:.green} {msg}")
164                .expect("valid spinner template"),
165        );
166        sp.set_message(message.to_string());
167        sp.enable_steady_tick(std::time::Duration::from_millis(80));
168        Some(sp)
169    }
170}
171
172/// Trait for types that can be rendered to the terminal in human-readable format.
173pub trait HumanRenderable {
174    /// Render this value to the terminal.
175    fn render_human(&self, ctx: &OutputContext);
176}
177
178/// The success/failure outcome of a command, with structured output.
179#[derive(Debug, Serialize)]
180pub struct CommandResult<T: Serialize> {
181    /// Whether the command succeeded.
182    pub success: bool,
183    /// The command name that produced this result.
184    pub command: String,
185    /// Command-specific result data.
186    pub data: T,
187    /// Warnings produced during execution.
188    #[serde(skip_serializing_if = "Vec::is_empty")]
189    pub warnings: Vec<String>,
190}
191
192impl<T: Serialize + HumanRenderable> HumanRenderable for CommandResult<T> {
193    fn render_human(&self, ctx: &OutputContext) {
194        self.data.render_human(ctx);
195        for w in &self.warnings {
196            ctx.print_warning(w);
197        }
198    }
199}