1use chrono::{DateTime, Duration, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::path::{Path, PathBuf};
26use tokio::fs;
27use tracing::{debug, info, warn};
28
29const COMPAT_LOCK_FILENAME: &str = "compat.lock.toml";
31
32const ACTR_TEMP_DIR: &str = "actr";
34
35const DEFAULT_TTL_HOURS: i64 = 24;
37
38fn compute_project_hash(project_root: &Path) -> String {
42 let canonical = project_root
43 .canonicalize()
44 .unwrap_or_else(|_| project_root.to_path_buf());
45 let path_str = canonical.to_string_lossy();
46 let mut hasher = Sha256::new();
47 hasher.update(path_str.as_bytes());
48 let result = hasher.finalize();
49 hex::encode(&result[..8])
51}
52
53fn get_compat_lock_dir(project_root: &Path) -> PathBuf {
57 let temp_dir = std::env::temp_dir();
58 let project_hash = compute_project_hash(project_root);
59 temp_dir.join(ACTR_TEMP_DIR).join(project_hash)
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct NegotiationEntry {
65 pub service_name: String,
67
68 pub requested_fingerprint: String,
70
71 pub resolved_fingerprint: String,
73
74 pub compatibility_check: CompatibilityCheck,
76
77 pub negotiated_at: DateTime<Utc>,
79
80 pub expires_at: DateTime<Utc>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum CompatibilityCheck {
88 ExactMatch,
90 BackwardCompatible,
92 BreakingChanges,
94}
95
96impl std::fmt::Display for CompatibilityCheck {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 CompatibilityCheck::ExactMatch => write!(f, "exact_match"),
100 CompatibilityCheck::BackwardCompatible => write!(f, "backward_compatible"),
101 CompatibilityCheck::BreakingChanges => write!(f, "breaking_changes"),
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct CompatLockFile {
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub _comment: Option<String>,
112
113 #[serde(default)]
115 pub negotiation: Vec<NegotiationEntry>,
116}
117
118impl CompatLockFile {
119 pub fn new() -> Self {
121 Self {
122 _comment: Some(
123 "This file indicates the system is in SUB-HEALTHY state.\n\
124 Consider running 'actr install --force-update' to update dependencies."
125 .to_string(),
126 ),
127 negotiation: Vec::new(),
128 }
129 }
130
131 pub async fn load(base_path: &Path) -> Result<Option<Self>, CompatLockError> {
133 let file_path = base_path.join(COMPAT_LOCK_FILENAME);
134
135 if !file_path.exists() {
136 return Ok(None);
137 }
138
139 let content =
140 fs::read_to_string(&file_path)
141 .await
142 .map_err(|e| CompatLockError::IoError {
143 path: file_path.clone(),
144 source: e,
145 })?;
146
147 let lock_file: Self =
148 toml::from_str(&content).map_err(|e| CompatLockError::ParseError {
149 path: file_path,
150 source: e,
151 })?;
152
153 Ok(Some(lock_file))
154 }
155
156 pub async fn save(&self, base_path: &Path) -> Result<(), CompatLockError> {
158 if !base_path.exists() {
160 fs::create_dir_all(base_path)
161 .await
162 .map_err(|e| CompatLockError::IoError {
163 path: base_path.to_path_buf(),
164 source: e,
165 })?;
166 debug!(
167 "Created compat.lock cache directory: {}",
168 base_path.display()
169 );
170 }
171
172 let file_path = base_path.join(COMPAT_LOCK_FILENAME);
173
174 let content = toml::to_string_pretty(self)
175 .map_err(|e| CompatLockError::SerializeError { source: e })?;
176
177 let full_content = format!(
179 "# compat.lock.toml - 兼容性协商缓存\n\
180 # This file indicates the system is in SUB-HEALTHY state.\n\
181 # Consider running 'actr install --force-update' to update dependencies.\n\
182 # Location: {}\n\n\
183 {content}",
184 file_path.display()
185 );
186
187 fs::write(&file_path, full_content)
188 .await
189 .map_err(|e| CompatLockError::IoError {
190 path: file_path,
191 source: e,
192 })?;
193
194 Ok(())
195 }
196
197 pub async fn remove(base_path: &Path) -> Result<bool, CompatLockError> {
199 let file_path = base_path.join(COMPAT_LOCK_FILENAME);
200
201 if file_path.exists() {
202 fs::remove_file(&file_path)
203 .await
204 .map_err(|e| CompatLockError::IoError {
205 path: file_path,
206 source: e,
207 })?;
208 Ok(true)
209 } else {
210 Ok(false)
211 }
212 }
213
214 pub fn find_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
216 self.negotiation
217 .iter()
218 .find(|e| e.service_name == service_name)
219 }
220
221 pub fn find_valid_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
223 let now = Utc::now();
224 self.negotiation
225 .iter()
226 .find(|e| e.service_name == service_name && e.expires_at > now)
227 }
228
229 pub fn upsert_entry(&mut self, entry: NegotiationEntry) {
231 self.negotiation
233 .retain(|e| e.service_name != entry.service_name);
234 self.negotiation.push(entry);
236 }
237
238 pub fn cleanup_expired(&mut self) -> usize {
240 let now = Utc::now();
241 let before = self.negotiation.len();
242 self.negotiation.retain(|e| e.expires_at > now);
243 before - self.negotiation.len()
244 }
245
246 pub async fn exists(base_path: &Path) -> bool {
248 base_path.join(COMPAT_LOCK_FILENAME).exists()
249 }
250
251 pub fn is_sub_healthy(&self) -> bool {
253 let now = Utc::now();
254 self.negotiation.iter().any(|e| {
255 e.expires_at > now && e.compatibility_check == CompatibilityCheck::BackwardCompatible
256 })
257 }
258}
259
260impl NegotiationEntry {
261 pub fn new(
263 service_name: String,
264 requested_fingerprint: String,
265 resolved_fingerprint: String,
266 compatibility_check: CompatibilityCheck,
267 ) -> Self {
268 let now = Utc::now();
269 Self {
270 service_name,
271 requested_fingerprint,
272 resolved_fingerprint,
273 compatibility_check,
274 negotiated_at: now,
275 expires_at: now + Duration::hours(DEFAULT_TTL_HOURS),
276 }
277 }
278
279 pub fn is_expired(&self) -> bool {
281 Utc::now() > self.expires_at
282 }
283}
284
285#[derive(Debug, thiserror::Error)]
287pub enum CompatLockError {
288 #[error("IO error at {path}: {source}")]
289 IoError {
290 path: PathBuf,
291 #[source]
292 source: std::io::Error,
293 },
294
295 #[error("Parse error at {path}: {source}")]
296 ParseError {
297 path: PathBuf,
298 #[source]
299 source: toml::de::Error,
300 },
301
302 #[error("Serialize error: {source}")]
303 SerializeError {
304 #[source]
305 source: toml::ser::Error,
306 },
307}
308
309pub struct CompatLockManager {
311 base_path: PathBuf,
313 #[allow(dead_code)]
315 project_root: PathBuf,
316 cached: Option<CompatLockFile>,
318}
319
320impl CompatLockManager {
321 pub fn new(project_root: PathBuf) -> Self {
329 let base_path = get_compat_lock_dir(&project_root);
330 debug!(
331 "CompatLockManager initialized: project_root={}, cache_dir={}",
332 project_root.display(),
333 base_path.display()
334 );
335 Self {
336 base_path,
337 project_root,
338 cached: None,
339 }
340 }
341
342 pub fn cache_dir(&self) -> &Path {
344 &self.base_path
345 }
346
347 pub async fn load(&mut self) -> Result<Option<&CompatLockFile>, CompatLockError> {
349 self.cached = CompatLockFile::load(&self.base_path).await?;
350 Ok(self.cached.as_ref())
351 }
352
353 pub fn get_cached(&self) -> Option<&CompatLockFile> {
355 self.cached.as_ref()
356 }
357
358 pub async fn record_negotiation(
364 &mut self,
365 service_name: &str,
366 requested_fingerprint: &str,
367 resolved_fingerprint: &str,
368 is_exact_match: bool,
369 compatibility_check: CompatibilityCheck,
370 ) -> Result<(), CompatLockError> {
371 if is_exact_match {
372 if let Some(ref mut lock_file) = self.cached {
374 lock_file
375 .negotiation
376 .retain(|e| e.service_name != service_name);
377
378 if lock_file.negotiation.is_empty() {
380 CompatLockFile::remove(&self.base_path).await?;
381 self.cached = None;
382 info!("✅ SYSTEM HEALTHY: 所有依赖精确匹配,已删除 compat.lock.toml");
383 } else {
384 lock_file.save(&self.base_path).await?;
385 }
386 }
387 } else {
388 let entry = NegotiationEntry::new(
390 service_name.to_string(),
391 requested_fingerprint.to_string(),
392 resolved_fingerprint.to_string(),
393 compatibility_check,
394 );
395
396 let lock_file = self.cached.get_or_insert_with(CompatLockFile::new);
397 lock_file.upsert_entry(entry);
398 lock_file.save(&self.base_path).await?;
399
400 warn!(
401 "🟡 SYSTEM SUB-HEALTHY: Service '{}' using compatible fingerprint ({}) instead of exact match ({}). \
402 Run 'actr install --force-update' to restore health.",
403 service_name,
404 &resolved_fingerprint[..20.min(resolved_fingerprint.len())],
405 &requested_fingerprint[..20.min(requested_fingerprint.len())],
406 );
407 }
408
409 Ok(())
410 }
411
412 pub fn find_cached_compatible(
414 &self,
415 service_name: &str,
416 requested_fingerprint: &str,
417 ) -> Option<&NegotiationEntry> {
418 self.cached.as_ref().and_then(|lock_file| {
419 lock_file
420 .find_valid_entry(service_name)
421 .filter(|entry| entry.requested_fingerprint == requested_fingerprint)
422 })
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use tempfile::TempDir;
430
431 #[tokio::test]
432 async fn test_compat_lock_file_roundtrip() {
433 let temp_dir = TempDir::new().unwrap();
434 let base_path = temp_dir.path();
435
436 let mut lock_file = CompatLockFile::new();
438 lock_file.upsert_entry(NegotiationEntry::new(
439 "user-service".to_string(),
440 "sha256:old".to_string(),
441 "sha256:new".to_string(),
442 CompatibilityCheck::BackwardCompatible,
443 ));
444 lock_file.save(base_path).await.unwrap();
445
446 assert!(CompatLockFile::exists(base_path).await);
448
449 let loaded = CompatLockFile::load(base_path).await.unwrap().unwrap();
451 assert_eq!(loaded.negotiation.len(), 1);
452 assert_eq!(loaded.negotiation[0].service_name, "user-service");
453 assert!(loaded.is_sub_healthy());
454 }
455
456 #[tokio::test]
457 async fn test_compat_lock_manager() {
458 let temp_dir = TempDir::new().unwrap();
459 let project_root = temp_dir.path().to_path_buf();
461
462 let mut manager = CompatLockManager::new(project_root.clone());
463
464 let cache_dir = manager.cache_dir().to_path_buf();
466 assert!(cache_dir.starts_with(std::env::temp_dir()));
467 assert!(cache_dir.to_string_lossy().contains("actr"));
468
469 manager
471 .record_negotiation(
472 "user-service",
473 "sha256:old",
474 "sha256:new",
475 false,
476 CompatibilityCheck::BackwardCompatible,
477 )
478 .await
479 .unwrap();
480
481 assert!(CompatLockFile::exists(&cache_dir).await);
483
484 assert!(!project_root.join(COMPAT_LOCK_FILENAME).exists());
486
487 let entry = manager.find_cached_compatible("user-service", "sha256:old");
489 assert!(entry.is_some());
490
491 manager
493 .record_negotiation(
494 "user-service",
495 "sha256:exact",
496 "sha256:exact",
497 true,
498 CompatibilityCheck::ExactMatch,
499 )
500 .await
501 .unwrap();
502
503 assert!(!CompatLockFile::exists(&cache_dir).await);
505 }
506
507 #[test]
508 fn test_project_hash_deterministic() {
509 let path1 = PathBuf::from("/tmp/test-project");
510 let path2 = PathBuf::from("/tmp/test-project");
511 let path3 = PathBuf::from("/tmp/other-project");
512
513 let hash1 = compute_project_hash(&path1);
514 let hash2 = compute_project_hash(&path2);
515 let hash3 = compute_project_hash(&path3);
516
517 assert_eq!(hash1, hash2);
519 assert_ne!(hash1, hash3);
521 assert_eq!(hash1.len(), 16);
523 }
524}