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.out.clone()
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) | None => name.to_string(),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_format_output_raw() {
288 let result = ExecResult::success("hello world");
289 let output = format_output(&result, OutputContext::Interactive);
290 assert_eq!(output, "hello world");
291 }
292
293 #[test]
294 fn test_detect_context_not_terminal() {
295 let context = detect_context();
297 assert_eq!(context, OutputContext::Piped);
298 }
299
300 #[test]
301 fn test_colorize_plain_file() {
302 let result = colorize_entry("test.txt", Some(EntryType::File));
304 assert_eq!(result, "test.txt");
305 }
306
307 #[test]
308 fn test_output_data_simple_text() {
309 let output_data = OutputData::text("hello world");
310 let result = ExecResult::with_output(output_data);
311 let formatted = format_output(&result, OutputContext::Interactive);
312 assert_eq!(formatted, "hello world");
313 }
314
315 #[test]
316 fn test_output_data_text_piped() {
317 let output_data = OutputData::text("hello world");
318 let result = ExecResult::with_output(output_data);
319 let formatted = format_output(&result, OutputContext::Piped);
320 assert_eq!(formatted, "hello world");
321 }
322
323 #[test]
324 fn test_output_data_flat_nodes_interactive() {
325 let nodes = vec![
326 OutputNode::new("file1.txt").with_entry_type(EntryType::File),
327 OutputNode::new("file2.txt").with_entry_type(EntryType::File),
328 OutputNode::new("dir").with_entry_type(EntryType::Directory),
329 ];
330 let output_data = OutputData::nodes(nodes);
331 let result = ExecResult::with_output(output_data);
332 let formatted = format_output(&result, OutputContext::Interactive);
333 assert!(formatted.contains("file1.txt"));
334 assert!(formatted.contains("file2.txt"));
335 assert!(formatted.contains("dir"));
336 }
337
338 #[test]
339 fn test_output_data_flat_nodes_piped() {
340 let nodes = vec![
341 OutputNode::new("file1.txt").with_entry_type(EntryType::File),
342 OutputNode::new("file2.txt").with_entry_type(EntryType::File),
343 ];
344 let output_data = OutputData::nodes(nodes);
345 let result = ExecResult::with_output(output_data);
346 let formatted = format_output(&result, OutputContext::Piped);
347 assert_eq!(formatted, "file1.txt\nfile2.txt");
349 }
350
351 #[test]
352 fn test_output_data_table_with_cells() {
353 let nodes = vec![
354 OutputNode::new("file1.txt")
355 .with_cells(vec!["1024".to_string()])
356 .with_entry_type(EntryType::File),
357 OutputNode::new("file2.txt")
358 .with_cells(vec!["2048".to_string()])
359 .with_entry_type(EntryType::File),
360 ];
361 let output_data = OutputData::table(
362 vec!["Name".to_string(), "Size".to_string()],
363 nodes,
364 );
365 let result = ExecResult::with_output(output_data);
366 let formatted = format_output(&result, OutputContext::Interactive);
367 assert!(formatted.contains("Name"));
368 assert!(formatted.contains("Size"));
369 assert!(formatted.contains("file1.txt"));
370 assert!(formatted.contains("1024"));
371 }
372
373 #[test]
374 fn test_output_data_nested_children_piped() {
375 let child = OutputNode::new("main.rs").with_entry_type(EntryType::File);
376 let parent = OutputNode::new("src")
377 .with_entry_type(EntryType::Directory)
378 .with_children(vec![child]);
379 let output_data = OutputData::nodes(vec![parent]);
380 let result = ExecResult::with_output(output_data);
381 let formatted = format_output(&result, OutputContext::Piped);
382 assert!(formatted.contains("src"));
384 assert!(formatted.contains("main.rs"));
385 }
386
387 #[test]
388 fn test_format_output_data_direct() {
389 let output_data = OutputData::text("direct test");
390 let formatted = format_output_data(&output_data, OutputContext::Interactive);
391 assert_eq!(formatted, "direct test");
392 }
393}