1use crate::mode::OutputMode;
14use crate::themes::FastApiTheme;
15
16fn truncate_str(s: &str, max_bytes: usize) -> String {
19 if s.len() <= max_bytes {
20 return s.to_string();
21 }
22 let target = max_bytes.saturating_sub(3);
23 let end = s
25 .char_indices()
26 .map(|(i, _)| i)
27 .take_while(|&i| i <= target)
28 .last()
29 .unwrap_or(0);
30 format!("{}...", &s[..end])
31}
32
33const ANSI_RESET: &str = "\x1b[0m";
34const ANSI_BOLD: &str = "\x1b[1m";
35
36#[derive(Debug, Clone)]
38pub struct EndpointInfo {
39 pub method: String,
41 pub path: String,
43 pub summary: Option<String>,
45 pub description: Option<String>,
47 pub tags: Vec<String>,
49 pub deprecated: bool,
51 pub security: Vec<String>,
53 pub operation_id: Option<String>,
55}
56
57impl EndpointInfo {
58 #[must_use]
60 pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
61 Self {
62 method: method.into(),
63 path: path.into(),
64 summary: None,
65 description: None,
66 tags: Vec::new(),
67 deprecated: false,
68 security: Vec::new(),
69 operation_id: None,
70 }
71 }
72
73 #[must_use]
75 pub fn summary(mut self, summary: impl Into<String>) -> Self {
76 self.summary = Some(summary.into());
77 self
78 }
79
80 #[must_use]
82 pub fn description(mut self, description: impl Into<String>) -> Self {
83 self.description = Some(description.into());
84 self
85 }
86
87 #[must_use]
89 pub fn tag(mut self, tag: impl Into<String>) -> Self {
90 self.tags.push(tag.into());
91 self
92 }
93
94 #[must_use]
96 pub fn deprecated(mut self, deprecated: bool) -> Self {
97 self.deprecated = deprecated;
98 self
99 }
100
101 #[must_use]
103 pub fn security(mut self, security: impl Into<String>) -> Self {
104 self.security.push(security.into());
105 self
106 }
107
108 #[must_use]
110 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
111 self.operation_id = Some(id.into());
112 self
113 }
114}
115
116#[derive(Debug, Clone)]
118pub enum SchemaType {
119 String {
121 format: Option<String>,
123 enum_values: Vec<String>,
125 },
126 Integer {
128 format: Option<String>,
130 minimum: Option<i64>,
132 maximum: Option<i64>,
134 },
135 Number {
137 format: Option<String>,
139 },
140 Boolean,
142 Array {
144 items: Box<SchemaType>,
146 },
147 Object {
149 properties: Vec<PropertyInfo>,
151 required: Vec<String>,
153 },
154 Ref {
156 name: String,
158 },
159 AnyOf {
161 options: Vec<SchemaType>,
163 },
164 Null,
166}
167
168impl SchemaType {
169 #[must_use]
171 pub fn short_description(&self) -> String {
172 match self {
173 Self::String {
174 format,
175 enum_values,
176 } => {
177 if !enum_values.is_empty() {
178 format!("enum[{}]", enum_values.len())
179 } else if let Some(fmt) = format {
180 format!("string<{fmt}>")
181 } else {
182 "string".to_string()
183 }
184 }
185 Self::Integer { format, .. } => {
186 if let Some(fmt) = format {
187 format!("integer<{fmt}>")
188 } else {
189 "integer".to_string()
190 }
191 }
192 Self::Number { format } => {
193 if let Some(fmt) = format {
194 format!("number<{fmt}>")
195 } else {
196 "number".to_string()
197 }
198 }
199 Self::Boolean => "boolean".to_string(),
200 Self::Array { items } => format!("array[{}]", items.short_description()),
201 Self::Object { properties, .. } => format!("object{{{}}}", properties.len()),
202 Self::Ref { name } => format!("${name}"),
203 Self::AnyOf { options } => {
204 let types: Vec<_> = options.iter().map(SchemaType::short_description).collect();
205 types.join(" | ")
206 }
207 Self::Null => "null".to_string(),
208 }
209 }
210}
211
212#[derive(Debug, Clone)]
214pub struct PropertyInfo {
215 pub name: String,
217 pub schema: SchemaType,
219 pub description: Option<String>,
221 pub required: bool,
223 pub default: Option<String>,
225 pub example: Option<String>,
227}
228
229impl PropertyInfo {
230 #[must_use]
232 pub fn new(name: impl Into<String>, schema: SchemaType) -> Self {
233 Self {
234 name: name.into(),
235 schema,
236 description: None,
237 required: false,
238 default: None,
239 example: None,
240 }
241 }
242
243 #[must_use]
245 pub fn description(mut self, desc: impl Into<String>) -> Self {
246 self.description = Some(desc.into());
247 self
248 }
249
250 #[must_use]
252 pub fn required(mut self, required: bool) -> Self {
253 self.required = required;
254 self
255 }
256
257 #[must_use]
259 pub fn default(mut self, default: impl Into<String>) -> Self {
260 self.default = Some(default.into());
261 self
262 }
263
264 #[must_use]
266 pub fn example(mut self, example: impl Into<String>) -> Self {
267 self.example = Some(example.into());
268 self
269 }
270}
271
272#[derive(Debug, Clone)]
274pub struct OpenApiSummary {
275 pub title: String,
277 pub version: String,
279 pub description: Option<String>,
281 pub servers: Vec<String>,
283 pub endpoints: Vec<EndpointInfo>,
285 pub endpoint_count: usize,
287}
288
289impl OpenApiSummary {
290 #[must_use]
292 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
293 Self {
294 title: title.into(),
295 version: version.into(),
296 description: None,
297 servers: Vec::new(),
298 endpoints: Vec::new(),
299 endpoint_count: 0,
300 }
301 }
302
303 #[must_use]
305 pub fn description(mut self, desc: impl Into<String>) -> Self {
306 self.description = Some(desc.into());
307 self
308 }
309
310 #[must_use]
312 pub fn server(mut self, url: impl Into<String>) -> Self {
313 self.servers.push(url.into());
314 self
315 }
316
317 #[must_use]
319 pub fn endpoint(mut self, endpoint: EndpointInfo) -> Self {
320 self.endpoint_count += 1;
321 self.endpoints.push(endpoint);
322 self
323 }
324}
325
326#[derive(Debug, Clone)]
328#[allow(clippy::struct_excessive_bools)]
329pub struct OpenApiDisplayConfig {
330 pub show_descriptions: bool,
332 pub show_security: bool,
334 pub show_deprecated: bool,
336 pub group_by_tags: bool,
338 pub max_endpoints: usize,
340 pub max_schema_depth: usize,
342}
343
344impl Default for OpenApiDisplayConfig {
345 fn default() -> Self {
346 Self {
347 show_descriptions: false,
348 show_security: true,
349 show_deprecated: true,
350 group_by_tags: false,
351 max_endpoints: 0,
352 max_schema_depth: 5,
353 }
354 }
355}
356
357#[derive(Debug, Clone)]
359pub struct OpenApiDisplay {
360 mode: OutputMode,
361 theme: FastApiTheme,
362 config: OpenApiDisplayConfig,
363}
364
365impl OpenApiDisplay {
366 #[must_use]
368 pub fn new(mode: OutputMode) -> Self {
369 Self {
370 mode,
371 theme: FastApiTheme::default(),
372 config: OpenApiDisplayConfig::default(),
373 }
374 }
375
376 #[must_use]
378 pub fn with_config(mode: OutputMode, config: OpenApiDisplayConfig) -> Self {
379 Self {
380 mode,
381 theme: FastApiTheme::default(),
382 config,
383 }
384 }
385
386 #[must_use]
388 pub fn theme(mut self, theme: FastApiTheme) -> Self {
389 self.theme = theme;
390 self
391 }
392
393 #[allow(dead_code)]
395 #[allow(clippy::unused_self)]
396 fn group_endpoints_by_tag<'a>(
397 &self,
398 endpoints: &'a [EndpointInfo],
399 ) -> Vec<(String, Vec<&'a EndpointInfo>)> {
400 use std::collections::BTreeMap;
401
402 let mut groups: BTreeMap<String, Vec<&'a EndpointInfo>> = BTreeMap::new();
403
404 for endpoint in endpoints {
405 let tag = endpoint
406 .tags
407 .first()
408 .cloned()
409 .unwrap_or_else(|| "Other".to_string());
410 groups.entry(tag).or_default().push(endpoint);
411 }
412
413 groups.into_iter().collect()
414 }
415
416 #[must_use]
418 pub fn render_summary(&self, summary: &OpenApiSummary) -> String {
419 match self.mode {
420 OutputMode::Plain => self.render_summary_plain(summary),
421 OutputMode::Minimal => self.render_summary_minimal(summary),
422 OutputMode::Rich => self.render_summary_rich(summary),
423 }
424 }
425
426 fn render_summary_plain(&self, summary: &OpenApiSummary) -> String {
427 let mut lines = Vec::new();
428
429 lines.push(format!("{} v{}", summary.title, summary.version));
431 lines.push("=".repeat(summary.title.len() + summary.version.len() + 2));
432
433 if let Some(desc) = &summary.description {
434 lines.push(desc.clone());
435 }
436
437 if !summary.servers.is_empty() {
439 lines.push(String::new());
440 lines.push("Servers:".to_string());
441 for server in &summary.servers {
442 lines.push(format!(" - {server}"));
443 }
444 }
445
446 lines.push(String::new());
448 lines.push(format!("Endpoints ({}):", summary.endpoint_count));
449 lines.push(String::new());
450
451 let method_width = summary
453 .endpoints
454 .iter()
455 .map(|e| e.method.len())
456 .max()
457 .unwrap_or(6)
458 .max(6);
459 let path_width = summary
460 .endpoints
461 .iter()
462 .map(|e| e.path.len())
463 .max()
464 .unwrap_or(10)
465 .min(40);
466
467 lines.push(format!(
469 "{:width$} {:pwidth$} Summary",
470 "Method",
471 "Path",
472 width = method_width,
473 pwidth = path_width
474 ));
475 lines.push("-".repeat(method_width + path_width + 30));
476
477 let endpoints = if self.config.max_endpoints > 0 {
479 summary.endpoints.iter().take(self.config.max_endpoints)
480 } else {
481 summary.endpoints.iter().take(usize::MAX)
482 };
483
484 for endpoint in endpoints {
485 if !self.config.show_deprecated && endpoint.deprecated {
486 continue;
487 }
488
489 let path = truncate_str(&endpoint.path, path_width);
490
491 let summary_text = endpoint.summary.as_deref().unwrap_or("-");
492
493 let mut indicators = Vec::new();
495 if !endpoint.security.is_empty() {
496 indicators.push("[auth]");
497 }
498 if endpoint.deprecated {
499 indicators.push("[deprecated]");
500 }
501 let indicator_str = if indicators.is_empty() {
502 String::new()
503 } else {
504 format!(" {}", indicators.join(" "))
505 };
506
507 lines.push(format!(
508 "{:width$} {:pwidth$} {summary_text}{indicator_str}",
509 endpoint.method,
510 path,
511 width = method_width,
512 pwidth = path_width
513 ));
514 }
515
516 if self.config.max_endpoints > 0 && summary.endpoint_count > self.config.max_endpoints {
517 lines.push(format!(
518 "... and {} more",
519 summary.endpoint_count - self.config.max_endpoints
520 ));
521 }
522
523 let has_auth = summary.endpoints.iter().any(|e| !e.security.is_empty());
525 let has_deprecated = summary.endpoints.iter().any(|e| e.deprecated);
526
527 if has_auth || has_deprecated {
528 lines.push(String::new());
529 lines.push("Legend:".to_string());
530 if has_auth {
531 lines.push(" [auth] = Authentication required".to_string());
532 }
533 if has_deprecated {
534 lines.push(" [deprecated] = Endpoint is deprecated".to_string());
535 }
536 }
537
538 lines.join("\n")
539 }
540
541 fn render_summary_minimal(&self, summary: &OpenApiSummary) -> String {
542 let muted = self.theme.muted.to_ansi_fg();
543 let accent = self.theme.accent.to_ansi_fg();
544 let header = self.theme.header.to_ansi_fg();
545
546 let mut lines = Vec::new();
547
548 lines.push(format!(
550 "{header}{ANSI_BOLD}{}{ANSI_RESET} {muted}v{}{ANSI_RESET}",
551 summary.title, summary.version
552 ));
553
554 if let Some(desc) = &summary.description {
555 lines.push(format!("{muted}{desc}{ANSI_RESET}"));
556 }
557
558 lines.push(String::new());
560 lines.push(format!(
561 "{header}Endpoints{ANSI_RESET} {muted}({}){ANSI_RESET}",
562 summary.endpoint_count
563 ));
564
565 let endpoints = if self.config.max_endpoints > 0 {
566 summary.endpoints.iter().take(self.config.max_endpoints)
567 } else {
568 summary.endpoints.iter().take(usize::MAX)
569 };
570
571 for endpoint in endpoints {
572 if !self.config.show_deprecated && endpoint.deprecated {
573 continue;
574 }
575
576 let method_color = self.method_color(&endpoint.method).to_ansi_fg();
577
578 let mut indicators = Vec::new();
580 if !endpoint.security.is_empty() {
581 indicators.push("🔒");
582 }
583 if endpoint.deprecated {
584 indicators.push("⚠");
585 }
586 let indicator_str = if indicators.is_empty() {
587 String::new()
588 } else {
589 format!(" {}", indicators.join(" "))
590 };
591
592 let summary_text = endpoint
593 .summary
594 .as_ref()
595 .map(|s| format!(" {muted}- {s}{ANSI_RESET}"))
596 .unwrap_or_default();
597
598 lines.push(format!(
599 " {method_color}{:7}{ANSI_RESET} {accent}{}{ANSI_RESET}{summary_text}{indicator_str}",
600 endpoint.method, endpoint.path
601 ));
602 }
603
604 let has_auth = summary.endpoints.iter().any(|e| !e.security.is_empty());
606 let has_deprecated = summary.endpoints.iter().any(|e| e.deprecated);
607
608 if has_auth || has_deprecated {
609 lines.push(String::new());
610 if has_auth {
611 lines.push(format!("{muted}🔒 = Auth required{ANSI_RESET}"));
612 }
613 if has_deprecated {
614 lines.push(format!("{muted}⚠ = Deprecated{ANSI_RESET}"));
615 }
616 }
617
618 lines.join("\n")
619 }
620
621 #[allow(clippy::too_many_lines)]
622 fn render_summary_rich(&self, summary: &OpenApiSummary) -> String {
623 let muted = self.theme.muted.to_ansi_fg();
624 let _accent = self.theme.accent.to_ansi_fg();
625 let border = self.theme.border.to_ansi_fg();
626 let header_style = self.theme.header.to_ansi_fg();
627 let success = self.theme.success.to_ansi_fg();
628
629 let mut lines = Vec::new();
630
631 let path_width = summary
633 .endpoints
634 .iter()
635 .map(|e| e.path.len())
636 .max()
637 .unwrap_or(10)
638 .min(35);
639 let summary_width = 25;
640 let table_width = 9 + path_width + summary_width + 4; lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(table_width)));
644
645 let title_text = format!("{} v{}", summary.title, summary.version);
647 let title_pad = (table_width - title_text.len()) / 2;
648 lines.push(format!(
649 "{border}│{ANSI_RESET}{}{header_style}{ANSI_BOLD}{title_text}{ANSI_RESET}{}{border}│{ANSI_RESET}",
650 " ".repeat(title_pad),
651 " ".repeat(table_width - title_pad - title_text.len())
652 ));
653
654 if let Some(desc) = &summary.description {
656 let desc_truncated = truncate_str(desc, table_width.saturating_sub(4));
657 lines.push(format!(
658 "{border}│{ANSI_RESET} {muted}{:width$}{ANSI_RESET} {border}│{ANSI_RESET}",
659 desc_truncated,
660 width = table_width - 2
661 ));
662 }
663
664 lines.push(format!("{border}├{}┤{ANSI_RESET}", "─".repeat(table_width)));
665
666 lines.push(format!(
668 "{border}│{ANSI_RESET} {header_style}{:7}{ANSI_RESET} {header_style}{:pwidth$}{ANSI_RESET} {header_style}{:swidth$}{ANSI_RESET} {border}│{ANSI_RESET}",
669 "Method",
670 "Path",
671 "Summary",
672 pwidth = path_width,
673 swidth = summary_width
674 ));
675 lines.push(format!("{border}├{}┤{ANSI_RESET}", "─".repeat(table_width)));
676
677 let endpoints = if self.config.max_endpoints > 0 {
679 summary.endpoints.iter().take(self.config.max_endpoints)
680 } else {
681 summary.endpoints.iter().take(usize::MAX)
682 };
683
684 for endpoint in endpoints {
685 if !self.config.show_deprecated && endpoint.deprecated {
686 continue;
687 }
688
689 let method_bg = self.method_color(&endpoint.method).to_ansi_bg();
690
691 let path = truncate_str(&endpoint.path, path_width);
692
693 let summary_text = endpoint
694 .summary
695 .as_ref()
696 .map_or_else(|| "-".to_string(), |s| truncate_str(s, summary_width));
697
698 let mut indicators = Vec::new();
700 if !endpoint.security.is_empty() {
701 indicators.push("🔒");
702 }
703 if endpoint.deprecated {
704 indicators.push("⚠");
705 }
706 let indicator_str = if indicators.is_empty() {
707 String::new()
708 } else {
709 format!(" {muted}{}{ANSI_RESET}", indicators.join(" "))
710 };
711
712 lines.push(format!(
713 "{border}│{ANSI_RESET} {method_bg}{ANSI_BOLD} {:5} {ANSI_RESET} {:pwidth$}{indicator_str} {muted}{:swidth$}{ANSI_RESET} {border}│{ANSI_RESET}",
714 endpoint.method,
715 path,
716 summary_text,
717 pwidth = path_width,
718 swidth = summary_width
719 ));
720 }
721
722 lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(table_width)));
724
725 let has_auth = summary.endpoints.iter().any(|e| !e.security.is_empty());
727 let has_deprecated = summary.endpoints.iter().any(|e| e.deprecated);
728
729 if has_auth || has_deprecated {
730 lines.push(String::new());
731 if has_auth {
732 lines.push(format!("{muted} 🔒 = Authentication required{ANSI_RESET}"));
733 }
734 if has_deprecated {
735 lines.push(format!("{muted} ⚠ = Deprecated{ANSI_RESET}"));
736 }
737 }
738
739 lines.push(String::new());
740
741 lines.push(format!(
743 "{success}✓{ANSI_RESET} {muted}{} endpoint(s) documented{ANSI_RESET}",
744 summary.endpoint_count
745 ));
746
747 lines.join("\n")
748 }
749
750 #[must_use]
752 pub fn render_schema(&self, schema: &SchemaType, title: Option<&str>) -> String {
753 match self.mode {
754 OutputMode::Plain => self.render_schema_plain(schema, title, 0),
755 OutputMode::Minimal => self.render_schema_minimal(schema, title, 0),
756 OutputMode::Rich => self.render_schema_rich(schema, title),
757 }
758 }
759
760 #[allow(clippy::self_only_used_in_recursion)]
761 fn render_schema_plain(
762 &self,
763 schema: &SchemaType,
764 title: Option<&str>,
765 depth: usize,
766 ) -> String {
767 let mut lines = Vec::new();
768 let prefix = " ".repeat(depth * 2);
769
770 if depth > self.config.max_schema_depth {
772 return format!("{prefix}... (max depth exceeded)");
773 }
774
775 if let Some(t) = title {
776 lines.push(format!("{prefix}{t}:"));
777 }
778
779 match schema {
780 SchemaType::Object {
781 properties,
782 required,
783 } => {
784 lines.push(format!("{prefix}{{"));
785 for prop in properties {
786 let required_marker = if prop.required || required.contains(&prop.name) {
787 " (required)"
788 } else {
789 ""
790 };
791 let type_desc = prop.schema.short_description();
792 lines.push(format!(
793 "{prefix} \"{}\": {type_desc}{required_marker}",
794 prop.name
795 ));
796
797 match &prop.schema {
799 SchemaType::Object { .. } | SchemaType::Array { .. } => {
800 lines.push(self.render_schema_plain(&prop.schema, None, depth + 2));
801 }
802 _ => {}
803 }
804 }
805 lines.push(format!("{prefix}}}"));
806 }
807 SchemaType::Array { items } => {
808 lines.push(format!("{prefix}["));
809 lines.push(self.render_schema_plain(items, None, depth + 1));
810 lines.push(format!("{prefix}]"));
811 }
812 SchemaType::String { enum_values, .. } if !enum_values.is_empty() => {
813 lines.push(format!("{prefix}enum: [{}]", enum_values.join(", ")));
814 }
815 _ => {
816 lines.push(format!("{prefix}{}", schema.short_description()));
817 }
818 }
819
820 lines.join("\n")
821 }
822
823 fn render_schema_minimal(
824 &self,
825 schema: &SchemaType,
826 title: Option<&str>,
827 depth: usize,
828 ) -> String {
829 let muted = self.theme.muted.to_ansi_fg();
830 let accent = self.theme.accent.to_ansi_fg();
831 let info = self.theme.info.to_ansi_fg();
832
833 let mut lines = Vec::new();
834 let prefix = " ".repeat(depth * 2);
835
836 if depth > self.config.max_schema_depth {
838 return format!("{prefix}{muted}... (max depth){ANSI_RESET}");
839 }
840
841 if let Some(t) = title {
842 lines.push(format!("{prefix}{accent}{t}:{ANSI_RESET}"));
843 }
844
845 match schema {
846 SchemaType::Object {
847 properties,
848 required,
849 } => {
850 lines.push(format!("{prefix}{muted}{{{ANSI_RESET}"));
851 for prop in properties {
852 let required_marker = if prop.required || required.contains(&prop.name) {
853 format!(" {info}*{ANSI_RESET}")
854 } else {
855 String::new()
856 };
857 let type_desc = prop.schema.short_description();
858 lines.push(format!(
859 "{prefix} {accent}\"{}\"{ANSI_RESET}: {muted}{type_desc}{ANSI_RESET}{required_marker}",
860 prop.name
861 ));
862 }
863 lines.push(format!("{prefix}{muted}}}{ANSI_RESET}"));
864 }
865 SchemaType::Array { items } => {
866 lines.push(format!("{prefix}{muted}[{ANSI_RESET}"));
867 lines.push(self.render_schema_minimal(items, None, depth + 1));
868 lines.push(format!("{prefix}{muted}]{ANSI_RESET}"));
869 }
870 _ => {
871 lines.push(format!(
872 "{prefix}{info}{}{ANSI_RESET}",
873 schema.short_description()
874 ));
875 }
876 }
877
878 lines.join("\n")
879 }
880
881 fn render_schema_rich(&self, schema: &SchemaType, title: Option<&str>) -> String {
882 let muted = self.theme.muted.to_ansi_fg();
883 let accent = self.theme.accent.to_ansi_fg();
884 let border = self.theme.border.to_ansi_fg();
885 let header_style = self.theme.header.to_ansi_fg();
886 let info = self.theme.info.to_ansi_fg();
887 let warning = self.theme.warning.to_ansi_fg();
888
889 let mut lines = Vec::new();
890
891 let width = 45;
893 lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(width)));
894
895 if let Some(t) = title {
896 lines.push(format!(
897 "{border}│{ANSI_RESET} {header_style}{ANSI_BOLD}{t}{ANSI_RESET}{}",
898 " ".repeat(width - t.len() - 1)
899 ));
900 lines.push(format!("{border}├{}┤{ANSI_RESET}", "─".repeat(width)));
901 }
902
903 match schema {
904 SchemaType::Object {
905 properties,
906 required,
907 } => {
908 for prop in properties {
909 let required_marker = if prop.required || required.contains(&prop.name) {
910 format!(" {warning}*{ANSI_RESET}")
911 } else {
912 String::new()
913 };
914 let type_desc = prop.schema.short_description();
915
916 lines.push(format!(
917 "{border}│{ANSI_RESET} {accent}\"{}\"{ANSI_RESET}: {info}{type_desc}{ANSI_RESET}{required_marker}",
918 prop.name
919 ));
920
921 if let Some(desc) = &prop.description {
922 let desc_truncated = truncate_str(desc, width.saturating_sub(6));
923 lines.push(format!(
924 "{border}│{ANSI_RESET} {muted}{desc_truncated}{ANSI_RESET}"
925 ));
926 }
927 }
928 }
929 SchemaType::Array { items } => {
930 lines.push(format!(
931 "{border}│{ANSI_RESET} {muted}Array of:{ANSI_RESET} {info}{}{ANSI_RESET}",
932 items.short_description()
933 ));
934 }
935 SchemaType::String { enum_values, .. } if !enum_values.is_empty() => {
936 lines.push(format!(
937 "{border}│{ANSI_RESET} {muted}Enum values:{ANSI_RESET}"
938 ));
939 for val in enum_values {
940 lines.push(format!(
941 "{border}│{ANSI_RESET} {accent}• {val}{ANSI_RESET}"
942 ));
943 }
944 }
945 _ => {
946 lines.push(format!(
947 "{border}│{ANSI_RESET} {info}{}{ANSI_RESET}",
948 schema.short_description()
949 ));
950 }
951 }
952
953 lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(width)));
954
955 lines.join("\n")
956 }
957
958 fn method_color(&self, method: &str) -> crate::themes::Color {
959 match method.to_uppercase().as_str() {
960 "GET" => self.theme.http_get,
961 "POST" => self.theme.http_post,
962 "PUT" => self.theme.http_put,
963 "DELETE" => self.theme.http_delete,
964 "PATCH" => self.theme.http_patch,
965 "OPTIONS" => self.theme.http_options,
966 "HEAD" => self.theme.http_head,
967 _ => self.theme.muted,
968 }
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 fn sample_summary() -> OpenApiSummary {
977 OpenApiSummary::new("My API", "1.0.0")
978 .description("A sample REST API")
979 .server("https://api.example.com")
980 .endpoint(
981 EndpointInfo::new("GET", "/users")
982 .summary("List all users")
983 .tag("users"),
984 )
985 .endpoint(
986 EndpointInfo::new("POST", "/users")
987 .summary("Create a new user")
988 .tag("users"),
989 )
990 .endpoint(
991 EndpointInfo::new("GET", "/users/{id}")
992 .summary("Get user by ID")
993 .tag("users"),
994 )
995 .endpoint(
996 EndpointInfo::new("DELETE", "/users/{id}")
997 .summary("Delete user")
998 .tag("users")
999 .deprecated(true),
1000 )
1001 }
1002
1003 fn sample_schema() -> SchemaType {
1004 SchemaType::Object {
1005 properties: vec![
1006 PropertyInfo::new(
1007 "id",
1008 SchemaType::Integer {
1009 format: Some("int64".to_string()),
1010 minimum: None,
1011 maximum: None,
1012 },
1013 )
1014 .description("Unique identifier")
1015 .required(true),
1016 PropertyInfo::new(
1017 "name",
1018 SchemaType::String {
1019 format: None,
1020 enum_values: vec![],
1021 },
1022 )
1023 .description("User's full name")
1024 .required(true),
1025 PropertyInfo::new(
1026 "email",
1027 SchemaType::String {
1028 format: Some("email".to_string()),
1029 enum_values: vec![],
1030 },
1031 )
1032 .description("Email address"),
1033 PropertyInfo::new(
1034 "status",
1035 SchemaType::String {
1036 format: None,
1037 enum_values: vec![
1038 "active".to_string(),
1039 "inactive".to_string(),
1040 "pending".to_string(),
1041 ],
1042 },
1043 )
1044 .default("pending"),
1045 ],
1046 required: vec!["id".to_string(), "name".to_string()],
1047 }
1048 }
1049
1050 #[test]
1051 fn test_endpoint_info_builder() {
1052 let endpoint = EndpointInfo::new("POST", "/users")
1053 .summary("Create user")
1054 .tag("users")
1055 .security("bearer")
1056 .deprecated(false);
1057
1058 assert_eq!(endpoint.method, "POST");
1059 assert_eq!(endpoint.path, "/users");
1060 assert_eq!(endpoint.summary, Some("Create user".to_string()));
1061 }
1062
1063 #[test]
1064 fn test_schema_type_description() {
1065 assert_eq!(SchemaType::Boolean.short_description(), "boolean");
1066 assert_eq!(
1067 SchemaType::String {
1068 format: Some("email".to_string()),
1069 enum_values: vec![]
1070 }
1071 .short_description(),
1072 "string<email>"
1073 );
1074 assert_eq!(
1075 SchemaType::Array {
1076 items: Box::new(SchemaType::Boolean)
1077 }
1078 .short_description(),
1079 "array[boolean]"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_openapi_display_plain() {
1085 let display = OpenApiDisplay::new(OutputMode::Plain);
1086 let output = display.render_summary(&sample_summary());
1087
1088 assert!(output.contains("My API"));
1089 assert!(output.contains("v1.0.0"));
1090 assert!(output.contains("GET"));
1091 assert!(output.contains("/users"));
1092 assert!(output.contains("List all users"));
1093 assert!(!output.contains("\x1b["));
1094 }
1095
1096 #[test]
1097 fn test_openapi_display_rich_has_ansi() {
1098 let display = OpenApiDisplay::new(OutputMode::Rich);
1099 let output = display.render_summary(&sample_summary());
1100
1101 assert!(output.contains("\x1b["));
1102 assert!(output.contains("My API"));
1103 }
1104
1105 #[test]
1106 fn test_schema_display_plain() {
1107 let display = OpenApiDisplay::new(OutputMode::Plain);
1108 let output = display.render_schema(&sample_schema(), Some("User"));
1109
1110 assert!(output.contains("User:"));
1111 assert!(output.contains("\"id\""));
1112 assert!(output.contains("\"name\""));
1113 assert!(output.contains("required"));
1114 }
1115
1116 #[test]
1117 fn test_schema_display_rich() {
1118 let display = OpenApiDisplay::new(OutputMode::Rich);
1119 let output = display.render_schema(&sample_schema(), Some("User"));
1120
1121 assert!(output.contains("\x1b["));
1122 assert!(output.contains("id"));
1123 }
1124
1125 #[test]
1126 fn test_max_endpoints_config() {
1127 let config = OpenApiDisplayConfig {
1128 max_endpoints: 2,
1129 ..Default::default()
1130 };
1131 let display = OpenApiDisplay::with_config(OutputMode::Plain, config);
1132 let output = display.render_summary(&sample_summary());
1133
1134 assert!(output.contains("and 2 more"));
1135 }
1136
1137 #[test]
1138 fn test_auth_indicator_plain() {
1139 let summary = OpenApiSummary::new("Auth API", "1.0.0")
1140 .endpoint(EndpointInfo::new("GET", "/public").summary("Public endpoint"))
1141 .endpoint(
1142 EndpointInfo::new("POST", "/protected")
1143 .summary("Protected endpoint")
1144 .security("bearer"),
1145 );
1146
1147 let display = OpenApiDisplay::new(OutputMode::Plain);
1148 let output = display.render_summary(&summary);
1149
1150 assert!(output.contains("[auth]"), "Should show [auth] indicator");
1151 assert!(output.contains("Legend:"), "Should show legend");
1152 assert!(output.contains("Authentication required"));
1153 }
1154
1155 #[test]
1156 fn test_auth_indicator_rich() {
1157 let summary = OpenApiSummary::new("Auth API", "1.0.0").endpoint(
1158 EndpointInfo::new("POST", "/protected")
1159 .summary("Protected endpoint")
1160 .security("bearer"),
1161 );
1162
1163 let display = OpenApiDisplay::new(OutputMode::Rich);
1164 let output = display.render_summary(&summary);
1165
1166 assert!(output.contains("🔒"), "Should show lock indicator");
1167 assert!(output.contains("Authentication required"));
1168 }
1169
1170 #[test]
1171 fn test_deprecated_indicator_rich() {
1172 let summary = OpenApiSummary::new("API", "1.0.0").endpoint(
1173 EndpointInfo::new("GET", "/old")
1174 .summary("Old endpoint")
1175 .deprecated(true),
1176 );
1177
1178 let display = OpenApiDisplay::new(OutputMode::Rich);
1179 let output = display.render_summary(&summary);
1180
1181 assert!(output.contains("⚠"), "Should show deprecated indicator");
1182 assert!(output.contains("Deprecated"));
1183 }
1184
1185 #[test]
1186 fn test_combined_indicators() {
1187 let summary = OpenApiSummary::new("API", "1.0.0").endpoint(
1188 EndpointInfo::new("POST", "/old-protected")
1189 .summary("Old protected endpoint")
1190 .security("bearer")
1191 .deprecated(true),
1192 );
1193
1194 let display = OpenApiDisplay::new(OutputMode::Rich);
1195 let output = display.render_summary(&summary);
1196
1197 assert!(output.contains("🔒"), "Should show lock indicator");
1198 assert!(output.contains("⚠"), "Should show deprecated indicator");
1199 }
1200
1201 #[test]
1202 fn test_no_legend_when_no_indicators() {
1203 let summary = OpenApiSummary::new("Simple API", "1.0.0")
1204 .endpoint(EndpointInfo::new("GET", "/simple").summary("Simple endpoint"));
1205
1206 let display = OpenApiDisplay::new(OutputMode::Plain);
1207 let output = display.render_summary(&summary);
1208
1209 assert!(
1210 !output.contains("Legend:"),
1211 "Should not show legend when no special endpoints"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_group_endpoints_helper() {
1217 let display = OpenApiDisplay::new(OutputMode::Plain);
1218 let endpoints = vec![
1219 EndpointInfo::new("GET", "/users").tag("users"),
1220 EndpointInfo::new("POST", "/users").tag("users"),
1221 EndpointInfo::new("GET", "/items").tag("items"),
1222 EndpointInfo::new("GET", "/health"), ];
1224
1225 let groups = display.group_endpoints_by_tag(&endpoints);
1226
1227 assert_eq!(groups.len(), 3);
1228
1229 let users_group = groups.iter().find(|(tag, _)| tag == "users");
1231 assert!(users_group.is_some());
1232 assert_eq!(users_group.unwrap().1.len(), 2);
1233
1234 let other_group = groups.iter().find(|(tag, _)| tag == "Other");
1235 assert!(other_group.is_some());
1236 assert_eq!(other_group.unwrap().1.len(), 1);
1237 }
1238
1239 #[test]
1240 fn test_schema_depth_limiting() {
1241 let deep_schema = SchemaType::Object {
1243 properties: vec![PropertyInfo::new(
1244 "level1",
1245 SchemaType::Object {
1246 properties: vec![PropertyInfo::new(
1247 "level2",
1248 SchemaType::Object {
1249 properties: vec![PropertyInfo::new(
1250 "level3",
1251 SchemaType::Object {
1252 properties: vec![PropertyInfo::new(
1253 "level4",
1254 SchemaType::Object {
1255 properties: vec![PropertyInfo::new(
1256 "level5",
1257 SchemaType::Object {
1258 properties: vec![PropertyInfo::new(
1259 "level6",
1260 SchemaType::String {
1261 format: None,
1262 enum_values: vec![],
1263 },
1264 )],
1265 required: vec![],
1266 },
1267 )],
1268 required: vec![],
1269 },
1270 )],
1271 required: vec![],
1272 },
1273 )],
1274 required: vec![],
1275 },
1276 )],
1277 required: vec![],
1278 },
1279 )],
1280 required: vec![],
1281 };
1282
1283 let config = OpenApiDisplayConfig {
1285 max_schema_depth: 2,
1286 ..Default::default()
1287 };
1288 let display = OpenApiDisplay::with_config(OutputMode::Plain, config);
1289 let output = display.render_schema(&deep_schema, Some("DeepSchema"));
1290
1291 assert!(
1292 output.contains("max depth"),
1293 "Should show max depth message for deep nesting"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_nested_schema_rendering() {
1299 let schema = SchemaType::Object {
1300 properties: vec![
1301 PropertyInfo::new(
1302 "id",
1303 SchemaType::Integer {
1304 format: Some("int64".to_string()),
1305 minimum: None,
1306 maximum: None,
1307 },
1308 ),
1309 PropertyInfo::new(
1310 "items",
1311 SchemaType::Array {
1312 items: Box::new(SchemaType::Object {
1313 properties: vec![PropertyInfo::new(
1314 "name",
1315 SchemaType::String {
1316 format: None,
1317 enum_values: vec![],
1318 },
1319 )],
1320 required: vec!["name".to_string()],
1321 }),
1322 },
1323 ),
1324 ],
1325 required: vec!["id".to_string()],
1326 };
1327
1328 let display = OpenApiDisplay::new(OutputMode::Plain);
1329 let output = display.render_schema(&schema, Some("Order"));
1330
1331 assert!(output.contains("id"), "Should contain id field");
1332 assert!(output.contains("items"), "Should contain items array");
1333 assert!(output.contains("array"), "Should show array type");
1334 }
1335}