bijux_cli/shared/
output.rs1#![forbid(unsafe_code)]
2use crate::contracts::{ColorMode, ErrorEnvelopeV1, LogLevel, OutputEnvelopeV1, OutputFormat};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum OutputStream {
11 Stdout,
13 Stderr,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RenderedOutput {
20 pub stream: OutputStream,
22 pub content: String,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct EmitterConfig {
29 pub format: OutputFormat,
31 pub pretty: bool,
33 pub color: ColorMode,
35 pub log_level: LogLevel,
37 pub quiet: bool,
39 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#[derive(Debug, thiserror::Error)]
58pub enum EmitError {
59 #[error("json serialization failed: {0}")]
61 Json(#[from] serde_json::Error),
62 #[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
196pub 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
206pub 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
221pub 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}