//! Version string utilities: extraction from arbitrary command output and //! lenient version comparison. use std::cmp::Ordering; /// Extract the first plausible version string from arbitrary text. /// /// Handles forms such as "v1.2.3", "1.2", "go1.21.0", "2026.1", as well as /// suffixes like "-beta" or "+meta". Prefers candidates with the most /// dot-separated components. pub fn find_version(text: &str) -> Option { let chars: Vec = text.chars().collect(); let n = chars.len(); let mut best: Option<(usize, usize, usize)> = None; // (dots, len, start) let mut i = 0; while i < n { if chars[i].is_ascii_digit() { let mut j = i; while j < n && chars[j].is_ascii_digit() { j += 1; } let mut dots = 0usize; loop { if j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() { dots += 1; j += 1; while j < n && chars[j].is_ascii_digit() { j += 1; } } else { break; } } let mut k = j; if k < n && (chars[k] == '-' || chars[k] == '+') { k += 1; while k < n && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-') { k += 1; } } let len = k - i; let score = dots.saturating_mul(1000) + len.min(999); if score > best.map(|(d, l, _)| d * 1000 + l.min(999)).unwrap_or(0) { best = Some((dots, len, i)); } i = k.max(j); } else { i += 1; } } let (_, _, start) = best?; // Reconstruct the chosen candidate. let mut j = start; while j < n && chars[j].is_ascii_digit() { j += 1; } while j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() { j += 1; while j < n && chars[j].is_ascii_digit() { j += 1; } } let mut k = j; if k < n && (chars[k] == '-' || chars[k] == '+') { k += 1; while k < n && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-') { k += 1; } } while k > start && (chars[k - 1] == '.' || chars[k - 1] == '-') { k -= 1; } if k <= start { return None; } Some(chars[start..k].iter().collect()) } /// Strip a leading "v"/"V" and surrounding whitespace. pub fn normalize(s: &str) -> String { let mut t = s.trim().to_string(); if t.len() > 1 && (t.starts_with('v') || t.starts_with('V')) && t[1..].chars().next().map_or(false, |c| c.is_ascii_digit()) { t = t[1..].to_string(); } t } /// Compare two version strings leniently: semver when possible, otherwise a /// numeric-tuple comparison. Returns None when the two cannot be compared at /// all (never happens for two non-empty strings). pub fn compare(a: &str, b: &str) -> Option { let a = normalize(a); let b = normalize(b); if a == b { return Some(Ordering::Equal); } if let (Ok(av), Ok(bv)) = (semver::Version::parse(&a), semver::Version::parse(&b)) { return Some(av.cmp(&bv)); } let pa = numeric_parts(&a); let pb = numeric_parts(&b); let mut idx = 0; loop { match (pa.get(idx), pb.get(idx)) { (Some(x), Some(y)) => { if x != y { return Some(x.cmp(y)); } idx += 1; } (Some(_), None) => return Some(Ordering::Greater), (None, Some(_)) => return Some(Ordering::Less), (None, None) => return None, } } } fn numeric_parts(s: &str) -> Vec { s.split(|c: char| !c.is_ascii_digit()) .filter(|p| !p.is_empty()) .filter_map(|p| p.parse::().ok()) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn extracts_common_forms() { assert_eq!(find_version("Claude Code v1.2.3 (abc)").as_deref(), Some("1.2.3")); assert_eq!(find_version("go version go1.21.0 windows/amd64").as_deref(), Some("1.21.0")); assert_eq!(find_version("uv 0.7.12").as_deref(), Some("0.7.12")); assert_eq!(find_version("node 18.20.4").as_deref(), Some("18.20.4")); assert_eq!(find_version("1.2.3-beta.1").as_deref(), Some("1.2.3-beta.1")); assert_eq!(find_version("no version here"), None); } #[test] fn prefers_dotted_candidate() { assert_eq!(find_version("cargo 1.85.0 (8a56 2026-01-01)").as_deref(), Some("1.85.0")); } #[test] fn compares_semver_and_numeric() { assert_eq!(compare("1.10.0", "1.9.0"), Some(Ordering::Greater)); assert_eq!(compare("v2.0.0", "2.0.0"), Some(Ordering::Equal)); assert_eq!(compare("2.0.0-rc1", "2.0.0"), Some(Ordering::Less)); assert_eq!(compare("2026", "2025"), Some(Ordering::Greater)); assert_eq!(compare("0.7", "0.7.12"), Some(Ordering::Less)); } }