use std::cmp::Ordering;
use std::path::PathBuf;
use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let mut parts = s.splitn(3, '.');
let parse_part = |raw: Option<&str>, label: &str| -> Result<u32> {
raw.with_context(|| format!("version '{s}' is missing the {label} component"))?
.parse::<u32>()
.with_context(|| format!("version '{s}': {label} component is not a valid u32"))
};
Ok(Self {
major: parse_part(parts.next(), "major")?,
minor: parse_part(parts.next(), "minor")?,
patch: parse_part(parts.next(), "patch")?,
})
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
pub fn stamp_path() -> Result<PathBuf> {
dirs::home_dir()
.context("could not resolve home directory")
.map(|home| home.join(".nab").join("version.stamp"))
}
pub fn read_stamp() -> Result<Option<Version>> {
let path = stamp_path()?;
match std::fs::read_to_string(&path) {
Ok(s) => Version::parse(&s).map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn write_stamp(version: &Version) -> Result<()> {
let path = stamp_path()?;
let dir = path.parent().context("stamp path has no parent")?;
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
std::fs::write(&path, format!("{version}\n"))
.with_context(|| format!("writing stamp to {}", path.display()))
}
pub struct Migration {
pub since: Version,
pub description: &'static str,
pub run: fn() -> Result<()>,
}
pub const MIGRATIONS: &[Migration] = &[];
struct WhatsNew {
version: Version,
items: &'static [&'static str],
}
static WHATS_NEW: &[WhatsNew] = &[WhatsNew {
version: Version {
major: 0,
minor: 7,
patch: 1,
},
items: &[
"New `upgrade` command with version stamp and migration framework",
"Agent-first install: tell your AI to read the README",
"12 MCP tools (analyze tool added)",
],
}];
fn print_whats_new(from: &Version, current: &Version) {
let items: Vec<&str> = WHATS_NEW
.iter()
.filter(|w| w.version > *from && w.version <= *current)
.flat_map(|w| w.items.iter().copied())
.collect();
if items.is_empty() {
return;
}
println!("What's new since v{from}:");
for item in items {
println!(" - {item}");
}
}
struct ModelHint {
name: &'static str,
available_since: Version,
hint: &'static str,
}
const MODEL_HINTS: &[ModelHint] = &[];
#[derive(Debug, Default)]
pub struct UpgradeConfig {
pub dry_run: bool,
pub quiet: bool,
}
pub fn check_upgrade() -> Result<()> {
let current = current_version()?;
match read_stamp()? {
None => {
write_stamp(¤t)?;
println!("Welcome to nab {current}!");
Ok(())
}
Some(stamp) if stamp < current => run_upgrade_inner(&stamp, ¤t, false, false, false),
Some(stamp) if stamp > current => {
eprintln!(
"warning: nab {stamp} stamp is newer than this binary ({current}); \
you may be running a downgraded binary"
);
Ok(())
}
_ => Ok(()), }
}
pub fn cmd_upgrade(cfg: &UpgradeConfig) -> Result<()> {
let current = current_version()?;
let Some(stamp) = read_stamp()? else {
if !cfg.quiet {
println!("nab v{current} — fresh install, stamp created.");
}
if !cfg.dry_run {
write_stamp(¤t)?;
}
print_model_hints(¤t, cfg.quiet);
return Ok(());
};
match stamp.cmp(¤t) {
Ordering::Greater => {
eprintln!(
"warning: stamp {stamp} is newer than binary {current}; \
skipping migrations (possible downgrade)"
);
Ok(())
}
Ordering::Equal => {
if !cfg.quiet {
println!("nab {current} — already up to date.");
}
print_model_hints(¤t, cfg.quiet);
Ok(())
}
Ordering::Less => run_upgrade_inner(&stamp, ¤t, cfg.dry_run, cfg.quiet, false),
}
}
fn current_version() -> Result<Version> {
Version::parse(env!("CARGO_PKG_VERSION"))
}
fn run_upgrade_inner(
from: &Version,
to: &Version,
dry_run: bool,
quiet: bool,
fresh: bool,
) -> Result<()> {
let pending: Vec<&Migration> = MIGRATIONS
.iter()
.filter(|m| m.since > *from && m.since <= *to)
.collect();
if !quiet && !fresh {
print_whats_new(from, to);
}
for migration in &pending {
if !quiet {
println!(" [migration] {}", migration.description);
}
if !dry_run {
(migration.run)()
.with_context(|| format!("migration '{}' failed", migration.description))?;
}
}
if !dry_run {
write_stamp(to)?;
} else if !quiet {
println!(" (dry-run: stamp not updated)");
}
print_model_hints(to, quiet);
if !quiet {
println!("nab upgraded v{from} → v{to}");
}
Ok(())
}
fn print_model_hints(current: &Version, quiet: bool) {
if quiet {
return;
}
for hint in MODEL_HINTS {
if *current >= hint.available_since && super::models::read_version(hint.name).is_some() {
println!(" hint [{}] {}", hint.name, hint.hint);
}
}
}
#[allow(dead_code, clippy::unnecessary_wraps)]
fn print_summary(migration_count: usize, quiet: bool) -> Result<()> {
if !quiet && migration_count > 0 {
println!(
"({migration_count} migration{} applied)",
if migration_count == 1 { "" } else { "s" }
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_parse_canonical() {
let v = Version::parse("1.2.3").unwrap();
assert_eq!(
v,
Version {
major: 1,
minor: 2,
patch: 3
}
);
}
#[test]
fn version_parse_trims_whitespace() {
let v = Version::parse(" 0.7.1\n").unwrap();
assert_eq!(
v,
Version {
major: 0,
minor: 7,
patch: 1
}
);
}
#[test]
fn version_parse_rejects_incomplete() {
let result = Version::parse("1.2");
assert!(result.is_err());
}
#[test]
fn version_parse_rejects_non_numeric_component() {
let result = Version::parse("1.2.beta");
assert!(result.is_err());
}
#[test]
fn version_ord_patch_comparison() {
let old = Version::parse("0.7.0").unwrap();
let new = Version::parse("0.7.1").unwrap();
assert!(old < new);
assert!(new > old);
}
#[test]
fn version_ord_minor_dominates_patch() {
let old = Version::parse("0.6.99").unwrap();
let new = Version::parse("0.7.0").unwrap();
assert!(old < new);
}
#[test]
fn version_ord_equal() {
let a = Version::parse("1.0.0").unwrap();
let b = Version::parse("1.0.0").unwrap();
assert_eq!(a, b);
assert_eq!(a.cmp(&b), Ordering::Equal);
}
#[test]
fn version_display_round_trips() {
let v = Version {
major: 2,
minor: 10,
patch: 0,
};
let s = v.to_string();
assert_eq!(s, "2.10.0");
assert_eq!(Version::parse(&s).unwrap(), v);
}
#[test]
fn read_stamp_absent_returns_none() {
let result = read_stamp_from_path(&PathBuf::from("/tmp/__nab_nonexistent_stamp_xyz__"));
assert!(result.unwrap().is_none());
}
#[test]
fn stamp_write_read_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("version.stamp");
let v = Version {
major: 0,
minor: 8,
patch: 0,
};
write_stamp_to_path(&v, &path).unwrap();
let read_back = read_stamp_from_path(&path).unwrap();
assert_eq!(read_back, Some(v));
}
#[test]
fn write_stamp_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("new_dir").join("version.stamp");
let v = Version {
major: 1,
minor: 0,
patch: 0,
};
let result = write_stamp_to_path(&v, &path);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
assert!(path.exists());
}
#[test]
fn pending_migrations_selects_correct_range() {
let from = Version::parse("0.7.0").unwrap();
let to = Version::parse("0.9.0").unwrap();
let versions = [
Version::parse("0.7.0").unwrap(), Version::parse("0.8.0").unwrap(), Version::parse("0.9.0").unwrap(), Version::parse("1.0.0").unwrap(), ];
let pending: Vec<&Version> = versions
.iter()
.filter(|v| **v > from && **v <= to)
.collect();
assert_eq!(pending.len(), 2);
assert_eq!(*pending[0], versions[1]);
assert_eq!(*pending[1], versions[2]);
}
#[test]
fn whats_new_selects_correct_range() {
let from = Version::parse("0.6.0").unwrap();
let current = Version::parse("0.7.1").unwrap();
let items: Vec<&str> = WHATS_NEW
.iter()
.filter(|w| w.version > from && w.version <= current)
.flat_map(|w| w.items.iter().copied())
.collect();
assert_eq!(items.len(), 3);
assert!(items[0].contains("upgrade"));
}
#[test]
fn whats_new_empty_when_already_current() {
let from = Version::parse("0.7.1").unwrap();
let current = Version::parse("0.7.1").unwrap();
let items: Vec<&str> = WHATS_NEW
.iter()
.filter(|w| w.version > from && w.version <= current)
.flat_map(|w| w.items.iter().copied())
.collect();
assert!(items.is_empty());
}
#[test]
fn whats_new_empty_when_from_is_newer() {
let from = Version::parse("1.0.0").unwrap();
let current = Version::parse("1.0.1").unwrap();
let items: Vec<&str> = WHATS_NEW
.iter()
.filter(|w| w.version > from && w.version <= current)
.flat_map(|w| w.items.iter().copied())
.collect();
assert!(items.is_empty());
}
fn read_stamp_from_path(path: &PathBuf) -> Result<Option<Version>> {
match std::fs::read_to_string(path) {
Ok(s) => Version::parse(&s).map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
fn write_stamp_to_path(version: &Version, path: &PathBuf) -> Result<()> {
let dir = path.parent().context("stamp path has no parent")?;
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
std::fs::write(path, format!("{version}\n"))
.with_context(|| format!("writing stamp to {}", path.display()))
}
}