Skip to main content

aperture_cli/
docs.rs

1//! Documentation and help system for improved CLI discoverability
2
3use crate::cache::models::{CachedCommand, CachedSpec};
4use crate::constants;
5use crate::error::Error;
6use crate::utils::to_kebab_case;
7use std::collections::BTreeMap;
8use std::fmt::Write;
9
10/// Documentation generator for API operations
11pub struct DocumentationGenerator {
12    specs: BTreeMap<String, CachedSpec>,
13}
14
15impl DocumentationGenerator {
16    /// Create a new documentation generator
17    #[must_use]
18    pub const fn new(specs: BTreeMap<String, CachedSpec>) -> Self {
19        Self { specs }
20    }
21
22    /// Generate comprehensive help for a specific command
23    /// Generate command help documentation
24    ///
25    /// # Errors
26    /// Returns an error if the API or operation is not found
27    pub fn generate_command_help(
28        &self,
29        api_name: &str,
30        tag: &str,
31        operation_id: &str,
32    ) -> Result<String, Error> {
33        let spec = self
34            .specs
35            .get(api_name)
36            .ok_or_else(|| Error::spec_not_found(api_name))?;
37
38        let command = spec
39            .commands
40            .iter()
41            .find(|cmd| Self::matches_command_reference(cmd, tag, operation_id))
42            .ok_or_else(|| {
43                Error::spec_not_found(format!(
44                    "Operation '{tag} {operation_id}' not found in API '{api_name}'"
45                ))
46            })?;
47
48        let effective_tag = Self::effective_group(command);
49        let effective_operation = Self::effective_operation(command);
50
51        let mut help = String::new();
52
53        // Build help sections
54        Self::add_command_header(&mut help, command);
55        Self::add_usage_section(&mut help, api_name, &effective_tag, &effective_operation);
56        Self::add_parameters_section(&mut help, command);
57        Self::add_request_body_section(&mut help, command);
58        Self::add_examples_section(
59            &mut help,
60            api_name,
61            &effective_tag,
62            &effective_operation,
63            command,
64        );
65        Self::add_responses_section(&mut help, command);
66        Self::add_authentication_section(&mut help, command);
67        Self::add_metadata_section(&mut help, command);
68
69        Ok(help)
70    }
71
72    /// Returns the effective command group shown in CLI paths.
73    fn effective_group(command: &CachedCommand) -> String {
74        command.display_group.as_ref().map_or_else(
75            || {
76                if command.name.is_empty() {
77                    constants::DEFAULT_GROUP.to_string()
78                } else {
79                    to_kebab_case(&command.name)
80                }
81            },
82            |group| to_kebab_case(group),
83        )
84    }
85
86    /// Returns the effective command name shown in CLI paths.
87    fn effective_operation(command: &CachedCommand) -> String {
88        command.display_name.as_ref().map_or_else(
89            || {
90                if command.operation_id.is_empty() {
91                    command.method.to_lowercase()
92                } else {
93                    to_kebab_case(&command.operation_id)
94                }
95            },
96            |name| to_kebab_case(name),
97        )
98    }
99
100    /// Returns true when the provided docs path references this command.
101    ///
102    /// Supports both effective (mapped) names and original names for compatibility.
103    fn matches_command_reference(command: &CachedCommand, tag: &str, operation: &str) -> bool {
104        let requested_tag = to_kebab_case(tag);
105        let requested_operation = to_kebab_case(operation);
106
107        let effective_tag = Self::effective_group(command);
108        let effective_operation = Self::effective_operation(command);
109
110        let legacy_tag = command.tags.first().map_or_else(
111            || {
112                if command.name.is_empty() {
113                    constants::DEFAULT_GROUP.to_string()
114                } else {
115                    to_kebab_case(&command.name)
116                }
117            },
118            |t| to_kebab_case(t),
119        );
120
121        let legacy_operation = if command.operation_id.is_empty() {
122            command.method.to_lowercase()
123        } else {
124            to_kebab_case(&command.operation_id)
125        };
126
127        let alias_match = command
128            .aliases
129            .iter()
130            .any(|alias| to_kebab_case(alias) == requested_operation);
131
132        let tag_match = requested_tag == effective_tag || requested_tag == legacy_tag;
133        let operation_match = requested_operation == effective_operation
134            || requested_operation == legacy_operation
135            || alias_match;
136
137        tag_match && operation_match
138    }
139
140    /// Add command header with title and description
141    fn add_command_header(help: &mut String, command: &CachedCommand) {
142        write!(
143            help,
144            "# {} {}\n\n",
145            command.method.to_uppercase(),
146            command.path
147        )
148        .ok();
149
150        if let Some(summary) = &command.summary {
151            write!(help, "**{summary}**\n\n").ok();
152        }
153
154        if let Some(description) = &command.description {
155            write!(help, "{description}\n\n").ok();
156        }
157    }
158
159    /// Add usage section with command syntax
160    fn add_usage_section(help: &mut String, api_name: &str, tag: &str, operation_id: &str) {
161        help.push_str("## Usage\n\n");
162        write!(
163            help,
164            "```bash\naperture api {api_name} {tag} {operation_id}\n```\n\n"
165        )
166        .ok();
167    }
168
169    /// Add parameters section if parameters exist
170    fn add_parameters_section(help: &mut String, command: &CachedCommand) {
171        if command.parameters.is_empty() {
172            return;
173        }
174
175        help.push_str("## Parameters\n\n");
176        for param in &command.parameters {
177            let required_badge = if param.required {
178                " **(required)**"
179            } else {
180                ""
181            };
182            let param_type = param.schema_type.as_deref().unwrap_or("string");
183            writeln!(
184                help,
185                "- `--{}` ({}){}  - {}",
186                to_kebab_case(&param.name),
187                param_type,
188                required_badge,
189                param.description.as_deref().unwrap_or("No description")
190            )
191            .ok();
192        }
193        help.push('\n');
194    }
195
196    /// Add request body section if present
197    fn add_request_body_section(help: &mut String, command: &CachedCommand) {
198        let Some(ref body) = command.request_body else {
199            return;
200        };
201
202        help.push_str("## Request Body\n\n");
203        if let Some(ref description) = body.description {
204            write!(help, "{description}\n\n").ok();
205        }
206        write!(help, "Required: {}\n\n", body.required).ok();
207    }
208
209    /// Add examples section with command examples
210    fn add_examples_section(
211        help: &mut String,
212        api_name: &str,
213        tag: &str,
214        operation_id: &str,
215        command: &CachedCommand,
216    ) {
217        if command.examples.is_empty() {
218            help.push_str("## Example\n\n");
219            help.push_str(&Self::generate_basic_example(
220                api_name,
221                tag,
222                operation_id,
223                command,
224            ));
225            return;
226        }
227
228        help.push_str("## Examples\n\n");
229        for (i, example) in command.examples.iter().enumerate() {
230            write!(help, "### Example {}\n\n", i + 1).ok();
231            write!(help, "**{}**\n\n", example.description).ok();
232            if let Some(ref explanation) = example.explanation {
233                write!(help, "{explanation}\n\n").ok();
234            }
235            write!(help, "```bash\n{}\n```\n\n", example.command_line).ok();
236        }
237    }
238
239    /// Add responses section if responses exist
240    fn add_responses_section(help: &mut String, command: &CachedCommand) {
241        if !command.responses.is_empty() {
242            help.push_str("## Responses\n\n");
243            for response in &command.responses {
244                writeln!(
245                    help,
246                    "- **{}**: {}",
247                    response.status_code,
248                    response.description.as_deref().unwrap_or("No description")
249                )
250                .ok();
251            }
252            help.push('\n');
253        }
254    }
255
256    /// Add authentication section if security requirements exist
257    fn add_authentication_section(help: &mut String, command: &CachedCommand) {
258        if !command.security_requirements.is_empty() {
259            help.push_str("## Authentication\n\n");
260            help.push_str("This operation requires authentication. Available schemes:\n\n");
261            for scheme_name in &command.security_requirements {
262                writeln!(help, "- {scheme_name}").ok();
263            }
264            help.push('\n');
265        }
266    }
267
268    /// Add metadata section with deprecation and external docs
269    fn add_metadata_section(help: &mut String, command: &CachedCommand) {
270        if command.deprecated {
271            help.push_str("āš ļø  **This operation is deprecated**\n\n");
272        }
273
274        if let Some(ref docs_url) = command.external_docs_url {
275            write!(help, "šŸ“– **External Documentation**: {docs_url}\n\n").ok();
276        }
277    }
278
279    /// Generate a basic example for a command
280    fn generate_basic_example(
281        api_name: &str,
282        tag: &str,
283        operation_id: &str,
284        command: &CachedCommand,
285    ) -> String {
286        let mut example = format!("```bash\naperture api {api_name} {tag} {operation_id}");
287
288        // Add required parameters
289        for param in &command.parameters {
290            if param.required {
291                let param_type = param.schema_type.as_deref().unwrap_or("string");
292                let example_value = Self::generate_example_value(param_type);
293                write!(
294                    example,
295                    " --{} {}",
296                    to_kebab_case(&param.name),
297                    example_value
298                )
299                .ok();
300            }
301        }
302
303        // Add request body if required
304        match command.request_body {
305            Some(ref body) if body.required => {
306                example.push_str(" --body '{\"key\": \"value\"}'");
307            }
308            _ => {}
309        }
310
311        example.push_str("\n```\n\n");
312        example
313    }
314
315    /// Generate example values for different parameter types
316    fn generate_example_value(param_type: &str) -> &'static str {
317        match param_type.to_lowercase().as_str() {
318            "string" => "\"example\"",
319            "integer" | "number" => "123",
320            "boolean" => "true",
321            "array" => "[\"item1\",\"item2\"]",
322            _ => "\"value\"",
323        }
324    }
325
326    /// Generate API overview with statistics
327    ///
328    /// # Errors
329    /// Returns an error if the API is not found
330    pub fn generate_api_overview(&self, api_name: &str) -> Result<String, Error> {
331        let spec = self
332            .specs
333            .get(api_name)
334            .ok_or_else(|| Error::spec_not_found(api_name))?;
335
336        let mut overview = String::new();
337
338        // API header
339        write!(overview, "# {} API\n\n", spec.name).ok();
340        writeln!(overview, "**Version**: {}", spec.version).ok();
341
342        if let Some(ref base_url) = spec.base_url {
343            writeln!(overview, "**Base URL**: {base_url}").ok();
344        }
345        overview.push('\n');
346
347        // Statistics (hidden commands are excluded from docs-style listings)
348        let visible_commands: Vec<&CachedCommand> = spec
349            .commands
350            .iter()
351            .filter(|command| !command.hidden)
352            .collect();
353        let total_operations = visible_commands.len();
354        let mut method_counts = BTreeMap::new();
355        let mut tag_counts = BTreeMap::new();
356
357        for command in &visible_commands {
358            *method_counts.entry(command.method.clone()).or_insert(0) += 1;
359            *tag_counts
360                .entry(Self::effective_group(command))
361                .or_insert(0) += 1;
362        }
363
364        overview.push_str("## Statistics\n\n");
365        writeln!(overview, "- **Total Operations**: {total_operations}").ok();
366        overview.push_str("- **Methods**:\n");
367        for (method, count) in method_counts {
368            writeln!(overview, "  - {method}: {count}").ok();
369        }
370        overview.push_str("- **Categories**:\n");
371        for (tag, count) in tag_counts {
372            writeln!(overview, "  - {tag}: {count}").ok();
373        }
374        overview.push('\n');
375
376        // Quick start examples
377        overview.push_str("## Quick Start\n\n");
378        write!(
379            overview,
380            "List all available commands:\n```bash\naperture list-commands {api_name}\n```\n\n"
381        )
382        .ok();
383
384        write!(
385            overview,
386            "Search for specific operations:\n```bash\naperture search \"keyword\" --api {api_name}\n```\n\n"
387        ).ok();
388
389        // Show first few operations as examples
390        if !visible_commands.is_empty() {
391            overview.push_str("## Sample Operations\n\n");
392            for (i, command) in visible_commands.iter().take(3).enumerate() {
393                let tag = Self::effective_group(command);
394                let operation = Self::effective_operation(command);
395                write!(
396                    overview,
397                    "{}. **{}** ({})\n   ```bash\n   aperture api {api_name} {tag} {operation}\n   ```\n   {}\n\n",
398                    i + 1,
399                    command.summary.as_deref().unwrap_or(&operation),
400                    command.method.to_uppercase(),
401                    command.description.as_deref().unwrap_or("No description")
402                )
403                .ok();
404            }
405        }
406
407        Ok(overview)
408    }
409
410    /// Generate interactive help menu
411    #[must_use]
412    pub fn generate_interactive_menu(&self) -> String {
413        let mut menu = String::new();
414
415        menu.push_str("# Aperture Interactive Help\n\n");
416        menu.push_str("Welcome to Aperture! Here are some ways to get started:\n\n");
417
418        // Available APIs
419        if self.specs.is_empty() {
420            menu.push_str("## No APIs Configured\n\n");
421            menu.push_str("Get started by adding an API specification:\n");
422            menu.push_str("```bash\naperture config add myapi ./openapi.yaml\n```\n\n");
423        } else {
424            menu.push_str("## Your APIs\n\n");
425            for (api_name, spec) in &self.specs {
426                let operation_count = spec.commands.len();
427                writeln!(
428                    menu,
429                    "- **{api_name}** ({operation_count} operations) - Version {}",
430                    spec.version
431                )
432                .ok();
433            }
434            menu.push('\n');
435        }
436
437        // Common commands
438        menu.push_str("## Common Commands\n\n");
439        menu.push_str("- `aperture config list` - List all configured APIs\n");
440        menu.push_str("- `aperture search <term>` - Search across all APIs\n");
441        menu.push_str("- `aperture list-commands <api>` - Show available commands for an API\n");
442        menu.push_str("- `aperture exec <shortcut>` - Execute using shortcuts\n");
443        menu.push_str("- `aperture api <api> --help` - Get help for an API\n\n");
444
445        // Tips
446        menu.push_str("## Tips\n\n");
447        menu.push_str("- Use `--describe-json` for machine-readable capability information\n");
448        menu.push_str("- Use `--dry-run` to see what request would be made without executing\n");
449        menu.push_str("- Use `--json-errors` for structured error output\n");
450        menu.push_str("- Environment variables can be used for authentication (see config)\n\n");
451
452        menu
453    }
454}
455
456/// Enhanced help formatter for better readability
457pub struct HelpFormatter;
458
459impl HelpFormatter {
460    /// Format command list with enhanced styling
461    #[must_use]
462    pub fn format_command_list(spec: &CachedSpec) -> String {
463        let mut output = String::new();
464
465        // Header with API info
466        writeln!(output, "šŸ“‹ {} API Commands", spec.name).ok();
467        let visible_commands: Vec<&CachedCommand> = spec
468            .commands
469            .iter()
470            .filter(|command| !command.hidden)
471            .collect();
472
473        writeln!(
474            output,
475            "   Version: {} | Operations: {}",
476            spec.version,
477            visible_commands.len()
478        )
479        .ok();
480
481        if let Some(ref base_url) = spec.base_url {
482            writeln!(output, "   Base URL: {base_url}").ok();
483        }
484        output.push_str(&"═".repeat(60));
485        output.push('\n');
486
487        // Group by effective command group
488        let mut tag_groups = BTreeMap::new();
489        for command in visible_commands {
490            let tag = DocumentationGenerator::effective_group(command);
491            tag_groups.entry(tag).or_insert_with(Vec::new).push(command);
492        }
493
494        for (tag, commands) in tag_groups {
495            writeln!(output, "\nšŸ“ {tag}").ok();
496            output.push_str(&"─".repeat(40));
497            output.push('\n');
498
499            for command in commands {
500                let operation_kebab = DocumentationGenerator::effective_operation(command);
501                let method_badge = Self::format_method_badge(&command.method);
502                let description = command
503                    .summary
504                    .as_ref()
505                    .or(command.description.as_ref())
506                    .map(|s| format!(" - {}", s.lines().next().unwrap_or(s)))
507                    .unwrap_or_default();
508
509                writeln!(
510                    output,
511                    "  {} {} {}{}",
512                    method_badge,
513                    operation_kebab,
514                    if command.deprecated { "āš ļø" } else { "" },
515                    description
516                )
517                .ok();
518
519                // Show path as subdued text
520                writeln!(output, "     Path: {}", command.path).ok();
521            }
522        }
523
524        output.push('\n');
525        output
526    }
527
528    /// Format HTTP method with color/styling
529    fn format_method_badge(method: &str) -> String {
530        match method.to_uppercase().as_str() {
531            "GET" => "šŸ” GET   ".to_string(),
532            "POST" => "šŸ“ POST  ".to_string(),
533            "PUT" => "āœļø  PUT   ".to_string(),
534            "DELETE" => "šŸ—‘ļø  DELETE".to_string(),
535            "PATCH" => "šŸ”§ PATCH ".to_string(),
536            "HEAD" => "šŸ‘ļø  HEAD  ".to_string(),
537            "OPTIONS" => "āš™ļø  OPTIONS".to_string(),
538            _ => format!("šŸ“‹ {:<7}", method.to_uppercase()),
539        }
540    }
541}