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/// Render a JSON `Value` as a plain-text CSV cell *payload* (pre-quoting).
103/// String variants return their raw contents; all others return
104/// `Value::to_string` (numbers unquoted, bools as `true`/`false`, null as
105/// `null`, nested objects/arrays as their JSON serialisation).
106fn csv_scalar_string(v: &Value) -> String {
107    match v {
108        Value::String(s) => s.clone(),
109        other => other.to_string(),
110    }
111}
112
113/// Quote a CSV field per RFC 4180. Fields containing a comma, a carriage
114/// return, a newline, or a double-quote are wrapped in double-quotes and
115/// have embedded double-quotes doubled. Otherwise the field is returned
116/// unchanged.
117fn 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
126// ---------------------------------------------------------------------------
127// format_module_list
128// ---------------------------------------------------------------------------
129
130/// Render a list of module descriptors as a table or JSON.
131///
132/// # Arguments
133/// * `modules`      — slice of `serde_json::Value` objects (module descriptors)
134/// * `format`       — `"table"` or `"json"`
135/// * `filter_tags`  — AND-filter: only modules that have ALL listed tags are shown
136///
137/// Returns the formatted string ready for printing to stdout.
138pub 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
197// ---------------------------------------------------------------------------
198// format_module_detail
199// ---------------------------------------------------------------------------
200
201/// Render a minimal bordered panel heading. Returns a String with a box around `title`.
202fn 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
210/// Render an optional section with a label and preformatted content.
211/// Returns None if content is empty.
212fn 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
219/// Render a single module descriptor with its full schema.
220///
221/// # Arguments
222/// * `module` — `serde_json::Value` module descriptor
223/// * `format` — `"table"` or `"json"`
224pub 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            // Header panel.
233            parts.push(render_panel(&format!("Module: {}", id)));
234
235            // Description.
236            parts.push(format!("\nDescription:\n  {}", description));
237
238            // Input schema.
239            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            // Output schema.
248            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            // Annotations.
257            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            // Extension metadata (x- or x_ prefixed keys at the top level).
274            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            // Tags.
298            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            // Optional fields: only include if present and non-null.
316            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            // Extension metadata.
340            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
361// ---------------------------------------------------------------------------
362// format_exec_result
363// ---------------------------------------------------------------------------
364
365/// Apply field selection to a JSON object.
366///
367/// `fields` is a comma-separated list of dot-paths (e.g. `"status,data.count"`).
368/// Returns a new object containing only the selected fields.
369fn 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
394/// Render a module execution result.
395///
396/// # Arguments
397/// * `result` — `serde_json::Value` (the `output` field from the executor response)
398/// * `format` — `"table"`, `"json"`, `"csv"`, `"yaml"`, or `"jsonl"`
399/// * `fields` — optional comma-separated dot-paths to select from the result
400pub 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        // Number, Bool -- convert to display string.
498        other => other.to_string(),
499    }
500}
501
502// ---------------------------------------------------------------------------
503// Unit tests
504// ---------------------------------------------------------------------------
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use serde_json::json;
510
511    // --- resolve_format_inner ---
512
513    #[test]
514    fn test_resolve_format_explicit_json_tty() {
515        // Explicit format wins over TTY state.
516        assert_eq!(resolve_format_inner(Some("json"), true), "json");
517    }
518
519    #[test]
520    fn test_resolve_format_explicit_table_non_tty() {
521        // Explicit format wins over non-TTY state.
522        assert_eq!(resolve_format_inner(Some("table"), false), "table");
523    }
524
525    #[test]
526    fn test_resolve_format_none_tty() {
527        // No explicit format + TTY → "table".
528        assert_eq!(resolve_format_inner(None, true), "table");
529    }
530
531    #[test]
532    fn test_resolve_format_none_non_tty() {
533        // No explicit format + non-TTY → "json".
534        assert_eq!(resolve_format_inner(None, false), "json");
535    }
536
537    // --- truncate ---
538
539    #[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    // --- format_module_list ---
569
570    #[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    // --- format_exec_result ---
658
659    #[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        // Strings are always printed raw, regardless of format.
675        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        // Table must contain both keys and their values.
694        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        // Arrays always render as JSON, even in table mode.
712        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    // --- format_module_detail ---
741
742    #[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}