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 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
505pub 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 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 #[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}