1use anyhow::Result;
8use camino::Utf8PathBuf;
9use chrono::{DateTime, Utc};
10use fd_lock::RwLock;
11use serde::{Deserialize, Serialize};
12use std::cell::RefCell;
13use std::fs;
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16use std::process;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19thread_local! {
21 static THREAD_HOME: RefCell<Option<Utf8PathBuf>> = const { RefCell::new(None) };
22}
23
24const DEFAULT_STALE_THRESHOLD_SECS: u64 = 3600; #[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct LockInfo {
30 pub pid: u32,
32 pub start_time: u64,
34 pub created_at: u64,
36 pub spec_id: String,
38 pub xchecker_version: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct XCheckerLock {
46 pub schema_version: String,
48 pub created_at: DateTime<Utc>,
50 pub model_full_name: String,
52 pub claude_cli_version: String,
54}
55
56#[derive(Debug, Clone)]
58pub struct RunContext {
59 pub model_full_name: String,
60 pub claude_cli_version: String,
61 pub schema_version: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DriftPair {
67 pub locked: String,
69 pub current: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct LockDrift {
76 pub model_full_name: Option<DriftPair>,
78 pub claude_cli_version: Option<DriftPair>,
80 pub schema_version: Option<DriftPair>,
82}
83
84#[derive(Debug, thiserror::Error)]
86pub enum LockError {
87 #[error(
88 "Concurrent execution detected for spec '{spec_id}' (PID {pid}, created {created_ago} ago)"
89 )]
90 ConcurrentExecution {
91 spec_id: String,
92 pid: u32,
93 created_ago: String,
94 },
95
96 #[error(
97 "Stale lock detected for spec '{spec_id}' (PID {pid}, age {age_secs}s). Use --force to override"
98 )]
99 StaleLock {
100 spec_id: String,
101 pid: u32,
102 age_secs: u64,
103 },
104
105 #[error("Lock file is corrupted or invalid: {reason}")]
106 CorruptedLock { reason: String },
107
108 #[error("Failed to acquire lock: {reason}")]
109 AcquisitionFailed { reason: String },
110
111 #[error("Failed to release lock: {reason}")]
112 ReleaseFailed { reason: String },
113
114 #[error("IO error during lock operation: {0}")]
115 Io(#[from] io::Error),
116}
117
118fn write_file_atomic(path: &Utf8PathBuf, content: &str) -> Result<(), io::Error> {
122 let parent = path
123 .parent()
124 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No parent directory"))?;
125
126 fs::create_dir_all(parent)?;
128
129 let temp_path = parent.join(format!(".{}.tmp", path.file_name().unwrap_or("file")));
131
132 fs::write(&temp_path, content)?;
134
135 fs::rename(&temp_path, path)?;
137
138 Ok(())
139}
140
141fn xchecker_home() -> Utf8PathBuf {
145 if let Some(tl) = THREAD_HOME.with(|tl| tl.borrow().clone()) {
146 return tl;
147 }
148 if let Ok(p) = std::env::var("XCHECKER_HOME") {
149 return Utf8PathBuf::from(p);
150 }
151 Utf8PathBuf::from(".xchecker")
152}
153
154fn spec_root(spec_id: &str) -> Utf8PathBuf {
158 xchecker_home().join("specs").join(spec_id)
159}
160
161fn ensure_dir_all(path: &Utf8PathBuf) -> Result<(), io::Error> {
165 if !path.as_std_path().exists() {
166 fs::create_dir_all(path.as_std_path())?;
167 }
168 Ok(())
169}
170
171#[cfg(any(test, feature = "test-utils"))]
173pub fn set_thread_home_for_tests(path: Utf8PathBuf) {
174 THREAD_HOME.with(|tl| *tl.borrow_mut() = Some(path));
175}
176
177#[cfg(test)]
181pub fn with_isolated_home() -> tempfile::TempDir {
182 let td = tempfile::TempDir::new().expect("Failed to create temp dir");
183 let p = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
184 set_thread_home_for_tests(p);
185 td
186}
187
188impl XCheckerLock {
189 #[must_use]
191 pub fn new(model_full_name: String, claude_cli_version: String) -> Self {
192 Self {
193 schema_version: "1".to_string(),
194 created_at: Utc::now(),
195 model_full_name,
196 claude_cli_version,
197 }
198 }
199
200 #[must_use]
203 pub fn detect_drift(&self, current: &RunContext) -> Option<LockDrift> {
204 let mut drift = LockDrift {
205 model_full_name: None,
206 claude_cli_version: None,
207 schema_version: None,
208 };
209
210 if self.model_full_name != current.model_full_name {
212 drift.model_full_name = Some(DriftPair {
213 locked: self.model_full_name.clone(),
214 current: current.model_full_name.clone(),
215 });
216 }
217
218 if self.claude_cli_version != current.claude_cli_version {
220 drift.claude_cli_version = Some(DriftPair {
221 locked: self.claude_cli_version.clone(),
222 current: current.claude_cli_version.clone(),
223 });
224 }
225
226 if self.schema_version != current.schema_version {
228 drift.schema_version = Some(DriftPair {
229 locked: self.schema_version.clone(),
230 current: current.schema_version.clone(),
231 });
232 }
233
234 if drift.model_full_name.is_none()
236 && drift.claude_cli_version.is_none()
237 && drift.schema_version.is_none()
238 {
239 None
240 } else {
241 Some(drift)
242 }
243 }
244
245 pub fn load(spec_id: &str) -> Result<Option<Self>, io::Error> {
247 let lock_path = Self::get_lock_path(spec_id);
248
249 if !lock_path.exists() {
250 return Ok(None);
251 }
252
253 let content = fs::read_to_string(&lock_path)?;
254 let lock: Self = serde_json::from_str(&content)
255 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
256
257 Ok(Some(lock))
258 }
259
260 pub fn save(&self, spec_id: &str) -> Result<(), io::Error> {
262 let lock_path = Self::get_lock_path_utf8(spec_id);
263
264 let json = serde_json::to_string_pretty(self)
265 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
266
267 write_file_atomic(&lock_path, &json).map_err(io::Error::other)?;
268
269 Ok(())
270 }
271
272 fn get_lock_path(spec_id: &str) -> PathBuf {
274 Self::get_lock_path_utf8(spec_id).into_std_path_buf()
275 }
276
277 fn get_lock_path_utf8(spec_id: &str) -> Utf8PathBuf {
279 spec_root(spec_id).join("lock.json")
280 }
281}
282
283pub struct FileLock {
285 lock_path: PathBuf,
287 _fd_lock: Option<Box<RwLock<fs::File>>>,
289 lock_info: LockInfo,
291}
292
293impl FileLock {
294 pub fn acquire(
309 spec_id: &str,
310 force: bool,
311 ttl_seconds: Option<u64>,
312 ) -> Result<Self, LockError> {
313 let spec_root = spec_root(spec_id);
314
315 ensure_dir_all(&spec_root).map_err(|e| LockError::AcquisitionFailed {
317 reason: format!("Failed to create spec directory: {e}"),
318 })?;
319
320 let lock_path = Self::get_lock_path(spec_id);
321 let ttl = ttl_seconds.unwrap_or(DEFAULT_STALE_THRESHOLD_SECS);
322
323 Self::acquire_with_retry(spec_id, &lock_path, force, ttl, 3)
325 }
326
327 fn acquire_with_retry(
329 spec_id: &str,
330 lock_path: &Path,
331 force: bool,
332 ttl_seconds: u64,
333 max_retries: u32,
334 ) -> Result<Self, LockError> {
335 for attempt in 0..max_retries {
336 let lock_info = LockInfo {
338 pid: process::id(),
339 start_time: Self::get_process_start_time()?,
340 created_at: SystemTime::now()
341 .duration_since(UNIX_EPOCH)
342 .unwrap()
343 .as_secs(),
344 spec_id: spec_id.to_string(),
345 xchecker_version: env!("CARGO_PKG_VERSION").to_string(),
346 };
347
348 match fs::OpenOptions::new()
350 .create_new(true)
351 .write(true)
352 .open(lock_path)
353 {
354 Ok(lock_file) => {
355 return Self::finalize_lock(lock_path.to_path_buf(), lock_file, lock_info);
357 }
358 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
359 match Self::check_existing_lock(lock_path, spec_id, force, ttl_seconds) {
361 Ok(()) => {
362 match Self::try_remove_stale_lock(lock_path, spec_id) {
364 Ok(()) => {
365 match fs::OpenOptions::new()
367 .create_new(true)
368 .write(true)
369 .open(lock_path)
370 {
371 Ok(lock_file) => {
372 return Self::finalize_lock(
373 lock_path.to_path_buf(),
374 lock_file,
375 lock_info,
376 );
377 }
378 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
379 if attempt + 1 < max_retries {
381 let base_delay_ms = 10u64
382 .saturating_mul(2u64.saturating_pow(attempt));
383 let jitter_ms = ((attempt as u64)
386 .wrapping_mul(3)
387 .wrapping_add((process::id() as u64) % 7))
388 % 7;
389 let delay_ms =
390 base_delay_ms.saturating_add(jitter_ms);
391 std::thread::sleep(
392 std::time::Duration::from_millis(
393 delay_ms.min(100),
394 ),
395 );
396 continue;
397 }
398 return Err(LockError::AcquisitionFailed {
400 reason: format!(
401 "Max retries exceeded for spec '{}': another process acquired lock immediately after stale removal",
402 spec_id
403 ),
404 });
405 }
406 Err(e) => {
407 return Err(LockError::AcquisitionFailed {
408 reason: format!(
409 "Failed to create lock for spec '{}' after removing stale lock: {e}",
410 spec_id
411 ),
412 });
413 }
414 }
415 }
416 Err(e) => {
417 return Err(e);
419 }
420 }
421 }
422 Err(e) => return Err(e),
423 }
424 }
425 Err(e) => {
426 return Err(LockError::AcquisitionFailed {
427 reason: format!(
428 "Failed to create lock file for spec '{}' at '{}': {e}",
429 spec_id,
430 lock_path.display()
431 ),
432 });
433 }
434 }
435 }
436
437 Err(LockError::AcquisitionFailed {
440 reason: format!(
441 "Max retries ({}) exceeded for lock acquisition on spec '{}'",
442 max_retries, spec_id
443 ),
444 })
445 }
446
447 fn finalize_lock(
449 lock_path: PathBuf,
450 lock_file: fs::File,
451 lock_info: LockInfo,
452 ) -> Result<Self, LockError> {
453 let lock_json =
454 serde_json::to_string_pretty(&lock_info).map_err(|e| LockError::AcquisitionFailed {
455 reason: format!(
456 "Failed to serialize lock info for spec '{}': {e}",
457 lock_info.spec_id
458 ),
459 })?;
460
461 let mut rw_lock = Box::new(RwLock::new(lock_file));
463 {
464 let fd_lock = rw_lock
465 .try_write()
466 .map_err(|_e| LockError::ConcurrentExecution {
467 spec_id: lock_info.spec_id.clone(),
468 pid: 0, created_ago: "unknown".to_string(),
470 })?;
471
472 let mut file_ref = &*fd_lock;
474 file_ref
475 .write_all(lock_json.as_bytes())
476 .map_err(|e| LockError::AcquisitionFailed {
477 reason: format!(
478 "Failed to write lock info for spec '{}': {e}",
479 lock_info.spec_id
480 ),
481 })?;
482 file_ref.flush().map_err(|e| LockError::AcquisitionFailed {
483 reason: format!(
484 "Failed to flush lock file for spec '{}': {e}",
485 lock_info.spec_id
486 ),
487 })?;
488
489 file_ref
491 .sync_all()
492 .map_err(|e| LockError::AcquisitionFailed {
493 reason: format!(
494 "Failed to sync lock file for spec '{}': {e}",
495 lock_info.spec_id
496 ),
497 })?;
498 }
499
500 Ok(Self {
501 lock_path,
502 _fd_lock: Some(rw_lock),
503 lock_info,
504 })
505 }
506
507 fn try_remove_stale_lock(lock_path: &Path, spec_id: &str) -> Result<(), LockError> {
513 let timestamp = SystemTime::now()
514 .duration_since(UNIX_EPOCH)
515 .unwrap()
516 .as_millis();
517 let pid = process::id();
518 let stale_path = lock_path.with_extension(format!("stale.{timestamp}.{pid}"));
519
520 match fs::rename(lock_path, &stale_path) {
522 Ok(()) => {
523 let _ = fs::remove_file(&stale_path);
525 Ok(())
526 }
527 Err(e) if e.kind() == io::ErrorKind::NotFound => {
528 Ok(())
530 }
531 Err(e) => Err(LockError::AcquisitionFailed {
532 reason: format!("Failed to rename stale lock for spec '{spec_id}': {e}"),
533 }),
534 }
535 }
536
537 #[must_use]
539 #[allow(dead_code)] pub fn exists(spec_id: &str) -> bool {
541 let lock_path = Self::get_lock_path(spec_id);
542 lock_path.exists()
543 }
544
545 pub fn get_lock_info(spec_id: &str) -> Result<Option<LockInfo>, LockError> {
547 let lock_path = Self::get_lock_path(spec_id);
548
549 if !lock_path.exists() {
550 return Ok(None);
551 }
552
553 let lock_content =
554 fs::read_to_string(&lock_path).map_err(|e| LockError::CorruptedLock {
555 reason: format!("Failed to read lock file: {e}"),
556 })?;
557
558 let lock_info: LockInfo =
559 serde_json::from_str(&lock_content).map_err(|e| LockError::CorruptedLock {
560 reason: format!("Failed to parse lock file: {e}"),
561 })?;
562
563 Ok(Some(lock_info))
564 }
565
566 #[allow(dead_code)] pub fn release(mut self) -> Result<(), LockError> {
569 self._fd_lock.take();
571
572 if self.lock_path.exists() {
574 fs::remove_file(&self.lock_path).map_err(|e| LockError::ReleaseFailed {
575 reason: format!("Failed to remove lock file: {e}"),
576 })?;
577 }
578
579 Ok(())
580 }
581
582 #[must_use]
584 #[allow(dead_code)] pub fn spec_id(&self) -> &str {
586 &self.lock_info.spec_id
587 }
588
589 #[must_use]
591 #[allow(dead_code)] pub const fn lock_info(&self) -> &LockInfo {
593 &self.lock_info
594 }
595
596 fn get_lock_path(spec_id: &str) -> PathBuf {
598 spec_root(spec_id).as_std_path().join(".lock")
599 }
600
601 fn check_existing_lock(
606 lock_path: &Path,
607 spec_id: &str,
608 force: bool,
609 ttl_seconds: u64,
610 ) -> Result<(), LockError> {
611 const MAX_READ_RETRIES: u32 = 3;
613 const READ_RETRY_DELAY_MS: u64 = 10;
614
615 for attempt in 0..MAX_READ_RETRIES {
616 let lock_content = match fs::read_to_string(lock_path) {
617 Ok(content) => content,
618 Err(e) if e.kind() == io::ErrorKind::NotFound => {
619 return Ok(());
622 }
623 Err(e) => {
624 if attempt + 1 < MAX_READ_RETRIES {
626 std::thread::sleep(std::time::Duration::from_millis(READ_RETRY_DELAY_MS));
627 continue;
628 }
629 return Err(LockError::CorruptedLock {
630 reason: format!("Failed to read existing lock for spec '{}': {e}", spec_id),
631 });
632 }
633 };
634
635 if lock_content.is_empty() {
637 if attempt + 1 < MAX_READ_RETRIES {
638 std::thread::sleep(std::time::Duration::from_millis(READ_RETRY_DELAY_MS));
639 continue;
640 }
641 return Err(LockError::CorruptedLock {
642 reason: format!(
643 "Lock file for spec '{}' is empty (may be initializing)",
644 spec_id
645 ),
646 });
647 }
648
649 match serde_json::from_str::<LockInfo>(&lock_content) {
651 Ok(existing_lock) => {
652 return Self::validate_existing_lock(
654 &existing_lock,
655 spec_id,
656 force,
657 ttl_seconds,
658 );
659 }
660 Err(e) => {
661 let is_likely_incomplete = e.is_eof()
663 || lock_content.trim().is_empty()
664 || (lock_content.starts_with('{') && !lock_content.contains('}'));
665
666 if is_likely_incomplete && attempt + 1 < MAX_READ_RETRIES {
668 std::thread::sleep(std::time::Duration::from_millis(READ_RETRY_DELAY_MS));
669 continue;
670 }
671
672 return Err(LockError::CorruptedLock {
673 reason: format!(
674 "Failed to parse existing lock for spec '{}': {e}",
675 spec_id
676 ),
677 });
678 }
679 }
680 }
681 unreachable!("check_existing_lock loop exhausted without returning")
684 }
685
686 fn validate_existing_lock(
688 existing_lock: &LockInfo,
689 spec_id: &str,
690 force: bool,
691 ttl_seconds: u64,
692 ) -> Result<(), LockError> {
693 let now_secs = SystemTime::now()
695 .duration_since(UNIX_EPOCH)
696 .unwrap()
697 .as_secs();
698
699 let lock_age = now_secs.saturating_sub(existing_lock.created_at);
700
701 let is_stale = lock_age > ttl_seconds;
702
703 if Self::is_process_running(existing_lock.pid) {
705 if !force {
707 let created_ago = Self::format_duration_since(existing_lock.created_at);
708 return Err(LockError::ConcurrentExecution {
709 spec_id: spec_id.to_string(),
710 pid: existing_lock.pid,
711 created_ago,
712 });
713 }
714 return Ok(());
716 }
717
718 if is_stale {
720 if force {
721 Ok(())
723 } else {
724 Err(LockError::StaleLock {
725 spec_id: spec_id.to_string(),
726 pid: existing_lock.pid,
727 age_secs: lock_age,
728 })
729 }
730 } else {
731 if force {
733 Ok(())
734 } else {
735 let created_ago = Self::format_duration_since(existing_lock.created_at);
736 Err(LockError::ConcurrentExecution {
737 spec_id: spec_id.to_string(),
738 pid: existing_lock.pid,
739 created_ago,
740 })
741 }
742 }
743 }
744
745 fn is_process_running(pid: u32) -> bool {
747 #[cfg(unix)]
748 {
749 let rc = unsafe { libc::kill(pid as i32, 0) };
754 if rc == 0 {
755 true
756 } else {
757 matches!(
759 io::Error::last_os_error().raw_os_error(),
760 Some(code) if code == libc::EPERM
761 )
762 }
763 }
764
765 #[cfg(windows)]
766 {
767 use winapi::um::handleapi::CloseHandle;
769 use winapi::um::minwinbase::STILL_ACTIVE;
770 use winapi::um::processthreadsapi::{GetExitCodeProcess, OpenProcess};
771 use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION;
772
773 unsafe {
774 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
777 if handle.is_null() {
778 return false;
779 }
780
781 let mut exit_code: u32 = 0;
783 let result = GetExitCodeProcess(handle, &mut exit_code);
784
785 if result == 0 {
787 CloseHandle(handle);
788 return false;
789 }
790
791 CloseHandle(handle);
793 exit_code == STILL_ACTIVE
794 }
795 }
796
797 #[cfg(not(any(unix, windows)))]
798 {
799 true
801 }
802 }
803
804 fn get_process_start_time() -> Result<u64, LockError> {
806 Ok(SystemTime::now()
809 .duration_since(UNIX_EPOCH)
810 .unwrap()
811 .as_secs())
812 }
813
814 fn format_duration_since(timestamp: u64) -> String {
816 let now = SystemTime::now()
817 .duration_since(UNIX_EPOCH)
818 .unwrap()
819 .as_secs();
820
821 let duration = now.saturating_sub(timestamp);
822
823 if duration < 60 {
824 format!("{duration}s")
825 } else if duration < 3600 {
826 format!("{}m", duration / 60)
827 } else if duration < 86400 {
828 format!("{}h", duration / 3600)
829 } else {
830 format!("{}d", duration / 86400)
831 }
832 }
833}
834
835impl std::fmt::Debug for FileLock {
836 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
837 f.debug_struct("FileLock")
838 .field("lock_path", &self.lock_path)
839 .field("lock_info", &self.lock_info)
840 .field("_fd_lock", &"<RwLock>")
841 .finish()
842 }
843}
844
845impl Drop for FileLock {
846 fn drop(&mut self) {
848 self._fd_lock.take();
850
851 if self.lock_path.exists() {
853 let _ = fs::remove_file(&self.lock_path);
854 }
855 }
856}
857
858pub mod utils {
860 use super::{
861 DEFAULT_STALE_THRESHOLD_SECS, FileLock, LockError, Result, SystemTime, UNIX_EPOCH, fs,
862 };
863
864 pub fn can_clean(
866 spec_id: &str,
867 force: bool,
868 ttl_seconds: Option<u64>,
869 ) -> Result<(), LockError> {
870 let ttl = ttl_seconds.unwrap_or(DEFAULT_STALE_THRESHOLD_SECS);
871 if let Some(lock_info) = FileLock::get_lock_info(spec_id)? {
872 if FileLock::is_process_running(lock_info.pid) {
873 if force {
874 return Ok(());
876 }
877 return Err(LockError::ConcurrentExecution {
878 spec_id: spec_id.to_string(),
879 pid: lock_info.pid,
880 created_ago: FileLock::format_duration_since(lock_info.created_at),
881 });
882 }
883
884 if !force {
886 let lock_age = SystemTime::now()
887 .duration_since(UNIX_EPOCH)
888 .unwrap()
889 .as_secs()
890 - lock_info.created_at;
891
892 if lock_age <= ttl {
893 return Err(LockError::StaleLock {
894 spec_id: spec_id.to_string(),
895 pid: lock_info.pid,
896 age_secs: lock_age,
897 });
898 }
899 }
900 }
901
902 Ok(())
903 }
904
905 #[allow(dead_code)] pub fn force_remove_lock(spec_id: &str) -> Result<(), LockError> {
908 let lock_path = FileLock::get_lock_path(spec_id);
909
910 if lock_path.exists() {
911 fs::remove_file(&lock_path).map_err(|e| LockError::ReleaseFailed {
912 reason: format!("Failed to force remove lock: {e}"),
913 })?;
914 }
915
916 Ok(())
917 }
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 use std::fs;
925 use tempfile::TempDir;
926
927 fn setup_test_env() -> TempDir {
928 with_isolated_home()
929 }
930
931 #[test]
932 fn test_lock_acquisition_and_release() {
933 let _temp_dir = setup_test_env();
934
935 let spec_id = "test-spec-acquisition-123";
936
937 let lock = FileLock::acquire(spec_id, false, None).unwrap();
939 assert_eq!(lock.spec_id(), spec_id);
940
941 let lock_path = FileLock::get_lock_path(spec_id);
943 assert!(
944 lock_path.exists(),
945 "Lock file should exist at: {lock_path:?}"
946 );
947 assert!(FileLock::exists(spec_id));
948
949 let result = FileLock::acquire(spec_id, false, None);
951 assert!(result.is_err());
952
953 lock.release().unwrap();
955 assert!(!FileLock::exists(spec_id));
956
957 let _lock2 = FileLock::acquire(spec_id, false, None).unwrap();
959 }
960
961 #[test]
962 fn test_lock_info_serialization() {
963 let _temp_dir = setup_test_env();
964
965 let spec_id = "test-spec-serialization-456";
966 let _lock = FileLock::acquire(spec_id, false, None).unwrap();
967
968 let lock_info = FileLock::get_lock_info(spec_id).unwrap().unwrap();
970 assert_eq!(lock_info.spec_id, spec_id);
971 assert_eq!(lock_info.pid, process::id());
972 assert!(!lock_info.xchecker_version.is_empty());
973 }
974
975 #[test]
976 fn test_automatic_cleanup_on_drop() {
977 let _temp_dir = setup_test_env();
978
979 let spec_id = "test-spec-cleanup-789";
980
981 {
982 let _lock = FileLock::acquire(spec_id, false, None).unwrap();
983 assert!(FileLock::exists(spec_id));
984 } assert!(!FileLock::exists(spec_id));
988 }
989
990 #[test]
991 fn test_force_override_stale_lock() {
992 let _temp_dir = setup_test_env();
993
994 let spec_id = "test-spec-stale-override";
995
996 let lock_path = FileLock::get_lock_path(spec_id);
998 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
999
1000 let old_lock_info = LockInfo {
1001 pid: 99999, start_time: 0,
1003 created_at: 0, spec_id: spec_id.to_string(),
1005 xchecker_version: "0.1.0".to_string(),
1006 };
1007
1008 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1009 fs::write(&lock_path, lock_json).unwrap();
1010
1011 let result = FileLock::acquire(spec_id, false, None);
1013 assert!(result.is_err());
1014 assert!(matches!(result.unwrap_err(), LockError::StaleLock { .. }));
1015
1016 let lock = FileLock::acquire(spec_id, true, None).unwrap();
1018 assert_eq!(lock.spec_id(), spec_id);
1019 }
1020
1021 #[test]
1022 fn test_clean_operation_checks() {
1023 let _temp_dir = setup_test_env();
1024
1025 let spec_id = "test-spec-clean-checks";
1026
1027 assert!(utils::can_clean(spec_id, false, None).is_ok());
1029
1030 let _lock = FileLock::acquire(spec_id, false, None).unwrap();
1032
1033 let result = utils::can_clean(spec_id, false, None);
1035 assert!(result.is_err());
1036 assert!(matches!(
1037 result.unwrap_err(),
1038 LockError::ConcurrentExecution { .. }
1039 ));
1040
1041 assert!(utils::can_clean(spec_id, true, None).is_ok());
1043 }
1044
1045 #[test]
1046 fn test_lock_path_generation() {
1047 let _temp_dir = setup_test_env();
1048
1049 let spec_id = "my-test-spec";
1050 let expected_path = spec_root(spec_id).as_std_path().join(".lock");
1051 assert_eq!(FileLock::get_lock_path(spec_id), expected_path);
1052 }
1053
1054 #[test]
1055 fn test_duration_formatting() {
1056 assert_eq!(
1057 FileLock::format_duration_since(
1058 SystemTime::now()
1059 .duration_since(UNIX_EPOCH)
1060 .unwrap()
1061 .as_secs()
1062 - 30
1063 ),
1064 "30s"
1065 );
1066 assert_eq!(
1067 FileLock::format_duration_since(
1068 SystemTime::now()
1069 .duration_since(UNIX_EPOCH)
1070 .unwrap()
1071 .as_secs()
1072 - 120
1073 ),
1074 "2m"
1075 );
1076 assert_eq!(
1077 FileLock::format_duration_since(
1078 SystemTime::now()
1079 .duration_since(UNIX_EPOCH)
1080 .unwrap()
1081 .as_secs()
1082 - 7200
1083 ),
1084 "2h"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_xchecker_lock_creation() {
1090 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1091
1092 assert_eq!(lock.schema_version, "1");
1093 assert_eq!(lock.model_full_name, "haiku");
1094 assert_eq!(lock.claude_cli_version, "0.8.1");
1095 }
1096
1097 #[test]
1098 fn test_xchecker_lock_no_drift() {
1099 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1100
1101 let context = RunContext {
1102 model_full_name: "haiku".to_string(),
1103 claude_cli_version: "0.8.1".to_string(),
1104 schema_version: "1".to_string(),
1105 };
1106
1107 let drift = lock.detect_drift(&context);
1108 assert!(drift.is_none(), "Expected no drift when values match");
1109 }
1110
1111 #[test]
1112 fn test_xchecker_lock_model_drift() {
1113 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1114
1115 let context = RunContext {
1116 model_full_name: "sonnet".to_string(),
1117 claude_cli_version: "0.8.1".to_string(),
1118 schema_version: "1".to_string(),
1119 };
1120
1121 let drift = lock.detect_drift(&context).expect("Expected drift");
1122 assert!(drift.model_full_name.is_some());
1123 assert!(drift.claude_cli_version.is_none());
1124 assert!(drift.schema_version.is_none());
1125
1126 let model_drift = drift.model_full_name.unwrap();
1127 assert_eq!(model_drift.locked, "haiku");
1128 assert_eq!(model_drift.current, "sonnet");
1129 }
1130
1131 #[test]
1132 fn test_xchecker_lock_cli_version_drift() {
1133 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1134
1135 let context = RunContext {
1136 model_full_name: "haiku".to_string(),
1137 claude_cli_version: "0.9.0".to_string(),
1138 schema_version: "1".to_string(),
1139 };
1140
1141 let drift = lock.detect_drift(&context).expect("Expected drift");
1142 assert!(drift.model_full_name.is_none());
1143 assert!(drift.claude_cli_version.is_some());
1144 assert!(drift.schema_version.is_none());
1145
1146 let cli_drift = drift.claude_cli_version.unwrap();
1147 assert_eq!(cli_drift.locked, "0.8.1");
1148 assert_eq!(cli_drift.current, "0.9.0");
1149 }
1150
1151 #[test]
1152 fn test_xchecker_lock_schema_version_drift() {
1153 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1154
1155 let context = RunContext {
1156 model_full_name: "haiku".to_string(),
1157 claude_cli_version: "0.8.1".to_string(),
1158 schema_version: "2".to_string(),
1159 };
1160
1161 let drift = lock.detect_drift(&context).expect("Expected drift");
1162 assert!(drift.model_full_name.is_none());
1163 assert!(drift.claude_cli_version.is_none());
1164 assert!(drift.schema_version.is_some());
1165
1166 let schema_drift = drift.schema_version.unwrap();
1167 assert_eq!(schema_drift.locked, "1");
1168 assert_eq!(schema_drift.current, "2");
1169 }
1170
1171 #[test]
1172 fn test_xchecker_lock_multiple_drift() {
1173 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1174
1175 let context = RunContext {
1176 model_full_name: "sonnet".to_string(),
1177 claude_cli_version: "0.9.0".to_string(),
1178 schema_version: "2".to_string(),
1179 };
1180
1181 let drift = lock.detect_drift(&context).expect("Expected drift");
1182 assert!(drift.model_full_name.is_some());
1183 assert!(drift.claude_cli_version.is_some());
1184 assert!(drift.schema_version.is_some());
1185 }
1186
1187 #[test]
1188 fn test_xchecker_lock_save_and_load() {
1189 let _temp_dir = setup_test_env();
1190
1191 let spec_id = "test-spec-lockfile";
1192 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1193
1194 lock.save(spec_id).expect("Failed to save lockfile");
1196
1197 let loaded = XCheckerLock::load(spec_id)
1199 .expect("Failed to load lockfile")
1200 .expect("Lockfile should exist");
1201
1202 assert_eq!(loaded.schema_version, lock.schema_version);
1203 assert_eq!(loaded.model_full_name, lock.model_full_name);
1204 assert_eq!(loaded.claude_cli_version, lock.claude_cli_version);
1205 }
1206
1207 #[test]
1208 fn test_xchecker_lock_load_nonexistent() {
1209 let _temp_dir = setup_test_env();
1210
1211 let spec_id = "nonexistent-spec";
1212 let loaded = XCheckerLock::load(spec_id).expect("Load should succeed");
1213
1214 assert!(
1215 loaded.is_none(),
1216 "Should return None for nonexistent lockfile"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_xchecker_lock_corrupted_file() {
1222 let _temp_dir = setup_test_env();
1223
1224 let spec_id = "test-spec-corrupted";
1225 let lock_path = XCheckerLock::get_lock_path(spec_id);
1226
1227 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1229
1230 fs::write(&lock_path, "{ invalid json }").unwrap();
1232
1233 let result = XCheckerLock::load(spec_id);
1235 assert!(result.is_err(), "Should fail to load corrupted lockfile");
1236 }
1237
1238 #[test]
1239 fn test_xchecker_lock_empty_file() {
1240 let _temp_dir = setup_test_env();
1241
1242 let spec_id = "test-spec-empty";
1243 let lock_path = XCheckerLock::get_lock_path(spec_id);
1244
1245 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1247
1248 fs::write(&lock_path, "").unwrap();
1250
1251 let result = XCheckerLock::load(spec_id);
1253 assert!(result.is_err(), "Should fail to load empty lockfile");
1254 }
1255
1256 #[test]
1257 fn test_xchecker_lock_overwrite_existing() {
1258 let _temp_dir = setup_test_env();
1259
1260 let spec_id = "test-spec-overwrite";
1261
1262 let lock1 = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1264 lock1.save(spec_id).unwrap();
1265
1266 let lock2 = XCheckerLock::new("sonnet".to_string(), "0.9.0".to_string());
1268 lock2.save(spec_id).unwrap();
1269
1270 let loaded = XCheckerLock::load(spec_id)
1272 .expect("Failed to load lockfile")
1273 .expect("Lockfile should exist");
1274
1275 assert_eq!(loaded.model_full_name, "sonnet");
1276 assert_eq!(loaded.claude_cli_version, "0.9.0");
1277 }
1278
1279 #[test]
1280 fn test_xchecker_lock_drift_all_fields_match() {
1281 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1282
1283 let context = RunContext {
1284 model_full_name: "haiku".to_string(),
1285 claude_cli_version: "0.8.1".to_string(),
1286 schema_version: "1".to_string(),
1287 };
1288
1289 let drift = lock.detect_drift(&context);
1290 assert!(
1291 drift.is_none(),
1292 "Should return None when all fields match exactly"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_xchecker_lock_drift_case_sensitive() {
1298 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1299
1300 let context = RunContext {
1302 model_full_name: "Claude-3-5-Sonnet-20241022".to_string(),
1303 claude_cli_version: "0.8.1".to_string(),
1304 schema_version: "1".to_string(),
1305 };
1306
1307 let drift = lock.detect_drift(&context);
1308 assert!(drift.is_some(), "Drift detection should be case-sensitive");
1309 assert!(drift.unwrap().model_full_name.is_some());
1310 }
1311
1312 #[test]
1313 fn test_xchecker_lock_drift_whitespace_sensitive() {
1314 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1315
1316 let context = RunContext {
1318 model_full_name: "haiku ".to_string(),
1319 claude_cli_version: "0.8.1".to_string(),
1320 schema_version: "1".to_string(),
1321 };
1322
1323 let drift = lock.detect_drift(&context);
1324 assert!(
1325 drift.is_some(),
1326 "Drift detection should be whitespace-sensitive"
1327 );
1328 assert!(drift.unwrap().model_full_name.is_some());
1329 }
1330
1331 #[test]
1332 fn test_xchecker_lock_save_creates_directory() {
1333 let _temp_dir = setup_test_env();
1334
1335 let spec_id = "test-spec-new-dir";
1336 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1337
1338 let lock_path = XCheckerLock::get_lock_path(spec_id);
1340 assert!(!lock_path.exists());
1341
1342 lock.save(spec_id).unwrap();
1344
1345 assert!(lock_path.exists());
1347 assert!(lock_path.parent().unwrap().exists());
1348 }
1349
1350 #[test]
1351 fn test_xchecker_lock_json_format() {
1352 let _temp_dir = setup_test_env();
1353
1354 let spec_id = "test-spec-json-format";
1355 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1356
1357 lock.save(spec_id).unwrap();
1358
1359 let lock_path = XCheckerLock::get_lock_path(spec_id);
1361 let json_content = fs::read_to_string(&lock_path).unwrap();
1362
1363 let parsed: serde_json::Value =
1365 serde_json::from_str(&json_content).expect("Should be valid JSON");
1366
1367 assert!(parsed.get("schema_version").is_some());
1369 assert!(parsed.get("created_at").is_some());
1370 assert!(parsed.get("model_full_name").is_some());
1371 assert!(parsed.get("claude_cli_version").is_some());
1372
1373 assert_eq!(parsed["schema_version"], "1");
1375 assert_eq!(parsed["model_full_name"], "haiku");
1376 assert_eq!(parsed["claude_cli_version"], "0.8.1");
1377 }
1378
1379 #[test]
1380 fn test_xchecker_lock_timestamp_format() {
1381 let lock = XCheckerLock::new("haiku".to_string(), "0.8.1".to_string());
1382
1383 let timestamp_str = lock.created_at.to_rfc3339();
1385 assert!(!timestamp_str.is_empty());
1386
1387 let parsed = DateTime::parse_from_rfc3339(×tamp_str);
1389 assert!(parsed.is_ok(), "Should be parseable RFC3339 timestamp");
1390 }
1391
1392 #[test]
1393 fn test_configurable_ttl_parameter() {
1394 let _temp_dir = setup_test_env();
1395
1396 let spec_id = "test-spec-configurable-ttl";
1397
1398 let lock_path = FileLock::get_lock_path(spec_id);
1400 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1401
1402 let two_minutes_ago = SystemTime::now()
1403 .duration_since(UNIX_EPOCH)
1404 .unwrap()
1405 .as_secs()
1406 - 120;
1407
1408 let old_lock_info = LockInfo {
1409 pid: 99999, start_time: 0,
1411 created_at: two_minutes_ago,
1412 spec_id: spec_id.to_string(),
1413 xchecker_version: "0.1.0".to_string(),
1414 };
1415
1416 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1417 fs::write(&lock_path, lock_json).unwrap();
1418
1419 let result = FileLock::acquire(spec_id, false, Some(60));
1421 assert!(result.is_err());
1422 assert!(matches!(result.unwrap_err(), LockError::StaleLock { .. }));
1423
1424 let result = FileLock::acquire(spec_id, false, Some(180));
1427 assert!(result.is_err());
1428
1429 let lock = FileLock::acquire(spec_id, true, Some(60)).unwrap();
1431 assert_eq!(lock.spec_id(), spec_id);
1432 }
1433
1434 #[test]
1435 fn test_stale_lock_detection_by_age() {
1436 let _temp_dir = setup_test_env();
1437
1438 let spec_id = "test-spec-stale-by-age";
1439
1440 let lock_path = FileLock::get_lock_path(spec_id);
1442 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1443
1444 let two_hours_ago = SystemTime::now()
1445 .duration_since(UNIX_EPOCH)
1446 .unwrap()
1447 .as_secs()
1448 - 7200;
1449
1450 let old_lock_info = LockInfo {
1451 pid: 99999, start_time: 0,
1453 created_at: two_hours_ago,
1454 spec_id: spec_id.to_string(),
1455 xchecker_version: "0.1.0".to_string(),
1456 };
1457
1458 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1459 fs::write(&lock_path, lock_json).unwrap();
1460
1461 let result = FileLock::acquire(spec_id, false, None);
1463 assert!(result.is_err());
1464 assert!(matches!(result.unwrap_err(), LockError::StaleLock { .. }));
1465
1466 let lock = FileLock::acquire(spec_id, true, None).unwrap();
1468 assert_eq!(lock.spec_id(), spec_id);
1469 }
1470
1471 #[test]
1472 fn test_stale_lock_detection_by_dead_process() {
1473 let _temp_dir = setup_test_env();
1474
1475 let spec_id = "test-spec-stale-by-pid";
1476
1477 let lock_path = FileLock::get_lock_path(spec_id);
1479 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1480
1481 let recent_time = SystemTime::now()
1482 .duration_since(UNIX_EPOCH)
1483 .unwrap()
1484 .as_secs()
1485 - 60; let old_lock_info = LockInfo {
1488 pid: 99999, start_time: 0,
1490 created_at: recent_time,
1491 spec_id: spec_id.to_string(),
1492 xchecker_version: "0.1.0".to_string(),
1493 };
1494
1495 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1496 fs::write(&lock_path, lock_json).unwrap();
1497
1498 let result = FileLock::acquire(spec_id, false, None);
1500 assert!(result.is_err());
1501
1502 let lock = FileLock::acquire(spec_id, true, None).unwrap();
1504 assert_eq!(lock.spec_id(), spec_id);
1505 }
1506
1507 #[test]
1508 fn test_concurrent_execution_detection() {
1509 let _temp_dir = setup_test_env();
1510
1511 let spec_id = "test-spec-concurrent";
1512
1513 let _lock1 = FileLock::acquire(spec_id, false, None).unwrap();
1515
1516 let result = FileLock::acquire(spec_id, false, None);
1518 assert!(result.is_err());
1519 assert!(matches!(
1520 result.unwrap_err(),
1521 LockError::ConcurrentExecution { .. }
1522 ));
1523
1524 let result = FileLock::acquire(spec_id, true, None);
1526 assert!(result.is_ok());
1527 }
1528
1529 #[test]
1530 fn test_lock_release_on_normal_exit() {
1531 let _temp_dir = setup_test_env();
1532
1533 let spec_id = "test-spec-normal-exit";
1534
1535 let lock = FileLock::acquire(spec_id, false, None).unwrap();
1537 assert!(FileLock::exists(spec_id));
1538
1539 lock.release().unwrap();
1541
1542 assert!(!FileLock::exists(spec_id));
1544
1545 let _lock2 = FileLock::acquire(spec_id, false, None).unwrap();
1547 }
1548
1549 #[test]
1550 fn test_lock_cleanup_on_panic() {
1551 let _temp_dir = setup_test_env();
1552
1553 let spec_id = "test-spec-panic-cleanup";
1554
1555 {
1556 let _lock = FileLock::acquire(spec_id, false, None).unwrap();
1557 assert!(FileLock::exists(spec_id));
1558 } assert!(!FileLock::exists(spec_id));
1562 }
1563
1564 #[test]
1565 fn test_force_flag_breaks_stale_lock() {
1566 let _temp_dir = setup_test_env();
1567
1568 let spec_id = "test-spec-force-break";
1569
1570 let lock_path = FileLock::get_lock_path(spec_id);
1572 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1573
1574 let old_lock_info = LockInfo {
1575 pid: 99999,
1576 start_time: 0,
1577 created_at: 0,
1578 spec_id: spec_id.to_string(),
1579 xchecker_version: "0.1.0".to_string(),
1580 };
1581
1582 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1583 fs::write(&lock_path, lock_json).unwrap();
1584
1585 let result = FileLock::acquire(spec_id, false, None);
1587 assert!(result.is_err());
1588
1589 let lock = FileLock::acquire(spec_id, true, None).unwrap();
1591 assert_eq!(lock.spec_id(), spec_id);
1592
1593 let new_lock_info = FileLock::get_lock_info(spec_id).unwrap().unwrap();
1595 assert_eq!(new_lock_info.pid, process::id());
1596 }
1597
1598 #[test]
1599 fn test_lock_info_with_invalid_pid() {
1600 let _temp_dir = setup_test_env();
1601
1602 let spec_id = "test-spec-invalid-pid";
1603 let lock_path = FileLock::get_lock_path(spec_id);
1604 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1605
1606 let invalid_lock_info = LockInfo {
1608 pid: 0,
1609 start_time: SystemTime::now()
1610 .duration_since(UNIX_EPOCH)
1611 .unwrap()
1612 .as_secs(),
1613 created_at: SystemTime::now()
1614 .duration_since(UNIX_EPOCH)
1615 .unwrap()
1616 .as_secs(),
1617 spec_id: spec_id.to_string(),
1618 xchecker_version: "0.1.0".to_string(),
1619 };
1620
1621 let lock_json = serde_json::to_string_pretty(&invalid_lock_info).unwrap();
1622 fs::write(&lock_path, lock_json).unwrap();
1623
1624 let result = FileLock::acquire(spec_id, true, None);
1626 assert!(result.is_ok());
1627 }
1628
1629 #[test]
1630 fn test_lock_info_with_invalid_host() {
1631 let _temp_dir = setup_test_env();
1632
1633 let spec_id = "test-spec-invalid-host";
1634
1635 let lock = FileLock::acquire(spec_id, false, None).unwrap();
1637 let lock_info = lock.lock_info();
1638
1639 assert_eq!(lock_info.spec_id, spec_id);
1641 assert_eq!(lock_info.pid, process::id());
1642 assert!(!lock_info.xchecker_version.is_empty());
1643 }
1644
1645 #[test]
1646 fn test_lock_with_corrupted_lock_file() {
1647 let _temp_dir = setup_test_env();
1648
1649 let spec_id = "test-spec-corrupted-lock";
1650 let lock_path = FileLock::get_lock_path(spec_id);
1651 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1652
1653 fs::write(&lock_path, "{ invalid json content }").unwrap();
1655
1656 let result = FileLock::acquire(spec_id, false, None);
1658 assert!(result.is_err());
1659 assert!(matches!(
1660 result.unwrap_err(),
1661 LockError::CorruptedLock { .. }
1662 ));
1663
1664 let result_force = FileLock::acquire(spec_id, true, None);
1667 assert!(result_force.is_err());
1668 assert!(matches!(
1669 result_force.unwrap_err(),
1670 LockError::CorruptedLock { .. }
1671 ));
1672 }
1673
1674 #[test]
1675 fn test_lock_with_partial_json() {
1676 let _temp_dir = setup_test_env();
1677
1678 let spec_id = "test-spec-partial-json";
1679 let lock_path = FileLock::get_lock_path(spec_id);
1680 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1681
1682 fs::write(&lock_path, r#"{"pid": 12345, "start_time":"#).unwrap();
1684
1685 let result = FileLock::acquire(spec_id, false, None);
1687 assert!(result.is_err());
1688 assert!(matches!(
1689 result.unwrap_err(),
1690 LockError::CorruptedLock { .. }
1691 ));
1692 }
1693
1694 #[test]
1695 fn test_lock_with_wrong_json_structure() {
1696 let _temp_dir = setup_test_env();
1697
1698 let spec_id = "test-spec-wrong-structure";
1699 let lock_path = FileLock::get_lock_path(spec_id);
1700 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1701
1702 fs::write(&lock_path, r#"["not", "a", "lock", "object"]"#).unwrap();
1704
1705 let result = FileLock::acquire(spec_id, false, None);
1707 assert!(result.is_err());
1708 assert!(matches!(
1709 result.unwrap_err(),
1710 LockError::CorruptedLock { .. }
1711 ));
1712 }
1713
1714 #[test]
1715 fn test_lock_with_missing_required_fields() {
1716 let _temp_dir = setup_test_env();
1717
1718 let spec_id = "test-spec-missing-fields";
1719 let lock_path = FileLock::get_lock_path(spec_id);
1720 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1721
1722 fs::write(&lock_path, r#"{"pid": 12345}"#).unwrap();
1724
1725 let result = FileLock::acquire(spec_id, false, None);
1727 assert!(result.is_err());
1728 assert!(matches!(
1729 result.unwrap_err(),
1730 LockError::CorruptedLock { .. }
1731 ));
1732 }
1733
1734 #[test]
1735 fn test_lock_with_extra_fields() {
1736 let _temp_dir = setup_test_env();
1737
1738 let spec_id = "test-spec-extra-fields";
1739 let lock_path = FileLock::get_lock_path(spec_id);
1740 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1741
1742 let lock_info_json = r#"{
1744 "pid": 12345,
1745 "start_time": 0,
1746 "created_at": 0,
1747 "spec_id": "test-spec-extra-fields",
1748 "xchecker_version": "0.1.0",
1749 "extra_field": "should be ignored"
1750 }"#;
1751
1752 fs::write(&lock_path, lock_info_json).unwrap();
1753
1754 let result = FileLock::acquire(spec_id, true, None);
1756 assert!(result.is_ok());
1757 }
1758
1759 #[test]
1760 fn test_lock_with_very_old_timestamp() {
1761 let _temp_dir = setup_test_env();
1762
1763 let spec_id = "test-spec-very-old";
1764
1765 let lock_path = FileLock::get_lock_path(spec_id);
1767 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1768
1769 let old_lock_info = LockInfo {
1770 pid: 99999,
1771 start_time: 0,
1772 created_at: 0, spec_id: spec_id.to_string(),
1774 xchecker_version: "0.1.0".to_string(),
1775 };
1776
1777 let lock_json = serde_json::to_string_pretty(&old_lock_info).unwrap();
1778 fs::write(&lock_path, lock_json).unwrap();
1779
1780 let result = FileLock::acquire(spec_id, false, None);
1782 assert!(result.is_err());
1783 assert!(matches!(result.unwrap_err(), LockError::StaleLock { .. }));
1784
1785 let lock = FileLock::acquire(spec_id, true, None).unwrap();
1787 assert_eq!(lock.spec_id(), spec_id);
1788 }
1789
1790 #[test]
1791 fn test_lock_with_future_timestamp() {
1792 let _temp_dir = setup_test_env();
1793
1794 let spec_id = "test-spec-future";
1795
1796 let lock_path = FileLock::get_lock_path(spec_id);
1798 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1799
1800 let future_timestamp = SystemTime::now()
1801 .duration_since(UNIX_EPOCH)
1802 .unwrap()
1803 .as_secs()
1804 + 3600; let future_lock_info = LockInfo {
1807 pid: 99999, start_time: future_timestamp,
1809 created_at: future_timestamp,
1810 spec_id: spec_id.to_string(),
1811 xchecker_version: "0.1.0".to_string(),
1812 };
1813
1814 let lock_json = serde_json::to_string_pretty(&future_lock_info).unwrap();
1815 fs::write(&lock_path, lock_json).unwrap();
1816
1817 let result = FileLock::acquire(spec_id, false, None);
1820 assert!(
1822 result.is_ok() || result.is_err(),
1823 "Should handle future timestamp without panic"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_lock_info_with_empty_spec_id() {
1829 let _temp_dir = setup_test_env();
1830
1831 let spec_id = "";
1832
1833 let result = FileLock::acquire(spec_id, false, None);
1835 assert!(
1837 result.is_ok() || result.is_err(),
1838 "Should handle empty spec_id without panic"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_lock_info_with_special_characters_in_spec_id() {
1844 let _temp_dir = setup_test_env();
1845
1846 let spec_id = "test-spec-with-special-@#$%";
1847
1848 let result = FileLock::acquire(spec_id, false, None);
1850 if let Ok(lock) = result {
1852 assert_eq!(lock.spec_id(), spec_id);
1853 }
1854 }
1855
1856 #[test]
1857 fn test_get_lock_info_with_nonexistent_lock() {
1858 let _temp_dir = setup_test_env();
1859
1860 let spec_id = "nonexistent-lock-spec";
1861
1862 let result = FileLock::get_lock_info(spec_id);
1863 assert!(result.is_ok());
1864 assert!(result.unwrap().is_none());
1865 }
1866
1867 #[test]
1868 fn test_get_lock_info_with_corrupted_lock() {
1869 let _temp_dir = setup_test_env();
1870
1871 let spec_id = "corrupted-lock-info-spec";
1872 let lock_path = FileLock::get_lock_path(spec_id);
1873 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1874
1875 fs::write(&lock_path, "not json at all").unwrap();
1877
1878 let result = FileLock::get_lock_info(spec_id);
1879 assert!(result.is_err());
1880 assert!(matches!(
1881 result.unwrap_err(),
1882 LockError::CorruptedLock { .. }
1883 ));
1884 }
1885
1886 #[test]
1887 fn test_xchecker_lock_with_empty_values() {
1888 let lock = XCheckerLock::new(String::new(), String::new());
1889
1890 assert_eq!(lock.schema_version, "1");
1891 assert_eq!(lock.model_full_name, "");
1892 assert_eq!(lock.claude_cli_version, "");
1893 }
1894
1895 #[test]
1896 fn test_xchecker_lock_with_very_long_values() {
1897 let long_model = "a".repeat(1000);
1898 let long_version = "b".repeat(1000);
1899
1900 let lock = XCheckerLock::new(long_model.clone(), long_version.clone());
1901
1902 assert_eq!(lock.model_full_name, long_model);
1903 assert_eq!(lock.claude_cli_version, long_version);
1904 }
1905
1906 #[test]
1907 fn test_xchecker_lock_with_unicode_values() {
1908 let unicode_model = "claude-测试-🚀";
1909 let unicode_version = "版本-1.0-✨";
1910
1911 let lock = XCheckerLock::new(unicode_model.to_string(), unicode_version.to_string());
1912
1913 assert_eq!(lock.model_full_name, unicode_model);
1914 assert_eq!(lock.claude_cli_version, unicode_version);
1915 }
1916
1917 #[test]
1918 fn test_empty_lockfile_error_includes_spec_id() {
1919 let _temp_dir = setup_test_env();
1920
1921 let spec_id = "test-spec-empty-lockfile-msg";
1922 let lock_path = FileLock::get_lock_path(spec_id);
1923 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1924
1925 fs::write(&lock_path, "").unwrap();
1927
1928 let result = FileLock::acquire(spec_id, false, None);
1930 assert!(result.is_err());
1931
1932 match result.unwrap_err() {
1933 LockError::CorruptedLock { reason } => {
1934 assert!(
1935 reason.contains(spec_id),
1936 "Error message should contain spec_id: {reason}"
1937 );
1938 assert!(
1939 reason.contains("empty") || reason.contains("initializing"),
1940 "Error message should mention empty/initializing: {reason}"
1941 );
1942 }
1943 other => panic!("Expected CorruptedLock error, got: {other:?}"),
1944 }
1945 }
1946
1947 #[test]
1948 fn test_partial_json_lockfile_error_includes_spec_id() {
1949 let _temp_dir = setup_test_env();
1950
1951 let spec_id = "test-spec-partial-json-msg";
1952 let lock_path = FileLock::get_lock_path(spec_id);
1953 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1954
1955 fs::write(&lock_path, r#"{"pid": 12345, "start_time":"#).unwrap();
1957
1958 let result = FileLock::acquire(spec_id, false, None);
1960 assert!(result.is_err());
1961
1962 match result.unwrap_err() {
1963 LockError::CorruptedLock { reason } => {
1964 assert!(
1965 reason.contains(spec_id),
1966 "Error message should contain spec_id: {reason}"
1967 );
1968 }
1969 other => panic!("Expected CorruptedLock error, got: {other:?}"),
1970 }
1971 }
1972
1973 #[test]
1974 fn test_corrupted_json_error_includes_spec_id() {
1975 let _temp_dir = setup_test_env();
1976
1977 let spec_id = "test-spec-error-format";
1978
1979 let lock_path = FileLock::get_lock_path(spec_id);
1980 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1981
1982 fs::write(&lock_path, r#"{"invalid": "structure", "no_pid": true}"#).unwrap();
1984
1985 let result = FileLock::acquire(spec_id, false, None);
1987 assert!(result.is_err());
1988
1989 match result.unwrap_err() {
1990 LockError::CorruptedLock { reason } => {
1991 assert!(
1992 reason.contains(spec_id),
1993 "Error message should contain spec_id: {reason}"
1994 );
1995 }
1996 other => panic!("Expected CorruptedLock error, got: {other:?}"),
1997 }
1998 }
1999
2000 #[test]
2001 fn test_concurrent_lock_error_includes_spec_id() {
2002 let _temp_dir = setup_test_env();
2003
2004 let spec_id = "test-spec-concurrent-msg";
2005
2006 let _lock1 = FileLock::acquire(spec_id, false, None).unwrap();
2008
2009 let result = FileLock::acquire(spec_id, false, None);
2011 assert!(result.is_err());
2012
2013 match result.unwrap_err() {
2015 LockError::ConcurrentExecution {
2016 spec_id: err_spec, ..
2017 } => {
2018 assert_eq!(err_spec, spec_id);
2019 }
2020 other => panic!("Expected ConcurrentExecution error, got: {other:?}"),
2021 }
2022 }
2023
2024 #[test]
2025 fn test_validate_existing_lock_handles_clock_skew() {
2026 let _temp_dir = setup_test_env();
2027
2028 let spec_id = "test-spec-clock-skew-validation";
2029 let lock_path = FileLock::get_lock_path(spec_id);
2030 fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2031
2032 let future_timestamp = SystemTime::now()
2034 .duration_since(UNIX_EPOCH)
2035 .unwrap()
2036 .as_secs()
2037 + 3600;
2038
2039 let lock_info = LockInfo {
2040 pid: 99999, start_time: future_timestamp,
2042 created_at: future_timestamp,
2043 spec_id: spec_id.to_string(),
2044 xchecker_version: "0.1.0".to_string(),
2045 };
2046
2047 let lock_json = serde_json::to_string_pretty(&lock_info).unwrap();
2048 fs::write(&lock_path, lock_json).unwrap();
2049
2050 let result = FileLock::acquire(spec_id, true, None);
2053 assert!(result.is_ok(), "Should handle clock skew gracefully");
2054 }
2055}