forkctl 0.0.1

Verify and rebase reusable StGit downstream patch stacks
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Component, Path};

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
    pub schema: u32,
    pub upstream: Upstream,
    pub bases: Bases,
    pub patches: Vec<Patch>,
    #[serde(default)]
    pub allow: Allow,
    #[serde(default)]
    pub required: Vec<RequiredText>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Upstream {
    pub remote: String,
    pub url: String,
    #[serde(rename = "ref")]
    pub git_ref: String,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Bases {
    pub canonical: String,
    pub stack: String,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Patch {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub export: Option<String>,
}

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Allow {
    pub base: Vec<String>,
    pub tooling: Vec<String>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RequiredText {
    pub path: String,
    pub contains: String,
}

impl Manifest {
    pub fn validate(&self, repo: &Path, manifest_path: &Path) -> Result<()> {
        ensure!(
            self.schema == 1,
            "unsupported manifest schema: {}",
            self.schema
        );
        ensure!(
            !self.upstream.remote.is_empty()
                && !self.upstream.url.is_empty()
                && !self.upstream.git_ref.is_empty(),
            "upstream remote, url, and ref are required"
        );
        ensure!(!self.patches.is_empty(), "at least one patch is required");
        for (label, value) in [
            ("canonical base", self.bases.canonical.as_str()),
            ("stack base", self.bases.stack.as_str()),
        ] {
            ensure!(
                value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()),
                "{label} must be a full 40-character commit SHA"
            );
        }

        let mut names = HashSet::new();
        let mut seen_tooling = false;
        for patch in &self.patches {
            ensure!(
                !patch.name.is_empty() && !patch.name.chars().any(char::is_whitespace),
                "invalid patch name: {:?}",
                patch.name
            );
            ensure!(
                names.insert(&patch.name),
                "duplicate patch name: {}",
                patch.name
            );
            match &patch.export {
                Some(path) => {
                    ensure!(
                        !seen_tooling,
                        "exported patch {} appears after a tooling-only patch",
                        patch.name
                    );
                    validate_repo_path(repo, path)?;
                }
                None => seen_tooling = true,
            }
        }
        self.source_top()?;
        for pattern in self.allow.base.iter().chain(&self.allow.tooling) {
            ensure!(
                !pattern.is_empty() && !pattern.starts_with('/') && !pattern.contains(".."),
                "invalid allow pattern: {pattern}"
            );
        }
        for required in &self.required {
            validate_repo_path(repo, &required.path)?;
            ensure!(
                !required.contains.is_empty(),
                "empty required text for {}",
                required.path
            );
        }
        ensure!(
            manifest_path.starts_with(repo),
            "manifest must live inside the repository"
        );
        Ok(())
    }

    pub fn patch_names(&self) -> Vec<String> {
        self.patches
            .iter()
            .map(|patch| patch.name.clone())
            .collect()
    }

    pub fn exported_patches(&self) -> impl Iterator<Item = &Patch> {
        self.patches.iter().filter(|patch| patch.export.is_some())
    }

    pub fn source_top(&self) -> Result<&Patch> {
        self.exported_patches()
            .last()
            .context("at least one exported source patch is required")
    }
}

fn validate_repo_path(repo: &Path, value: &str) -> Result<()> {
    let path = Path::new(value);
    ensure!(
        !path.is_absolute()
            && path
                .components()
                .all(|component| matches!(component, Component::Normal(_))),
        "path must be repository-relative: {value}"
    );
    ensure!(
        repo.join(path).starts_with(repo),
        "path escapes repository: {value}"
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn derives_source_top_from_last_exported_patch() {
        let manifest = Manifest {
            schema: 1,
            upstream: Upstream {
                remote: "upstream".into(),
                url: "https://example.com/upstream.git".into(),
                git_ref: "upstream/main".into(),
            },
            bases: Bases {
                canonical: "0".repeat(40),
                stack: "0".repeat(40),
            },
            patches: vec![
                Patch {
                    name: "source".into(),
                    export: Some("patches/0001-source.patch".into()),
                },
                Patch {
                    name: "tooling".into(),
                    export: None,
                },
            ],
            allow: Allow::default(),
            required: Vec::new(),
        };
        assert_eq!(manifest.source_top().unwrap().name, "source");
    }
}