sprawl-guard 0.1.0

Repository sprawl checker CLI.
use sprawl_guard_lib::classification::{PathClassification, SourceExplanation, SourceKind};
use sprawl_guard_lib::{MatchedOverride, RustCfgTestStatus, SourceResolvedLimits};

pub(super) fn explain_message(classification: &PathClassification) -> String {
    match classification {
        PathClassification::Ignored { path } => {
            format!("{path}: ignored by standard ignore handling")
        }
        PathClassification::GloballyExcluded { path } => {
            format!("{path}: excluded by global file config")
        }
        PathClassification::NonFile { path } => {
            format!("{path}: not a regular file")
        }
        PathClassification::NotSource { path } => {
            format!("{path}: not a source file for any enabled language")
        }
        PathClassification::AmbiguousLanguage { path, languages } => {
            let languages = languages
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(", ");
            format!("{path}: ambiguous language match ({languages})")
        }
        PathClassification::LanguageExcluded { path, language } => {
            format!("{path}: excluded by {language} language config")
        }
        PathClassification::LanguagesExcluded { path, languages } => {
            let languages = languages
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(", ");
            format!("{path}: excluded by all matching language configs ({languages})")
        }
        PathClassification::TestExcluded { path, language } => {
            format!("{path}: excluded because path is classified as test code ({language})")
        }
        PathClassification::Source(source) => source_message(source),
    }
}

fn source_message(explanation: &SourceExplanation) -> String {
    let kind = match explanation.source.kind {
        SourceKind::Production => "production source",
        SourceKind::Test => "test code",
    };
    let mut message = format!(
        "{}: {kind} ({})",
        explanation.source.path, explanation.source.language
    );
    if let Some(limits) = &explanation.resolved_limits {
        message.push_str(&resolved_limits_message(limits));
    }
    if let Some(rust_cfg_test) = &explanation.rust_cfg_test {
        message.push_str(&format!(
            "; rust cfg(test): {}; excluded top-level items: {}",
            rust_cfg_test_status_message(rust_cfg_test.status),
            rust_cfg_test.excluded_items.get()
        ));
    }
    message
}

fn resolved_limits_message(limits: &SourceResolvedLimits) -> String {
    let mut message = format!("; file LOC limit: {}", limits.file.value);
    if let Some(directory) = &limits.directory {
        message.push_str(&format!("; directory leaf-file limit: {}", directory.value));
    }
    let file_overrides = matching_overrides_message(&limits.file.matched_overrides);
    if !file_overrides.is_empty() {
        message.push_str("; matched file overrides: ");
        message.push_str(&file_overrides);
    }
    if let Some(directory) = &limits.directory {
        let directory_overrides = matching_overrides_message(&directory.matched_overrides);
        if !directory_overrides.is_empty() {
            message.push_str("; matched directory overrides: ");
            message.push_str(&directory_overrides);
        }
    }
    message
}

fn matching_overrides_message(overrides: &[MatchedOverride]) -> String {
    overrides
        .iter()
        .map(|override_entry| {
            format!(
                "#{} paths=[{}]",
                override_entry.ordinal.get(),
                override_entry.paths
            )
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn rust_cfg_test_status_message(status: RustCfgTestStatus) -> &'static str {
    match status {
        RustCfgTestStatus::Disabled => "disabled",
        RustCfgTestStatus::PathClassifiedTest => "not applied to path-classified test code",
        RustCfgTestStatus::PrefilterSkipped => "skipped by prefilter",
        RustCfgTestStatus::Parsed => "parsed",
        RustCfgTestStatus::ParseFailed => "parse failed; counted without exclusion",
    }
}

#[cfg(test)]
mod tests {
    use sprawl_guard_lib::classification::ClassifiedSource;
    use sprawl_guard_lib::{ExcludedItemCount, LanguageId, RelativePath, RustCfgTestInfo};

    use super::*;

    fn path(value: &str) -> RelativePath {
        RelativePath::new(value).unwrap()
    }

    fn source(path: &str, kind: SourceKind) -> SourceExplanation {
        SourceExplanation {
            source: ClassifiedSource {
                path: RelativePath::new(path).unwrap(),
                language: LanguageId::new("rust").unwrap(),
                kind,
            },
            resolved_limits: None,
            rust_cfg_test: None,
        }
    }

    mod explain_message_tests {
        use super::*;

        mod when_the_path_is_not_a_regular_file {
            use super::*;

            #[test]
            fn it_reports_the_filesystem_decision() {
                let message = explain_message(&PathClassification::NonFile {
                    path: path("src/lib.rs"),
                });

                assert_eq!(message, "src/lib.rs: not a regular file");
            }
        }

        mod when_the_file_does_not_match_an_enabled_language {
            use super::*;

            #[test]
            fn it_reports_the_language_decision() {
                let message = explain_message(&PathClassification::NotSource {
                    path: path("README.md"),
                });

                assert_eq!(
                    message,
                    "README.md: not a source file for any enabled language"
                );
            }
        }

        mod when_path_classified_tests_are_disabled {
            use super::*;

            #[test]
            fn it_reports_test_code_without_calling_it_test_source() {
                let message = explain_message(&PathClassification::TestExcluded {
                    path: path("tests/integration.rs"),
                    language: LanguageId::new("rust").unwrap(),
                });

                assert_eq!(
                    message,
                    "tests/integration.rs: excluded because path is classified as test code (Rust)"
                );
            }
        }

        mod when_all_matching_languages_exclude_the_file {
            use super::*;

            #[test]
            fn it_reports_all_matching_languages() {
                let message = explain_message(&PathClassification::LanguagesExcluded {
                    path: path("src/shared"),
                    languages: vec![
                        LanguageId::new("rust").unwrap(),
                        LanguageId::new("go").unwrap(),
                    ],
                });

                assert_eq!(
                    message,
                    "src/shared: excluded by all matching language configs (Rust, Go)"
                );
            }
        }

        mod when_a_rust_source_has_cfg_test_preprocessing {
            use super::*;

            #[test]
            fn it_reports_the_status_and_excluded_item_count() {
                let mut explanation = source("src/lib.rs", SourceKind::Production);
                explanation.rust_cfg_test = Some(RustCfgTestInfo {
                    status: RustCfgTestStatus::Parsed,
                    excluded_items: ExcludedItemCount::new(2),
                });

                let message = explain_message(&PathClassification::Source(explanation));

                assert_eq!(
                    message,
                    "src/lib.rs: production source (Rust); rust cfg(test): parsed; excluded top-level items: 2"
                );
            }
        }
    }
}