Skip to main content

fastapi_output/components/
openapi_display.rs

1//! OpenAPI schema display component.
2//!
3//! Provides visual representation of OpenAPI specifications including
4//! endpoint tables, schema visualization, and documentation display.
5//!
6//! # Features
7//!
8//! - Endpoint summary table with method coloring
9//! - Schema type visualization (objects, arrays, enums)
10//! - Request/response body display
11//! - Authentication requirements display
12
13use crate::mode::OutputMode;
14use crate::themes::FastApiTheme;
15
16/// Truncate a string to at most `max_bytes` bytes, respecting UTF-8 boundaries.
17/// Appends "..." if truncated.
18fn 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    // Find the last valid UTF-8 char boundary at or before `target`
24    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/// An OpenAPI endpoint for display.
37#[derive(Debug, Clone)]
38pub struct EndpointInfo {
39    /// HTTP method.
40    pub method: String,
41    /// Path pattern.
42    pub path: String,
43    /// Operation summary.
44    pub summary: Option<String>,
45    /// Operation description.
46    pub description: Option<String>,
47    /// Tags for grouping.
48    pub tags: Vec<String>,
49    /// Whether deprecated.
50    pub deprecated: bool,
51    /// Security requirements.
52    pub security: Vec<String>,
53    /// Operation ID.
54    pub operation_id: Option<String>,
55}
56
57impl EndpointInfo {
58    /// Create a new endpoint info.
59    #[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    /// Set the summary.
74    #[must_use]
75    pub fn summary(mut self, summary: impl Into<String>) -> Self {
76        self.summary = Some(summary.into());
77        self
78    }
79
80    /// Set the description.
81    #[must_use]
82    pub fn description(mut self, description: impl Into<String>) -> Self {
83        self.description = Some(description.into());
84        self
85    }
86
87    /// Add a tag.
88    #[must_use]
89    pub fn tag(mut self, tag: impl Into<String>) -> Self {
90        self.tags.push(tag.into());
91        self
92    }
93
94    /// Mark as deprecated.
95    #[must_use]
96    pub fn deprecated(mut self, deprecated: bool) -> Self {
97        self.deprecated = deprecated;
98        self
99    }
100
101    /// Add a security requirement.
102    #[must_use]
103    pub fn security(mut self, security: impl Into<String>) -> Self {
104        self.security.push(security.into());
105        self
106    }
107
108    /// Set the operation ID.
109    #[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/// Schema type for display.
117#[derive(Debug, Clone)]
118pub enum SchemaType {
119    /// String type.
120    String {
121        /// Format (e.g., "email", "date-time").
122        format: Option<String>,
123        /// Enum values if constrained.
124        enum_values: Vec<String>,
125    },
126    /// Integer type.
127    Integer {
128        /// Format (e.g., "int32", "int64").
129        format: Option<String>,
130        /// Minimum value.
131        minimum: Option<i64>,
132        /// Maximum value.
133        maximum: Option<i64>,
134    },
135    /// Number type.
136    Number {
137        /// Format (e.g., "float", "double").
138        format: Option<String>,
139    },
140    /// Boolean type.
141    Boolean,
142    /// Array type.
143    Array {
144        /// Item schema.
145        items: Box<SchemaType>,
146    },
147    /// Object type.
148    Object {
149        /// Properties.
150        properties: Vec<PropertyInfo>,
151        /// Required property names.
152        required: Vec<String>,
153    },
154    /// Reference to another schema.
155    Ref {
156        /// Reference name.
157        name: String,
158    },
159    /// Any of (union type).
160    AnyOf {
161        /// Options.
162        options: Vec<SchemaType>,
163    },
164    /// Null type.
165    Null,
166}
167
168impl SchemaType {
169    /// Get a short type description.
170    #[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/// Property information for object schemas.
213#[derive(Debug, Clone)]
214pub struct PropertyInfo {
215    /// Property name.
216    pub name: String,
217    /// Property type.
218    pub schema: SchemaType,
219    /// Description.
220    pub description: Option<String>,
221    /// Whether required.
222    pub required: bool,
223    /// Default value.
224    pub default: Option<String>,
225    /// Example value.
226    pub example: Option<String>,
227}
228
229impl PropertyInfo {
230    /// Create a new property info.
231    #[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    /// Set the description.
244    #[must_use]
245    pub fn description(mut self, desc: impl Into<String>) -> Self {
246        self.description = Some(desc.into());
247        self
248    }
249
250    /// Mark as required.
251    #[must_use]
252    pub fn required(mut self, required: bool) -> Self {
253        self.required = required;
254        self
255    }
256
257    /// Set the default value.
258    #[must_use]
259    pub fn default(mut self, default: impl Into<String>) -> Self {
260        self.default = Some(default.into());
261        self
262    }
263
264    /// Set the example value.
265    #[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/// OpenAPI spec summary for display.
273#[derive(Debug, Clone)]
274pub struct OpenApiSummary {
275    /// API title.
276    pub title: String,
277    /// API version.
278    pub version: String,
279    /// API description.
280    pub description: Option<String>,
281    /// Server URLs.
282    pub servers: Vec<String>,
283    /// Endpoints.
284    pub endpoints: Vec<EndpointInfo>,
285    /// Total endpoint count.
286    pub endpoint_count: usize,
287}
288
289impl OpenApiSummary {
290    /// Create a new OpenAPI summary.
291    #[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    /// Set the description.
304    #[must_use]
305    pub fn description(mut self, desc: impl Into<String>) -> Self {
306        self.description = Some(desc.into());
307        self
308    }
309
310    /// Add a server URL.
311    #[must_use]
312    pub fn server(mut self, url: impl Into<String>) -> Self {
313        self.servers.push(url.into());
314        self
315    }
316
317    /// Add an endpoint.
318    #[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/// Configuration for OpenAPI display.
327#[derive(Debug, Clone)]
328#[allow(clippy::struct_excessive_bools)]
329pub struct OpenApiDisplayConfig {
330    /// Show endpoint descriptions.
331    pub show_descriptions: bool,
332    /// Show security requirements.
333    pub show_security: bool,
334    /// Show deprecated endpoints.
335    pub show_deprecated: bool,
336    /// Group by tags.
337    pub group_by_tags: bool,
338    /// Maximum endpoints to show (0 = unlimited).
339    pub max_endpoints: usize,
340    /// Maximum depth for nested schema rendering (default: 5).
341    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/// OpenAPI endpoint table display.
358#[derive(Debug, Clone)]
359pub struct OpenApiDisplay {
360    mode: OutputMode,
361    theme: FastApiTheme,
362    config: OpenApiDisplayConfig,
363}
364
365impl OpenApiDisplay {
366    /// Create a new OpenAPI display.
367    #[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    /// Create with custom configuration.
377    #[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    /// Set the theme.
387    #[must_use]
388    pub fn theme(mut self, theme: FastApiTheme) -> Self {
389        self.theme = theme;
390        self
391    }
392
393    /// Group endpoints by their first tag.
394    #[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    /// Render an OpenAPI summary.
417    #[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        // Header
430        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        // Servers
438        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        // Endpoints table
447        lines.push(String::new());
448        lines.push(format!("Endpoints ({}):", summary.endpoint_count));
449        lines.push(String::new());
450
451        // Calculate column widths
452        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        // Header row
468        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        // Endpoint rows
478        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            // Build indicators
494            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        // Legend
524        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        // Header
549        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        // Endpoints
559        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            // Build indicators
579            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        // Legend
605        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        // Calculate table width
632        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; // method(9) + path + summary + borders
641
642        // Top border with title
643        lines.push(format!("{border}┌{}┐{ANSI_RESET}", "─".repeat(table_width)));
644
645        // Title row
646        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        // Description if present
655        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        // Column headers
667        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        // Endpoint rows
678        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            // Build indicators
699            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        // Bottom border
723        lines.push(format!("{border}└{}┘{ANSI_RESET}", "─".repeat(table_width)));
724
725        // Legend
726        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        // Summary line
742        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    /// Render a schema type.
751    #[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        // Check max depth
771        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                    // Recursively render nested objects/arrays
798                    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        // Check max depth
837        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        // Box border
892        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"), // No tag, should be "Other"
1223        ];
1224
1225        let groups = display.group_endpoints_by_tag(&endpoints);
1226
1227        assert_eq!(groups.len(), 3);
1228
1229        // Check that groups contain expected endpoints
1230        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        // Create a deeply nested schema
1242        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        // With low max depth
1284        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}