use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use super::DriftError;
use super::config::DriftConfig;
use super::state::{EnvironmentState, hash_file};
pub struct DriftDetector<'a> {
config: &'a DriftConfig,
expected_state: &'a EnvironmentState,
project_dir: &'a Path,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftReport {
pub timestamp: String,
pub status: DriftStatus,
pub summary: DriftSummary,
pub version_changes: Vec<VersionChange>,
pub missing_tools: Vec<MissingTool>,
pub extra_tools: Vec<ExtraTool>,
pub changed_files: Vec<ChangedFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftSummary {
pub total_issues: usize,
pub version_changes: usize,
pub missing_tools: usize,
pub extra_tools: usize,
pub changed_files: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriftStatus {
NoDrift,
DriftDetected,
NoBaseline,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionChange {
pub tool: String,
pub expected: String,
pub actual: String,
pub direction: VersionDirection,
pub auto_fixable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VersionDirection {
Upgrade,
Downgrade,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissingTool {
pub tool: String,
pub expected_version: String,
pub auto_fixable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtraTool {
pub tool: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedFile {
pub path: String,
pub expected_hash: String,
pub actual_hash: String,
pub auto_fixable: bool,
}
impl<'a> DriftDetector<'a> {
pub fn new(
config: &'a DriftConfig,
expected_state: &'a EnvironmentState,
project_dir: &'a Path,
) -> Self {
Self {
config,
expected_state,
project_dir,
}
}
pub fn detect(&self) -> Result<DriftReport, DriftError> {
let mut report = DriftReport {
timestamp: current_timestamp(),
status: DriftStatus::NoDrift,
summary: DriftSummary {
total_issues: 0,
version_changes: 0,
missing_tools: 0,
extra_tools: 0,
changed_files: 0,
},
version_changes: Vec::new(),
missing_tools: Vec::new(),
extra_tools: Vec::new(),
changed_files: Vec::new(),
};
for (name, expected) in &self.expected_state.tools {
if self.config.ignore_tools.contains(name) {
continue;
}
match get_tool_version(name) {
Some(actual_version) => {
if !self
.config
.version_policy
.versions_match(&expected.version, &actual_version)
{
let direction = if is_upgrade(&expected.version, &actual_version) {
VersionDirection::Upgrade
} else {
VersionDirection::Downgrade
};
if self.config.allow_upgrades && direction == VersionDirection::Upgrade {
continue;
}
report.version_changes.push(VersionChange {
tool: name.clone(),
expected: expected.version.clone(),
actual: actual_version,
direction,
auto_fixable: is_auto_fixable(name, &expected.install_method),
reason: None,
});
}
}
None => {
report.missing_tools.push(MissingTool {
tool: name.clone(),
expected_version: expected.version.clone(),
auto_fixable: true,
});
}
}
}
for (path, expected_hash) in &self.expected_state.files {
let file_path = self.project_dir.join(path);
if !file_path.exists() {
report.changed_files.push(ChangedFile {
path: path.clone(),
expected_hash: expected_hash.clone(),
actual_hash: "missing".to_string(),
auto_fixable: false,
});
} else if let Ok(actual_hash) = hash_file(&file_path) {
if actual_hash != *expected_hash {
report.changed_files.push(ChangedFile {
path: path.clone(),
expected_hash: expected_hash.clone(),
actual_hash,
auto_fixable: false,
});
}
}
}
report.summary.version_changes = report.version_changes.len();
report.summary.missing_tools = report.missing_tools.len();
report.summary.extra_tools = report.extra_tools.len();
report.summary.changed_files = report.changed_files.len();
report.summary.total_issues = report.summary.version_changes
+ report.summary.missing_tools
+ report.summary.changed_files;
if report.summary.total_issues > 0 {
report.status = DriftStatus::DriftDetected;
}
Ok(report)
}
}
fn get_tool_version(tool: &str) -> Option<String> {
let output = Command::new(tool)
.arg("--version")
.output()
.or_else(|_| Command::new(tool).arg("-V").output())
.or_else(|_| Command::new(tool).arg("version").output())
.ok()?;
if !output.status.success() {
return None;
}
let output_str = String::from_utf8_lossy(&output.stdout);
extract_version(&output_str)
}
fn extract_version(output: &str) -> Option<String> {
let version_regex =
regex::Regex::new(r"(?i)v?(\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?)")
.ok()?;
version_regex
.captures(output)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
fn is_upgrade(old: &str, new: &str) -> bool {
match (semver::Version::parse(old), semver::Version::parse(new)) {
(Ok(old_v), Ok(new_v)) => new_v > old_v,
_ => new > old, }
}
fn is_auto_fixable(tool: &str, install_method: &str) -> bool {
let version_managers = ["rustup", "nvm", "pyenv", "rbenv", "sdkman"];
!version_managers.contains(&install_method)
&& !version_managers
.iter()
.any(|vm| tool.contains(vm) || install_method.contains(vm))
}
fn current_timestamp() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}Z", duration.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version() {
assert_eq!(
extract_version("git version 2.39.0"),
Some("2.39.0".to_string())
);
assert_eq!(
extract_version("node v20.10.0"),
Some("20.10.0".to_string())
);
assert_eq!(extract_version("rustc 1.75.0"), Some("1.75.0".to_string()));
assert_eq!(
extract_version("Docker version 24.0.7, build afdd53b"),
Some("24.0.7".to_string())
);
}
#[test]
fn test_is_upgrade() {
assert!(is_upgrade("1.0.0", "1.0.1"));
assert!(is_upgrade("1.0.0", "1.1.0"));
assert!(is_upgrade("1.0.0", "2.0.0"));
assert!(!is_upgrade("1.0.1", "1.0.0"));
assert!(!is_upgrade("2.0.0", "1.0.0"));
assert!(!is_upgrade("1.0.0", "1.0.0"));
}
#[test]
fn test_is_auto_fixable() {
assert!(is_auto_fixable("node", "brew"));
assert!(is_auto_fixable("docker", "apt"));
assert!(!is_auto_fixable("rust", "rustup"));
assert!(!is_auto_fixable("node", "nvm"));
assert!(!is_auto_fixable("python", "pyenv"));
}
#[test]
fn test_drift_status_serialization() {
let status = DriftStatus::DriftDetected;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "\"drift_detected\"");
}
#[test]
fn test_version_direction_serialization() {
let upgrade = VersionDirection::Upgrade;
let json = serde_json::to_string(&upgrade).unwrap();
assert_eq!(json, "\"upgrade\"");
}
}