Skip to main content

bijux_cli/shared/
output.rs

1#![forbid(unsafe_code)]
2//! Output encoding and envelope rendering surfaces for core app execution.
3
4use crate::contracts::{ColorMode, ErrorEnvelopeV1, LogLevel, OutputEnvelopeV1, OutputFormat};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8/// Output stream target for emitters.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum OutputStream {
11    /// Standard output stream.
12    Stdout,
13    /// Standard error stream.
14    Stderr,
15}
16
17/// Rendered output payload.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RenderedOutput {
20    /// Output stream target.
21    pub stream: OutputStream,
22    /// Rendered content.
23    pub content: String,
24}
25
26/// Emitter configuration.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct EmitterConfig {
29    /// Render format.
30    pub format: OutputFormat,
31    /// Pretty rendering toggle.
32    pub pretty: bool,
33    /// Color mode policy.
34    pub color: ColorMode,
35    /// Log-level formatting control.
36    pub log_level: LogLevel,
37    /// Quiet mode suppression.
38    pub quiet: bool,
39    /// External no-color policy flag.
40    pub no_color: bool,
41}
42
43impl Default for EmitterConfig {
44    fn default() -> Self {
45        Self {
46            format: OutputFormat::Text,
47            pretty: true,
48            color: ColorMode::Auto,
49            log_level: LogLevel::Info,
50            quiet: false,
51            no_color: false,
52        }
53    }
54}
55
56/// Emitter-level errors.
57#[derive(Debug, thiserror::Error)]
58pub enum EmitError {
59    /// JSON serialization failed.
60    #[error("json serialization failed: {0}")]
61    Json(#[from] serde_json::Error),
62    /// YAML serialization failed.
63    #[error("yaml serialization failed: {0}")]
64    Yaml(#[from] serde_yaml::Error),
65}
66
67fn should_emit_color(cfg: EmitterConfig) -> bool {
68    if cfg.no_color {
69        return false;
70    }
71
72    match cfg.color {
73        ColorMode::Always => true,
74        ColorMode::Never => false,
75        ColorMode::Auto => std::io::stdout().is_terminal() || std::io::stderr().is_terminal(),
76    }
77}
78
79fn colorize_error(s: &str, cfg: EmitterConfig) -> String {
80    if should_emit_color(cfg) {
81        format!("\u{001b}[31m{s}\u{001b}[0m")
82    } else {
83        s.to_string()
84    }
85}
86
87fn with_trailing_newline(mut content: String) -> String {
88    if !content.ends_with('\n') {
89        content.push('\n');
90    }
91    content
92}
93
94fn render_json(value: &Value, pretty: bool) -> Result<String, EmitError> {
95    if pretty {
96        serde_json::to_string_pretty(value).map_err(EmitError::from)
97    } else {
98        serde_json::to_string(value).map_err(EmitError::from)
99    }
100}
101
102fn scalar_text(value: &Value) -> Option<String> {
103    match value {
104        Value::Null => Some("null".to_string()),
105        Value::Bool(boolean) => Some(boolean.to_string()),
106        Value::Number(number) => Some(number.to_string()),
107        Value::String(text) => Some(text.clone()),
108        _ => None,
109    }
110}
111
112fn render_text_lines(value: &Value, indent: usize, lines: &mut Vec<String>) {
113    let pad = " ".repeat(indent);
114    match value {
115        Value::Object(map) => {
116            if map.is_empty() {
117                lines.push(format!("{pad}{{}}"));
118                return;
119            }
120            for (key, item) in map {
121                if let Some(scalar) = scalar_text(item) {
122                    lines.push(format!("{pad}{key}: {scalar}"));
123                    continue;
124                }
125                if let Some(array) = item.as_array() {
126                    if array.is_empty() {
127                        lines.push(format!("{pad}{key}: []"));
128                        continue;
129                    }
130                }
131                if let Some(object) = item.as_object() {
132                    if object.is_empty() {
133                        lines.push(format!("{pad}{key}: {{}}"));
134                        continue;
135                    }
136                }
137
138                lines.push(format!("{pad}{key}:"));
139                render_text_lines(item, indent + 2, lines);
140            }
141        }
142        Value::Array(items) => {
143            if items.is_empty() {
144                lines.push(format!("{pad}[]"));
145                return;
146            }
147            for item in items {
148                if let Some(scalar) = scalar_text(item) {
149                    lines.push(format!("{pad}- {scalar}"));
150                    continue;
151                }
152                if let Some(array) = item.as_array() {
153                    if array.is_empty() {
154                        lines.push(format!("{pad}- []"));
155                        continue;
156                    }
157                }
158                if let Some(object) = item.as_object() {
159                    if object.is_empty() {
160                        lines.push(format!("{pad}- {{}}"));
161                        continue;
162                    }
163                }
164
165                lines.push(format!("{pad}-"));
166                render_text_lines(item, indent + 2, lines);
167            }
168        }
169        _ => lines.push(format!("{pad}{}", scalar_text(value).unwrap_or_default())),
170    }
171}
172
173fn render_text(value: &Value) -> String {
174    if let Some(scalar) = scalar_text(value) {
175        return scalar;
176    }
177
178    let mut lines = Vec::new();
179    render_text_lines(value, 0, &mut lines);
180    lines.join("\n")
181}
182
183/// Render arbitrary value in configured format.
184pub fn render_value(value: &Value, cfg: EmitterConfig) -> Result<String, EmitError> {
185    match cfg.format {
186        OutputFormat::Yaml => serde_yaml::to_string(value).map_err(EmitError::from),
187        OutputFormat::Text => Ok(render_text(value)),
188        _ => render_json(value, cfg.pretty),
189    }
190}
191
192/// Render success envelope to stdout, honoring quiet mode rules.
193pub fn emit_success(
194    envelope: &OutputEnvelopeV1,
195    cfg: EmitterConfig,
196) -> Result<Option<RenderedOutput>, EmitError> {
197    if cfg.quiet && cfg.format == OutputFormat::Text {
198        return Ok(None);
199    }
200
201    let value = serde_json::to_value(envelope)?;
202    let content = with_trailing_newline(render_value(&value, cfg)?);
203
204    Ok(Some(RenderedOutput { stream: OutputStream::Stdout, content }))
205}
206
207/// Render error envelope to stderr (never suppressed by quiet mode).
208pub fn emit_error(
209    envelope: &ErrorEnvelopeV1,
210    cfg: EmitterConfig,
211) -> Result<RenderedOutput, EmitError> {
212    let value = serde_json::to_value(envelope)?;
213
214    let content = match cfg.format {
215        OutputFormat::Text => {
216            let msg = envelope.error.message.as_str();
217            colorize_error(msg, cfg)
218        }
219        _ => with_trailing_newline(render_value(&value, cfg)?),
220    };
221    Ok(RenderedOutput { stream: OutputStream::Stderr, content: with_trailing_newline(content) })
222}