1use std::io::IsTerminal;
13
14use kaish_kernel::interpreter::{EntryType, ExecResult, OutputData, OutputNode};
15use kaish_kernel::tools::OutputContext;
16
17pub fn format_output(result: &ExecResult, context: OutputContext) -> String {
22 if let Some(ref output) = result.output {
24 return format_output_data(output, context);
25 }
26
27 result.text_out().into_owned()
29}
30
31pub fn format_output_data(output: &OutputData, context: OutputContext) -> String {
39 if !matches!(context, OutputContext::Interactive) {
41 return output.to_canonical_string();
42 }
43
44 if let Some(text) = output.as_text() {
46 return text.to_string();
47 }
48
49 if !output.is_flat() {
51 return format_tree_from_output_data(output);
52 }
53
54 if output.is_tabular() {
56 return format_table_from_output_data(output);
57 }
58
59 format_columns_from_output_data(output)
61}
62
63fn 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
77fn format_tree_node(output: &mut String, node: &OutputNode, prefix: &str, is_last: bool) {
79 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 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 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
109fn format_table_from_output_data(output: &OutputData) -> String {
111 if output.root.is_empty() {
112 return String::new();
113 }
114
115 let rows: Vec<Vec<&str>> = output.root.iter().map(|node| {
117 let mut row = Vec::new();
118 row.push(node.display_name());
120 for cell in &node.cells {
122 row.push(cell.as_str());
123 }
124 row
125 }).collect();
126
127 let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
129 let mut col_widths = vec![0; num_cols];
130
131 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 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 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 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 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 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
198fn format_columns_from_output_data(output: &OutputData) -> String {
200 if output.root.is_empty() {
201 return String::new();
202 }
203
204 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 let max_len = items.iter()
213 .map(|n| n.display_name().len())
214 .max()
215 .unwrap_or(0);
216
217 let col_width = max_len + 2;
219 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 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
251pub fn detect_context() -> OutputContext {
253 if std::io::stdout().is_terminal() {
254 OutputContext::Interactive
255 } else {
256 OutputContext::Piped
257 }
258}
259
260fn colorize_entry(name: &str, entry_type: Option<EntryType>) -> String {
262 use owo_colors::OwoColorize;
263
264 if std::env::var("NO_COLOR").is_ok() {
266 return name.to_string();
267 }
268
269 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 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 let context = detect_context();
298 assert_eq!(context, OutputContext::Piped);
299 }
300
301 #[test]
302 fn test_colorize_plain_file() {
303 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 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 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}