1use crate::agent::ui::colors::ansi;
11use colored::Colorize;
12use rig::agent::CancelSignal;
13use rig::completion::{CompletionModel, CompletionResponse, Message, Usage};
14use rig::message::{AssistantContent, Reasoning};
15use std::io::{self, Write};
16use std::sync::Arc;
17use tokio::sync::Mutex;
18
19const PREVIEW_LINES: usize = 4;
21
22fn truncate_safe(s: &str, max_chars: usize) -> String {
25 let char_count = s.chars().count();
26 if char_count <= max_chars {
27 s.to_string()
28 } else {
29 let truncate_to = max_chars.saturating_sub(3);
30 let truncated: String = s.chars().take(truncate_to).collect();
31 format!("{}...", truncated)
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct ToolCallState {
38 pub name: String,
39 pub args: String,
40 pub output: Option<String>,
41 pub output_lines: Vec<String>,
42 pub is_running: bool,
43 pub is_expanded: bool,
44 pub is_collapsible: bool,
45 pub status_ok: bool,
46}
47
48#[derive(Debug, Default, Clone)]
50pub struct AccumulatedUsage {
51 pub input_tokens: u64,
52 pub output_tokens: u64,
53 pub total_tokens: u64,
54}
55
56impl AccumulatedUsage {
57 pub fn add(&mut self, usage: &Usage) {
59 self.input_tokens += usage.input_tokens;
60 self.output_tokens += usage.output_tokens;
61 self.total_tokens += usage.total_tokens;
62 }
63
64 pub fn has_data(&self) -> bool {
66 self.input_tokens > 0 || self.output_tokens > 0 || self.total_tokens > 0
67 }
68}
69
70#[derive(Default)]
72pub struct DisplayState {
73 pub tool_calls: Vec<ToolCallState>,
74 pub agent_messages: Vec<String>,
75 pub current_tool_index: Option<usize>,
76 pub last_expandable_index: Option<usize>,
77 pub usage: AccumulatedUsage,
79 pub progress_state: Option<std::sync::Arc<crate::agent::ui::progress::ProgressState>>,
81 pub cancel_signal: Option<CancelSignal>,
83}
84
85#[derive(Clone)]
87pub struct ToolDisplayHook {
88 state: Arc<Mutex<DisplayState>>,
89}
90
91impl ToolDisplayHook {
92 pub fn new() -> Self {
93 Self {
94 state: Arc::new(Mutex::new(DisplayState::default())),
95 }
96 }
97
98 pub fn state(&self) -> Arc<Mutex<DisplayState>> {
100 self.state.clone()
101 }
102
103 pub async fn get_usage(&self) -> AccumulatedUsage {
105 let state = self.state.lock().await;
106 state.usage.clone()
107 }
108
109 pub async fn reset_usage(&self) {
111 let mut state = self.state.lock().await;
112 state.usage = AccumulatedUsage::default();
113 }
114
115 pub async fn set_progress_state(
117 &self,
118 progress: std::sync::Arc<crate::agent::ui::progress::ProgressState>,
119 ) {
120 let mut state = self.state.lock().await;
121 state.progress_state = Some(progress);
122 }
123
124 pub async fn clear_progress_state(&self) {
126 let mut state = self.state.lock().await;
127 state.progress_state = None;
128 }
129
130 pub async fn cancel(&self) {
133 let state = self.state.lock().await;
134 if let Some(ref cancel_sig) = state.cancel_signal {
135 cancel_sig.cancel();
136 }
137 }
138
139 pub async fn can_cancel(&self) -> bool {
141 let state = self.state.lock().await;
142 state.cancel_signal.is_some()
143 }
144}
145
146impl Default for ToolDisplayHook {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
153where
154 M: CompletionModel,
155{
156 fn on_tool_call(
157 &self,
158 tool_name: &str,
159 _tool_call_id: Option<String>,
160 args: &str,
161 cancel: CancelSignal,
162 ) -> impl std::future::Future<Output = ()> + Send {
163 let state = self.state.clone();
164 let name = tool_name.to_string();
165 let args_str = args.to_string();
166
167 async move {
168 {
170 let mut s = state.lock().await;
171 s.cancel_signal = Some(cancel);
172 }
173 {
175 let s = state.lock().await;
176 if let Some(ref progress) = s.progress_state {
177 progress.pause();
178 }
179 }
180
181 print!("\r{}", ansi::CLEAR_LINE);
184 let _ = io::stdout().flush();
185
186 println!(); print_tool_header(&name, &args_str);
189
190 {
192 let s = state.lock().await;
193 if let Some(ref progress) = s.progress_state {
194 let action = tool_to_action(&name);
196 progress.set_action(&action);
197
198 let focus = tool_to_focus(&name, &args_str);
200 if let Some(f) = focus {
201 progress.set_focus(&f);
202 }
203 }
204 }
205
206 let mut s = state.lock().await;
208 let idx = s.tool_calls.len();
209 s.tool_calls.push(ToolCallState {
210 name,
211 args: args_str,
212 output: None,
213 output_lines: Vec::new(),
214 is_running: true,
215 is_expanded: false,
216 is_collapsible: false,
217 status_ok: true,
218 });
219 s.current_tool_index = Some(idx);
220 }
221 }
222
223 fn on_tool_result(
224 &self,
225 tool_name: &str,
226 _tool_call_id: Option<String>,
227 args: &str,
228 result: &str,
229 _cancel: CancelSignal,
230 ) -> impl std::future::Future<Output = ()> + Send {
231 let state = self.state.clone();
232 let name = tool_name.to_string();
233 let args_str = args.to_string();
234 let result_str = result.to_string();
235
236 async move {
237 let (status_ok, output_lines, is_collapsible) =
239 print_tool_result(&name, &args_str, &result_str);
240
241 let mut s = state.lock().await;
243 if let Some(idx) = s.current_tool_index {
244 if let Some(tool) = s.tool_calls.get_mut(idx) {
245 tool.output = Some(result_str);
246 tool.output_lines = output_lines;
247 tool.is_running = false;
248 tool.is_collapsible = is_collapsible;
249 tool.status_ok = status_ok;
250 }
251 if is_collapsible {
253 s.last_expandable_index = Some(idx);
254 }
255 }
256 s.current_tool_index = None;
257
258 if let Some(ref progress) = s.progress_state {
260 progress.set_action("Thinking");
261 progress.clear_focus();
262 progress.resume();
263 }
264 }
265 }
266
267 fn on_completion_response(
268 &self,
269 _prompt: &Message,
270 response: &CompletionResponse<M::Response>,
271 cancel: CancelSignal,
272 ) -> impl std::future::Future<Output = ()> + Send {
273 let state = self.state.clone();
274
275 let usage = response.usage;
277
278 let cancel_for_store = cancel.clone();
281
282 let has_tool_calls = response
285 .choice
286 .iter()
287 .any(|content| matches!(content, AssistantContent::ToolCall(_)));
288
289 let reasoning_parts: Vec<String> = response
291 .choice
292 .iter()
293 .filter_map(|content| {
294 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
295 let text = reasoning.to_vec().join("\n");
297 if !text.trim().is_empty() {
298 Some(text)
299 } else {
300 None
301 }
302 } else {
303 None
304 }
305 })
306 .collect();
307
308 let text_parts: Vec<String> = response
310 .choice
311 .iter()
312 .filter_map(|content| {
313 if let AssistantContent::Text(text) = content {
314 let trimmed = text.text.trim();
316 if !trimmed.is_empty() {
317 Some(trimmed.to_string())
318 } else {
319 None
320 }
321 } else {
322 None
323 }
324 })
325 .collect();
326
327 async move {
328 {
330 let mut s = state.lock().await;
331 s.cancel_signal = Some(cancel_for_store);
332 }
333
334 {
336 let mut s = state.lock().await;
337 s.usage.add(&usage);
338
339 if let Some(ref progress) = s.progress_state {
341 progress.update_tokens(usage.input_tokens, usage.output_tokens);
342 }
343 }
344
345 if !reasoning_parts.is_empty() {
347 let thinking_text = reasoning_parts.join("\n");
348
349 let mut s = state.lock().await;
351 s.agent_messages.push(thinking_text.clone());
352 if let Some(ref progress) = s.progress_state {
353 progress.pause();
354 }
355 drop(s);
356
357 print!("\r{}", ansi::CLEAR_LINE);
359 let _ = io::stdout().flush();
360
361 print_agent_thinking(&thinking_text);
363
364 let s = state.lock().await;
366 if let Some(ref progress) = s.progress_state {
367 progress.resume();
368 }
369 }
370
371 if !text_parts.is_empty() && has_tool_calls {
374 let thinking_text = text_parts.join("\n");
375
376 let mut s = state.lock().await;
378 s.agent_messages.push(thinking_text.clone());
379 if let Some(ref progress) = s.progress_state {
380 progress.pause();
381 }
382 drop(s);
383
384 print!("\r{}", ansi::CLEAR_LINE);
386 let _ = io::stdout().flush();
387
388 print_agent_thinking(&thinking_text);
390
391 let s = state.lock().await;
393 if let Some(ref progress) = s.progress_state {
394 progress.resume();
395 }
396 }
397 }
398 }
399}
400
401fn print_agent_thinking(text: &str) {
404 use crate::agent::ui::response::brand;
405
406 println!();
407
408 let mut in_code_block = false;
410
411 for line in text.lines() {
412 let trimmed = line.trim();
413
414 if trimmed.starts_with("```") {
416 if in_code_block {
417 println!(
418 "{} └────────────────────────────────────────────────────────┘{}",
419 brand::LIGHT_PEACH,
420 brand::RESET
421 );
422 in_code_block = false;
423 } else {
424 let lang = trimmed.strip_prefix("```").unwrap_or("");
425 let lang_display = if lang.is_empty() { "code" } else { lang };
426 println!(
427 "{} ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
428 brand::LIGHT_PEACH,
429 brand::CYAN,
430 lang_display,
431 brand::LIGHT_PEACH,
432 brand::RESET
433 );
434 in_code_block = true;
435 }
436 continue;
437 }
438
439 if in_code_block {
440 println!(
441 "{} │ {}{}{} │",
442 brand::LIGHT_PEACH,
443 brand::CYAN,
444 line,
445 brand::RESET
446 );
447 continue;
448 }
449
450 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
452 let content = trimmed
453 .strip_prefix("- ")
454 .or_else(|| trimmed.strip_prefix("* "))
455 .unwrap_or(trimmed);
456 println!(
457 "{} • {}{}",
458 brand::PEACH,
459 format_thinking_inline(content),
460 brand::RESET
461 );
462 continue;
463 }
464
465 if trimmed
467 .chars()
468 .next()
469 .map(|c| c.is_ascii_digit())
470 .unwrap_or(false)
471 && trimmed.chars().nth(1) == Some('.')
472 {
473 println!(
474 "{} {}{}",
475 brand::PEACH,
476 format_thinking_inline(trimmed),
477 brand::RESET
478 );
479 continue;
480 }
481
482 if trimmed.is_empty() {
484 println!();
485 } else {
486 let wrapped = wrap_text(trimmed, 76);
488 for wrapped_line in wrapped {
489 println!(
490 "{} {}{}",
491 brand::PEACH,
492 format_thinking_inline(&wrapped_line),
493 brand::RESET
494 );
495 }
496 }
497 }
498
499 println!();
500 let _ = io::stdout().flush();
501}
502
503fn format_thinking_inline(text: &str) -> String {
505 use crate::agent::ui::response::brand;
506
507 let mut result = String::new();
508 let chars: Vec<char> = text.chars().collect();
509 let mut i = 0;
510
511 while i < chars.len() {
512 if chars[i] == '`'
514 && (i + 1 >= chars.len() || chars[i + 1] != '`')
515 && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
516 {
517 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
518 result.push_str(brand::CYAN);
519 result.push('`');
520 result.push_str(&code_text);
521 result.push('`');
522 result.push_str(brand::RESET);
523 result.push_str(brand::PEACH);
524 i = i + 2 + end;
525 continue;
526 }
527
528 if i + 1 < chars.len()
530 && chars[i] == '*'
531 && chars[i + 1] == '*'
532 && let Some(end_offset) = find_double_star(&chars, i + 2)
533 {
534 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
535 result.push_str(brand::RESET);
536 result.push_str(brand::CORAL);
537 result.push_str(brand::BOLD);
538 result.push_str(&bold_text);
539 result.push_str(brand::RESET);
540 result.push_str(brand::PEACH);
541 i = i + 4 + end_offset;
542 continue;
543 }
544
545 result.push(chars[i]);
546 i += 1;
547 }
548
549 result
550}
551
552fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
554 for i in start..chars.len().saturating_sub(1) {
555 if chars[i] == '*' && chars[i + 1] == '*' {
556 return Some(i - start);
557 }
558 }
559 None
560}
561
562fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
564 if text.len() <= max_width {
565 return vec![text.to_string()];
566 }
567
568 let mut lines = Vec::new();
569 let mut current_line = String::new();
570
571 for word in text.split_whitespace() {
572 if current_line.is_empty() {
573 current_line = word.to_string();
574 } else if current_line.len() + 1 + word.len() <= max_width {
575 current_line.push(' ');
576 current_line.push_str(word);
577 } else {
578 lines.push(current_line);
579 current_line = word.to_string();
580 }
581 }
582
583 if !current_line.is_empty() {
584 lines.push(current_line);
585 }
586
587 if lines.is_empty() {
588 lines.push(text.to_string());
589 }
590
591 lines
592}
593
594fn print_tool_header(name: &str, args: &str) {
596 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
597 let args_display = format_args_display(name, &parsed);
598
599 if args_display.is_empty() {
601 println!("\n{} {}", "●".yellow(), name.cyan().bold());
602 } else {
603 println!(
604 "\n{} {}({})",
605 "●".yellow(),
606 name.cyan().bold(),
607 args_display.dimmed()
608 );
609 }
610
611 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
613
614 let _ = io::stdout().flush();
615}
616
617fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
620 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
622 let _ = io::stdout().flush();
623
624 let parsed: Result<serde_json::Value, _> =
626 serde_json::from_str(result).map(|v: serde_json::Value| {
627 if let Some(inner_str) = v.as_str() {
630 serde_json::from_str(inner_str).unwrap_or(v)
631 } else {
632 v
633 }
634 });
635
636 let parsed = if parsed.is_err() && !result.is_empty() {
639 let is_tool_error = result.contains("error:")
641 || result.contains("Error:")
642 || result.starts_with("Shell error")
643 || result.starts_with("Toolset error")
644 || result.starts_with("ToolCallError");
645
646 if is_tool_error {
647 let clean_msg = result
649 .replace("Toolset error: ", "")
650 .replace("ToolCallError: ", "")
651 .replace("Shell error: ", "");
652 Ok(serde_json::json!({
653 "error": true,
654 "message": clean_msg,
655 "success": false
656 }))
657 } else {
658 parsed
659 }
660 } else {
661 parsed
662 };
663
664 let (status_ok, output_lines) = match name {
666 "shell" => format_shell_result(&parsed),
667 "write_file" | "write_files" => format_write_result(&parsed),
668 "read_file" => format_read_result(&parsed),
669 "list_directory" => format_list_result(&parsed),
670 "analyze_project" => format_analyze_result(&parsed),
671 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
672 "hadolint" => format_hadolint_result(&parsed),
673 "kubelint" => format_kubelint_result(&parsed),
674 "helmlint" => format_helmlint_result(&parsed),
675 "retrieve_output" => format_retrieve_result(&parsed),
676 _ => (true, vec!["done".to_string()]),
677 };
678
679 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
681
682 let dot = if status_ok {
684 "●".green()
685 } else {
686 "●".red()
687 };
688
689 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
691 let args_display = format_args_display(name, &args_parsed);
692
693 if args_display.is_empty() {
694 println!("{} {}", dot, name.cyan().bold());
695 } else {
696 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
697 }
698
699 let total_lines = output_lines.len();
701 let is_collapsible = total_lines > PREVIEW_LINES;
702
703 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
704 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
705 "└"
706 } else {
707 "│"
708 };
709 println!(" {} {}", prefix.dimmed(), line);
710 }
711
712 if is_collapsible {
714 println!(
715 " {} {}",
716 "└".dimmed(),
717 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
718 );
719 }
720
721 let _ = io::stdout().flush();
722 (status_ok, output_lines, is_collapsible)
723}
724
725fn format_args_display(
727 name: &str,
728 parsed: &Result<serde_json::Value, serde_json::Error>,
729) -> String {
730 match name {
731 "shell" => {
732 if let Ok(v) = parsed {
733 v.get("command")
734 .and_then(|c| c.as_str())
735 .unwrap_or("")
736 .to_string()
737 } else {
738 String::new()
739 }
740 }
741 "write_file" => {
742 if let Ok(v) = parsed {
743 v.get("path")
744 .and_then(|p| p.as_str())
745 .unwrap_or("")
746 .to_string()
747 } else {
748 String::new()
749 }
750 }
751 "write_files" => {
752 if let Ok(v) = parsed {
753 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
754 let paths: Vec<&str> = files
755 .iter()
756 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
757 .take(3)
758 .collect();
759 let more = if files.len() > 3 {
760 format!(", +{} more", files.len() - 3)
761 } else {
762 String::new()
763 };
764 format!("{}{}", paths.join(", "), more)
765 } else {
766 String::new()
767 }
768 } else {
769 String::new()
770 }
771 }
772 "read_file" => {
773 if let Ok(v) = parsed {
774 v.get("path")
775 .and_then(|p| p.as_str())
776 .unwrap_or("")
777 .to_string()
778 } else {
779 String::new()
780 }
781 }
782 "list_directory" => {
783 if let Ok(v) = parsed {
784 v.get("path")
785 .and_then(|p| p.as_str())
786 .unwrap_or(".")
787 .to_string()
788 } else {
789 ".".to_string()
790 }
791 }
792 "kubelint" | "helmlint" | "hadolint" | "dclint" => {
793 if let Ok(v) = parsed {
794 if let Some(path) = v.get("path").and_then(|p| p.as_str()) {
796 return path.to_string();
797 }
798 if v.get("content").and_then(|c| c.as_str()).is_some() {
800 return "<inline>".to_string();
801 }
802 "<auto>".to_string()
804 } else {
805 String::new()
806 }
807 }
808 "retrieve_output" => {
809 if let Ok(v) = parsed {
810 let ref_id = v
811 .get("ref_id")
812 .and_then(|r| r.as_str())
813 .unwrap_or("?");
814 let query = v.get("query").and_then(|q| q.as_str());
815
816 if let Some(q) = query {
817 format!("{}, \"{}\"", ref_id, q)
818 } else {
819 ref_id.to_string()
820 }
821 } else {
822 String::new()
823 }
824 }
825 _ => String::new(),
826 }
827}
828
829fn format_shell_result(
831 parsed: &Result<serde_json::Value, serde_json::Error>,
832) -> (bool, Vec<String>) {
833 if let Ok(v) = parsed {
834 if let Some(error_msg) = v.get("message").and_then(|m| m.as_str()) {
836 if v.get("error").and_then(|e| e.as_bool()).unwrap_or(false) {
837 return (false, vec![error_msg.to_string()]);
838 }
839 }
840
841 if v.get("cancelled").and_then(|c| c.as_bool()).unwrap_or(false) {
843 let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("cancelled");
844 return (false, vec![reason.to_string()]);
845 }
846
847 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
848 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
849 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
850 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
851
852 let mut lines = Vec::new();
853
854 for line in stdout.lines() {
856 if !line.trim().is_empty() {
857 lines.push(line.to_string());
858 }
859 }
860
861 if !success {
863 for line in stderr.lines() {
864 if !line.trim().is_empty() {
865 lines.push(format!("{}", line.red()));
866 }
867 }
868 if let Some(code) = exit_code {
869 lines.push(format!("exit code: {}", code).red().to_string());
870 }
871 }
872
873 if lines.is_empty() {
874 lines.push(if success {
875 "completed".to_string()
876 } else {
877 "failed".to_string()
878 });
879 }
880
881 (success, lines)
882 } else {
883 (false, vec!["parse error".to_string()])
884 }
885}
886
887fn format_write_result(
889 parsed: &Result<serde_json::Value, serde_json::Error>,
890) -> (bool, Vec<String>) {
891 if let Ok(v) = parsed {
892 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
893 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
894 let lines_written = v
895 .get("lines_written")
896 .or_else(|| v.get("total_lines"))
897 .and_then(|n| n.as_u64())
898 .unwrap_or(0);
899 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
900
901 let msg = if files_written > 1 {
902 format!(
903 "{} {} files ({} lines)",
904 action, files_written, lines_written
905 )
906 } else {
907 format!("{} ({} lines)", action, lines_written)
908 };
909
910 (success, vec![msg])
911 } else {
912 (false, vec!["write failed".to_string()])
913 }
914}
915
916fn format_read_result(
918 parsed: &Result<serde_json::Value, serde_json::Error>,
919) -> (bool, Vec<String>) {
920 if let Ok(v) = parsed {
921 if v.get("error").is_some() {
923 let error_msg = v
924 .get("error")
925 .and_then(|e| e.as_str())
926 .unwrap_or("file not found");
927 return (false, vec![error_msg.to_string()]);
928 }
929
930 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
932 let msg = if total_lines == 1 {
933 "read 1 line".to_string()
934 } else {
935 format!("read {} lines", total_lines)
936 };
937 return (true, vec![msg]);
938 }
939
940 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
943 let lines = content.lines().count();
944 return (true, vec![format!("read {} lines", lines)]);
945 }
946
947 if v.is_string() {
949 return (true, vec!["read file".to_string()]);
951 }
952
953 (true, vec!["read file".to_string()])
954 } else {
955 (false, vec!["read failed".to_string()])
956 }
957}
958
959fn format_list_result(
961 parsed: &Result<serde_json::Value, serde_json::Error>,
962) -> (bool, Vec<String>) {
963 if let Ok(v) = parsed {
964 let entries = v.get("entries").and_then(|e| e.as_array());
965
966 let mut lines = Vec::new();
967
968 if let Some(entries) = entries {
969 let total = entries.len();
970 for entry in entries.iter().take(PREVIEW_LINES + 2) {
971 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
972 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
973 let prefix = if entry_type == "directory" {
974 "📁"
975 } else {
976 "📄"
977 };
978 lines.push(format!("{} {}", prefix, name));
979 }
980 if total > PREVIEW_LINES + 2 {
982 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
983 }
984 }
985
986 if lines.is_empty() {
987 lines.push("empty directory".to_string());
988 }
989
990 (true, lines)
991 } else {
992 (false, vec!["parse error".to_string()])
993 }
994}
995
996fn format_analyze_result(
998 parsed: &Result<serde_json::Value, serde_json::Error>,
999) -> (bool, Vec<String>) {
1000 if let Ok(v) = parsed {
1001 let mut lines = Vec::new();
1002
1003 let is_compressed = v.get("full_data_ref").is_some();
1005
1006 if is_compressed {
1007 let ref_id = v.get("full_data_ref").and_then(|r| r.as_str()).unwrap_or("?");
1009
1010 if let Some(count) = v.get("project_count").and_then(|c| c.as_u64()) {
1012 lines.push(format!(
1013 "{}📁 {} projects detected{}",
1014 ansi::SUCCESS, count, ansi::RESET
1015 ));
1016 }
1017
1018 if let Some(langs) = v.get("languages_detected").and_then(|l| l.as_array()) {
1020 let names: Vec<&str> = langs.iter().filter_map(|l| l.as_str()).take(5).collect();
1021 if !names.is_empty() {
1022 lines.push(format!(" │ Languages: {}", names.join(", ")));
1023 }
1024 }
1025
1026 if let Some(fws) = v.get("frameworks_detected").and_then(|f| f.as_array()) {
1028 let names: Vec<&str> = fws.iter().filter_map(|f| f.as_str()).take(5).collect();
1029 if !names.is_empty() {
1030 lines.push(format!(" │ Frameworks: {}", names.join(", ")));
1031 }
1032 }
1033
1034 if let Some(techs) = v.get("technologies_detected").and_then(|t| t.as_array()) {
1036 let names: Vec<&str> = techs.iter().filter_map(|t| t.as_str()).take(5).collect();
1037 if !names.is_empty() {
1038 lines.push(format!(" │ Technologies: {}", names.join(", ")));
1039 }
1040 }
1041
1042 if let Some(services) = v.get("services_detected").and_then(|s| s.as_array()) {
1044 let names: Vec<&str> = services.iter().filter_map(|s| s.as_str()).take(4).collect();
1045 if !names.is_empty() {
1046 lines.push(format!(" │ Services: {}", names.join(", ")));
1047 }
1048 } else if let Some(count) = v.get("services_count").and_then(|c| c.as_u64()) {
1049 if count > 0 {
1050 lines.push(format!(" │ Services: {} detected", count));
1051 }
1052 }
1053
1054 lines.push(format!(
1056 "{} └ Full data: retrieve_output('{}'){}",
1057 ansi::GRAY, ref_id, ansi::RESET
1058 ));
1059
1060 return (true, lines);
1061 }
1062
1063 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
1066 let lang_names: Vec<&str> = langs
1067 .iter()
1068 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1069 .take(5)
1070 .collect();
1071 if !lang_names.is_empty() {
1072 lines.push(format!("Languages: {}", lang_names.join(", ")));
1073 }
1074 }
1075
1076 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
1078 let fw_names: Vec<&str> = frameworks
1079 .iter()
1080 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
1081 .take(5)
1082 .collect();
1083 if !fw_names.is_empty() {
1084 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
1085 }
1086 }
1087
1088 if lines.is_empty() {
1089 lines.push("analysis complete".to_string());
1090 }
1091
1092 (true, lines)
1093 } else {
1094 (false, vec!["parse error".to_string()])
1095 }
1096}
1097
1098fn format_security_result(
1100 parsed: &Result<serde_json::Value, serde_json::Error>,
1101) -> (bool, Vec<String>) {
1102 if let Ok(v) = parsed {
1103 let findings = v
1104 .get("findings")
1105 .or_else(|| v.get("vulnerabilities"))
1106 .and_then(|f| f.as_array())
1107 .map(|a| a.len())
1108 .unwrap_or(0);
1109
1110 if findings == 0 {
1111 (true, vec!["no issues found".to_string()])
1112 } else {
1113 (false, vec![format!("{} issues found", findings)])
1114 }
1115 } else {
1116 (false, vec!["parse error".to_string()])
1117 }
1118}
1119
1120fn format_hadolint_result(
1122 parsed: &Result<serde_json::Value, serde_json::Error>,
1123) -> (bool, Vec<String>) {
1124 if let Ok(v) = parsed {
1125 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1126 let summary = v.get("summary");
1127 let action_plan = v.get("action_plan");
1128
1129 let mut lines = Vec::new();
1130
1131 let total = summary
1133 .and_then(|s| s.get("total"))
1134 .and_then(|t| t.as_u64())
1135 .unwrap_or(0);
1136
1137 if total == 0 {
1139 lines.push(format!(
1140 "{}🐳 Dockerfile OK - no issues found{}",
1141 ansi::SUCCESS,
1142 ansi::RESET
1143 ));
1144 return (true, lines);
1145 }
1146
1147 let critical = summary
1149 .and_then(|s| s.get("by_priority"))
1150 .and_then(|p| p.get("critical"))
1151 .and_then(|c| c.as_u64())
1152 .unwrap_or(0);
1153 let high = summary
1154 .and_then(|s| s.get("by_priority"))
1155 .and_then(|p| p.get("high"))
1156 .and_then(|h| h.as_u64())
1157 .unwrap_or(0);
1158 let medium = summary
1159 .and_then(|s| s.get("by_priority"))
1160 .and_then(|p| p.get("medium"))
1161 .and_then(|m| m.as_u64())
1162 .unwrap_or(0);
1163 let low = summary
1164 .and_then(|s| s.get("by_priority"))
1165 .and_then(|p| p.get("low"))
1166 .and_then(|l| l.as_u64())
1167 .unwrap_or(0);
1168
1169 let mut priority_parts = Vec::new();
1171 if critical > 0 {
1172 priority_parts.push(format!(
1173 "{}🔴 {} critical{}",
1174 ansi::CRITICAL,
1175 critical,
1176 ansi::RESET
1177 ));
1178 }
1179 if high > 0 {
1180 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1181 }
1182 if medium > 0 {
1183 priority_parts.push(format!(
1184 "{}🟡 {} medium{}",
1185 ansi::MEDIUM,
1186 medium,
1187 ansi::RESET
1188 ));
1189 }
1190 if low > 0 {
1191 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1192 }
1193
1194 let header_color = if critical > 0 {
1195 ansi::CRITICAL
1196 } else if high > 0 {
1197 ansi::HIGH
1198 } else {
1199 ansi::DOCKER_BLUE
1200 };
1201
1202 lines.push(format!(
1203 "{}🐳 {} issue{} found: {}{}",
1204 header_color,
1205 total,
1206 if total == 1 { "" } else { "s" },
1207 priority_parts.join(" "),
1208 ansi::RESET
1209 ));
1210
1211 let mut shown = 0;
1213 const MAX_PREVIEW: usize = 6;
1214
1215 if let Some(critical_issues) = action_plan
1217 .and_then(|a| a.get("critical"))
1218 .and_then(|c| c.as_array())
1219 {
1220 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1221 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
1222 shown += 1;
1223 }
1224 }
1225
1226 if shown < MAX_PREVIEW
1228 && let Some(high_issues) = action_plan
1229 .and_then(|a| a.get("high"))
1230 .and_then(|h| h.as_array())
1231 {
1232 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1233 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
1234 shown += 1;
1235 }
1236 }
1237
1238 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1240 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1241 {
1242 let truncated = truncate_safe(first_fix, 70);
1243 lines.push(format!(
1244 "{} → Fix: {}{}",
1245 ansi::INFO_BLUE,
1246 truncated,
1247 ansi::RESET
1248 ));
1249 }
1250
1251 let remaining = total as usize - shown;
1253 if remaining > 0 {
1254 lines.push(format!(
1255 "{} +{} more issue{}{}",
1256 ansi::GRAY,
1257 remaining,
1258 if remaining == 1 { "" } else { "s" },
1259 ansi::RESET
1260 ));
1261 }
1262
1263 (success, lines)
1264 } else {
1265 (false, vec!["parse error".to_string()])
1266 }
1267}
1268
1269fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1271 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1272 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1273 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1274 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1275
1276 let badge = match category {
1278 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1279 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1280 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1281 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1282 _ => String::new(),
1283 };
1284
1285 let msg_display = truncate_safe(message, 50);
1287
1288 format!(
1289 "{}{} L{}:{} {}{}[{}]{} {} {}",
1290 color,
1291 icon,
1292 line_num,
1293 ansi::RESET,
1294 ansi::DOCKER_BLUE,
1295 ansi::BOLD,
1296 code,
1297 ansi::RESET,
1298 badge,
1299 msg_display
1300 )
1301}
1302
1303fn format_kubelint_result(
1305 parsed: &Result<serde_json::Value, serde_json::Error>,
1306) -> (bool, Vec<String>) {
1307 if let Ok(v) = parsed {
1308 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1309 let summary = v.get("summary");
1310 let action_plan = v.get("action_plan");
1311 let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1312
1313 let total = summary
1314 .and_then(|s| s.get("total_issues"))
1315 .and_then(|t| t.as_u64())
1316 .unwrap_or(0);
1317
1318 let mut lines = Vec::new();
1319
1320 if let Some(errors) = parse_errors
1322 && !errors.is_empty()
1323 {
1324 lines.push(format!(
1325 "{}☸ {} parse error{} (files could not be fully analyzed){}",
1326 ansi::HIGH,
1327 errors.len(),
1328 if errors.len() == 1 { "" } else { "s" },
1329 ansi::RESET
1330 ));
1331 for (i, err) in errors.iter().take(3).enumerate() {
1332 if let Some(err_str) = err.as_str() {
1333 let truncated = truncate_safe(err_str, 70);
1334 lines.push(format!(
1335 "{} {} {}{}",
1336 ansi::HIGH,
1337 if i == errors.len().min(3) - 1 {
1338 "└"
1339 } else {
1340 "│"
1341 },
1342 truncated,
1343 ansi::RESET
1344 ));
1345 }
1346 }
1347 if errors.len() > 3 {
1348 lines.push(format!(
1349 "{} +{} more errors{}",
1350 ansi::GRAY,
1351 errors.len() - 3,
1352 ansi::RESET
1353 ));
1354 }
1355 if total == 0 {
1357 return (false, lines);
1358 }
1359 }
1360
1361 if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1362 lines.push(format!(
1363 "{}☸ K8s manifests OK - no issues found{}",
1364 ansi::SUCCESS,
1365 ansi::RESET
1366 ));
1367 return (true, lines);
1368 }
1369
1370 let critical = summary
1372 .and_then(|s| s.get("by_priority"))
1373 .and_then(|p| p.get("critical"))
1374 .and_then(|c| c.as_u64())
1375 .unwrap_or(0);
1376 let high = summary
1377 .and_then(|s| s.get("by_priority"))
1378 .and_then(|p| p.get("high"))
1379 .and_then(|h| h.as_u64())
1380 .unwrap_or(0);
1381 let medium = summary
1382 .and_then(|s| s.get("by_priority"))
1383 .and_then(|p| p.get("medium"))
1384 .and_then(|m| m.as_u64())
1385 .unwrap_or(0);
1386 let low = summary
1387 .and_then(|s| s.get("by_priority"))
1388 .and_then(|p| p.get("low"))
1389 .and_then(|l| l.as_u64())
1390 .unwrap_or(0);
1391
1392 let mut priority_parts = Vec::new();
1394 if critical > 0 {
1395 priority_parts.push(format!(
1396 "{}🔴 {} critical{}",
1397 ansi::CRITICAL,
1398 critical,
1399 ansi::RESET
1400 ));
1401 }
1402 if high > 0 {
1403 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1404 }
1405 if medium > 0 {
1406 priority_parts.push(format!(
1407 "{}🟡 {} medium{}",
1408 ansi::MEDIUM,
1409 medium,
1410 ansi::RESET
1411 ));
1412 }
1413 if low > 0 {
1414 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1415 }
1416
1417 let header_color = if critical > 0 {
1418 ansi::CRITICAL
1419 } else if high > 0 {
1420 ansi::HIGH
1421 } else {
1422 ansi::CYAN
1423 };
1424
1425 lines.push(format!(
1426 "{}☸ {} issue{} found: {}{}",
1427 header_color,
1428 total,
1429 if total == 1 { "" } else { "s" },
1430 priority_parts.join(" "),
1431 ansi::RESET
1432 ));
1433
1434 let mut shown = 0;
1436 const MAX_PREVIEW: usize = 6;
1437
1438 if let Some(critical_issues) = action_plan
1440 .and_then(|a| a.get("critical"))
1441 .and_then(|c| c.as_array())
1442 {
1443 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1444 lines.push(format_kubelint_issue(issue, "🔴", ansi::CRITICAL));
1445 shown += 1;
1446 }
1447 }
1448
1449 if shown < MAX_PREVIEW
1451 && let Some(high_issues) = action_plan
1452 .and_then(|a| a.get("high"))
1453 .and_then(|h| h.as_array())
1454 {
1455 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1456 lines.push(format_kubelint_issue(issue, "🟠", ansi::HIGH));
1457 shown += 1;
1458 }
1459 }
1460
1461 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1463 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1464 {
1465 let truncated = truncate_safe(first_fix, 70);
1466 lines.push(format!(
1467 "{} → Fix: {}{}",
1468 ansi::INFO_BLUE,
1469 truncated,
1470 ansi::RESET
1471 ));
1472 }
1473
1474 let remaining = total as usize - shown;
1476 if remaining > 0 {
1477 lines.push(format!(
1478 "{} +{} more issue{}{}",
1479 ansi::GRAY,
1480 remaining,
1481 if remaining == 1 { "" } else { "s" },
1482 ansi::RESET
1483 ));
1484 }
1485
1486 (success && total == 0, lines)
1487 } else {
1488 (false, vec!["kubelint analysis complete".to_string()])
1489 }
1490}
1491fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1492 let check = issue.get("check").and_then(|c| c.as_str()).unwrap_or("?");
1493 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1494 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1495 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1496
1497 let badge = match category {
1499 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1500 "rbac" => format!("{}[RBAC]{}", ansi::CRITICAL, ansi::RESET),
1501 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1502 "validation" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1503 _ => String::new(),
1504 };
1505
1506 let msg_display = truncate_safe(message, 50);
1508
1509 format!(
1510 "{}{} L{}:{} {}{}[{}]{} {} {}",
1511 color,
1512 icon,
1513 line_num,
1514 ansi::RESET,
1515 ansi::CYAN,
1516 ansi::BOLD,
1517 check,
1518 ansi::RESET,
1519 badge,
1520 msg_display
1521 )
1522}
1523
1524fn format_helmlint_result(
1526 parsed: &Result<serde_json::Value, serde_json::Error>,
1527) -> (bool, Vec<String>) {
1528 if let Ok(v) = parsed {
1529 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1530 let summary = v.get("summary");
1531 let action_plan = v.get("action_plan");
1532 let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1533
1534 let total = summary
1535 .and_then(|s| s.get("total"))
1536 .and_then(|t| t.as_u64())
1537 .unwrap_or(0);
1538
1539 let mut lines = Vec::new();
1540
1541 if let Some(errors) = parse_errors
1543 && !errors.is_empty()
1544 {
1545 lines.push(format!(
1546 "{}⎈ {} parse error{} (chart could not be fully analyzed){}",
1547 ansi::HIGH,
1548 errors.len(),
1549 if errors.len() == 1 { "" } else { "s" },
1550 ansi::RESET
1551 ));
1552 for (i, err) in errors.iter().take(3).enumerate() {
1553 if let Some(err_str) = err.as_str() {
1554 let truncated = truncate_safe(err_str, 70);
1555 lines.push(format!(
1556 "{} {} {}{}",
1557 ansi::HIGH,
1558 if i == errors.len().min(3) - 1 {
1559 "└"
1560 } else {
1561 "│"
1562 },
1563 truncated,
1564 ansi::RESET
1565 ));
1566 }
1567 }
1568 if errors.len() > 3 {
1569 lines.push(format!(
1570 "{} +{} more errors{}",
1571 ansi::GRAY,
1572 errors.len() - 3,
1573 ansi::RESET
1574 ));
1575 }
1576 if total == 0 {
1578 return (false, lines);
1579 }
1580 }
1581
1582 if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1583 lines.push(format!(
1584 "{}⎈ Helm chart OK - no issues found{}",
1585 ansi::SUCCESS,
1586 ansi::RESET
1587 ));
1588 return (true, lines);
1589 }
1590
1591 let critical = summary
1593 .and_then(|s| s.get("by_priority"))
1594 .and_then(|p| p.get("critical"))
1595 .and_then(|c| c.as_u64())
1596 .unwrap_or(0);
1597 let high = summary
1598 .and_then(|s| s.get("by_priority"))
1599 .and_then(|p| p.get("high"))
1600 .and_then(|h| h.as_u64())
1601 .unwrap_or(0);
1602 let medium = summary
1603 .and_then(|s| s.get("by_priority"))
1604 .and_then(|p| p.get("medium"))
1605 .and_then(|m| m.as_u64())
1606 .unwrap_or(0);
1607 let low = summary
1608 .and_then(|s| s.get("by_priority"))
1609 .and_then(|p| p.get("low"))
1610 .and_then(|l| l.as_u64())
1611 .unwrap_or(0);
1612
1613 let mut priority_parts = Vec::new();
1615 if critical > 0 {
1616 priority_parts.push(format!(
1617 "{}🔴 {} critical{}",
1618 ansi::CRITICAL,
1619 critical,
1620 ansi::RESET
1621 ));
1622 }
1623 if high > 0 {
1624 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1625 }
1626 if medium > 0 {
1627 priority_parts.push(format!(
1628 "{}🟡 {} medium{}",
1629 ansi::MEDIUM,
1630 medium,
1631 ansi::RESET
1632 ));
1633 }
1634 if low > 0 {
1635 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1636 }
1637
1638 let header_color = if critical > 0 {
1639 ansi::CRITICAL
1640 } else if high > 0 {
1641 ansi::HIGH
1642 } else {
1643 ansi::CYAN
1644 };
1645
1646 lines.push(format!(
1647 "{}⎈ {} issue{} found: {}{}",
1648 header_color,
1649 total,
1650 if total == 1 { "" } else { "s" },
1651 priority_parts.join(" "),
1652 ansi::RESET
1653 ));
1654
1655 let mut shown = 0;
1657 const MAX_PREVIEW: usize = 6;
1658
1659 if let Some(critical_issues) = action_plan
1661 .and_then(|a| a.get("critical"))
1662 .and_then(|c| c.as_array())
1663 {
1664 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1665 lines.push(format_helmlint_issue(issue, "🔴", ansi::CRITICAL));
1666 shown += 1;
1667 }
1668 }
1669
1670 if shown < MAX_PREVIEW
1672 && let Some(high_issues) = action_plan
1673 .and_then(|a| a.get("high"))
1674 .and_then(|h| h.as_array())
1675 {
1676 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1677 lines.push(format_helmlint_issue(issue, "🟠", ansi::HIGH));
1678 shown += 1;
1679 }
1680 }
1681
1682 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1684 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1685 {
1686 let truncated = truncate_safe(first_fix, 70);
1687 lines.push(format!(
1688 "{} → Fix: {}{}",
1689 ansi::INFO_BLUE,
1690 truncated,
1691 ansi::RESET
1692 ));
1693 }
1694
1695 let remaining = total as usize - shown;
1697 if remaining > 0 {
1698 lines.push(format!(
1699 "{} +{} more issue{}{}",
1700 ansi::GRAY,
1701 remaining,
1702 if remaining == 1 { "" } else { "s" },
1703 ansi::RESET
1704 ));
1705 }
1706
1707 (success && total == 0, lines)
1708 } else {
1709 (false, vec!["helmlint analysis complete".to_string()])
1710 }
1711}
1712
1713fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1715 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1716 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1717 let file = issue.get("file").and_then(|f| f.as_str()).unwrap_or("");
1718 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1719 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1720
1721 let badge = match category {
1723 "Security" | "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1724 "Structure" | "structure" => format!("{}[STRUCT]{}", ansi::GRAY, ansi::RESET),
1725 "Template" | "template" => format!("{}[TPL]{}", ansi::MEDIUM, ansi::RESET),
1726 "Values" | "values" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1727 _ => String::new(),
1728 };
1729
1730 let file_short = if file.chars().count() > 20 {
1732 let skip = file.chars().count().saturating_sub(17);
1733 format!("...{}", file.chars().skip(skip).collect::<String>())
1734 } else {
1735 file.to_string()
1736 };
1737
1738 let msg_display = truncate_safe(message, 40);
1740
1741 format!(
1742 "{}{} {}:{}:{} {}{}[{}]{} {} {}",
1743 color,
1744 icon,
1745 file_short,
1746 line_num,
1747 ansi::RESET,
1748 ansi::CYAN,
1749 ansi::BOLD,
1750 code,
1751 ansi::RESET,
1752 badge,
1753 msg_display
1754 )
1755}
1756
1757fn format_retrieve_result(
1759 parsed: &Result<serde_json::Value, serde_json::Error>,
1760) -> (bool, Vec<String>) {
1761 if let Ok(v) = parsed {
1762 let mut lines = Vec::new();
1763
1764 if let Some(error) = v.get("error").and_then(|e| e.as_str()) {
1766 lines.push(format!("{}❌ {}{}", ansi::CRITICAL, error, ansi::RESET));
1767 return (false, lines);
1768 }
1769
1770 if let Some(total) = v.get("total_matches").and_then(|t| t.as_u64()) {
1772 let query = v
1773 .get("query")
1774 .and_then(|q| q.as_str())
1775 .unwrap_or("unfiltered");
1776
1777 lines.push(format!(
1778 "{}📦 Retrieved {} match{} for '{}'{}",
1779 ansi::SUCCESS,
1780 total,
1781 if total == 1 { "" } else { "es" },
1782 query,
1783 ansi::RESET
1784 ));
1785
1786 if let Some(results) = v.get("results").and_then(|r| r.as_array()) {
1788 for (i, result) in results.iter().take(3).enumerate() {
1789 let preview = format_result_preview(result);
1790 let prefix = if i == results.len().min(3) - 1 && results.len() <= 3 {
1791 "└"
1792 } else {
1793 "│"
1794 };
1795 lines.push(format!(" {} {}", prefix, preview));
1796 }
1797 if results.len() > 3 {
1798 lines.push(format!(
1799 "{} └ +{} more results{}",
1800 ansi::GRAY,
1801 results.len() - 3,
1802 ansi::RESET
1803 ));
1804 }
1805 }
1806
1807 return (true, lines);
1808 }
1809
1810 if v.get("project_count").is_some() || v.get("total_projects").is_some() {
1812 let count = v
1813 .get("project_count")
1814 .or_else(|| v.get("total_projects"))
1815 .and_then(|c| c.as_u64())
1816 .unwrap_or(0);
1817
1818 lines.push(format!(
1819 "{}📦 Retrieved project summary ({} projects){}",
1820 ansi::SUCCESS,
1821 count,
1822 ansi::RESET
1823 ));
1824
1825 if let Some(names) = v.get("project_names").and_then(|n| n.as_array()) {
1827 let name_list: Vec<&str> = names
1828 .iter()
1829 .filter_map(|n| n.as_str())
1830 .take(5)
1831 .collect();
1832 if !name_list.is_empty() {
1833 lines.push(format!(" │ Projects: {}", name_list.join(", ")));
1834 }
1835 if names.len() > 5 {
1836 lines.push(format!("{} └ +{} more{}", ansi::GRAY, names.len() - 5, ansi::RESET));
1837 }
1838 }
1839
1840 return (true, lines);
1841 }
1842
1843 if let Some(total) = v.get("total_services").and_then(|t| t.as_u64()) {
1845 lines.push(format!(
1846 "{}📦 Retrieved {} service{}{}",
1847 ansi::SUCCESS,
1848 total,
1849 if total == 1 { "" } else { "s" },
1850 ansi::RESET
1851 ));
1852
1853 if let Some(services) = v.get("services").and_then(|s| s.as_array()) {
1854 for (i, svc) in services.iter().take(4).enumerate() {
1855 let name = svc.get("name").and_then(|n| n.as_str()).unwrap_or("?");
1856 let svc_type = svc.get("service_type").and_then(|t| t.as_str()).unwrap_or("");
1857 let prefix = if i == services.len().min(4) - 1 && services.len() <= 4 {
1858 "└"
1859 } else {
1860 "│"
1861 };
1862 lines.push(format!(" {} 🔧 {} {}", prefix, name, svc_type));
1863 }
1864 if services.len() > 4 {
1865 lines.push(format!("{} └ +{} more{}", ansi::GRAY, services.len() - 4, ansi::RESET));
1866 }
1867 }
1868
1869 return (true, lines);
1870 }
1871
1872 if v.get("languages").is_some() || v.get("technologies").is_some() {
1874 lines.push(format!(
1875 "{}📦 Retrieved analysis data{}",
1876 ansi::SUCCESS,
1877 ansi::RESET
1878 ));
1879
1880 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
1881 let names: Vec<&str> = langs
1882 .iter()
1883 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1884 .take(5)
1885 .collect();
1886 if !names.is_empty() {
1887 lines.push(format!(" │ Languages: {}", names.join(", ")));
1888 }
1889 }
1890
1891 if let Some(techs) = v.get("technologies").and_then(|t| t.as_array()) {
1892 let names: Vec<&str> = techs
1893 .iter()
1894 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
1895 .take(5)
1896 .collect();
1897 if !names.is_empty() {
1898 lines.push(format!(" └ Technologies: {}", names.join(", ")));
1899 }
1900 }
1901
1902 return (true, lines);
1903 }
1904
1905 let json_str = serde_json::to_string(v).unwrap_or_default();
1907 let size_kb = json_str.len() as f64 / 1024.0;
1908
1909 lines.push(format!(
1910 "{}📦 Retrieved {:.1} KB of data{}",
1911 ansi::SUCCESS,
1912 size_kb,
1913 ansi::RESET
1914 ));
1915
1916 if let Some(obj) = v.as_object() {
1918 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(5).collect();
1919 if !keys.is_empty() {
1920 lines.push(format!(" └ Fields: {}", keys.join(", ")));
1921 }
1922 }
1923
1924 (true, lines)
1925 } else {
1926 (false, vec!["retrieve failed".to_string()])
1927 }
1928}
1929
1930fn format_result_preview(result: &serde_json::Value) -> String {
1932 let name = result
1934 .get("name")
1935 .or_else(|| result.get("code"))
1936 .or_else(|| result.get("check"))
1937 .and_then(|v| v.as_str())
1938 .unwrap_or("item");
1939
1940 let detail = result
1941 .get("message")
1942 .or_else(|| result.get("description"))
1943 .or_else(|| result.get("path"))
1944 .and_then(|v| v.as_str())
1945 .unwrap_or("");
1946
1947 let detail_short = truncate_safe(detail, 40);
1948
1949 if detail_short.is_empty() {
1950 name.to_string()
1951 } else {
1952 format!("{}: {}", name, detail_short)
1953 }
1954}
1955
1956fn tool_to_action(tool_name: &str) -> String {
1958 match tool_name {
1959 "read_file" => "Reading file".to_string(),
1960 "write_file" | "write_files" => "Writing file".to_string(),
1961 "list_directory" => "Listing directory".to_string(),
1962 "shell" => "Running command".to_string(),
1963 "analyze_project" => "Analyzing project".to_string(),
1964 "security_scan" | "check_vulnerabilities" => "Scanning security".to_string(),
1965 "hadolint" => "Linting Dockerfile".to_string(),
1966 "dclint" => "Linting docker-compose".to_string(),
1967 "kubelint" => "Linting Kubernetes".to_string(),
1968 "helmlint" => "Linting Helm chart".to_string(),
1969 "terraform_fmt" => "Formatting Terraform".to_string(),
1970 "terraform_validate" => "Validating Terraform".to_string(),
1971 "plan_create" => "Creating plan".to_string(),
1972 "plan_list" => "Listing plans".to_string(),
1973 "plan_next" | "plan_update" => "Updating plan".to_string(),
1974 "retrieve_output" => "Retrieving data".to_string(),
1975 "list_stored_outputs" => "Listing outputs".to_string(),
1976 _ => "Processing".to_string(),
1977 }
1978}
1979
1980fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
1982 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
1983 let parsed = parsed.ok()?;
1984
1985 match tool_name {
1986 "read_file" | "write_file" => {
1987 parsed.get("path").and_then(|p| p.as_str()).map(|p| {
1988 let char_count = p.chars().count();
1990 if char_count > 50 {
1991 let skip = char_count.saturating_sub(47);
1992 format!("...{}", p.chars().skip(skip).collect::<String>())
1993 } else {
1994 p.to_string()
1995 }
1996 })
1997 }
1998 "list_directory" => parsed
1999 .get("path")
2000 .and_then(|p| p.as_str())
2001 .map(|p| p.to_string()),
2002 "shell" => parsed.get("command").and_then(|c| c.as_str()).map(|cmd| {
2003 truncate_safe(cmd, 60)
2005 }),
2006 "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed
2007 .get("path")
2008 .and_then(|p| p.as_str())
2009 .map(|p| p.to_string())
2010 .or_else(|| {
2011 if parsed.get("content").is_some() {
2012 Some("<inline content>".to_string())
2013 } else {
2014 Some("<auto-detect>".to_string())
2015 }
2016 }),
2017 "plan_create" => parsed
2018 .get("name")
2019 .and_then(|n| n.as_str())
2020 .map(|n| n.to_string()),
2021 "retrieve_output" => {
2022 let ref_id = parsed.get("ref_id").and_then(|r| r.as_str())?;
2023 let query = parsed.get("query").and_then(|q| q.as_str());
2024 Some(if let Some(q) = query {
2025 format!("{} ({})", ref_id, q)
2026 } else {
2027 ref_id.to_string()
2028 })
2029 }
2030 _ => None,
2031 }
2032}
2033
2034pub use crate::agent::ui::Spinner;
2036use tokio::sync::mpsc;
2037
2038#[derive(Debug, Clone)]
2040pub enum ToolEvent {
2041 ToolStart { name: String, args: String },
2042 ToolComplete { name: String, result: String },
2043}
2044
2045pub fn spawn_tool_display_handler(
2047 _receiver: mpsc::Receiver<ToolEvent>,
2048 _spinner: Arc<crate::agent::ui::Spinner>,
2049) -> tokio::task::JoinHandle<()> {
2050 tokio::spawn(async {})
2051}