specfold-core 0.3.0

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! L7: a document that pins a remote document by content (`pins: {<remote>:<id>: sha256:…}`)
//! is told when that document changes, using the same body hash as a translation's source_rev.

use super::support::key_line;
use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::remotes::{self, Remote};
use crate::translation;

pub struct PinsHold;

impl Rule for PinsHold {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "L7",
            title: "pinned remote documents are unchanged",
            explain: "`pins` maps a remote document (`<remote>:<id>`, see [remotes]) to the body revision it was read at, `sha256:<12 hex>`, the same hash as a translation's source_rev. When the remote directory is present and the body has changed, L7 warns: read the change, update this document if it depends on it, then record the new revision with `spec set <id> --pin <remote>:<id>`. A pin that names no document there is an error; a key that is not a remote reference, or a value that is not sha256:<12 hex>, is a warning. An absent remote directory is skipped.",
            on_commit: false,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        let cfg = &ctx.corpus.cfg;
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            if !ctx.scope.covers(i) {
                continue;
            }
            let Some(pins) = d.fm().and_then(|m| m.get("pins")) else { continue };
            let line = key_line(ctx, &d.path, "pins");
            let Some(pins) = pins.as_object() else {
                out.push(Finding::warn("L7", &d.path, line, "pins is not a map of <remote>:<id> to sha256:<12 hex>"));
                continue;
            };
            for (target, rev) in pins {
                let rev = rev.as_str().unwrap_or_default();
                let fix = format!("spec set {} --pin {target}", d.id);
                if translation::rev_problem(rev).is_some() {
                    let msg = format!(
                        "pin of {target}: pin value '{rev}' is not sha256:<12 hex>; pin it again or remove it (spec set {} --unpin {target})",
                        d.id
                    );
                    out.push(Finding::warn("L7", &d.path, line, msg).fix_cmd(fix));
                    continue;
                }
                match remotes::resolve(cfg, target) {
                    Remote::Local => out.push(Finding::warn(
                        "L7",
                        &d.path,
                        line,
                        format!("pins names {target}, which is not in a configured remote; pins are for [remotes]"),
                    )),
                    Remote::Absent { .. } => {}
                    Remote::Found { .. } => {
                        if let Some((rid, now)) = remotes::rev(cfg, target)
                            && now != rev
                        {
                            out.push(
                                Finding::warn(
                                    "L7",
                                    &d.path,
                                    line,
                                    format!("{rid} changed since the pinned {rev}: review it, then pin again"),
                                )
                                .fix_cmd(fix),
                            );
                        }
                    }
                    Remote::Missing { name, id } => out.push(Finding::error(
                        "L7",
                        &d.path,
                        line,
                        format!("pin names no document in the remote {name} ({id})"),
                    )),
                    Remote::MissingAnchor { name, id, anchor } => out.push(Finding::error(
                        "L7",
                        &d.path,
                        line,
                        format!("pin {name}:{id} has no anchor #{anchor}; pin the document"),
                    )),
                    Remote::Ambiguous { name, ids } => out.push(Finding::error(
                        "L7",
                        &d.path,
                        line,
                        format!("pin is ambiguous in the remote {name}: {}", ids.join(", ")),
                    )),
                }
            }
        }
    }
}