main-spex 0.21.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anstream::println;
use eros::Context;
use owo_colors::OwoColorize;

use crate::config::{self, Package, PreexistingError, spex_yaml_edit};
use crate::error::{Result, could_not_read};
use crate::rule::Severity;
use crate::support::fs::write_file;
use crate::support::text::plural;

use super::finding::Finding;
use super::report::{CheckReport, ReportTarget};

#[cfg(test)]
#[path = "baseline_test.rs"]
mod baseline_test;

enum Baseline {
    WholeCheck,
    Files(BTreeSet<String>),
}

impl Baseline {
    fn add_file(&mut self, file: Option<PathBuf>) {
        match (self, file) {
            (Baseline::WholeCheck, _) => {}
            (slot @ Baseline::Files(_), None) => *slot = Baseline::WholeCheck,
            (Baseline::Files(files), Some(file)) => {
                files.insert(file.to_string_lossy().into_owned());
            }
        }
    }
}

struct Owner<'a> {
    package: &'a Package,
    file: Option<PathBuf>,
}

fn owner<'a>(
    target: &ReportTarget<'a>,
    finding: &Finding,
    selected: &[&'a Package],
) -> Option<Owner<'a>> {
    let file = finding
        .location
        .as_ref()
        .map(|location| location.file.as_path());
    match target {
        ReportTarget::Package(package) => Some(Owner {
            package,
            file: file.map(Path::to_path_buf),
        }),
        ReportTarget::Spex { .. } => None,
        ReportTarget::Project { spex_dir } => {
            let located = spex_dir.join(file?);
            selected.iter().find_map(|package| {
                let relative = located.strip_prefix(&package.path).ok()?;
                (!relative.starts_with("..")).then(|| Owner {
                    package,
                    file: Some(relative.to_path_buf()),
                })
            })
        }
    }
}

fn is_baselined(finding: &Finding, owner: &Owner) -> bool {
    finding.severity == Severity::Error
        && finding
            .source
            .baseline_key()
            .is_some_and(|(rule_id, check)| {
                owner.package.ignored_errors.iter().any(|entry| {
                    entry.rule_id == rule_id
                        && entry.check == check
                        && (entry.files.is_empty()
                            || owner.file.as_ref().is_some_and(|file| {
                                entry.files.iter().any(|listed| Path::new(listed) == file)
                            }))
                })
            })
}

pub(super) fn drop_baselined_findings<'a>(
    reports: &mut [CheckReport<'a>],
    selected: &[&'a Package],
) {
    for report in reports {
        let target = &report.target;
        report.findings.retain(|finding| {
            !owner(target, finding, selected).is_some_and(|owner| is_baselined(finding, &owner))
        });
    }
}

pub(super) fn fill_preexisting_errors(
    spex_dir: &Path,
    selected: &[&Package],
    reports: &[CheckReport],
) -> Result<ExitCode> {
    let mut baselines_by_package: HashMap<String, BTreeMap<(String, String), Baseline>> = selected
        .iter()
        .map(|package| (package.package_name.clone(), BTreeMap::new()))
        .collect();

    for report in reports {
        for finding in &report.findings {
            if finding.severity != Severity::Error {
                continue;
            }
            let Some((rule_id, check)) = finding.source.baseline_key() else {
                continue;
            };
            let Some(Owner { package, file }) = owner(&report.target, finding, selected) else {
                continue;
            };
            let Some(checks) = baselines_by_package.get_mut(&package.package_name) else {
                continue;
            };
            checks
                .entry((rule_id.to_string(), check.to_string()))
                .or_insert_with(|| Baseline::Files(BTreeSet::new()))
                .add_file(file);
        }
    }

    let by_package: HashMap<String, Vec<PreexistingError>> = baselines_by_package
        .into_iter()
        .map(|(name, checks)| {
            let entries = checks
                .into_iter()
                .map(|((rule_id, check), baseline)| PreexistingError {
                    rule_id,
                    check,
                    files: match baseline {
                        Baseline::WholeCheck => Vec::new(),
                        Baseline::Files(files) => files.into_iter().collect(),
                    },
                })
                .collect();
            (name, entries)
        })
        .collect();

    let config_path = spex_dir.join(config::SPEX_CONFIG_FILE);
    let raw =
        std::fs::read_to_string(&config_path).with_context(|| could_not_read(&config_path))?;
    let updated = spex_yaml_edit::set_preexisting_errors(&raw, &by_package);
    let changed = updated != raw;
    if changed {
        write_file(&config_path, &updated)?;
    }

    report_fill(selected, &by_package, changed);
    Ok(ExitCode::SUCCESS)
}

fn report_fill(
    selected: &[&Package],
    by_package: &HashMap<String, Vec<PreexistingError>>,
    changed: bool,
) {
    println!("{}", "spex check --fill-preexisting-errors".bold());
    println!();
    for package in selected {
        let count = by_package.get(&package.package_name).map_or(0, Vec::len);
        let summary = if count == 0 {
            "no pre-existing errors".green().to_string()
        } else {
            format!("baselined {}", plural(count, "check"))
                .dimmed()
                .to_string()
        };
        println!("  {}  {summary}", package.package_name.bold());
    }
    println!();
    if changed {
        println!("Updated {}", config::SPEX_CONFIG_FILE);
    } else {
        println!("{} unchanged", config::SPEX_CONFIG_FILE);
    }
}