1use crate::config::{AuditConfig, IssueSeverity, OutputFormat};
2use crate::error::Result;
3use clap::{Parser, Subcommand, ValueEnum};
4use std::path::PathBuf;
5use tracing::{debug, info};
6
7#[derive(Parser)]
9#[command(name = "adk-doc-audit")]
10#[command(about = "Validates documentation against actual crate implementations")]
11#[command(version)]
12pub struct AuditCli {
13 #[command(subcommand)]
14 pub command: AuditCommand,
15
16 #[arg(short, long, global = true)]
18 pub verbose: bool,
19
20 #[arg(short, long, global = true, conflicts_with = "verbose")]
22 pub quiet: bool,
23
24 #[arg(short, long, global = true)]
26 pub config: Option<PathBuf>,
27}
28
29#[derive(Subcommand)]
31pub enum AuditCommand {
32 Audit {
34 #[arg(short, long, default_value = ".")]
36 workspace: PathBuf,
37
38 #[arg(short, long, default_value = "docs")]
40 docs: PathBuf,
41
42 #[arg(long)]
44 crate_name: Option<String>,
45
46 #[arg(long, conflicts_with = "crate_name")]
48 crate_path: Option<PathBuf>,
49
50 #[arg(short, long, default_value = "console")]
52 format: CliOutputFormat,
53
54 #[arg(short, long, default_value = "warning")]
56 severity: CliSeverity,
57
58 #[arg(long, default_value = "true")]
60 fail_on_critical: bool,
61
62 #[arg(long, action = clap::ArgAction::Append)]
64 exclude_files: Vec<String>,
65
66 #[arg(long, action = clap::ArgAction::Append)]
68 exclude_crates: Vec<String>,
69
70 #[arg(short, long)]
72 output: Option<PathBuf>,
73
74 #[arg(long)]
76 no_fail: bool,
77
78 #[arg(long, default_value = "0")]
80 max_issues: usize,
81
82 #[arg(long)]
84 ci_mode: bool,
85 },
86
87 Crate {
89 crate_name: String,
91
92 #[arg(short, long, default_value = ".")]
94 workspace: PathBuf,
95
96 #[arg(short, long, default_value = "console")]
98 format: CliOutputFormat,
99
100 #[arg(short, long, default_value = "warning")]
102 severity: CliSeverity,
103
104 #[arg(long, default_value = "true")]
106 fail_on_critical: bool,
107
108 #[arg(short, long)]
110 output: Option<PathBuf>,
111 },
112
113 Incremental {
115 #[arg(short, long, default_value = ".")]
117 workspace: PathBuf,
118
119 #[arg(short, long, default_value = "docs")]
121 docs: PathBuf,
122
123 #[arg(required = true)]
125 changed_files: Vec<PathBuf>,
126
127 #[arg(short, long, default_value = "console")]
129 format: CliOutputFormat,
130 },
131
132 Validate {
134 file_path: PathBuf,
136
137 #[arg(short, long, default_value = ".")]
139 workspace: PathBuf,
140
141 #[arg(short, long, default_value = "console")]
143 format: CliOutputFormat,
144 },
145
146 Init {
148 #[arg(long, default_value = "adk-doc-audit.toml")]
150 config_path: PathBuf,
151
152 #[arg(short, long, default_value = ".")]
154 workspace: PathBuf,
155
156 #[arg(short, long, default_value = "docs")]
158 docs: PathBuf,
159 },
160
161 Stats {
163 #[arg(short, long, default_value = ".")]
165 workspace: PathBuf,
166
167 #[arg(short, long, default_value = "10")]
169 limit: usize,
170 },
171}
172
173#[derive(ValueEnum, Clone, Copy, Debug)]
175pub enum CliOutputFormat {
176 Console,
177 Json,
178 Markdown,
179}
180
181impl From<CliOutputFormat> for OutputFormat {
182 fn from(cli_format: CliOutputFormat) -> Self {
183 match cli_format {
184 CliOutputFormat::Console => OutputFormat::Console,
185 CliOutputFormat::Json => OutputFormat::Json,
186 CliOutputFormat::Markdown => OutputFormat::Markdown,
187 }
188 }
189}
190
191#[derive(ValueEnum, Clone, Copy, Debug)]
193pub enum CliSeverity {
194 Info,
195 Warning,
196 Critical,
197}
198
199impl From<CliSeverity> for IssueSeverity {
200 fn from(cli_severity: CliSeverity) -> Self {
201 match cli_severity {
202 CliSeverity::Info => IssueSeverity::Info,
203 CliSeverity::Warning => IssueSeverity::Warning,
204 CliSeverity::Critical => IssueSeverity::Critical,
205 }
206 }
207}
208
209impl AuditCli {
210 pub fn parse_args() -> Self {
212 Self::parse()
213 }
214
215 pub fn to_config(&self) -> Result<AuditConfig> {
217 let mut config = if let Some(config_path) = &self.config {
219 info!("Loading configuration from: {}", config_path.display());
220 AuditConfig::from_file(config_path)?
221 } else {
222 let default_paths = [
224 PathBuf::from("adk-doc-audit.toml"),
225 PathBuf::from(".adk-doc-audit.toml"),
226 PathBuf::from("config/adk-doc-audit.toml"),
227 ];
228
229 let mut loaded_config = None;
230 for path in &default_paths {
231 if path.exists() {
232 info!("Found configuration file at: {}", path.display());
233 loaded_config = Some(AuditConfig::from_file(path)?);
234 break;
235 }
236 }
237
238 loaded_config.unwrap_or_else(|| {
239 debug!("No configuration file found, using defaults");
240 AuditConfig::default()
241 })
242 };
243
244 config.verbose = self.verbose;
246 config.quiet = self.quiet;
247
248 match &self.command {
249 AuditCommand::Audit {
250 workspace,
251 docs,
252 format,
253 severity,
254 fail_on_critical,
255 exclude_files,
256 exclude_crates,
257 no_fail,
258 max_issues: _,
259 ci_mode,
260 crate_name,
261 crate_path,
262 ..
263 } => {
264 config.workspace_path = workspace.clone();
265
266 if let Some(name) = crate_name {
268 let crate_dir = config.workspace_path.join(name);
270 if !crate_dir.exists() {
271 let prefixed_name = format!("adk-{}", name);
273 let prefixed_dir = config.workspace_path.join(&prefixed_name);
274 if prefixed_dir.exists() {
275 config.workspace_path = prefixed_dir.clone();
276 config.docs_path = prefixed_dir.join("docs");
277 } else {
278 return Err(crate::AuditError::ConfigurationError {
279 message: format!(
280 "Crate '{}' not found in workspace. Tried '{}' and '{}'",
281 name,
282 crate_dir.display(),
283 prefixed_dir.display()
284 ),
285 });
286 }
287 } else {
288 config.workspace_path = crate_dir.clone();
289 config.docs_path = crate_dir.join("docs");
290 }
291
292 if !config.docs_path.exists() {
294 let alt_docs = [
296 config.workspace_path.join("README.md"),
297 config.workspace_path.join("doc"),
298 config.workspace_path.join("documentation"),
299 ];
300
301 let mut found_docs = false;
302 for alt_path in &alt_docs {
303 if alt_path.exists() {
304 if alt_path.is_file() {
305 config.docs_path = config.workspace_path.clone();
307 } else {
308 config.docs_path = alt_path.clone();
310 }
311 found_docs = true;
312 break;
313 }
314 }
315
316 if !found_docs {
317 config.docs_path = config.workspace_path.clone();
320 }
321 }
322 } else if let Some(path) = crate_path {
323 if !path.exists() {
324 return Err(crate::AuditError::ConfigurationError {
325 message: format!("Crate path does not exist: {}", path.display()),
326 });
327 }
328 config.workspace_path = path.clone();
329 config.docs_path = path.join("docs");
330
331 if !config.docs_path.exists() {
333 let alt_docs = [
335 config.workspace_path.join("README.md"),
336 config.workspace_path.join("doc"),
337 config.workspace_path.join("documentation"),
338 ];
339
340 let mut found_docs = false;
341 for alt_path in &alt_docs {
342 if alt_path.exists() {
343 if alt_path.is_file() {
344 config.docs_path = config.workspace_path.clone();
346 } else {
347 config.docs_path = alt_path.clone();
349 }
350 found_docs = true;
351 break;
352 }
353 }
354
355 if !found_docs {
356 config.docs_path = config.workspace_path.clone();
358 }
359 }
360 } else {
361 config.docs_path = docs.clone();
363 }
364
365 config.output_format = (*format).into();
366 config.severity_threshold = (*severity).into();
367 config.fail_on_critical = *fail_on_critical && !*no_fail; config.excluded_files.extend(exclude_files.clone());
369 config.excluded_crates.extend(exclude_crates.clone());
370
371 if *ci_mode {
373 config.quiet = true; }
375
376 }
379 AuditCommand::Crate { workspace, format, severity, fail_on_critical, .. } => {
380 config.workspace_path = workspace.clone();
381 config.output_format = (*format).into();
382 config.severity_threshold = (*severity).into();
383 config.fail_on_critical = *fail_on_critical;
384 }
386 AuditCommand::Incremental { workspace, docs, format, .. } => {
387 config.workspace_path = workspace.clone();
388 config.docs_path = docs.clone();
389 config.output_format = (*format).into();
390 }
391 AuditCommand::Validate { workspace, format, .. } => {
392 config.workspace_path = workspace.clone();
393 config.output_format = (*format).into();
394 }
395 AuditCommand::Init { workspace, docs, .. } => {
396 config.workspace_path = workspace.clone();
397 config.docs_path = docs.clone();
398 }
399 AuditCommand::Stats { workspace, .. } => {
400 config.workspace_path = workspace.clone();
401 }
402 }
403
404 AuditConfig::builder()
406 .workspace_path(&config.workspace_path)
407 .docs_path(&config.docs_path)
408 .exclude_files(config.excluded_files.clone())
409 .exclude_crates(config.excluded_crates.clone())
410 .severity_threshold(config.severity_threshold)
411 .fail_on_critical(config.fail_on_critical)
412 .example_timeout(config.example_timeout)
413 .output_format(config.output_format)
414 .database_path(config.database_path.clone())
415 .verbose(config.verbose)
416 .quiet(config.quiet)
417 .build()
418 }
419
420 pub fn get_output_path(&self) -> Option<&PathBuf> {
422 match &self.command {
423 AuditCommand::Audit { output, .. } => output.as_ref(),
424 AuditCommand::Crate { output, .. } => output.as_ref(),
425 _ => None,
426 }
427 }
428
429 pub fn get_output_format(&self) -> OutputFormat {
431 match &self.command {
432 AuditCommand::Audit { format, .. } => (*format).into(),
433 AuditCommand::Crate { format, .. } => (*format).into(),
434 AuditCommand::Incremental { format, .. } => (*format).into(),
435 AuditCommand::Validate { format, .. } => (*format).into(),
436 _ => OutputFormat::Console,
437 }
438 }
439
440 pub fn get_output_path_with_default(&self) -> Option<PathBuf> {
442 if let Some(path) = self.get_output_path() {
444 return Some(path.clone());
445 }
446
447 let format = self.get_output_format();
449 match format {
450 crate::config::OutputFormat::Console => None, crate::config::OutputFormat::Json => {
452 let filename = match &self.command {
453 AuditCommand::Audit { .. } => "audit-report.json",
454 AuditCommand::Crate { crate_name, .. } => {
455 return Some(PathBuf::from(format!("audit-{}.json", crate_name)));
456 }
457 _ => "audit-report.json",
458 };
459 Some(PathBuf::from(filename))
460 }
461 crate::config::OutputFormat::Markdown => {
462 let filename = match &self.command {
463 AuditCommand::Audit { .. } => "audit-report.md",
464 AuditCommand::Crate { crate_name, .. } => {
465 return Some(PathBuf::from(format!("audit-{}.md", crate_name)));
466 }
467 _ => "audit-report.md",
468 };
469 Some(PathBuf::from(filename))
470 }
471 }
472 }
473
474 pub fn get_crate_name(&self) -> Option<&String> {
476 match &self.command {
477 AuditCommand::Crate { crate_name, .. } => Some(crate_name),
478 _ => None,
479 }
480 }
481
482 pub fn get_single_crate_options(&self) -> Option<(Option<&String>, Option<&PathBuf>)> {
484 match &self.command {
485 AuditCommand::Audit { crate_name, crate_path, .. } => {
486 Some((crate_name.as_ref(), crate_path.as_ref()))
487 }
488 _ => None,
489 }
490 }
491
492 pub fn get_changed_files(&self) -> Option<&[PathBuf]> {
494 match &self.command {
495 AuditCommand::Incremental { changed_files, .. } => Some(changed_files),
496 _ => None,
497 }
498 }
499
500 pub fn get_validate_file(&self) -> Option<&PathBuf> {
502 match &self.command {
503 AuditCommand::Validate { file_path, .. } => Some(file_path),
504 _ => None,
505 }
506 }
507
508 pub fn get_init_config_path(&self) -> Option<&PathBuf> {
510 match &self.command {
511 AuditCommand::Init { config_path, .. } => Some(config_path),
512 _ => None,
513 }
514 }
515
516 pub fn get_stats_limit(&self) -> Option<usize> {
518 match &self.command {
519 AuditCommand::Stats { limit, .. } => Some(*limit),
520 _ => None,
521 }
522 }
523
524 pub fn get_ci_options(&self) -> Option<(bool, usize, bool)> {
526 match &self.command {
527 AuditCommand::Audit { no_fail, max_issues, ci_mode, .. } => {
528 Some((*no_fail, *max_issues, *ci_mode))
529 }
530 _ => None,
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use clap::CommandFactory;
539
540 #[test]
541 fn test_cli_verify() {
542 AuditCli::command().debug_assert();
544 }
545
546 #[test]
547 fn test_cli_parsing() {
548 let cli = AuditCli::try_parse_from([
550 "adk-doc-audit",
551 "audit",
552 "--workspace",
553 "/tmp/workspace",
554 "--docs",
555 "/tmp/docs",
556 "--format",
557 "json",
558 "--severity",
559 "critical",
560 ]);
561
562 assert!(cli.is_ok());
563 let cli = cli.unwrap();
564
565 match cli.command {
566 AuditCommand::Audit { workspace, docs, format, severity, .. } => {
567 assert_eq!(workspace, PathBuf::from("/tmp/workspace"));
568 assert_eq!(docs, PathBuf::from("/tmp/docs"));
569 assert!(matches!(format, CliOutputFormat::Json));
570 assert!(matches!(severity, CliSeverity::Critical));
571 }
572 _ => panic!("Expected Audit command"),
573 }
574 }
575
576 #[test]
577 fn test_incremental_command() {
578 let cli = AuditCli::try_parse_from([
579 "adk-doc-audit",
580 "incremental",
581 "--workspace",
582 "/tmp/workspace",
583 "file1.md",
584 "file2.md",
585 ]);
586
587 assert!(cli.is_ok());
588 let cli = cli.unwrap();
589
590 match cli.command {
591 AuditCommand::Incremental { changed_files, .. } => {
592 assert_eq!(changed_files.len(), 2);
593 assert_eq!(changed_files[0], PathBuf::from("file1.md"));
594 assert_eq!(changed_files[1], PathBuf::from("file2.md"));
595 }
596 _ => panic!("Expected Incremental command"),
597 }
598 }
599
600 #[test]
601 fn test_validate_command() {
602 let cli =
603 AuditCli::try_parse_from(["adk-doc-audit", "validate", "docs/getting-started.md"]);
604
605 assert!(cli.is_ok());
606 let cli = cli.unwrap();
607
608 match cli.command {
609 AuditCommand::Validate { file_path, .. } => {
610 assert_eq!(file_path, PathBuf::from("docs/getting-started.md"));
611 }
612 _ => panic!("Expected Validate command"),
613 }
614 }
615
616 #[test]
617 fn test_global_flags() {
618 let cli = AuditCli::try_parse_from(["adk-doc-audit", "--verbose", "audit"]);
619
620 assert!(cli.is_ok());
621 let cli = cli.unwrap();
622 assert!(cli.verbose);
623 assert!(!cli.quiet);
624 }
625
626 #[test]
627 fn test_conflicting_flags() {
628 let cli = AuditCli::try_parse_from(["adk-doc-audit", "--verbose", "--quiet", "audit"]);
629
630 assert!(cli.is_err());
632 }
633}