1use 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
19fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SchemaStyle {
40 Prose,
42 Table,
44 Json,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ModuleStyle {
51 Markdown,
53 Skill,
56 TableRow,
58 Json,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum GroupBy {
65 Tag,
68 Prefix,
70}
71
72#[derive(Debug, Clone)]
77pub enum FormatOutput {
78 Text(String),
79 Value(Value),
80 Values(Vec<Value>),
81}
82
83impl FormatOutput {
84 pub fn as_str(&self) -> Option<&str> {
86 match self {
87 FormatOutput::Text(s) => Some(s.as_str()),
88 _ => None,
89 }
90 }
91
92 pub fn as_value(&self) -> Option<&Value> {
94 match self {
95 FormatOutput::Value(v) => Some(v),
96 _ => None,
97 }
98 }
99
100 pub fn as_values(&self) -> Option<&[Value]> {
102 match self {
103 FormatOutput::Values(v) => Some(v),
104 _ => None,
105 }
106 }
107}
108
109#[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
118pub fn format_schema(
128 schema: &Value,
129 style: SchemaStyle,
130 max_depth: Option<usize>,
131) -> Result<FormatOutput, FormatError> {
132 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
295pub 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
462fn 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 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
533pub 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 #[test]
645 fn test_format_schema_returns_err_for_non_object_schema() {
646 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 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 let out = format_schema(&json!({}), SchemaStyle::Prose, None).unwrap();
725 assert_eq!(out.as_str().unwrap(), "");
726 }
727
728 #[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 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 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 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 #[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 #[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 #[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 #[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 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 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 #[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 assert!(head.readonly);
1004 assert!(!head.cacheable);
1005 assert!(options.readonly);
1006 assert!(!options.cacheable);
1007 }
1008}