mod baseline;
mod check;
mod components;
mod config;
mod diff;
mod lang;
mod report;
mod scan;
mod suppress;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use baseline::{Baseline, Verdict};
use components::ComponentMap;
use config::Config;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Check {
#[arg(long, short, default_value = config::DEFAULT_CONFIG)]
config: PathBuf,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
no_baseline: bool,
#[arg(long, value_name = "BASE")]
diff: Option<String>,
},
Baseline {
#[arg(long, short, default_value = config::DEFAULT_CONFIG)]
config: PathBuf,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Text,
Json,
}
struct Project {
root: PathBuf,
config: Config,
}
impl Project {
fn open(config_path: &Path) -> Result<Self> {
let config = Config::load(config_path)?;
let config_path = dunce::canonicalize(config_path)
.with_context(|| format!("cannot resolve {}", config_path.display()))?;
let root = config_path
.parent()
.context("config has no parent directory")?
.to_path_buf();
Ok(Self { root, config })
}
fn baseline_path(&self) -> PathBuf {
let rel = self
.config
.baseline
.clone()
.unwrap_or_else(|| config::DEFAULT_BASELINE.into());
self.root.join(rel)
}
fn violations(&self) -> Result<(Vec<check::Violation>, check::Stats)> {
let scan = scan::scan(&self.root, &self.config)?;
let map = ComponentMap::new(&self.config)?;
let (violations, edges, mut stats) = check::evaluate(&self.config, &map, &scan.deps);
let (mut violations, suppressed) = suppress::apply(violations, &scan.suppressions);
violations.extend(check::cycles(&self.config, &edges));
stats.files = scan.files;
stats.suppressed = suppressed;
Ok((violations, stats))
}
}
fn run(cli: Cli) -> Result<bool> {
match cli.command {
Command::Check {
config,
format,
no_baseline,
diff,
} => {
let project = Project::open(&config)?;
let (violations, stats) = project.violations()?;
let current = violations.iter().map(check::Violation::key).collect();
let changes = diff
.map(|base| diff::Changes::collect(&project.root, &base))
.transpose()?;
let judged = match &changes {
Some(changes) => violations
.into_iter()
.filter(|v| v.evidence.iter().any(|(f, l)| changes.contains(f, *l)))
.collect(),
None => violations,
};
let baseline = if no_baseline {
None
} else {
Baseline::load(&project.baseline_path())?
};
let mut verdict = Verdict::judge(check::dedupe(judged), ¤t, baseline.as_ref());
verdict
.new
.sort_by(|a, b| (&a.file, a.line, a.rule).cmp(&(&b.file, b.line, b.rule)));
let changes = changes.as_ref();
match format {
Format::Text => print!("{}", report::text(&verdict, &stats, changes)),
Format::Json => println!("{}", report::json(&verdict, &stats, changes)),
}
Ok(verdict.passed())
}
Command::Baseline { config } => {
let project = Project::open(&config)?;
let (violations, _) = project.violations()?;
let violations = check::dedupe(violations);
let path = project.baseline_path();
Baseline::from_violations(&violations).save(&path)?;
println!(
"easement: {} violation(s) written to {}",
violations.len(),
path.display()
);
Ok(true)
}
}
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(err) => {
eprintln!("easement: error: {err:#}");
ExitCode::from(2)
}
}
}