sofie-check 0.2.0

Structure checks (comment density, god types) and the `sofie check` rules over the app model.
Documentation
use std::path::PathBuf;

use sofie_check::{Date, Finding, Rule, Screens, check_flag_ages};
use sofie_model::testing::{app, area, module};
use sofie_model::{App, FeatureDeclaration, FeatureGate, MenuEntry, MenuFile, Module};

fn check(app: &App) -> Vec<Finding> {
    sofie_check::check(app, &Screens::read(&app.modules))
}

fn declaration(constant: &str, name: Option<&str>, line: usize) -> FeatureDeclaration {
    FeatureDeclaration {
        constant: vec!["module".to_owned(), constant.to_owned()],
        name: name.map(str::to_owned),
        added: Some("2026-09-01".to_owned()),
        retire_after_days: 30,
        variants: false,
        file: PathBuf::from("/shop/modules/order/src/module.rs"),
        line,
    }
}

fn order_with(declarations: Vec<FeatureDeclaration>, gates: Vec<FeatureGate>) -> Module {
    let mut order = module("order", "/shop/modules/order");
    order.code.features.declarations = declarations;
    order.code.features.gates = gates;
    order
}

fn gate(feature: &str) -> FeatureGate {
    FeatureGate {
        function: vec!["listeners".to_owned(), "audit".to_owned()],
        feature: vec![feature.to_owned()],
        file: PathBuf::from("/shop/modules/order/src/listeners.rs"),
        line: 9,
    }
}

fn flag_findings(app: &App) -> Vec<(Rule, usize)> {
    check(app)
        .into_iter()
        .filter(|finding| finding.rule == Rule::FeatureFlag)
        .map(|finding| (finding.rule, finding.line))
        .collect()
}

#[test]
fn a_well_named_flag_and_its_gate_pass() {
    let order = order_with(
        vec![declaration("SPLIT", Some("order.split"), 3)],
        vec![gate("SPLIT")],
    );
    assert_eq!(flag_findings(&app("/shop", vec![order])), []);
}

#[test]
fn a_flag_of_another_module_or_without_a_literal_name_is_reported() {
    let order = order_with(
        vec![
            declaration("SPLIT", Some("payment.split"), 3),
            declaration("COMPUTED", None, 4),
            declaration("NESTED", Some("order.a.b"), 5),
        ],
        Vec::new(),
    );
    let lines: Vec<usize> = flag_findings(&app("/shop", vec![order]))
        .into_iter()
        .map(|(_, line)| line)
        .collect();
    assert_eq!(lines, [3, 4, 5]);
}

#[test]
fn a_flag_declared_twice_and_a_gate_naming_no_flag_are_reported() {
    let order = order_with(
        vec![
            declaration("SPLIT", Some("order.split"), 3),
            declaration("ALSO_SPLIT", Some("order.split"), 4),
        ],
        vec![gate("MISSING")],
    );
    assert_eq!(
        flag_findings(&app("/shop", vec![order])),
        [(Rule::FeatureFlag, 9), (Rule::FeatureFlag, 4)]
    );
}

#[test]
fn a_flag_declared_outside_the_manifest_is_reported() {
    let mut elsewhere = declaration("SPLIT", Some("order.split"), 6);
    elsewhere.file = PathBuf::from("/shop/modules/order/src/flags.rs");
    let order = order_with(vec![elsewhere], Vec::new());
    let findings = check(&app("/shop", vec![order]));
    assert_eq!(findings.len(), 1, "{findings:?}");
    assert!(
        findings[0]
            .message
            .contains("outside the module's manifest"),
        "{}",
        findings[0].message
    );
}

#[test]
fn a_flag_older_than_its_declared_age_is_reported_with_its_file_and_line() {
    let mut younger = declaration("LAYOUT", Some("order.layout"), 4);
    younger.added = Some("2026-09-02".to_owned());
    let order = order_with(
        vec![declaration("SPLIT", Some("order.split"), 3), younger],
        Vec::new(),
    );
    let today = Date::parse("2026-10-02").unwrap();
    let findings = check_flag_ages(&app("/shop", vec![order]), today);
    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].rule, Rule::FlagAge);
    assert_eq!(
        findings[0].file,
        PathBuf::from("/shop/modules/order/src/module.rs")
    );
    assert_eq!(findings[0].line, 3);
    assert!(
        findings[0]
            .message
            .contains("added 31 days ago, past the 30"),
        "{}",
        findings[0].message
    );
}

#[test]
fn a_flag_without_a_readable_day_it_was_added_is_reported() {
    let mut undated = declaration("SPLIT", Some("order.split"), 3);
    undated.added = None;
    let mut misdated = declaration("LAYOUT", Some("order.layout"), 4);
    misdated.added = Some("2026-02-30".to_owned());
    let order = order_with(vec![undated, misdated], Vec::new());
    let lines: Vec<usize> = check(&app("/shop", vec![order]))
        .into_iter()
        .filter(|finding| finding.rule == Rule::FlagAge)
        .map(|finding| finding.line)
        .collect();
    assert_eq!(lines, [3, 4]);
}

#[test]
fn a_menu_entry_naming_no_flag_and_an_unreachable_menu_are_reported() {
    let mut order = order_with(
        vec![declaration("SPLIT", Some("order.split"), 3)],
        Vec::new(),
    );
    let entry = |label: &str, feature: &str, line| MenuEntry {
        label: label.to_owned(),
        feature: Some(feature.to_owned()),
        line,
    };
    let mut console = area(&order, "console", Vec::new());
    console.menu = Some(MenuFile {
        file: PathBuf::from("/shop/modules/order/src/areas/console/menu.rs"),
        declared: false,
        entries: vec![
            entry("Split", "crate::module::SPLIT", 2),
            entry("Beta", "BETA", 3),
        ],
    });
    order.areas.push(console);

    let findings = check(&app("/shop", vec![order]));

    let reported: Vec<(&str, usize)> = findings
        .iter()
        .filter(|finding| finding.file.ends_with("menu.rs"))
        .map(|finding| (finding.rule.name(), finding.line))
        .collect();
    assert_eq!(reported, [("undeclared-routes", 1), ("feature-flag", 3)]);
}