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 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
183pub 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
192pub 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
207pub 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}