verctl 0.0.3

Stack-agnostic version PRs from Changesets-format fragments
Documentation
//! Rewrite collocated tool pins when a package version changes.

use crate::config::{Config, Pin};
use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Component, Path, PathBuf};
use toml_edit::{DocumentMut, Item, value};

/// Current declared versions of every configured package.
pub fn current_versions(root: &Path, config: &Config) -> Result<Vec<(String, String)>> {
    let mut versions = Vec::new();
    for spec in &config.packages {
        let path = root.join(&spec.path);
        let driver = spec.resolve(config, root)?;
        let raw = fs::read_to_string(&path).with_context(|| path.display().to_string())?;
        versions.push((spec.name.clone(), driver.read(&raw)?.trim().to_owned()));
    }
    Ok(versions)
}

pub fn write(root: &Path, pins: &[Pin], versions: &[(String, String)]) -> Result<Vec<PathBuf>> {
    apply(root, pins, versions, true)
}

/// Same checks as `write`, without touching disk.
pub fn plan(root: &Path, pins: &[Pin], versions: &[(String, String)]) -> Result<Vec<PathBuf>> {
    apply(root, pins, versions, false)
}

fn apply(
    root: &Path,
    pins: &[Pin],
    versions: &[(String, String)],
    persist: bool,
) -> Result<Vec<PathBuf>> {
    let mut written = Vec::new();
    for pin in pins {
        let Some((_, version)) = versions.iter().find(|(name, _)| name == &pin.package) else {
            continue;
        };
        ensure_inside(root, &pin.file)?;
        let path = root.join(&pin.file);
        let raw =
            fs::read_to_string(&path).with_context(|| format!("read pin {}", path.display()))?;
        let mut doc: DocumentMut = raw
            .parse()
            .with_context(|| format!("parse pin {}", path.display()))?;
        let tools = doc
            .get_mut("tools")
            .and_then(Item::as_table_like_mut)
            .with_context(|| format!("{} has no [tools]", path.display()))?;
        if tools.get(&pin.tool).is_none() {
            bail!("{} has no tools.{tool}", path.display(), tool = pin.tool);
        }
        tools.insert(&pin.tool, value(version.as_str()));
        let body = rewrite_own_refs(&doc.to_string(), &pin.tool, version);
        if persist {
            fs::write(&path, body).with_context(|| format!("write pin {}", path.display()))?;
        }
        written.push(path);
    }
    Ok(written)
}

fn ensure_inside(root: &Path, file: &Path) -> Result<()> {
    if file.is_absolute()
        || file
            .components()
            .any(|part| matches!(part, Component::ParentDir))
    {
        bail!("pin file must stay inside the repo: {}", file.display());
    }
    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
    let path = root.join(file);
    if let Ok(meta) = fs::symlink_metadata(&path)
        && meta.file_type().is_symlink()
    {
        bail!("pin file must not be a symlink: {}", path.display());
    }
    let resolved = path.canonicalize().unwrap_or(path);
    if !resolved.starts_with(&root) {
        bail!("pin file must stay inside the repo: {}", file.display());
    }
    Ok(())
}

