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 render_jsonl(value: &Value) -> Result<String, EmitError> {
103    match value {
104        Value::Array(items) => {
105            let mut lines = Vec::with_capacity(items.len());
106            for item in items {
107                lines.push(serde_json::to_string(item).map_err(EmitError::from)?);
108            }
109            Ok(lines.join("\n"))
110        }
111        _ => serde_json::to_string(value).map_err(EmitError::from),
112    }
113}
114
115fn scalar_text(value: &Value) -> Option<String> {
116    match value {
117        Value::Null => Some("null".to_string()),
118        Value::Bool(boolean) => Some(boolean.to_string()),
119        Value::Number(number) => Some(number.to_string()),
120        Value::String(text) => Some(text.clone()),
121        _ => None,
122    }
123}
124
125fn render_text_lines(value: &Value, indent: usize, lines: &mut Vec<String>) {
126    let pad = " ".repeat(indent);
127    match value {
128        Value::Object(map) => {
129            if map.is_empty() {
130                lines.push(format!("{pad}{{}}"));
131                return;
132            }
133            for (key, item) in map {
134                if let Some(scalar) = scalar_text(item) {
135                    lines.push(format!("{pad}{key}: {scalar}"));
136                    continue;
137                }
138                if let Some(array) = item.as_array() {
139                    if array.is_empty() {
140                        lines.push(format!("{pad}{key}: []"));
141                        continue;
142                    }
143                }
144                if let Some(object) = item.as_object() {
145                    if object.is_empty() {
146                        lines.push(format!("{pad}{key}: {{}}"));
147                        continue;
148                    }
149                }
150
151                lines.push(format!("{pad}{key}:"));
152                render_text_lines(item, indent + 2, lines);
153            }
154        }
155        Value::Array(items) => {
156            if items.is_empty() {
157                lines.push(format!("{pad}[]"));
158                return;
159            }
160            for item in items {
161                if let Some(scalar) = scalar_text(item) {
162                    lines.push(format!("{pad}- {scalar}"));
163                    continue;
164                }
165                if let Some(array) = item.as_array() {
166                    if array.is_empty() {
167                        lines.push(format!("{pad}- []"));
168                        continue;
169                    }
170                }
171                if let Some(object) = item.as_object() {
172                    if object.is_empty() {
173                        lines.push(format!("{pad}- {{}}"));
174                        continue;
175                    }
176                }
177
178                lines.push(format!("{pad}-"));
179                render_text_lines(item, indent + 2, lines);
180            }
181        }
182        _ => lines.push(format!("{pad}{}", scalar_text(value).unwrap_or_default())),
183    }
184}
185
186fn render_text(value: &Value) -> String {
187    if let Some(scalar) = scalar_text(value) {
188        return scalar;
189    }
190
191    let mut lines = Vec::new();
192    render_text_lines(value, 0, &mut lines);
193    lines.join("\n")
194}
195
196/// Render arbitrary value in configured format.
197pub fn render_value(value: &Value, cfg: EmitterConfig) -> Result<String, EmitError> {
198    match cfg.format {
199        OutputFormat::Jsonl => render_jsonl(value),
200        OutputFormat::Yaml => serde_yaml::to_string(value).map_err(EmitError::from),
201        OutputFormat::Text => Ok(render_text(value)),
202        _ => render_json(value, cfg.pretty),
203    }
204}
205
206/// Render success envelope to stdout, honoring quiet mode rules.
207pub fn emit_success(
208    envelope: &OutputEnvelopeV1,
209    cfg: EmitterConfig,
210) -> Result<Option<RenderedOutput>, EmitError> {
211    if cfg.quiet && cfg.format == OutputFormat::Text {
212        return Ok(None);
213    }
214
215    let value = serde_json::to_value(envelope)?;
216    let content = with_trailing_newline(render_value(&value, cfg)?);
217
218    Ok(Some(RenderedOutput { stream: OutputStream::Stdout, content }))
219}
220
221/// Render error envelope to stderr (never suppressed by quiet mode).
222pub fn emit_error(
223    envelope: &ErrorEnvelopeV1,
224    cfg: EmitterConfig,
225) -> Result<RenderedOutput, EmitError> {
226    let value = serde_json::to_value(envelope)?;
227
228    let content = match cfg.format {
229        OutputFormat::Text => {
230            let msg = envelope.error.message.as_str();
231            colorize_error(msg, cfg)
232        }
233        _ => with_trailing_newline(render_value(&value, cfg)?),
234    };
235    Ok(RenderedOutput { stream: OutputStream::Stderr, content: with_trailing_newline(content) })
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{render_value, EmitterConfig};
241    use crate::contracts::OutputFormat;
242    use serde_json::json;
243
244    #[test]
245    fn render_value_jsonl_emits_one_line_per_array_item() {
246        let cfg = EmitterConfig { format: OutputFormat::Jsonl, ..EmitterConfig::default() };
247        let rendered = render_value(&json!([{"a": 1}, {"b": 2}]), cfg).expect("jsonl");
248        assert_eq!(rendered, "{\"a\":1}\n{\"b\":2}");
249    }
250
251    #[test]
252    fn render_value_jsonl_emits_single_line_for_object() {
253        let cfg = EmitterConfig { format: OutputFormat::Jsonl, ..EmitterConfig::default() };
254        let rendered = render_value(&json!({"status": "ok"}), cfg).expect("jsonl");
255        assert_eq!(rendered, "{\"status\":\"ok\"}");
256    }
257}