main-spex 0.26.0

AI-first development framework that turns specifications into verified 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, PackageConfig, PreexistingError, SpexConfig, package_yaml_edit, spex_yaml_edit,
};
use crate::error::{Result, could_not_read};
use crate::rule::Severity;
use crate::support::fs::{relative_to, 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::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 {
    is_listed(&owner.package.check_ignore, finding, owner.file.as_deref())
}

fn is_listed(check_ignore: &[PreexistingError], finding: &Finding, file: Option<&Path>) -> bool {
    finding.severity == Severity::Error
        && finding
            .source
            .baseline_key()
            .is_some_and(|(rule_id, check)| {
                check_ignore.iter().any(|entry| {
                    entry.rule_id == rule_id
                        && entry.check == check
                        && (entry.files.is_empty()
                            || file.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) enum BaselineFile {
    SpexYaml,
    PackageYaml,
}

struct Written {
    path: PathBuf,
    changed: bool,
}

pub(super) fn fill_preexisting_errors(
    spex_dir: &Path,
    selected: &[&Package],
    reports: &[CheckReport],
    target: BaselineFile,
) -> Result<ExitCode> {
    let other_check_ignore = other_check_ignore(spex_dir, selected, &target)?;
    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;
            };
            if other_check_ignore
                .get(&package.package_name)
                .is_some_and(|listed| is_listed(listed, finding, file.as_deref()))
            {
                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 written = match target {
        BaselineFile::SpexYaml => vec![write_spex_yaml(spex_dir, &by_package)?],
        BaselineFile::PackageYaml => selected
            .iter()
            .map(|package| write_package_yaml(package, &by_package))
            .collect::<Result<_>>()?,
    };

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

fn other_check_ignore(
    spex_dir: &Path,
    selected: &[&Package],
    target: &BaselineFile,
) -> Result<HashMap<String, Vec<PreexistingError>>> {
    match target {
        BaselineFile::SpexYaml => selected
            .iter()
            .map(|package| -> Result<_> {
                let package_config = PackageConfig::load(&package.path)?;
                Ok((package.package_name.clone(), package_config.check_ignore))
            })
            .collect(),
        BaselineFile::PackageYaml => {
            let spex_config = SpexConfig::load(&spex_dir.join(config::SPEX_CONFIG_FILE))?;
            Ok(spex_config
                .packages
                .into_iter()
                .map(|entry| (entry.package_name, entry.check_ignore))
                .collect())
        }
    }
}

fn write_spex_yaml(
    spex_dir: &Path,
    by_package: &HashMap<String, Vec<PreexistingError>>,
) -> Result<Written> {
    let path = spex_dir.join(config::SPEX_CONFIG_FILE);
    let raw = std::fs::read_to_string(&path).with_context(|| could_not_read(&path))?;
    let updated = spex_yaml_edit::set_preexisting_errors(&raw, by_package);
    write_if_changed(path, &raw, &updated)
}

fn write_package_yaml(
    package: &Package,
    by_package: &HashMap<String, Vec<PreexistingError>>,
) -> Result<Written> {
    let path = package.path.join(config::PACKAGE_CONFIG_FILE);
    let raw = if path.is_file() {
        std::fs::read_to_string(&path).with_context(|| could_not_read(&path))?
    } else {
        String::new()
    };
    let entries = by_package
        .get(&package.package_name)
        .map_or(&[][..], Vec::as_slice);
    let updated = package_yaml_edit::set_check_ignore(&raw, entries);
    write_if_changed(path, &raw, &updated)
}

fn write_if_changed(path: PathBuf, raw: &str, updated: &str) -> Result<Written> {
    let changed = updated != raw;
    if changed {
        write_file(&path, updated)?;
    }
    Ok(Written { path, changed })
}

fn report_fill(
    spex_dir: &Path,
    selected: &[&Package],
    by_package: &HashMap<String, Vec<PreexistingError>>,
    written: &[Written],
) {
    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 {
            "nothing to baseline".green().to_string()
        } else {
            format!("baselined {}", plural(count, "check"))
                .dimmed()
                .to_string()
        };
        println!("  {}  {summary}", package.package_name.bold());
    }
    println!();
    for Written { path, changed } in written {
        let shown = relative_to(path, spex_dir).display();
        if *changed {
            println!("Updated {shown}");
        } else {
            println!("{shown} unchanged");
        }
    }
}