Skip to main content

kaish_repl/
format.rs

1//! Output formatting for the REPL.
2//!
3//! This module handles formatting ExecResult output based on OutputData.
4//! It provides smart formatting for different audiences:
5//!
6//! - **Interactive** → Pretty columns, colors, traditional tree
7//! - **Piped/Model** → Token-efficient compact formats
8//!
9//! If `result.output` is present, format using `format_output_data()`.
10//! Otherwise, use raw `result.out`.
11
12use std::io::IsTerminal;
13
14use kaish_kernel::interpreter::{EntryType, ExecResult, OutputData, OutputNode};
15use kaish_kernel::tools::OutputContext;
16
17/// Format an ExecResult for display based on the output context.
18///
19/// This is the main entry point for formatting command output. It uses
20/// structured OutputData if present, otherwise uses raw output.
21pub fn format_output(result: &ExecResult, context: OutputContext) -> String {
22    // Use OutputData if present
23    if let Some(ref output) = result.output {
24        return format_output_data(output, context);
25    }
26
27    // No structured output - use raw output
28    result.text_out().into_owned()
29}
30
31/// Format OutputData for display based on context.
32///
33/// Rendering rules:
34/// - Single text node → Print text
35/// - Flat nodes with name only → Multi-column (interactive) or one-per-line
36/// - Flat nodes with cells → Aligned table
37/// - Nested children → Box-drawing tree (interactive) or brace notation
38pub fn format_output_data(output: &OutputData, context: OutputContext) -> String {
39    // Non-interactive contexts use canonical string
40    if !matches!(context, OutputContext::Interactive) {
41        return output.to_canonical_string();
42    }
43
44    // Simple text output
45    if let Some(text) = output.as_text() {
46        return text.to_string();
47    }
48
49    // Check if we have nested children (tree structure)
50    if !output.is_flat() {
51        return format_tree_from_output_data(output);
52    }
53
54    // Check if we have tabular data (cells present)
55    if output.is_tabular() {
56        return format_table_from_output_data(output);
57    }
58
59    // Flat list of names - format as columns
60    format_columns_from_output_data(output)
61}
62
63/// Format output data as a tree with box-drawing characters.
64fn format_tree_from_output_data(output: &OutputData) -> String {
65    let mut result = String::new();
66
67    for (i, node) in output.root.iter().enumerate() {
68        if i > 0 {
69            result.push('\n');
70        }
71        format_tree_node(&mut result, node, "", true);
72    }
73
74    result.trim_end().to_string()
75}
76
77/// Recursively format a tree node with box-drawing characters.
78fn format_tree_node(output: &mut String, node: &OutputNode, prefix: &str, is_last: bool) {
79    // Print this node
80    let connector = if is_last { "└── " } else { "├── " };
81    let name = if node.name.is_empty() {
82        node.text.as_deref().unwrap_or("")
83    } else {
84        &node.name
85    };
86
87    // Add directory suffix for directories
88    let suffix = if node.entry_type == EntryType::Directory && node.children.is_empty() {
89        "/"
90    } else {
91        ""
92    };
93
94    output.push_str(prefix);
95    output.push_str(connector);
96    output.push_str(&colorize_entry(name, Some(node.entry_type)));
97    output.push_str(suffix);
98    output.push('\n');
99
100    // Print children
101    let child_prefix = format!("{}{}   ", prefix, if is_last { " " } else { "│" });
102    let children: Vec<_> = node.children.iter().collect();
103    for (i, child) in children.iter().enumerate() {
104        let is_last_child = i == children.len() - 1;
105        format_tree_node(output, child, &child_prefix, is_last_child);
106    }
107}
108
109/// Format output data as an aligned table.
110fn format_table_from_output_data(output: &OutputData) -> String {
111    if output.root.is_empty() {
112        return String::new();
113    }
114
115    // Build rows from nodes
116    let rows: Vec<Vec<&str>> = output.root.iter().map(|node| {
117        let mut row = Vec::new();
118        // Add name as first column
119        row.push(node.display_name());
120        // Add cells
121        for cell in &node.cells {
122            row.push(cell.as_str());
123        }
124        row
125    }).collect();
126
127    // Calculate column widths
128    let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
129    let mut col_widths = vec![0; num_cols];
130
131    // Include headers in width calculation
132    if let Some(ref headers) = output.headers {
133        for (i, header) in headers.iter().enumerate() {
134            if i < col_widths.len() {
135                col_widths[i] = col_widths[i].max(header.len());
136            }
137        }
138    }
139
140    // Calculate widths from data
141    for row in &rows {
142        for (i, cell) in row.iter().enumerate() {
143            if i < col_widths.len() {
144                col_widths[i] = col_widths[i].max(cell.len());
145            }
146        }
147    }
148
149    let mut result = String::new();
150
151    // Output headers if present
152    if let Some(ref headers) = output.headers {
153        for (i, header) in headers.iter().enumerate() {
154            if i > 0 {
155                result.push_str("  ");
156            }
157            result.push_str(header);
158            if i < headers.len() - 1 {
159                let padding = col_widths[i].saturating_sub(header.len());
160                for _ in 0..padding {
161                    result.push(' ');
162                }
163            }
164        }
165        result.push('\n');
166    }
167
168    // Output rows
169    for (row_idx, row) in rows.iter().enumerate() {
170        for (i, cell) in row.iter().enumerate() {
171            if i > 0 {
172                result.push_str("  ");
173            }
174
175            // Colorize based on entry type (name column is usually first)
176            let colored_cell = if i == 0 {
177                colorize_entry(cell, Some(output.root[row_idx].entry_type))
178            } else {
179                (*cell).to_string()
180            };
181
182            result.push_str(&colored_cell);
183
184            // Only add padding if not the last column
185            if i < row.len() - 1 {
186                let padding = col_widths[i].saturating_sub(cell.len());
187                for _ in 0..padding {
188                    result.push(' ');
189                }
190            }
191        }
192        result.push('\n');
193    }
194
195    result.trim_end().to_string()
196}
197
198/// Format output data as multi-column display (like ls).
199fn format_columns_from_output_data(output: &OutputData) -> String {
200    if output.root.is_empty() {
201        return String::new();
202    }
203
204    // Get terminal width, default to 80 if unavailable
205    let term_width = terminal_size::terminal_size()
206        .map(|(w, _)| w.0 as usize)
207        .unwrap_or(80);
208
209    let items: Vec<_> = output.root.iter().collect();
210
211    // Find the longest item
212    let max_len = items.iter()
213        .map(|n| n.display_name().len())
214        .max()
215        .unwrap_or(0);
216
217    // Add padding between columns (2 spaces)
218    let col_width = max_len + 2;
219    // Calculate number of columns that fit
220    let num_cols = (term_width / col_width).max(1);
221
222    let mut result = String::new();
223    let mut col = 0;
224
225    for (i, node) in items.iter().enumerate() {
226        let colored_item = colorize_entry(node.display_name(), Some(node.entry_type));
227
228        if col > 0 && col >= num_cols {
229            result.push('\n');
230            col = 0;
231        }
232
233        if col > 0 {
234            // Pad previous item to column width
235            let prev_len = items.get(i.saturating_sub(1))
236                .map(|n| n.display_name().len())
237                .unwrap_or(0);
238            let padding = col_width.saturating_sub(prev_len);
239            for _ in 0..padding {
240                result.push(' ');
241            }
242        }
243
244        result.push_str(&colored_item);
245        col += 1;
246    }
247
248    result
249}
250
251/// Detect the output context based on terminal state.
252pub fn detect_context() -> OutputContext {
253    if std::io::stdout().is_terminal() {
254        OutputContext::Interactive
255    } else {
256        OutputContext::Piped
257    }
258}
259
260/// Colorize an entry based on its type.
261fn colorize_entry(name: &str, entry_type: Option<EntryType>) -> String {
262    use owo_colors::OwoColorize;
263
264    // Check NO_COLOR environment variable
265    if std::env::var("NO_COLOR").is_ok() {
266        return name.to_string();
267    }
268
269    // Check TERM=dumb
270    if std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false) {
271        return name.to_string();
272    }
273
274    match entry_type {
275        Some(EntryType::Directory) => name.blue().bold().to_string(),
276        Some(EntryType::Executable) => name.green().bold().to_string(),
277        Some(EntryType::Symlink) => name.cyan().to_string(),
278        // EntryType is #[non_exhaustive] — unknown variants render unstyled
279        Some(EntryType::File) | Some(EntryType::Text) | Some(_) | None => name.to_string(),
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_format_output_raw() {
289        let result = ExecResult::success("hello world");
290        let output = format_output(&result, OutputContext::Interactive);
291        assert_eq!(output, "hello world");
292    }
293
294    #[test]
295    fn test_detect_context_not_terminal() {
296        // In test environment, stdout is typically not a terminal
297        let context = detect_context();
298        assert_eq!(context, OutputContext::Piped);
299    }
300
301    #[test]
302    fn test_colorize_plain_file() {
303        // Regular file should not be colored
304        let result = colorize_entry("test.txt", Some(EntryType::File));
305        assert_eq!(result, "test.txt");
306    }
307
308    #[test]
309    fn test_output_data_simple_text() {
310        let output_data = OutputData::text("hello world");
311        let result = ExecResult::with_output(output_data);
312        let formatted = format_output(&result, OutputContext::Interactive);
313        assert_eq!(formatted, "hello world");
314    }
315
316    #[test]
317    fn test_output_data_text_piped() {
318        let output_data = OutputData::text("hello world");
319        let result = ExecResult::with_output(output_data);
320        let formatted = format_output(&result, OutputContext::Piped);
321        assert_eq!(formatted, "hello world");
322    }
323
324    #[test]
325    fn test_output_data_flat_nodes_interactive() {
326        let nodes = vec![
327            OutputNode::new("file1.txt").with_entry_type(EntryType::File),
328            OutputNode::new("file2.txt").with_entry_type(EntryType::File),
329            OutputNode::new("dir").with_entry_type(EntryType::Directory),
330        ];
331        let output_data = OutputData::nodes(nodes);
332        let result = ExecResult::with_output(output_data);
333        let formatted = format_output(&result, OutputContext::Interactive);
334        assert!(formatted.contains("file1.txt"));
335        assert!(formatted.contains("file2.txt"));
336        assert!(formatted.contains("dir"));
337    }
338
339    #[test]
340    fn test_output_data_flat_nodes_piped() {
341        let nodes = vec![
342            OutputNode::new("file1.txt").with_entry_type(EntryType::File),
343            OutputNode::new("file2.txt").with_entry_type(EntryType::File),
344        ];
345        let output_data = OutputData::nodes(nodes);
346        let result = ExecResult::with_output(output_data);
347        let formatted = format_output(&result, OutputContext::Piped);
348        // Piped output should be one per line
349        assert_eq!(formatted, "file1.txt\nfile2.txt");
350    }
351
352    #[test]
353    fn test_output_data_table_with_cells() {
354        let nodes = vec![
355            OutputNode::new("file1.txt")
356                .with_cells(vec!["1024".to_string()])
357                .with_entry_type(EntryType::File),
358            OutputNode::new("file2.txt")
359                .with_cells(vec!["2048".to_string()])
360                .with_entry_type(EntryType::File),
361        ];
362        let output_data = OutputData::table(
363            vec!["Name".to_string(), "Size".to_string()],
364            nodes,
365        );
366        let result = ExecResult::with_output(output_data);
367        let formatted = format_output(&result, OutputContext::Interactive);
368        assert!(formatted.contains("Name"));
369        assert!(formatted.contains("Size"));
370        assert!(formatted.contains("file1.txt"));
371        assert!(formatted.contains("1024"));
372    }
373
374    #[test]
375    fn test_output_data_nested_children_piped() {
376        let child = OutputNode::new("main.rs").with_entry_type(EntryType::File);
377        let parent = OutputNode::new("src")
378            .with_entry_type(EntryType::Directory)
379            .with_children(vec![child]);
380        let output_data = OutputData::nodes(vec![parent]);
381        let result = ExecResult::with_output(output_data);
382        let formatted = format_output(&result, OutputContext::Piped);
383        // Piped should use brace notation
384        assert!(formatted.contains("src"));
385        assert!(formatted.contains("main.rs"));
386    }
387
388    #[test]
389    fn test_format_output_data_direct() {
390        let output_data = OutputData::text("direct test");
391        let formatted = format_output_data(&output_data, OutputContext::Interactive);
392        assert_eq!(formatted, "direct test");
393    }
394}