use crate::{keychain::auth_source, BOB_MIN_NODE_VERSION};
use serde::Serialize;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BobReadinessSnapshot {
pub bob: BobProbe,
pub node: NodeProbe,
pub npm: NpmProbe,
pub auth: AuthProbe,
pub ready: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BobProbe {
pub installed: bool,
pub version: Option<String>,
pub path: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeProbe {
pub installed: bool,
pub version: Option<String>,
pub satisfies_min: bool,
pub min_version: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NpmProbe {
pub installed: bool,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthProbe {
pub configured: bool,
pub source: Option<String>,
}
pub fn get_readiness() -> BobReadinessSnapshot {
let bob_path = run_login_shell("command -v bob");
let bob_version_raw = run_login_shell("bob --version");
let bob_version = bob_version_raw.as_ref().and_then(|v| {
v.split_whitespace().last().map(|s| s.to_owned())
});
let bob_installed = bob_path.is_some() && bob_version.is_some();
let node_raw = run_login_shell("node --version");
let node_installed = node_raw.is_some();
let node_satisfies = node_raw
.as_deref()
.map(|v| semver_at_least(v, BOB_MIN_NODE_VERSION))
.unwrap_or(false);
let npm_raw = run_login_shell("npm --version");
let auth = match auth_source() {
Some(source) => AuthProbe {
configured: true,
source: Some(match source {
crate::keychain::KeySource::Env => "env".to_owned(),
crate::keychain::KeySource::Keychain => "keychain".to_owned(),
}),
},
None => AuthProbe { configured: false, source: None },
};
BobReadinessSnapshot {
bob: BobProbe {
installed: bob_installed,
path: bob_path,
version: bob_version,
error: if bob_installed { None } else { Some("bob CLI not found on PATH".to_owned()) },
},
node: NodeProbe {
installed: node_installed,
version: node_raw,
satisfies_min: node_satisfies,
min_version: BOB_MIN_NODE_VERSION.to_owned(),
},
npm: NpmProbe {
installed: npm_raw.is_some(),
version: npm_raw,
},
ready: bob_installed && node_satisfies && auth.configured,
auth,
}
}
fn run_login_shell(command: &str) -> Option<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_owned());
let output = Command::new(&shell)
.arg("-l")
.arg("-c")
.arg(command)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
pub(crate) fn semver_at_least(actual: &str, minimum: &str) -> bool {
fn parse(raw: &str) -> (u32, u32, u32) {
let clean = raw.trim_start_matches('v').split('-').next().unwrap_or("");
let mut parts = clean.split('.').map(|seg| seg.parse::<u32>().unwrap_or(0));
(parts.next().unwrap_or(0), parts.next().unwrap_or(0), parts.next().unwrap_or(0))
}
let (a_maj, a_min, a_patch) = parse(actual);
let (m_maj, m_min, m_patch) = parse(minimum);
if a_maj != m_maj { return a_maj > m_maj; }
if a_min != m_min { return a_min > m_min; }
a_patch >= m_patch
}
#[cfg(test)]
mod tests {
use super::semver_at_least;
#[test]
fn handles_v_prefix() {
assert!(semver_at_least("v22.15.0", "22.15.0"));
assert!(semver_at_least("22.15.0", "v22.15.0"));
}
#[test]
fn major_dominates() {
assert!(semver_at_least("v24.0.0", "22.15.0"));
assert!(!semver_at_least("v18.99.99", "22.15.0"));
}
#[test]
fn minor_dominates_within_major() {
assert!(semver_at_least("v22.16.0", "22.15.0"));
assert!(!semver_at_least("v22.14.999", "22.15.0"));
}
#[test]
fn patch_inclusive() {
assert!(semver_at_least("v22.15.0", "22.15.0"));
assert!(!semver_at_least("v22.15.0", "22.15.1"));
}
#[test]
fn ignores_prerelease_suffix() {
assert!(semver_at_least("v22.15.0-rc.1", "22.15.0"));
}
}