forkctl 0.0.3

Control audited StGit downstream patch stacks
use crate::manifest::{Manifest, PatchKind};

pub fn render(manifest: &Manifest) -> String {
    let mut output = format!(
        "# Downstream Patches\n\n> Generated by `forkctl`; edit the manifest, not this file.\n\nBase: `{}` (`{}`)\n\n| Order | Patch | Kind | Purpose | Upstream status | Drop condition |\n|--:|:--|:--|:--|:--|:--|\n",
        escape(&manifest.base.label),
        manifest.base.stack
    );
    for (index, patch) in manifest.patches.iter().enumerate() {
        let kind = match patch.kind {
            PatchKind::Source => "source",
            PatchKind::Tooling => "tooling",
        };
        output.push_str("| ");
        output.push_str(&(index + 1).to_string());
        output.push_str(" | `");
        output.push_str(&escape(&patch.name));
        output.push_str("` | ");
        output.push_str(kind);
        output.push_str(" | ");
        output.push_str(&escape(&patch.purpose));
        output.push_str(" | ");
        output.push_str(&escape(&patch.upstream_status));
        output.push_str(" | ");
        output.push_str(&escape(&patch.drop_when));
        output.push_str(" |\n");
    }
    output
}

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);
        assert_eq!(first, render(&manifest));
        assert!(first.contains("Own A \\| B."));
        assert!(first.ends_with('\n'));
    }
}