Skip to main content

apcore_toolkit/formatting/
surface.rs

1// Surface-aware formatters.
2//
3// Render `ScannedModule` and JSON Schema for specific consumer surfaces:
4// LLM context (markdown), agent skill files (skill), CLI listings (table-row),
5// and programmatic APIs (json). See
6// `apcore-toolkit/docs/features/formatting.md`.
7
8use std::collections::BTreeMap;
9use std::sync::OnceLock;
10
11use apcore::module::ModuleAnnotations;
12use serde_json::{Map, Value};
13use thiserror::Error;
14
15use crate::serializers::{annotations_to_dict, module_to_dict};
16use crate::types::ScannedModule;
17
18/// Snake-case Map of every default-valued annotation field.
19///
20/// Used by the behavior-table renderer to skip fields that match the
21/// protocol default, keeping the table focused on what is actually
22/// non-default about the module. Lazily initialised on first access; the
23/// `ModuleAnnotations::default()` value never changes within a process so
24/// caching it is safe.
25fn default_annotations_dict() -> &'static Map<String, Value> {
26    static CACHE: OnceLock<Map<String, Value>> = OnceLock::new();
27    CACHE.get_or_init(|| {
28        let default_ann = ModuleAnnotations::default();
29        match annotations_to_dict(Some(&default_ann)) {
30            Value::Object(map) => map,
31            _ => Map::new(),
32        }
33    })
34}
35
36/// Schema render style.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SchemaStyle {
39    /// Markdown bullet list, one line per top-level property.
40    Prose,
41    /// Markdown pipe table.
42    Table,
43    /// Pass-through JSON.
44    Json,
45}
46
47/// Module render style.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ModuleStyle {
50    /// LLM context — sections + parameter prose + behavior facts.
51    Markdown,
52    /// `markdown` body prefixed with minimal `name` + `description` YAML
53    /// frontmatter (vendor-neutral SKILL.md form).
54    Skill,
55    /// CLI listing — single pipe-separated row.
56    TableRow,
57    /// Pass-through `module_to_dict`.
58    Json,
59}
60
61/// Group-by axis for [`format_modules`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum GroupBy {
64    /// Group by `ScannedModule.tags` (modules in multiple tags appear
65    /// multiple times; untagged modules go into `(untagged)`).
66    Tag,
67    /// Group by everything before the first `.` in `module_id`.
68    Prefix,
69}
70
71/// Polymorphic return for the surface formatters.
72///
73/// `Markdown` / `Skill` / `TableRow` styles return `Text`; `Json`
74/// returns `Value` (single module) or `Values` (module list).
75#[derive(Debug, Clone)]
76pub enum FormatOutput {
77    Text(String),
78    Value(Value),
79    Values(Vec<Value>),
80}
81
82impl FormatOutput {
83    /// Borrow as a string slice if this is a [`FormatOutput::Text`].
84    pub fn as_str(&self) -> Option<&str> {
85        match self {
86            FormatOutput::Text(s) => Some(s.as_str()),
87            _ => None,
88        }
89    }
90
91    /// Borrow as a single JSON value if this is a [`FormatOutput::Value`].
92    pub fn as_value(&self) -> Option<&Value> {
93        match self {
94            FormatOutput::Value(v) => Some(v),
95            _ => None,
96        }
97    }
98
99    /// Borrow as a JSON array if this is a [`FormatOutput::Values`].
100    pub fn as_values(&self) -> Option<&[Value]> {
101        match self {
102            FormatOutput::Values(v) => Some(v),
103            _ => None,
104        }
105    }
106}
107
108/// Error returned by the surface formatters.
109#[derive(Debug, Error)]
110pub enum FormatError {
111    #[error("formatSchema: schema must be a JSON object, got {0}")]
112    SchemaNotObject(&'static str),
113}
114
115const DEFAULT_MAX_DEPTH: usize = 3;
116
117/// Render a JSON Schema for a specific surface.
118///
119/// See `Contract: format_schema` in
120/// `apcore-toolkit/docs/features/formatting.md`.
121pub fn format_schema(schema: &Value, style: SchemaStyle, max_depth: Option<usize>) -> FormatOutput {
122    let max_depth = max_depth.unwrap_or(DEFAULT_MAX_DEPTH);
123    match style {
124        SchemaStyle::Json => FormatOutput::Value(schema.clone()),
125        SchemaStyle::Prose => FormatOutput::Text(render_schema_prose(schema, max_depth, 0)),
126        SchemaStyle::Table => FormatOutput::Text(render_schema_table(schema)),
127    }
128}
129
130fn render_schema_prose(schema: &Value, max_depth: usize, depth: usize) -> String {
131    let Some(obj) = schema.as_object() else {
132        return String::new();
133    };
134    let type_ = obj.get("type").and_then(|v| v.as_str());
135    let properties = obj.get("properties").and_then(|v| v.as_object());
136    if type_ != Some("object") || properties.is_none() {
137        if let Some(t) = type_ {
138            if t != "object" {
139                return format!("_schema accepts {t}_");
140            }
141        }
142        return String::new();
143    }
144    let properties = properties.unwrap();
145    let required = required_set(obj);
146    render_properties_prose(properties, &required, max_depth, depth)
147}
148
149fn render_properties_prose(
150    properties: &Map<String, Value>,
151    required: &std::collections::HashSet<String>,
152    max_depth: usize,
153    depth: usize,
154) -> String {
155    let mut lines: Vec<String> = Vec::new();
156    for (name, prop) in properties.iter() {
157        let prop_obj = prop.as_object();
158        let type_ = prop_obj
159            .and_then(|o| o.get("type"))
160            .and_then(|v| v.as_str())
161            .unwrap_or("any");
162        let req_label = if required.contains(name) {
163            "required"
164        } else {
165            "optional"
166        };
167        let desc = prop_obj
168            .and_then(|o| o.get("description"))
169            .and_then(|v| v.as_str())
170            .unwrap_or("")
171            .trim();
172        let mut head = format!("- `{name}` ({type_}, {req_label})");
173        if !desc.is_empty() {
174            head.push_str(" — ");
175            head.push_str(desc);
176        }
177        lines.push(head);
178
179        if let Some(prop_obj) = prop_obj {
180            if prop_obj.get("type").and_then(|v| v.as_str()) == Some("object") {
181                if let Some(nested_props) = prop_obj.get("properties").and_then(|v| v.as_object()) {
182                    if depth + 1 >= max_depth {
183                        lines.push("  ```json".to_string());
184                        let pretty =
185                            serde_json::to_string_pretty(prop).unwrap_or_else(|_| "{}".to_string());
186                        for line in pretty.lines() {
187                            lines.push(format!("  {line}"));
188                        }
189                        lines.push("  ```".to_string());
190                    } else {
191                        let nested_required = required_set(prop_obj);
192                        let nested = render_properties_prose(
193                            nested_props,
194                            &nested_required,
195                            max_depth,
196                            depth + 1,
197                        );
198                        for line in nested.lines() {
199                            lines.push(format!("  {line}"));
200                        }
201                    }
202                }
203            }
204        }
205    }
206    lines.join("\n")
207}
208
209fn render_schema_table(schema: &Value) -> String {
210    let Some(obj) = schema.as_object() else {
211        return String::new();
212    };
213    let type_ = obj.get("type").and_then(|v| v.as_str());
214    let properties = obj.get("properties").and_then(|v| v.as_object());
215    if type_ != Some("object") || properties.is_none() {
216        if let Some(t) = type_ {
217            if t != "object" {
218                return format!("_schema accepts {t}_");
219            }
220        }
221        return "| Name | Type | Required | Default | Description |\n|---|---|---|---|---|\n"
222            .to_string();
223    }
224    let properties = properties.unwrap();
225    let required = required_set(obj);
226    let mut rows: Vec<String> = vec![
227        "| Name | Type | Required | Default | Description |".to_string(),
228        "|---|---|---|---|---|".to_string(),
229    ];
230    for (name, prop) in properties.iter() {
231        let prop_obj = prop.as_object();
232        let type_ = prop_obj
233            .and_then(|o| o.get("type"))
234            .and_then(|v| v.as_str())
235            .unwrap_or("any");
236        let req_label = if required.contains(name) { "yes" } else { "no" };
237        let desc = prop_obj
238            .and_then(|o| o.get("description"))
239            .and_then(|v| v.as_str())
240            .unwrap_or("")
241            .trim();
242        let default_str = prop_obj
243            .and_then(|o| o.get("default"))
244            .map(|v| {
245                if v.is_string() {
246                    v.as_str().unwrap_or("").to_string()
247                } else {
248                    v.to_string()
249                }
250            })
251            .unwrap_or_default();
252        rows.push(format!(
253            "| `{name}` | {type_} | {req_label} | {default_str} | {desc} |"
254        ));
255    }
256    rows.join("\n")
257}
258
259fn required_set(obj: &Map<String, Value>) -> std::collections::HashSet<String> {
260    obj.get("required")
261        .and_then(|v| v.as_array())
262        .map(|arr| {
263            arr.iter()
264                .filter_map(|v| v.as_str().map(String::from))
265                .collect()
266        })
267        .unwrap_or_default()
268}
269
270/// Render a single ScannedModule for the chosen surface.
271///
272/// See `Contract: format_module` in
273/// `apcore-toolkit/docs/features/formatting.md`.
274pub fn format_module(module: &ScannedModule, style: ModuleStyle, display: bool) -> FormatOutput {
275    if matches!(style, ModuleStyle::Json) {
276        return FormatOutput::Value(module_to_dict(module));
277    }
278
279    let resolved = resolve_display_fields(module, display);
280
281    if matches!(style, ModuleStyle::TableRow) {
282        let alias = if resolved.title != module.module_id {
283            resolved.title.clone()
284        } else {
285            String::new()
286        };
287        let tag_str = if resolved.tags.is_empty() {
288            String::new()
289        } else {
290            resolved.tags.join(", ")
291        };
292        let line = format!(
293            "`{}` │ `{}` │ {} │ {}",
294            module.module_id, alias, resolved.description, tag_str
295        );
296        return FormatOutput::Text(line);
297    }
298
299    let body = render_module_markdown_body(module, &resolved);
300
301    match style {
302        ModuleStyle::Skill => {
303            let one_line = resolved.description.replace('\n', " ");
304            let one_line = one_line.trim();
305            let frontmatter = format!(
306                "---\nname: {}\ndescription: {}\n---\n\n",
307                resolved.title,
308                yaml_scalar(one_line)
309            );
310            FormatOutput::Text(frontmatter + &body)
311        }
312        ModuleStyle::Markdown => FormatOutput::Text(body),
313        ModuleStyle::TableRow | ModuleStyle::Json => unreachable!("handled above"),
314    }
315}
316
317struct ResolvedDisplay {
318    title: String,
319    description: String,
320    guidance: Option<String>,
321    tags: Vec<String>,
322}
323
324fn resolve_display_fields(module: &ScannedModule, use_display: bool) -> ResolvedDisplay {
325    let raw_title = module.module_id.clone();
326    let raw_desc = module.description.clone();
327    let raw_tags = module.tags.clone();
328    if !use_display {
329        return ResolvedDisplay {
330            title: raw_title,
331            description: raw_desc,
332            guidance: None,
333            tags: raw_tags,
334        };
335    }
336    let Some(overlay) = module.display.as_ref().and_then(|v| v.as_object()) else {
337        return ResolvedDisplay {
338            title: raw_title,
339            description: raw_desc,
340            guidance: None,
341            tags: raw_tags,
342        };
343    };
344    let title = overlay
345        .get("alias")
346        .and_then(|v| v.as_str())
347        .filter(|s| !s.is_empty())
348        .map(String::from)
349        .unwrap_or(raw_title);
350    let description = overlay
351        .get("description")
352        .and_then(|v| v.as_str())
353        .filter(|s| !s.is_empty())
354        .map(String::from)
355        .unwrap_or(raw_desc);
356    let guidance = overlay
357        .get("guidance")
358        .and_then(|v| v.as_str())
359        .filter(|s| !s.is_empty())
360        .map(String::from);
361    let tags = overlay
362        .get("tags")
363        .and_then(|v| v.as_array())
364        .map(|arr| {
365            arr.iter()
366                .filter_map(|v| v.as_str().map(String::from))
367                .collect::<Vec<_>>()
368        })
369        .filter(|v| !v.is_empty())
370        .unwrap_or(raw_tags);
371    ResolvedDisplay {
372        title,
373        description,
374        guidance,
375        tags,
376    }
377}
378
379fn render_module_markdown_body(module: &ScannedModule, resolved: &ResolvedDisplay) -> String {
380    let mut sections: Vec<String> = Vec::new();
381    sections.push(format!("# {}", resolved.title));
382    if !resolved.description.is_empty() {
383        sections.push(resolved.description.clone());
384    }
385    if let Some(guidance) = &resolved.guidance {
386        sections.push(format!("_{guidance}_"));
387    }
388
389    sections.push("## Parameters".to_string());
390    let params = render_schema_prose(&module.input_schema, DEFAULT_MAX_DEPTH, 0);
391    sections.push(if params.is_empty() {
392        "_(no parameters)_".to_string()
393    } else {
394        params
395    });
396
397    sections.push("## Returns".to_string());
398    let returns = render_schema_prose(&module.output_schema, DEFAULT_MAX_DEPTH, 0);
399    sections.push(if returns.is_empty() {
400        "_(no return schema)_".to_string()
401    } else {
402        returns
403    });
404
405    if let Some(table) = render_annotations_table(module.annotations.as_ref()) {
406        sections.push("## Behavior".to_string());
407        sections.push(table);
408    }
409
410    if !module.examples.is_empty() {
411        sections.push("## Examples".to_string());
412        for (idx, example) in module.examples.iter().enumerate() {
413            sections.push(format!("### Example {}", idx + 1));
414            sections.push("```json".to_string());
415            sections
416                .push(serde_json::to_string_pretty(example).unwrap_or_else(|_| "{}".to_string()));
417            sections.push("```".to_string());
418        }
419    }
420
421    if !resolved.tags.is_empty() {
422        sections.push("## Tags".to_string());
423        let line = resolved
424            .tags
425            .iter()
426            .map(|t| format!("`{t}`"))
427            .collect::<Vec<_>>()
428            .join(", ");
429        sections.push(line);
430    }
431
432    let mut body = sections.join("\n\n");
433    body.push('\n');
434    body
435}
436
437/// Render `ModuleAnnotations` as a Markdown fact table.
438///
439/// Cross-SDK alignment rules (see
440/// `apcore-toolkit/docs/features/formatting.md` § Annotations Rendering):
441///
442/// 1. Emit only fields whose value differs from `ModuleAnnotations::default()`.
443/// 2. The `extra` free-form bag is always skipped.
444/// 3. Rows are sorted alphabetically by snake_case key (`serde_json::Map`
445///    iteration order under default features is already alphabetical via
446///    the underlying `BTreeMap`, so this is a property the data structure
447///    already gives us).
448/// 4. Bool values render as lowercase `true` / `false`; numbers, arrays,
449///    and objects use `serde_json::Value::Display` (which is JSON form);
450///    string values use their raw content.
451///
452/// Returns `None` when the resulting table would be empty (every annotation
453/// field equals its default), causing the caller to omit the `## Behavior`
454/// section entirely.
455fn render_annotations_table(annotations: Option<&ModuleAnnotations>) -> Option<String> {
456    let value = annotations_to_dict(annotations);
457    let obj = value.as_object()?;
458    let defaults = default_annotations_dict();
459    let mut entries: Vec<(&String, &Value)> = Vec::new();
460    for (key, value) in obj.iter() {
461        if key == "extra" {
462            continue;
463        }
464        if defaults.get(key) == Some(value) {
465            continue;
466        }
467        entries.push((key, value));
468    }
469    if entries.is_empty() {
470        return None;
471    }
472    // The toolkit enables serde_json's `preserve_order` feature for byte-
473    // equivalent tabular formatters (csv / jsonl), so Map no longer iterates
474    // alphabetically. Re-sort entries here to keep the formatting.md contract:
475    // "Rows are sorted alphabetically by snake_case key".
476    entries.sort_by(|a, b| a.0.cmp(b.0));
477    let mut rows = vec!["| Flag | Value |".to_string(), "|---|---|".to_string()];
478    for (key, value) in entries {
479        let rendered = match value {
480            Value::String(s) => s.clone(),
481            Value::Bool(true) => "true".to_string(),
482            Value::Bool(false) => "false".to_string(),
483            other => other.to_string(),
484        };
485        rows.push(format!("| `{key}` | {rendered} |"));
486    }
487    Some(rows.join("\n"))
488}
489
490fn yaml_scalar(text: &str) -> String {
491    if text.is_empty() {
492        return "\"\"".to_string();
493    }
494    let needs_quote = text.chars().any(|c| {
495        matches!(
496            c,
497            ':' | '#' | '{' | '}' | '[' | ']' | '\'' | '"' | '\n' | '&' | '*' | '!' | '|' | '>'
498        )
499    });
500    let starts_special = text.starts_with(['-', '?', '%', '@', '`']);
501    if !needs_quote && !starts_special {
502        return text.to_string();
503    }
504    let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
505    format!("\"{escaped}\"")
506}
507
508/// Render a sequence of ScannedModule for the chosen surface.
509///
510/// See `Contract: format_modules` in
511/// `apcore-toolkit/docs/features/formatting.md`.
512pub fn format_modules(
513    modules: &[ScannedModule],
514    style: ModuleStyle,
515    group_by: Option<GroupBy>,
516    display: bool,
517) -> FormatOutput {
518    if matches!(style, ModuleStyle::Json) {
519        return FormatOutput::Values(modules.iter().map(module_to_dict).collect());
520    }
521
522    let joiner = match style {
523        ModuleStyle::Markdown | ModuleStyle::Skill => "\n\n",
524        ModuleStyle::TableRow => "\n",
525        ModuleStyle::Json => unreachable!(),
526    };
527
528    let render_one = |m: &ScannedModule| -> String {
529        match format_module(m, style, display) {
530            FormatOutput::Text(s) => s,
531            _ => unreachable!("non-text style handled above"),
532        }
533    };
534
535    let Some(axis) = group_by else {
536        let parts: Vec<String> = modules.iter().map(&render_one).collect();
537        return FormatOutput::Text(parts.join(joiner));
538    };
539
540    let groups = group_modules(modules, axis);
541    let mut out: Vec<String> = Vec::new();
542    for (group_name, members) in groups {
543        let header = match style {
544            ModuleStyle::Markdown | ModuleStyle::Skill => format!("## {group_name}"),
545            ModuleStyle::TableRow => format!("── {group_name} ──"),
546            ModuleStyle::Json => unreachable!(),
547        };
548        out.push(header);
549        for m in members {
550            out.push(render_one(m));
551        }
552    }
553    FormatOutput::Text(out.join(joiner))
554}
555
556fn group_modules<'a>(
557    modules: &'a [ScannedModule],
558    axis: GroupBy,
559) -> BTreeMap<String, Vec<&'a ScannedModule>> {
560    let mut groups: BTreeMap<String, Vec<&'a ScannedModule>> = BTreeMap::new();
561    for module in modules {
562        match axis {
563            GroupBy::Prefix => {
564                let prefix = match module.module_id.find('.') {
565                    Some(idx) => module.module_id[..idx].to_string(),
566                    None => module.module_id.clone(),
567                };
568                groups.entry(prefix).or_default().push(module);
569            }
570            GroupBy::Tag => {
571                if module.tags.is_empty() {
572                    groups
573                        .entry("(untagged)".to_string())
574                        .or_default()
575                        .push(module);
576                } else {
577                    for tag in &module.tags {
578                        groups.entry(tag.clone()).or_default().push(module);
579                    }
580                }
581            }
582        }
583    }
584    groups
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use apcore::module::{ModuleAnnotations, ModuleExample};
591    use serde_json::json;
592
593    fn fixture_module() -> ScannedModule {
594        let mut m = ScannedModule::new(
595            "users.get_user".into(),
596            "Look up a user by id".into(),
597            json!({
598                "type": "object",
599                "properties": {"id": {"type": "integer", "description": "User id"}},
600                "required": ["id"],
601            }),
602            json!({
603                "type": "object",
604                "properties": {"name": {"type": "string"}},
605            }),
606            vec!["users".into()],
607            "myapp.views:get_user".into(),
608        );
609        m.annotations = Some(ModuleAnnotations {
610            readonly: true,
611            cacheable: true,
612            ..Default::default()
613        });
614        m
615    }
616
617    // ---------- format_schema ----------
618
619    #[test]
620    fn schema_prose_marks_required_and_optional() {
621        let schema = json!({
622            "type": "object",
623            "properties": {
624                "id": {"type": "integer", "description": "User id"},
625                "verbose": {"type": "boolean"},
626            },
627            "required": ["id"],
628        });
629        let out = format_schema(&schema, SchemaStyle::Prose, None);
630        let s = out.as_str().unwrap();
631        assert!(s.contains("`id` (integer, required) — User id"), "got: {s}");
632        assert!(s.contains("`verbose` (boolean, optional)"));
633    }
634
635    #[test]
636    fn schema_table_emits_header_and_yes_no_required() {
637        let schema = json!({
638            "type": "object",
639            "properties": {"id": {"type": "integer", "description": "User id"}},
640            "required": ["id"],
641        });
642        let out = format_schema(&schema, SchemaStyle::Table, None);
643        let s = out.as_str().unwrap();
644        assert!(s.contains("| Name | Type | Required | Default | Description |"));
645        assert!(s.contains("| `id` | integer | yes |  | User id |"));
646    }
647
648    #[test]
649    fn schema_json_passthrough() {
650        let schema = json!({"type": "object"});
651        let out = format_schema(&schema, SchemaStyle::Json, None);
652        assert_eq!(out.as_value().unwrap(), &schema);
653    }
654
655    #[test]
656    fn schema_max_depth_collapses_nested() {
657        let schema = json!({
658            "type": "object",
659            "properties": {
660                "outer": {
661                    "type": "object",
662                    "properties": {
663                        "inner": {
664                            "type": "object",
665                            "properties": {"deep": {"type": "string"}},
666                        },
667                    },
668                },
669            },
670        });
671        let out = format_schema(&schema, SchemaStyle::Prose, Some(2));
672        assert!(out.as_str().unwrap().contains("```json"));
673    }
674
675    #[test]
676    fn schema_non_object_renders_summary() {
677        let out = format_schema(&json!({"type": "string"}), SchemaStyle::Prose, None);
678        assert!(out.as_str().unwrap().contains("string"));
679    }
680
681    #[test]
682    fn schema_empty_prose_returns_empty() {
683        let out = format_schema(&json!({}), SchemaStyle::Prose, None);
684        assert_eq!(out.as_str().unwrap(), "");
685    }
686
687    // ---------- format_module markdown ----------
688
689    #[test]
690    fn module_markdown_emits_sections() {
691        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
692        let s = out.as_str().unwrap();
693        assert!(s.starts_with("# users.get_user"));
694        assert!(s.contains("Look up a user by id"));
695        assert!(s.contains("## Parameters"));
696        assert!(s.contains("## Returns"));
697        assert!(s.contains("`id` (integer, required) — User id"));
698    }
699
700    #[test]
701    fn module_markdown_annotations_fact_table() {
702        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
703        let s = out.as_str().unwrap();
704        assert!(s.contains("## Behavior"));
705        assert!(s.contains("| Flag | Value |"));
706        assert!(s.contains("`readonly`"));
707        assert!(s.contains("`cacheable`"));
708        // destructive matches the default; must not appear.
709        assert!(!s.contains("`destructive`"));
710    }
711
712    #[test]
713    fn module_markdown_annotations_lowercase_bool() {
714        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
715        let s = out.as_str().unwrap();
716        assert!(s.contains("| `readonly` | true |"));
717        assert!(s.contains("| `cacheable` | true |"));
718    }
719
720    #[test]
721    fn module_markdown_annotations_alphabetical() {
722        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
723        let s = out.as_str().unwrap();
724        let readonly_idx = s.find("`readonly`").unwrap();
725        let cacheable_idx = s.find("`cacheable`").unwrap();
726        // 'cacheable' < 'readonly' alphabetically
727        assert!(cacheable_idx < readonly_idx);
728    }
729
730    #[test]
731    fn module_markdown_skips_default_values() {
732        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
733        let s = out.as_str().unwrap();
734        // pagination_style defaults to "cursor"; must not appear.
735        assert!(!s.contains("`pagination_style`"));
736    }
737
738    #[test]
739    fn module_markdown_omits_behavior_when_all_defaults() {
740        let mut m = fixture_module();
741        m.annotations = Some(ModuleAnnotations::default());
742        let out = format_module(&m, ModuleStyle::Markdown, true);
743        assert!(!out.as_str().unwrap().contains("## Behavior"));
744    }
745
746    #[test]
747    fn module_markdown_omits_behavior_when_annotations_none() {
748        let mut m = fixture_module();
749        m.annotations = None;
750        let out = format_module(&m, ModuleStyle::Markdown, true);
751        assert!(!out.as_str().unwrap().contains("## Behavior"));
752    }
753
754    #[test]
755    fn module_markdown_examples_block() {
756        let mut m = fixture_module();
757        m.examples = vec![{
758            let mut ex = ModuleExample::default();
759            ex.title = "lookup".into();
760            ex.inputs = json!({"id": 1});
761            ex.output = json!({"name": "Ada"});
762            ex
763        }];
764        let out = format_module(&m, ModuleStyle::Markdown, true);
765        let s = out.as_str().unwrap();
766        assert!(s.contains("## Examples"));
767        assert!(s.contains("Ada"));
768    }
769
770    #[test]
771    fn module_markdown_tags_section() {
772        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
773        let s = out.as_str().unwrap();
774        assert!(s.contains("## Tags"));
775        assert!(s.contains("`users`"));
776    }
777
778    // ---------- format_module skill ----------
779
780    #[test]
781    fn module_skill_minimal_frontmatter() {
782        let out = format_module(&fixture_module(), ModuleStyle::Skill, true);
783        let s = out.as_str().unwrap();
784        assert!(s.starts_with("---\n"));
785        let head = s.split("\n---\n").next().unwrap();
786        assert!(head.contains("name: users.get_user"));
787        assert!(head.contains("description: "));
788        for forbidden in ["allowed-tools", "paths", "when_to_use", "user-invocable"] {
789            assert!(
790                !s.contains(forbidden),
791                "skill output leaked vendor key {forbidden}"
792            );
793        }
794    }
795
796    #[test]
797    fn module_skill_body_matches_markdown() {
798        let skill = format_module(&fixture_module(), ModuleStyle::Skill, true);
799        let markdown = format_module(&fixture_module(), ModuleStyle::Markdown, true);
800        let skill_str = skill.as_str().unwrap();
801        let body = skill_str.split_once("\n---\n").unwrap().1;
802        let body = body.trim_start_matches('\n');
803        assert_eq!(body, markdown.as_str().unwrap());
804    }
805
806    #[test]
807    fn module_skill_quotes_colon_in_description() {
808        let mut m = fixture_module();
809        m.description = "Get: by id".into();
810        let out = format_module(&m, ModuleStyle::Skill, true);
811        assert!(out
812            .as_str()
813            .unwrap()
814            .contains("description: \"Get: by id\""));
815    }
816
817    // ---------- format_module table-row + json ----------
818
819    #[test]
820    fn module_table_row_pipe_separated() {
821        let out = format_module(&fixture_module(), ModuleStyle::TableRow, true);
822        let s = out.as_str().unwrap();
823        assert!(s.contains("`users.get_user`"));
824        assert!(s.contains("Look up a user by id"));
825        assert!(s.contains("users"));
826    }
827
828    #[test]
829    fn module_json_passthrough() {
830        let out = format_module(&fixture_module(), ModuleStyle::Json, true);
831        let v = out.as_value().unwrap();
832        assert_eq!(v["module_id"], "users.get_user");
833        assert_eq!(v["description"], "Look up a user by id");
834    }
835
836    // ---------- display overlay ----------
837
838    #[test]
839    fn display_true_uses_overlay() {
840        let mut m = fixture_module();
841        m.display = Some(json!({
842            "alias": "lookup-user",
843            "description": "Quickly look someone up.",
844            "tags": ["accounts"],
845        }));
846        let out = format_module(&m, ModuleStyle::Markdown, true);
847        let s = out.as_str().unwrap();
848        assert!(s.contains("# lookup-user"));
849        assert!(s.contains("Quickly look someone up."));
850        assert!(s.contains("`accounts`"));
851    }
852
853    #[test]
854    fn display_false_uses_raw() {
855        let mut m = fixture_module();
856        m.display = Some(json!({"alias": "lookup-user", "description": "ignored"}));
857        let out = format_module(&m, ModuleStyle::Markdown, false);
858        let s = out.as_str().unwrap();
859        assert!(s.contains("# users.get_user"));
860        assert!(s.contains("Look up a user by id"));
861        assert!(!s.contains("lookup-user"));
862    }
863
864    // ---------- format_modules ----------
865
866    #[test]
867    fn modules_ungrouped_concatenates() {
868        let mut a = fixture_module();
869        let mut b = fixture_module();
870        b.module_id = "users.create_user".into();
871        b.description = "Create a user".into();
872        let out = format_modules(&[a.clone(), b.clone()], ModuleStyle::Markdown, None, true);
873        let s = out.as_str().unwrap();
874        assert!(s.contains("users.get_user"));
875        assert!(s.contains("users.create_user"));
876        // Avoid unused mut warning.
877        a.module_id.clear();
878    }
879
880    #[test]
881    fn modules_group_by_tag() {
882        let a = fixture_module();
883        let mut b = fixture_module();
884        b.module_id = "tasks.list".into();
885        b.description = "List tasks".into();
886        b.tags = vec!["tasks".into()];
887        let out = format_modules(&[a, b], ModuleStyle::Markdown, Some(GroupBy::Tag), true);
888        let s = out.as_str().unwrap();
889        assert!(s.contains("## users"));
890        assert!(s.contains("## tasks"));
891    }
892
893    #[test]
894    fn modules_group_by_prefix() {
895        let a = fixture_module();
896        let mut b = fixture_module();
897        b.module_id = "tasks.list".into();
898        b.description = "List tasks".into();
899        b.tags = vec![];
900        let out = format_modules(&[a, b], ModuleStyle::Markdown, Some(GroupBy::Prefix), true);
901        let s = out.as_str().unwrap();
902        assert!(s.contains("## users"));
903        assert!(s.contains("## tasks"));
904    }
905
906    #[test]
907    fn modules_json_returns_array_of_dicts() {
908        let m = fixture_module();
909        let out = format_modules(&[m], ModuleStyle::Json, None, true);
910        let arr = out.as_values().unwrap();
911        assert_eq!(arr.len(), 1);
912        assert_eq!(arr[0]["module_id"], "users.get_user");
913    }
914
915    #[test]
916    fn modules_untagged_bucket() {
917        let mut m = fixture_module();
918        m.tags = vec![];
919        let out = format_modules(&[m], ModuleStyle::Markdown, Some(GroupBy::Tag), true);
920        assert!(out.as_str().unwrap().contains("## (untagged)"));
921    }
922
923    // ---------- HEAD/OPTIONS canonical mapping (RFC 9110: readonly without cacheable) ----------
924
925    #[test]
926    fn scanner_head_options_canonical_mapping() {
927        use crate::scanner::infer_annotations_from_method;
928        let head = infer_annotations_from_method("HEAD");
929        let options = infer_annotations_from_method("OPTIONS");
930        // HEAD and OPTIONS are readonly per RFC 9110, but not cacheable by default —
931        // matching Python and TypeScript implementations and the canonical mapping
932        // in apcore-toolkit/docs/features/scanning.md.
933        assert!(head.readonly);
934        assert!(!head.cacheable);
935        assert!(options.readonly);
936        assert!(!options.cacheable);
937    }
938}