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