use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct SemVer(u32, u32, u32);
impl SemVer {
fn parse(s: &str) -> Option<Self> {
let mut parts = s.splitn(3, '.').map(|p| {
p.split(|c: char| !c.is_ascii_digit())
.next()
.and_then(|n| n.parse::<u32>().ok())
});
Some(Self(parts.next()??, parts.next()??, parts.next()??))
}
}
impl std::fmt::Display for SemVer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.0, self.1, self.2)
}
}
struct Migration {
since: SemVer,
description: &'static str,
run: fn() -> Result<()>,
}
static MIGRATIONS: &[Migration] = &[
];
struct WhatsNew {
version: SemVer,
items: &'static [&'static str],
}
static WHATS_NEW: &[WhatsNew] = &[WhatsNew {
version: SemVer(0, 9, 0),
items: &[
"New `upgrade` command with version stamp and migration framework",
"Shell completions for `upgrade` subcommand",
],
}];
fn print_whats_new(from: SemVer, current: SemVer) {
let items: Vec<_> = WHATS_NEW
.iter()
.filter(|w| w.version > from && w.version <= current)
.flat_map(|w| w.items.iter())
.collect();
if items.is_empty() {
return;
}
println!("What's new in v{current}:");
for item in items {
println!(" - {item}");
}
}
pub fn stamp_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("$HOME not set")?;
Ok(PathBuf::from(home)
.join(".axterminator")
.join("version.stamp"))
}
pub fn read_stamp(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s.trim().to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("read stamp {}", path.display())),
}
}
pub fn write_stamp(path: &Path, version: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create stamp directory {}", parent.display()))?;
}
std::fs::write(path, version).with_context(|| format!("write stamp {}", path.display()))
}
#[derive(Debug, Clone, Default)]
pub struct UpgradeOptions {
pub dry_run: bool,
pub quiet: bool,
}
pub fn check_upgrade(opts: &UpgradeOptions) -> Result<UpgradeOutcome> {
let path = stamp_path()?;
let current_str = env!("CARGO_PKG_VERSION");
let current = SemVer::parse(current_str)
.with_context(|| format!("cannot parse CARGO_PKG_VERSION {current_str:?}"))?;
let stamp_raw = read_stamp(&path)?;
let outcome = match stamp_raw.as_deref() {
None => handle_fresh_install(&path, current_str, opts)?,
Some(s) => match SemVer::parse(s) {
None => handle_corrupt_stamp(&path, s, current_str, opts)?,
Some(stamp) => match stamp.cmp(¤t) {
Ordering::Equal => UpgradeOutcome::UpToDate,
Ordering::Less => handle_upgrade(&path, stamp, current, current_str, opts)?,
Ordering::Greater => handle_downgrade(stamp, current, opts),
},
},
};
if !opts.quiet {
print_outcome_summary(&outcome, current_str);
}
Ok(outcome)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpgradeOutcome {
FreshInstall,
UpToDate,
Upgraded { from: String, to: String },
Downgrade { stamp: String, binary: String },
CorruptStamp,
}
fn handle_fresh_install(
path: &Path,
current: &str,
opts: &UpgradeOptions,
) -> Result<UpgradeOutcome> {
if !opts.dry_run {
write_stamp(path, current)?;
}
Ok(UpgradeOutcome::FreshInstall)
}
fn handle_corrupt_stamp(
path: &Path,
bad: &str,
current: &str,
opts: &UpgradeOptions,
) -> Result<UpgradeOutcome> {
if !opts.quiet {
eprintln!(
"axterminator: warning: version stamp contains unrecognised value {bad:?}, resetting"
);
}
if !opts.dry_run {
write_stamp(path, current)?;
}
Ok(UpgradeOutcome::CorruptStamp)
}
fn handle_upgrade(
path: &Path,
from: SemVer,
to: SemVer,
to_str: &str,
opts: &UpgradeOptions,
) -> Result<UpgradeOutcome> {
if !opts.quiet {
print_whats_new(from, to);
}
for migration in MIGRATIONS.iter().filter(|m| m.since > from) {
if opts.dry_run {
if !opts.quiet {
println!(" [dry-run] would run migration: {}", migration.description);
}
} else {
if !opts.quiet {
println!(" Running migration: {}", migration.description);
}
(migration.run)()?;
}
}
if !opts.dry_run {
write_stamp(path, to_str)?;
}
Ok(UpgradeOutcome::Upgraded {
from: from.to_string(),
to: to.to_string(),
})
}
fn handle_downgrade(stamp: SemVer, binary: SemVer, opts: &UpgradeOptions) -> UpgradeOutcome {
if !opts.quiet {
eprintln!(
"axterminator: warning: stamp version v{stamp} is newer than binary v{binary} — \
downgrade detected. Run `axterminator upgrade` to reset the stamp."
);
}
UpgradeOutcome::Downgrade {
stamp: stamp.to_string(),
binary: binary.to_string(),
}
}
fn print_outcome_summary(outcome: &UpgradeOutcome, current: &str) {
match outcome {
UpgradeOutcome::FreshInstall => {
println!("axterminator v{current} — fresh install, stamp created.");
}
UpgradeOutcome::UpToDate
| UpgradeOutcome::Downgrade { .. }
| UpgradeOutcome::CorruptStamp => {}
UpgradeOutcome::Upgraded { from, to } => {
println!("axterminator upgraded v{from} → v{to}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
tempfile::Builder::new()
.prefix("axt-upgrade-")
.tempdir()
.unwrap()
}
#[test]
fn semver_parse_standard_triple() {
assert_eq!(SemVer::parse("1.2.3"), Some(SemVer(1, 2, 3)));
}
#[test]
fn semver_parse_strips_prerelease_suffix() {
assert_eq!(SemVer::parse("0.9.0-alpha"), Some(SemVer(0, 9, 0)));
}
#[test]
fn semver_parse_rejects_garbage() {
assert_eq!(SemVer::parse("notaversion"), None);
assert_eq!(SemVer::parse(""), None);
assert_eq!(SemVer::parse("1.2"), None);
}
#[test]
fn semver_ordering_is_correct() {
assert!(SemVer(1, 0, 0) > SemVer(0, 9, 99));
assert!(SemVer(0, 9, 1) > SemVer(0, 9, 0));
assert_eq!(SemVer(1, 2, 3), SemVer(1, 2, 3));
}
#[test]
fn read_stamp_returns_none_when_missing() {
let dir = temp_dir();
let path = dir.path().join("version.stamp");
assert_eq!(read_stamp(&path).unwrap(), None);
}
#[test]
fn write_then_read_stamp_roundtrips() {
let dir = temp_dir();
let path = dir.path().join("version.stamp");
write_stamp(&path, "1.2.3").unwrap();
assert_eq!(read_stamp(&path).unwrap().as_deref(), Some("1.2.3"));
}
#[test]
fn write_stamp_creates_parent_dirs() {
let dir = temp_dir();
let path = dir.path().join("nested").join("deep").join("version.stamp");
write_stamp(&path, "0.1.0").unwrap();
assert!(path.exists());
}
#[test]
fn write_stamp_strips_trailing_whitespace_on_read() {
let dir = temp_dir();
let path = dir.path().join("version.stamp");
std::fs::write(&path, "0.9.0\n").unwrap();
assert_eq!(read_stamp(&path).unwrap().as_deref(), Some("0.9.0"));
}
fn opts_quiet_dry() -> UpgradeOptions {
UpgradeOptions {
dry_run: true,
quiet: true,
}
}
fn opts_quiet() -> UpgradeOptions {
UpgradeOptions {
dry_run: false,
quiet: true,
}
}
fn check_with_path(stamp: &Path, opts: &UpgradeOptions) -> Result<UpgradeOutcome> {
let current_str = env!("CARGO_PKG_VERSION");
let current = SemVer::parse(current_str).unwrap();
let raw = read_stamp(stamp)?;
match raw.as_deref() {
None => handle_fresh_install(stamp, current_str, opts),
Some(s) => match SemVer::parse(s) {
None => handle_corrupt_stamp(stamp, s, current_str, opts),
Some(v) => match v.cmp(¤t) {
Ordering::Equal => Ok(UpgradeOutcome::UpToDate),
Ordering::Less => handle_upgrade(stamp, v, current, current_str, opts),
Ordering::Greater => Ok(handle_downgrade(v, current, opts)),
},
},
}
}
#[test]
fn fresh_install_writes_stamp_and_returns_fresh() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
assert_eq!(outcome, UpgradeOutcome::FreshInstall);
assert!(stamp.exists());
}
#[test]
fn fresh_install_dry_run_does_not_write_stamp() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
let outcome = check_with_path(&stamp, &opts_quiet_dry()).unwrap();
assert_eq!(outcome, UpgradeOutcome::FreshInstall);
assert!(!stamp.exists());
}
#[test]
fn up_to_date_stamp_is_noop() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
write_stamp(&stamp, env!("CARGO_PKG_VERSION")).unwrap();
let mtime_before = std::fs::metadata(&stamp).unwrap().modified().unwrap();
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
assert_eq!(outcome, UpgradeOutcome::UpToDate);
let mtime_after = std::fs::metadata(&stamp).unwrap().modified().unwrap();
assert_eq!(mtime_before, mtime_after);
}
#[test]
fn older_stamp_triggers_upgrade_and_updates_stamp() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
write_stamp(&stamp, "0.0.1").unwrap();
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
assert!(matches!(outcome, UpgradeOutcome::Upgraded { .. }));
let new_stamp = read_stamp(&stamp).unwrap().unwrap();
assert_eq!(new_stamp, env!("CARGO_PKG_VERSION"));
}
#[test]
fn older_stamp_dry_run_does_not_update_stamp() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
write_stamp(&stamp, "0.0.1").unwrap();
let outcome = check_with_path(&stamp, &opts_quiet_dry()).unwrap();
assert!(matches!(outcome, UpgradeOutcome::Upgraded { .. }));
let unchanged = read_stamp(&stamp).unwrap().unwrap();
assert_eq!(unchanged, "0.0.1");
}
#[test]
fn newer_stamp_triggers_downgrade_warning() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
write_stamp(&stamp, "99.0.0").unwrap();
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
assert!(matches!(outcome, UpgradeOutcome::Downgrade { .. }));
}
#[test]
fn corrupt_stamp_resets_to_current() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
std::fs::write(&stamp, "not-a-version").unwrap();
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
assert_eq!(outcome, UpgradeOutcome::CorruptStamp);
let reset = read_stamp(&stamp).unwrap().unwrap();
assert_eq!(reset, env!("CARGO_PKG_VERSION"));
}
#[test]
fn upgrade_outcome_from_contains_old_version() {
let dir = temp_dir();
let stamp = dir.path().join("version.stamp");
write_stamp(&stamp, "0.0.1").unwrap();
let outcome = check_with_path(&stamp, &opts_quiet()).unwrap();
match outcome {
UpgradeOutcome::Upgraded { from, .. } => assert_eq!(from, "0.0.1"),
other => panic!("expected Upgraded, got {other:?}"),
}
}
}