sprawl-guard 0.1.0

Repository sprawl checker CLI.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::num::NonZeroUsize;
use std::path::Path;

use serde::Serialize;
use serde_json::json;
use sprawl_guard_lib::check_without_ratchet;
use sprawl_guard_lib::classification::ClassifiedSource;
use sprawl_guard_lib::{
    CheckOptions, CheckViolation, DirectoryViolationKind, FileViolationKind, LanguageId,
};

use crate::error::CliError;
use crate::error::Result;

use super::init_language_key;

const GENERATED_VENDOR_ADVISORY_THRESHOLD: usize = 100;

pub(super) struct InitAdvisory {
    source_summary: SourceSummary,
    report: sprawl_guard_lib::CheckReport,
}

pub(super) fn run_advisory_scan(
    root: &Path,
    target: &Path,
    config: &sprawl_guard_lib::config::Config,
) -> Result<InitAdvisory> {
    run_advisory_scan_inner(root, config).map_err(|source| CliError::InitAdvisoryScan {
        path: target.to_path_buf(),
        source: Box::new(source),
    })
}

pub(super) fn write_advisory_summary(output: &mut String, advisory: &InitAdvisory) {
    advisory.source_summary.write_summary(output);
    let counts = ViolationCounts::from_report(&advisory.report);
    if counts.is_empty() {
        output.push_str("Current tree has no violations under the new policy.\n");
    } else {
        output.push_str("Current tree has violations under the new policy:\n");
        writeln!(
            output,
            "  production file LOC violations: {}",
            counts.production_file_loc
        )
        .unwrap();
        writeln!(
            output,
            "  test file LOC violations: {}",
            counts.test_file_loc
        )
        .unwrap();
        writeln!(
            output,
            "  production directory fan-out violations: {}",
            counts.production_directory_fanout
        )
        .unwrap();
        writeln!(
            output,
            "  test directory fan-out violations: {}",
            counts.test_directory_fanout.message()
        )
        .unwrap();
    }
    advisory
        .source_summary
        .write_generated_vendor_advisories(output);
}

pub(super) fn advisory_json(advisory: &InitAdvisory) -> serde_json::Value {
    json!({
        "source_summary": advisory.source_summary,
        "violation_counts": ViolationCounts::from_report(&advisory.report),
    })
}

fn run_advisory_scan_inner(
    root: &Path,
    config: &sprawl_guard_lib::config::Config,
) -> Result<InitAdvisory> {
    let sources = sprawl_guard_lib::classification::discover_sources(root, config)?;
    let source_summary = SourceSummary::from_sources(&sources);
    let report = check_without_ratchet(CheckOptions {
        root: root.to_path_buf(),
        config,
        paths: vec![],
        require_ratchet_current: Some(false),
        traversal_error_policy: None,
    })?;
    Ok(InitAdvisory {
        source_summary,
        report,
    })
}

#[derive(Default, Serialize)]
struct SourceSummary {
    language_counts: BTreeMap<LanguageId, usize>,
    generated_vendor_warnings: Vec<GeneratedVendorWarning>,
}

impl SourceSummary {
    fn from_sources(sources: &[ClassifiedSource]) -> Self {
        let mut language_counts = BTreeMap::new();
        let mut generated_vendor_counts: BTreeMap<
            GeneratedVendorSegment,
            BTreeMap<LanguageId, usize>,
        > = BTreeMap::new();
        for source in sources {
            *language_counts.entry(source.language).or_insert(0) += 1;
            if let Some(segment) = generated_vendor_segment(source) {
                *generated_vendor_counts
                    .entry(segment.to_owned())
                    .or_default()
                    .entry(source.language)
                    .or_insert(0) += 1;
            }
        }
        let generated_vendor_warnings = generated_vendor_counts
            .into_iter()
            .filter_map(|(segment, language_counts)| {
                GeneratedVendorWarning::new(segment, language_counts)
            })
            .collect();
        Self {
            language_counts,
            generated_vendor_warnings,
        }
    }

    fn write_summary(&self, output: &mut String) {
        if self.language_counts.is_empty() {
            return;
        }
        output.push_str("Included source files:\n");
        for (language, count) in &self.language_counts {
            writeln!(
                output,
                "  {:<12} {}",
                init_language_key(*language),
                file_count(*count)
            )
            .unwrap();
        }
        output.push('\n');
    }

    fn write_generated_vendor_advisories(&self, output: &mut String) {
        if self.generated_vendor_warnings.is_empty() {
            return;
        }
        output.push('\n');
        output.push_str("Large generated/vendor-like directories were included:\n");
        for warning in &self.generated_vendor_warnings {
            writeln!(
                output,
                "  {}/          {}",
                warning.segment.as_str(),
                warning.language_summary()
            )
            .unwrap();
        }
        output.push('\n');
        output.push_str("sprawl-guard has no built-in semantic excludes.\n");
        output.push_str(
            "Add [files].exclude entries or ignore rules if these should not be checked.\n",
        );
    }
}

