1use std::fs::{self, File};
31use std::io::Write;
32use std::path::{Path, PathBuf};
33use std::time::Duration;
34
35use anyhow::{Context, Result, bail};
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38
39pub const LOCK_FILE: &str = "lock";
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LockInfo {
45 pub pid: u32,
47 pub hostname: String,
49 pub acquired_at: DateTime<Utc>,
51 pub plan_id: Option<String>,
53}
54
55#[derive(Debug)]
57pub struct LockFile {
58 path: PathBuf,
59}
60
61impl LockFile {
62 pub fn acquire(state_dir: &Path, workspace_root: Option<&Path>) -> Result<Self> {
81 let lock_path = lock_path(state_dir, workspace_root);
82
83 fs::create_dir_all(state_dir)
85 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
86
87 if lock_path.exists() {
89 let existing_info = read_lock_info_from_path(&lock_path)?;
90 bail!(
91 "lock already held by pid {} on {} since {} (plan_id: {:?})",
92 existing_info.pid,
93 existing_info.hostname,
94 existing_info.acquired_at,
95 existing_info.plan_id
96 );
97 }
98
99 let pid = std::process::id();
101 let hostname = gethostname::gethostname().to_string_lossy().to_string();
102
103 let info = LockInfo {
104 pid,
105 hostname,
106 acquired_at: Utc::now(),
107 plan_id: None,
108 };
109
110 let tmp_path = lock_path.with_extension("tmp");
112 let json = serde_json::to_string_pretty(&info).context("failed to serialize lock info")?;
113
114 {
115 let mut file = File::create(&tmp_path).with_context(|| {
116 format!("failed to create lock tmp file {}", tmp_path.display())
117 })?;
118 file.write_all(json.as_bytes())
119 .with_context(|| format!("failed to write lock tmp file {}", tmp_path.display()))?;
120 file.sync_all().context("failed to sync lock file")?;
121 }
122
123 fs::rename(&tmp_path, &lock_path)
124 .with_context(|| format!("failed to rename lock file to {}", lock_path.display()))?;
125
126 if let Some(parent) = lock_path.parent()
128 && let Ok(dir_file) = File::open(parent)
129 {
130 let _ = dir_file.sync_all();
131 }
132
133 Ok(Self { path: lock_path })
134 }
135
136 pub fn acquire_with_timeout(
162 state_dir: &Path,
163 workspace_root: Option<&Path>,
164 timeout: Duration,
165 ) -> Result<Self> {
166 let lock_path = lock_path(state_dir, workspace_root);
167
168 if lock_path.exists() {
169 if let Ok(info) = read_lock_info_from_path(&lock_path) {
170 let age = Utc::now() - info.acquired_at;
171 if age.num_seconds().unsigned_abs() > timeout.as_secs() {
173 fs::remove_file(&lock_path).with_context(|| {
175 format!("failed to remove stale lock file {}", lock_path.display())
176 })?;
177 } else {
178 bail!(
179 "lock already held by pid {} on {} since {} (age: {:?})",
180 info.pid,
181 info.hostname,
182 info.acquired_at,
183 age
184 );
185 }
186 } else {
187 fs::remove_file(&lock_path).with_context(|| {
189 format!("failed to remove corrupt lock file {}", lock_path.display())
190 })?;
191 }
192 }
193
194 Self::acquire(state_dir, workspace_root)
195 }
196
197 pub fn release(&self) -> Result<()> {
202 if self.path.exists() {
203 fs::remove_file(&self.path)
204 .with_context(|| format!("failed to remove lock file {}", self.path.display()))?;
205 }
206 Ok(())
207 }
208
209 pub fn set_plan_id(&self, plan_id: &str) -> Result<()> {
211 if !self.path.exists() {
212 bail!("lock file does not exist at {}", self.path.display());
213 }
214
215 let mut info = read_lock_info_from_path(&self.path)?;
216 info.plan_id = Some(plan_id.to_string());
217
218 let json = serde_json::to_string_pretty(&info).context("failed to serialize lock info")?;
219
220 let tmp_path = self.path.with_extension("tmp");
221 {
222 let mut file = File::create(&tmp_path).with_context(|| {
223 format!("failed to create lock tmp file {}", tmp_path.display())
224 })?;
225 file.write_all(json.as_bytes())
226 .with_context(|| format!("failed to write lock tmp file {}", tmp_path.display()))?;
227 file.sync_all().context("failed to sync lock file")?;
228 }
229
230 fs::rename(&tmp_path, &self.path)
231 .with_context(|| format!("failed to rename lock file to {}", self.path.display()))?;
232
233 Ok(())
234 }
235
236 pub fn is_locked(state_dir: &Path, workspace_root: Option<&Path>) -> Result<bool> {
238 Ok(lock_path(state_dir, workspace_root).exists())
239 }
240
241 pub fn read_lock_info(state_dir: &Path, workspace_root: Option<&Path>) -> Result<LockInfo> {
243 read_lock_info_from_path(&lock_path(state_dir, workspace_root))
244 }
245}
246
247impl Drop for LockFile {
248 fn drop(&mut self) {
249 let _ = self.release();
251 }
252}
253
254fn read_lock_info_from_path(path: &Path) -> Result<LockInfo> {
256 let content = fs::read_to_string(path)
257 .with_context(|| format!("failed to read lock file {}", path.display()))?;
258 let info: LockInfo = serde_json::from_str(&content)
259 .with_context(|| format!("failed to parse lock JSON from {}", path.display()))?;
260 Ok(info)
261}
262
263pub fn lock_path(state_dir: &Path, workspace_root: Option<&Path>) -> PathBuf {
265 if let Some(root) = workspace_root {
266 use std::collections::hash_map::DefaultHasher;
267 use std::hash::{Hash, Hasher};
268 let mut hasher = DefaultHasher::new();
269 root.hash(&mut hasher);
270 let hash = hasher.finish();
271 state_dir.join(format!("{}_{:016x}", LOCK_FILE, hash))
272 } else {
273 state_dir.join(LOCK_FILE)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use tempfile::tempdir;
280
281 use super::*;
282
283 mod proptests {
284 use super::*;
285 use proptest::prelude::*;
286
287 proptest! {
288 #[test]
289 fn lock_path_without_root_ends_with_lock_file(dir_name in "[a-zA-Z0-9_]{1,64}") {
290 let base = PathBuf::from(&dir_name);
291 let p = lock_path(&base, None);
292 prop_assert_eq!(p, base.join(LOCK_FILE));
293 }
294
295 #[test]
296 fn lock_path_with_root_contains_hex_hash(
297 dir_name in "[a-zA-Z0-9_]{1,64}",
298 root_name in "[a-zA-Z0-9_/]{1,128}",
299 ) {
300 let base = PathBuf::from(&dir_name);
301 let root = PathBuf::from(&root_name);
302 let p = lock_path(&base, Some(&root));
303 let name = p.file_name().unwrap().to_string_lossy();
304 let expected_prefix = format!("{}_", LOCK_FILE);
305 prop_assert!(name.starts_with(&expected_prefix));
306 let suffix = &name[LOCK_FILE.len() + 1..];
308 prop_assert_eq!(suffix.len(), 16);
309 prop_assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
310 }
311
312 #[test]
313 fn lock_path_with_root_is_deterministic(
314 dir_name in "[a-zA-Z0-9_]{1,64}",
315 root_name in "[a-zA-Z0-9_/]{1,128}",
316 ) {
317 let base = PathBuf::from(&dir_name);
318 let root = PathBuf::from(&root_name);
319 prop_assert_eq!(
320 lock_path(&base, Some(&root)),
321 lock_path(&base, Some(&root))
322 );
323 }
324
325 #[test]
326 fn timeout_duration_from_arbitrary_secs(secs in 0u64..=u64::MAX) {
327 let d = Duration::from_secs(secs);
328 prop_assert_eq!(d.as_secs(), secs);
329 }
330
331 #[test]
332 fn acquire_release_lifecycle(dir_suffix in "[a-zA-Z0-9]{1,32}") {
333 let td = tempdir().expect("tempdir");
334 let state_dir = td.path().join(dir_suffix);
335
336 let lock = LockFile::acquire(&state_dir, None).expect("acquire");
337 prop_assert!(lock_path(&state_dir, None).exists());
338
339 let info = LockFile::read_lock_info(&state_dir, None).expect("read");
340 prop_assert_eq!(info.pid, std::process::id());
341 prop_assert!(!info.hostname.is_empty());
342
343 lock.release().expect("release");
344 prop_assert!(!lock_path(&state_dir, None).exists());
345 }
346
347 #[test]
348 fn stale_lock_detected_by_arbitrary_age(
349 age_hours in 2u32..1000u32,
350 timeout_secs in 1u64..3600u64,
351 ) {
352 let td = tempdir().expect("tempdir");
353 let lp = lock_path(td.path(), None);
354
355 let old_info = LockInfo {
356 pid: 99999,
357 hostname: "prop-host".to_string(),
358 acquired_at: Utc::now() - chrono::Duration::hours(i64::from(age_hours)),
359 plan_id: None,
360 };
361 std::fs::write(
362 &lp,
363 serde_json::to_string(&old_info).expect("ser"),
364 ).expect("write");
365
366 let lock = LockFile::acquire_with_timeout(
369 td.path(),
370 None,
371 Duration::from_secs(timeout_secs),
372 ).expect("should replace stale lock");
373
374 let new_info = LockFile::read_lock_info(td.path(), None).expect("read");
375 prop_assert_eq!(new_info.pid, std::process::id());
376 prop_assert_ne!(new_info.pid, 99999);
377 drop(lock);
378 }
379
380 #[test]
381 fn fresh_lock_not_removed_with_large_timeout(
382 age_minutes in 1u32..59u32,
383 ) {
384 let td = tempdir().expect("tempdir");
385 let lp = lock_path(td.path(), None);
386
387 let info = LockInfo {
388 pid: 88888,
389 hostname: "fresh-host".to_string(),
390 acquired_at: Utc::now() - chrono::Duration::minutes(i64::from(age_minutes)),
391 plan_id: None,
392 };
393 std::fs::write(
394 &lp,
395 serde_json::to_string(&info).expect("ser"),
396 ).expect("write");
397
398 let result = LockFile::acquire_with_timeout(
400 td.path(),
401 None,
402 Duration::from_secs(3600),
403 );
404 prop_assert!(result.is_err());
405 prop_assert!(result.unwrap_err().to_string().contains("lock already held"));
406 }
407
408 #[test]
409 fn lock_info_serde_roundtrip_proptest(
410 pid in any::<u32>(),
411 hostname in "[a-zA-Z0-9._-]{1,64}",
412 plan_id in proptest::option::of("[a-zA-Z0-9_-]{1,64}"),
413 ) {
414 let info = LockInfo {
415 pid,
416 hostname: hostname.clone(),
417 acquired_at: Utc::now(),
418 plan_id: plan_id.clone(),
419 };
420 let json = serde_json::to_string(&info).expect("ser");
421 let parsed: LockInfo = serde_json::from_str(&json).expect("de");
422 prop_assert_eq!(parsed.pid, pid);
423 prop_assert_eq!(parsed.hostname, hostname);
424 prop_assert_eq!(parsed.plan_id, plan_id);
425 }
426
427 #[test]
428 fn lock_path_parent_is_always_state_dir(
429 dir_name in "[a-zA-Z0-9_]{1,64}",
430 root_name in proptest::option::of("[a-zA-Z0-9_/]{1,128}"),
431 ) {
432 let base = PathBuf::from(&dir_name);
433 let root = root_name.as_ref().map(PathBuf::from);
434 let p = lock_path(&base, root.as_deref());
435 prop_assert_eq!(p.parent().unwrap(), &*base);
436 }
437
438 #[test]
439 fn acquire_release_with_workspace_root(
440 dir_suffix in "[a-zA-Z0-9]{1,32}",
441 ws_suffix in "[a-zA-Z0-9]{1,32}",
442 ) {
443 let td = tempdir().expect("tempdir");
444 let state_dir = td.path().join(&dir_suffix);
445 let ws_root = td.path().join(&ws_suffix);
446
447 let lock = LockFile::acquire(&state_dir, Some(&ws_root)).expect("acquire");
448 prop_assert!(LockFile::is_locked(&state_dir, Some(&ws_root)).expect("is_locked"));
449 prop_assert!(!LockFile::is_locked(&state_dir, None).unwrap_or(false));
450
451 lock.release().expect("release");
452 prop_assert!(!LockFile::is_locked(&state_dir, Some(&ws_root)).expect("after release"));
453 }
454
455 #[test]
456 fn set_plan_id_roundtrip(plan_id in "[a-zA-Z0-9_-]{1,64}") {
457 let td = tempdir().expect("tempdir");
458 let lock = LockFile::acquire(td.path(), None).expect("acquire");
459 lock.set_plan_id(&plan_id).expect("set_plan_id");
460
461 let info = LockFile::read_lock_info(td.path(), None).expect("read");
462 prop_assert_eq!(info.plan_id.as_deref(), Some(plan_id.as_str()));
463 prop_assert_eq!(info.pid, std::process::id());
464 drop(lock);
465 }
466
467 #[test]
468 fn stale_lock_with_plan_id_is_replaced(
469 age_hours in 2u32..500u32,
470 plan_id in "[a-zA-Z0-9_-]{1,64}",
471 ) {
472 let td = tempdir().expect("tempdir");
473 let lp = lock_path(td.path(), None);
474
475 let old_info = LockInfo {
476 pid: 77777,
477 hostname: "stale-host".to_string(),
478 acquired_at: Utc::now() - chrono::Duration::hours(i64::from(age_hours)),
479 plan_id: Some(plan_id),
480 };
481 std::fs::write(
482 &lp,
483 serde_json::to_string(&old_info).expect("ser"),
484 ).expect("write");
485
486 let lock = LockFile::acquire_with_timeout(
487 td.path(),
488 None,
489 Duration::from_secs(3600),
490 ).expect("should replace stale lock with plan_id");
491
492 let new_info = LockFile::read_lock_info(td.path(), None).expect("read");
493 prop_assert_eq!(new_info.pid, std::process::id());
494 prop_assert!(new_info.plan_id.is_none());
495 drop(lock);
496 }
497
498 #[test]
499 fn lock_file_on_disk_has_expected_json_structure(
500 dir_suffix in "[a-zA-Z0-9]{1,32}",
501 ) {
502 let td = tempdir().expect("tempdir");
503 let state_dir = td.path().join(dir_suffix);
504 let lock = LockFile::acquire(&state_dir, None).expect("acquire");
505
506 let lp = lock_path(&state_dir, None);
507 let content = std::fs::read_to_string(&lp).expect("read");
508 let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse");
509
510 let obj = parsed.as_object().expect("should be object");
511 prop_assert!(obj["pid"].is_number());
512 prop_assert!(obj["hostname"].is_string());
513 prop_assert!(obj["acquired_at"].is_string());
514 prop_assert!(obj.contains_key("plan_id"));
515 prop_assert!(content.contains('\n'));
517
518 drop(lock);
519 }
520 }
521 }
522
523 #[test]
524 fn lock_path_returns_expected_path() {
525 let base = PathBuf::from("x");
526 assert_eq!(lock_path(&base, None), PathBuf::from("x").join(LOCK_FILE));
527 }
528
529 #[test]
530 fn acquire_creates_lock_file() {
531 let td = tempdir().expect("tempdir");
532 let lock = LockFile::acquire(td.path(), None).expect("acquire");
533 assert!(lock_path(td.path(), None).exists());
534 lock.release().expect("release");
535 assert!(!lock_path(td.path(), None).exists());
536 }
537
538 #[test]
539 fn acquire_fails_when_locked() {
540 let td = tempdir().expect("tempdir");
541 let _lock1 = LockFile::acquire(td.path(), None).expect("first acquire");
542
543 let result = LockFile::acquire(td.path(), None);
544 assert!(result.is_err());
545 assert!(
546 result
547 .unwrap_err()
548 .to_string()
549 .contains("lock already held")
550 );
551 }
552
553 #[test]
554 fn drop_releases_lock() {
555 let td = tempdir().expect("tempdir");
556 {
557 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
558 assert!(lock_path(td.path(), None).exists());
559 }
560 assert!(!lock_path(td.path(), None).exists());
562 }
563
564 #[test]
565 fn read_lock_info_returns_correct_info() {
566 let td = tempdir().expect("tempdir");
567 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
568
569 let info = LockFile::read_lock_info(td.path(), None).expect("read info");
570 assert_eq!(info.pid, std::process::id());
571 assert!(!info.hostname.is_empty());
572 assert!(info.plan_id.is_none());
573 }
574
575 #[test]
576 fn set_plan_id_updates_lock() {
577 let td = tempdir().expect("tempdir");
578 let lock = LockFile::acquire(td.path(), None).expect("acquire");
579
580 lock.set_plan_id("test-plan-123").expect("set plan_id");
581
582 let info = LockFile::read_lock_info(td.path(), None).expect("read info");
583 assert_eq!(info.plan_id, Some("test-plan-123".to_string()));
584 }
585
586 #[test]
587 fn is_locked_returns_correct_status() {
588 let td = tempdir().expect("tempdir");
589 assert!(!LockFile::is_locked(td.path(), None).expect("is_locked"));
590
591 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
592 assert!(LockFile::is_locked(td.path(), None).expect("is_locked"));
593 }
594
595 #[test]
596 fn acquire_with_timeout_removes_stale_locks() {
597 let td = tempdir().expect("tempdir");
598
599 let lock_path = lock_path(td.path(), None);
601 let old_info = LockInfo {
602 pid: 12345,
603 hostname: "test-host".to_string(),
604 acquired_at: Utc::now() - chrono::Duration::hours(2),
605 plan_id: None,
606 };
607 fs::write(
608 &lock_path,
609 serde_json::to_string(&old_info).expect("serialize"),
610 )
611 .expect("write stale lock");
612
613 let _lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600))
615 .expect("acquire with timeout");
616
617 let info = LockFile::read_lock_info(td.path(), None).expect("read info");
618 assert_eq!(info.pid, std::process::id());
619 assert_ne!(info.pid, 12345);
620 }
621
622 #[test]
623 fn acquire_with_timeout_fails_on_fresh_lock() {
624 let td = tempdir().expect("tempdir");
625
626 let _lock1 = LockFile::acquire(td.path(), None).expect("first acquire");
628
629 let result = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600));
631 assert!(result.is_err());
632 assert!(
633 result
634 .unwrap_err()
635 .to_string()
636 .contains("lock already held")
637 );
638 }
639
640 #[test]
641 fn lock_info_serde_roundtrip() {
642 let info = LockInfo {
643 pid: 12345,
644 hostname: "test-host".to_string(),
645 acquired_at: Utc::now(),
646 plan_id: Some("plan-123".to_string()),
647 };
648
649 let json = serde_json::to_string(&info).expect("serialize");
650 let parsed: LockInfo = serde_json::from_str(&json).expect("deserialize");
651
652 assert_eq!(parsed.pid, info.pid);
653 assert_eq!(parsed.hostname, info.hostname);
654 assert_eq!(parsed.plan_id, info.plan_id);
655 }
656
657 #[test]
658 fn lock_info_serde_roundtrip_no_plan_id() {
659 let info = LockInfo {
660 pid: 99,
661 hostname: "h".to_string(),
662 acquired_at: Utc::now(),
663 plan_id: None,
664 };
665 let json = serde_json::to_string(&info).expect("serialize");
666 let parsed: LockInfo = serde_json::from_str(&json).expect("deserialize");
667 assert_eq!(parsed.plan_id, None);
668 }
669
670 #[test]
671 fn lock_path_with_workspace_root_is_hashed() {
672 let base = PathBuf::from("state");
673 let root = Path::new("/some/workspace");
674 let p = lock_path(&base, Some(root));
675 let name = p.file_name().unwrap().to_string_lossy();
677 assert!(name.starts_with(&format!("{}_", LOCK_FILE)));
678 assert!(name.len() > LOCK_FILE.len() + 1);
679 }
680
681 #[test]
682 fn lock_path_different_roots_produce_different_paths() {
683 let base = PathBuf::from("state");
684 let p1 = lock_path(&base, Some(Path::new("/workspace/a")));
685 let p2 = lock_path(&base, Some(Path::new("/workspace/b")));
686 assert_ne!(p1, p2);
687 }
688
689 #[test]
690 fn lock_path_same_root_produces_same_path() {
691 let base = PathBuf::from("state");
692 let p1 = lock_path(&base, Some(Path::new("/workspace/a")));
693 let p2 = lock_path(&base, Some(Path::new("/workspace/a")));
694 assert_eq!(p1, p2);
695 }
696
697 #[test]
698 fn acquire_with_workspace_root() {
699 let td = tempdir().expect("tempdir");
700 let root = td.path().join("project");
701 let lock = LockFile::acquire(td.path(), Some(&root)).expect("acquire");
702 assert!(LockFile::is_locked(td.path(), Some(&root)).expect("is_locked"));
703 assert!(!LockFile::is_locked(td.path(), None).expect("is_locked none"));
705 drop(lock);
706 assert!(!LockFile::is_locked(td.path(), Some(&root)).expect("is_locked after drop"));
707 }
708
709 #[test]
710 fn multiple_locks_different_workspace_roots() {
711 let td = tempdir().expect("tempdir");
712 let root_a = td.path().join("a");
713 let root_b = td.path().join("b");
714 let lock_a = LockFile::acquire(td.path(), Some(&root_a)).expect("acquire a");
715 let lock_b = LockFile::acquire(td.path(), Some(&root_b)).expect("acquire b");
716 assert!(LockFile::is_locked(td.path(), Some(&root_a)).expect("locked a"));
717 assert!(LockFile::is_locked(td.path(), Some(&root_b)).expect("locked b"));
718 drop(lock_a);
719 assert!(!LockFile::is_locked(td.path(), Some(&root_a)).expect("unlocked a"));
720 assert!(LockFile::is_locked(td.path(), Some(&root_b)).expect("still locked b"));
721 drop(lock_b);
722 }
723
724 #[test]
725 fn acquire_creates_state_directory() {
726 let td = tempdir().expect("tempdir");
727 let nested = td.path().join("deep").join("nested").join("dir");
728 assert!(!nested.exists());
729 let lock = LockFile::acquire(&nested, None).expect("acquire");
730 assert!(nested.exists());
731 drop(lock);
732 }
733
734 #[test]
735 fn release_is_idempotent() {
736 let td = tempdir().expect("tempdir");
737 let lock = LockFile::acquire(td.path(), None).expect("acquire");
738 lock.release().expect("first release");
739 lock.release().expect("second release");
741 }
742
743 #[test]
744 fn is_locked_returns_false_after_drop() {
745 let td = tempdir().expect("tempdir");
746 let lock = LockFile::acquire(td.path(), None).expect("acquire");
747 assert!(LockFile::is_locked(td.path(), None).expect("locked"));
748 drop(lock);
749 assert!(!LockFile::is_locked(td.path(), None).expect("unlocked"));
750 }
751
752 #[test]
753 fn read_lock_info_fails_when_no_lock() {
754 let td = tempdir().expect("tempdir");
755 let result = LockFile::read_lock_info(td.path(), None);
756 assert!(result.is_err());
757 }
758
759 #[test]
760 fn set_plan_id_fails_when_lock_released() {
761 let td = tempdir().expect("tempdir");
762 let lock = LockFile::acquire(td.path(), None).expect("acquire");
763 lock.release().expect("release");
764 let result = lock.set_plan_id("some-plan");
765 assert!(result.is_err());
766 assert!(result.unwrap_err().to_string().contains("does not exist"));
767 }
768
769 #[test]
770 fn set_plan_id_can_be_updated_multiple_times() {
771 let td = tempdir().expect("tempdir");
772 let lock = LockFile::acquire(td.path(), None).expect("acquire");
773 lock.set_plan_id("plan-1").expect("set 1");
774 lock.set_plan_id("plan-2").expect("set 2");
775 let info = LockFile::read_lock_info(td.path(), None).expect("read");
776 assert_eq!(info.plan_id, Some("plan-2".to_string()));
777 }
778
779 #[test]
780 fn acquire_with_timeout_removes_corrupt_lock() {
781 let td = tempdir().expect("tempdir");
782 let lp = lock_path(td.path(), None);
783 fs::write(&lp, "not-valid-json").expect("write corrupt");
784
785 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600))
786 .expect("acquire after corrupt");
787 let info = LockFile::read_lock_info(td.path(), None).expect("read");
788 assert_eq!(info.pid, std::process::id());
789 drop(lock);
790 }
791
792 #[test]
793 fn lock_file_contains_valid_json() {
794 let td = tempdir().expect("tempdir");
795 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
796 let lp = lock_path(td.path(), None);
797 let content = fs::read_to_string(&lp).expect("read");
798 let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse json");
799 assert!(parsed.get("pid").is_some());
800 assert!(parsed.get("hostname").is_some());
801 assert!(parsed.get("acquired_at").is_some());
802 }
803
804 #[test]
805 fn acquire_with_timeout_respects_fresh_lock_age() {
806 let td = tempdir().expect("tempdir");
807 let lp = lock_path(td.path(), None);
809 let info = LockInfo {
810 pid: 99999,
811 hostname: "other-host".to_string(),
812 acquired_at: Utc::now() - chrono::Duration::minutes(30),
813 plan_id: Some("active-plan".to_string()),
814 };
815 fs::write(&lp, serde_json::to_string(&info).expect("ser")).expect("write");
816
817 let result = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600));
818 assert!(result.is_err());
819 let err_msg = result.unwrap_err().to_string();
820 assert!(err_msg.contains("lock already held"));
821 assert!(err_msg.contains("99999"));
822 }
823
824 #[test]
825 fn acquire_with_timeout_and_workspace_root() {
826 let td = tempdir().expect("tempdir");
827 let root = td.path().join("ws");
828 let lp = lock_path(td.path(), Some(&root));
830 let old_info = LockInfo {
831 pid: 11111,
832 hostname: "stale-host".to_string(),
833 acquired_at: Utc::now() - chrono::Duration::hours(5),
834 plan_id: None,
835 };
836 fs::write(&lp, serde_json::to_string(&old_info).expect("ser")).expect("write");
837
838 let lock =
839 LockFile::acquire_with_timeout(td.path(), Some(&root), Duration::from_secs(3600))
840 .expect("acquire stale with root");
841 let info = LockFile::read_lock_info(td.path(), Some(&root)).expect("read");
842 assert_eq!(info.pid, std::process::id());
843 drop(lock);
844 }
845
846 #[test]
847 fn lock_path_none_root_is_deterministic() {
848 let base = PathBuf::from("dir");
849 assert_eq!(lock_path(&base, None), lock_path(&base, None));
850 }
851
852 #[test]
853 fn acquire_contention_error_includes_holder_details() {
854 let td = tempdir().expect("tempdir");
855 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
856 let err = LockFile::acquire(td.path(), None).unwrap_err();
857 let msg = err.to_string();
858 assert!(msg.contains(&std::process::id().to_string()));
860 assert!(msg.contains("lock already held"));
861 }
862
863 #[test]
864 fn set_plan_id_preserves_other_fields() {
865 let td = tempdir().expect("tempdir");
866 let lock = LockFile::acquire(td.path(), None).expect("acquire");
867 let before = LockFile::read_lock_info(td.path(), None).expect("read before");
868
869 lock.set_plan_id("my-plan").expect("set");
870
871 let after = LockFile::read_lock_info(td.path(), None).expect("read after");
872 assert_eq!(before.pid, after.pid);
873 assert_eq!(before.hostname, after.hostname);
874 assert_eq!(before.acquired_at, after.acquired_at);
875 assert_eq!(after.plan_id, Some("my-plan".to_string()));
876 }
877}
878
879#[cfg(test)]
880mod snapshot_tests {
881 use super::*;
882 use chrono::TimeZone;
883 use tempfile::tempdir;
884
885 fn fixed_lock_info(plan_id: Option<&str>) -> LockInfo {
887 LockInfo {
888 pid: 42,
889 hostname: "build-host".to_string(),
890 acquired_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
891 plan_id: plan_id.map(String::from),
892 }
893 }
894
895 #[test]
898 fn lock_file_content_without_plan_id() {
899 let info = fixed_lock_info(None);
900 let json = serde_json::to_string_pretty(&info).expect("serialize");
901 insta::assert_snapshot!("lock_file_content_without_plan_id", json);
902 }
903
904 #[test]
905 fn lock_file_content_with_plan_id() {
906 let info = fixed_lock_info(Some("release-2025-01-15"));
907 let json = serde_json::to_string_pretty(&info).expect("serialize");
908 insta::assert_snapshot!("lock_file_content_with_plan_id", json);
909 }
910
911 #[test]
912 fn lock_file_yaml_roundtrip() {
913 let info = fixed_lock_info(Some("plan-abc-123"));
914 insta::assert_yaml_snapshot!("lock_info_yaml", info);
915 }
916
917 #[test]
918 fn lock_file_on_disk_matches_expected() {
919 let td = tempdir().expect("tempdir");
920 let lp = lock_path(td.path(), None);
921 let info = fixed_lock_info(Some("on-disk-plan"));
922 let json = serde_json::to_string_pretty(&info).expect("serialize");
923 fs::create_dir_all(td.path()).ok();
924 fs::write(&lp, &json).expect("write");
925
926 let content = fs::read_to_string(&lp).expect("read");
927 insta::assert_snapshot!("lock_file_on_disk", content);
928 }
929
930 #[test]
933 fn error_lock_already_held() {
934 let td = tempdir().expect("tempdir");
935 let lp = lock_path(td.path(), None);
936 let info = fixed_lock_info(None);
937 fs::create_dir_all(td.path()).ok();
938 fs::write(&lp, serde_json::to_string(&info).expect("ser")).expect("write");
939
940 let err = LockFile::acquire(td.path(), None).unwrap_err();
941 insta::assert_snapshot!("error_lock_already_held", err.to_string());
942 }
943
944 #[test]
945 fn error_lock_already_held_with_plan_id() {
946 let td = tempdir().expect("tempdir");
947 let lp = lock_path(td.path(), None);
948 let info = fixed_lock_info(Some("active-plan"));
949 fs::create_dir_all(td.path()).ok();
950 fs::write(&lp, serde_json::to_string(&info).expect("ser")).expect("write");
951
952 let err = LockFile::acquire(td.path(), None).unwrap_err();
953 insta::assert_snapshot!("error_lock_already_held_with_plan_id", err.to_string());
954 }
955
956 #[test]
957 fn error_fresh_lock_with_timeout() {
958 let td = tempdir().expect("tempdir");
959 let _existing = LockFile::acquire(td.path(), None).expect("seed lock");
961
962 let err = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(86400 * 365))
963 .unwrap_err();
964 let msg = err.to_string();
965 assert!(msg.contains("lock already held"));
967 insta::assert_snapshot!(
968 "error_fresh_lock_with_timeout_prefix",
969 "lock already held by current process (fresh lock within timeout)"
970 );
971 }
972
973 #[test]
974 fn error_read_nonexistent_lock() {
975 let td = tempdir().expect("tempdir");
976 let err = LockFile::read_lock_info(td.path(), None).unwrap_err();
977 let msg = err.to_string();
979 assert!(msg.contains("failed to read lock file"));
980 insta::assert_snapshot!(
981 "error_read_nonexistent_lock_prefix",
982 "failed to read lock file"
983 );
984 }
985
986 #[test]
987 fn error_set_plan_id_after_release() {
988 let td = tempdir().expect("tempdir");
989 let lock = LockFile::acquire(td.path(), None).expect("acquire");
990 lock.release().expect("release");
991 let err = lock.set_plan_id("orphan-plan").unwrap_err();
992 assert!(err.to_string().contains("lock file does not exist"));
994 insta::assert_snapshot!(
995 "error_set_plan_id_after_release_prefix",
996 "lock file does not exist"
997 );
998 }
999
1000 #[test]
1001 fn error_corrupt_lock_file() {
1002 let td = tempdir().expect("tempdir");
1003 let lp = lock_path(td.path(), None);
1004 fs::create_dir_all(td.path()).ok();
1005 fs::write(&lp, "<<<not json>>>").expect("write");
1006
1007 let err = LockFile::acquire(td.path(), None).unwrap_err();
1008 let msg = err.to_string();
1009 assert!(msg.contains("failed to parse lock JSON"));
1010 insta::assert_snapshot!(
1011 "error_corrupt_lock_file_prefix",
1012 "failed to parse lock JSON"
1013 );
1014 }
1015
1016 #[test]
1019 fn lock_status_unlocked() {
1020 let td = tempdir().expect("tempdir");
1021 let locked = LockFile::is_locked(td.path(), None).expect("check");
1022 insta::assert_snapshot!("lock_status_unlocked", format!("locked: {locked}"));
1023 }
1024
1025 #[test]
1026 fn lock_status_locked() {
1027 let td = tempdir().expect("tempdir");
1028 let _lock = LockFile::acquire(td.path(), None).expect("acquire");
1029 let locked = LockFile::is_locked(td.path(), None).expect("check");
1030 insta::assert_snapshot!("lock_status_locked", format!("locked: {locked}"));
1031 }
1032
1033 #[test]
1034 fn lock_info_debug_display() {
1035 let info = fixed_lock_info(Some("display-plan"));
1036 insta::assert_snapshot!("lock_info_debug", format!("{info:#?}"));
1037 }
1038
1039 #[test]
1040 fn lock_path_without_root_snapshot() {
1041 let p = lock_path(Path::new(".shipper"), None);
1042 insta::assert_snapshot!(
1043 "lock_path_without_root",
1044 p.to_string_lossy().replace('\\', "/")
1045 );
1046 }
1047
1048 #[test]
1049 fn lock_path_with_root_snapshot() {
1050 let p = lock_path(Path::new(".shipper"), Some(Path::new("/my/workspace")));
1051 let name = p.file_name().unwrap().to_string_lossy().to_string();
1052 insta::assert_snapshot!("lock_path_with_root_filename", name);
1054 }
1055}
1056
1057#[cfg(test)]
1058mod edge_case_tests {
1059 use super::*;
1060 use std::sync::{Arc, Barrier};
1061 use tempfile::tempdir;
1062
1063 #[test]
1066 fn stale_lock_exactly_at_timeout_boundary_is_not_removed() {
1067 let td = tempdir().expect("tempdir");
1070 let lp = lock_path(td.path(), None);
1071 let timeout_secs = 3600u64;
1072 let info = LockInfo {
1073 pid: 55555,
1074 hostname: "boundary-host".to_string(),
1075 acquired_at: Utc::now() - chrono::Duration::seconds(timeout_secs as i64),
1076 plan_id: None,
1077 };
1078 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1079
1080 let result =
1084 LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(timeout_secs + 1));
1085 assert!(result.is_err());
1086 assert!(
1087 result
1088 .unwrap_err()
1089 .to_string()
1090 .contains("lock already held")
1091 );
1092 }
1093
1094 #[test]
1095 fn stale_lock_one_second_past_timeout_is_removed() {
1096 let td = tempdir().expect("tempdir");
1097 let lp = lock_path(td.path(), None);
1098 let timeout_secs = 60u64;
1099 let info = LockInfo {
1100 pid: 55556,
1101 hostname: "past-host".to_string(),
1102 acquired_at: Utc::now() - chrono::Duration::seconds((timeout_secs + 2) as i64),
1103 plan_id: Some("stale-plan".to_string()),
1104 };
1105 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1106
1107 let lock =
1108 LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(timeout_secs))
1109 .expect("should remove stale lock");
1110 let new_info = LockFile::read_lock_info(td.path(), None).unwrap();
1111 assert_eq!(new_info.pid, std::process::id());
1112 assert!(new_info.plan_id.is_none());
1113 drop(lock);
1114 }
1115
1116 #[test]
1117 fn stale_lock_recovery_preserves_state_dir() {
1118 let td = tempdir().expect("tempdir");
1119 let nested = td.path().join("deep").join("state");
1120 fs::create_dir_all(&nested).unwrap();
1121 let lp = lock_path(&nested, None);
1122 let info = LockInfo {
1123 pid: 44444,
1124 hostname: "stale-nested".to_string(),
1125 acquired_at: Utc::now() - chrono::Duration::hours(10),
1126 plan_id: None,
1127 };
1128 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1129
1130 let lock = LockFile::acquire_with_timeout(&nested, None, Duration::from_secs(60)).unwrap();
1131 assert!(nested.exists());
1132 drop(lock);
1133 }
1134
1135 #[test]
1138 fn concurrent_acquire_only_one_succeeds() {
1139 let td = tempdir().expect("tempdir");
1140 let state_dir = td.path().to_path_buf();
1141 let thread_count = 8;
1142 let barrier = Arc::new(Barrier::new(thread_count));
1143
1144 let handles: Vec<_> = (0..thread_count)
1145 .map(|_| {
1146 let dir = state_dir.clone();
1147 let b = Arc::clone(&barrier);
1148 std::thread::spawn(move || {
1149 b.wait();
1150 LockFile::acquire(&dir, None).ok()
1151 })
1152 })
1153 .collect();
1154
1155 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1156 let successes = results.iter().filter(|r| r.is_some()).count();
1157 assert!(successes >= 1, "at least one thread must acquire the lock");
1161 }
1162
1163 #[test]
1164 fn concurrent_acquire_with_timeout_replaces_stale() {
1165 let td = tempdir().expect("tempdir");
1166 let state_dir = td.path().to_path_buf();
1167 let lp = lock_path(&state_dir, None);
1168 let info = LockInfo {
1169 pid: 99990,
1170 hostname: "old".to_string(),
1171 acquired_at: Utc::now() - chrono::Duration::hours(5),
1172 plan_id: None,
1173 };
1174 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1175
1176 let thread_count = 4;
1177 let barrier = Arc::new(Barrier::new(thread_count));
1178 let handles: Vec<_> = (0..thread_count)
1179 .map(|_| {
1180 let dir = state_dir.clone();
1181 let b = Arc::clone(&barrier);
1182 std::thread::spawn(move || {
1183 b.wait();
1184 LockFile::acquire_with_timeout(&dir, None, Duration::from_secs(60)).ok()
1185 })
1186 })
1187 .collect();
1188
1189 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1190 let successes = results.iter().filter(|r| r.is_some()).count();
1191 assert!(successes >= 1, "at least one thread must succeed");
1192 }
1193
1194 #[test]
1197 fn force_break_by_removing_lock_then_reacquire() {
1198 let td = tempdir().expect("tempdir");
1199 let _lock = LockFile::acquire(td.path(), None).expect("initial acquire");
1200 let lp = lock_path(td.path(), None);
1201 assert!(lp.exists());
1202
1203 fs::remove_file(&lp).expect("force remove");
1205 assert!(!lp.exists());
1206
1207 let lock2 = LockFile::acquire(td.path(), None).expect("reacquire after force-break");
1209 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1210 assert_eq!(info.pid, std::process::id());
1211 drop(lock2);
1212 }
1213
1214 #[test]
1215 fn force_break_stale_via_timeout_zero() {
1216 let td = tempdir().expect("tempdir");
1219 let lp = lock_path(td.path(), None);
1220 let info = LockInfo {
1221 pid: 33333,
1222 hostname: "force-host".to_string(),
1223 acquired_at: Utc::now() - chrono::Duration::seconds(1),
1224 plan_id: None,
1225 };
1226 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1227
1228 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(0)).unwrap();
1229 let new_info = LockFile::read_lock_info(td.path(), None).unwrap();
1230 assert_eq!(new_info.pid, std::process::id());
1231 drop(lock);
1232 }
1233
1234 #[test]
1235 fn force_break_lock_held_by_different_workspace_root() {
1236 let td = tempdir().expect("tempdir");
1237 let root_a = td.path().join("ws-a");
1238 let root_b = td.path().join("ws-b");
1239 let _lock_a = LockFile::acquire(td.path(), Some(&root_a)).unwrap();
1240
1241 let lp_a = lock_path(td.path(), Some(&root_a));
1243 fs::remove_file(&lp_a).unwrap();
1244
1245 let lock_a2 = LockFile::acquire(td.path(), Some(&root_a)).unwrap();
1246 assert!(!LockFile::is_locked(td.path(), Some(&root_b)).unwrap());
1248 drop(lock_a2);
1249 }
1250
1251 #[test]
1254 fn corrupt_lock_empty_file() {
1255 let td = tempdir().expect("tempdir");
1256 let lp = lock_path(td.path(), None);
1257 fs::write(&lp, "").unwrap();
1258
1259 let err = LockFile::acquire(td.path(), None).unwrap_err();
1261 assert!(err.to_string().contains("failed to parse lock JSON"));
1262 }
1263
1264 #[test]
1265 fn corrupt_lock_partial_json() {
1266 let td = tempdir().expect("tempdir");
1267 let lp = lock_path(td.path(), None);
1268 fs::write(&lp, r#"{"pid": 1, "hostname": "h""#).unwrap();
1269
1270 let err = LockFile::acquire(td.path(), None).unwrap_err();
1271 assert!(err.to_string().contains("failed to parse lock JSON"));
1272 }
1273
1274 #[test]
1275 fn corrupt_lock_wrong_json_type() {
1276 let td = tempdir().expect("tempdir");
1277 let lp = lock_path(td.path(), None);
1278 fs::write(&lp, "[1, 2, 3]").unwrap();
1279
1280 let err = LockFile::acquire(td.path(), None).unwrap_err();
1281 assert!(err.to_string().contains("failed to parse lock JSON"));
1282 }
1283
1284 #[test]
1285 fn corrupt_lock_missing_required_fields() {
1286 let td = tempdir().expect("tempdir");
1287 let lp = lock_path(td.path(), None);
1288 fs::write(&lp, r#"{"pid": 1}"#).unwrap();
1289
1290 let err = LockFile::acquire(td.path(), None).unwrap_err();
1291 assert!(err.to_string().contains("failed to parse lock JSON"));
1292 }
1293
1294 #[test]
1295 fn corrupt_lock_binary_content() {
1296 let td = tempdir().expect("tempdir");
1297 let lp = lock_path(td.path(), None);
1298 fs::write(&lp, [0xFF, 0xFE, 0x00, 0x01, 0x80]).unwrap();
1299
1300 let err = LockFile::acquire(td.path(), None).unwrap_err();
1301 assert!(
1302 err.to_string().contains("failed to read lock file")
1303 || err.to_string().contains("failed to parse lock JSON")
1304 );
1305 }
1306
1307 #[test]
1308 fn corrupt_lock_removed_by_acquire_with_timeout() {
1309 let td = tempdir().expect("tempdir");
1310 let lp = lock_path(td.path(), None);
1311 fs::write(&lp, "totally-invalid").unwrap();
1312
1313 let lock =
1315 LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600)).unwrap();
1316 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1317 assert_eq!(info.pid, std::process::id());
1318 drop(lock);
1319 }
1320
1321 #[test]
1322 fn corrupt_lock_empty_json_object() {
1323 let td = tempdir().expect("tempdir");
1324 let lp = lock_path(td.path(), None);
1325 fs::write(&lp, "{}").unwrap();
1326
1327 let err = LockFile::acquire(td.path(), None).unwrap_err();
1328 assert!(err.to_string().contains("failed to parse lock JSON"));
1329 }
1330
1331 #[test]
1332 fn corrupt_lock_wrong_pid_type() {
1333 let td = tempdir().expect("tempdir");
1334 let lp = lock_path(td.path(), None);
1335 fs::write(
1336 &lp,
1337 r#"{"pid": "not-a-number", "hostname": "h", "acquired_at": "2025-01-01T00:00:00Z", "plan_id": null}"#,
1338 )
1339 .unwrap();
1340
1341 let err = LockFile::acquire(td.path(), None).unwrap_err();
1342 assert!(err.to_string().contains("failed to parse lock JSON"));
1343 }
1344
1345 #[test]
1348 fn lock_in_unicode_directory() {
1349 let td = tempdir().expect("tempdir");
1350 let unicode_dir = td.path().join("ünïcödé_目录_🔒");
1351 let lock = LockFile::acquire(&unicode_dir, None).expect("acquire in unicode dir");
1352 assert!(LockFile::is_locked(&unicode_dir, None).unwrap());
1353 let info = LockFile::read_lock_info(&unicode_dir, None).unwrap();
1354 assert_eq!(info.pid, std::process::id());
1355 drop(lock);
1356 assert!(!LockFile::is_locked(&unicode_dir, None).unwrap());
1357 }
1358
1359 #[test]
1360 fn lock_with_unicode_workspace_root() {
1361 let td = tempdir().expect("tempdir");
1362 let root = td.path().join("プロジェクト");
1363 let lock = LockFile::acquire(td.path(), Some(&root)).unwrap();
1364 assert!(LockFile::is_locked(td.path(), Some(&root)).unwrap());
1365 lock.release().unwrap();
1366 assert!(!LockFile::is_locked(td.path(), Some(&root)).unwrap());
1367 }
1368
1369 #[test]
1370 fn lock_in_deeply_nested_unicode_path() {
1371 let td = tempdir().expect("tempdir");
1372 let deep = td.path().join("α").join("β").join("γ").join("δ");
1373 let lock = LockFile::acquire(&deep, None).unwrap();
1374 assert!(deep.exists());
1375 let info = LockFile::read_lock_info(&deep, None).unwrap();
1376 assert_eq!(info.pid, std::process::id());
1377 drop(lock);
1378 }
1379
1380 #[test]
1383 fn zero_timeout_breaks_old_lock() {
1384 let td = tempdir().expect("tempdir");
1385 let lp = lock_path(td.path(), None);
1386 let info = LockInfo {
1387 pid: 22222,
1388 hostname: "zero-host".to_string(),
1389 acquired_at: Utc::now() - chrono::Duration::seconds(2),
1390 plan_id: None,
1391 };
1392 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1393
1394 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(0)).unwrap();
1395 let new_info = LockFile::read_lock_info(td.path(), None).unwrap();
1396 assert_eq!(new_info.pid, std::process::id());
1397 drop(lock);
1398 }
1399
1400 #[test]
1401 fn zero_timeout_on_empty_dir_succeeds() {
1402 let td = tempdir().expect("tempdir");
1403 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(0)).unwrap();
1404 assert!(LockFile::is_locked(td.path(), None).unwrap());
1405 drop(lock);
1406 }
1407
1408 #[test]
1409 fn zero_timeout_removes_corrupt_lock() {
1410 let td = tempdir().expect("tempdir");
1411 let lp = lock_path(td.path(), None);
1412 fs::write(&lp, "garbage").unwrap();
1413
1414 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(0)).unwrap();
1415 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1416 assert_eq!(info.pid, std::process::id());
1417 drop(lock);
1418 }
1419
1420 #[test]
1423 fn very_large_timeout_does_not_remove_fresh_lock() {
1424 let td = tempdir().expect("tempdir");
1425 let lp = lock_path(td.path(), None);
1426 let info = LockInfo {
1427 pid: 11112,
1428 hostname: "large-timeout-host".to_string(),
1429 acquired_at: Utc::now() - chrono::Duration::hours(24 * 365),
1430 plan_id: None,
1431 };
1432 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1433
1434 let result = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(u64::MAX));
1436 assert!(result.is_err());
1437 assert!(
1438 result
1439 .unwrap_err()
1440 .to_string()
1441 .contains("lock already held")
1442 );
1443 }
1444
1445 #[test]
1446 fn very_large_timeout_on_empty_dir_succeeds() {
1447 let td = tempdir().expect("tempdir");
1448 let lock =
1449 LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(u64::MAX)).unwrap();
1450 assert!(LockFile::is_locked(td.path(), None).unwrap());
1451 drop(lock);
1452 }
1453
1454 #[test]
1455 fn max_duration_timeout_with_stale_lock() {
1456 let td = tempdir().expect("tempdir");
1457 let lp = lock_path(td.path(), None);
1458 let info = LockInfo {
1459 pid: 11113,
1460 hostname: "max-host".to_string(),
1461 acquired_at: Utc::now() - chrono::Duration::weeks(52 * 100),
1462 plan_id: Some("ancient-plan".to_string()),
1463 };
1464 fs::write(&lp, serde_json::to_string(&info).unwrap()).unwrap();
1465
1466 let result = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(u64::MAX));
1468 assert!(result.is_err());
1469 }
1470
1471 #[test]
1474 fn acquire_in_nonexistent_nested_directory() {
1475 let td = tempdir().expect("tempdir");
1476 let deep = td.path().join("a").join("b").join("c").join("d");
1477 assert!(!deep.exists());
1478 let lock = LockFile::acquire(&deep, None).unwrap();
1479 assert!(deep.exists());
1480 assert!(LockFile::is_locked(&deep, None).unwrap());
1481 drop(lock);
1482 }
1483
1484 #[test]
1485 fn release_already_deleted_lock_is_ok() {
1486 let td = tempdir().expect("tempdir");
1487 let lock = LockFile::acquire(td.path(), None).unwrap();
1488 let lp = lock_path(td.path(), None);
1489 fs::remove_file(&lp).unwrap();
1491 lock.release().unwrap();
1493 }
1494
1495 #[test]
1496 fn double_release_is_idempotent() {
1497 let td = tempdir().expect("tempdir");
1498 let lock = LockFile::acquire(td.path(), None).unwrap();
1499 lock.release().unwrap();
1500 lock.release().unwrap();
1501 assert!(!lock_path(td.path(), None).exists());
1502 }
1503
1504 #[test]
1505 fn set_plan_id_on_externally_deleted_lock_fails() {
1506 let td = tempdir().expect("tempdir");
1507 let lock = LockFile::acquire(td.path(), None).unwrap();
1508 let lp = lock_path(td.path(), None);
1509 fs::remove_file(&lp).unwrap();
1510 let err = lock.set_plan_id("orphan").unwrap_err();
1511 assert!(err.to_string().contains("does not exist"));
1512 }
1513
1514 #[test]
1515 fn read_lock_info_on_corrupt_file_fails() {
1516 let td = tempdir().expect("tempdir");
1517 let lp = lock_path(td.path(), None);
1518 fs::write(&lp, "not json").unwrap();
1519 let err = LockFile::read_lock_info(td.path(), None).unwrap_err();
1520 assert!(err.to_string().contains("failed to parse lock JSON"));
1521 }
1522
1523 #[derive(Debug)]
1527 #[allow(dead_code)]
1528 enum LockState {
1529 Unlocked,
1530 Locked(LockInfo),
1531 Stale(LockInfo),
1532 Corrupt(String),
1533 }
1534
1535 fn fixed_info(pid: u32, plan_id: Option<&str>) -> LockInfo {
1536 use chrono::TimeZone;
1537 LockInfo {
1538 pid,
1539 hostname: "snap-host".to_string(),
1540 acquired_at: Utc.with_ymd_and_hms(2025, 6, 15, 8, 30, 0).unwrap(),
1541 plan_id: plan_id.map(String::from),
1542 }
1543 }
1544
1545 #[test]
1546 fn snapshot_lock_state_unlocked() {
1547 let state = LockState::Unlocked;
1548 insta::assert_debug_snapshot!("lock_state_unlocked", state);
1549 }
1550
1551 #[test]
1552 fn snapshot_lock_state_locked_no_plan() {
1553 let state = LockState::Locked(fixed_info(100, None));
1554 insta::assert_debug_snapshot!("lock_state_locked_no_plan", state);
1555 }
1556
1557 #[test]
1558 fn snapshot_lock_state_locked_with_plan() {
1559 let state = LockState::Locked(fixed_info(200, Some("release-v1.0")));
1560 insta::assert_debug_snapshot!("lock_state_locked_with_plan", state);
1561 }
1562
1563 #[test]
1564 fn snapshot_lock_state_stale() {
1565 let state = LockState::Stale(fixed_info(300, Some("old-plan")));
1566 insta::assert_debug_snapshot!("lock_state_stale", state);
1567 }
1568
1569 #[test]
1570 fn snapshot_lock_state_corrupt() {
1571 let state = LockState::Corrupt("<<<not json>>>".to_string());
1572 insta::assert_debug_snapshot!("lock_state_corrupt", state);
1573 }
1574
1575 #[test]
1576 fn snapshot_lock_info_all_fields() {
1577 let info = fixed_info(42, Some("plan-xyz-789"));
1578 insta::assert_debug_snapshot!("lock_info_all_fields", info);
1579 }
1580
1581 #[test]
1582 fn snapshot_lock_info_no_plan_id() {
1583 let info = fixed_info(1, None);
1584 insta::assert_debug_snapshot!("lock_info_no_plan_id", info);
1585 }
1586}
1587
1588#[cfg(test)]
1589mod proptest_edge_cases {
1590 use super::*;
1591 use proptest::prelude::*;
1592 use tempfile::tempdir;
1593
1594 proptest! {
1595 #[test]
1598 fn acquire_release_always_paired(dir_suffix in "[a-zA-Z0-9]{1,16}") {
1599 let td = tempdir().expect("tempdir");
1600 let state_dir = td.path().join(dir_suffix);
1601
1602 prop_assert!(!lock_path(&state_dir, None).exists());
1604
1605 let lock = LockFile::acquire(&state_dir, None).expect("acquire");
1607 prop_assert!(lock_path(&state_dir, None).exists());
1608
1609 lock.release().expect("release");
1611 prop_assert!(!lock_path(&state_dir, None).exists());
1612 }
1613
1614 #[test]
1615 fn acquire_drop_always_paired(dir_suffix in "[a-zA-Z0-9]{1,16}") {
1616 let td = tempdir().expect("tempdir");
1617 let state_dir = td.path().join(dir_suffix);
1618
1619 prop_assert!(!lock_path(&state_dir, None).exists());
1620
1621 {
1622 let _lock = LockFile::acquire(&state_dir, None).expect("acquire");
1623 prop_assert!(lock_path(&state_dir, None).exists());
1624 }
1625 prop_assert!(!lock_path(&state_dir, None).exists());
1627 }
1628
1629 #[test]
1630 fn acquire_with_timeout_release_always_paired(
1631 dir_suffix in "[a-zA-Z0-9]{1,16}",
1632 timeout_secs in 1u64..=3600u64,
1633 ) {
1634 let td = tempdir().expect("tempdir");
1635 let state_dir = td.path().join(dir_suffix);
1636
1637 let lock = LockFile::acquire_with_timeout(
1638 &state_dir,
1639 None,
1640 Duration::from_secs(timeout_secs),
1641 ).expect("acquire");
1642 prop_assert!(lock_path(&state_dir, None).exists());
1643
1644 lock.release().expect("release");
1645 prop_assert!(!lock_path(&state_dir, None).exists());
1646 }
1647
1648 #[test]
1649 fn acquire_set_plan_release_always_paired(
1650 dir_suffix in "[a-zA-Z0-9]{1,16}",
1651 plan_id in "[a-zA-Z0-9_-]{1,32}",
1652 ) {
1653 let td = tempdir().expect("tempdir");
1654 let state_dir = td.path().join(dir_suffix);
1655
1656 let lock = LockFile::acquire(&state_dir, None).expect("acquire");
1657 lock.set_plan_id(&plan_id).expect("set_plan_id");
1658 let info = LockFile::read_lock_info(&state_dir, None).expect("read");
1659 prop_assert_eq!(info.plan_id.as_deref(), Some(plan_id.as_str()));
1660
1661 lock.release().expect("release");
1662 prop_assert!(!lock_path(&state_dir, None).exists());
1663 }
1664
1665 #[test]
1666 fn corrupt_lock_always_recoverable_with_timeout(
1667 dir_suffix in "[a-zA-Z0-9]{1,16}",
1668 garbage in "[^\x00]{1,256}",
1669 ) {
1670 let td = tempdir().expect("tempdir");
1671 let state_dir = td.path().join(&dir_suffix);
1672 fs::create_dir_all(&state_dir).expect("mkdir");
1673 let lp = lock_path(&state_dir, None);
1674 fs::write(&lp, &garbage).expect("write garbage");
1675
1676 let lock = LockFile::acquire_with_timeout(
1677 &state_dir,
1678 None,
1679 Duration::from_secs(3600),
1680 ).expect("should recover from corrupt lock");
1681
1682 let info = LockFile::read_lock_info(&state_dir, None).expect("read");
1683 prop_assert_eq!(info.pid, std::process::id());
1684
1685 lock.release().expect("release");
1686 prop_assert!(!lock_path(&state_dir, None).exists());
1687 }
1688 }
1689}
1690
1691#[cfg(test)]
1692mod hardened_tests {
1693 use super::*;
1694 use chrono::TimeZone;
1695 use tempfile::tempdir;
1696
1697 #[test]
1700 fn acquire_records_current_pid() {
1701 let td = tempdir().unwrap();
1702 let _lock = LockFile::acquire(td.path(), None).unwrap();
1703 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1704 assert_eq!(info.pid, std::process::id());
1705 }
1706
1707 #[test]
1708 fn acquire_timestamp_is_recent() {
1709 let before = Utc::now();
1710 let td = tempdir().unwrap();
1711 let _lock = LockFile::acquire(td.path(), None).unwrap();
1712 let after = Utc::now();
1713 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1714 assert!(info.acquired_at >= before);
1715 assert!(info.acquired_at <= after);
1716 }
1717
1718 #[test]
1719 fn plan_id_is_none_immediately_after_acquire() {
1720 let td = tempdir().unwrap();
1721 let _lock = LockFile::acquire(td.path(), None).unwrap();
1722 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1723 assert_eq!(info.plan_id, None);
1724 }
1725
1726 #[test]
1729 fn plan_id_matches_after_set() {
1730 let td = tempdir().unwrap();
1731 let lock = LockFile::acquire(td.path(), None).unwrap();
1732 lock.set_plan_id("abc-123").unwrap();
1733 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1734 assert_eq!(info.plan_id.as_deref(), Some("abc-123"));
1735 }
1736
1737 #[test]
1738 fn plan_id_does_not_match_different_value() {
1739 let td = tempdir().unwrap();
1740 let lock = LockFile::acquire(td.path(), None).unwrap();
1741 lock.set_plan_id("plan-a").unwrap();
1742 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1743 assert_ne!(info.plan_id.as_deref(), Some("plan-b"));
1744 }
1745
1746 #[test]
1747 fn set_plan_id_with_empty_string() {
1748 let td = tempdir().unwrap();
1749 let lock = LockFile::acquire(td.path(), None).unwrap();
1750 lock.set_plan_id("").unwrap();
1751 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1752 assert_eq!(info.plan_id, Some(String::new()));
1753 }
1754
1755 #[test]
1756 fn set_plan_id_overwrites_previous() {
1757 let td = tempdir().unwrap();
1758 let lock = LockFile::acquire(td.path(), None).unwrap();
1759 lock.set_plan_id("first").unwrap();
1760 lock.set_plan_id("second").unwrap();
1761 lock.set_plan_id("third").unwrap();
1762 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1763 assert_eq!(info.plan_id.as_deref(), Some("third"));
1764 }
1765
1766 #[test]
1769 fn drop_in_inner_scope_allows_reacquire() {
1770 let td = tempdir().unwrap();
1771 {
1772 let _lock = LockFile::acquire(td.path(), None).unwrap();
1773 }
1774 let lock2 = LockFile::acquire(td.path(), None).unwrap();
1776 assert!(LockFile::is_locked(td.path(), None).unwrap());
1777 drop(lock2);
1778 }
1779
1780 #[test]
1781 fn explicit_release_then_reacquire() {
1782 let td = tempdir().unwrap();
1783 let lock = LockFile::acquire(td.path(), None).unwrap();
1784 lock.release().unwrap();
1785 let _lock2 = LockFile::acquire(td.path(), None).unwrap();
1786 assert!(LockFile::is_locked(td.path(), None).unwrap());
1787 }
1788
1789 #[test]
1792 fn lock_file_json_has_exactly_four_keys() {
1793 let td = tempdir().unwrap();
1794 let _lock = LockFile::acquire(td.path(), None).unwrap();
1795 let lp = lock_path(td.path(), None);
1796 let content = fs::read_to_string(&lp).unwrap();
1797 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1798 let obj = parsed.as_object().unwrap();
1799 assert_eq!(obj.len(), 4);
1800 assert!(obj.contains_key("pid"));
1801 assert!(obj.contains_key("hostname"));
1802 assert!(obj.contains_key("acquired_at"));
1803 assert!(obj.contains_key("plan_id"));
1804 }
1805
1806 #[test]
1807 fn lock_file_json_plan_id_null_when_unset() {
1808 let td = tempdir().unwrap();
1809 let _lock = LockFile::acquire(td.path(), None).unwrap();
1810 let lp = lock_path(td.path(), None);
1811 let content = fs::read_to_string(&lp).unwrap();
1812 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1813 assert!(parsed["plan_id"].is_null());
1814 }
1815
1816 #[test]
1817 fn lock_file_json_plan_id_string_when_set() {
1818 let td = tempdir().unwrap();
1819 let lock = LockFile::acquire(td.path(), None).unwrap();
1820 lock.set_plan_id("my-plan").unwrap();
1821 let lp = lock_path(td.path(), None);
1822 let content = fs::read_to_string(&lp).unwrap();
1823 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1824 assert_eq!(parsed["plan_id"].as_str(), Some("my-plan"));
1825 }
1826
1827 #[test]
1830 fn force_acquire_via_timeout_replaces_all_metadata() {
1831 let td = tempdir().unwrap();
1832 let lp = lock_path(td.path(), None);
1833 let old = LockInfo {
1834 pid: 65432,
1835 hostname: "old-machine".to_string(),
1836 acquired_at: Utc::now() - chrono::Duration::hours(3),
1837 plan_id: Some("stale-run".to_string()),
1838 };
1839 fs::write(&lp, serde_json::to_string(&old).unwrap()).unwrap();
1840
1841 let _lock =
1842 LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(60)).unwrap();
1843 let info = LockFile::read_lock_info(td.path(), None).unwrap();
1844 assert_ne!(info.pid, 65432);
1845 assert_ne!(info.hostname, "old-machine");
1846 assert!(info.plan_id.is_none());
1847 }
1848
1849 #[test]
1852 fn lock_path_empty_workspace_root_differs_from_none() {
1853 let base = PathBuf::from("state");
1854 let with_empty = lock_path(&base, Some(Path::new("")));
1855 let without = lock_path(&base, None);
1856 assert_ne!(with_empty, without);
1857 }
1858
1859 #[test]
1860 fn lock_path_dot_and_dotdot_roots_differ() {
1861 let base = PathBuf::from("state");
1862 let p1 = lock_path(&base, Some(Path::new(".")));
1863 let p2 = lock_path(&base, Some(Path::new("..")));
1864 assert_ne!(p1, p2);
1865 }
1866
1867 #[test]
1870 fn snapshot_lock_info_with_empty_plan_id() {
1871 let info = LockInfo {
1872 pid: 42,
1873 hostname: "snap-host".to_string(),
1874 acquired_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
1875 plan_id: Some(String::new()),
1876 };
1877 let json = serde_json::to_string_pretty(&info).unwrap();
1878 insta::assert_snapshot!("lock_info_empty_plan_id", json);
1879 }
1880
1881 #[test]
1882 fn snapshot_lock_info_json_key_order() {
1883 let info = LockInfo {
1884 pid: 1,
1885 hostname: "h".to_string(),
1886 acquired_at: Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap(),
1887 plan_id: Some("p".to_string()),
1888 };
1889 let json = serde_json::to_string_pretty(&info).unwrap();
1890 insta::assert_snapshot!("lock_info_json_key_order", json);
1892 }
1893
1894 #[test]
1895 fn snapshot_lock_file_after_set_plan_id() {
1896 let info = LockInfo {
1897 pid: 42,
1898 hostname: "build-host".to_string(),
1899 acquired_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
1900 plan_id: Some("updated-plan-456".to_string()),
1901 };
1902 let json = serde_json::to_string_pretty(&info).unwrap();
1903 insta::assert_snapshot!("lock_file_after_set_plan_id", json);
1904 }
1905}
1906
1907#[cfg(test)]
1908mod hardened_proptests {
1909 use super::*;
1910 use proptest::prelude::*;
1911 use tempfile::tempdir;
1912
1913 proptest! {
1914 #[test]
1915 fn arbitrary_plan_ids_never_panic_on_set(
1916 plan_id in ".*",
1917 ) {
1918 let td = tempdir().expect("tempdir");
1919 let lock = LockFile::acquire(td.path(), None).expect("acquire");
1920 let _ = lock.set_plan_id(&plan_id);
1922 drop(lock);
1923 }
1924
1925 #[test]
1926 fn arbitrary_paths_never_panic_on_lock_path(
1927 dir in "[a-zA-Z0-9_./-]{0,128}",
1928 root in proptest::option::of("[a-zA-Z0-9_./-]{0,128}"),
1929 ) {
1930 let base = PathBuf::from(&dir);
1931 let root_path = root.as_ref().map(PathBuf::from);
1932 let _ = lock_path(&base, root_path.as_deref());
1934 }
1935
1936 #[test]
1937 fn read_lock_info_on_arbitrary_content_never_panics(
1938 content in ".*",
1939 ) {
1940 let td = tempdir().expect("tempdir");
1941 let lp = lock_path(td.path(), None);
1942 std::fs::write(&lp, content.as_bytes()).expect("write");
1943 let _ = LockFile::read_lock_info(td.path(), None);
1945 }
1946
1947 #[test]
1948 fn plan_id_roundtrip_matches_for_arbitrary_ids(
1949 plan_id in "[^\x00]{1,256}",
1950 ) {
1951 let td = tempdir().expect("tempdir");
1952 let lock = LockFile::acquire(td.path(), None).expect("acquire");
1953 lock.set_plan_id(&plan_id).expect("set");
1954 let info = LockFile::read_lock_info(td.path(), None).expect("read");
1955 prop_assert_eq!(info.plan_id.as_deref(), Some(plan_id.as_str()));
1956 drop(lock);
1957 }
1958 }
1959}
1960
1961#[cfg(test)]
1962mod lock_edge_case_tests {
1963 use super::*;
1964 use tempfile::tempdir;
1965
1966 #[test]
1969 fn stale_lock_with_wrong_pid_replaced_by_timeout() {
1970 let td = tempdir().expect("tempdir");
1971 let lp = lock_path(td.path(), None);
1972
1973 let info = LockInfo {
1975 pid: 1,
1976 hostname: "other-machine".to_string(),
1977 acquired_at: Utc::now() - chrono::Duration::hours(3),
1978 plan_id: Some("old-plan".to_string()),
1979 };
1980 std::fs::write(&lp, serde_json::to_string(&info).expect("ser")).expect("write");
1981
1982 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600))
1983 .expect("should replace stale lock with wrong PID");
1984 let new_info = LockFile::read_lock_info(td.path(), None).expect("read");
1985 assert_eq!(new_info.pid, std::process::id());
1986 assert!(new_info.plan_id.is_none());
1987 drop(lock);
1988 }
1989
1990 #[test]
1993 fn truncated_json_lock_is_treated_as_corrupt() {
1994 let td = tempdir().expect("tempdir");
1995 let lp = lock_path(td.path(), None);
1996 std::fs::write(&lp, r#"{"pid": 42, "hostname":"#).expect("write");
1997
1998 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600))
1999 .expect("should replace corrupt lock");
2000 let info = LockFile::read_lock_info(td.path(), None).expect("read");
2001 assert_eq!(info.pid, std::process::id());
2002 drop(lock);
2003 }
2004
2005 #[test]
2008 fn empty_lock_file_treated_as_corrupt() {
2009 let td = tempdir().expect("tempdir");
2010 let lp = lock_path(td.path(), None);
2011 std::fs::write(&lp, "").expect("write");
2012
2013 let lock = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(3600))
2014 .expect("should replace empty lock");
2015 let info = LockFile::read_lock_info(td.path(), None).expect("read");
2016 assert_eq!(info.pid, std::process::id());
2017 drop(lock);
2018 }
2019
2020 #[test]
2023 fn lock_in_deeply_nested_directory() {
2024 let td = tempdir().expect("tempdir");
2025 let deep = td.path().join("a").join("b").join("c").join("d");
2026 let lock = LockFile::acquire(&deep, None).expect("acquire in deep dir");
2028 assert!(LockFile::is_locked(&deep, None).expect("is_locked"));
2029 drop(lock);
2030 }
2031
2032 #[test]
2035 fn lock_path_with_unicode_workspace_root() {
2036 let base = std::path::PathBuf::from("state");
2037 let root1 = std::path::Path::new("/ワークスペース/α");
2038 let root2 = std::path::Path::new("/ワークスペース/β");
2039 let p1 = lock_path(&base, Some(root1));
2040 let p2 = lock_path(&base, Some(root2));
2041 assert_ne!(p1, p2);
2043 assert_eq!(lock_path(&base, Some(root1)), p1);
2045 }
2046
2047 #[test]
2050 fn lock_json_structure_after_set_plan_id() {
2051 let td = tempdir().expect("tempdir");
2052 let lock = LockFile::acquire(td.path(), None).expect("acquire");
2053 lock.set_plan_id("edge-plan-🚀").expect("set");
2054
2055 let lp = lock_path(td.path(), None);
2056 let content = std::fs::read_to_string(&lp).expect("read");
2057 let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse");
2058 assert_eq!(parsed["plan_id"].as_str(), Some("edge-plan-🚀"));
2059 assert!(parsed["pid"].is_number());
2060 assert!(parsed["hostname"].is_string());
2061 drop(lock);
2062 }
2063}