#![allow(dead_code)]
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, info, warn};
const COMPAT_LOCK_FILENAME: &str = "compat.lock.toml";
const ACTR_TEMP_DIR: &str = "actr";
const DEFAULT_TTL_HOURS: i64 = 24;
fn compute_project_hash(project_root: &Path) -> String {
let canonical = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let path_str = canonical.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..8])
}
fn get_compat_lock_dir(project_root: &Path) -> PathBuf {
let temp_dir = std::env::temp_dir();
let project_hash = compute_project_hash(project_root);
temp_dir.join(ACTR_TEMP_DIR).join(project_hash)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct NegotiationEntry {
pub service_name: String,
pub requested_fingerprint: String,
pub resolved_fingerprint: String,
pub compatibility_check: CompatibilityCheck,
pub negotiated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CompatibilityCheck {
ExactMatch,
BackwardCompatible,
BreakingChanges,
}
impl std::fmt::Display for CompatibilityCheck {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompatibilityCheck::ExactMatch => write!(f, "exact_match"),
CompatibilityCheck::BackwardCompatible => write!(f, "backward_compatible"),
CompatibilityCheck::BreakingChanges => write!(f, "breaking_changes"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct CompatLockFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub _comment: Option<String>,
#[serde(default)]
pub negotiation: Vec<NegotiationEntry>,
}
impl CompatLockFile {
pub fn new() -> Self {
Self {
_comment: Some(
"This file indicates the system is in SUB-HEALTHY state.\n\
Consider running 'actr deps install --force-update' to update dependencies."
.to_string(),
),
negotiation: Vec::new(),
}
}
pub async fn load(base_path: &Path) -> Result<Option<Self>, CompatLockError> {
let file_path = base_path.join(COMPAT_LOCK_FILENAME);
if !file_path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&file_path)
.await
.map_err(|e| CompatLockError::IoError {
path: file_path.clone(),
source: e,
})?;
let lock_file: Self =
toml::from_str(&content).map_err(|e| CompatLockError::ParseError {
path: file_path,
source: e,
})?;
Ok(Some(lock_file))
}
pub async fn save(&self, base_path: &Path) -> Result<(), CompatLockError> {
if !base_path.exists() {
fs::create_dir_all(base_path)
.await
.map_err(|e| CompatLockError::IoError {
path: base_path.to_path_buf(),
source: e,
})?;
debug!(
"Created compat.lock cache directory: {}",
base_path.display()
);
}
let file_path = base_path.join(COMPAT_LOCK_FILENAME);
let content = toml::to_string_pretty(self)
.map_err(|e| CompatLockError::SerializeError { source: e })?;
let full_content = format!(
"# compat.lock.toml - Compatibility negotiation cache\n\
# This file indicates the system is in SUB-HEALTHY state.\n\
# Consider running 'actr deps install --force-update' to update dependencies.\n\
# Location: {}\n\n\
{content}",
file_path.display()
);
fs::write(&file_path, full_content)
.await
.map_err(|e| CompatLockError::IoError {
path: file_path,
source: e,
})?;
Ok(())
}
pub async fn remove(base_path: &Path) -> Result<bool, CompatLockError> {
let file_path = base_path.join(COMPAT_LOCK_FILENAME);
if file_path.exists() {
fs::remove_file(&file_path)
.await
.map_err(|e| CompatLockError::IoError {
path: file_path,
source: e,
})?;
Ok(true)
} else {
Ok(false)
}
}
pub fn find_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
self.negotiation
.iter()
.find(|e| e.service_name == service_name)
}
pub fn find_valid_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
let now = Utc::now();
self.negotiation
.iter()
.find(|e| e.service_name == service_name && e.expires_at > now)
}
pub fn upsert_entry(&mut self, entry: NegotiationEntry) {
self.negotiation
.retain(|e| e.service_name != entry.service_name);
self.negotiation.push(entry);
}
pub fn cleanup_expired(&mut self) -> usize {
let now = Utc::now();
let before = self.negotiation.len();
self.negotiation.retain(|e| e.expires_at > now);
before - self.negotiation.len()
}
pub async fn exists(base_path: &Path) -> bool {
base_path.join(COMPAT_LOCK_FILENAME).exists()
}
pub fn is_sub_healthy(&self) -> bool {
let now = Utc::now();
self.negotiation.iter().any(|e| {
e.expires_at > now && e.compatibility_check == CompatibilityCheck::BackwardCompatible
})
}
}
impl NegotiationEntry {
pub fn new(
service_name: String,
requested_fingerprint: String,
resolved_fingerprint: String,
compatibility_check: CompatibilityCheck,
) -> Self {
let now = Utc::now();
Self {
service_name,
requested_fingerprint,
resolved_fingerprint,
compatibility_check,
negotiated_at: now,
expires_at: now + Duration::hours(DEFAULT_TTL_HOURS),
}
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
#[derive(Debug, thiserror::Error)]
#[allow(clippy::enum_variant_names)]
pub(crate) enum CompatLockError {
#[error("IO error at {path}: {source}")]
IoError {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Parse error at {path}: {source}")]
ParseError {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("Serialize error: {source}")]
SerializeError {
#[source]
source: toml::ser::Error,
},
}
pub(crate) struct CompatLockManager {
base_path: PathBuf,
#[allow(dead_code)]
project_root: PathBuf,
cached: Option<CompatLockFile>,
}
impl CompatLockManager {
pub fn new(project_root: PathBuf) -> Self {
let base_path = get_compat_lock_dir(&project_root);
debug!(
"CompatLockManager initialized: project_root={}, cache_dir={}",
project_root.display(),
base_path.display()
);
Self {
base_path,
project_root,
cached: None,
}
}
pub fn cache_dir(&self) -> &Path {
&self.base_path
}
pub async fn load(&mut self) -> Result<Option<&CompatLockFile>, CompatLockError> {
self.cached = CompatLockFile::load(&self.base_path).await?;
Ok(self.cached.as_ref())
}
pub fn get_cached(&self) -> Option<&CompatLockFile> {
self.cached.as_ref()
}
pub async fn record_negotiation(
&mut self,
service_name: &str,
requested_fingerprint: &str,
resolved_fingerprint: &str,
is_exact_match: bool,
compatibility_check: CompatibilityCheck,
) -> Result<(), CompatLockError> {
if is_exact_match {
if let Some(ref mut lock_file) = self.cached {
lock_file
.negotiation
.retain(|e| e.service_name != service_name);
if lock_file.negotiation.is_empty() {
CompatLockFile::remove(&self.base_path).await?;
self.cached = None;
info!(
"SYSTEM HEALTHY: all dependencies are exact matches, removed compat.lock.toml"
);
} else {
lock_file.save(&self.base_path).await?;
}
}
} else {
let entry = NegotiationEntry::new(
service_name.to_string(),
requested_fingerprint.to_string(),
resolved_fingerprint.to_string(),
compatibility_check,
);
let lock_file = self.cached.get_or_insert_with(CompatLockFile::new);
lock_file.upsert_entry(entry);
lock_file.save(&self.base_path).await?;
warn!(
"🟡 SYSTEM SUB-HEALTHY: Service '{}' using compatible fingerprint ({}) instead of exact match ({}). \
Run 'actr deps install --force-update' to restore health.",
service_name,
&resolved_fingerprint[..20.min(resolved_fingerprint.len())],
&requested_fingerprint[..20.min(requested_fingerprint.len())],
);
}
Ok(())
}
pub fn find_cached_compatible(
&self,
service_name: &str,
requested_fingerprint: &str,
) -> Option<&NegotiationEntry> {
self.cached.as_ref().and_then(|lock_file| {
lock_file
.find_valid_entry(service_name)
.filter(|entry| entry.requested_fingerprint == requested_fingerprint)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_compat_lock_file_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let mut lock_file = CompatLockFile::new();
lock_file.upsert_entry(NegotiationEntry::new(
"user-service".to_string(),
"sha256:old".to_string(),
"sha256:new".to_string(),
CompatibilityCheck::BackwardCompatible,
));
lock_file.save(base_path).await.unwrap();
assert!(CompatLockFile::exists(base_path).await);
let loaded = CompatLockFile::load(base_path).await.unwrap().unwrap();
assert_eq!(loaded.negotiation.len(), 1);
assert_eq!(loaded.negotiation[0].service_name, "user-service");
assert!(loaded.is_sub_healthy());
}
#[tokio::test]
async fn test_compat_lock_manager() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path().to_path_buf();
let mut manager = CompatLockManager::new(project_root.clone());
let cache_dir = manager.cache_dir().to_path_buf();
assert!(cache_dir.starts_with(std::env::temp_dir()));
assert!(cache_dir.to_string_lossy().contains("actr"));
manager
.record_negotiation(
"user-service",
"sha256:old",
"sha256:new",
false,
CompatibilityCheck::BackwardCompatible,
)
.await
.unwrap();
assert!(CompatLockFile::exists(&cache_dir).await);
assert!(!project_root.join(COMPAT_LOCK_FILENAME).exists());
let entry = manager.find_cached_compatible("user-service", "sha256:old");
assert!(entry.is_some());
manager
.record_negotiation(
"user-service",
"sha256:exact",
"sha256:exact",
true,
CompatibilityCheck::ExactMatch,
)
.await
.unwrap();
assert!(!CompatLockFile::exists(&cache_dir).await);
}
#[test]
fn test_project_hash_deterministic() {
let path1 = PathBuf::from("/tmp/test-project");
let path2 = PathBuf::from("/tmp/test-project");
let path3 = PathBuf::from("/tmp/other-project");
let hash1 = compute_project_hash(&path1);
let hash2 = compute_project_hash(&path2);
let hash3 = compute_project_hash(&path3);
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
assert_eq!(hash1.len(), 16);
}
}