use crate::services::doc_validator::{DocValidator, ValidationStatus, ValidatorConfig};
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
use std::process::ExitCode;
pub use crate::contracts::OutputFormat;
#[derive(Parser, Debug)]
pub struct ValidateDocsCmd {
#[arg(short, long)]
pub root: Option<PathBuf>,
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(short, long, default_value = "true")]
pub fail_on_error: bool,
#[arg(short, long, default_value = "text")]
pub output: OutputFormat,
#[arg(long, default_value = "10")]
pub max_concurrent: usize,
#[arg(long, default_value = "30")]
pub timeout: u64,
#[arg(long, default_value = "3")]
pub max_retries: u32,
#[arg(long)]
pub exclude: Vec<String>,
#[arg(short, long)]
pub verbose: bool,
}
impl ValidateDocsCmd {
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub async fn execute(&self) -> Result<ExitCode> {
let config = if let Some(config_path) = &self.config {
self.load_config(config_path)?
} else {
self.build_config()
};
if self.verbose {
eprintln!("🔍 Validating documentation links...");
eprintln!("📁 Root: {}", config.root_dir.display());
eprintln!("⏱️ Timeout: {}ms", config.http_timeout_ms);
eprintln!("🔄 Max retries: {}", config.max_retries);
eprintln!("⚡ Max concurrent: {}", config.max_concurrent_requests);
}
let validator = DocValidator::new(config);
let root = self.root.clone().unwrap_or_else(|| PathBuf::from("."));
let summary = validator.validate_directory(&root).await?;
match self.output {
OutputFormat::Text | OutputFormat::Plain => self.print_text_summary(&summary),
OutputFormat::Json => self.print_json_summary(&summary)?,
OutputFormat::Junit => self.print_junit_summary(&summary)?,
_ => self.print_text_summary(&summary),
}
if self.fail_on_error && summary.broken_links > 0 {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
}
}
fn build_config(&self) -> ValidatorConfig {
let mut exclude_patterns = vec![
"archive".to_string(),
"node_modules".to_string(),
".git".to_string(),
"target".to_string(),
];
exclude_patterns.extend(self.exclude.clone());
ValidatorConfig {
root_dir: self.root.clone().unwrap_or_else(|| PathBuf::from(".")),
http_timeout_ms: self.timeout * 1000,
max_retries: self.max_retries,
retry_delay_ms: 1000,
max_concurrent_requests: self.max_concurrent,
exclude_patterns,
follow_redirects: true,
user_agent: format!("pmat-doc-validator/{}", env!("CARGO_PKG_VERSION")),
}
}
fn load_config(&self, path: &PathBuf) -> Result<ValidatorConfig> {
let content = std::fs::read_to_string(path)?;
let config: ValidatorConfig = toml::from_str(&content)?;
Ok(config)
}
fn print_text_summary(&self, summary: &crate::services::doc_validator::ValidationSummary) {
use crate::cli::colors as c;
println!();
println!("{}", c::header("Documentation Link Validation Summary"));
println!();
println!(
" Files scanned: {}",
c::number(&summary.total_files.to_string())
);
println!(
" Links found: {}",
c::number(&summary.total_links.to_string())
);
println!(
" Valid links: {}{}{}",
c::GREEN,
summary.valid_links,
c::RESET
);
println!(
" Broken links: {}{}{}",
if summary.broken_links > 0 {
c::RED
} else {
c::GREEN
},
summary.broken_links,
c::RESET
);
println!(
" Skipped links: {}{}{}",
c::DIM,
summary.skipped_links,
c::RESET
);
println!(
" Duration: {}{}ms{}",
c::DIM,
summary.duration_ms,
c::RESET
);
println!();
if summary.broken_links > 0 {
println!("{}", c::subheader("Broken Links:"));
println!("{}", c::rule());
for result in &summary.results {
if matches!(
result.status,
ValidationStatus::NotFound | ValidationStatus::HttpError(_)
) {
println!(
" {} {}{}{}:{}{}{}",
c::fail(""),
c::CYAN,
result.link.source_file.display(),
c::RESET,
c::YELLOW,
result.link.line_number,
c::RESET
);
println!(" Link: {}[{}]{}", c::DIM, result.link.text, c::RESET);
if let Some(msg) = &result.error_message {
println!(" Error: {}{}{}", c::RED, msg, c::RESET);
}
if let Some(code) = result.http_status_code {
println!(" HTTP Status: {}{}{}", c::RED, code, c::RESET);
}
println!();
}
}
}
if self.verbose {
println!();
println!("{}", c::subheader("All Links:"));
println!("{}", c::rule());
for result in &summary.results {
let (status_icon, color) = match result.status {
ValidationStatus::Valid => ("✓", c::GREEN),
ValidationStatus::NotFound => ("✗", c::RED),
ValidationStatus::HttpError(_) => ("✗", c::RED),
ValidationStatus::NetworkError => ("⚠", c::YELLOW),
ValidationStatus::InvalidLink => ("⚠", c::YELLOW),
ValidationStatus::Skipped => ("⏭", c::DIM),
};
println!(
" {color}{status_icon}{} {}{}{}:{}{}{} → {}",
c::RESET,
c::CYAN,
result.link.source_file.display(),
c::RESET,
c::YELLOW,
result.link.line_number,
c::RESET,
result.link.target
);
}
}
if summary.broken_links == 0 {
println!("{}", c::pass("All documentation links are valid!"));
} else {
println!(
"{}",
c::fail(&format!("Found {} broken link(s)", summary.broken_links))
);
}
}
fn print_json_summary(
&self,
summary: &crate::services::doc_validator::ValidationSummary,
) -> Result<()> {
use serde_json::json;
let results_json: Vec<_> = summary
.results
.iter()
.map(|r| {
json!({
"source_file": r.link.source_file.to_string_lossy(),
"line_number": r.link.line_number,
"link_text": r.link.text,
"link_target": r.link.target,
"link_type": format!("{:?}", r.link.link_type),
"status": format!("{:?}", r.status),
"error_message": r.error_message,
"http_status_code": r.http_status_code,
"response_time_ms": r.response_time_ms,
})
})
.collect();
let output = json!({
"total_files": summary.total_files,
"total_links": summary.total_links,
"valid_links": summary.valid_links,
"broken_links": summary.broken_links,
"skipped_links": summary.skipped_links,
"duration_ms": summary.duration_ms,
"results": results_json,
});
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn print_junit_summary(
&self,
summary: &crate::services::doc_validator::ValidationSummary,
) -> Result<()> {
println!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
println!(
"<testsuites name=\"Documentation Link Validation\" tests=\"{}\" failures=\"{}\" time=\"{:.3}\">",
summary.total_links,
summary.broken_links,
summary.duration_ms as f64 / 1000.0
);
println!(
" <testsuite name=\"Link Validation\" tests=\"{}\" failures=\"{}\">",
summary.total_links, summary.broken_links
);
for result in &summary.results {
let test_name = format!(
"{}:{} - {}",
result.link.source_file.display(),
result.link.line_number,
result.link.target
);
print!(
" <testcase name=\"{}\" classname=\"LinkValidation\"",
xml_escape(&test_name)
);
if let Some(time_ms) = result.response_time_ms {
print!(" time=\"{:.3}\"", time_ms as f64 / 1000.0);
}
if matches!(
result.status,
ValidationStatus::NotFound | ValidationStatus::HttpError(_)
) {
println!(">");
println!(
" <failure message=\"{}\">",
xml_escape(&result.error_message.clone().unwrap_or_default())
);
println!("Link: {}", xml_escape(&result.link.target));
println!(" </failure>");
println!(" </testcase>");
} else {
println!(" />");
}
}
println!(" </testsuite>");
println!("</testsuites>");
Ok(())
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("hello"), "hello");
assert_eq!(xml_escape("<tag>"), "<tag>");
assert_eq!(xml_escape("a & b"), "a & b");
assert_eq!(xml_escape("\"quoted\""), ""quoted"");
}
#[test]
fn test_build_config() {
let cmd = ValidateDocsCmd {
root: Some(PathBuf::from("docs")),
config: None,
fail_on_error: true,
output: OutputFormat::Text,
max_concurrent: 20,
timeout: 60,
max_retries: 5,
exclude: vec!["custom_exclude".to_string()],
verbose: false,
};
let config = cmd.build_config();
assert_eq!(config.root_dir, PathBuf::from("docs"));
assert_eq!(config.http_timeout_ms, 60000);
assert_eq!(config.max_retries, 5);
assert_eq!(config.max_concurrent_requests, 20);
assert!(config.exclude_patterns.contains(&"archive".to_string()));
assert!(config
.exclude_patterns
.contains(&"node_modules".to_string()));
assert!(config.exclude_patterns.contains(&".git".to_string()));
assert!(config.exclude_patterns.contains(&"target".to_string()));
assert!(config
.exclude_patterns
.contains(&"custom_exclude".to_string()));
assert_eq!(config.exclude_patterns.len(), 5);
}
#[test]
fn test_build_config_without_custom_excludes() {
let cmd = ValidateDocsCmd {
root: Some(PathBuf::from("docs")),
config: None,
fail_on_error: true,
output: OutputFormat::Text,
max_concurrent: 10,
timeout: 30,
max_retries: 3,
exclude: vec![], verbose: false,
};
let config = cmd.build_config();
assert_eq!(
config.exclude_patterns,
vec![
"archive".to_string(),
"node_modules".to_string(),
".git".to_string(),
"target".to_string(),
]
);
}
}