use std::collections::HashMap;
use std::process::Command;
pub struct HostToolValidator {
required: Vec<String>,
optional: Vec<String>,
}
impl Clone for HostToolValidator {
fn clone(&self) -> Self {
Self {
required: self.required.clone(),
optional: self.optional.clone(),
}
}
}
impl HostToolValidator {
pub fn new(required: Vec<String>, optional: Vec<String>) -> Self {
Self { required, optional }
}
pub fn validate_required(&self) -> Vec<String> {
self.required
.iter()
.filter(|tool| !Self::is_tool_available(tool))
.cloned()
.collect()
}
pub fn check_optional(&self) -> HashMap<String, bool> {
self.optional
.iter()
.map(|tool| (tool.clone(), Self::is_tool_available(tool)))
.collect()
}
pub fn full_check(&self) -> HostToolStatus {
let missing_required = self.validate_required();
let optional_available = self.check_optional();
HostToolStatus {
all_required_present: missing_required.is_empty(),
missing_required,
optional_available,
}
}
pub fn is_tool_available(tool: &str) -> bool {
Self::check_command(tool, &["--version"])
|| Self::check_command(tool, &["-v"])
|| Self::check_command(tool, &["version"])
}
fn check_command(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn required_tools(&self) -> &[String] {
&self.required
}
pub fn optional_tools(&self) -> &[String] {
&self.optional
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HostToolStatus {
pub all_required_present: bool,
pub missing_required: Vec<String>,
pub optional_available: HashMap<String, bool>,
}
pub mod common {
pub const REQUIRED: &[&str] = &["git"];
pub const OPTIONAL: &[&str] = &[
"gh", "remindctl", "shortcuts", "osascript", "open", "jq", "curl", "ripgrep", "sqlite3", ];
pub const CONTAINER_MINIMAL: &[&str] =
&["bash", "python3", "git", "curl", "jq", "ripgrep", "sqlite3"];
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_required_all_present() {
let validator = HostToolValidator::new(vec!["echo".to_string()], Vec::new());
let missing = validator.validate_required();
assert!(missing.is_empty());
}
#[test]
fn test_validate_required_missing() {
let validator = HostToolValidator::new(
vec!["definitely-not-a-real-tool-12345".to_string()],
Vec::new(),
);
let missing = validator.validate_required();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "definitely-not-a-real-tool-12345");
}
#[test]
fn test_validate_required_multiple_missing() {
let validator = HostToolValidator::new(
vec!["not-real-1".to_string(), "not-real-2".to_string()],
Vec::new(),
);
let missing = validator.validate_required();
assert_eq!(missing.len(), 2);
}
#[test]
fn test_check_optional() {
let validator = HostToolValidator::new(
Vec::new(),
vec!["echo".to_string(), "definitely-not-real".to_string()],
);
let results = validator.check_optional();
assert_eq!(results.len(), 2);
assert!(results["echo"]);
assert!(!results["definitely-not-real"]);
}
#[test]
fn test_is_tool_available() {
assert!(HostToolValidator::is_tool_available("echo"));
assert!(HostToolValidator::is_tool_available("ls"));
assert!(HostToolValidator::is_tool_available("cat"));
}
#[test]
fn test_is_tool_available_not_found() {
assert!(!HostToolValidator::is_tool_available(
"this-tool-definitely-does-not-exist-abc123"
));
}
#[test]
fn test_full_check() {
let validator = HostToolValidator::new(vec!["echo".to_string()], vec!["cat".to_string()]);
let status = validator.full_check();
assert!(status.all_required_present);
assert!(status.missing_required.is_empty());
assert!(status.optional_available["cat"]);
}
#[test]
fn test_full_check_missing_required() {
let validator = HostToolValidator::new(
vec!["echo".to_string(), "not-real-xyz".to_string()],
Vec::new(),
);
let status = validator.full_check();
assert!(!status.all_required_present);
assert_eq!(status.missing_required.len(), 1);
}
#[test]
fn test_required_tools_accessors() {
let validator = HostToolValidator::new(
vec!["git".to_string(), "gh".to_string()],
vec!["jq".to_string()],
);
assert_eq!(validator.required_tools(), &["git", "gh"]);
assert_eq!(validator.optional_tools(), &["jq"]);
}
#[test]
fn test_common_tools_constants() {
assert!(!common::REQUIRED.is_empty());
assert!(common::REQUIRED.contains(&"git"));
assert!(!common::OPTIONAL.is_empty());
assert!(common::OPTIONAL.contains(&"gh"));
assert!(common::OPTIONAL.contains(&"jq"));
assert!(common::OPTIONAL.contains(&"curl"));
assert!(!common::CONTAINER_MINIMAL.is_empty());
assert!(common::CONTAINER_MINIMAL.contains(&"bash"));
assert!(common::CONTAINER_MINIMAL.contains(&"git"));
}
#[test]
fn test_host_tool_status_serialization() {
let status = HostToolStatus {
all_required_present: true,
missing_required: vec!["git".to_string()],
optional_available: HashMap::from([
("jq".to_string(), true),
("curl".to_string(), false),
]),
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("all_required_present"));
assert!(json.contains("missing_required"));
assert!(json.contains("optional_available"));
let loaded: HostToolStatus = serde_json::from_str(&json).unwrap();
assert!(loaded.all_required_present);
assert_eq!(loaded.missing_required.len(), 1);
}
}