sofie-check 0.2.0

Structure checks (comment density, god types) and the `sofie check` rules over the app model.
Documentation
use sofie_model::{Aggregate, Module};

use super::{Finding, Rule};

pub(super) fn check_exposed(modules: &[Module]) -> Vec<Finding> {
    let mut findings = Vec::new();
    for module in modules {
        for aggregate in module
            .code
            .aggregates
            .iter()
            .filter(|aggregate| !aggregate.exposed.is_empty())
        {
            if module.contract.is_none() {
                findings.push(Finding {
                    rule: Rule::ExposedField,
                    file: aggregate.file.clone(),
                    line: aggregate.line,
                    message: format!(
                        "`{}` exposes fields, but module `{}` has no contract to publish them in",
                        aggregate.name, module.krate.package
                    ),
                    fix: "add the module's `contract/` crate, or drop `#[expose]`".to_owned(),
                });
            }
            if let Err(field) = aggregate.contract_fields(&module.code) {
                findings.push(Finding {
                    rule: Rule::ExposedField,
                    file: aggregate.file.clone(),
                    line: field.line,
                    message: format!(
                        "exposed field `{}` is a `{}`, which a contract cannot name",
                        field.name, field.ty
                    ),
                    fix: "expose a field of a scalar, `String`, an sofie value, `Id<X>`, a module \
                          enum or an `Option` of one, or drop `#[expose]`"
                        .to_owned(),
                });
            }
        }
    }
    findings
}

pub(super) fn check_mirrors(modules: &[Module]) -> Vec<Finding> {
    let mut findings = Vec::new();
    for module in modules {
        for mirror in &module.code.aggregates {
            let Some(source) = &mirror.mirror else {
                continue;
            };
            findings.extend(check_mirror(modules, mirror, source));
        }
    }
    findings
}

fn check_mirror(modules: &[Module], mirror: &Aggregate, source: &[String]) -> Vec<Finding> {
    let [contract, name] = source else {
        return vec![mirror_finding(
            mirror,
            mirror.line,
            format!(
                "`#[mirror({})]` does not name a contract's aggregate",
                source.join("::")
            ),
        )];
    };
    let exposing = modules
        .iter()
        .filter(|module| {
            module
                .contract
                .as_ref()
                .is_some_and(|krate| &krate.crate_name == contract)
        })
        .flat_map(|module| &module.code.aggregates)
        .find(|aggregate| &aggregate.name == name);
    let Some(exposing) = exposing else {
        return vec![mirror_finding(
            mirror,
            mirror.line,
            format!("`{contract}::{name}` is no aggregate whose module has that contract"),
        )];
    };
    let hides_nothing = (exposing.soft_deletes() && !mirror.soft_deletes()).then(|| {
        mirror_finding(
            mirror,
            mirror.line,
            format!(
                "`{name}` deletes softly, but its mirror `{}` keeps no `deleted_at`, so it would \
                 go on showing deleted records",
                mirror.name
            ),
        )
    });
    mirror
        .fields
        .iter()
        .filter(|field| !exposing.exposed.contains(&field.name))
        .map(|field| {
            mirror_finding(
                mirror,
                field.line,
                format!(
                    "`{}` mirrors `{}`, which `{name}` does not expose",
                    mirror.name, field.name
                ),
            )
        })
        .chain(hides_nothing)
        .collect()
}

fn mirror_finding(mirror: &Aggregate, line: usize, message: String) -> Finding {
    Finding {
        rule: Rule::MirrorField,
        file: mirror.file.clone(),
        line,
        message,
        fix: "mirror only fields the source marks `#[expose]`, by the same name, and delete \
              softly (`#[aggregate(behaviors(SoftDelete))]`) when the source does"
            .to_owned(),
    }
}