Skip to main content

torrust_tracker_deployer_lib/infrastructure/cli_docs/
generator.rs

1//! CLI Documentation Generator
2//!
3//! Generates JSON documentation representation of CLI structure using Clap introspection.
4//! This provides a machine-readable, versionable specification of the CLI interface.
5
6use clap::{Command, CommandFactory};
7use serde_json::{json, Value};
8
9use super::errors::CliDocsGenerationError;
10use super::schema_builder;
11
12/// CLI documentation generator for creating JSON documentation from Clap CLI structures
13///
14/// This is a stateless utility that uses Clap's introspection APIs to extract
15/// comprehensive CLI metadata and convert it to a structured JSON documentation format.
16///
17/// # Architecture
18///
19/// - Uses `CommandFactory` trait to access CLI structure
20/// - Recursively traverses commands and subcommands
21/// - Delegates JSON construction to `schema_builder` module
22///
23/// # Examples
24///
25/// ```rust
26/// use clap::Parser;
27/// use torrust_tracker_deployer_lib::infrastructure::cli_docs::CliDocsGenerator;
28///
29/// #[derive(Parser)]
30/// struct MyCli {
31///     #[arg(short, long)]
32///     verbose: bool,
33/// }
34///
35/// let docs = CliDocsGenerator::generate::<MyCli>()?;
36/// assert!(docs.contains("\"name\""));
37/// # Ok::<(), Box<dyn std::error::Error>>(())
38/// ```
39pub struct CliDocsGenerator;
40
41impl CliDocsGenerator {
42    /// Generates JSON documentation for the given CLI type
43    ///
44    /// The type must implement `CommandFactory` from Clap, which is automatically
45    /// provided by the `#[derive(Parser)]` macro.
46    ///
47    /// # Type Parameters
48    ///
49    /// * `T` - The CLI type to generate documentation for (must implement `CommandFactory`)
50    ///
51    /// # Returns
52    ///
53    /// * `Ok(String)` - The JSON documentation as a pretty-printed JSON string
54    /// * `Err(CliDocsGenerationError)` - If documentation generation or serialization fails
55    ///
56    /// # Examples
57    ///
58    /// ```rust
59    /// use clap::Parser;
60    /// use torrust_tracker_deployer_lib::infrastructure::cli_docs::CliDocsGenerator;
61    ///
62    /// #[derive(Parser)]
63    /// #[command(name = "my-app", version = "1.0.0", about = "My application")]
64    /// struct MyCli {
65    ///     #[arg(short, long)]
66    ///     verbose: bool,
67    /// }
68    ///
69    /// let docs = CliDocsGenerator::generate::<MyCli>()?;
70    /// assert!(docs.contains("my-app"));
71    /// assert!(docs.contains("verbose"));
72    /// # Ok::<(), Box<dyn std::error::Error>>(())
73    /// ```
74    ///
75    /// # Errors
76    ///
77    /// Returns `CliDocsGenerationError::SerializationFailed` if the documentation
78    /// cannot be serialized to JSON (this should be extremely rare).
79    pub fn generate<T: CommandFactory>() -> Result<String, CliDocsGenerationError> {
80        let command = T::command();
81        let schema = Self::build_schema(&command);
82
83        // Serialize to pretty-printed JSON
84        serde_json::to_string_pretty(&schema)
85            .map_err(|source| CliDocsGenerationError::SerializationFailed { source })
86    }
87
88    /// Builds the complete documentation structure from a Clap command
89    ///
90    /// Creates a JSON object with:
91    /// - `format`: Documentation format identifier ("cli-documentation")
92    /// - `format_version`: Format version ("1.0")
93    /// - `cli`: Complete CLI metadata
94    ///   - Application info (name, version, description)
95    ///   - Global arguments
96    ///   - Subcommands (recursively)
97    ///
98    /// # Arguments
99    ///
100    /// * `command` - The Clap command structure to introspect
101    ///
102    /// # Returns
103    ///
104    /// A `serde_json::Value` containing the complete documentation
105    fn build_schema(command: &Command) -> Value {
106        let mut cli = schema_builder::build_app_metadata(command);
107
108        // Extract global arguments (excluding built-in help/version)
109        let global_args: Vec<Value> = command
110            .get_arguments()
111            .filter(|arg| {
112                let id = arg.get_id().as_str();
113                id != "help" && id != "version"
114            })
115            .map(|arg| Value::Object(schema_builder::build_argument_schema(arg)))
116            .collect();
117
118        if !global_args.is_empty() {
119            cli.insert("global_arguments".to_string(), json!(global_args));
120        }
121
122        // Extract subcommands (excluding built-in help)
123        let subcommands: Vec<Value> = command
124            .get_subcommands()
125            .filter(|cmd| cmd.get_name() != "help")
126            .map(|cmd| Value::Object(schema_builder::build_subcommand_schema(cmd)))
127            .collect();
128
129        if !subcommands.is_empty() {
130            cli.insert("commands".to_string(), json!(subcommands));
131        }
132
133        // Build complete documentation with format metadata
134        json!({
135            "format": "cli-documentation",
136            "format_version": "1.0",
137            "cli": cli
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use clap::{Parser, Subcommand};
146
147    // Test CLI structure
148    #[derive(Parser)]
149    #[command(name = "test-cli", version = "1.0.0", about = "A test CLI")]
150    struct TestCli {
151        /// Verbose output
152        #[arg(short, long)]
153        verbose: bool,
154
155        #[command(subcommand)]
156        command: Option<TestCommands>,
157    }
158
159    #[derive(Subcommand)]
160    enum TestCommands {
161        /// Create a resource
162        Create {
163            /// Resource name
164            name: String,
165        },
166    }
167
168    #[test]
169    fn it_should_generate_valid_json_schema_when_given_valid_cli() {
170        let result = CliDocsGenerator::generate::<TestCli>();
171        assert!(result.is_ok());
172
173        let docs = result.unwrap();
174        assert!(docs.contains("\"format\""));
175        assert!(docs.contains("cli-documentation"));
176        assert!(docs.contains("test-cli"));
177    }
178
179    #[test]
180    fn it_should_include_app_metadata_in_schema() {
181        let schema = CliDocsGenerator::generate::<TestCli>().unwrap();
182        assert!(schema.contains("\"name\""));
183        assert!(schema.contains("\"version\""));
184        assert!(schema.contains("\"description\""));
185        assert!(schema.contains("test-cli"));
186        assert!(schema.contains("1.0.0"));
187    }
188
189    #[test]
190    fn it_should_include_global_arguments_in_schema() {
191        let schema = CliDocsGenerator::generate::<TestCli>().unwrap();
192        assert!(schema.contains("global_arguments"));
193        assert!(schema.contains("verbose"));
194    }
195
196    #[test]
197    fn it_should_include_subcommands_in_schema() {
198        let schema = CliDocsGenerator::generate::<TestCli>().unwrap();
199        assert!(schema.contains("commands"));
200        assert!(schema.contains("Create"));
201    }
202
203    #[test]
204    fn it_should_generate_pretty_printed_json_output() {
205        let schema = CliDocsGenerator::generate::<TestCli>().unwrap();
206        // Pretty-printed JSON has newlines
207        assert!(schema.contains('\n'));
208        // And indentation
209        assert!(schema.contains("  "));
210    }
211
212    #[test]
213    fn it_should_include_json_schema_version() {
214        let docs = CliDocsGenerator::generate::<TestCli>().unwrap();
215        assert!(docs.contains("\"format\""));
216        assert!(docs.contains("cli-documentation"));
217        assert!(docs.contains("\"format_version\""));
218        assert!(docs.contains("1.0"));
219    }
220}