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 "csv" => "csv",
25 "yaml" => "yaml",
26 "jsonl" => "jsonl",
27 other => {
28 tracing::warn!("Unknown format '{}', defaulting to 'json'.", other);
31 "json"
32 }
33 };
34 }
35 if is_tty {
36 "table"
37 } else {
38 "json"
39 }
40}
41
42pub fn resolve_format(explicit_format: Option<&str>) -> &'static str {
49 let is_tty = std::io::stdout().is_terminal();
50 resolve_format_inner(explicit_format, is_tty)
51}
52
53pub(crate) fn truncate(text: &str, max_length: usize) -> String {
64 if text.len() <= max_length {
65 return text.to_string();
66 }
67 let cutoff = max_length.saturating_sub(3);
68 let mut end = cutoff;
70 while end > 0 && !text.is_char_boundary(end) {
71 end -= 1;
72 }
73 format!("{}...", &text[..end])
74}
75
76fn extract_str<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
82 for key in keys {
83 if let Some(s) = v.get(key).and_then(|s| s.as_str()) {
84 return s;
85 }
86 }
87 ""
88}
89
90fn extract_tags(v: &Value) -> Vec<String> {
92 v.get("tags")
93 .and_then(|t| t.as_array())
94 .map(|arr| {
95 arr.iter()
96 .filter_map(|s| s.as_str().map(|s| s.to_string()))
97 .collect()
98 })
99 .unwrap_or_default()
100}
101
102fn csv_scalar_string(v: &Value) -> String {
107 match v {
108 Value::String(s) => s.clone(),
109 other => other.to_string(),
110 }
111}
112
113fn csv_field(s: &str) -> String {
118 if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
119 let escaped = s.replace('"', "\"\"");
120 format!("\"{escaped}\"")
121 } else {
122 s.to_string()
123 }
124}
125
126pub fn format_module_list(modules: &[Value], format: &str, filter_tags: &[&str]) -> String {
139 use comfy_table::{ContentArrangement, Table};
140
141 match format {
142 "table" => {
143 if modules.is_empty() {
144 if !filter_tags.is_empty() {
145 return format!(
146 "No modules found matching tags: {}.",
147 filter_tags.join(", ")
148 );
149 }
150 return "No modules found.".to_string();
151 }
152
153 let mut table = Table::new();
154 table.set_content_arrangement(ContentArrangement::Dynamic);
155 table.set_header(vec!["ID", "Description", "Tags"]);
156
157 for m in modules {
158 let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
159 let desc_raw = extract_str(m, &["description"]);
160 let desc = truncate(desc_raw, DESCRIPTION_TRUNCATE_LEN);
161 let tags = extract_tags(m).join(", ");
162 table.add_row(vec![id.to_string(), desc, tags]);
163 }
164
165 table.to_string()
166 }
167 "json" => {
168 let result: Vec<serde_json::Value> = modules
169 .iter()
170 .map(|m| {
171 let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
172 let desc = extract_str(m, &["description"]);
173 let tags: Vec<serde_json::Value> = extract_tags(m)
174 .into_iter()
175 .map(serde_json::Value::String)
176 .collect();
177 serde_json::json!({
178 "id": id,
179 "description": desc,
180 "tags": tags,
181 })
182 })
183 .collect();
184
185 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "[]".to_string())
186 }
187 unknown => {
188 tracing::warn!(
189 "Unknown format '{}' in format_module_list, using json.",
190 unknown
191 );
192 format_module_list(modules, "json", filter_tags)
193 }
194 }
195}
196
197fn render_panel(title: &str) -> String {
203 use comfy_table::Table;
204 let mut table = Table::new();
205 table.load_preset(comfy_table::presets::UTF8_FULL);
206 table.add_row(vec![title]);
207 table.to_string()
208}
209
210fn render_section(title: &str, content: &str) -> Option<String> {
213 if content.is_empty() {
214 return None;
215 }
216 Some(format!("\n{}:\n{}", title, content))
217}
218
219pub fn format_module_detail(module: &Value, format: &str) -> String {
225 let id = extract_str(module, &["module_id", "id", "canonical_id", "name"]);
226 let description = extract_str(module, &["description"]);
227
228 match format {
229 "table" => {
230 let mut parts: Vec<String> = Vec::new();
231
232 parts.push(render_panel(&format!("Module: {}", id)));
234
235 parts.push(format!("\nDescription:\n {}", description));
237
238 if let Some(input_schema) = module.get("input_schema").filter(|v| !v.is_null()) {
240 let content =
241 serde_json::to_string_pretty(input_schema).unwrap_or_else(|_| "{}".to_string());
242 if let Some(section) = render_section("Input Schema", &content) {
243 parts.push(section);
244 }
245 }
246
247 if let Some(output_schema) = module.get("output_schema").filter(|v| !v.is_null()) {
249 let content = serde_json::to_string_pretty(output_schema)
250 .unwrap_or_else(|_| "{}".to_string());
251 if let Some(section) = render_section("Output Schema", &content) {
252 parts.push(section);
253 }
254 }
255
256 if let Some(ann) = module.get("annotations").and_then(|v| v.as_object()) {
258 if !ann.is_empty() {
259 let content: String = ann
260 .iter()
261 .map(|(k, v)| {
262 let val = v.as_str().unwrap_or(&v.to_string()).to_string();
263 format!(" {}: {}", k, val)
264 })
265 .collect::<Vec<_>>()
266 .join("\n");
267 if let Some(section) = render_section("Annotations", &content) {
268 parts.push(section);
269 }
270 }
271 }
272
273 let x_fields: Vec<(String, String)> = module
275 .as_object()
276 .map(|obj| {
277 obj.iter()
278 .filter(|(k, _)| k.starts_with("x-") || k.starts_with("x_"))
279 .map(|(k, v)| {
280 let val = v.as_str().unwrap_or(&v.to_string()).to_string();
281 (k.clone(), val)
282 })
283 .collect()
284 })
285 .unwrap_or_default();
286 if !x_fields.is_empty() {
287 let content: String = x_fields
288 .iter()
289 .map(|(k, v)| format!(" {}: {}", k, v))
290 .collect::<Vec<_>>()
291 .join("\n");
292 if let Some(section) = render_section("Extension Metadata", &content) {
293 parts.push(section);
294 }
295 }
296
297 let tags = extract_tags(module);
299 if !tags.is_empty() {
300 if let Some(section) = render_section("Tags", &format!(" {}", tags.join(", "))) {
301 parts.push(section);
302 }
303 }
304
305 parts.join("\n")
306 }
307 "json" => {
308 let mut result = serde_json::Map::new();
309 result.insert("id".to_string(), serde_json::Value::String(id.to_string()));
310 result.insert(
311 "description".to_string(),
312 serde_json::Value::String(description.to_string()),
313 );
314
315 for key in &["input_schema", "output_schema"] {
317 if let Some(v) = module.get(*key).filter(|v| !v.is_null()) {
318 result.insert(key.to_string(), v.clone());
319 }
320 }
321
322 if let Some(ann) = module
323 .get("annotations")
324 .filter(|v| !v.is_null() && v.as_object().is_some_and(|o| !o.is_empty()))
325 {
326 result.insert("annotations".to_string(), ann.clone());
327 }
328
329 let tags = extract_tags(module);
330 if !tags.is_empty() {
331 result.insert(
332 "tags".to_string(),
333 serde_json::Value::Array(
334 tags.into_iter().map(serde_json::Value::String).collect(),
335 ),
336 );
337 }
338
339 if let Some(obj) = module.as_object() {
341 for (k, v) in obj {
342 if k.starts_with("x-") || k.starts_with("x_") {
343 result.insert(k.clone(), v.clone());
344 }
345 }
346 }
347
348 serde_json::to_string_pretty(&serde_json::Value::Object(result))
349 .unwrap_or_else(|_| "{}".to_string())
350 }
351 unknown => {
352 tracing::warn!(
353 "Unknown format '{}' in format_module_detail, using json.",
354 unknown
355 );
356 format_module_detail(module, "json")
357 }
358 }
359}
360
361fn apply_field_selection(result: &Value, fields: &str) -> Value {
370 if let Some(obj) = result.as_object() {
371 let mut selected = serde_json::Map::new();
372 for field in fields.split(',') {
373 let field = field.trim();
374 if field.is_empty() {
375 continue;
376 }
377 let mut val: &Value = &Value::Object(obj.clone());
378 for part in field.split('.') {
379 if let Some(next) = val.get(part) {
380 val = next;
381 } else {
382 val = &Value::Null;
383 break;
384 }
385 }
386 selected.insert(field.to_string(), val.clone());
387 }
388 Value::Object(selected)
389 } else {
390 result.clone()
391 }
392}
393
394pub fn format_exec_result(result: &Value, format: &str, fields: Option<&str>) -> String {
401 use comfy_table::{ContentArrangement, Table};
402
403 let result = if let Some(f) = fields {
404 apply_field_selection(result, f)
405 } else {
406 result.clone()
407 };
408
409 match &result {
410 Value::Null => String::new(),
411
412 Value::String(s) => s.clone(),
413
414 Value::Object(_) if format == "csv" => {
415 let obj = result.as_object().unwrap();
416 let keys: Vec<&String> = obj.keys().collect();
417 let header = keys
418 .iter()
419 .map(|k| csv_field(k.as_str()))
420 .collect::<Vec<_>>()
421 .join(",");
422 let values = keys
423 .iter()
424 .map(|k| {
425 let v = obj.get(*k).unwrap();
426 csv_field(&csv_scalar_string(v))
427 })
428 .collect::<Vec<_>>()
429 .join(",");
430 format!("{header}\n{values}")
431 }
432
433 Value::Array(arr) if format == "csv" => {
434 if arr.is_empty() {
435 return String::new();
436 }
437 if let Some(first_obj) = arr[0].as_object() {
438 let keys: Vec<&String> = first_obj.keys().collect();
439 let header = keys
440 .iter()
441 .map(|k| csv_field(k.as_str()))
442 .collect::<Vec<_>>()
443 .join(",");
444 let mut rows = vec![header];
445 for item in arr {
446 if let Some(obj) = item.as_object() {
447 let row = keys
448 .iter()
449 .map(|k| {
450 let v = obj.get(*k).unwrap_or(&Value::Null);
451 csv_field(&csv_scalar_string(v))
452 })
453 .collect::<Vec<_>>()
454 .join(",");
455 rows.push(row);
456 }
457 }
458 rows.join("\n")
459 } else {
460 serde_json::to_string(&result).unwrap_or_default()
461 }
462 }
463
464 _ if format == "yaml" => serde_yaml::to_string(&result)
465 .map(|s| s.trim_end().to_string())
466 .unwrap_or_else(|_| {
467 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
468 }),
469
470 Value::Array(arr) if format == "jsonl" => arr
471 .iter()
472 .map(|item| serde_json::to_string(item).unwrap_or_default())
473 .collect::<Vec<_>>()
474 .join("\n"),
475
476 _ if format == "jsonl" => serde_json::to_string(&result).unwrap_or_default(),
477
478 Value::Object(_) if format == "table" => {
479 let obj = result.as_object().unwrap();
480 let mut table = Table::new();
481 table.set_content_arrangement(ContentArrangement::Dynamic);
482 table.set_header(vec!["Key", "Value"]);
483 for (k, v) in obj {
484 let val_str = match v {
485 Value::String(s) => s.clone(),
486 other => other.to_string(),
487 };
488 table.add_row(vec![k.clone(), val_str]);
489 }
490 table.to_string()
491 }
492
493 Value::Object(_) | Value::Array(_) => {
494 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
495 }
496
497 other => other.to_string(),
499 }
500}
501
502#[cfg(test)]
507mod tests {
508 use super::*;
509 use serde_json::json;
510
511 #[test]
514 fn test_resolve_format_explicit_json_tty() {
515 assert_eq!(resolve_format_inner(Some("json"), true), "json");
517 }
518
519 #[test]
520 fn test_resolve_format_explicit_table_non_tty() {
521 assert_eq!(resolve_format_inner(Some("table"), false), "table");
523 }
524
525 #[test]
526 fn test_resolve_format_none_tty() {
527 assert_eq!(resolve_format_inner(None, true), "table");
529 }
530
531 #[test]
532 fn test_resolve_format_none_non_tty() {
533 assert_eq!(resolve_format_inner(None, false), "json");
535 }
536
537 #[test]
540 fn test_truncate_short_string() {
541 let s = "hello";
542 assert_eq!(truncate(s, 80), "hello");
543 }
544
545 #[test]
546 fn test_truncate_exact_length() {
547 let s = "a".repeat(80);
548 assert_eq!(truncate(&s, 80), s);
549 }
550
551 #[test]
552 fn test_truncate_over_limit() {
553 let s = "a".repeat(100);
554 let result = truncate(&s, 80);
555 assert_eq!(result.len(), 80);
556 assert!(result.ends_with("..."));
557 assert_eq!(&result[..77], &"a".repeat(77));
558 }
559
560 #[test]
561 fn test_truncate_exactly_81_chars() {
562 let s = "b".repeat(81);
563 let result = truncate(&s, 80);
564 assert_eq!(result.len(), 80);
565 assert!(result.ends_with("..."));
566 }
567
568 #[test]
571 fn test_format_module_list_json_two_modules() {
572 let modules = vec![
573 json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]}),
574 json!({"module_id": "text.upper", "description": "Uppercase", "tags": []}),
575 ];
576 let output = format_module_list(&modules, "json", &[]);
577 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
578 let arr = parsed.as_array().expect("must be array");
579 assert_eq!(arr.len(), 2);
580 assert_eq!(arr[0]["id"], "math.add");
581 assert_eq!(arr[1]["id"], "text.upper");
582 }
583
584 #[test]
585 fn test_format_module_list_json_empty() {
586 let output = format_module_list(&[], "json", &[]);
587 assert_eq!(output.trim(), "[]");
588 }
589
590 #[test]
591 fn test_format_module_list_table_two_modules() {
592 let modules =
593 vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]})];
594 let output = format_module_list(&modules, "table", &[]);
595 assert!(output.contains("math.add"), "table must contain module ID");
596 assert!(
597 output.contains("Add numbers"),
598 "table must contain description"
599 );
600 }
601
602 #[test]
603 fn test_format_module_list_table_columns() {
604 let modules =
605 vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": []})];
606 let output = format_module_list(&modules, "table", &[]);
607 assert!(output.contains("ID"), "table must have ID column");
608 assert!(
609 output.contains("Description"),
610 "table must have Description column"
611 );
612 assert!(output.contains("Tags"), "table must have Tags column");
613 }
614
615 #[test]
616 fn test_format_module_list_table_empty_no_tags() {
617 let output = format_module_list(&[], "table", &[]);
618 assert_eq!(output.trim(), "No modules found.");
619 }
620
621 #[test]
622 fn test_format_module_list_table_empty_with_filter_tags() {
623 let output = format_module_list(&[], "table", &["math", "text"]);
624 assert!(
625 output.contains("No modules found matching tags:"),
626 "must contain tag-filter message"
627 );
628 assert!(output.contains("math"), "must contain tag name");
629 assert!(output.contains("text"), "must contain tag name");
630 }
631
632 #[test]
633 fn test_format_module_list_table_description_truncated() {
634 let long_desc = "a".repeat(100);
635 let modules = vec![json!({"module_id": "x.y", "description": long_desc, "tags": []})];
636 let output = format_module_list(&modules, "table", &[]);
637 assert!(
638 output.contains("..."),
639 "long description must be truncated with '...'"
640 );
641 assert!(
642 !output.contains(&"a".repeat(100)),
643 "full description must not appear"
644 );
645 }
646
647 #[test]
648 fn test_format_module_list_json_tags_present() {
649 let modules = vec![json!({"module_id": "a.b", "description": "desc", "tags": ["x", "y"]})];
650 let output = format_module_list(&modules, "json", &[]);
651 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
652 let tags = parsed[0]["tags"].as_array().unwrap();
653 assert_eq!(tags.len(), 2);
654 assert_eq!(tags[0], "x");
655 }
656
657 #[test]
660 fn test_format_exec_result_null_returns_empty() {
661 let output = format_exec_result(&Value::Null, "json", None);
662 assert_eq!(output, "", "Null result must produce empty string");
663 }
664
665 #[test]
666 fn test_format_exec_result_string_plain() {
667 let result = json!("hello world");
668 let output = format_exec_result(&result, "json", None);
669 assert_eq!(output, "hello world");
670 }
671
672 #[test]
673 fn test_format_exec_result_string_table_mode_also_plain() {
674 let result = json!("hello");
676 let output = format_exec_result(&result, "table", None);
677 assert_eq!(output, "hello");
678 }
679
680 #[test]
681 fn test_format_exec_result_object_json_mode() {
682 let result = json!({"sum": 42, "status": "ok"});
683 let output = format_exec_result(&result, "json", None);
684 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
685 assert_eq!(parsed["sum"], 42);
686 assert_eq!(parsed["status"], "ok");
687 }
688
689 #[test]
690 fn test_format_exec_result_object_table_mode() {
691 let result = json!({"key": "value", "count": 3});
692 let output = format_exec_result(&result, "table", None);
693 assert!(output.contains("key"), "table must contain 'key'");
695 assert!(output.contains("value"), "table must contain 'value'");
696 assert!(output.contains("count"), "table must contain 'count'");
697 assert!(output.contains('3'), "table must contain '3'");
698 }
699
700 #[test]
701 fn test_format_exec_result_array_is_json() {
702 let result = json!([1, 2, 3]);
703 let output = format_exec_result(&result, "json", None);
704 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
705 assert!(parsed.is_array());
706 assert_eq!(parsed.as_array().unwrap().len(), 3);
707 }
708
709 #[test]
710 fn test_format_exec_result_array_table_mode_is_json() {
711 let result = json!([{"a": 1}, {"b": 2}]);
713 let output = format_exec_result(&result, "table", None);
714 let parsed: serde_json::Value =
715 serde_json::from_str(&output).expect("array must produce JSON");
716 assert!(parsed.is_array());
717 }
718
719 #[test]
720 fn test_format_exec_result_number_scalar() {
721 let result = json!(42);
722 let output = format_exec_result(&result, "json", None);
723 assert_eq!(output, "42");
724 }
725
726 #[test]
727 fn test_format_exec_result_bool_scalar() {
728 let result = json!(true);
729 let output = format_exec_result(&result, "json", None);
730 assert_eq!(output, "true");
731 }
732
733 #[test]
734 fn test_format_exec_result_float_scalar() {
735 let result = json!(3.15);
736 let output = format_exec_result(&result, "json", None);
737 assert!(output.starts_with("3.15"), "float must stringify correctly");
738 }
739
740 #[test]
743 fn test_format_module_detail_json_full() {
744 let module = json!({
745 "module_id": "math.add",
746 "description": "Add two numbers",
747 "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
748 "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
749 "tags": ["math"],
750 "annotations": {"author": "test"}
751 });
752 let output = format_module_detail(&module, "json");
753 let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
754 assert_eq!(parsed["id"], "math.add");
755 assert_eq!(parsed["description"], "Add two numbers");
756 assert!(
757 parsed.get("input_schema").is_some(),
758 "input_schema must be present"
759 );
760 assert!(
761 parsed.get("output_schema").is_some(),
762 "output_schema must be present"
763 );
764 let tags = parsed["tags"].as_array().unwrap();
765 assert_eq!(tags[0], "math");
766 }
767
768 #[test]
769 fn test_format_module_detail_json_no_output_schema() {
770 let module = json!({
771 "module_id": "text.upper",
772 "description": "Uppercase",
773 });
774 let output = format_module_detail(&module, "json");
775 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
776 assert!(
777 parsed.get("output_schema").is_none(),
778 "output_schema must be absent when not set"
779 );
780 }
781
782 #[test]
783 fn test_format_module_detail_json_no_none_fields() {
784 let module = json!({
785 "module_id": "a.b",
786 "description": "desc",
787 "input_schema": null,
788 "output_schema": null,
789 "tags": null,
790 });
791 let output = format_module_detail(&module, "json");
792 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
793 assert!(
794 parsed.get("input_schema").is_none(),
795 "null input_schema must be absent"
796 );
797 assert!(parsed.get("tags").is_none(), "null tags must be absent");
798 }
799
800 #[test]
801 fn test_format_module_detail_table_contains_description() {
802 let module = json!({
803 "module_id": "math.add",
804 "description": "Add two numbers",
805 });
806 let output = format_module_detail(&module, "table");
807 assert!(
808 output.contains("Add two numbers"),
809 "table must contain description"
810 );
811 }
812
813 #[test]
814 fn test_format_module_detail_table_contains_module_id() {
815 let module = json!({
816 "module_id": "math.add",
817 "description": "desc",
818 });
819 let output = format_module_detail(&module, "table");
820 assert!(output.contains("math.add"), "table must contain module ID");
821 }
822
823 #[test]
824 fn test_format_module_detail_table_input_schema_section() {
825 let module = json!({
826 "module_id": "math.add",
827 "description": "desc",
828 "input_schema": {"type": "object"}
829 });
830 let output = format_module_detail(&module, "table");
831 assert!(
832 output.contains("Input Schema"),
833 "table must contain Input Schema section"
834 );
835 }
836
837 #[test]
838 fn test_format_module_detail_table_no_output_schema_section_when_absent() {
839 let module = json!({
840 "module_id": "text.upper",
841 "description": "desc",
842 });
843 let output = format_module_detail(&module, "table");
844 assert!(
845 !output.contains("Output Schema"),
846 "Output Schema section must be absent when not set"
847 );
848 }
849
850 #[test]
851 fn test_format_module_detail_table_tags_section() {
852 let module = json!({
853 "module_id": "math.add",
854 "description": "desc",
855 "tags": ["math", "arithmetic"]
856 });
857 let output = format_module_detail(&module, "table");
858 assert!(output.contains("Tags"), "table must contain Tags section");
859 assert!(output.contains("math"), "table must contain tag value");
860 }
861
862 #[test]
863 fn test_format_module_detail_table_annotations_section() {
864 let module = json!({
865 "module_id": "a.b",
866 "description": "desc",
867 "annotations": {"author": "alice", "version": "1.0"}
868 });
869 let output = format_module_detail(&module, "table");
870 assert!(
871 output.contains("Annotations"),
872 "table must contain Annotations section"
873 );
874 assert!(
875 output.contains("author"),
876 "table must contain annotation key"
877 );
878 assert!(
879 output.contains("alice"),
880 "table must contain annotation value"
881 );
882 }
883
884 #[test]
885 fn test_format_module_detail_table_extension_metadata() {
886 let module = json!({
887 "module_id": "a.b",
888 "description": "desc",
889 "x-category": "utility"
890 });
891 let output = format_module_detail(&module, "table");
892 assert!(
893 output.contains("Extension Metadata"),
894 "must contain Extension Metadata section"
895 );
896 assert!(output.contains("x-category"), "must contain x- key");
897 assert!(output.contains("utility"), "must contain x- value");
898 }
899}