adk_doc_audit/
cli.rs

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/// Documentation audit system for ADK-Rust.
8#[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    /// Enable verbose output
17    #[arg(short, long, global = true)]
18    pub verbose: bool,
19
20    /// Enable quiet mode (minimal output)
21    #[arg(short, long, global = true, conflicts_with = "verbose")]
22    pub quiet: bool,
23
24    /// Configuration file path
25    #[arg(short, long, global = true)]
26    pub config: Option<PathBuf>,
27}
28
29/// Available audit commands.
30#[derive(Subcommand)]
31pub enum AuditCommand {
32    /// Run a full documentation audit
33    Audit {
34        /// Path to workspace root
35        #[arg(short, long, default_value = ".")]
36        workspace: PathBuf,
37
38        /// Path to documentation directory
39        #[arg(short, long, default_value = "docs")]
40        docs: PathBuf,
41
42        /// Audit only a specific crate (by name)
43        #[arg(long)]
44        crate_name: Option<String>,
45
46        /// Audit only a specific crate (by path)
47        #[arg(long, conflicts_with = "crate_name")]
48        crate_path: Option<PathBuf>,
49
50        /// Output format
51        #[arg(short, long, default_value = "console")]
52        format: CliOutputFormat,
53
54        /// Minimum severity to report
55        #[arg(short, long, default_value = "warning")]
56        severity: CliSeverity,
57
58        /// Fail build on critical issues
59        #[arg(long, default_value = "true")]
60        fail_on_critical: bool,
61
62        /// Files to exclude (glob patterns)
63        #[arg(long, action = clap::ArgAction::Append)]
64        exclude_files: Vec<String>,
65
66        /// Crates to exclude
67        #[arg(long, action = clap::ArgAction::Append)]
68        exclude_crates: Vec<String>,
69
70        /// Output file path (for JSON/Markdown formats)
71        #[arg(short, long)]
72        output: Option<PathBuf>,
73
74        /// Exit with code 0 even if issues are found (for CI/CD flexibility)
75        #[arg(long)]
76        no_fail: bool,
77
78        /// Maximum number of issues to report (0 = unlimited)
79        #[arg(long, default_value = "0")]
80        max_issues: usize,
81
82        /// CI/CD mode: optimized output for build systems
83        #[arg(long)]
84        ci_mode: bool,
85    },
86
87    /// Audit a single crate's documentation
88    Crate {
89        /// Name of the crate to audit (e.g., "core" for adk-core)
90        crate_name: String,
91
92        /// Path to workspace root
93        #[arg(short, long, default_value = ".")]
94        workspace: PathBuf,
95
96        /// Output format
97        #[arg(short, long, default_value = "console")]
98        format: CliOutputFormat,
99
100        /// Minimum severity to report
101        #[arg(short, long, default_value = "warning")]
102        severity: CliSeverity,
103
104        /// Fail build on critical issues
105        #[arg(long, default_value = "true")]
106        fail_on_critical: bool,
107
108        /// Output file path (for JSON/Markdown formats)
109        #[arg(short, long)]
110        output: Option<PathBuf>,
111    },
112
113    /// Run incremental audit on changed files
114    Incremental {
115        /// Path to workspace root
116        #[arg(short, long, default_value = ".")]
117        workspace: PathBuf,
118
119        /// Path to documentation directory
120        #[arg(short, long, default_value = "docs")]
121        docs: PathBuf,
122
123        /// Changed files to audit
124        #[arg(required = true)]
125        changed_files: Vec<PathBuf>,
126
127        /// Output format
128        #[arg(short, long, default_value = "console")]
129        format: CliOutputFormat,
130    },
131
132    /// Validate a single documentation file
133    Validate {
134        /// Path to the file to validate
135        file_path: PathBuf,
136
137        /// Path to workspace root (for context)
138        #[arg(short, long, default_value = ".")]
139        workspace: PathBuf,
140
141        /// Output format
142        #[arg(short, long, default_value = "console")]
143        format: CliOutputFormat,
144    },
145
146    /// Initialize audit configuration
147    Init {
148        /// Path to create configuration file
149        #[arg(long, default_value = "adk-doc-audit.toml")]
150        config_path: PathBuf,
151
152        /// Path to workspace root
153        #[arg(short, long, default_value = ".")]
154        workspace: PathBuf,
155
156        /// Path to documentation directory
157        #[arg(short, long, default_value = "docs")]
158        docs: PathBuf,
159    },
160
161    /// Show audit statistics and history
162    Stats {
163        /// Path to workspace root
164        #[arg(short, long, default_value = ".")]
165        workspace: PathBuf,
166
167        /// Number of recent audit runs to show
168        #[arg(short, long, default_value = "10")]
169        limit: usize,
170    },
171}
172
173/// CLI-compatible output format enum.
174#[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/// CLI-compatible severity enum.
192#[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    /// Parse command line arguments and create configuration.
211    pub fn parse_args() -> Self {
212        Self::parse()
213    }
214
215    /// Convert CLI arguments to AuditConfig.
216    pub fn to_config(&self) -> Result<AuditConfig> {
217        // Load base config from file if specified, or try default locations
218        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            // Try to load from default locations
223            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        // Override with CLI arguments
245        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                // Handle single crate auditing in CLI configuration
267                if let Some(name) = crate_name {
268                    // Find the crate path by name
269                    let crate_dir = config.workspace_path.join(name);
270                    if !crate_dir.exists() {
271                        // Try with adk- prefix if not found
272                        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                    // Check if the single crate has documentation
293                    if !config.docs_path.exists() {
294                        // Try alternative documentation locations
295                        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                                    // Single README file - audit the parent directory
306                                    config.docs_path = config.workspace_path.clone();
307                                } else {
308                                    // Alternative docs directory
309                                    config.docs_path = alt_path.clone();
310                                }
311                                found_docs = true;
312                                break;
313                            }
314                        }
315
316                        if !found_docs {
317                            // Set docs_path to workspace_path to avoid validation error
318                            // The orchestrator will handle the case where no docs exist
319                            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                    // Check if the single crate has documentation
332                    if !config.docs_path.exists() {
333                        // Try alternative documentation locations
334                        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                                    // Single README file - audit the parent directory
345                                    config.docs_path = config.workspace_path.clone();
346                                } else {
347                                    // Alternative docs directory
348                                    config.docs_path = alt_path.clone();
349                                }
350                                found_docs = true;
351                                break;
352                            }
353                        }
354
355                        if !found_docs {
356                            // Set docs_path to workspace_path to avoid validation error
357                            config.docs_path = config.workspace_path.clone();
358                        }
359                    }
360                } else {
361                    // Regular workspace audit
362                    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; // no_fail overrides fail_on_critical
368                config.excluded_files.extend(exclude_files.clone());
369                config.excluded_crates.extend(exclude_crates.clone());
370
371                // CI/CD specific settings
372                if *ci_mode {
373                    config.quiet = true; // CI mode implies quiet output
374                }
375
376                // Store CI/CD specific options in config (we'll need to extend AuditConfig for this)
377                // For now, we'll handle these in the command execution
378            }
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                // docs_path will be set based on crate_name in main.rs
385            }
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        // Validate the final configuration
405        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    /// Get the output file path for commands that support it.
421    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    /// Get the output format for the current command.
430    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    /// Get the output file path with default filename if format requires file output.
441    pub fn get_output_path_with_default(&self) -> Option<PathBuf> {
442        // First check if user provided explicit output path
443        if let Some(path) = self.get_output_path() {
444            return Some(path.clone());
445        }
446
447        // Generate default filename based on format and command
448        let format = self.get_output_format();
449        match format {
450            crate::config::OutputFormat::Console => None, // Console output doesn't need a file
451            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    /// Get the crate name for single crate audit.
475    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    /// Get the single crate options for audit command.
483    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    /// Get the changed files for incremental audit.
493    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    /// Get the file path for single file validation.
501    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    /// Get the configuration path for init command.
509    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    /// Get the limit for stats command.
517    pub fn get_stats_limit(&self) -> Option<usize> {
518        match &self.command {
519            AuditCommand::Stats { limit, .. } => Some(*limit),
520            _ => None,
521        }
522    }
523
524    /// Get CI/CD specific options for audit command.
525    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        // Verify that the CLI definition is valid
543        AuditCli::command().debug_assert();
544    }
545
546    #[test]
547    fn test_cli_parsing() {
548        // Test basic audit command
549        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        // Should fail due to conflicting flags
631        assert!(cli.is_err());
632    }
633}