rlls 0.0.36

Cut a version, tag it, and publish a GitHub Release with raw git notes
Documentation
use anyhow::{anyhow, Result};
use fs_err as fs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lock {
    pub version: u32,
    pub repo_id: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub packages: Vec<LockedPackage>,
    pub last_txn: Option<Txn>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedPackage {
    pub name: String,
    pub path: String,
    pub last_release: Option<PackageRelease>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageRelease {
    pub tag: String,
    pub version: String,
    pub sha: String,
    pub trailers: Trailers,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Trailers {
    pub id: Option<String>,
    pub pkg: Option<String>,
    pub ver: Option<String>,
    pub sha: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Txn {
    pub id: String,
    pub started_at: chrono::DateTime<chrono::Utc>,
    pub packages: Vec<TxnPkg>,
    pub completed: bool,
    pub rolled_back: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxnPkg {
    pub name: String,
    pub from: Option<String>,
    pub to: Option<String>,
    pub created_tags: Vec<String>,
}

pub fn path(root: &Path) -> PathBuf {
    root.join("rlls.lock")
}

pub fn exists(root: &Path) -> bool {
    path(root).exists()
}

pub fn load(root: &Path) -> Result<Lock> {
    let p = path(root);
    let s = fs::read_to_string(&p)?;
    let l: Lock = toml::from_str(&s)?;
    if l.version == 0 {
        return Err(anyhow!(
            "invalid rlls.lock: version must be > 0"
        ));
    }
    if l.repo_id.trim().is_empty() {
        return Err(anyhow!("invalid rlls.lock: repo_id missing"));
    }
    Ok(l)
}

pub fn save(root: &Path, lock: &Lock, force: bool) -> Result<()> {
    let p = path(root);
    if p.exists() && !force {
        return Err(anyhow!(
            "rlls.lock already exists. rerun with --force to overwrite"
        ));
    }
    let s = toml::to_string_pretty(lock)?;
    fs::write(p, s)?;
    Ok(())
}

pub fn rebuild(root: &Path, force: bool) -> Result<()> {
    rebuild_with(root, force, None, &[])
}

pub fn rebuild_with(
    root: &Path,
    force: bool,
    _from_file: Option<&Path>,
    baselines: &[String],
) -> Result<()> {
    let cfg = crate::config::load("rlls.toml")
        .or_else(|_| crate::config::load(".rllsrc"))?;

    let mut baseline_map: BTreeMap<String, String> = BTreeMap::new();
    for b in baselines {
        if let Some((name, r)) = b.split_once(':') {
            baseline_map.insert(name.to_string(), r.to_string());
        }
    }

    let root_fs = std::env::current_dir()?;
    let discovered = crate::project::discover_packages(&root_fs)?;
    let mut locked_packages: Vec<LockedPackage> = vec![];
    for name in &cfg.packages {
        if let Some(pkg) = discovered.iter().find(|p| p.name == *name)
        {
            let baseline =
                select_baseline(name, &cfg, &baseline_map)?;
            if baseline.is_none() {
                return Err(anyhow!(
                    "package '{}' has no usable tag; pass --baseline {}:REF or add migration.legacy_tag_patterns",
                    name, name
                ));
            }
            locked_packages.push(LockedPackage {
                name: name.clone(),
                path: path_rel(&root_fs, &pkg.path),
                last_release: baseline,
            });
        } else {
            return Err(anyhow!(
                "configured package '{}' not found in repo",
                name
            ));
        }
    }

    let repo_id = crate::git::rev_parse("HEAD")
        .ok_or_else(|| anyhow!("not a git repo"))?;
    let lock = Lock {
        version: 1,
        repo_id,
        created_at: chrono::Utc::now(),
        packages: locked_packages,
        last_txn: None,
    };
    save(root, &lock, force)
}

fn path_rel(root: &Path, p: &Path) -> String {
    match p.strip_prefix(root) {
        Ok(rel) => rel.to_string_lossy().to_string(),
        Err(_) => p.to_string_lossy().to_string(),
    }
}

fn select_baseline(
    name: &str,
    cfg: &crate::config::Config,
    baseline_map: &BTreeMap<String, String>,
) -> Result<Option<PackageRelease>> {
    if let Some(r) = baseline_map.get(name) {
        return materialize_ref(name, r);
    }
    let tag = newest_reachable_tag_for(name, cfg)?;
    if let Some(tag) = tag {
        let sha = tag_commit_sha(&tag)
            .ok_or_else(|| anyhow!("cannot resolve tag {}", tag))?;
        let ver = tag
            .split('@')
            .next_back()
            .unwrap_or("")
            .trim_start_matches('v')
            .to_string();
        return Ok(Some(PackageRelease {
            tag,
            version: ver,
            sha,
            trailers: Default::default(),
        }));
    }
    Ok(None)
}

fn materialize_ref(
    name: &str,
    r: &str,
) -> Result<Option<PackageRelease>> {
    let (tag, sha, ver): (Option<String>, String, String);
    if r.starts_with('v') {
        let t = format!("{}@{}", name, r);
        let s = tag_commit_sha(&t)
            .ok_or_else(|| anyhow!("unknown tag {}", t))?;
        tag = Some(t);
        sha = s;
        ver = r.trim_start_matches('v').to_string();
    } else if r.len() >= 7 && r.chars().all(|c| c.is_ascii_hexdigit())
    {
        tag = None;
        sha = r.to_string();
        ver = "0.0.0".to_string();
    } else if let Some(stripped) =
        r.strip_prefix(&format!("{}:", name))
    {
        return materialize_ref(name, stripped);
    } else {
        let t = format!("{}@{}", name, r);
        let s = tag_commit_sha(&t)
            .ok_or_else(|| anyhow!("unknown tag {}", t))?;
        tag = Some(t);
        ver = r.trim_start_matches('v').to_string();
        sha = s;
    }
    Ok(Some(PackageRelease {
        tag: tag.unwrap_or_default(),
        version: ver,
        sha,
        trailers: Default::default(),
    }))
}

fn newest_reachable_tag_for(
    name: &str,
    cfg: &crate::config::Config,
) -> Result<Option<String>> {
    let pattern = format!("{}@v*", name);
    let mut candidates = crate::git::list_tags(&pattern)?;
    if let Some(h) = cfg.migration.get(name) {
        if let Some(pats) = &h.legacy_tag_patterns {
            for p in pats {
                let mut v = crate::git::list_tags(p)?;
                candidates.append(&mut v);
            }
        }
    }
    let head = crate::git::head_commit()
        .ok_or_else(|| anyhow!("no HEAD"))?;
    let mut reachable: Vec<String> = vec![];
    for t in candidates {
        if let Some(tag_sha) = tag_commit_sha(&t) {
            if crate::git::is_ancestor(&tag_sha, &head) {
                reachable.push(t);
            }
        }
    }
    if reachable.is_empty() {
        return Ok(None);
    }
    let mut by_ver: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for t in &reachable {
        let v = tag_version_core(t);
        by_ver.entry(v).or_default().push(t.clone());
    }
    let mut max_key = None;
    for (ver, tags) in &by_ver {
        if max_key.as_ref().map(|s: &String| ver > s).unwrap_or(true)
        {
            max_key = Some(ver.clone());
        }
        if tags.len() > 1 {
            return Err(anyhow!("legacy patterns collide at the same version {}; disambiguate migration patterns", ver));
        }
    }
    reachable.sort_by_key(|a| semver_key(a));
    Ok(reachable.pop())
}

fn semver_key(tag: &str) -> (u64, u64, u64, String) {
    let v = tag
        .split('@')
        .next_back()
        .unwrap_or("")
        .trim_start_matches('v');
    let mut parts = v.split('.');
    let maj = parts
        .next()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    let min = parts
        .next()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    let pat = parts
        .next()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    (maj, min, pat, v.to_string())
}

fn tag_version_core(tag: &str) -> String {
    tag.split('@')
        .next_back()
        .unwrap_or("")
        .trim_start_matches('v')
        .to_string()
}

fn tag_commit_sha(tag: &str) -> Option<String> {
    let escaped = tag.replace('/', "_");
    crate::git::tag_commit(&escaped)
}

pub fn verify(root: &Path) -> Result<()> {
    let l = load(root)?;
    let head = crate::git::head_commit()
        .ok_or_else(|| anyhow!("no HEAD"))?;
    for p in &l.packages {
        if let Some(rel) = &p.last_release {
            if !crate::git::is_ancestor(&rel.sha, &head) {
                return Err(anyhow!(
                    "package '{}' baseline {} is not reachable from HEAD",
                    p.name, rel.tag
                ));
            }
        }
    }
    Ok(())
}

pub fn synthesize_tags(
    _root: &Path,
    _baselines: &[String],
) -> Result<()> {
    let mut any = false;
    for b in _baselines {
        if let Some((name, r)) = b.split_once(':') {
            let tag = format!("{}@{}", name, r);
            let msg =
                format!("release {}", r.trim_start_matches('v'));
            let sha = tag_commit_sha(&tag)
                .or_else(|| crate::git::rev_parse(r));
            let target =
                if let Some(s) = sha { s } else { r.to_string() };
            crate::git::create_annotated_tag_at(
                &tag,
                Some(&msg),
                &target,
            )?;
            any = true;
        }
    }
    if !any {
        return Err(anyhow!(
            "no baselines provided; use --baseline NAME:REF"
        ));
    }
    Ok(())
}