1use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig};
2use clap::{Arg, Command};
3use std::fs;
4use std::process;
5
6pub struct CliConfig {
8 pub api_name: &'static str,
10 pub default_module_name: &'static str,
12 pub streaming_config: Option<StreamingConfig>,
14 pub enable_specta: bool,
16}
17
18pub async fn run_generation_cli(cli_config: CliConfig) {
20 let matches = Command::new("api-gen")
21 .version("0.1.0")
22 .about("Generate API types and streaming client")
23 .arg(
24 Arg::new("input")
25 .help("Input OpenAPI spec (file path or URL)")
26 .required(true)
27 .index(1),
28 )
29 .arg(
30 Arg::new("output-dir")
31 .long("output-dir")
32 .value_name("DIR")
33 .help("Output directory for generated files (default: src/generated)")
34 .default_value("src/generated"),
35 )
36 .arg(
37 Arg::new("module-name")
38 .short('m')
39 .long("module-name")
40 .value_name("NAME")
41 .help("Generated module name")
42 .default_value(cli_config.default_module_name),
43 )
44 .arg(
45 Arg::new("verbose")
46 .short('v')
47 .long("verbose")
48 .help("Enable verbose output")
49 .action(clap::ArgAction::SetTrue),
50 )
51 .arg(
52 Arg::new("dry-run")
53 .long("dry-run")
54 .help("Print generated code to stdout instead of writing to file")
55 .action(clap::ArgAction::SetTrue),
56 )
57 .get_matches();
58
59 let Some(input) = matches.get_one::<String>("input") else {
60 eprintln!("Error: missing required argument 'input'");
61 process::exit(1);
62 };
63 let Some(output_dir) = matches.get_one::<String>("output-dir") else {
64 eprintln!("Error: missing required argument 'output-dir'");
65 process::exit(1);
66 };
67 let Some(module_name) = matches.get_one::<String>("module-name") else {
68 eprintln!("Error: missing required argument 'module-name'");
69 process::exit(1);
70 };
71 let verbose = matches.get_flag("verbose");
72 let dry_run = matches.get_flag("dry-run");
73
74 if verbose {
75 println!("🚀 {} API Generator", cli_config.api_name);
76 println!("Input: {input}");
77 if !dry_run {
78 println!("Output: {output_dir}");
79 }
80 println!("Module: {module_name}");
81 if cli_config.streaming_config.is_some() {
82 println!("🌊 Streaming: enabled");
83 }
84 println!();
85 }
86
87 let spec_content = match load_spec(input, verbose).await {
89 Ok(content) => content,
90 Err(e) => {
91 eprintln!("❌ Error loading spec: {e}");
92 process::exit(1);
93 }
94 };
95
96 if verbose {
97 println!("📄 Loaded OpenAPI spec ({} bytes)", spec_content.len());
98 }
99
100 let spec_value: serde_json::Value = match parse_spec(&spec_content, input) {
102 Ok(value) => value,
103 Err(e) => {
104 eprintln!("❌ Error parsing spec: {e}");
105 process::exit(1);
106 }
107 };
108
109 let oas_version = spec_value
114 .get("openapi")
115 .and_then(|v| v.as_str())
116 .unwrap_or("");
117 if let Some((major, minor)) = parse_oas_version(oas_version) {
118 match (major, minor) {
119 (3, 0) | (3, 1) => {}
120 (3, 2) => {
121 eprintln!(
122 "⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported. \
123 Some 3.2-only features (additionalOperations, query method, \
124 itemSchema, $self, defaultMapping) are not yet wired through codegen. \
125 See issue #14 for status."
126 );
127 }
128 _ => {
129 eprintln!(
130 "❌ Unsupported OpenAPI version: {oas_version}. \
131 This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x."
132 );
133 process::exit(1);
134 }
135 }
136 } else {
137 eprintln!(
138 "❌ Missing or unrecognised `openapi` field. Expected something like \"3.1.0\", got: {oas_version:?}"
139 );
140 process::exit(1);
141 }
142
143 if verbose {
144 if let Some(info) = spec_value.get("info") {
145 if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
146 println!("📋 Title: {title}");
147 }
148 if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
149 println!("🏷️ Version: {version}");
150 }
151 }
152 println!();
153 }
154
155 if verbose {
157 println!("🔍 Analyzing schemas...");
158 }
159
160 let mut analyzer = match SchemaAnalyzer::new(spec_value) {
161 Ok(analyzer) => analyzer,
162 Err(e) => {
163 eprintln!("❌ Error creating analyzer: {e}");
164 process::exit(1);
165 }
166 };
167
168 let mut analysis = match analyzer.analyze() {
169 Ok(analysis) => analysis,
170 Err(e) => {
171 eprintln!("❌ Error analyzing schemas: {e}");
172 process::exit(1);
173 }
174 };
175
176 if verbose {
177 println!("📈 Found {} schemas", analysis.schemas.len());
178 println!("📈 Found {} operations", analysis.operations.len());
179 if let Some(ref config) = cli_config.streaming_config {
180 println!("🌊 Found {} streaming endpoints", config.endpoints.len());
181 }
182 println!();
183 }
184
185 if verbose {
187 let stream_status = if cli_config.streaming_config.is_some() {
188 "with streaming support"
189 } else {
190 ""
191 };
192 println!(
193 "⚙️ Generating {} API code {}...",
194 cli_config.api_name, stream_status
195 );
196 }
197
198 let config = GeneratorConfig {
199 module_name: module_name.clone(),
200 output_dir: output_dir.into(),
201 streaming_config: cli_config.streaming_config,
202 enable_specta: cli_config.enable_specta,
203 ..Default::default()
204 };
205
206 let generator = CodeGenerator::new(config);
207 let generation_result = match generator.generate_all(&mut analysis) {
208 Ok(result) => result,
209 Err(e) => {
210 eprintln!("❌ Error generating code: {e}");
211 process::exit(1);
212 }
213 };
214
215 if dry_run {
216 println!("=== Generated Files ===");
217 for file in &generation_result.files {
218 println!("\n--- {} ---", file.path.display());
219 println!("{}", file.content);
220 }
221 println!("\n--- {} ---", generation_result.mod_file.path.display());
222 println!("{}", generation_result.mod_file.content);
223 } else {
224 if let Err(e) = generator.write_files(&generation_result) {
226 eprintln!("❌ Error writing files: {e}");
227 process::exit(1);
228 }
229
230 if verbose {
231 println!(
232 "✅ Generated {} files written to: {}",
233 generation_result.files.len() + 1,
234 generator.config().output_dir.display()
235 );
236 for file in &generation_result.files {
237 println!(" - {}", file.path.display());
238 }
239 println!(" - {}", generation_result.mod_file.path.display());
240 } else {
241 println!(
242 "✅ Generated {} files written to: {}",
243 generation_result.files.len() + 1,
244 generator.config().output_dir.display()
245 );
246 }
247 }
248}
249
250async fn load_spec(input: &str, verbose: bool) -> Result<String, Box<dyn std::error::Error>> {
251 if input.starts_with("http://") || input.starts_with("https://") {
252 if verbose {
254 println!("🌐 Fetching from URL...");
255 }
256
257 let response = reqwest::get(input).await?;
258 if !response.status().is_success() {
259 return Err(format!("HTTP error: {}", response.status()).into());
260 }
261
262 let content = response.text().await?;
263 Ok(content)
264 } else {
265 if verbose {
267 println!("📁 Reading from file...");
268 }
269
270 let content = fs::read_to_string(input)?;
271 Ok(content)
272 }
273}
274
275pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
278 let mut parts = s.split('.');
279 let major = parts.next()?.parse().ok()?;
280 let minor_raw = parts.next()?;
281 let minor_digits: String = minor_raw
282 .chars()
283 .take_while(|c| c.is_ascii_digit())
284 .collect();
285 let minor = minor_digits.parse().ok()?;
286 Some((major, minor))
287}
288
289fn parse_spec(content: &str, input: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
290 let is_yaml = input.ends_with(".yaml")
292 || input.ends_with(".yml")
293 || content.trim_start().starts_with("openapi:")
294 || content.trim_start().starts_with("swagger:");
295
296 if is_yaml {
297 let value = yaml_to_json_value(content)?;
298 Ok(value)
299 } else {
300 let value = json_from_str_lossy(content)?;
301 Ok(value)
302 }
303}
304
305pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
310 let preprocessed = sanitize_large_yaml_integers(content);
311 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
312 Ok(yaml_value_to_json(yaml_value))
313}
314
315pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
317 match serde_json::from_str::<serde_json::Value>(content) {
319 Ok(v) => Ok(v),
320 Err(e) => {
321 let err_msg = e.to_string();
322 if err_msg.contains("number out of range") {
323 let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
325 Ok(yaml_value_to_json(yaml_value))
326 } else {
327 Err(e.into())
328 }
329 }
330 }
331}
332
333fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
334 match yaml {
335 serde_yaml::Value::Null => serde_json::Value::Null,
336 serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
337 serde_yaml::Value::Number(n) => {
338 if let Some(i) = n.as_i64() {
339 serde_json::Value::Number(i.into())
340 } else if let Some(u) = n.as_u64() {
341 serde_json::Value::Number(u.into())
342 } else if let Some(f) = n.as_f64() {
343 serde_json::json!(f)
344 } else {
345 serde_json::json!(0.0)
347 }
348 }
349 serde_yaml::Value::String(s) => serde_json::Value::String(s),
350 serde_yaml::Value::Sequence(seq) => {
351 serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
352 }
353 serde_yaml::Value::Mapping(map) => {
354 let obj = map
355 .into_iter()
356 .filter_map(|(k, v)| {
357 let key = match k {
358 serde_yaml::Value::String(s) => s,
359 serde_yaml::Value::Number(n) => n.to_string(),
360 serde_yaml::Value::Bool(b) => b.to_string(),
361 _ => return None,
362 };
363 Some((key, yaml_value_to_json(v)))
364 })
365 .collect();
366 serde_json::Value::Object(obj)
367 }
368 serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
369 }
370}
371
372fn sanitize_large_yaml_integers(content: &str) -> String {
376 let mut result = String::with_capacity(content.len());
377 for line in content.lines() {
378 if let Some(sanitized) = try_sanitize_integer_line(line) {
379 result.push_str(&sanitized);
380 } else {
381 result.push_str(line);
382 }
383 result.push('\n');
384 }
385 result
386}
387
388fn try_sanitize_integer_line(line: &str) -> Option<String> {
391 let trimmed = line.trim();
394
395 if trimmed.is_empty() || trimmed.starts_with('#') {
397 return None;
398 }
399
400 let colon_pos = line.find(": ")?;
402 let value_start = colon_pos + 2;
403 let value_str = line[value_start..].trim();
404
405 if value_str.is_empty() {
407 return None;
408 }
409
410 let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
411 (true, rest)
412 } else {
413 (false, value_str)
414 };
415
416 if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
418 return None;
419 }
420
421 let overflows = if is_negative {
423 digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
425 } else {
426 digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
428 };
429
430 if overflows {
431 let mut sanitized = line[..value_start].to_string();
433 sanitized.push_str(value_str);
434 sanitized.push_str(".0");
435 Some(sanitized)
436 } else {
437 None
438 }
439}