1use serde_json::Value;
6use std::io::IsTerminal;
7
8pub(crate) const DESCRIPTION_TRUNCATE_LEN: usize = 80;
13
14pub(crate) fn resolve_format_inner(explicit_format: Option<&str>, is_tty: bool) -> &'static str {
20 if let Some(fmt) = explicit_format {
21 return match fmt {
22 "json" => "json",
23 "table" => "table",
24 other => {
25 tracing::warn!("Unknown format '{}', defaulting to 'json'.", other);
28 "json"
29 }
30 };
31 }
32 if is_tty {
33 "table"
34 } else {
35 "json"
36 }
37}
38
39pub fn resolve_format(explicit_format: Option<&str>) -> &'static str {
46 let is_tty = std::io::stdout().is_terminal();
47 resolve_format_inner(explicit_format, is_tty)
48}
49
50pub(crate) fn truncate(text: &str, max_length: usize) -> String {
61 if text.len() <= max_length {
62 return text.to_string();
63 }
64 let cutoff = max_length.saturating_sub(3);
65 let mut end = cutoff;
67 while end > 0 && !text.is_char_boundary(end) {
68 end -= 1;
69 }
70 format!("{}...", &text[..end])
71}
72
73fn extract_str<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
79 for key in keys {
80 if let Some(s) = v.get(key).and_then(|s| s.as_str()) {
81 return s;
82 }
83 }
84 ""
85}
86
87fn extract_tags(v: &Value) -> Vec<String> {
89 v.get("tags")
90 .and_then(|t| t.as_array())
91 .map(|arr| {
92 arr.iter()
93 .filter_map(|s| s.as_str().map(|s| s.to_string()))
94 .collect()
95 })
96 .unwrap_or_default()
97}
98
99pub fn format_module_list(modules: &[Value], format: &str, filter_tags: &[&str]) -> String {
112 use comfy_table::{ContentArrangement, Table};
113
114 match format {
115 "table" => {
116 if modules.is_empty() {
117 if !filter_tags.is_empty() {
118 return format!(
119 "No modules found matching tags: {}.",
120 filter_tags.join(", ")
121 );
122 }
123 return "No modules found.".to_string();
124 }
125
126 let mut table = Table::new();
127 table.set_content_arrangement(ContentArrangement::Dynamic);
128 table.set_header(vec!["ID", "Description", "Tags"]);
129
130 for m in modules {
131 let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
132 let desc_raw = extract_str(m, &["description"]);
133 let desc = truncate(desc_raw, DESCRIPTION_TRUNCATE_LEN);
134 let tags = extract_tags(m).join(", ");
135 table.add_row(vec![id.to_string(), desc, tags]);
136 }
137
138 table.to_string()
139 }
140 "json" => {
141 let result: Vec<serde_json::Value> = modules
142 .iter()
143 .map(|m| {
144 let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
145 let desc = extract_str(m, &["description"]);
146 let tags: Vec<serde_json::Value> = extract_tags(m)
147 .into_iter()
148 .map(serde_json::Value::String)
149 .collect();
150 serde_json::json!({
151 "id": id,
152 "description": desc,
153 "tags": tags,
154 })
155 })
156 .collect();
157
158 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "[]".to_string())
159 }
160 unknown => {
161 tracing::warn!(
162 "Unknown format '{}' in format_module_list, using json.",
163 unknown
164 );
165 format_module_list(modules, "json", filter_tags)
166 }
167 }
168}
169
170fn render_panel(title: &str) -> String {
176 use comfy_table::Table;
177 let mut table = Table::new();
178 table.load_preset(comfy_table::presets::UTF8_FULL);
179 table.add_row(vec![title]);
180 table.to_string()
181}
182
183fn render_section(title: &str, content: &str) -> Option<String> {
186 if content.is_empty() {
187 return None;
188 }
189 Some(format!("\n{}:\n{}", title, content))
190}
191
192pub fn format_module_detail(module: &Value, format: &str) -> String {
198 let id = extract_str(module, &["module_id", "id", "canonical_id", "name"]);
199 let description = extract_str(module, &["description"]);
200
201 match format {
202 "table" => {
203 let mut parts: Vec<String> = Vec::new();
204
205 parts.push(render_panel(&format!("Module: {}", id)));
207
208 parts.push(format!("\nDescription:\n {}", description));
210
211 if let Some(input_schema) = module.get("input_schema").filter(|v| !v.is_null()) {
213 let content =
214 serde_json::to_string_pretty(input_schema).unwrap_or_else(|_| "{}".to_string());
215 if let Some(section) = render_section("Input Schema", &content) {
216 parts.push(section);
217 }
218 }
219
220 if let Some(output_schema) = module.get("output_schema").filter(|v| !v.is_null()) {
222 let content = serde_json::to_string_pretty(output_schema)
223 .unwrap_or_else(|_| "{}".to_string());
224 if let Some(section) = render_section("Output Schema", &content) {
225 parts.push(section);
226 }
227 }
228
229 if let Some(ann) = module.get("annotations").and_then(|v| v.as_object()) {
231 if !ann.is_empty() {
232 let content: String = ann
233 .iter()
234 .map(|(k, v)| {
235 let val = v.as_str().unwrap_or(&v.to_string()).to_string();
236 format!(" {}: {}", k, val)
237 })
238 .collect::<Vec<_>>()
239 .join("\n");
240 if let Some(section) = render_section("Annotations", &content) {
241 parts.push(section);
242 }
243 }
244 }
245
246 let x_fields: Vec<(String, String)> = module
248 .as_object()
249 .map(|obj| {
250 obj.iter()
251 .filter(|(k, _)| k.starts_with("x-") || k.starts_with("x_"))
252 .map(|(k, v)| {
253 let val = v.as_str().unwrap_or(&v.to_string()).to_string();
254 (k.clone(), val)
255 })
256 .collect()
257 })
258 .unwrap_or_default();
259 if !x_fields.is_empty() {
260 let content: String = x_fields
261 .iter()
262 .map(|(k, v)| format!(" {}: {}", k, v))
263 .collect::<Vec<_>>()
264 .join("\n");
265 if let Some(section) = render_section("Extension Metadata", &content) {
266 parts.push(section);
267 }
268 }
269
270 let tags = extract_tags(module);
272 if !tags.is_empty() {
273 if let Some(section) = render_section("Tags", &format!(" {}", tags.join(", "))) {
274 parts.push(section);
275 }
276 }
277
278 parts.join("\n")
279 }
280 "json" => {
281 let mut result = serde_json::Map::new();
282 result.insert("id".to_string(), serde_json::Value::String(id.to_string()));
283 result.insert(
284 "description".to_string(),
285 serde_json::Value::String(description.to_string()),
286 );
287
288 for key in &["input_schema", "output_schema"] {
290 if let Some(v) = module.get(*key).filter(|v| !v.is_null()) {
291 result.insert(key.to_string(), v.clone());
292 }
293 }
294
295 if let Some(ann) = module
296 .get("annotations")
297 .filter(|v| !v.is_null() && v.as_object().is_some_and(|o| !o.is_empty()))
298 {
299 result.insert("annotations".to_string(), ann.clone());
300 }
301
302 let tags = extract_tags(module);
303 if !tags.is_empty() {
304 result.insert(
305 "tags".to_string(),
306 serde_json::Value::Array(
307 tags.into_iter().map(serde_json::Value::String).collect(),
308 ),
309 );
310 }
311
312 if let Some(obj) = module.as_object() {
314 for (k, v) in obj {
315 if k.starts_with("x-") || k.starts_with("x_") {
316 result.insert(k.clone(), v.clone());
317 }
318 }
319 }
320
321 serde_json::to_string_pretty(&serde_json::Value::Object(result))
322 .unwrap_or_else(|_| "{}".to_string())
323 }
324 unknown => {
325 tracing::warn!(
326 "Unknown format '{}' in format_module_detail, using json.",
327 unknown
328 );
329 format_module_detail(module, "json")
330 }
331 }
332}
333
334pub fn format_exec_result(result: &Value, format: &str) -> String {
344 use comfy_table::{ContentArrangement, Table};
345
346 match result {
347 Value::Null => String::new(),
348
349 Value::String(s) => s.clone(),
350
351 Value::Object(_) if format == "table" => {
352 let obj = result.as_object().unwrap(); let mut table = Table::new();
354 table.set_content_arrangement(ContentArrangement::Dynamic);
355 table.set_header(vec!["Key", "Value"]);
356 for (k, v) in obj {
357 let val_str = match v {
358 Value::String(s) => s.clone(),
359 other => other.to_string(),
360 };
361 table.add_row(vec![k.clone(), val_str]);
362 }
363 table.to_string()
364 }
365
366 Value::Object(_) | Value::Array(_) => {
367 serde_json::to_string_pretty(result).unwrap_or_else(|_| "null".to_string())
368 }
369
370 other => other.to_string(),
372 }
373}
374
375#[cfg(test)]
380mod tests {
381 use super::*;
382 use serde_json::json;
383
384 #[test]
387 fn test_resolve_format_explicit_json_tty() {
388 assert_eq!(resolve_format_inner(Some("json"), true), "json");
390 }
391
392 #[test]
393 fn test_resolve_format_explicit_table_non_tty() {
394 assert_eq!(resolve_format_inner(Some("table"), false), "table");
396 }
397
398 #[test]
399 fn test_resolve_format_none_tty() {
400 assert_eq!(resolve_format_inner(None, true), "table");
402 }
403
404 #[test]
405 fn test_resolve_format_none_non_tty() {
406 assert_eq!(resolve_format_inner(None, false), "json");
408 }
409
410 #[test]
413 fn test_truncate_short_string() {
414 let s = "hello";
415 assert_eq!(truncate(s, 80), "hello");
416 }
417
418 #[test]
419 fn test_truncate_exact_length() {
420 let s = "a".repeat(80);
421 assert_eq!(truncate(&s, 80), s);
422 }
423
424 #[test]
425 fn test_truncate_over_limit() {
426 let s = "a".repeat(100);
427 let result = truncate(&s, 80);
428 assert_eq!(result.len(), 80);
429 assert!(result.ends_with("..."));
430 assert_eq!(&result[..77], &"a".repeat(77));
431 }
432
433 #[test]
434 fn test_truncate_exactly_81_chars() {
435 let s = "b".repeat(81);
436 let result = truncate(&s, 80);
437 assert_eq!(result.len(), 80);
438 assert!(result.ends_with("..."));
439 }
440
441 #[test]
444 fn test_format_module_list_json_two_modules() {
445 let modules = vec![
446 json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]}),
447 json!({"module_id": "text.upper", "description": "Uppercase", "tags": []}),
448 ];
449 let output = format_module_list(&modules, "json", &[]);
450 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
451 let arr = parsed.as_array().expect("must be array");
452 assert_eq!(arr.len(), 2);
453 assert_eq!(arr[0]["id"], "math.add");
454 assert_eq!(arr[1]["id"], "text.upper");
455 }
456
457 #[test]
458 fn test_format_module_list_json_empty() {
459 let output = format_module_list(&[], "json", &[]);
460 assert_eq!(output.trim(), "[]");
461 }
462
463 #[test]
464 fn test_format_module_list_table_two_modules() {
465 let modules =
466 vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]})];
467 let output = format_module_list(&modules, "table", &[]);
468 assert!(output.contains("math.add"), "table must contain module ID");
469 assert!(
470 output.contains("Add numbers"),
471 "table must contain description"
472 );
473 }
474
475 #[test]
476 fn test_format_module_list_table_columns() {
477 let modules =
478 vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": []})];
479 let output = format_module_list(&modules, "table", &[]);
480 assert!(output.contains("ID"), "table must have ID column");
481 assert!(
482 output.contains("Description"),
483 "table must have Description column"
484 );
485 assert!(output.contains("Tags"), "table must have Tags column");
486 }
487
488 #[test]
489 fn test_format_module_list_table_empty_no_tags() {
490 let output = format_module_list(&[], "table", &[]);
491 assert_eq!(output.trim(), "No modules found.");
492 }
493
494 #[test]
495 fn test_format_module_list_table_empty_with_filter_tags() {
496 let output = format_module_list(&[], "table", &["math", "text"]);
497 assert!(
498 output.contains("No modules found matching tags:"),
499 "must contain tag-filter message"
500 );
501 assert!(output.contains("math"), "must contain tag name");
502 assert!(output.contains("text"), "must contain tag name");
503 }
504
505 #[test]
506 fn test_format_module_list_table_description_truncated() {
507 let long_desc = "a".repeat(100);
508 let modules = vec![json!({"module_id": "x.y", "description": long_desc, "tags": []})];
509 let output = format_module_list(&modules, "table", &[]);
510 assert!(
511 output.contains("..."),
512 "long description must be truncated with '...'"
513 );
514 assert!(
515 !output.contains(&"a".repeat(100)),
516 "full description must not appear"
517 );
518 }
519
520 #[test]
521 fn test_format_module_list_json_tags_present() {
522 let modules = vec![json!({"module_id": "a.b", "description": "desc", "tags": ["x", "y"]})];
523 let output = format_module_list(&modules, "json", &[]);
524 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
525 let tags = parsed[0]["tags"].as_array().unwrap();
526 assert_eq!(tags.len(), 2);
527 assert_eq!(tags[0], "x");
528 }
529
530 #[test]
533 fn test_format_exec_result_null_returns_empty() {
534 let output = format_exec_result(&Value::Null, "json");
535 assert_eq!(output, "", "Null result must produce empty string");
536 }
537
538 #[test]
539 fn test_format_exec_result_string_plain() {
540 let result = json!("hello world");
541 let output = format_exec_result(&result, "json");
542 assert_eq!(output, "hello world");
543 }
544
545 #[test]
546 fn test_format_exec_result_string_table_mode_also_plain() {
547 let result = json!("hello");
549 let output = format_exec_result(&result, "table");
550 assert_eq!(output, "hello");
551 }
552
553 #[test]
554 fn test_format_exec_result_object_json_mode() {
555 let result = json!({"sum": 42, "status": "ok"});
556 let output = format_exec_result(&result, "json");
557 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
558 assert_eq!(parsed["sum"], 42);
559 assert_eq!(parsed["status"], "ok");
560 }
561
562 #[test]
563 fn test_format_exec_result_object_table_mode() {
564 let result = json!({"key": "value", "count": 3});
565 let output = format_exec_result(&result, "table");
566 assert!(output.contains("key"), "table must contain 'key'");
568 assert!(output.contains("value"), "table must contain 'value'");
569 assert!(output.contains("count"), "table must contain 'count'");
570 assert!(output.contains('3'), "table must contain '3'");
571 }
572
573 #[test]
574 fn test_format_exec_result_array_is_json() {
575 let result = json!([1, 2, 3]);
576 let output = format_exec_result(&result, "json");
577 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
578 assert!(parsed.is_array());
579 assert_eq!(parsed.as_array().unwrap().len(), 3);
580 }
581
582 #[test]
583 fn test_format_exec_result_array_table_mode_is_json() {
584 let result = json!([{"a": 1}, {"b": 2}]);
586 let output = format_exec_result(&result, "table");
587 let parsed: serde_json::Value =
588 serde_json::from_str(&output).expect("array must produce JSON");
589 assert!(parsed.is_array());
590 }
591
592 #[test]
593 fn test_format_exec_result_number_scalar() {
594 let result = json!(42);
595 let output = format_exec_result(&result, "json");
596 assert_eq!(output, "42");
597 }
598
599 #[test]
600 fn test_format_exec_result_bool_scalar() {
601 let result = json!(true);
602 let output = format_exec_result(&result, "json");
603 assert_eq!(output, "true");
604 }
605
606 #[test]
607 fn test_format_exec_result_float_scalar() {
608 let result = json!(3.15);
609 let output = format_exec_result(&result, "json");
610 assert!(output.starts_with("3.15"), "float must stringify correctly");
611 }
612
613 #[test]
616 fn test_format_module_detail_json_full() {
617 let module = json!({
618 "module_id": "math.add",
619 "description": "Add two numbers",
620 "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
621 "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
622 "tags": ["math"],
623 "annotations": {"author": "test"}
624 });
625 let output = format_module_detail(&module, "json");
626 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
627 assert_eq!(parsed["id"], "math.add");
628 assert_eq!(parsed["description"], "Add two numbers");
629 assert!(
630 parsed.get("input_schema").is_some(),
631 "input_schema must be present"
632 );
633 assert!(
634 parsed.get("output_schema").is_some(),
635 "output_schema must be present"
636 );
637 let tags = parsed["tags"].as_array().unwrap();
638 assert_eq!(tags[0], "math");
639 }
640
641 #[test]
642 fn test_format_module_detail_json_no_output_schema() {
643 let module = json!({
644 "module_id": "text.upper",
645 "description": "Uppercase",
646 });
647 let output = format_module_detail(&module, "json");
648 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
649 assert!(
650 parsed.get("output_schema").is_none(),
651 "output_schema must be absent when not set"
652 );
653 }
654
655 #[test]
656 fn test_format_module_detail_json_no_none_fields() {
657 let module = json!({
658 "module_id": "a.b",
659 "description": "desc",
660 "input_schema": null,
661 "output_schema": null,
662 "tags": null,
663 });
664 let output = format_module_detail(&module, "json");
665 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
666 assert!(
667 parsed.get("input_schema").is_none(),
668 "null input_schema must be absent"
669 );
670 assert!(parsed.get("tags").is_none(), "null tags must be absent");
671 }
672
673 #[test]
674 fn test_format_module_detail_table_contains_description() {
675 let module = json!({
676 "module_id": "math.add",
677 "description": "Add two numbers",
678 });
679 let output = format_module_detail(&module, "table");
680 assert!(
681 output.contains("Add two numbers"),
682 "table must contain description"
683 );
684 }
685
686 #[test]
687 fn test_format_module_detail_table_contains_module_id() {
688 let module = json!({
689 "module_id": "math.add",
690 "description": "desc",
691 });
692 let output = format_module_detail(&module, "table");
693 assert!(output.contains("math.add"), "table must contain module ID");
694 }
695
696 #[test]
697 fn test_format_module_detail_table_input_schema_section() {
698 let module = json!({
699 "module_id": "math.add",
700 "description": "desc",
701 "input_schema": {"type": "object"}
702 });
703 let output = format_module_detail(&module, "table");
704 assert!(
705 output.contains("Input Schema"),
706 "table must contain Input Schema section"
707 );
708 }
709
710 #[test]
711 fn test_format_module_detail_table_no_output_schema_section_when_absent() {
712 let module = json!({
713 "module_id": "text.upper",
714 "description": "desc",
715 });
716 let output = format_module_detail(&module, "table");
717 assert!(
718 !output.contains("Output Schema"),
719 "Output Schema section must be absent when not set"
720 );
721 }
722
723 #[test]
724 fn test_format_module_detail_table_tags_section() {
725 let module = json!({
726 "module_id": "math.add",
727 "description": "desc",
728 "tags": ["math", "arithmetic"]
729 });
730 let output = format_module_detail(&module, "table");
731 assert!(output.contains("Tags"), "table must contain Tags section");
732 assert!(output.contains("math"), "table must contain tag value");
733 }
734
735 #[test]
736 fn test_format_module_detail_table_annotations_section() {
737 let module = json!({
738 "module_id": "a.b",
739 "description": "desc",
740 "annotations": {"author": "alice", "version": "1.0"}
741 });
742 let output = format_module_detail(&module, "table");
743 assert!(
744 output.contains("Annotations"),
745 "table must contain Annotations section"
746 );
747 assert!(
748 output.contains("author"),
749 "table must contain annotation key"
750 );
751 assert!(
752 output.contains("alice"),
753 "table must contain annotation value"
754 );
755 }
756
757 #[test]
758 fn test_format_module_detail_table_extension_metadata() {
759 let module = json!({
760 "module_id": "a.b",
761 "description": "desc",
762 "x-category": "utility"
763 });
764 let output = format_module_detail(&module, "table");
765 assert!(
766 output.contains("Extension Metadata"),
767 "must contain Extension Metadata section"
768 );
769 assert!(output.contains("x-category"), "must contain x- key");
770 assert!(output.contains("utility"), "must contain x- value");
771 }
772}