forkctl 0.0.4

Control audited StGit downstream patch stacks
use crate::manifest::{Manifest, PatchKind};
use anyhow::{Context, Result};
use askama::Template;

#[derive(Template)]
#[template(path = "patches.md", escape = "none")]
struct LedgerTemplate<'a> {
    base_label: String,
    base_sha: &'a str,
    patches: Vec<PatchRow>,
}

struct PatchRow {
    name: String,
    kind: &'static str,
    purpose: String,
    upstream_status: String,
    drop_when: String,
}

pub fn render(manifest: &Manifest) -> Result<String> {
    LedgerTemplate {
        base_label: escape(&manifest.base.label),
        base_sha: &manifest.base.stack,
        patches: manifest
            .patches
            .iter()
            .map(|patch| PatchRow {
                name: escape(&patch.name),
                kind: match patch.kind {
                    PatchKind::Source => "source",
                    PatchKind::Tooling => "tooling",
                },
                purpose: escape(&patch.purpose),
                upstream_status: escape(&patch.upstream_status),
                drop_when: escape(&patch.drop_when),
            })
            .collect(),
    }
    .render()
    .context("render PATCHES.md")
}

fn escape(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('|', "\\|")
        .replace('`', "\\`")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::manifest::{Allow, Base, Downstream, Patch, Upstream};

    #[test]
    fn renders_stable_escaped_table() {
        let manifest = Manifest {
            schema: 1,
            downstream: Downstream {
                remote: "origin".into(),
                branch: "main".into(),
                backup_tag_prefix: "vsh/pre-sync".into(),
            },
            upstream: Upstream {
                remote: "upstream".into(),
                url: "https://example.com/upstream.git".into(),
                fetch_ref: "refs/heads/main".into(),
            },
            base: Base {
                label: "refs/tags/v1".into(),
                canonical: "0".repeat(40),
                stack: "0".repeat(40),
            },
            ledger: "PATCHES.md".into(),
            bookkeeping_patch: "fork-tooling".into(),
            patches: vec![Patch {
                name: "fork-tooling".into(),
                kind: PatchKind::Tooling,
                purpose: "Own A | B.".into(),
                upstream_status: "downstream-only".into(),
                drop_when: "The fork is retired.".into(),
                paths: vec!["fork.json".into(), "PATCHES.md".into()],
                export: None,
            }],
            allow: Allow::default(),
            required: Vec::new(),
        };
        let first = render(&manifest).unwrap();
        assert_eq!(first, render(&manifest).unwrap());
        assert!(first.contains("Own A \\| B."));
        assert!(first.ends_with('\n'));
        assert_eq!(first.matches("| 1 | `fork-tooling`").count(), 1);
    }
}