use std::collections::HashMap;
use log::debug;
use regex::Regex;
use semver::Version;
use super::{Providers, run_cli};
pub struct CliSpec {
pub scheme: &'static str,
pub bin: &'static str,
pub version_args: &'static [&'static str],
pub min_version: &'static str,
}
pub static CLI_SPECS: &[CliSpec] = &[
CliSpec {
scheme: "op",
bin: "op",
version_args: &["--version"],
min_version: "2.18.0",
},
CliSpec {
scheme: "doppler",
bin: "doppler",
version_args: &["--version"],
min_version: "3.76.0",
},
CliSpec {
scheme: "vault",
bin: "vault",
version_args: &["version"],
min_version: "1.11.0",
},
CliSpec {
scheme: "infisical",
bin: "infisical",
version_args: &["--version"],
min_version: "0.4.0",
},
CliSpec {
scheme: "passbolt",
bin: "passbolt",
version_args: &["--version"],
min_version: "0.5.0",
},
];
pub fn spec_for(scheme: &str) -> Option<&'static CliSpec> {
CLI_SPECS.iter().find(|s| s.scheme == scheme)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompatWarning {
pub name: String,
pub found: String,
pub min: String,
pub install_url: String,
}
fn parse_version(output: &str) -> Option<Version> {
let re = Regex::new(r"(\d+)\.(\d+)\.(\d+)").expect("valid version regex");
let caps = re.captures(output)?;
Version::parse(&format!("{}.{}.{}", &caps[1], &caps[2], &caps[3])).ok()
}
async fn detect_version(
spec: &CliSpec,
name: &str,
install_url: &str,
extra_env: &HashMap<String, String>,
) -> Option<Version> {
let mut cmd = vec![spec.bin];
cmd.extend_from_slice(spec.version_args);
let output = run_cli(&cmd, extra_env, name, install_url, None)
.await
.ok()?;
parse_version(&String::from_utf8_lossy(&output.stdout))
.or_else(|| parse_version(&String::from_utf8_lossy(&output.stderr)))
}
pub async fn check(schemes: &[String], extra_env: &HashMap<String, String>) -> Vec<CompatWarning> {
let providers = Providers::new();
let targets: Vec<(&'static CliSpec, &'static str, &'static str)> = schemes
.iter()
.filter_map(|scheme| {
let spec = spec_for(scheme)?;
let provider = providers.provider(scheme)?;
Some((spec, provider.name(), provider.install_url()))
})
.collect();
let checks = targets
.into_iter()
.map(|(spec, name, install_url)| async move {
let found = detect_version(spec, name, install_url, extra_env).await?;
let min = Version::parse(spec.min_version).expect("valid embedded min version");
debug!("compat {name}: found {found}, min {min}");
(found < min).then(|| CompatWarning {
name: name.to_string(),
found: found.to_string(),
min: spec.min_version.to_string(),
install_url: install_url.to_string(),
})
});
futures::future::join_all(checks)
.await
.into_iter()
.flatten()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::fake_cli;
use tempfile::tempdir;
fn path_env(dir: &tempfile::TempDir) -> HashMap<String, String> {
HashMap::from([(
"PATH".to_string(),
dir.path().to_string_lossy().into_owned(),
)])
}
#[test]
fn test_parse_version_plain() {
assert_eq!(parse_version("2.30.0"), Some(Version::new(2, 30, 0)));
}
#[test]
fn test_parse_version_with_prefix() {
assert_eq!(parse_version("v3.76.0"), Some(Version::new(3, 76, 0)));
}
#[test]
fn test_parse_version_vault_format() {
assert_eq!(
parse_version("Vault v1.15.0 ('abc')"),
Some(Version::new(1, 15, 0))
);
}
#[test]
fn test_parse_version_none() {
assert_eq!(parse_version("no version here"), None);
}
#[test]
fn test_specs_have_valid_min_versions() {
for spec in CLI_SPECS {
assert!(
Version::parse(spec.min_version).is_ok(),
"{} has invalid min_version {}",
spec.scheme,
spec.min_version
);
}
}
#[tokio::test]
#[cfg(unix)]
async fn test_check_warns_on_old_version() {
let fake_bin = tempdir().unwrap();
fake_cli(&fake_bin, "op", "echo '2.0.0'");
let warnings = check(&["op".to_string()], &path_env(&fake_bin)).await;
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].name, "1Password");
assert_eq!(warnings[0].found, "2.0.0");
}
#[tokio::test]
#[cfg(unix)]
async fn test_check_ok_on_recent_version() {
let fake_bin = tempdir().unwrap();
fake_cli(&fake_bin, "op", "echo '2.30.0'");
let warnings = check(&["op".to_string()], &path_env(&fake_bin)).await;
assert!(warnings.is_empty());
}
#[tokio::test]
#[cfg(unix)]
async fn test_check_skips_missing_binary() {
let empty_bin = tempdir().unwrap();
let warnings = check(&["op".to_string()], &path_env(&empty_bin)).await;
assert!(warnings.is_empty());
}
#[tokio::test]
#[cfg(unix)]
async fn test_check_ignores_unknown_scheme() {
let warnings = check(&["unknown".to_string()], &HashMap::new()).await;
assert!(warnings.is_empty());
}
}