pub(crate) mod render;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::{ArgGroup, Args};
use crate::Exit;
use crate::analysis::findings::{self, Finding, Severity};
use crate::analysis::result::FailureReason;
use crate::cli::severity_parser;
use crate::files;
#[derive(Debug, Args)]
#[command(group(ArgGroup::new("docs-input").args(["paths", "staged"]).multiple(false)))]
pub struct LintDocsArgs {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(long)]
pub staged: bool,
#[arg(long, conflicts_with = "fail_on")]
pub strict: bool,
#[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
pub fail_on: Option<Severity>,
}
impl LintDocsArgs {
pub fn threshold(&self) -> Option<Severity> {
self.fail_on.or(self.strict.then_some(Severity::Info))
}
}
pub struct LintOutcome {
pub findings: Vec<Finding>,
pub failures: BTreeMap<PathBuf, FailureReason>,
pub exit: Exit,
pub gating: Gating,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gating {
ReportOnly,
NoneReached(Severity),
Blocked,
}
pub async fn run(args: &LintDocsArgs, root: &Path) -> Result<Exit> {
let outcome = outcome_for(args, root).await?;
render::render(&outcome)?;
Ok(outcome.exit)
}
pub(crate) async fn outcome_for(args: &LintDocsArgs, root: &Path) -> Result<LintOutcome> {
if !args.staged {
return Ok(analyze(args, root));
}
let staged = crate::diff::staged_files(root, files::is_markdown).await?;
Ok(analyze_files(
staged.into_iter().map(|p| root.join(p)).collect(),
BTreeMap::new(),
args.threshold(),
))
}
fn analyze(args: &LintDocsArgs, root: &Path) -> LintOutcome {
let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
let targets = resolve(&args.paths, root, &mut failures);
analyze_files(targets, failures, args.threshold())
}
fn analyze_files(
targets: Vec<PathBuf>,
mut failures: BTreeMap<PathBuf, FailureReason>,
threshold: Option<Severity>,
) -> LintOutcome {
let mut findings = Vec::new();
for path in targets {
match std::fs::read_to_string(&path) {
Ok(content) => findings.extend(crate::docs::analyze(&path, &content)),
Err(err) => {
failures.insert(path, FailureReason::Unreadable(err.to_string()));
}
}
}
findings.sort_by(|a, b| a.file_path.cmp(&b.file_path));
let gating = gating(&findings, threshold);
let exit = gate(&failures, gating);
LintOutcome {
findings,
failures,
exit,
gating,
}
}
fn resolve(
paths: &[PathBuf],
root: &Path,
failures: &mut BTreeMap<PathBuf, FailureReason>,
) -> Vec<PathBuf> {
let files::Expansion { targets, rejected } =
files::expand_named(paths, root, files::is_markdown);
for (path, why) in rejected {
let reason = match why {
files::Rejected::Missing => {
FailureReason::Unreadable("no such file or directory".to_owned())
}
files::Rejected::Unanalyzable => {
FailureReason::unsupported(&path, files::redirect_hint(&path))
}
};
failures.insert(path, reason);
}
targets
}
fn gating(findings: &[Finding], threshold: Option<Severity>) -> Gating {
match threshold {
None => Gating::ReportOnly,
Some(threshold) if findings::any_at_or_above(findings, threshold) => Gating::Blocked,
Some(threshold) => Gating::NoneReached(threshold),
}
}
fn gate(failures: &BTreeMap<PathBuf, FailureReason>, gating: Gating) -> Exit {
if !failures.is_empty() {
return Exit::Unanalyzed;
}
match gating {
Gating::Blocked => Exit::FoundIssues,
Gating::ReportOnly | Gating::NoneReached(_) => Exit::Clean,
}
}
#[cfg(test)]
mod tests;