specfold-core 0.7.1

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! S2 and S3: a document's id matches its path, and it sits where its type says.

use crate::check::context::{Ctx, key_line};
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::model::Tier;

pub struct IdMatchesPath;
pub struct Placement;

impl Rule for IdMatchesPath {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "S2",
            title: "id matches the path",
            explain: "A document's id is computed from where it lives: <type>:<owner>/<key>. The id written in frontmatter must equal it, so a copied or moved file cannot keep a stale id. Move documents with `spec mv`, which rewrites both.",
            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;
            }
            let Some(declared) = d.str_field("id") else { continue };
            let expected = d.id.to_string();
            if declared != expected {
                let line = key_line(ctx, &d.path, "id");
                out.push(
                    Finding::error(
                        "S2",
                        &d.path,
                        line,
                        format!("id is '{declared}' but the path makes it '{expected}'"),
                    )
                    .fix_replace(format!("id: {declared}"), format!("id: {expected}")),
                );
            }
        }
    }
}

impl Rule for Placement {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "S3",
            title: "every file in a spec directory belongs to a document",
            explain: "A spec directory holds only type directories, and in them only `<key>-<slug>.<type>.md` files or document bundles. A type with its own directory (`[types.<name>] dir`) holds the same files there, and its documents still in the spec directory are reported with the move that takes them over (`spec mv <id> --write`). A spec.toml that drops or moves a `dir` while documents remain there is refused the same way until each is moved (the old directory is known from spec.toml at HEAD). Files matching `[types.<name>] ignore` (globs relative to the type's directory, such as README.md) are left alone. Anything else (old layouts, notes without a type, loose attachments) is reported. Create documents with `spec new`, attach files with `spec attach`, bring old layouts in with `spec import`.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        for s in &ctx.corpus.strays {
            if ctx.scope.covers_path(&s.path) {
                out.push(Finding::error("S3", &s.path, 1, s.reason.clone()));
            }
        }
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            if d.tier != Tier::Committed {
                continue;
            }
            let root = ctx.corpus.spec_root_of(i);
            // Whatever the range: the change at fault is the working spec.toml, not these files.
            if let Some(r) = root.filter(|r| r.former) {
                out.push(
                    Finding::error(
                        "S3",
                        &d.path,
                        1,
                        format!("documents remain in former type dir {}; move them with spec mv first", r.dir),
                    )
                    .fix_cmd(format!("spec mv {} --write", crate::suggest::shell::word(&d.id))),
                );
                continue;
            }
            if !ctx.scope.covers(i) {
                continue;
            }
            let Some(dir) = ctx.corpus.cfg.registry.get(&d.id.ty).and_then(|t| t.dir.as_deref()) else { continue };
            if root.is_some_and(|r| r.ty.is_none()) {
                out.push(
                    Finding::error(
                        "S3",
                        &d.path,
                        1,
                        format!(
                            "{} documents live in {dir}/ ([types.{}] dir), not in the spec directory",
                            d.id.ty, d.id.ty
                        ),
                    )
                    .fix_cmd(format!("spec mv {} --write", crate::suggest::shell::word(&d.id))),
                );
            }
        }
    }
}