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    // serde_json::Map iterates in alphabetical key order under default
473    // features, so `entries` is already sorted. We do not re-sort.
474    let mut rows = vec!["| Flag | Value |".to_string(), "|---|---|".to_string()];
475    for (key, value) in entries {
476        let rendered = match value {
477            Value::String(s) => s.clone(),
478            Value::Bool(true) => "true".to_string(),
479            Value::Bool(false) => "false".to_string(),
480            other => other.to_string(),
481        };
482        rows.push(format!("| `{key}` | {rendered} |"));
483    }
484    Some(rows.join("\n"))
485}
486
487fn yaml_scalar(text: &str) -> String {
488    if text.is_empty() {
489        return "\"\"".to_string();
490    }
491    let needs_quote = text.chars().any(|c| {
492        matches!(
493            c,
494            ':' | '#' | '{' | '}' | '[' | ']' | '\'' | '"' | '\n' | '&' | '*' | '!' | '|' | '>'
495        )
496    });
497    let starts_special = text.starts_with(['-', '?', '%', '@', '`']);
498    if !needs_quote && !starts_special {
499        return text.to_string();
500    }
501    let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
502    format!("\"{escaped}\"")
503}
504
505/// Render a sequence of ScannedModule for the chosen surface.
506///
507/// See `Contract: format_modules` in
508/// `apcore-toolkit/docs/features/formatting.md`.
509pub fn format_modules(
510    modules: &[ScannedModule],
511    style: ModuleStyle,
512    group_by: Option<GroupBy>,
513    display: bool,
514) -> FormatOutput {
515    if matches!(style, ModuleStyle::Json) {
516        return FormatOutput::Values(modules.iter().map(module_to_dict).collect());
517    }
518
519    let joiner = match style {
520        ModuleStyle::Markdown | ModuleStyle::Skill => "\n\n",
521        ModuleStyle::TableRow => "\n",
522        ModuleStyle::Json => unreachable!(),
523    };
524
525    let render_one = |m: &ScannedModule| -> String {
526        match format_module(m, style, display) {
527            FormatOutput::Text(s) => s,
528            _ => unreachable!("non-text style handled above"),
529        }
530    };
531
532    let Some(axis) = group_by else {
533        let parts: Vec<String> = modules.iter().map(&render_one).collect();
534        return FormatOutput::Text(parts.join(joiner));
535    };
536
537    let groups = group_modules(modules, axis);
538    let mut out: Vec<String> = Vec::new();
539    for (group_name, members) in groups {
540        let header = match style {
541            ModuleStyle::Markdown | ModuleStyle::Skill => format!("## {group_name}"),
542            ModuleStyle::TableRow => format!("── {group_name} ──"),
543            ModuleStyle::Json => unreachable!(),
544        };
545        out.push(header);
546        for m in members {
547            out.push(render_one(m));
548        }
549    }
550    FormatOutput::Text(out.join(joiner))
551}
552
553fn group_modules<'a>(
554    modules: &'a [ScannedModule],
555    axis: GroupBy,
556) -> BTreeMap<String, Vec<&'a ScannedModule>> {
557    let mut groups: BTreeMap<String, Vec<&'a ScannedModule>> = BTreeMap::new();
558    for module in modules {
559        match axis {
560            GroupBy::Prefix => {
561                let prefix = match module.module_id.find('.') {
562                    Some(idx) => module.module_id[..idx].to_string(),
563                    None => module.module_id.clone(),
564                };
565                groups.entry(prefix).or_default().push(module);
566            }
567            GroupBy::Tag => {
568                if module.tags.is_empty() {
569                    groups
570                        .entry("(untagged)".to_string())
571                        .or_default()
572                        .push(module);
573                } else {
574                    for tag in &module.tags {
575                        groups.entry(tag.clone()).or_default().push(module);
576                    }
577                }
578            }
579        }
580    }
581    groups
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use apcore::module::{ModuleAnnotations, ModuleExample};
588    use serde_json::json;
589
590    fn fixture_module() -> ScannedModule {
591        let mut m = ScannedModule::new(
592            "users.get_user".into(),
593            "Look up a user by id".into(),
594            json!({
595                "type": "object",
596                "properties": {"id": {"type": "integer", "description": "User id"}},
597                "required": ["id"],
598            }),
599            json!({
600                "type": "object",
601                "properties": {"name": {"type": "string"}},
602            }),
603            vec!["users".into()],
604            "myapp.views:get_user".into(),
605        );
606        m.annotations = Some(ModuleAnnotations {
607            readonly: true,
608            cacheable: true,
609            ..Default::default()
610        });
611        m
612    }
613
614    // ---------- format_schema ----------
615
616    #[test]
617    fn schema_prose_marks_required_and_optional() {
618        let schema = json!({
619            "type": "object",
620            "properties": {
621                "id": {"type": "integer", "description": "User id"},
622                "verbose": {"type": "boolean"},
623            },
624            "required": ["id"],
625        });
626        let out = format_schema(&schema, SchemaStyle::Prose, None);
627        let s = out.as_str().unwrap();
628        assert!(s.contains("`id` (integer, required) — User id"), "got: {s}");
629        assert!(s.contains("`verbose` (boolean, optional)"));
630    }
631
632    #[test]
633    fn schema_table_emits_header_and_yes_no_required() {
634        let schema = json!({
635            "type": "object",
636            "properties": {"id": {"type": "integer", "description": "User id"}},
637            "required": ["id"],
638        });
639        let out = format_schema(&schema, SchemaStyle::Table, None);
640        let s = out.as_str().unwrap();
641        assert!(s.contains("| Name | Type | Required | Default | Description |"));
642        assert!(s.contains("| `id` | integer | yes |  | User id |"));
643    }
644
645    #[test]
646    fn schema_json_passthrough() {
647        let schema = json!({"type": "object"});
648        let out = format_schema(&schema, SchemaStyle::Json, None);
649        assert_eq!(out.as_value().unwrap(), &schema);
650    }
651
652    #[test]
653    fn schema_max_depth_collapses_nested() {
654        let schema = json!({
655            "type": "object",
656            "properties": {
657                "outer": {
658                    "type": "object",
659                    "properties": {
660                        "inner": {
661                            "type": "object",
662                            "properties": {"deep": {"type": "string"}},
663                        },
664                    },
665                },
666            },
667        });
668        let out = format_schema(&schema, SchemaStyle::Prose, Some(2));
669        assert!(out.as_str().unwrap().contains("```json"));
670    }
671
672    #[test]
673    fn schema_non_object_renders_summary() {
674        let out = format_schema(&json!({"type": "string"}), SchemaStyle::Prose, None);
675        assert!(out.as_str().unwrap().contains("string"));
676    }
677
678    #[test]
679    fn schema_empty_prose_returns_empty() {
680        let out = format_schema(&json!({}), SchemaStyle::Prose, None);
681        assert_eq!(out.as_str().unwrap(), "");
682    }
683
684    // ---------- format_module markdown ----------
685
686    #[test]
687    fn module_markdown_emits_sections() {
688        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
689        let s = out.as_str().unwrap();
690        assert!(s.starts_with("# users.get_user"));
691        assert!(s.contains("Look up a user by id"));
692        assert!(s.contains("## Parameters"));
693        assert!(s.contains("## Returns"));
694        assert!(s.contains("`id` (integer, required) — User id"));
695    }
696
697    #[test]
698    fn module_markdown_annotations_fact_table() {
699        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
700        let s = out.as_str().unwrap();
701        assert!(s.contains("## Behavior"));
702        assert!(s.contains("| Flag | Value |"));
703        assert!(s.contains("`readonly`"));
704        assert!(s.contains("`cacheable`"));
705        // destructive matches the default; must not appear.
706        assert!(!s.contains("`destructive`"));
707    }
708
709    #[test]
710    fn module_markdown_annotations_lowercase_bool() {
711        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
712        let s = out.as_str().unwrap();
713        assert!(s.contains("| `readonly` | true |"));
714        assert!(s.contains("| `cacheable` | true |"));
715    }
716
717    #[test]
718    fn module_markdown_annotations_alphabetical() {
719        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
720        let s = out.as_str().unwrap();
721        let readonly_idx = s.find("`readonly`").unwrap();
722        let cacheable_idx = s.find("`cacheable`").unwrap();
723        // 'cacheable' < 'readonly' alphabetically
724        assert!(cacheable_idx < readonly_idx);
725    }
726
727    #[test]
728    fn module_markdown_skips_default_values() {
729        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
730        let s = out.as_str().unwrap();
731        // pagination_style defaults to "cursor"; must not appear.
732        assert!(!s.contains("`pagination_style`"));
733    }
734
735    #[test]
736    fn module_markdown_omits_behavior_when_all_defaults() {
737        let mut m = fixture_module();
738        m.annotations = Some(ModuleAnnotations::default());
739        let out = format_module(&m, ModuleStyle::Markdown, true);
740        assert!(!out.as_str().unwrap().contains("## Behavior"));
741    }
742
743    #[test]
744    fn module_markdown_omits_behavior_when_annotations_none() {
745        let mut m = fixture_module();
746        m.annotations = None;
747        let out = format_module(&m, ModuleStyle::Markdown, true);
748        assert!(!out.as_str().unwrap().contains("## Behavior"));
749    }
750
751    #[test]
752    fn module_markdown_examples_block() {
753        let mut m = fixture_module();
754        m.examples = vec![{
755            let mut ex = ModuleExample::default();
756            ex.title = "lookup".into();
757            ex.inputs = json!({"id": 1});
758            ex.output = json!({"name": "Ada"});
759            ex
760        }];
761        let out = format_module(&m, ModuleStyle::Markdown, true);
762        let s = out.as_str().unwrap();
763        assert!(s.contains("## Examples"));
764        assert!(s.contains("Ada"));
765    }
766
767    #[test]
768    fn module_markdown_tags_section() {
769        let out = format_module(&fixture_module(), ModuleStyle::Markdown, true);
770        let s = out.as_str().unwrap();
771        assert!(s.contains("## Tags"));
772        assert!(s.contains("`users`"));
773    }
774
775    // ---------- format_module skill ----------
776
777    #[test]
778    fn module_skill_minimal_frontmatter() {
779        let out = format_module(&fixture_module(), ModuleStyle::Skill, true);
780        let s = out.as_str().unwrap();
781        assert!(s.starts_with("---\n"));
782        let head = s.split("\n---\n").next().unwrap();
783        assert!(head.contains("name: users.get_user"));
784        assert!(head.contains("description: "));
785        for forbidden in ["allowed-tools", "paths", "when_to_use", "user-invocable"] {
786            assert!(
787                !s.contains(forbidden),
788                "skill output leaked vendor key {forbidden}"
789            );
790        }
791    }
792
793    #[test]
794    fn module_skill_body_matches_markdown() {
795        let skill = format_module(&fixture_module(), ModuleStyle::Skill, true);
796        let markdown = format_module(&fixture_module(), ModuleStyle::Markdown, true);
797        let skill_str = skill.as_str().unwrap();
798        let body = skill_str.split_once("\n---\n").unwrap().1;
799        let body = body.trim_start_matches('\n');
800        assert_eq!(body, markdown.as_str().unwrap());
801    }
802
803    #[test]
804    fn module_skill_quotes_colon_in_description() {
805        let mut m = fixture_module();
806        m.description = "Get: by id".into();
807        let out = format_module(&m, ModuleStyle::Skill, true);
808        assert!(out
809            .as_str()
810            .unwrap()
811            .contains("description: \"Get: by id\""));
812    }
813
814    // ---------- format_module table-row + json ----------
815
816    #[test]
817    fn module_table_row_pipe_separated() {
818        let out = format_module(&fixture_module(), ModuleStyle::TableRow, true);
819        let s = out.as_str().unwrap();
820        assert!(s.contains("`users.get_user`"));
821        assert!(s.contains("Look up a user by id"));
822        assert!(s.contains("users"));
823    }
824
825    #[test]
826    fn module_json_passthrough() {
827        let out = format_module(&fixture_module(), ModuleStyle::Json, true);
828        let v = out.as_value().unwrap();
829        assert_eq!(v["module_id"], "users.get_user");
830        assert_eq!(v["description"], "Look up a user by id");
831    }
832
833    // ---------- display overlay ----------
834
835    #[test]
836    fn display_true_uses_overlay() {
837        let mut m = fixture_module();
838        m.display = Some(json!({
839            "alias": "lookup-user",
840            "description": "Quickly look someone up.",
841            "tags": ["accounts"],
842        }));
843        let out = format_module(&m, ModuleStyle::Markdown, true);
844        let s = out.as_str().unwrap();
845        assert!(s.contains("# lookup-user"));
846        assert!(s.contains("Quickly look someone up."));
847        assert!(s.contains("`accounts`"));
848    }
849
850    #[test]
851    fn display_false_uses_raw() {
852        let mut m = fixture_module();
853        m.display = Some(json!({"alias": "lookup-user", "description": "ignored"}));
854        let out = format_module(&m, ModuleStyle::Markdown, false);
855        let s = out.as_str().unwrap();
856        assert!(s.contains("# users.get_user"));
857        assert!(s.contains("Look up a user by id"));
858        assert!(!s.contains("lookup-user"));
859    }
860
861    // ---------- format_modules ----------
862
863    #[test]
864    fn modules_ungrouped_concatenates() {
865        let mut a = fixture_module();
866        let mut b = fixture_module();
867        b.module_id = "users.create_user".into();
868        b.description = "Create a user".into();
869        let out = format_modules(&[a.clone(), b.clone()], ModuleStyle::Markdown, None, true);
870        let s = out.as_str().unwrap();
871        assert!(s.contains("users.get_user"));
872        assert!(s.contains("users.create_user"));
873        // Avoid unused mut warning.
874        a.module_id.clear();
875    }
876
877    #[test]
878    fn modules_group_by_tag() {
879        let a = fixture_module();
880        let mut b = fixture_module();
881        b.module_id = "tasks.list".into();
882        b.description = "List tasks".into();
883        b.tags = vec!["tasks".into()];
884        let out = format_modules(&[a, b], ModuleStyle::Markdown, Some(GroupBy::Tag), true);
885        let s = out.as_str().unwrap();
886        assert!(s.contains("## users"));
887        assert!(s.contains("## tasks"));
888    }
889
890    #[test]
891    fn modules_group_by_prefix() {
892        let a = fixture_module();
893        let mut b = fixture_module();
894        b.module_id = "tasks.list".into();
895        b.description = "List tasks".into();
896        b.tags = vec![];
897        let out = format_modules(&[a, b], ModuleStyle::Markdown, Some(GroupBy::Prefix), true);
898        let s = out.as_str().unwrap();
899        assert!(s.contains("## users"));
900        assert!(s.contains("## tasks"));
901    }
902
903    #[test]
904    fn modules_json_returns_array_of_dicts() {
905        let m = fixture_module();
906        let out = format_modules(&[m], ModuleStyle::Json, None, true);
907        let arr = out.as_values().unwrap();
908        assert_eq!(arr.len(), 1);
909        assert_eq!(arr[0]["module_id"], "users.get_user");
910    }
911
912    #[test]
913    fn modules_untagged_bucket() {
914        let mut m = fixture_module();
915        m.tags = vec![];
916        let out = format_modules(&[m], ModuleStyle::Markdown, Some(GroupBy::Tag), true);
917        assert!(out.as_str().unwrap().contains("## (untagged)"));
918    }
919
920    // ---------- HEAD/OPTIONS canonical mapping (D10-001: all-false defaults) ----------
921
922    #[test]
923    fn scanner_head_options_canonical_mapping() {
924        use crate::scanner::infer_annotations_from_method;
925        let head = infer_annotations_from_method("HEAD");
926        let options = infer_annotations_from_method("OPTIONS");
927        assert!(!head.readonly);
928        assert!(!head.cacheable);
929        assert!(!options.readonly);
930        assert!(!options.cacheable);
931    }
932}