Skip to main content

aperture_cli/cli/
render.rs

1//! Rendering layer for [`ExecutionResult`](crate::invocation::ExecutionResult) values.
2//!
3//! Converts structured execution results into user-facing output
4//! (stdout) in the requested format (JSON, YAML, table). This module
5//! owns all `println!` calls for API response rendering.
6
7use crate::cache::models::CachedCommand;
8use crate::cli::OutputFormat;
9use crate::constants;
10use crate::engine::executor::apply_jq_filter;
11use crate::error::Error;
12use crate::invocation::ExecutionResult;
13use crate::utils::to_kebab_case;
14use serde_json::Value;
15use std::collections::BTreeMap;
16use std::fmt::Write;
17use tabled::Table;
18
19/// Maximum number of rows to display in table format to prevent memory exhaustion.
20const MAX_TABLE_ROWS: usize = 1000;
21
22// Table structures for tabled crate
23#[derive(tabled::Tabled)]
24struct TableRow {
25    #[tabled(rename = "Key")]
26    key: String,
27    #[tabled(rename = "Value")]
28    value: String,
29}
30
31#[derive(tabled::Tabled)]
32struct KeyValue {
33    #[tabled(rename = "Key")]
34    key: String,
35    #[tabled(rename = "Value")]
36    value: String,
37}
38
39/// Renders an [`ExecutionResult`] to stdout in the given format.
40///
41/// # Errors
42///
43/// Returns an error if JQ filtering or serialization fails.
44pub fn render_result(
45    result: &ExecutionResult,
46    format: &OutputFormat,
47    jq_filter: Option<&str>,
48) -> Result<(), Error> {
49    match result {
50        ExecutionResult::Success { body, .. } | ExecutionResult::Cached { body } => {
51            if body.is_empty() {
52                return Ok(());
53            }
54            format_and_print(body, format, jq_filter, false)?;
55        }
56        ExecutionResult::DryRun { request_info } => {
57            let output = serde_json::to_string_pretty(request_info).map_err(|e| {
58                Error::serialization_error(format!("Failed to serialize dry run info: {e}"))
59            })?;
60            // ast-grep-ignore: no-println
61            println!("{output}");
62        }
63        ExecutionResult::Empty => {}
64    }
65    Ok(())
66}
67
68/// Renders an [`ExecutionResult`] to a `String` instead of stdout.
69///
70/// Used by the batch processor when capturing output.
71///
72/// # Errors
73///
74/// Returns an error if JQ filtering or serialization fails.
75pub fn render_result_to_string(
76    result: &ExecutionResult,
77    format: &OutputFormat,
78    jq_filter: Option<&str>,
79) -> Result<Option<String>, Error> {
80    match result {
81        ExecutionResult::Success { body, .. } | ExecutionResult::Cached { body } => {
82            if body.is_empty() {
83                return Ok(None);
84            }
85            format_and_print(body, format, jq_filter, true)
86        }
87        ExecutionResult::DryRun { request_info } => {
88            let output = serde_json::to_string_pretty(request_info).map_err(|e| {
89                Error::serialization_error(format!("Failed to serialize dry run info: {e}"))
90            })?;
91            Ok(Some(output))
92        }
93        ExecutionResult::Empty => Ok(None),
94    }
95}
96
97/// Renders extended examples for a command to stdout.
98pub fn render_examples(operation: &CachedCommand) {
99    // ast-grep-ignore: no-println
100    println!("Command: {}\n", to_kebab_case(&operation.operation_id));
101
102    if let Some(ref summary) = operation.summary {
103        // ast-grep-ignore: no-println
104        println!("Description: {summary}\n");
105    }
106
107    // ast-grep-ignore: no-println
108    println!("Method: {} {}\n", operation.method, operation.path);
109
110    if operation.examples.is_empty() {
111        // ast-grep-ignore: no-println
112        println!("No examples available for this command.");
113        return;
114    }
115
116    // ast-grep-ignore: no-println
117    println!("Examples:\n");
118    for (i, example) in operation.examples.iter().enumerate() {
119        // ast-grep-ignore: no-println
120        println!("{}. {}", i + 1, example.description);
121        // ast-grep-ignore: no-println
122        println!("   {}", example.command_line);
123        if let Some(ref explanation) = example.explanation {
124            // ast-grep-ignore: no-println
125            println!("   {explanation}");
126        }
127        // ast-grep-ignore: no-println
128        println!();
129    }
130
131    // Additional helpful information
132    if operation.parameters.is_empty() {
133        return;
134    }
135
136    // ast-grep-ignore: no-println
137    println!("Parameters:");
138    for param in &operation.parameters {
139        let required = if param.required { " (required)" } else { "" };
140        let param_type = param.schema_type.as_deref().unwrap_or("string");
141        // ast-grep-ignore: no-println
142        println!("  --{}{} [{}]", param.name, required, param_type);
143
144        let Some(ref desc) = param.description else {
145            continue;
146        };
147        // ast-grep-ignore: no-println
148        println!("      {desc}");
149    }
150    // ast-grep-ignore: no-println
151    println!();
152
153    if operation.request_body.is_some() {
154        // ast-grep-ignore: no-println
155        println!("Request Body:");
156        // ast-grep-ignore: no-println
157        println!("  --body JSON (required)");
158        // ast-grep-ignore: no-println
159        println!("      JSON data to send in the request body");
160    }
161}
162
163// ── Internal helpers ────────────────────────────────────────────────
164
165/// Core formatting logic shared by `render_result` and `render_result_to_string`.
166fn format_and_print(
167    response_text: &str,
168    output_format: &OutputFormat,
169    jq_filter: Option<&str>,
170    capture_output: bool,
171) -> Result<Option<String>, Error> {
172    // Apply JQ filter if provided
173    let processed_text = if let Some(filter) = jq_filter {
174        apply_jq_filter(response_text, filter)?
175    } else {
176        response_text.to_string()
177    };
178
179    match output_format {
180        OutputFormat::Json => {
181            let output = serde_json::from_str::<Value>(&processed_text)
182                .ok()
183                .and_then(|json_value| serde_json::to_string_pretty(&json_value).ok())
184                .unwrap_or_else(|| processed_text.clone());
185
186            if capture_output {
187                return Ok(Some(output));
188            }
189            // ast-grep-ignore: no-println
190            println!("{output}");
191        }
192        OutputFormat::Yaml => {
193            let output = serde_json::from_str::<Value>(&processed_text)
194                .ok()
195                .and_then(|json_value| serde_yaml::to_string(&json_value).ok())
196                .unwrap_or_else(|| processed_text.clone());
197
198            if capture_output {
199                return Ok(Some(output));
200            }
201            // ast-grep-ignore: no-println
202            println!("{output}");
203        }
204        OutputFormat::Table => {
205            let Ok(json_value) = serde_json::from_str::<Value>(&processed_text) else {
206                if capture_output {
207                    return Ok(Some(processed_text));
208                }
209                // ast-grep-ignore: no-println
210                println!("{processed_text}");
211                return Ok(None);
212            };
213
214            let table_output = print_as_table(&json_value, capture_output)?;
215            if capture_output {
216                return Ok(table_output);
217            }
218        }
219    }
220
221    Ok(None)
222}
223
224/// Prints items as a numbered list.
225fn print_numbered_list(items: &[Value], capture_output: bool) -> Option<String> {
226    if capture_output {
227        let mut output = String::new();
228        for (i, item) in items.iter().enumerate() {
229            writeln!(&mut output, "{}: {}", i, format_value_for_table(item))
230                .expect("writing to String cannot fail");
231        }
232        return Some(output.trim_end().to_string());
233    }
234
235    for (i, item) in items.iter().enumerate() {
236        // ast-grep-ignore: no-println
237        println!("{}: {}", i, format_value_for_table(item));
238    }
239    None
240}
241
242/// Helper to output or capture a message.
243fn output_or_capture(message: &str, capture_output: bool) -> Option<String> {
244    if capture_output {
245        return Some(message.to_string());
246    }
247    // ast-grep-ignore: no-println
248    println!("{message}");
249    None
250}
251
252/// Prints JSON data as a formatted table.
253#[allow(clippy::unnecessary_wraps, clippy::too_many_lines)]
254fn print_as_table(json_value: &Value, capture_output: bool) -> Result<Option<String>, Error> {
255    match json_value {
256        Value::Array(items) => {
257            if items.is_empty() {
258                return Ok(output_or_capture(constants::EMPTY_ARRAY, capture_output));
259            }
260
261            if items.len() > MAX_TABLE_ROWS {
262                let msg = format!(
263                    "Array too large: {} items (max {} for table display)\nUse --format json or --jq to process the full data",
264                    items.len(),
265                    MAX_TABLE_ROWS
266                );
267                return Ok(output_or_capture(&msg, capture_output));
268            }
269
270            let Some(Value::Object(_)) = items.first() else {
271                return Ok(print_numbered_list(items, capture_output));
272            };
273
274            let mut table_data: Vec<BTreeMap<String, String>> = Vec::new();
275
276            for item in items {
277                let Value::Object(obj) = item else {
278                    continue;
279                };
280                let mut row = BTreeMap::new();
281                for (key, value) in obj {
282                    row.insert(key.clone(), format_value_for_table(value));
283                }
284                table_data.push(row);
285            }
286
287            if table_data.is_empty() {
288                return Ok(print_numbered_list(items, capture_output));
289            }
290
291            let mut rows = Vec::new();
292            for (i, row) in table_data.iter().enumerate() {
293                if i > 0 {
294                    rows.push(TableRow {
295                        key: "---".to_string(),
296                        value: "---".to_string(),
297                    });
298                }
299                for (key, value) in row {
300                    rows.push(TableRow {
301                        key: key.clone(),
302                        value: value.clone(),
303                    });
304                }
305            }
306
307            let table = Table::new(&rows);
308            Ok(output_or_capture(&table.to_string(), capture_output))
309        }
310        Value::Object(obj) => {
311            if obj.len() > MAX_TABLE_ROWS {
312                let msg = format!(
313                    "Object too large: {} fields (max {} for table display)\nUse --format json or --jq to process the full data",
314                    obj.len(),
315                    MAX_TABLE_ROWS
316                );
317                return Ok(output_or_capture(&msg, capture_output));
318            }
319
320            let rows: Vec<KeyValue> = obj
321                .iter()
322                .map(|(key, value)| KeyValue {
323                    key: key.clone(),
324                    value: format_value_for_table(value),
325                })
326                .collect();
327
328            let table = Table::new(&rows);
329            Ok(output_or_capture(&table.to_string(), capture_output))
330        }
331        _ => {
332            let formatted = format_value_for_table(json_value);
333            Ok(output_or_capture(&formatted, capture_output))
334        }
335    }
336}
337
338/// Formats a JSON value for display in a table cell.
339fn format_value_for_table(value: &Value) -> String {
340    match value {
341        Value::Null => constants::NULL_VALUE.to_string(),
342        Value::Bool(b) => b.to_string(),
343        Value::Number(n) => n.to_string(),
344        Value::String(s) => s.clone(),
345        Value::Array(arr) => {
346            if arr.len() <= 3 {
347                format!(
348                    "[{}]",
349                    arr.iter()
350                        .map(format_value_for_table)
351                        .collect::<Vec<_>>()
352                        .join(", ")
353                )
354            } else {
355                format!("[{} items]", arr.len())
356            }
357        }
358        Value::Object(obj) => {
359            if obj.len() <= 2 {
360                format!(
361                    "{{{}}}",
362                    obj.iter()
363                        .map(|(k, v)| format!("{}: {}", k, format_value_for_table(v)))
364                        .collect::<Vec<_>>()
365                        .join(", ")
366                )
367            } else {
368                format!("{{object with {} fields}}", obj.len())
369            }
370        }
371    }
372}