fn generated_vendor_segment(source: &ClassifiedSource) -> Option<GeneratedVendorSegment> {
    source
        .path
        .as_str()
        .split('/')
        .find_map(GeneratedVendorSegment::from_path_segment)
}

#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
enum GeneratedVendorSegment {
    Dist,
    Build,
    Target,
    Out,
    Generated,
    Vendor,
    NodeModules,
    Next,
    Turbo,
}

impl GeneratedVendorSegment {
    fn from_path_segment(segment: &str) -> Option<Self> {
        match segment {
            "dist" => Some(Self::Dist),
            "build" => Some(Self::Build),
            "target" => Some(Self::Target),
            "out" => Some(Self::Out),
            "generated" => Some(Self::Generated),
            "vendor" => Some(Self::Vendor),
            "node_modules" => Some(Self::NodeModules),
            ".next" => Some(Self::Next),
            ".turbo" => Some(Self::Turbo),
            _ => None,
        }
    }

    fn as_str(self) -> &'static str {
        match self {
            Self::Dist => "dist",
            Self::Build => "build",
            Self::Target => "target",
            Self::Out => "out",
            Self::Generated => "generated",
            Self::Vendor => "vendor",
            Self::NodeModules => "node_modules",
            Self::Next => ".next",
            Self::Turbo => ".turbo",
        }
    }
}

#[derive(Serialize)]
struct GeneratedVendorWarning {
    segment: GeneratedVendorSegment,
    language_counts: BTreeMap<LanguageId, usize>,
}

impl GeneratedVendorWarning {
    fn new(
        segment: GeneratedVendorSegment,
        language_counts: BTreeMap<LanguageId, usize>,
    ) -> Option<Self> {
        let total = language_counts.values().sum::<usize>();
        (total >= GENERATED_VENDOR_ADVISORY_THRESHOLD).then_some(Self {
            segment,
            language_counts,
        })
    }

    fn language_summary(&self) -> String {
        self.language_counts
            .iter()
            .map(|(language, count)| {
                format!("{} {}", file_count(*count), init_language_key(*language))
            })
            .collect::<Vec<_>>()
            .join(", ")
    }
}

#[derive(Default, Serialize)]
struct ViolationCounts {
    production_file_loc: usize,
    test_file_loc: usize,
    production_directory_fanout: usize,
    test_directory_fanout: TestDirectoryFanoutViolations,
}

impl ViolationCounts {
    fn from_report(report: &sprawl_guard_lib::CheckReport) -> Self {
        let mut counts = Self::default();
        for violation in report.violations() {
            match violation {
                CheckViolation::FileLoc(violation) => match violation.kind {
                    FileViolationKind::FileLoc => counts.production_file_loc += 1,
                    FileViolationKind::TestFileLoc => counts.test_file_loc += 1,
                },
                CheckViolation::DirectoryFanout(violation) => match violation.kind {
                    DirectoryViolationKind::DirectoryFanout => {
                        counts.production_directory_fanout += 1;
                    }
                    DirectoryViolationKind::TestDirectoryFanout => {
                        counts.test_directory_fanout.increment();
                    }
                },
                CheckViolation::Ratchet(_) => {}
            }
        }
        counts
    }

    fn is_empty(&self) -> bool {
        self.production_file_loc == 0
            && self.test_file_loc == 0
            && self.production_directory_fanout == 0
            && self.test_directory_fanout.is_empty()
    }
}

#[derive(Default, Serialize)]
#[serde(rename_all = "snake_case")]
enum TestDirectoryFanoutViolations {
    #[default]
    NotEnforced,
    Counted(NonZeroUsize),
}

impl TestDirectoryFanoutViolations {
    fn increment(&mut self) {
        match self {
            Self::NotEnforced => *self = Self::Counted(NonZeroUsize::MIN),
            Self::Counted(count) => {
                *count =
                    NonZeroUsize::new(count.get() + 1).expect("incremented count must be nonzero");
            }
        }
    }

    fn is_empty(&self) -> bool {
        match self {
            Self::NotEnforced => true,
            Self::Counted(_) => false,
        }
    }

    fn message(&self) -> String {
        match self {
            Self::NotEnforced => "not enforced (unlimited)".to_owned(),
            Self::Counted(count) => count.to_string(),
        }
    }
}

fn file_count(count: usize) -> String {
    match count {
        1 => "1 file".to_owned(),
        count => format!("{count} files"),
    }
}