whetstone-cli 2.1.1

Installer and CLI for Claude Code token optimization (Headroom + RTK + Memory)
use anyhow::{Context, Result};
use semver::Version;
use std::fs;
use std::path::Path;

pub fn current() -> &'static str {
    env!("WHETSTONE_VERSION")
}

pub fn read_from_file(path: &Path) -> Result<Version> {
    let raw = fs::read_to_string(path)
        .with_context(|| format!("reading VERSION from {}", path.display()))?;
    let trimmed = raw.trim();
    Version::parse(trimmed).with_context(|| format!("parsing semver from '{trimmed}'"))
}

pub fn write_to_file(path: &Path, version: &Version) -> Result<()> {
    fs::write(path, format!("{version}\n"))
        .with_context(|| format!("writing VERSION to {}", path.display()))
}

pub fn bump(current: &Version, kind: BumpKind) -> Version {
    match kind {
        BumpKind::Patch => Version::new(current.major, current.minor, current.patch + 1),
        BumpKind::Minor => Version::new(current.major, current.minor + 1, 0),
        BumpKind::Major => Version::new(current.major + 1, 0, 0),
    }
}

#[derive(Debug, Clone, Copy)]
pub enum BumpKind {
    Patch,
    Minor,
    Major,
}

pub fn is_older(installed: &str, minimum: &str) -> bool {
    let Ok(inst) = Version::parse(installed) else {
        return true;
    };
    let Ok(min) = Version::parse(minimum) else {
        return false;
    };
    inst < min
}

pub fn extract_semver(raw: &str) -> Option<String> {
    let chars: Vec<char> = raw.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        if chars[i].is_ascii_digit() {
            let start = i;
            let mut dots = 0;
            while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
                if chars[i] == '.' {
                    dots += 1;
                }
                i += 1;
            }
            if dots >= 2 {
                let candidate: String = chars[start..i].iter().collect();
                let candidate = candidate.trim_end_matches('.');
                if Version::parse(candidate).is_ok() {
                    return Some(candidate.to_string());
                }
            }
        }
        i += 1;
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bump_patch() {
        let v = Version::new(1, 2, 3);
        assert_eq!(bump(&v, BumpKind::Patch), Version::new(1, 2, 4));
    }

    #[test]
    fn bump_minor() {
        let v = Version::new(1, 2, 3);
        assert_eq!(bump(&v, BumpKind::Minor), Version::new(1, 3, 0));
    }

    #[test]
    fn bump_major() {
        let v = Version::new(1, 2, 3);
        assert_eq!(bump(&v, BumpKind::Major), Version::new(2, 0, 0));
    }

    #[test]
    fn extract_from_version_string() {
        assert_eq!(extract_semver("headroom 0.14.2"), Some("0.14.2".into()));
        assert_eq!(extract_semver("no version here"), None);
    }

    #[test]
    fn is_older_works() {
        assert!(is_older("0.13.0", "0.14.0"));
        assert!(!is_older("0.14.0", "0.14.0"));
        assert!(!is_older("0.15.0", "0.14.0"));
    }
}