specfold-core 0.7.1

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! S1: the frontmatter parses and holds the common keys, and every other key is declared.

use crate::check::context::{Ctx, key_line};
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::suggest::distance;
use crate::suggest::shell::quote;

/// Frontmatter keys every document may carry.
const COMMON_KEYS: &[&str] = &[
    "id",
    "title",
    "status",
    "created",
    "updated",
    "owners",
    "tags",
    "aliases",
    "rel",
    "files",
    "generated_by",
    "check_skip",
    "verified_at",
    "lang",
    "source_rev",
    "review_by",
    "pins",
];
const REQUIRED_KEYS: &[&str] = &["id", "title", "status", "created", "updated"];

pub struct Frontmatter;

impl Rule for Frontmatter {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "S1",
            title: "frontmatter parses and has the required keys",
            explain: "Every document starts with a YAML frontmatter block holding id, title, status, created and updated. Keys outside the common set and the type's own fields are reported so typos do not pass silently; a key that is meant to stay is declared in spec.toml as `[types.<type>] fields = [...]`, a list that replaces the type's built-in fields, so it keeps them, or for every type as `[check] extra_keys = [\"team\", \"review_*\"]` (a trailing * allows a prefix). `spec fix --rule S1` fills missing keys from the path and the first heading. A file that is not UTF-8 is refused; convert it from its encoding with `iconv -f <encoding> -t UTF-8 <file>`.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            if !ctx.scope.covers(i) {
                continue;
            }
            if let Some(e) = &d.frontmatter.error
                && e.starts_with(crate::corpus::overlay::UTF8)
            {
                let line = crate::corpus::overlay::utf8_line(e).unwrap_or(1);
                out.push(Finding::error("S1", &d.path, line, format!("file is {e}; save it as UTF-8")));
                continue;
            }
            if let Some(e) = &d.frontmatter.error {
                out.push(Finding::error("S1", &d.path, 1, format!("frontmatter does not parse: {e}")));
                continue;
            }
            let Some(fm) = d.fm() else {
                out.push(
                    Finding::error("S1", &d.path, 1, "no frontmatter block")
                        .fix_cmd(format!("spec fix --rule S1 --write {}", quote(&d.path))),
                );
                continue;
            };
            let missing: Vec<&str> = REQUIRED_KEYS.iter().copied().filter(|k| !fm.contains_key(*k)).collect();
            if !missing.is_empty() {
                out.push(
                    Finding::error("S1", &d.path, 1, format!("missing {}", missing.join(", ")))
                        .fix_cmd(format!("spec fix --rule S1 --write {}", quote(&d.path))),
                );
            }
            // C200: a title of the wrong YAML type (a list, a map, a number, "") is no title.
            if let Some(t) = fm.get("title")
                && !t.as_str().is_some_and(|s| !s.trim().is_empty())
            {
                out.push(
                    Finding::error(
                        "S1",
                        &d.path,
                        key_line(ctx, &d.path, "title"),
                        format!("title is {t}, not a non-empty string"),
                    )
                    .fix_cmd(format!("spec set {} 'title=<text>' --write", d.id)),
                );
            }
            let ty = ctx.corpus.cfg.registry.get(&d.id.ty);
            for k in fm.keys() {
                let known = COMMON_KEYS.contains(&k.as_str())
                    || ctx.corpus.cfg.raw.check.extra_key(k)
                    || ty.is_some_and(|t| t.fields.iter().any(|f| f == k) || (t.sealed && k == "sealed"));
                if !known {
                    out.push(Finding::warn(
                        "S1",
                        &d.path,
                        key_line(ctx, &d.path, k),
                        unknown_key_message(k, &d.id.ty, ty.map(|t| t.fields.as_slice()).unwrap_or_default()),
                    ));
                }
            }
        }
    }
}

/// The S1 warning `spec set` would cause by writing `key` on a document of type `ty`, so set can
/// say it at once.
pub fn unknown_key(c: &crate::corpus::Corpus, ty: &str, key: &str) -> Option<String> {
    let k = key.split('.').next().unwrap_or(key);
    let t = c.cfg.registry.get(ty);
    let known = COMMON_KEYS.contains(&k)
        || c.cfg.raw.check.extra_key(k)
        || t.is_some_and(|t| t.fields.iter().any(|f| f == k) || (t.sealed && k == "sealed"));
    (!known).then(|| unknown_key_message(k, ty, t.map(|t| t.fields.as_slice()).unwrap_or_default()))
}

/// Names a close known key when the unknown one looks like a typo, and otherwise shows the
/// spec.toml line that declares it. `fields` replaces the type's list, so the line keeps it.
fn unknown_key_message(k: &str, ty: &str, fields: &[String]) -> String {
    let known = COMMON_KEYS.iter().map(|s| s.to_string()).chain(fields.iter().cloned());
    if let Some(near) = known.filter(|c| c != k).find(|c| distance(c, k) <= 2.min(k.len() / 3)) {
        return format!("unknown key '{k}' for {ty}; did you mean '{near}'?");
    }
    let list: Vec<String> = fields.iter().map(String::as_str).chain([k]).map(|f| format!("\"{f}\"")).collect();
    format!(
        "unknown key '{k}' for {ty}; to keep it, declare it in spec.toml: [types.{ty}] fields = [{}], or for every type: [check] extra_keys = [\"{k}\"]",
        list.join(", ")
    )
}

#[cfg(test)]
mod unknown_key_tests {
    #[test]
    fn suggests_a_near_key_or_the_declaration() {
        let f = vec!["verify".to_string()];
        assert!(super::unknown_key_message("staus", "adr", &f).ends_with("did you mean 'status'?"));
        assert_eq!(
            super::unknown_key_message("domain", "adr", &f),
            "unknown key 'domain' for adr; to keep it, declare it in spec.toml: [types.adr] fields = [\"verify\", \"domain\"], or for every type: [check] extra_keys = [\"domain\"]"
        );
    }
}