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            other => {
25                // Unknown format: log a warning and fall back to json.
26                // (Invalid values are caught by clap upstream; this is a safety net.)
27                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
39/// Determine the output format to use.
40///
41/// Resolution order:
42/// 1. `explicit_format` if `Some`.
43/// 2. `"table"` when stdout is a TTY.
44/// 3. `"json"` otherwise.
45pub 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
50// ---------------------------------------------------------------------------
51// truncate
52// ---------------------------------------------------------------------------
53
54/// Truncate `text` to at most `max_length` characters.
55///
56/// If truncation occurs, the last 3 characters are replaced with `"..."`.
57/// Uses char-boundary-safe truncation to handle Unicode correctly: byte length
58/// is used for the boundary check (matching Python's `len()` on ASCII-dominant
59/// module descriptions), but slicing respects char boundaries.
60pub(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    // Walk back from cutoff to find a valid char boundary.
66    let mut end = cutoff;
67    while end > 0 && !text.is_char_boundary(end) {
68        end -= 1;
69    }
70    format!("{}...", &text[..end])
71}
72
73// ---------------------------------------------------------------------------
74// format_module_list helpers
75// ---------------------------------------------------------------------------
76
77/// Extract a string field from a JSON module descriptor with fallback keys.
78fn 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
87/// Extract tags array from a JSON module descriptor. Returns empty Vec on missing/invalid.
88fn 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
99// ---------------------------------------------------------------------------
100// format_module_list
101// ---------------------------------------------------------------------------
102
103/// Render a list of module descriptors as a table or JSON.
104///
105/// # Arguments
106/// * `modules`      — slice of `serde_json::Value` objects (module descriptors)
107/// * `format`       — `"table"` or `"json"`
108/// * `filter_tags`  — AND-filter: only modules that have ALL listed tags are shown
109///
110/// Returns the formatted string ready for printing to stdout.
111pub 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
170// ---------------------------------------------------------------------------
171// format_module_detail
172// ---------------------------------------------------------------------------
173
174/// Render a minimal bordered panel heading. Returns a String with a box around `title`.
175fn 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
183/// Render an optional section with a label and preformatted content.
184/// Returns None if content is empty.
185fn 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
192/// Render a single module descriptor with its full schema.
193///
194/// # Arguments
195/// * `module` — `serde_json::Value` module descriptor
196/// * `format` — `"table"` or `"json"`
197pub 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            // Header panel.
206            parts.push(render_panel(&format!("Module: {}", id)));
207
208            // Description.
209            parts.push(format!("\nDescription:\n  {}", description));
210
211            // Input schema.
212            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            // Output schema.
221            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            // Annotations.
230            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            // Extension metadata (x- or x_ prefixed keys at the top level).
247            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            // Tags.
271            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            // Optional fields: only include if present and non-null.
289            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            // Extension metadata.
313            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
334// ---------------------------------------------------------------------------
335// format_exec_result
336// ---------------------------------------------------------------------------
337
338/// Render a module execution result.
339///
340/// # Arguments
341/// * `result` — `serde_json::Value` (the `output` field from the executor response)
342/// * `format` — `"table"` or `"json"`
343pub 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(); // safe: matched Object above
353            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        // Number, Bool — convert to display string.
371        other => other.to_string(),
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Unit tests
377// ---------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use serde_json::json;
383
384    // --- resolve_format_inner ---
385
386    #[test]
387    fn test_resolve_format_explicit_json_tty() {
388        // Explicit format wins over TTY state.
389        assert_eq!(resolve_format_inner(Some("json"), true), "json");
390    }
391
392    #[test]
393    fn test_resolve_format_explicit_table_non_tty() {
394        // Explicit format wins over non-TTY state.
395        assert_eq!(resolve_format_inner(Some("table"), false), "table");
396    }
397
398    #[test]
399    fn test_resolve_format_none_tty() {
400        // No explicit format + TTY → "table".
401        assert_eq!(resolve_format_inner(None, true), "table");
402    }
403
404    #[test]
405    fn test_resolve_format_none_non_tty() {
406        // No explicit format + non-TTY → "json".
407        assert_eq!(resolve_format_inner(None, false), "json");
408    }
409
410    // --- truncate ---
411
412    #[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    // --- format_module_list ---
442
443    #[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    // --- format_exec_result ---
531
532    #[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        // Strings are always printed raw, regardless of format.
548        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        // Table must contain both keys and their values.
567        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        // Arrays always render as JSON, even in table mode.
585        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    // --- format_module_detail ---
614
615    #[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}