Skip to main content

apcore_cli/
output.rs

1// apcore-cli — TTY-adaptive output formatting.
2// Protocol spec: FE-04 (format_module_list, format_module_detail,
3//                        format_exec_result, resolve_format)
4
5use serde_json::Value;
6use std::io::IsTerminal;
7
8// ---------------------------------------------------------------------------
9// Constants
10// ---------------------------------------------------------------------------
11
12pub(crate) const DESCRIPTION_TRUNCATE_LEN: usize = 80;
13
14// ---------------------------------------------------------------------------
15// resolve_format
16// ---------------------------------------------------------------------------
17
18/// Private inner: accepts explicit TTY state for testability.
19pub(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                // Unknown format: log a warning and fall back to json.
29                // (Invalid values are caught by clap upstream; this is a safety net.)
30                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
42/// Determine the output format to use.
43///
44/// Resolution order:
45/// 1. `explicit_format` if `Some`.
46/// 2. `"table"` when stdout is a TTY.
47/// 3. `"json"` otherwise.
48pub 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
53// ---------------------------------------------------------------------------
54// truncate
55// ---------------------------------------------------------------------------
56
57/// Truncate `text` to at most `max_length` characters.
58///
59/// If truncation occurs, the last 3 characters are replaced with `"..."`.
60/// Uses char-boundary-safe truncation to handle Unicode correctly: byte length
61/// is used for the boundary check (matching Python's `len()` on ASCII-dominant
62/// module descriptions), but slicing respects char boundaries.
63pub(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    // Walk back from cutoff to find a valid char boundary.
69    let mut end = cutoff;
70    while end > 0 && !text.is_char_boundary(end) {
71        end -= 1;
72    }
73    format!("{}...", &text[..end])
74}
75
76// ---------------------------------------------------------------------------
77// format_module_list helpers
78// ---------------------------------------------------------------------------
79
80/// Extract a string field from a JSON module descriptor with fallback keys.
81fn 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
90/// Extract tags array from a JSON module descriptor. Returns empty Vec on missing/invalid.
91fn 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
102// ---------------------------------------------------------------------------
103// format_module_list
104// ---------------------------------------------------------------------------
105
106/// Render a list of module descriptors as a table or JSON.
107///
108/// # Arguments
109/// * `modules`      — slice of `serde_json::Value` objects (module descriptors)
110/// * `format`       — `"table"` or `"json"`
111/// * `filter_tags`  — AND-filter: only modules that have ALL listed tags are shown
112///
113/// Returns the formatted string ready for printing to stdout.
114pub fn format_module_list(modules: &[Value], format: &str, filter_tags: &[&str]) -> String {
115    format_module_list_with_deps(modules, format, filter_tags, false)
116}
117
118/// Format module list with optional dependency count column.
119pub fn format_module_list_with_deps(
120    modules: &[Value],
121    format: &str,
122    filter_tags: &[&str],
123    show_deps: bool,
124) -> String {
125    use comfy_table::{ContentArrangement, Table};
126
127    match format {
128        "table" => {
129            if modules.is_empty() {
130                if !filter_tags.is_empty() {
131                    return format!(
132                        "No modules found matching tags: {}.",
133                        filter_tags.join(", ")
134                    );
135                }
136                return "No modules found.".to_string();
137            }
138
139            let mut table = Table::new();
140            table.set_content_arrangement(ContentArrangement::Dynamic);
141            if show_deps {
142                table.set_header(vec!["ID", "Description", "Tags", "Deps"]);
143            } else {
144                table.set_header(vec!["ID", "Description", "Tags"]);
145            }
146
147            for m in modules {
148                let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
149                let desc_raw = extract_str(m, &["description"]);
150                let desc = truncate(desc_raw, DESCRIPTION_TRUNCATE_LEN);
151                let tags = extract_tags(m).join(", ");
152                if show_deps {
153                    let dep_count = m
154                        .get("dependencies")
155                        .and_then(|v| v.as_array())
156                        .map_or(0, |a| a.len());
157                    table.add_row(vec![id.to_string(), desc, tags, dep_count.to_string()]);
158                } else {
159                    table.add_row(vec![id.to_string(), desc, tags]);
160                }
161            }
162
163            table.to_string()
164        }
165        "json" => {
166            let result: Vec<serde_json::Value> = modules
167                .iter()
168                .map(|m| {
169                    let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
170                    let desc = extract_str(m, &["description"]);
171                    let tags: Vec<serde_json::Value> = extract_tags(m)
172                        .into_iter()
173                        .map(serde_json::Value::String)
174                        .collect();
175                    serde_json::json!({
176                        "id": id,
177                        "description": desc,
178                        "tags": tags,
179                    })
180                })
181                .collect();
182
183            serde_json::to_string_pretty(&result).unwrap_or_else(|_| "[]".to_string())
184        }
185        unknown => {
186            tracing::warn!(
187                "Unknown format '{}' in format_module_list, using json.",
188                unknown
189            );
190            format_module_list(modules, "json", filter_tags)
191        }
192    }
193}
194
195// ---------------------------------------------------------------------------
196// format_module_detail
197// ---------------------------------------------------------------------------
198
199/// Render a minimal bordered panel heading. Returns a String with a box around `title`.
200fn render_panel(title: &str) -> String {
201    use comfy_table::Table;
202    let mut table = Table::new();
203    table.load_preset(comfy_table::presets::UTF8_FULL);
204    table.add_row(vec![title]);
205    table.to_string()
206}
207
208/// Render an optional section with a label and preformatted content.
209/// Returns None if content is empty.
210fn render_section(title: &str, content: &str) -> Option<String> {
211    if content.is_empty() {
212        return None;
213    }
214    Some(format!("\n{}:\n{}", title, content))
215}
216
217/// Render a single module descriptor with its full schema.
218///
219/// # Arguments
220/// * `module` — `serde_json::Value` module descriptor
221/// * `format` — `"table"` or `"json"`
222pub fn format_module_detail(module: &Value, format: &str) -> String {
223    let id = extract_str(module, &["module_id", "id", "canonical_id", "name"]);
224    let description = extract_str(module, &["description"]);
225
226    match format {
227        "table" => {
228            let mut parts: Vec<String> = Vec::new();
229
230            // Header panel.
231            parts.push(render_panel(&format!("Module: {}", id)));
232
233            // Description.
234            parts.push(format!("\nDescription:\n  {}", description));
235
236            // Input schema.
237            if let Some(input_schema) = module.get("input_schema").filter(|v| !v.is_null()) {
238                let content =
239                    serde_json::to_string_pretty(input_schema).unwrap_or_else(|_| "{}".to_string());
240                if let Some(section) = render_section("Input Schema", &content) {
241                    parts.push(section);
242                }
243            }
244
245            // Output schema.
246            if let Some(output_schema) = module.get("output_schema").filter(|v| !v.is_null()) {
247                let content = serde_json::to_string_pretty(output_schema)
248                    .unwrap_or_else(|_| "{}".to_string());
249                if let Some(section) = render_section("Output Schema", &content) {
250                    parts.push(section);
251                }
252            }
253
254            // Annotations.
255            if let Some(ann) = module.get("annotations").and_then(|v| v.as_object()) {
256                if !ann.is_empty() {
257                    let content: String = ann
258                        .iter()
259                        .map(|(k, v)| {
260                            let val = v.as_str().unwrap_or(&v.to_string()).to_string();
261                            format!("  {}: {}", k, val)
262                        })
263                        .collect::<Vec<_>>()
264                        .join("\n");
265                    if let Some(section) = render_section("Annotations", &content) {
266                        parts.push(section);
267                    }
268                }
269            }
270
271            // Extension metadata (x- or x_ prefixed keys at the top level).
272            let x_fields: Vec<(String, String)> = module
273                .as_object()
274                .map(|obj| {
275                    obj.iter()
276                        .filter(|(k, _)| k.starts_with("x-") || k.starts_with("x_"))
277                        .map(|(k, v)| {
278                            let val = v.as_str().unwrap_or(&v.to_string()).to_string();
279                            (k.clone(), val)
280                        })
281                        .collect()
282                })
283                .unwrap_or_default();
284            if !x_fields.is_empty() {
285                let content: String = x_fields
286                    .iter()
287                    .map(|(k, v)| format!("  {}: {}", k, v))
288                    .collect::<Vec<_>>()
289                    .join("\n");
290                if let Some(section) = render_section("Extension Metadata", &content) {
291                    parts.push(section);
292                }
293            }
294
295            // Tags.
296            let tags = extract_tags(module);
297            if !tags.is_empty() {
298                if let Some(section) = render_section("Tags", &format!("  {}", tags.join(", "))) {
299                    parts.push(section);
300                }
301            }
302
303            parts.join("\n")
304        }
305        "json" => {
306            let mut result = serde_json::Map::new();
307            result.insert("id".to_string(), serde_json::Value::String(id.to_string()));
308            result.insert(
309                "description".to_string(),
310                serde_json::Value::String(description.to_string()),
311            );
312
313            // Optional fields: only include if present and non-null.
314            for key in &["input_schema", "output_schema"] {
315                if let Some(v) = module.get(*key).filter(|v| !v.is_null()) {
316                    result.insert(key.to_string(), v.clone());
317                }
318            }
319
320            if let Some(ann) = module
321                .get("annotations")
322                .filter(|v| !v.is_null() && v.as_object().is_some_and(|o| !o.is_empty()))
323            {
324                result.insert("annotations".to_string(), ann.clone());
325            }
326
327            let tags = extract_tags(module);
328            if !tags.is_empty() {
329                result.insert(
330                    "tags".to_string(),
331                    serde_json::Value::Array(
332                        tags.into_iter().map(serde_json::Value::String).collect(),
333                    ),
334                );
335            }
336
337            // Extension metadata.
338            if let Some(obj) = module.as_object() {
339                for (k, v) in obj {
340                    if k.starts_with("x-") || k.starts_with("x_") {
341                        result.insert(k.clone(), v.clone());
342                    }
343                }
344            }
345
346            serde_json::to_string_pretty(&serde_json::Value::Object(result))
347                .unwrap_or_else(|_| "{}".to_string())
348        }
349        unknown => {
350            tracing::warn!(
351                "Unknown format '{}' in format_module_detail, using json.",
352                unknown
353            );
354            format_module_detail(module, "json")
355        }
356    }
357}
358
359// ---------------------------------------------------------------------------
360// format_exec_result
361// ---------------------------------------------------------------------------
362
363/// Apply field selection to a JSON object.
364///
365/// `fields` is a comma-separated list of dot-paths (e.g. `"status,data.count"`).
366/// Returns a new object containing only the selected fields.
367fn apply_field_selection(result: &Value, fields: &str) -> Value {
368    if let Some(obj) = result.as_object() {
369        let mut selected = serde_json::Map::new();
370        for field in fields.split(',') {
371            let field = field.trim();
372            if field.is_empty() {
373                continue;
374            }
375            let mut val: &Value = &Value::Object(obj.clone());
376            for part in field.split('.') {
377                if let Some(next) = val.get(part) {
378                    val = next;
379                } else {
380                    val = &Value::Null;
381                    break;
382                }
383            }
384            selected.insert(field.to_string(), val.clone());
385        }
386        Value::Object(selected)
387    } else {
388        result.clone()
389    }
390}
391
392/// Render a module execution result.
393///
394/// # Arguments
395/// * `result` — `serde_json::Value` (the `output` field from the executor response)
396/// * `format` — `"table"`, `"json"`, `"csv"`, `"yaml"`, or `"jsonl"`
397/// * `fields` — optional comma-separated dot-paths to select from the result
398pub fn format_exec_result(result: &Value, format: &str, fields: Option<&str>) -> String {
399    use comfy_table::{ContentArrangement, Table};
400
401    let result = if let Some(f) = fields {
402        apply_field_selection(result, f)
403    } else {
404        result.clone()
405    };
406
407    match &result {
408        Value::Null => String::new(),
409
410        Value::String(s) => s.clone(),
411
412        Value::Object(_) if format == "csv" => {
413            let obj = result.as_object().unwrap();
414            let keys: Vec<&String> = obj.keys().collect();
415            let header = keys
416                .iter()
417                .map(|k| k.as_str())
418                .collect::<Vec<_>>()
419                .join(",");
420            let values = keys
421                .iter()
422                .map(|k| {
423                    let v = obj.get(*k).unwrap();
424                    match v {
425                        Value::String(s) => s.clone(),
426                        other => other.to_string(),
427                    }
428                })
429                .collect::<Vec<_>>()
430                .join(",");
431            format!("{header}\n{values}")
432        }
433
434        Value::Array(arr) if format == "csv" => {
435            if arr.is_empty() {
436                return String::new();
437            }
438            if let Some(first_obj) = arr[0].as_object() {
439                let keys: Vec<&String> = first_obj.keys().collect();
440                let header = keys
441                    .iter()
442                    .map(|k| k.as_str())
443                    .collect::<Vec<_>>()
444                    .join(",");
445                let mut rows = vec![header];
446                for item in arr {
447                    if let Some(obj) = item.as_object() {
448                        let row = keys
449                            .iter()
450                            .map(|k| {
451                                let v = obj.get(*k).unwrap_or(&Value::Null);
452                                match v {
453                                    Value::String(s) => s.clone(),
454                                    other => other.to_string(),
455                                }
456                            })
457                            .collect::<Vec<_>>()
458                            .join(",");
459                        rows.push(row);
460                    }
461                }
462                rows.join("\n")
463            } else {
464                serde_json::to_string(&result).unwrap_or_default()
465            }
466        }
467
468        _ if format == "yaml" => serde_yaml::to_string(&result)
469            .map(|s| s.trim_end().to_string())
470            .unwrap_or_else(|_| {
471                serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
472            }),
473
474        Value::Array(arr) if format == "jsonl" => arr
475            .iter()
476            .map(|item| serde_json::to_string(item).unwrap_or_default())
477            .collect::<Vec<_>>()
478            .join("\n"),
479
480        _ if format == "jsonl" => serde_json::to_string(&result).unwrap_or_default(),
481
482        Value::Object(_) if format == "table" => {
483            let obj = result.as_object().unwrap();
484            let mut table = Table::new();
485            table.set_content_arrangement(ContentArrangement::Dynamic);
486            table.set_header(vec!["Key", "Value"]);
487            for (k, v) in obj {
488                let val_str = match v {
489                    Value::String(s) => s.clone(),
490                    other => other.to_string(),
491                };
492                table.add_row(vec![k.clone(), val_str]);
493            }
494            table.to_string()
495        }
496
497        Value::Object(_) | Value::Array(_) => {
498            serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
499        }
500
501        // Number, Bool -- convert to display string.
502        other => other.to_string(),
503    }
504}
505
506// ---------------------------------------------------------------------------
507// Unit tests
508// ---------------------------------------------------------------------------
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use serde_json::json;
514
515    // --- resolve_format_inner ---
516
517    #[test]
518    fn test_resolve_format_explicit_json_tty() {
519        // Explicit format wins over TTY state.
520        assert_eq!(resolve_format_inner(Some("json"), true), "json");
521    }
522
523    #[test]
524    fn test_resolve_format_explicit_table_non_tty() {
525        // Explicit format wins over non-TTY state.
526        assert_eq!(resolve_format_inner(Some("table"), false), "table");
527    }
528
529    #[test]
530    fn test_resolve_format_none_tty() {
531        // No explicit format + TTY → "table".
532        assert_eq!(resolve_format_inner(None, true), "table");
533    }
534
535    #[test]
536    fn test_resolve_format_none_non_tty() {
537        // No explicit format + non-TTY → "json".
538        assert_eq!(resolve_format_inner(None, false), "json");
539    }
540
541    // --- truncate ---
542
543    #[test]
544    fn test_truncate_short_string() {
545        let s = "hello";
546        assert_eq!(truncate(s, 80), "hello");
547    }
548
549    #[test]
550    fn test_truncate_exact_length() {
551        let s = "a".repeat(80);
552        assert_eq!(truncate(&s, 80), s);
553    }
554
555    #[test]
556    fn test_truncate_over_limit() {
557        let s = "a".repeat(100);
558        let result = truncate(&s, 80);
559        assert_eq!(result.len(), 80);
560        assert!(result.ends_with("..."));
561        assert_eq!(&result[..77], &"a".repeat(77));
562    }
563
564    #[test]
565    fn test_truncate_exactly_81_chars() {
566        let s = "b".repeat(81);
567        let result = truncate(&s, 80);
568        assert_eq!(result.len(), 80);
569        assert!(result.ends_with("..."));
570    }
571
572    // --- format_module_list ---
573
574    #[test]
575    fn test_format_module_list_json_two_modules() {
576        let modules = vec![
577            json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]}),
578            json!({"module_id": "text.upper", "description": "Uppercase", "tags": []}),
579        ];
580        let output = format_module_list(&modules, "json", &[]);
581        let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
582        let arr = parsed.as_array().expect("must be array");
583        assert_eq!(arr.len(), 2);
584        assert_eq!(arr[0]["id"], "math.add");
585        assert_eq!(arr[1]["id"], "text.upper");
586    }
587
588    #[test]
589    fn test_format_module_list_json_empty() {
590        let output = format_module_list(&[], "json", &[]);
591        assert_eq!(output.trim(), "[]");
592    }
593
594    #[test]
595    fn test_format_module_list_table_two_modules() {
596        let modules =
597            vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]})];
598        let output = format_module_list(&modules, "table", &[]);
599        assert!(output.contains("math.add"), "table must contain module ID");
600        assert!(
601            output.contains("Add numbers"),
602            "table must contain description"
603        );
604    }
605
606    #[test]
607    fn test_format_module_list_table_columns() {
608        let modules =
609            vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": []})];
610        let output = format_module_list(&modules, "table", &[]);
611        assert!(output.contains("ID"), "table must have ID column");
612        assert!(
613            output.contains("Description"),
614            "table must have Description column"
615        );
616        assert!(output.contains("Tags"), "table must have Tags column");
617    }
618
619    #[test]
620    fn test_format_module_list_table_empty_no_tags() {
621        let output = format_module_list(&[], "table", &[]);
622        assert_eq!(output.trim(), "No modules found.");
623    }
624
625    #[test]
626    fn test_format_module_list_table_empty_with_filter_tags() {
627        let output = format_module_list(&[], "table", &["math", "text"]);
628        assert!(
629            output.contains("No modules found matching tags:"),
630            "must contain tag-filter message"
631        );
632        assert!(output.contains("math"), "must contain tag name");
633        assert!(output.contains("text"), "must contain tag name");
634    }
635
636    #[test]
637    fn test_format_module_list_table_description_truncated() {
638        let long_desc = "a".repeat(100);
639        let modules = vec![json!({"module_id": "x.y", "description": long_desc, "tags": []})];
640        let output = format_module_list(&modules, "table", &[]);
641        assert!(
642            output.contains("..."),
643            "long description must be truncated with '...'"
644        );
645        assert!(
646            !output.contains(&"a".repeat(100)),
647            "full description must not appear"
648        );
649    }
650
651    #[test]
652    fn test_format_module_list_json_tags_present() {
653        let modules = vec![json!({"module_id": "a.b", "description": "desc", "tags": ["x", "y"]})];
654        let output = format_module_list(&modules, "json", &[]);
655        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
656        let tags = parsed[0]["tags"].as_array().unwrap();
657        assert_eq!(tags.len(), 2);
658        assert_eq!(tags[0], "x");
659    }
660
661    // --- format_exec_result ---
662
663    #[test]
664    fn test_format_exec_result_null_returns_empty() {
665        let output = format_exec_result(&Value::Null, "json", None);
666        assert_eq!(output, "", "Null result must produce empty string");
667    }
668
669    #[test]
670    fn test_format_exec_result_string_plain() {
671        let result = json!("hello world");
672        let output = format_exec_result(&result, "json", None);
673        assert_eq!(output, "hello world");
674    }
675
676    #[test]
677    fn test_format_exec_result_string_table_mode_also_plain() {
678        // Strings are always printed raw, regardless of format.
679        let result = json!("hello");
680        let output = format_exec_result(&result, "table", None);
681        assert_eq!(output, "hello");
682    }
683
684    #[test]
685    fn test_format_exec_result_object_json_mode() {
686        let result = json!({"sum": 42, "status": "ok"});
687        let output = format_exec_result(&result, "json", None);
688        let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
689        assert_eq!(parsed["sum"], 42);
690        assert_eq!(parsed["status"], "ok");
691    }
692
693    #[test]
694    fn test_format_exec_result_object_table_mode() {
695        let result = json!({"key": "value", "count": 3});
696        let output = format_exec_result(&result, "table", None);
697        // Table must contain both keys and their values.
698        assert!(output.contains("key"), "table must contain 'key'");
699        assert!(output.contains("value"), "table must contain 'value'");
700        assert!(output.contains("count"), "table must contain 'count'");
701        assert!(output.contains('3'), "table must contain '3'");
702    }
703
704    #[test]
705    fn test_format_exec_result_array_is_json() {
706        let result = json!([1, 2, 3]);
707        let output = format_exec_result(&result, "json", None);
708        let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
709        assert!(parsed.is_array());
710        assert_eq!(parsed.as_array().unwrap().len(), 3);
711    }
712
713    #[test]
714    fn test_format_exec_result_array_table_mode_is_json() {
715        // Arrays always render as JSON, even in table mode.
716        let result = json!([{"a": 1}, {"b": 2}]);
717        let output = format_exec_result(&result, "table", None);
718        let parsed: serde_json::Value =
719            serde_json::from_str(&output).expect("array must produce JSON");
720        assert!(parsed.is_array());
721    }
722
723    #[test]
724    fn test_format_exec_result_number_scalar() {
725        let result = json!(42);
726        let output = format_exec_result(&result, "json", None);
727        assert_eq!(output, "42");
728    }
729
730    #[test]
731    fn test_format_exec_result_bool_scalar() {
732        let result = json!(true);
733        let output = format_exec_result(&result, "json", None);
734        assert_eq!(output, "true");
735    }
736
737    #[test]
738    fn test_format_exec_result_float_scalar() {
739        let result = json!(3.15);
740        let output = format_exec_result(&result, "json", None);
741        assert!(output.starts_with("3.15"), "float must stringify correctly");
742    }
743
744    // --- format_module_detail ---
745
746    #[test]
747    fn test_format_module_detail_json_full() {
748        let module = json!({
749            "module_id": "math.add",
750            "description": "Add two numbers",
751            "input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
752            "output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
753            "tags": ["math"],
754            "annotations": {"author": "test"}
755        });
756        let output = format_module_detail(&module, "json");
757        let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
758        assert_eq!(parsed["id"], "math.add");
759        assert_eq!(parsed["description"], "Add two numbers");
760        assert!(
761            parsed.get("input_schema").is_some(),
762            "input_schema must be present"
763        );
764        assert!(
765            parsed.get("output_schema").is_some(),
766            "output_schema must be present"
767        );
768        let tags = parsed["tags"].as_array().unwrap();
769        assert_eq!(tags[0], "math");
770    }
771
772    #[test]
773    fn test_format_module_detail_json_no_output_schema() {
774        let module = json!({
775            "module_id": "text.upper",
776            "description": "Uppercase",
777        });
778        let output = format_module_detail(&module, "json");
779        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
780        assert!(
781            parsed.get("output_schema").is_none(),
782            "output_schema must be absent when not set"
783        );
784    }
785
786    #[test]
787    fn test_format_module_detail_json_no_none_fields() {
788        let module = json!({
789            "module_id": "a.b",
790            "description": "desc",
791            "input_schema": null,
792            "output_schema": null,
793            "tags": null,
794        });
795        let output = format_module_detail(&module, "json");
796        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
797        assert!(
798            parsed.get("input_schema").is_none(),
799            "null input_schema must be absent"
800        );
801        assert!(parsed.get("tags").is_none(), "null tags must be absent");
802    }
803
804    #[test]
805    fn test_format_module_detail_table_contains_description() {
806        let module = json!({
807            "module_id": "math.add",
808            "description": "Add two numbers",
809        });
810        let output = format_module_detail(&module, "table");
811        assert!(
812            output.contains("Add two numbers"),
813            "table must contain description"
814        );
815    }
816
817    #[test]
818    fn test_format_module_detail_table_contains_module_id() {
819        let module = json!({
820            "module_id": "math.add",
821            "description": "desc",
822        });
823        let output = format_module_detail(&module, "table");
824        assert!(output.contains("math.add"), "table must contain module ID");
825    }
826
827    #[test]
828    fn test_format_module_detail_table_input_schema_section() {
829        let module = json!({
830            "module_id": "math.add",
831            "description": "desc",
832            "input_schema": {"type": "object"}
833        });
834        let output = format_module_detail(&module, "table");
835        assert!(
836            output.contains("Input Schema"),
837            "table must contain Input Schema section"
838        );
839    }
840
841    #[test]
842    fn test_format_module_detail_table_no_output_schema_section_when_absent() {
843        let module = json!({
844            "module_id": "text.upper",
845            "description": "desc",
846        });
847        let output = format_module_detail(&module, "table");
848        assert!(
849            !output.contains("Output Schema"),
850            "Output Schema section must be absent when not set"
851        );
852    }
853
854    #[test]
855    fn test_format_module_detail_table_tags_section() {
856        let module = json!({
857            "module_id": "math.add",
858            "description": "desc",
859            "tags": ["math", "arithmetic"]
860        });
861        let output = format_module_detail(&module, "table");
862        assert!(output.contains("Tags"), "table must contain Tags section");
863        assert!(output.contains("math"), "table must contain tag value");
864    }
865
866    #[test]
867    fn test_format_module_detail_table_annotations_section() {
868        let module = json!({
869            "module_id": "a.b",
870            "description": "desc",
871            "annotations": {"author": "alice", "version": "1.0"}
872        });
873        let output = format_module_detail(&module, "table");
874        assert!(
875            output.contains("Annotations"),
876            "table must contain Annotations section"
877        );
878        assert!(
879            output.contains("author"),
880            "table must contain annotation key"
881        );
882        assert!(
883            output.contains("alice"),
884            "table must contain annotation value"
885        );
886    }
887
888    #[test]
889    fn test_format_module_detail_table_extension_metadata() {
890        let module = json!({
891            "module_id": "a.b",
892            "description": "desc",
893            "x-category": "utility"
894        });
895        let output = format_module_detail(&module, "table");
896        assert!(
897            output.contains("Extension Metadata"),
898            "must contain Extension Metadata section"
899        );
900        assert!(output.contains("x-category"), "must contain x- key");
901        assert!(output.contains("utility"), "must contain x- value");
902    }
903}