- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
159 lines
5.1 KiB
Rust
159 lines
5.1 KiB
Rust
//! 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<String> {
|
|
let chars: Vec<char> = 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<Ordering> {
|
|
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<u64> {
|
|
s.split(|c: char| !c.is_ascii_digit())
|
|
.filter(|p| !p.is_empty())
|
|
.filter_map(|p| p.parse::<u64>().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));
|
|
}
|
|
}
|