use std::path::PathBuf;
use std::process;
use clap::Args;
use colored::Colorize;
use aaai_core::{
AuditEngine, AuditStatus, DiffEngine, DiffType, IgnoreRules, MaskingEngine,
config::io as config_io,
history::{record::HistoryRecord, store as history_store},
project::config::ProjectConfig,
};
#[derive(Args)]
pub struct AuditArgs {
#[arg(short = 'l', long, value_name = "PATH")]
pub left: PathBuf,
#[arg(short = 'r', long, value_name = "PATH")]
pub right: PathBuf,
#[arg(short = 'c', long, value_name = "FILE")]
pub config: PathBuf,
#[arg(long, value_name = "FILE")]
pub ignore: Option<PathBuf>,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long = "json-output")]
pub json_output: bool,
#[arg(long)]
pub allow_pending: bool,
#[arg(long)]
pub no_history: bool,
#[arg(long)]
pub mask_secrets: bool,
#[arg(long, value_name = "NAME")]
pub approver: Option<String>,
#[arg(long)]
pub progress: bool,
#[arg(long, value_delimiter = ',', value_name = "KIND")]
pub suppress_warnings: Vec<String>,
#[arg(long)]
pub warn_only: bool,
}
pub fn run(args: AuditArgs) -> anyhow::Result<()> {
let left_path = args.left.clone();
let right_path = args.right.clone();
let ignore_path = args.ignore.clone()
.unwrap_or_else(|| args.left.join(".aaaiignore"));
let ignore = IgnoreRules::load(&ignore_path)?;
let proj_cfg = ProjectConfig::discover(&args.left)
.unwrap_or(None)
.map(|(c, _)| c);
let use_masking = args.mask_secrets || proj_cfg.as_ref().map_or(false, |c| c.mask_secrets);
let masker: Option<MaskingEngine> = if use_masking {
let custom = proj_cfg.as_ref()
.map(|c| c.custom_mask_patterns.clone())
.unwrap_or_default();
Some(MaskingEngine::with_custom(&custom))
} else {
None
};
let definition = config_io::load(&args.config)?;
let diffs = if args.progress {
use aaai_core::{ChannelProgress, DiffProgress};
use std::sync::mpsc;
let (tx, rx) = mpsc::channel::<DiffProgress>();
let sink = ChannelProgress::new(tx);
let pb = indicatif::ProgressBar::new(0);
pb.set_style(
indicatif::ProgressStyle::with_template(
" {spinner:.cyan} [{bar:30.cyan/blue}] {pos}/{len} {msg}"
)?.progress_chars("█▓░")
);
let _lp = args.left.clone();
let _rp = args.right.clone();
let _ign = ignore.clone();
let handle = std::thread::spawn(move || {
DiffEngine::compare_with_progress(&_lp, &_rp, &_ign, &sink)
});
for event in rx {
match event {
DiffProgress::Started { total } => pb.set_length(total as u64),
DiffProgress::File { path: _path, processed, total: _total } => {
pb.set_position(processed as u64);
}
DiffProgress::Sorting => pb.set_message("Sorting…"),
DiffProgress::Done { total_files } => {
pb.finish_with_message(format!("{total_files} files"));
}
}
}
handle.join().unwrap()?
} else {
DiffEngine::compare_with_ignore(&args.left, &args.right, &ignore)?
};
let result = AuditEngine::evaluate(&diffs, &definition);
let s = &result.summary;
if !args.no_history {
let record = HistoryRecord::new(&args.left, &args.right, Some(&args.config), s);
if let Err(e) = history_store::append(&record) {
log::warn!("Could not write history: {e}");
}
}
let expired = definition.expired_entries();
let expiring_soon = definition.expiring_soon(30);
if args.json_output {
let doc = serde_json::json!({
"result": if s.is_passing() { "PASSED" } else { "FAILED" },
"summary": { "total": s.total, "ok": s.ok, "pending": s.pending,
"failed": s.failed, "ignored": s.ignored, "error": s.error },
"expired_count": expired.len(),
"expiring_soon_count": expiring_soon.len(),
"entries": result.results.iter().map(|r| serde_json::json!({
"path": r.diff.path,
"diff_type": r.diff.diff_type.to_string(),
"status": r.status.to_string(),
"reason": r.entry.as_ref().map(|e| &e.reason),
"ticket": r.entry.as_ref().and_then(|e| e.ticket.as_ref()),
"approved_by":r.entry.as_ref().and_then(|e| e.approved_by.as_ref()),
"approved_at":r.entry.as_ref().and_then(|e| e.approved_at),
"expires_at": r.entry.as_ref().and_then(|e| e.expires_at),
"strategy": r.entry.as_ref().map(|e| e.strategy.label()),
"detail": r.detail,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&doc)?);
process::exit(exit_code(s, args.allow_pending));
}
if !args.quiet {
println!("{}", "aaai audit".bold());
println!("Before : {}", left_path.display());
println!("After : {}", right_path.display());
println!("Config : {}", args.config.display());
if ignore_path.exists() {
println!("Ignore : {}", ignore_path.display());
}
println!();
if !expired.is_empty() {
println!("{}", format!("⚠ {} EXPIRED entries in definition:", expired.len()).yellow().bold());
for e in &expired {
println!(" {} (expired: {})",
e.path,
e.expires_at.map(|d| d.to_string()).unwrap_or_default());
}
println!();
}
if !expiring_soon.is_empty() {
println!("{}", format!("⏰ {} entries expiring within 30 days:", expiring_soon.len()).yellow());
for e in &expiring_soon {
println!(" {} (expires: {})",
e.path,
e.expires_at.map(|d| d.to_string()).unwrap_or_default());
}
println!();
}
for r in &result.results {
let show = match r.status {
AuditStatus::Ok => args.verbose && r.diff.diff_type != DiffType::Unchanged,
AuditStatus::Ignored => args.verbose,
_ => r.diff.diff_type != DiffType::Unchanged,
};
if !show { continue; }
let status_str = match r.status {
AuditStatus::Ok => "OK ".green().to_string(),
AuditStatus::Pending => "PENDING".yellow().to_string(),
AuditStatus::Failed => "FAILED ".red().bold().to_string(),
AuditStatus::Ignored => "IGNORED".dimmed().to_string(),
AuditStatus::Error => "ERROR ".red().to_string(),
};
let ticket_tag = r.entry.as_ref()
.and_then(|e| e.ticket.as_ref())
.map(|t| format!(" [{}]", t))
.unwrap_or_default();
let expiry_tag = r.entry.as_ref()
.and_then(|e| e.expires_at)
.map(|d| {
let today = chrono::Utc::now().date_naive();
if d <= today { format!(" ⚠expired:{d}") }
else { format!(" ⏰:{d}") }
})
.unwrap_or_default();
println!("{status_str} {}{ticket_tag}{expiry_tag} ({})",
r.diff.path, r.diff.diff_type);
if let Some(detail) = &r.detail {
if r.status != AuditStatus::Ok {
println!(" {}", detail.dimmed());
}
}
if args.verbose {
if let Some(entry) = &r.entry {
if !entry.reason.is_empty() {
let reason = masker.as_ref()
.map(|m| m.mask(&entry.reason))
.unwrap_or_else(|| entry.reason.clone());
println!(" Reason: {}", reason.dimmed());
}
if let Some(ab) = &entry.approved_by {
println!(" Approved by: {}", ab.dimmed());
}
}
if let Some(stats) = &r.diff.stats {
println!(" Lines: +{} -{} (={} unchanged)",
stats.lines_added, stats.lines_removed, stats.lines_unchanged);
}
if let Some(label) = r.diff.size_change_label() {
println!(" Size: {}", label.dimmed());
}
if r.diff.is_binary {
println!(" {}", "(binary file)".dimmed());
}
}
}
println!();
}
let verdict_str = if s.is_passing() {
"Result: PASSED".green().bold()
} else {
"Result: FAILED".red().bold()
};
println!("{verdict_str}");
println!(
" Total: {} OK: {} Pending: {} Failed: {} Error: {} Ignored: {}",
s.total,
s.ok.to_string().green(),
s.pending.to_string().yellow(),
s.failed.to_string().red(),
s.error.to_string().red(),
s.ignored,
);
process::exit(exit_code(s, args.allow_pending));
}
fn exit_code(s: &aaai_core::AuditSummary, allow_pending: bool) -> i32 {
if s.error > 0 { return 3; }
if s.failed > 0 { return 1; }
if !allow_pending && s.pending > 0 { return 2; }
0
}