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}