agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- 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
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user