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