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}