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