version.rs

6.0 kB · rust · 201 lines

1use crate::model::{Manifest, Result};2use serde_json::Value;3use std::collections::{BTreeMap, BTreeSet};4use std::path::Path;5use std::process::Command;67const SOURCE: &str = "pkgs/mrlyrs/Cargo.toml";89const STAMPS: &[(&str, &str)] = &[10    ("pkgs/mrlypy/Cargo.toml", "version = \""),11    ("pkgs/mrlyjs/package.json", "\"version\": \""),12];1314pub type Semver = (u64, u64, u64);1516pub struct Diff {17    pub added: usize,18    pub removed: Vec<String>,19    pub changed: Vec<String>,20}2122// STAMP2324pub fn read(root: &Path) -> Result<String> {25    let text = load(&root.join(SOURCE))?;26    let (start, end) =27        find(&text, "version = \"").ok_or_else(|| format!("no version in {SOURCE}"))?;28    Ok(text[start..end].to_string())29}3031pub fn write(manifest: &Manifest, root: &Path) -> Result<()> {32    for (file, key) in STAMPS {33        let path = root.join(file);34        let text = load(&path)?;35        let stamped = stamp(&text, key, &manifest.version).map_err(|e| format!("{file}: {e}"))?;36        if stamped != text {37            std::fs::write(&path, stamped).map_err(|e| format!("{file}: {e}"))?;38        }39    }40    Ok(())41}4243pub fn stamp(text: &str, key: &str, version: &str) -> Result<String> {44    let (start, end) = find(text, key).ok_or_else(|| format!("no line starting {key}"))?;45    Ok(format!("{}{version}{}", &text[..start], &text[end..]))46}4748fn find(text: &str, key: &str) -> Option<(usize, usize)> {49    let mut at = 0;50    for line in text.split_inclusive('\n') {51        let indent = line.len() - line.trim_start().len();52        if line[indent..].starts_with(key) {53            let start = at + indent + key.len();54            return Some((start, start + text[start..].find('"')?));55        }56        at += line.len();57    }58    None59}6061fn load(path: &Path) -> Result<String> {62    std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))63}6465// BUMP6667pub fn bump(root: &Path) -> Result<()> {68    let new = json(&load(&root.join("bridge/manifest.json"))?)?;69    let now = new["version"]70        .as_str()71        .ok_or("the manifest has no version")?;72    let current = semver(now).ok_or_else(|| format!("{now} is not X.Y.Z"))?;73    let tags = git(root, &["tag", "-l", "v*"])?;74    let previous = tags75        .lines()76        .filter_map(|tag| Some((semver(tag.strip_prefix('v')?)?, tag)))77        .filter(|(version, _)| *version != current)78        .max();79    let Some((before, tag)) = previous else {80        println!("   no other v* tag; {now} is the first release");81        return Ok(());82    };83    let old = json(&git(84        root,85        &["show", &format!("{tag}:bridge/manifest.json")],86    )?)?;87    let diff = diff(&old, &new);88    for path in &diff.removed {89        println!("   removed {path}");90    }91    for path in &diff.changed {92        println!("   changed {path}");93    }94    println!(95        "   {tag} to {now}: {} added, {} removed, {} changed",96        diff.added,97        diff.removed.len(),98        diff.changed.len()99    );100    let breaking = !diff.removed.is_empty() || !diff.changed.is_empty();101    let floor = need(before, breaking);102    if current < floor {103        let what = if breaking {104            "a breaking change"105        } else {106            "a release"107        };108        return Err(format!(109            "{now} is too small a step over {tag}: {what} needs {} at least",110            show(floor)111        ));112    }113    Ok(())114}115116pub fn diff(old: &Value, new: &Value) -> Diff {117    let (old_all, old_ok) = entries(old);118    let (new_all, new_ok) = entries(new);119    let removed: BTreeSet<&String> = old_all120        .keys()121        .filter(|path| !new_all.contains_key(*path))122        .chain(old_ok.iter().filter(|path| !new_ok.contains(*path)))123        .collect();124    let changed = old_all125        .iter()126        .filter(|(path, sigs)| {127            !removed.contains(path) && new_all.get(*path).is_some_and(|new| !same(sigs, new))128        })129        .map(|(path, _)| path.clone())130        .collect();131    Diff {132        added: new_all133            .keys()134            .filter(|path| !old_all.contains_key(*path))135            .count(),136        removed: removed.into_iter().cloned().collect(),137        changed,138    }139}140141fn entries(manifest: &Value) -> (BTreeMap<String, Vec<Value>>, BTreeSet<String>) {142    let mut all: BTreeMap<String, Vec<Value>> = BTreeMap::new();143    let mut ok = BTreeSet::new();144    for f in manifest["functions"].as_array().into_iter().flatten() {145        let status = f["cross"]["status"].as_str().unwrap_or_default();146        let path = f["path"].as_str().unwrap_or_default().to_string();147        if status == "private" {148            continue;149        }150        if status == "ok" {151            ok.insert(path.clone());152        }153        let sig = ["self_kind", "params", "ret", "dims"].map(|key| f[key].clone());154        all.entry(path).or_default().push(Value::from(sig.to_vec()));155    }156    (all, ok)157}158159fn same(a: &[Value], b: &[Value]) -> bool {160    a.len() == b.len() && a.iter().all(|sig| b.contains(sig))161}162163pub fn need(before: Semver, breaking: bool) -> Semver {164    let (major, minor, patch) = before;165    match (breaking, major) {166        (false, _) => (major, minor, patch + 1),167        (true, 0) => (0, minor + 1, 0),168        (true, _) => (major + 1, 0, 0),169    }170}171172pub fn semver(text: &str) -> Option<Semver> {173    let mut parts = text.split('.').map(|part| part.parse::<u64>().ok());174    let version = (parts.next()??, parts.next()??, parts.next()??);175    parts.next().is_none().then_some(version)176}177178fn show((major, minor, patch): Semver) -> String {179    format!("{major}.{minor}.{patch}")180}181182fn json(text: &str) -> Result<Value> {183    serde_json::from_str(text).map_err(|e| e.to_string())184}185186fn git(root: &Path, args: &[&str]) -> Result<String> {187    let out = Command::new("git")188        .arg("-C")189        .arg(root)190        .args(args)191        .output()192        .map_err(|e| format!("git: {e}"))?;193    if !out.status.success() {194        return Err(format!(195            "git {}: {}",196            args.join(" "),197            String::from_utf8_lossy(&out.stderr).trim()198        ));199    }200    String::from_utf8(out.stdout).map_err(|e| e.to_string())201}