1use std::collections::HashMap;
7
8use fastmcp_protocol::{Prompt, PromptArgument, Resource, Tool};
9use rich_rust::r#box::ROUNDED;
10use rich_rust::prelude::*;
11use rich_rust::text::OverflowMethod;
12use serde_json::Value;
13
14use crate::console::FastMcpConsole;
15use crate::detection::DisplayContext;
16use crate::theme::FastMcpTheme;
17
18#[derive(Debug, Clone)]
27pub struct ToolTableRenderer {
28 theme: &'static FastMcpTheme,
29 context: DisplayContext,
30 pub show_parameters: bool,
32 pub max_description_width: usize,
34}
35
36impl ToolTableRenderer {
37 #[must_use]
39 pub fn new(context: DisplayContext) -> Self {
40 Self {
41 theme: crate::theme::theme(),
42 context,
43 show_parameters: true,
44 max_description_width: 50,
45 }
46 }
47
48 #[must_use]
50 pub fn detect() -> Self {
51 Self::new(DisplayContext::detect())
52 }
53
54 pub fn render(&self, tools: &[Tool], console: &FastMcpConsole) {
56 if tools.is_empty() {
57 if self.should_use_rich(console) {
58 console.print("[dim]No tools registered[/]");
59 } else {
60 console.print("No tools registered");
61 }
62 return;
63 }
64
65 if !self.should_use_rich(console) {
66 self.render_plain(tools, console);
67 return;
68 }
69
70 let mut table = Table::new()
71 .title(format!("Registered Tools ({})", tools.len()))
72 .title_style(self.theme.header_style.clone())
73 .box_style(&ROUNDED)
74 .border_style(self.theme.border_style.clone())
75 .show_header(true);
76
77 table.add_column(Column::new("Name").style(self.theme.key_style.clone()));
78 table.add_column(
79 Column::new("Description")
80 .max_width(self.max_description_width)
81 .overflow(OverflowMethod::Ellipsis),
82 );
83
84 if self.show_parameters {
85 table.add_column(Column::new("Parameters").justify(JustifyMethod::Center));
86 }
87
88 for tool in tools {
89 let desc = tool.description.as_deref().unwrap_or("-");
90 let truncated_desc = self.truncate_description(desc);
91
92 if self.show_parameters {
93 let params = self.format_parameters(&tool.input_schema);
94 table.add_row_cells([tool.name.as_str(), truncated_desc.as_str(), params.as_str()]);
95 } else {
96 table.add_row_cells([tool.name.as_str(), truncated_desc.as_str()]);
97 }
98 }
99
100 console.render(&table);
101 }
102
103 pub fn render_detail(&self, tool: &Tool, console: &FastMcpConsole) {
105 if !self.should_use_rich(console) {
106 self.render_detail_plain(tool, console);
107 return;
108 }
109
110 console.print(&format!("\n[bold cyan]{}[/]", tool.name));
112 console.print(&format!(
113 "[dim]{}[/]\n",
114 tool.description.as_deref().unwrap_or("No description")
115 ));
116
117 let params = self.extract_parameters(&tool.input_schema);
119 if !params.is_empty() {
120 let mut param_table = Table::new()
121 .title("Parameters")
122 .title_style(self.theme.subheader_style.clone())
123 .box_style(&ROUNDED)
124 .border_style(self.theme.border_style.clone())
125 .show_header(true);
126
127 param_table.add_column(Column::new("Name").style(self.theme.key_style.clone()));
128 param_table.add_column(Column::new("Type"));
129 param_table.add_column(Column::new("Required").justify(JustifyMethod::Center));
130 param_table.add_column(Column::new("Description").max_width(40));
131
132 for param in ¶ms {
133 let required_mark = if param.required { "✓" } else { "" };
134 param_table.add_row_cells([
135 param.name.as_str(),
136 ¶m.type_name,
137 required_mark,
138 param.description.as_deref().unwrap_or("-"),
139 ]);
140 }
141
142 console.render(¶m_table);
143 } else {
144 console.print("[dim]No parameters[/]");
145 }
146 }
147
148 fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
149 self.context.is_human() && console.is_rich()
150 }
151
152 fn format_parameters(&self, schema: &Value) -> String {
153 let params = self.extract_parameters(schema);
154 if params.is_empty() {
155 "none".to_string()
156 } else {
157 let required = params.iter().filter(|p| p.required).count();
158 let optional = params.len() - required;
159
160 match (required, optional) {
161 (r, 0) => format!("{r} required"),
162 (0, o) => format!("{o} optional"),
163 (r, o) => format!("{r} req, {o} opt"),
164 }
165 }
166 }
167
168 fn extract_parameters(&self, schema: &Value) -> Vec<ParameterInfo> {
169 let mut params = Vec::new();
170
171 let properties = schema.get("properties").and_then(Value::as_object);
172 let required = schema
173 .get("required")
174 .and_then(Value::as_array)
175 .map(|arr| {
176 arr.iter()
177 .filter_map(Value::as_str)
178 .map(String::from)
179 .collect::<Vec<_>>()
180 })
181 .unwrap_or_default();
182
183 if let Some(props) = properties {
184 for (name, prop) in props {
185 let type_name = prop
186 .get("type")
187 .and_then(Value::as_str)
188 .unwrap_or("any")
189 .to_string();
190
191 let description = prop
192 .get("description")
193 .and_then(Value::as_str)
194 .map(String::from);
195
196 params.push(ParameterInfo {
197 name: name.clone(),
198 type_name,
199 required: required.contains(name),
200 description,
201 });
202 }
203 }
204
205 params.sort_by(|a, b| match (a.required, b.required) {
207 (true, false) => std::cmp::Ordering::Less,
208 (false, true) => std::cmp::Ordering::Greater,
209 _ => a.name.cmp(&b.name),
210 });
211
212 params
213 }
214
215 fn truncate_description(&self, desc: &str) -> String {
216 let char_count = desc.chars().count();
217 if char_count <= self.max_description_width {
218 desc.to_string()
219 } else {
220 let truncated: String = desc
221 .chars()
222 .take(self.max_description_width.saturating_sub(3))
223 .collect();
224 format!("{truncated}...")
225 }
226 }
227
228 fn render_plain(&self, tools: &[Tool], console: &FastMcpConsole) {
229 console.print_plain(&format!("Registered Tools ({})", tools.len()));
230 console.print_plain(&"=".repeat(40));
231 for tool in tools {
232 let desc = tool.description.as_deref().unwrap_or("-");
233 if self.show_parameters {
234 let params = self.format_parameters(&tool.input_schema);
235 console.print_plain(&format!(" {} - {} [{}]", tool.name, desc, params));
236 } else {
237 console.print_plain(&format!(" {} - {}", tool.name, desc));
238 }
239 }
240 }
241
242 fn render_detail_plain(&self, tool: &Tool, console: &FastMcpConsole) {
243 console.print_plain(&format!("Tool: {}", tool.name));
244 console.print_plain(&format!(
245 "Description: {}",
246 tool.description.as_deref().unwrap_or("No description")
247 ));
248
249 let params = self.extract_parameters(&tool.input_schema);
250 if params.is_empty() {
251 console.print_plain("Parameters: none");
252 } else {
253 console.print_plain("Parameters:");
254 for param in ¶ms {
255 let req = if param.required {
256 "required"
257 } else {
258 "optional"
259 };
260 console.print_plain(&format!(
261 " - {}: {} ({}) - {}",
262 param.name,
263 param.type_name,
264 req,
265 param.description.as_deref().unwrap_or("-")
266 ));
267 }
268 }
269 }
270}
271
272impl Default for ToolTableRenderer {
273 fn default() -> Self {
274 Self::detect()
275 }
276}
277
278#[derive(Debug, Clone)]
280struct ParameterInfo {
281 name: String,
282 type_name: String,
283 required: bool,
284 description: Option<String>,
285}
286
287#[derive(Debug, Clone)]
293pub struct ResourceTableRenderer {
294 theme: &'static FastMcpTheme,
295 context: DisplayContext,
296 pub max_description_width: usize,
298 pub show_mime_type: bool,
300}
301
302impl ResourceTableRenderer {
303 #[must_use]
305 pub fn new(context: DisplayContext) -> Self {
306 Self {
307 theme: crate::theme::theme(),
308 context,
309 max_description_width: 40,
310 show_mime_type: true,
311 }
312 }
313
314 #[must_use]
316 pub fn detect() -> Self {
317 Self::new(DisplayContext::detect())
318 }
319
320 pub fn render(&self, resources: &[Resource], console: &FastMcpConsole) {
322 if resources.is_empty() {
323 if self.should_use_rich(console) {
324 console.print("[dim]No resources registered[/]");
325 } else {
326 console.print_plain("No resources registered");
327 }
328 return;
329 }
330
331 if !self.should_use_rich(console) {
332 self.render_plain(resources, console);
333 return;
334 }
335
336 let mut table = Table::new()
337 .title(format!("Registered Resources ({})", resources.len()))
338 .title_style(self.theme.header_style.clone())
339 .box_style(&ROUNDED)
340 .border_style(self.theme.border_style.clone())
341 .show_header(true);
342
343 table.add_column(Column::new("Name").style(self.theme.key_style.clone()));
344 table.add_column(Column::new("URI").style(self.theme.muted_style.clone()));
345 table.add_column(
346 Column::new("Description")
347 .max_width(self.max_description_width)
348 .overflow(OverflowMethod::Ellipsis),
349 );
350
351 if self.show_mime_type {
352 table.add_column(Column::new("Type"));
353 }
354
355 for resource in resources {
356 let desc = resource.description.as_deref().unwrap_or("-");
357 let truncated_desc = self.truncate_description(desc);
358 let formatted_uri = self.format_uri(&resource.uri);
359
360 if self.show_mime_type {
361 let mime = resource.mime_type.as_deref().unwrap_or("-");
362 table.add_row_cells([
363 resource.name.as_str(),
364 formatted_uri.as_str(),
365 truncated_desc.as_str(),
366 mime,
367 ]);
368 } else {
369 table.add_row_cells([
370 resource.name.as_str(),
371 formatted_uri.as_str(),
372 truncated_desc.as_str(),
373 ]);
374 }
375 }
376
377 console.render(&table);
378 }
379
380 pub fn render_detail(&self, resource: &Resource, console: &FastMcpConsole) {
382 if !self.should_use_rich(console) {
383 self.render_detail_plain(resource, console);
384 return;
385 }
386
387 console.print(&format!("\n[bold cyan]{}[/]", resource.name));
388 console.print(&format!("[dim]URI:[/] {}", self.format_uri(&resource.uri)));
389 console.print(&format!(
390 "[dim]Description:[/] {}",
391 resource.description.as_deref().unwrap_or("No description")
392 ));
393 if let Some(mime) = &resource.mime_type {
394 console.print(&format!("[dim]MIME Type:[/] {}", mime));
395 }
396 }
397
398 pub fn render_tree(&self, resources: &[Resource], console: &FastMcpConsole) {
403 if resources.is_empty() {
404 if self.should_use_rich(console) {
405 console.print("[dim]No resources registered[/]");
406 } else {
407 console.print_plain("No resources registered");
408 }
409 return;
410 }
411
412 if !self.should_use_rich(console) {
413 self.render_plain(resources, console);
414 return;
415 }
416
417 let mut groups: HashMap<String, Vec<&Resource>> = HashMap::new();
419 for resource in resources {
420 let prefix = self.extract_uri_prefix(&resource.uri);
421 groups.entry(prefix).or_default().push(resource);
422 }
423
424 let root = TreeNode::with_icon("📄", format!("[bold]Resources[/] ({})", resources.len()));
426
427 let mut sorted_keys: Vec<_> = groups.keys().cloned().collect();
429 sorted_keys.sort();
430
431 let root = sorted_keys.into_iter().fold(root, |root, prefix| {
432 let group_resources = groups.get(&prefix).unwrap();
433 let group_node =
434 TreeNode::new(format!("[cyan]{}[/] ({})", prefix, group_resources.len()));
435
436 let group_node = group_resources.iter().fold(group_node, |node, resource| {
438 let name_part = self.extract_uri_path(&resource.uri);
439 let desc = self.truncate_description(resource.description.as_deref().unwrap_or(""));
440 let leaf_label = if desc.is_empty() {
441 self.format_uri(&name_part)
442 } else {
443 format!("{} [dim]- {}[/]", self.format_uri(&name_part), desc)
444 };
445 node.child(TreeNode::new(leaf_label))
446 });
447
448 root.child(group_node)
449 });
450
451 let tree = Tree::new(root).guides(TreeGuides::Rounded);
452 console.render(&tree);
453 }
454
455 fn format_uri(&self, uri: &str) -> String {
460 if !uri.contains('{') {
461 return uri.to_string();
462 }
463
464 let mut result = String::with_capacity(uri.len() + 20); let mut buffer = String::new();
466 let mut in_template = false;
467
468 for c in uri.chars() {
469 if in_template {
470 buffer.push(c);
471 if c == '}' {
472 result.push_str("[yellow]");
474 result.push_str(&buffer);
475 result.push_str("[/]");
476 buffer.clear();
477 in_template = false;
478 } else if c == '{' {
479 result.push_str(&buffer[..buffer.len() - 1]); buffer.clear();
489 buffer.push('{');
490 }
491 } else if c == '{' {
492 in_template = true;
493 buffer.push(c);
494 } else {
495 result.push(c);
496 }
497 }
498
499 if in_template {
501 result.push_str(&buffer);
502 }
503
504 result
505 }
506
507 fn extract_uri_prefix(&self, uri: &str) -> String {
509 if let Some(idx) = uri.find("://") {
510 uri[..idx].to_string()
511 } else if let Some(idx) = uri.find(':') {
512 uri[..idx].to_string()
513 } else {
514 "other".to_string()
515 }
516 }
517
518 fn extract_uri_path(&self, uri: &str) -> String {
520 if let Some(idx) = uri.find("://") {
521 uri[idx + 3..].to_string()
522 } else if let Some(idx) = uri.find(':') {
523 uri[idx + 1..].to_string()
524 } else {
525 uri.to_string()
526 }
527 }
528
529 fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
530 self.context.is_human() && console.is_rich()
531 }
532
533 fn truncate_description(&self, desc: &str) -> String {
534 let char_count = desc.chars().count();
535 if char_count <= self.max_description_width {
536 desc.to_string()
537 } else {
538 let truncated: String = desc
539 .chars()
540 .take(self.max_description_width.saturating_sub(3))
541 .collect();
542 format!("{truncated}...")
543 }
544 }
545
546 fn render_plain(&self, resources: &[Resource], console: &FastMcpConsole) {
547 console.print_plain(&format!("Registered Resources ({})", resources.len()));
548 console.print_plain(&"=".repeat(40));
549 for resource in resources {
550 let desc = resource.description.as_deref().unwrap_or("-");
551 console.print_plain(&format!(
552 " {} ({}) - {}",
553 resource.name, resource.uri, desc
554 ));
555 }
556 }
557
558 fn render_detail_plain(&self, resource: &Resource, console: &FastMcpConsole) {
559 console.print_plain(&format!("Resource: {}", resource.name));
560 console.print_plain(&format!("URI: {}", resource.uri));
561 console.print_plain(&format!(
562 "Description: {}",
563 resource.description.as_deref().unwrap_or("No description")
564 ));
565 if let Some(mime) = &resource.mime_type {
566 console.print_plain(&format!("MIME Type: {}", mime));
567 }
568 }
569}
570
571impl Default for ResourceTableRenderer {
572 fn default() -> Self {
573 Self::detect()
574 }
575}
576
577#[derive(Debug, Clone)]
583pub struct PromptTableRenderer {
584 theme: &'static FastMcpTheme,
585 context: DisplayContext,
586 pub max_description_width: usize,
588 pub show_arguments: bool,
590}
591
592impl PromptTableRenderer {
593 #[must_use]
595 pub fn new(context: DisplayContext) -> Self {
596 Self {
597 theme: crate::theme::theme(),
598 context,
599 max_description_width: 50,
600 show_arguments: true,
601 }
602 }
603
604 #[must_use]
606 pub fn detect() -> Self {
607 Self::new(DisplayContext::detect())
608 }
609
610 pub fn render(&self, prompts: &[Prompt], console: &FastMcpConsole) {
612 if prompts.is_empty() {
613 if self.should_use_rich(console) {
614 console.print("[dim]No prompts registered[/]");
615 } else {
616 console.print_plain("No prompts registered");
617 }
618 return;
619 }
620
621 if !self.should_use_rich(console) {
622 self.render_plain(prompts, console);
623 return;
624 }
625
626 let mut table = Table::new()
627 .title(format!("Registered Prompts ({})", prompts.len()))
628 .title_style(self.theme.header_style.clone())
629 .box_style(&ROUNDED)
630 .border_style(self.theme.border_style.clone())
631 .show_header(true);
632
633 table.add_column(Column::new("Name").style(self.theme.key_style.clone()));
634 table.add_column(
635 Column::new("Description")
636 .max_width(self.max_description_width)
637 .overflow(OverflowMethod::Ellipsis),
638 );
639
640 if self.show_arguments {
641 table.add_column(Column::new("Arguments").justify(JustifyMethod::Center));
642 }
643
644 for prompt in prompts {
645 let desc = prompt.description.as_deref().unwrap_or("-");
646 let truncated_desc = self.truncate_description(desc);
647
648 if self.show_arguments {
649 let args = self.format_arguments(&prompt.arguments);
650 table.add_row_cells([prompt.name.as_str(), truncated_desc.as_str(), args.as_str()]);
651 } else {
652 table.add_row_cells([prompt.name.as_str(), truncated_desc.as_str()]);
653 }
654 }
655
656 console.render(&table);
657 }
658
659 pub fn render_detail(&self, prompt: &Prompt, console: &FastMcpConsole) {
661 if !self.should_use_rich(console) {
662 self.render_detail_plain(prompt, console);
663 return;
664 }
665
666 console.print(&format!("\n[bold cyan]{}[/]", prompt.name));
667 console.print(&format!(
668 "[dim]{}[/]\n",
669 prompt.description.as_deref().unwrap_or("No description")
670 ));
671
672 if !prompt.arguments.is_empty() {
674 let mut arg_table = Table::new()
675 .title("Arguments")
676 .title_style(self.theme.subheader_style.clone())
677 .box_style(&ROUNDED)
678 .border_style(self.theme.border_style.clone())
679 .show_header(true);
680
681 arg_table.add_column(Column::new("Name").style(self.theme.key_style.clone()));
682 arg_table.add_column(Column::new("Required").justify(JustifyMethod::Center));
683 arg_table.add_column(Column::new("Description").max_width(40));
684
685 for arg in &prompt.arguments {
686 let required_mark = if arg.required { "✓" } else { "" };
687 arg_table.add_row_cells([
688 arg.name.as_str(),
689 required_mark,
690 arg.description.as_deref().unwrap_or("-"),
691 ]);
692 }
693
694 console.render(&arg_table);
695 } else {
696 console.print("[dim]No arguments[/]");
697 }
698 }
699
700 fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
701 self.context.is_human() && console.is_rich()
702 }
703
704 fn format_arguments(&self, args: &[PromptArgument]) -> String {
705 if args.is_empty() {
706 return "none".to_string();
707 }
708 let required = args.iter().filter(|a| a.required).count();
709 let optional = args.len() - required;
710
711 match (required, optional) {
712 (r, 0) => format!("{r} required"),
713 (0, o) => format!("{o} optional"),
714 (r, o) => format!("{r} req, {o} opt"),
715 }
716 }
717
718 fn truncate_description(&self, desc: &str) -> String {
719 let char_count = desc.chars().count();
720 if char_count <= self.max_description_width {
721 desc.to_string()
722 } else {
723 let truncated: String = desc
724 .chars()
725 .take(self.max_description_width.saturating_sub(3))
726 .collect();
727 format!("{truncated}...")
728 }
729 }
730
731 fn render_plain(&self, prompts: &[Prompt], console: &FastMcpConsole) {
732 console.print_plain(&format!("Registered Prompts ({})", prompts.len()));
733 console.print_plain(&"=".repeat(40));
734 for prompt in prompts {
735 let desc = prompt.description.as_deref().unwrap_or("-");
736 if self.show_arguments {
737 let args = self.format_arguments(&prompt.arguments);
738 console.print_plain(&format!(" {} - {} [{}]", prompt.name, desc, args));
739 } else {
740 console.print_plain(&format!(" {} - {}", prompt.name, desc));
741 }
742 }
743 }
744
745 fn render_detail_plain(&self, prompt: &Prompt, console: &FastMcpConsole) {
746 console.print_plain(&format!("Prompt: {}", prompt.name));
747 console.print_plain(&format!(
748 "Description: {}",
749 prompt.description.as_deref().unwrap_or("No description")
750 ));
751
752 if prompt.arguments.is_empty() {
753 console.print_plain("Arguments: none");
754 } else {
755 console.print_plain("Arguments:");
756 for arg in &prompt.arguments {
757 let req = if arg.required { "required" } else { "optional" };
758 console.print_plain(&format!(
759 " - {} ({}) - {}",
760 arg.name,
761 req,
762 arg.description.as_deref().unwrap_or("-")
763 ));
764 }
765 }
766 }
767}
768
769impl Default for PromptTableRenderer {
770 fn default() -> Self {
771 Self::detect()
772 }
773}
774
775pub fn render_tools_table(tools: &[Tool], console: &FastMcpConsole) {
783 ToolTableRenderer::detect().render(tools, console);
784}
785
786pub fn render_resources_table(resources: &[Resource], console: &FastMcpConsole) {
790 ResourceTableRenderer::detect().render(resources, console);
791}
792
793pub fn render_prompts_table(prompts: &[Prompt], console: &FastMcpConsole) {
797 PromptTableRenderer::detect().render(prompts, console);
798}
799
800#[cfg(test)]
805mod tests {
806 use super::*;
807 use crate::testing::TestConsole;
808 use serde_json::json;
809
810 fn sample_tools() -> Vec<Tool> {
811 vec![
812 Tool {
813 name: "calculate".to_string(),
814 description: Some("Perform mathematical calculations".to_string()),
815 input_schema: json!({
816 "type": "object",
817 "properties": {
818 "expression": {
819 "type": "string",
820 "description": "Mathematical expression to evaluate"
821 },
822 "precision": {
823 "type": "integer",
824 "description": "Number of decimal places"
825 }
826 },
827 "required": ["expression"]
828 }),
829 output_schema: None,
830 icon: None,
831 version: None,
832 tags: vec![],
833 annotations: None,
834 },
835 Tool {
836 name: "search".to_string(),
837 description: Some("Search for files matching a pattern".to_string()),
838 input_schema: json!({
839 "type": "object",
840 "properties": {
841 "pattern": {
842 "type": "string",
843 "description": "Search pattern"
844 }
845 },
846 "required": ["pattern"]
847 }),
848 output_schema: None,
849 icon: None,
850 version: None,
851 tags: vec![],
852 annotations: None,
853 },
854 ]
855 }
856
857 fn sample_resources() -> Vec<Resource> {
858 vec![
859 Resource {
860 uri: "file://config.json".to_string(),
861 name: "config".to_string(),
862 description: Some("Application configuration".to_string()),
863 mime_type: Some("application/json".to_string()),
864 icon: None,
865 version: None,
866 tags: vec![],
867 },
868 Resource {
869 uri: "file://data.csv".to_string(),
870 name: "data".to_string(),
871 description: Some("Data file".to_string()),
872 mime_type: Some("text/csv".to_string()),
873 icon: None,
874 version: None,
875 tags: vec![],
876 },
877 ]
878 }
879
880 fn sample_prompts() -> Vec<Prompt> {
881 vec![
882 Prompt {
883 name: "greeting".to_string(),
884 description: Some("Generate a greeting message".to_string()),
885 arguments: vec![PromptArgument {
886 name: "name".to_string(),
887 description: Some("Person's name".to_string()),
888 required: true,
889 }],
890 icon: None,
891 version: None,
892 tags: vec![],
893 },
894 Prompt {
895 name: "summarize".to_string(),
896 description: Some("Summarize the given text".to_string()),
897 arguments: vec![
898 PromptArgument {
899 name: "text".to_string(),
900 description: Some("Text to summarize".to_string()),
901 required: true,
902 },
903 PromptArgument {
904 name: "length".to_string(),
905 description: Some("Target length".to_string()),
906 required: false,
907 },
908 ],
909 icon: None,
910 version: None,
911 tags: vec![],
912 },
913 ]
914 }
915
916 #[test]
917 fn test_tool_table_render_plain() {
918 let tools = sample_tools();
919 let console = TestConsole::new();
920 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
921 renderer.render(&tools, console.console());
922 console.assert_contains("Registered Tools (2)");
923 console.assert_contains("calculate");
924 console.assert_contains("search");
925 }
926
927 #[test]
928 fn test_tool_table_render_rich() {
929 let tools = sample_tools();
930 let console = TestConsole::new_rich();
931 let renderer = ToolTableRenderer::new(DisplayContext::new_human());
932 renderer.render(&tools, console.console());
933 console.assert_contains("Registered Tools");
934 console.assert_contains("calculate");
935 }
936
937 #[test]
938 fn test_tool_table_empty() {
939 let console = TestConsole::new();
940 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
941 renderer.render(&[], console.console());
942 console.assert_contains("No tools registered");
943 }
944
945 #[test]
946 fn test_tool_table_empty_rich() {
947 let console = TestConsole::new_rich();
948 let renderer = ToolTableRenderer::new(DisplayContext::new_human());
949 renderer.render(&[], console.console());
950 console.assert_contains("No tools registered");
951 }
952
953 #[test]
954 fn test_tool_parameter_extraction() {
955 let tools = sample_tools();
956 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
957 let params = renderer.extract_parameters(&tools[0].input_schema);
958 assert_eq!(params.len(), 2);
959 assert!(params[0].required);
961 assert_eq!(params[0].name, "expression");
962 }
963
964 #[test]
965 fn test_resource_table_render_plain() {
966 let resources = sample_resources();
967 let console = TestConsole::new();
968 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
969 renderer.render(&resources, console.console());
970 console.assert_contains("Registered Resources (2)");
971 console.assert_contains("config");
972 }
973
974 #[test]
975 fn test_resource_table_empty() {
976 let console = TestConsole::new();
977 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
978 renderer.render(&[], console.console());
979 console.assert_contains("No resources registered");
980 }
981
982 #[test]
983 fn test_resource_table_empty_rich() {
984 let console = TestConsole::new_rich();
985 let renderer = ResourceTableRenderer::new(DisplayContext::new_human());
986 renderer.render(&[], console.console());
987 console.assert_contains("No resources registered");
988 }
989
990 #[test]
991 fn test_prompt_table_render_plain() {
992 let prompts = sample_prompts();
993 let console = TestConsole::new();
994 let renderer = PromptTableRenderer::new(DisplayContext::new_agent());
995 renderer.render(&prompts, console.console());
996 console.assert_contains("Registered Prompts (2)");
997 console.assert_contains("greeting");
998 console.assert_contains("summarize");
999 }
1000
1001 #[test]
1002 fn test_prompt_table_empty() {
1003 let console = TestConsole::new();
1004 let renderer = PromptTableRenderer::new(DisplayContext::new_agent());
1005 renderer.render(&[], console.console());
1006 console.assert_contains("No prompts registered");
1007 }
1008
1009 #[test]
1010 fn test_prompt_table_empty_rich() {
1011 let console = TestConsole::new_rich();
1012 let renderer = PromptTableRenderer::new(DisplayContext::new_human());
1013 renderer.render(&[], console.console());
1014 console.assert_contains("No prompts registered");
1015 }
1016
1017 #[test]
1018 fn test_prompt_arguments_formatting() {
1019 let renderer = PromptTableRenderer::new(DisplayContext::new_agent());
1020
1021 assert_eq!(renderer.format_arguments(&[]), "none");
1023
1024 let args = vec![
1026 PromptArgument {
1027 name: "a".to_string(),
1028 description: None,
1029 required: true,
1030 },
1031 PromptArgument {
1032 name: "b".to_string(),
1033 description: None,
1034 required: false,
1035 },
1036 ];
1037 assert_eq!(renderer.format_arguments(&args), "1 req, 1 opt");
1038 }
1039
1040 #[test]
1041 fn test_description_truncation() {
1042 let renderer = ToolTableRenderer {
1043 theme: crate::theme::theme(),
1044 context: DisplayContext::new_agent(),
1045 show_parameters: true,
1046 max_description_width: 20,
1047 };
1048
1049 assert_eq!(renderer.truncate_description("Short"), "Short");
1050 assert_eq!(
1051 renderer
1052 .truncate_description("This is a very long description that should be truncated"),
1053 "This is a very lo..."
1054 );
1055 }
1056
1057 #[test]
1058 fn test_uri_template_highlighting() {
1059 let renderer = ResourceTableRenderer::new(DisplayContext::new_human());
1060
1061 assert_eq!(
1063 renderer.format_uri("file://config.json"),
1064 "file://config.json"
1065 );
1066
1067 assert_eq!(
1069 renderer.format_uri("file://{path}"),
1070 "file://[yellow]{path}[/]"
1071 );
1072
1073 assert_eq!(
1075 renderer.format_uri("db://{table}/{id}"),
1076 "db://[yellow]{table}[/]/[yellow]{id}[/]"
1077 );
1078
1079 assert_eq!(
1081 renderer.format_uri("api://users/{id}/profile"),
1082 "api://users/[yellow]{id}[/]/profile"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_uri_prefix_extraction() {
1088 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1089
1090 assert_eq!(renderer.extract_uri_prefix("file://path"), "file");
1091 assert_eq!(renderer.extract_uri_prefix("db://table"), "db");
1092 assert_eq!(renderer.extract_uri_prefix("config:settings"), "config");
1093 assert_eq!(renderer.extract_uri_prefix("no-scheme"), "other");
1094 }
1095
1096 #[test]
1097 fn test_uri_path_extraction() {
1098 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1099
1100 assert_eq!(
1101 renderer.extract_uri_path("file://config.json"),
1102 "config.json"
1103 );
1104 assert_eq!(renderer.extract_uri_path("db://users/{id}"), "users/{id}");
1105 assert_eq!(renderer.extract_uri_path("config:settings"), "settings");
1106 }
1107
1108 fn sample_resources_with_templates() -> Vec<Resource> {
1109 vec![
1110 Resource {
1111 uri: "file://{path}".to_string(),
1112 name: "file".to_string(),
1113 description: Some("Read file contents".to_string()),
1114 mime_type: None,
1115 icon: None,
1116 version: None,
1117 tags: vec![],
1118 },
1119 Resource {
1120 uri: "file://config.json".to_string(),
1121 name: "config".to_string(),
1122 description: Some("Application config".to_string()),
1123 mime_type: Some("application/json".to_string()),
1124 icon: None,
1125 version: None,
1126 tags: vec![],
1127 },
1128 Resource {
1129 uri: "db://users/{id}".to_string(),
1130 name: "user".to_string(),
1131 description: Some("User record by ID".to_string()),
1132 mime_type: None,
1133 icon: None,
1134 version: None,
1135 tags: vec![],
1136 },
1137 Resource {
1138 uri: "cache://stats".to_string(),
1139 name: "stats".to_string(),
1140 description: Some("Cached statistics".to_string()),
1141 mime_type: None,
1142 icon: None,
1143 version: None,
1144 tags: vec![],
1145 },
1146 ]
1147 }
1148
1149 #[test]
1150 fn test_resource_tree_render_plain() {
1151 let resources = sample_resources_with_templates();
1152 let console = TestConsole::new();
1153 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1154 renderer.render_tree(&resources, console.console());
1156 console.assert_contains("Registered Resources (4)");
1157 }
1158
1159 #[test]
1160 fn test_resource_tree_empty() {
1161 let console = TestConsole::new();
1162 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1163 renderer.render_tree(&[], console.console());
1164 console.assert_contains("No resources registered");
1165 }
1166
1167 #[test]
1168 fn test_tool_table_plain_without_parameter_summary() {
1169 let tools = sample_tools();
1170 let console = TestConsole::new();
1171 let mut renderer = ToolTableRenderer::new(DisplayContext::new_agent());
1172 renderer.show_parameters = false;
1173 renderer.render(&tools, console.console());
1174 console.assert_contains("Registered Tools (2)");
1175 console.assert_not_contains("[");
1176 }
1177
1178 #[test]
1179 fn test_tool_table_rich_without_parameter_summary() {
1180 let tools = sample_tools();
1181 let console = TestConsole::new_rich();
1182 let mut renderer = ToolTableRenderer::new(DisplayContext::new_human());
1183 renderer.show_parameters = false;
1184 renderer.render(&tools, console.console());
1185 console.assert_contains("Registered Tools");
1186 console.assert_contains("calculate");
1187 }
1188
1189 #[test]
1190 fn test_tool_detail_plain_and_rich() {
1191 let tools = sample_tools();
1192
1193 let plain = TestConsole::new();
1194 let renderer_plain = ToolTableRenderer::new(DisplayContext::new_agent());
1195 renderer_plain.render_detail(&tools[0], plain.console());
1196 plain.assert_contains("Tool: calculate");
1197 plain.assert_contains("Parameters:");
1198 plain.assert_contains("expression: string (required)");
1199
1200 let rich = TestConsole::new_rich();
1201 let renderer_rich = ToolTableRenderer::new(DisplayContext::new_human());
1202 renderer_rich.render_detail(&tools[0], rich.console());
1203 rich.assert_contains("calculate");
1204 rich.assert_contains("Parameters");
1205 }
1206
1207 #[test]
1208 fn test_tool_detail_plain_no_parameters() {
1209 let tool = Tool {
1210 name: "ping".to_string(),
1211 description: Some("No args".to_string()),
1212 input_schema: json!({"type": "object"}),
1213 output_schema: None,
1214 icon: None,
1215 version: None,
1216 tags: vec![],
1217 annotations: None,
1218 };
1219 let console = TestConsole::new();
1220 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
1221 renderer.render_detail(&tool, console.console());
1222 console.assert_contains("Parameters: none");
1223 }
1224
1225 #[test]
1226 fn test_tool_detail_rich_no_parameters() {
1227 let tool = Tool {
1228 name: "ping".to_string(),
1229 description: Some("No args".to_string()),
1230 input_schema: json!({"type": "object"}),
1231 output_schema: None,
1232 icon: None,
1233 version: None,
1234 tags: vec![],
1235 annotations: None,
1236 };
1237 let console = TestConsole::new_rich();
1238 let renderer = ToolTableRenderer::new(DisplayContext::new_human());
1239 renderer.render_detail(&tool, console.console());
1240 console.assert_contains("ping");
1241 console.assert_contains("No parameters");
1242 }
1243
1244 #[test]
1245 fn test_tool_format_parameters_variants() {
1246 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
1247 assert_eq!(
1248 renderer.format_parameters(&json!({"type": "object"})),
1249 "none"
1250 );
1251 assert_eq!(
1252 renderer.format_parameters(&json!({
1253 "type": "object",
1254 "properties": {"a": {"type": "string"}},
1255 "required": ["a"]
1256 })),
1257 "1 required"
1258 );
1259 assert_eq!(
1260 renderer.format_parameters(&json!({
1261 "type": "object",
1262 "properties": {"a": {"type": "string"}}
1263 })),
1264 "1 optional"
1265 );
1266 assert_eq!(
1267 renderer.format_parameters(&json!({
1268 "type": "object",
1269 "properties": {"a": {"type": "string"}, "b": {"type": "number"}},
1270 "required": ["a"]
1271 })),
1272 "1 req, 1 opt"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_tool_extract_parameters_defaults_to_any() {
1278 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
1279 let params = renderer.extract_parameters(&json!({
1280 "type": "object",
1281 "properties": {
1282 "raw": {}
1283 }
1284 }));
1285 assert_eq!(params.len(), 1);
1286 assert_eq!(params[0].name, "raw");
1287 assert_eq!(params[0].type_name, "any");
1288 assert!(!params[0].required);
1289 assert!(params[0].description.is_none());
1290 }
1291
1292 #[test]
1293 fn test_tool_extract_parameters_sorts_required_before_optional() {
1294 let renderer = ToolTableRenderer::new(DisplayContext::new_agent());
1295 let params = renderer.extract_parameters(&json!({
1296 "type": "object",
1297 "properties": {
1298 "a": {"type": "string"},
1299 "z": {"type": "number"}
1300 },
1301 "required": ["z"]
1302 }));
1303 assert_eq!(params.len(), 2);
1304 assert_eq!(params[0].name, "z");
1305 assert!(params[0].required);
1306 assert_eq!(params[1].name, "a");
1307 assert!(!params[1].required);
1308 }
1309
1310 #[test]
1311 fn test_resource_table_without_mime_column() {
1312 let resources = sample_resources();
1313 let console = TestConsole::new();
1314 let mut renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1315 renderer.show_mime_type = false;
1316 renderer.render(&resources, console.console());
1317 console.assert_contains("Registered Resources (2)");
1318 console.assert_contains("config (file://config.json) - Application configuration");
1319 }
1320
1321 #[test]
1322 fn test_resource_table_rich_with_and_without_mime_column() {
1323 let resources = sample_resources();
1324 let console = TestConsole::new_rich();
1325 let mut renderer = ResourceTableRenderer::new(DisplayContext::new_human());
1326
1327 renderer.render(&resources, console.console());
1328 console.assert_contains("Registered Resources");
1329 console.assert_contains("application/json");
1330
1331 console.clear();
1332 renderer.show_mime_type = false;
1333 renderer.render(&resources, console.console());
1334 console.assert_contains("Registered Resources");
1335 console.assert_not_contains("application/json");
1336 }
1337
1338 #[test]
1339 fn test_resource_detail_plain_and_rich() {
1340 let resources = sample_resources();
1341
1342 let plain = TestConsole::new();
1343 let renderer_plain = ResourceTableRenderer::new(DisplayContext::new_agent());
1344 renderer_plain.render_detail(&resources[0], plain.console());
1345 plain.assert_contains("Resource: config");
1346 plain.assert_contains("URI: file://config.json");
1347 plain.assert_contains("MIME Type: application/json");
1348
1349 let rich = TestConsole::new_rich();
1350 let renderer_rich = ResourceTableRenderer::new(DisplayContext::new_human());
1351 renderer_rich.render_detail(&resources[0], rich.console());
1352 rich.assert_contains("config");
1353 rich.assert_contains("URI:");
1354 }
1355
1356 #[test]
1357 fn test_resource_detail_plain_without_mime() {
1358 let resource = Resource {
1359 uri: "cache://hits".to_string(),
1360 name: "hits".to_string(),
1361 description: Some("Cache hits".to_string()),
1362 mime_type: None,
1363 icon: None,
1364 version: None,
1365 tags: vec![],
1366 };
1367 let console = TestConsole::new();
1368 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1369 renderer.render_detail(&resource, console.console());
1370 console.assert_contains("Resource: hits");
1371 console.assert_not_contains("MIME Type:");
1372 }
1373
1374 #[test]
1375 fn test_resource_tree_render_rich_groups_by_prefix() {
1376 let resources = sample_resources_with_templates();
1377 let console = TestConsole::new_rich();
1378 let renderer = ResourceTableRenderer::new(DisplayContext::new_human());
1379 renderer.render_tree(&resources, console.console());
1380 console.assert_contains("Resources");
1381 console.assert_contains("(4)");
1382 console.assert_contains("file");
1383 console.assert_contains("db");
1384 console.assert_contains("cache");
1385 }
1386
1387 #[test]
1388 fn test_resource_tree_render_rich_with_empty_description_leaf() {
1389 let mut resources = sample_resources_with_templates();
1390 resources.push(Resource {
1391 uri: "file://no-desc".to_string(),
1392 name: "no_desc".to_string(),
1393 description: None,
1394 mime_type: None,
1395 icon: None,
1396 version: None,
1397 tags: vec![],
1398 });
1399
1400 let console = TestConsole::new_rich();
1401 let renderer = ResourceTableRenderer::new(DisplayContext::new_human());
1402 renderer.render_tree(&resources, console.console());
1403 console.assert_contains("no-desc");
1404 console.assert_not_contains("no-desc -");
1405 }
1406
1407 #[test]
1408 fn test_resource_format_uri_unclosed_and_nested_braces() {
1409 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1410 assert_eq!(renderer.format_uri("file://{path"), "file://{path");
1411 assert_eq!(
1412 renderer.format_uri("weird://{a{b}"),
1413 "weird://{a[yellow]{b}[/]"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_resource_extract_uri_path_no_scheme() {
1419 let renderer = ResourceTableRenderer::new(DisplayContext::new_agent());
1420 assert_eq!(renderer.extract_uri_path("just-a-path"), "just-a-path");
1421 }
1422
1423 #[test]
1424 fn test_prompt_table_plain_without_argument_summary() {
1425 let prompts = sample_prompts();
1426 let console = TestConsole::new();
1427 let mut renderer = PromptTableRenderer::new(DisplayContext::new_agent());
1428 renderer.show_arguments = false;
1429 renderer.render(&prompts, console.console());
1430 console.assert_contains("Registered Prompts (2)");
1431 console.assert_contains("greeting - Generate a greeting message");
1432 }
1433
1434 #[test]
1435 fn test_prompt_table_rich_without_argument_summary() {
1436 let prompts = sample_prompts();
1437 let console = TestConsole::new_rich();
1438 let mut renderer = PromptTableRenderer::new(DisplayContext::new_human());
1439 renderer.show_arguments = false;
1440 renderer.render(&prompts, console.console());
1441 console.assert_contains("Registered Prompts");
1442 console.assert_contains("greeting");
1443 }
1444
1445 #[test]
1446 fn test_prompt_detail_plain_and_rich() {
1447 let prompts = sample_prompts();
1448
1449 let plain = TestConsole::new();
1450 let renderer_plain = PromptTableRenderer::new(DisplayContext::new_agent());
1451 renderer_plain.render_detail(&prompts[1], plain.console());
1452 plain.assert_contains("Prompt: summarize");
1453 plain.assert_contains("Arguments:");
1454 plain.assert_contains("text (required)");
1455 plain.assert_contains("length (optional)");
1456
1457 let rich = TestConsole::new_rich();
1458 let renderer_rich = PromptTableRenderer::new(DisplayContext::new_human());
1459 renderer_rich.render_detail(&prompts[1], rich.console());
1460 rich.assert_contains("summarize");
1461 rich.assert_contains("Arguments");
1462 }
1463
1464 #[test]
1465 fn test_prompt_detail_plain_no_arguments() {
1466 let prompt = Prompt {
1467 name: "ping".to_string(),
1468 description: Some("No args".to_string()),
1469 arguments: vec![],
1470 icon: None,
1471 version: None,
1472 tags: vec![],
1473 };
1474 let console = TestConsole::new();
1475 let renderer = PromptTableRenderer::new(DisplayContext::new_agent());
1476 renderer.render_detail(&prompt, console.console());
1477 console.assert_contains("Arguments: none");
1478 }
1479
1480 #[test]
1481 fn test_prompt_detail_rich_no_arguments() {
1482 let prompt = Prompt {
1483 name: "ping".to_string(),
1484 description: Some("No args".to_string()),
1485 arguments: vec![],
1486 icon: None,
1487 version: None,
1488 tags: vec![],
1489 };
1490 let console = TestConsole::new_rich();
1491 let renderer = PromptTableRenderer::new(DisplayContext::new_human());
1492 renderer.render_detail(&prompt, console.console());
1493 console.assert_contains("ping");
1494 console.assert_contains("No arguments");
1495 }
1496
1497 #[test]
1498 fn test_resource_and_prompt_description_truncation_helpers() {
1499 let resource_renderer = ResourceTableRenderer {
1500 theme: crate::theme::theme(),
1501 context: DisplayContext::new_human(),
1502 max_description_width: 12,
1503 show_mime_type: true,
1504 };
1505 assert_eq!(resource_renderer.truncate_description("short"), "short");
1506 assert_eq!(
1507 resource_renderer.truncate_description("this description is too long"),
1508 "this desc..."
1509 );
1510
1511 let prompt_renderer = PromptTableRenderer {
1512 theme: crate::theme::theme(),
1513 context: DisplayContext::new_human(),
1514 max_description_width: 12,
1515 show_arguments: true,
1516 };
1517 assert_eq!(prompt_renderer.truncate_description("short"), "short");
1518 assert_eq!(
1519 prompt_renderer.truncate_description("this description is too long"),
1520 "this desc..."
1521 );
1522 }
1523
1524 #[test]
1525 fn test_prompt_format_arguments_variants() {
1526 let renderer = PromptTableRenderer::new(DisplayContext::new_agent());
1527 assert_eq!(renderer.format_arguments(&[]), "none");
1528 assert_eq!(
1529 renderer.format_arguments(&[PromptArgument {
1530 name: "a".to_string(),
1531 description: None,
1532 required: true,
1533 }]),
1534 "1 required"
1535 );
1536 assert_eq!(
1537 renderer.format_arguments(&[PromptArgument {
1538 name: "b".to_string(),
1539 description: None,
1540 required: false,
1541 }]),
1542 "1 optional"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_legacy_render_functions() {
1548 let tools = sample_tools();
1549 let resources = sample_resources();
1550 let prompts = sample_prompts();
1551 let console = TestConsole::new();
1552
1553 render_tools_table(&tools, console.console());
1554 render_resources_table(&resources, console.console());
1555 render_prompts_table(&prompts, console.console());
1556
1557 console.assert_contains("Registered Tools (2)");
1558 console.assert_contains("Registered Resources (2)");
1559 console.assert_contains("Registered Prompts (2)");
1560 }
1561
1562 #[test]
1563 fn test_renderer_defaults_detect() {
1564 let _tool = ToolTableRenderer::default();
1565 let _resource = ResourceTableRenderer::default();
1566 let _prompt = PromptTableRenderer::default();
1567 }
1568}