fn rewrite_own_refs(body: &str, tool: &str, version: &str) -> String {
    let name = tool.rsplit(['/', ':']).next().unwrap_or(tool);
    let marker_git = format!("/{name}.git");
    let marker_path = format!("/{name}//");
    let mut out = String::new();
    let mut rest = body;
    while let Some(at) = rest.find("?ref=v") {
        let after = &rest[at + 6..];
        let end = after
            .find(|ch: char| !ch.is_ascii_digit() && ch != '.')
            .unwrap_or(after.len());
        let prefix = &rest[..at];
        let seg_start = prefix
            .rfind(['"', '\'', ',', '[', ' ', '\t', '\n'])
            .map_or(0, |i| i + 1);
        let segment = &prefix[seg_start..];
        out.push_str(prefix);
        if end > 0 && (segment.contains(&marker_git) || segment.contains(&marker_path)) {
            out.push_str("?ref=v");
            out.push_str(version);
        } else {
            out.push_str(&rest[at..at + 6 + end]);
        }
        rest = &after[end..];
    }
    out.push_str(rest);
    out
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use indoc::indoc;
    use tempfile::TempDir;

    fn versions(to: &str) -> Vec<(String, String)> {
        vec![("verctl".into(), to.into())]
    }

    #[test]
    fn updates_the_named_tool_and_keeps_siblings() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.release.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                leftover = "1"
                "github:victor-software-house/verctl" = "0.0.1"
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.release.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "verctl".into(),
        }];
        write(root.path(), &pins, &versions("0.0.2")).unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("0.0.2"), "{body}");
        assert!(body.contains("leftover"), "{body}");
        assert!(!body.contains("\"0.0.1\""), "{body}");
    }

    #[test]
    fn skips_when_the_package_is_not_in_the_plan() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.release.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                "github:victor-software-house/verctl" = "0.0.1"
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.release.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "other".into(),
        }];
        assert_eq!(
            write(root.path(), &pins, &versions("0.0.2")).unwrap(),
            Vec::<PathBuf>::new()
        );
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("0.0.1"), "{body}");
    }

    #[test]
    fn rejects_parent_dir_pins() {
        let err = ensure_inside(Path::new("/tmp"), Path::new("../secret")).unwrap_err();
        assert!(format!("{err:#}").contains("inside the repo"), "{err:#}");
    }

    #[test]
    fn rewrites_task_include_ref() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                "github:victor-software-house/verctl" = "0.0.1"
                [task_config]
                includes = ["git::https://github.com/victor-software-house/verctl.git//tasks/ver?ref=v0.0.1"]
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "verctl".into(),
        }];
        write(root.path(), &pins, &versions("0.0.2")).unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("?ref=v0.0.2"), "{body}");
        assert!(!body.contains("verctl.git//tasks/ver?ref=v0.0.1"), "{body}");
    }

    #[test]
    fn leaves_other_repos_refs_alone() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                "github:victor-software-house/verctl" = "0.0.1"
                [task_config]
                includes = ["git::https://example.com/qctl.git//tasks/q?ref=v0.0.1"]
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "verctl".into(),
        }];
        write(root.path(), &pins, &versions("0.0.2")).unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("qctl.git//tasks/q?ref=v0.0.1"), "{body}");
    }

    #[test]
    fn rewrites_each_own_include_on_one_line() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                "github:victor-software-house/verctl" = "0.0.1"
                [task_config]
                includes = ["git::https://example.com/verctl.git//a?ref=v0.0.1", "git::https://example.com/qctl.git//b?ref=v0.0.1", "git::https://example.com/verctl.git//c?ref=v9.9.9"]
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "verctl".into(),
        }];
        write(root.path(), &pins, &versions("0.0.2")).unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("verctl.git//a?ref=v0.0.2"), "{body}");
        assert!(body.contains("verctl.git//c?ref=v0.0.2"), "{body}");
        assert!(body.contains("qctl.git//b?ref=v0.0.1"), "{body}");
    }

    #[test]
    fn rewrites_any_semver_ref_on_this_package_include() {
        let root = TempDir::new().unwrap();
        let path = root.path().join("mise.toml");
        fs::write(
            &path,
            indoc! {r#"
                [tools]
                "github:victor-software-house/verctl" = "0.0.1"
                [task_config]
                includes = ["git::https://example.com/verctl.git//tasks/ver?ref=v1.2.3"]
            "#},
        )
        .unwrap();
        let pins = [Pin {
            file: PathBuf::from("mise.toml"),
            tool: "github:victor-software-house/verctl".into(),
            package: "verctl".into(),
        }];
        write(root.path(), &pins, &versions("0.0.2")).unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("verctl.git//tasks/ver?ref=v0.0.2"), "{body}");
        assert!(!body.contains("?ref=v1.2.3"), "{body}");
    }

    #[test]
    fn rejects_symlink_pins() {
        let root = TempDir::new().unwrap();
        let target = root.path().join("outside.txt");
        fs::write(&target, "x\n").unwrap();
        let link = root.path().join("mise.release.toml");
        std::os::unix::fs::symlink(&target, &link).unwrap();
        let err = ensure_inside(root.path(), Path::new("mise.release.toml")).unwrap_err();
        assert!(format!("{err:#}").contains("symlink"), "{err:#}");
    }
}