Skip to main content

shipper_core/ops/lock/
mod.rs

1//! File-based locking mechanism to prevent concurrent operations.
2//!
3//! This module provides a simple file-based lock that can be used to prevent
4//! concurrent access to shared resources across processes. The lock file
5//! contains metadata about the lock holder (PID, hostname, timestamp).
6//!
7//! Absorbed from the standalone `shipper-lock` crate during the decrating
8//! effort (see `docs/decrating-plan.md` §6 Phase 2). The public surface at
9//! `shipper::lock` is preserved via a re-export in `crate::lib`.
10//!
11//! # Example
12//!
13//! ```
14//! use shipper_core::lock::LockFile;
15//! use std::path::Path;
16//!
17//! # fn example() -> anyhow::Result<()> {
18//! // Acquire a lock
19//! let lock = LockFile::acquire(Path::new(".shipper"), None)?;
20//!
21//! // Check if locked
22//! assert!(LockFile::is_locked(Path::new(".shipper"), None)?);
23//!
24//! // Lock is automatically released when dropped
25//! drop(lock);
26//! # Ok(())
27//! # }
28//! ```
29
30use 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
39/// Default lock file name
40pub const LOCK_FILE: &str = "lock";
41
42/// Information stored in the lock file
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LockInfo {
45    /// Process ID of the lock holder
46    pub pid: u32,
47    /// Hostname where the lock was acquired
48    pub hostname: String,
49    /// When the lock was acquired
50    pub acquired_at: DateTime<Utc>,
51    /// Optional plan ID being executed
52    pub plan_id: Option<String>,
53}
54
55/// Lock file handle that automatically releases on Drop
56#[derive(Debug)]
57pub struct LockFile {
58    path: PathBuf,
59}
60
61impl LockFile {
62    /// Acquire a lock file in the specified state directory
63    ///
64    /// This will fail if a lock already exists and is not stale.
65    /// Use `is_locked` first to check, or use `acquire_with_timeout` for
66    /// automatic stale lock handling.
67    ///
68    /// # Example
69    ///
70    /// ```
71    /// use shipper_core::lock::LockFile;
72    /// use std::path::Path;
73    ///
74    /// # fn example() -> anyhow::Result<()> {
75    /// let lock = LockFile::acquire(Path::new(".mylock"), None)?;
76    /// # drop(lock);
77    /// # Ok(())
78    /// # }
79    /// ```
80    pub fn acquire(state_dir: &Path, workspace_root: Option<&Path>) -> Result<Self> {
81        let lock_path = lock_path(state_dir, workspace_root);
82
83        // Create state directory if it doesn't exist
84        fs::create_dir_all(state_dir)
85            .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
86
87        // Check if lock already exists
88        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        // Get current process info
100        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        // Write lock file atomically
111        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        // Sync parent directory for durability
127        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    /// Acquire a lock, automatically removing stale locks older than timeout
137    ///
138    /// # Arguments
139    ///
140    /// * `state_dir` - Directory to store the lock file
141    /// * `workspace_root` - Optional workspace root to hash for avoiding global lock collisions
142    /// * `timeout` - Age threshold for considering a lock stale
143    ///
144    /// # Example
145    ///
146    /// ```
147    /// use shipper_core::lock::LockFile;
148    /// use std::path::Path;
149    /// use std::time::Duration;
150    ///
151    /// # fn example() -> anyhow::Result<()> {
152    /// let lock = LockFile::acquire_with_timeout(
153    ///     Path::new(".mylock"),
154    ///     None,
155    ///     Duration::from_secs(3600)
156    /// )?;
157    /// # drop(lock);
158    /// # Ok(())
159    /// # }
160    /// ```
161    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                // chrono::Duration doesn't have to_std(), use num_seconds directly
172                if age.num_seconds().unsigned_abs() > timeout.as_secs() {
173                    // Lock is stale, remove it
174                    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                // Lock file exists but is corrupt, remove it
188                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    /// Release the lock file
198    ///
199    /// This is normally called automatically when the lock is dropped,
200    /// but can be called explicitly if needed.
201    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    /// Update the plan_id in the lock file
210    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    /// Check if a lock file exists
237    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    /// Read the lock file information
242    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        // Best effort to release the lock
250        let _ = self.release();
251    }
252}
253
254/// Read lock info from a specific path
255fn 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
263/// Get the lock file path for a state directory and optional workspace root
264pub 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                // 16 hex chars after the underscore
307                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                // age_hours >= 2 means at least 7200 seconds; timeout_secs < 3600
367                // so the lock is always stale relative to the timeout
368                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                // 1-hour timeout; lock is < 1 hour old → should fail
399                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                // Content is pretty-printed (contains newlines)
516                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        // Lock should be released after drop
561        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        // Create a lock with old timestamp
600        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        // Acquire with 1 hour timeout - should succeed and remove stale lock
614        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        // Create a fresh lock
627        let _lock1 = LockFile::acquire(td.path(), None).expect("first acquire");
628
629        // Try to acquire with timeout - should fail
630        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        // Should contain the LOCK_FILE prefix and a hex hash suffix
676        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        // Default path should NOT be locked
704        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        // Second release should not error even though file is gone
740        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        // Create a lock 30 minutes old, with a 1-hour timeout — should NOT be stale
808        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        // Create a stale lock with workspace root
829        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        // Should include PID of current process (the holder)
859        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    /// Helper to build a deterministic `LockInfo` for snapshot stability.
886    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    // ── Lock file content format ────────────────────────────────────────
896
897    #[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    // ── Lock error messages ─────────────────────────────────────────────
931
932    #[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        // Use a real lock so the file definitely exists on disk
960        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        // The message contains the dynamic current PID/host/age, so snapshot only the stable prefix
966        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        // Only snapshot the root cause message (path-independent part)
978        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        // Path-independent portion
993        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    // ── Lock status display ─────────────────────────────────────────────
1017
1018    #[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        // Snapshot only the filename (hash is deterministic for same input)
1053        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    // ── 1. Stale lock detection and recovery ────────────────────────────
1064
1065    #[test]
1066    fn stale_lock_exactly_at_timeout_boundary_is_not_removed() {
1067        // A lock whose age equals the timeout should NOT be considered stale
1068        // because the comparison is strictly greater-than.
1069        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        // Lock age ≈ timeout; due to time passing between write and check
1081        // this may be slightly over, so we use a timeout 1 second larger to
1082        // ensure the lock is truly at the boundary.
1083        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    // ── 2. Concurrent lock acquisition from multiple threads ────────────
1136
1137    #[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        // At most one thread should successfully acquire the lock.
1158        // Due to the non-atomic check-then-create in acquire(), more than
1159        // one *might* succeed in a race, but at least one must succeed.
1160        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    // ── 3. Force-break of existing lock ─────────────────────────────────
1195
1196    #[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        // Simulate a force-break: manually remove the lock file
1204        fs::remove_file(&lp).expect("force remove");
1205        assert!(!lp.exists());
1206
1207        // Now a new acquire should succeed
1208        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        // With timeout=0, any existing lock is considered stale (age > 0)
1217        // unless it was literally just created in the same second.
1218        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        // Force-break for root_a should not affect root_b
1242        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        // root_b should still be unlocked (never had a lock)
1247        assert!(!LockFile::is_locked(td.path(), Some(&root_b)).unwrap());
1248        drop(lock_a2);
1249    }
1250
1251    // ── 4. Lock file with corrupt/invalid content ───────────────────────
1252
1253    #[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        // acquire should fail because the file exists but can't be parsed
1260        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        // acquire_with_timeout should remove corrupt lock and succeed
1314        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    // ── 5. Lock in unicode directory path ───────────────────────────────
1346
1347    #[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    // ── 6. Lock with zero timeout ───────────────────────────────────────
1381
1382    #[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    // ── 7. Lock with very large timeout ─────────────────────────────────
1421
1422    #[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        // Timeout so large that even a year-old lock is not stale
1435        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        // u64::MAX seconds ≈ 584 billion years — nothing is stale
1467        let result = LockFile::acquire_with_timeout(td.path(), None, Duration::from_secs(u64::MAX));
1468        assert!(result.is_err());
1469    }
1470
1471    // ── 8. Lock file permissions edge cases ─────────────────────────────
1472
1473    #[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        // External process deletes the lock file
1490        fs::remove_file(&lp).unwrap();
1491        // release should not error
1492        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    // ── 9. Snapshot tests for LockState variants ────────────────────────
1524
1525    /// Represents the logical state of a lock for snapshot testing.
1526    #[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        // ── 10. Property test: lock acquire + release is always paired ──
1596
1597        #[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            // Before: not locked
1603            prop_assert!(!lock_path(&state_dir, None).exists());
1604
1605            // Acquire
1606            let lock = LockFile::acquire(&state_dir, None).expect("acquire");
1607            prop_assert!(lock_path(&state_dir, None).exists());
1608
1609            // Release
1610            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            // Drop should have released
1626            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    // ── Lock acquisition metadata ───────────────────────────────────────
1698
1699    #[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    // ── Plan ID matching ────────────────────────────────────────────────
1727
1728    #[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    // ── RAII drop release ───────────────────────────────────────────────
1767
1768    #[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        // After drop, should be able to re-acquire
1775        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    // ── Lock file JSON format stability ─────────────────────────────────
1790
1791    #[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    // ── Stale lock: force acquisition ───────────────────────────────────
1828
1829    #[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    // ── lock_path edge cases ────────────────────────────────────────────
1850
1851    #[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    // ── Snapshot tests ──────────────────────────────────────────────────
1868
1869    #[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        // Snapshot captures the exact key order to detect accidental reordering
1891        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            // set_plan_id should never panic regardless of input
1921            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            // lock_path should never panic
1933            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            // Should return Err, never panic
1944            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    // ── Stale lock with wrong (non-existent) PID ────────────────────
1967
1968    #[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        // Write a lock claiming PID 1 (init/system, not our process)
1974        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    // ── Lock file with truncated JSON (corrupt) ─────────────────────
1991
1992    #[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    // ── Lock file with empty content ────────────────────────────────
2006
2007    #[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    // ── Lock in deeply nested directory ──────────────────────────────
2021
2022    #[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        // acquire should create all intermediate dirs
2027        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    // ── Lock path with Unicode workspace root ───────────────────────
2033
2034    #[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        // Different roots should produce different paths
2042        assert_ne!(p1, p2);
2043        // Same root should be deterministic
2044        assert_eq!(lock_path(&base, Some(root1)), p1);
2045    }
2046
2047    // ── Acquire, set_plan_id, then re-read verifies JSON structure ──
2048
2049    #[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}