Skip to main content

iso_code/
state.rs

1//! State persistence: state.json v2 read/write/migrate.
2//!
3//! State lives at `<repo>/.git/iso-code/state.json` and is rewritten via a
4//! write-temp → fsync → rename sequence for crash safety. The file lock is
5//! scoped strictly around each read-modify-write. Unknown fields are preserved
6//! through a `#[serde(flatten)]` catch-all to keep newer writers forward
7//! compatible with older readers.
8
9use std::collections::HashMap;
10use std::fs;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::error::WorktreeError;
19use crate::types::{PortLease, WorktreeState};
20
21// ── state.json v2 schema ─────────────────────────────────────────────────
22
23/// Top-level state file (v2).
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[non_exhaustive]
26pub struct StateV2 {
27    pub schema_version: u64,
28    pub repo_id: String,
29    pub last_modified: DateTime<Utc>,
30    #[serde(default)]
31    pub active_worktrees: HashMap<String, ActiveWorktreeEntry>,
32    #[serde(default)]
33    pub stale_worktrees: HashMap<String, StaleWorktreeEntry>,
34    #[serde(default)]
35    pub port_leases: HashMap<String, PortLease>,
36    #[serde(default)]
37    pub config_snapshot: Option<ConfigSnapshot>,
38    #[serde(default)]
39    pub gc_history: Vec<GcHistoryEntry>,
40    /// Catch-all for forward compatibility.
41    #[serde(flatten)]
42    pub extra: HashMap<String, Value>,
43}
44
45/// An active worktree entry in state.json.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct ActiveWorktreeEntry {
49    pub path: String,
50    pub branch: String,
51    pub base_commit: String,
52    pub state: WorktreeState,
53    pub created_at: DateTime<Utc>,
54    #[serde(default)]
55    pub last_activity: Option<DateTime<Utc>>,
56    pub creator_pid: u32,
57    #[serde(default)]
58    pub creator_name: String,
59    pub session_uuid: String,
60    #[serde(default)]
61    pub adapter: Option<String>,
62    #[serde(default)]
63    pub setup_complete: bool,
64    #[serde(default)]
65    pub port: Option<u16>,
66    /// Catch-all for forward compatibility.
67    #[serde(flatten)]
68    pub extra: HashMap<String, Value>,
69}
70
71/// A stale (evicted) worktree entry in state.json.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[non_exhaustive]
74pub struct StaleWorktreeEntry {
75    pub original_path: String,
76    pub branch: String,
77    pub base_commit: String,
78    #[serde(default)]
79    pub creator_name: String,
80    pub session_uuid: String,
81    #[serde(default)]
82    pub port: Option<u16>,
83    #[serde(default)]
84    pub last_activity: Option<DateTime<Utc>>,
85    pub evicted_at: DateTime<Utc>,
86    #[serde(default)]
87    pub eviction_reason: String,
88    pub expires_at: DateTime<Utc>,
89    /// Catch-all for forward compatibility.
90    #[serde(flatten)]
91    pub extra: HashMap<String, Value>,
92}
93
94/// Snapshot of config written into state.json for diagnostics.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[non_exhaustive]
97pub struct ConfigSnapshot {
98    #[serde(default = "default_max_worktrees")]
99    pub max_worktrees: usize,
100    #[serde(default = "default_disk_threshold")]
101    pub disk_threshold_percent: u8,
102    #[serde(default = "default_gc_max_age")]
103    pub gc_max_age_days: u32,
104    #[serde(default = "default_port_start")]
105    pub port_range_start: u16,
106    #[serde(default = "default_port_end")]
107    pub port_range_end: u16,
108    #[serde(default = "default_stale_ttl")]
109    pub stale_metadata_ttl_days: u32,
110    /// Catch-all for forward compatibility.
111    #[serde(flatten)]
112    pub extra: HashMap<String, Value>,
113}
114
115fn default_max_worktrees() -> usize { 20 }
116fn default_disk_threshold() -> u8 { 90 }
117fn default_gc_max_age() -> u32 { 7 }
118fn default_port_start() -> u16 { 3100 }
119fn default_port_end() -> u16 { 5100 }
120fn default_stale_ttl() -> u32 { 30 }
121
122/// A single GC history record.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[non_exhaustive]
125pub struct GcHistoryEntry {
126    pub timestamp: DateTime<Utc>,
127    #[serde(default)]
128    pub removed: u32,
129    #[serde(default)]
130    pub evicted: u32,
131    #[serde(default)]
132    pub freed_mb: u64,
133    /// Catch-all for forward compatibility.
134    #[serde(flatten)]
135    pub extra: HashMap<String, Value>,
136}
137
138// ── Constructors ─────────────────────────────────────────────────────────
139
140impl StateV2 {
141    /// Create a fresh empty state for the given repo.
142    pub fn new_empty(repo_id: String) -> Self {
143        Self {
144            schema_version: 2,
145            repo_id,
146            last_modified: Utc::now(),
147            active_worktrees: HashMap::new(),
148            stale_worktrees: HashMap::new(),
149            port_leases: HashMap::new(),
150            config_snapshot: None,
151            gc_history: Vec::new(),
152            extra: HashMap::new(),
153        }
154    }
155}
156
157// ── Path helpers ─────────────────────────────────────────────────────────
158
159/// Compute the repo_id: sha256 hex of the absolute canonicalized repo path.
160pub fn compute_repo_id(repo_root: &Path) -> String {
161    use sha2::{Digest, Sha256};
162    let canonical = dunce::canonicalize(repo_root)
163        .unwrap_or_else(|_| repo_root.to_path_buf());
164    let mut hasher = Sha256::new();
165    hasher.update(canonical.to_string_lossy().as_bytes());
166    format!("{:x}", hasher.finalize())
167}
168
169/// Return the state directory: `<repo>/.git/iso-code/`.
170/// Respects `ISO_CODE_HOME` env var override and `Config.home_override`.
171pub fn state_dir(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
172    // Check config override first, then env var
173    if let Some(home) = home_override {
174        return home.to_path_buf();
175    }
176    if let Ok(home) = std::env::var("ISO_CODE_HOME") {
177        return PathBuf::from(home);
178    }
179    repo_root.join(".git").join("iso-code")
180}
181
182/// Return the state.json path.
183pub fn state_json_path(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
184    state_dir(repo_root, home_override).join("state.json")
185}
186
187/// Return the state.lock path.
188pub fn state_lock_path(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
189    state_dir(repo_root, home_override).join("state.lock")
190}
191
192/// Ensure the state directory exists. Called from Manager::new().
193pub fn ensure_state_dir(repo_root: &Path, home_override: Option<&Path>) -> Result<(), WorktreeError> {
194    let dir = state_dir(repo_root, home_override);
195    fs::create_dir_all(&dir)?;
196    Ok(())
197}
198
199// ── Read / Write / Migrate ───────────────────────────────────────────────
200
201/// Read and parse state.json, migrating from v1 if needed.
202/// If the file is missing, returns a fresh empty state.
203///
204/// If the file exists but cannot be parsed as JSON, the corrupt file is
205/// renamed to `state.json.corrupt.<timestamp>` and a fresh empty state is
206/// returned. The next `list()` will repopulate active worktrees from
207/// `git worktree list`. A migration failure (unknown schema version) is
208/// surfaced as `StateCorrupted` — we don't clobber data we can't interpret.
209pub fn read_state(repo_root: &Path, home_override: Option<&Path>) -> Result<StateV2, WorktreeError> {
210    let path = state_json_path(repo_root, home_override);
211
212    if !path.exists() {
213        let repo_id = compute_repo_id(repo_root);
214        return Ok(StateV2::new_empty(repo_id));
215    }
216
217    let raw_bytes = fs::read(&path)?;
218    let raw: Value = match serde_json::from_slice(&raw_bytes) {
219        Ok(v) => v,
220        Err(e) => {
221            let ts = chrono::Utc::now().timestamp();
222            let backup = path.with_extension(format!("json.corrupt.{ts}"));
223            let rename_result = fs::rename(&path, &backup);
224            eprintln!(
225                "[iso-code] WARNING: state.json is corrupt ({e}); {} rebuilding from git",
226                match &rename_result {
227                    Ok(_) => format!("moved to {}", backup.display()),
228                    Err(re) => format!("could not back up ({re});"),
229                }
230            );
231            let repo_id = compute_repo_id(repo_root);
232            return Ok(StateV2::new_empty(repo_id));
233        }
234    };
235
236    migrate(raw)
237}
238
239/// Atomically write state.json: write tmp -> fsync -> rename.
240pub fn write_state(
241    repo_root: &Path,
242    home_override: Option<&Path>,
243    state: &mut StateV2,
244) -> Result<(), WorktreeError> {
245    let dir = state_dir(repo_root, home_override);
246    fs::create_dir_all(&dir)?;
247
248    let final_path = dir.join("state.json");
249    let tmp_path = dir.join("state.json.tmp");
250
251    // Update last_modified timestamp
252    state.last_modified = Utc::now();
253
254    let json = serde_json::to_string_pretty(state).map_err(|e| {
255        WorktreeError::StateCorrupted {
256            reason: format!("serialization failed: {e}"),
257        }
258    })?;
259
260    // Write to tmp file
261    {
262        let mut file = fs::File::create(&tmp_path)?;
263        file.write_all(json.as_bytes())?;
264        file.sync_all()?; // fsync
265    }
266
267    // Atomic rename
268    fs::rename(&tmp_path, &final_path)?;
269
270    Ok(())
271}
272
273/// Schema migration dispatcher. Upgrades older state files to the current
274/// schema version in-place before they are returned to callers.
275pub fn migrate(raw: Value) -> Result<StateV2, WorktreeError> {
276    let version = raw.get("schema_version")
277        .and_then(|v| v.as_u64())
278        .or_else(|| raw.get("version").and_then(|v| v.as_u64()))
279        .unwrap_or(1);
280
281    match version {
282        1 => migrate_v1_to_v2(raw),
283        2 => serde_json::from_value(raw).map_err(|e| WorktreeError::StateCorrupted {
284            reason: e.to_string(),
285        }),
286        v => Err(WorktreeError::StateCorrupted {
287            reason: format!("unknown schema version {v}"),
288        }),
289    }
290}
291
292/// Migrate a v1 state file to the v2 schema.
293///
294/// v1 format: `{ "version": 1, "worktrees": { ... } }`.
295fn migrate_v1_to_v2(raw: Value) -> Result<StateV2, WorktreeError> {
296    let repo_id = raw.get("repo_id")
297        .and_then(|v| v.as_str())
298        .unwrap_or("")
299        .to_string();
300
301    let now = Utc::now();
302
303    // Convert v1 worktrees map to v2 active_worktrees
304    let mut active_worktrees = HashMap::new();
305    if let Some(wts) = raw.get("worktrees").and_then(|v| v.as_object()) {
306        for (key, val) in wts {
307            let entry: ActiveWorktreeEntry = serde_json::from_value(val.clone())
308                .map_err(|e| WorktreeError::StateCorrupted {
309                    reason: format!("v1 worktree entry '{key}' invalid: {e}"),
310                })?;
311            active_worktrees.insert(key.clone(), entry);
312        }
313    }
314
315    Ok(StateV2 {
316        schema_version: 2,
317        repo_id,
318        last_modified: now,
319        active_worktrees,
320        stale_worktrees: HashMap::new(),
321        port_leases: HashMap::new(),
322        config_snapshot: None,
323        gc_history: Vec::new(),
324        extra: HashMap::new(),
325    })
326}
327
328/// Default lock acquisition timeout when the caller doesn't supply one.
329/// Tests and internal callers without a `Config` in hand use this.
330const DEFAULT_LOCK_TIMEOUT_MS: u64 = 30_000;
331
332/// Read-modify-write helper: acquires state.lock, reads state, applies the
333/// closure, then writes back. The lock is released as soon as this function
334/// returns — callers must not perform long-running work inside the closure.
335///
336/// Uses `DEFAULT_LOCK_TIMEOUT_MS`. Prefer [`with_state_timeout`] from inside a
337/// Manager so `Config.lock_timeout_ms` is honored.
338pub fn with_state<F>(
339    repo_root: &Path,
340    home_override: Option<&Path>,
341    f: F,
342) -> Result<StateV2, WorktreeError>
343where
344    F: FnOnce(&mut StateV2) -> Result<(), WorktreeError>,
345{
346    with_state_timeout(repo_root, home_override, DEFAULT_LOCK_TIMEOUT_MS, f)
347}
348
349/// Like [`with_state`] but takes an explicit lock acquisition timeout.
350pub fn with_state_timeout<F>(
351    repo_root: &Path,
352    home_override: Option<&Path>,
353    lock_timeout_ms: u64,
354    f: F,
355) -> Result<StateV2, WorktreeError>
356where
357    F: FnOnce(&mut StateV2) -> Result<(), WorktreeError>,
358{
359    let lock_path = state_lock_path(repo_root, home_override);
360    let _lock = crate::lock::StateLock::acquire(&lock_path, lock_timeout_ms)?;
361
362    let mut state = read_state(repo_root, home_override)?;
363    f(&mut state)?;
364    write_state(repo_root, home_override, &mut state)?;
365    Ok(state)
366    // _lock dropped here → flock released
367}
368
369// ── Tests ────────────────────────────────────────────────────────────────
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tempfile::TempDir;
375
376    fn setup_repo() -> TempDir {
377        let dir = TempDir::new().unwrap();
378        // Create .git directory to mimic a repo
379        fs::create_dir_all(dir.path().join(".git")).unwrap();
380        dir
381    }
382
383    #[test]
384    fn test_compute_repo_id_deterministic() {
385        let dir = setup_repo();
386        let id1 = compute_repo_id(dir.path());
387        let id2 = compute_repo_id(dir.path());
388        assert_eq!(id1, id2);
389        assert_eq!(id1.len(), 64); // sha256 hex = 64 chars
390    }
391
392    #[test]
393    fn test_state_dir_default() {
394        let dir = setup_repo();
395        let sd = state_dir(dir.path(), None);
396        assert!(sd.ends_with("iso-code"));
397        assert!(sd.to_string_lossy().contains(".git"));
398    }
399
400    #[test]
401    fn test_state_dir_home_override() {
402        let dir = setup_repo();
403        let override_path = dir.path().join("custom");
404        let sd = state_dir(dir.path(), Some(&override_path));
405        assert_eq!(sd, override_path);
406    }
407
408    #[test]
409    fn test_ensure_state_dir_creates_directory() {
410        let dir = setup_repo();
411        let sd = state_dir(dir.path(), None);
412        assert!(!sd.exists());
413        ensure_state_dir(dir.path(), None).unwrap();
414        assert!(sd.exists());
415    }
416
417    #[test]
418    fn test_new_empty_state() {
419        let state = StateV2::new_empty("test-repo-id".to_string());
420        assert_eq!(state.schema_version, 2);
421        assert_eq!(state.repo_id, "test-repo-id");
422        assert!(state.active_worktrees.is_empty());
423        assert!(state.stale_worktrees.is_empty());
424        assert!(state.port_leases.is_empty());
425        assert!(state.gc_history.is_empty());
426    }
427
428    #[test]
429    fn test_serialization_roundtrip() {
430        let mut state = StateV2::new_empty("roundtrip-test".to_string());
431
432        // Add an active worktree
433        state.active_worktrees.insert(
434            "feature-auth".to_string(),
435            ActiveWorktreeEntry {
436                path: "/tmp/worktrees/feature-auth".to_string(),
437                branch: "feature-auth".to_string(),
438                base_commit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
439                state: WorktreeState::Active,
440                created_at: Utc::now(),
441                last_activity: Some(Utc::now()),
442                creator_pid: 12345,
443                creator_name: "test".to_string(),
444                session_uuid: "f7a3b9c1-2d4e-4f56-a789-0123456789ab".to_string(),
445                adapter: None,
446                setup_complete: true,
447                port: Some(3200),
448                extra: HashMap::new(),
449            },
450        );
451
452        // Add a stale worktree
453        state.stale_worktrees.insert(
454            "old-refactor".to_string(),
455            StaleWorktreeEntry {
456                original_path: "/tmp/worktrees/old-refactor".to_string(),
457                branch: "refactor/db-layer".to_string(),
458                base_commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(),
459                creator_name: "alice".to_string(),
460                session_uuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(),
461                port: Some(3100),
462                last_activity: Some(Utc::now()),
463                evicted_at: Utc::now(),
464                eviction_reason: "auto-gc: inactive >7 days".to_string(),
465                expires_at: Utc::now(),
466                extra: HashMap::new(),
467            },
468        );
469
470        // Serialize
471        let json = serde_json::to_string_pretty(&state).unwrap();
472
473        // Deserialize
474        let parsed: StateV2 = serde_json::from_str(&json).unwrap();
475
476        assert_eq!(parsed.schema_version, 2);
477        assert_eq!(parsed.repo_id, "roundtrip-test");
478        assert_eq!(parsed.active_worktrees.len(), 1);
479        assert_eq!(parsed.stale_worktrees.len(), 1);
480
481        let active = parsed.active_worktrees.get("feature-auth").unwrap();
482        assert_eq!(active.branch, "feature-auth");
483        assert_eq!(active.port, Some(3200));
484        assert!(active.setup_complete);
485
486        let stale = parsed.stale_worktrees.get("old-refactor").unwrap();
487        assert_eq!(stale.branch, "refactor/db-layer");
488        assert_eq!(stale.eviction_reason, "auto-gc: inactive >7 days");
489    }
490
491    #[test]
492    fn test_forward_compatibility_unknown_fields_preserved() {
493        let json = r#"{
494            "schema_version": 2,
495            "repo_id": "test",
496            "last_modified": "2026-04-13T14:22:00Z",
497            "active_worktrees": {},
498            "stale_worktrees": {},
499            "port_leases": {},
500            "gc_history": [],
501            "future_field": "hello from the future",
502            "another_unknown": 42
503        }"#;
504
505        let state: StateV2 = serde_json::from_str(json).unwrap();
506        assert_eq!(state.extra.get("future_field").unwrap(), "hello from the future");
507        assert_eq!(state.extra.get("another_unknown").unwrap(), 42);
508
509        // Re-serialize and verify unknown fields survive
510        let reserialized = serde_json::to_string(&state).unwrap();
511        assert!(reserialized.contains("future_field"));
512        assert!(reserialized.contains("another_unknown"));
513    }
514
515    #[test]
516    fn test_active_worktree_entry_unknown_fields() {
517        let json = r#"{
518            "path": "/tmp/wt",
519            "branch": "feat",
520            "base_commit": "abc123",
521            "state": "Active",
522            "created_at": "2026-04-10T09:00:00Z",
523            "creator_pid": 100,
524            "creator_name": "test",
525            "session_uuid": "uuid-1",
526            "new_v3_field": true
527        }"#;
528
529        let entry: ActiveWorktreeEntry = serde_json::from_str(json).unwrap();
530        assert_eq!(entry.branch, "feat");
531        assert!(entry.extra.contains_key("new_v3_field"));
532
533        let reserialized = serde_json::to_string(&entry).unwrap();
534        assert!(reserialized.contains("new_v3_field"));
535    }
536
537    #[test]
538    fn test_read_state_missing_file_returns_empty() {
539        let dir = setup_repo();
540        let state = read_state(dir.path(), None).unwrap();
541        assert_eq!(state.schema_version, 2);
542        assert!(state.active_worktrees.is_empty());
543    }
544
545    #[test]
546    fn test_write_and_read_state_roundtrip() {
547        let dir = setup_repo();
548        let mut state = StateV2::new_empty(compute_repo_id(dir.path()));
549        state.active_worktrees.insert(
550            "test-branch".to_string(),
551            ActiveWorktreeEntry {
552                path: "/tmp/test".to_string(),
553                branch: "test-branch".to_string(),
554                base_commit: "abc123".to_string(),
555                state: WorktreeState::Active,
556                created_at: Utc::now(),
557                last_activity: None,
558                creator_pid: std::process::id(),
559                creator_name: "test".to_string(),
560                session_uuid: uuid::Uuid::new_v4().to_string(),
561                adapter: None,
562                setup_complete: false,
563                port: None,
564                extra: HashMap::new(),
565            },
566        );
567
568        write_state(dir.path(), None, &mut state).unwrap();
569
570        let read_back = read_state(dir.path(), None).unwrap();
571        assert_eq!(read_back.active_worktrees.len(), 1);
572        assert!(read_back.active_worktrees.contains_key("test-branch"));
573    }
574
575    #[test]
576    fn test_atomic_write_creates_no_tmp_file() {
577        let dir = setup_repo();
578        let mut state = StateV2::new_empty("test".to_string());
579        write_state(dir.path(), None, &mut state).unwrap();
580
581        let sd = state_dir(dir.path(), None);
582        assert!(sd.join("state.json").exists());
583        assert!(!sd.join("state.json.tmp").exists());
584    }
585
586    #[test]
587    fn test_migrate_v2_passthrough() {
588        let json = serde_json::json!({
589            "schema_version": 2,
590            "repo_id": "test",
591            "last_modified": "2026-04-13T14:22:00Z",
592            "active_worktrees": {},
593            "stale_worktrees": {},
594            "port_leases": {},
595            "gc_history": []
596        });
597
598        let state = migrate(json).unwrap();
599        assert_eq!(state.schema_version, 2);
600        assert_eq!(state.repo_id, "test");
601    }
602
603    #[test]
604    fn test_migrate_v1_to_v2() {
605        let json = serde_json::json!({
606            "version": 1,
607            "repo_id": "legacy-repo",
608            "worktrees": {
609                "feat-x": {
610                    "path": "/tmp/feat-x",
611                    "branch": "feat-x",
612                    "base_commit": "abc123",
613                    "state": "Active",
614                    "created_at": "2026-01-01T00:00:00Z",
615                    "creator_pid": 999,
616                    "creator_name": "old-tool",
617                    "session_uuid": "uuid-old"
618                }
619            }
620        });
621
622        let state = migrate(json).unwrap();
623        assert_eq!(state.schema_version, 2);
624        assert_eq!(state.repo_id, "legacy-repo");
625        assert_eq!(state.active_worktrees.len(), 1);
626        assert!(state.active_worktrees.contains_key("feat-x"));
627        assert!(state.stale_worktrees.is_empty());
628
629        let entry = state.active_worktrees.get("feat-x").unwrap();
630        assert_eq!(entry.branch, "feat-x");
631        assert_eq!(entry.creator_pid, 999);
632    }
633
634    #[test]
635    fn test_migrate_unknown_version_returns_error() {
636        let json = serde_json::json!({
637            "schema_version": 99,
638            "repo_id": "future"
639        });
640
641        let result = migrate(json);
642        assert!(result.is_err());
643        match result.unwrap_err() {
644            WorktreeError::StateCorrupted { reason } => {
645                assert!(reason.contains("unknown schema version 99"));
646            }
647            other => panic!("expected StateCorrupted, got: {other:?}"),
648        }
649    }
650
651    #[test]
652    fn test_corrupt_json_rebuilds_empty_and_backs_up() {
653        let dir = setup_repo();
654        ensure_state_dir(dir.path(), None).unwrap();
655        let path = state_json_path(dir.path(), None);
656        fs::write(&path, "this is not valid json {{{").unwrap();
657
658        let state = read_state(dir.path(), None).expect("corrupt JSON should rebuild empty");
659        assert!(state.active_worktrees.is_empty());
660        assert_eq!(state.schema_version, 2);
661
662        // Corrupt file moved aside with a .corrupt.<ts> suffix.
663        let sd = state_dir(dir.path(), None);
664        let moved: Vec<_> = fs::read_dir(&sd)
665            .unwrap()
666            .filter_map(|e| e.ok())
667            .filter(|e| {
668                e.file_name()
669                    .to_string_lossy()
670                    .starts_with("state.json.corrupt.")
671            })
672            .collect();
673        assert_eq!(moved.len(), 1, "corrupt backup should exist");
674    }
675
676    #[test]
677    fn test_unknown_schema_version_still_errors() {
678        // Migration failures don't wipe data — we return StateCorrupted
679        // so an operator can inspect.
680        let dir = setup_repo();
681        ensure_state_dir(dir.path(), None).unwrap();
682        let path = state_json_path(dir.path(), None);
683        fs::write(&path, r#"{"schema_version": 99, "repo_id": "x"}"#).unwrap();
684        let result = read_state(dir.path(), None);
685        assert!(matches!(result, Err(WorktreeError::StateCorrupted { .. })));
686    }
687
688    #[test]
689    fn test_with_state_read_modify_write() {
690        let dir = setup_repo();
691
692        // First write
693        let state = with_state(dir.path(), None, |s| {
694            s.active_worktrees.insert(
695                "branch-a".to_string(),
696                ActiveWorktreeEntry {
697                    path: "/tmp/a".to_string(),
698                    branch: "branch-a".to_string(),
699                    base_commit: "aaa".to_string(),
700                    state: WorktreeState::Active,
701                    created_at: Utc::now(),
702                    last_activity: None,
703                    creator_pid: 1,
704                    creator_name: "test".to_string(),
705                    session_uuid: "uuid-a".to_string(),
706                    adapter: None,
707                    setup_complete: false,
708                    port: None,
709                    extra: HashMap::new(),
710                },
711            );
712            Ok(())
713        })
714        .unwrap();
715        assert_eq!(state.active_worktrees.len(), 1);
716
717        // Second modify
718        let state = with_state(dir.path(), None, |s| {
719            s.active_worktrees.insert(
720                "branch-b".to_string(),
721                ActiveWorktreeEntry {
722                    path: "/tmp/b".to_string(),
723                    branch: "branch-b".to_string(),
724                    base_commit: "bbb".to_string(),
725                    state: WorktreeState::Active,
726                    created_at: Utc::now(),
727                    last_activity: None,
728                    creator_pid: 2,
729                    creator_name: "test".to_string(),
730                    session_uuid: "uuid-b".to_string(),
731                    adapter: None,
732                    setup_complete: false,
733                    port: None,
734                    extra: HashMap::new(),
735                },
736            );
737            Ok(())
738        })
739        .unwrap();
740        assert_eq!(state.active_worktrees.len(), 2);
741
742        // Verify on disk
743        let final_state = read_state(dir.path(), None).unwrap();
744        assert_eq!(final_state.active_worktrees.len(), 2);
745    }
746
747    #[test]
748    fn test_config_snapshot_roundtrip() {
749        let snap = ConfigSnapshot {
750            max_worktrees: 20,
751            disk_threshold_percent: 90,
752            gc_max_age_days: 7,
753            port_range_start: 3100,
754            port_range_end: 5100,
755            stale_metadata_ttl_days: 30,
756            extra: HashMap::new(),
757        };
758
759        let json = serde_json::to_string(&snap).unwrap();
760        let parsed: ConfigSnapshot = serde_json::from_str(&json).unwrap();
761        assert_eq!(parsed.max_worktrees, 20);
762        assert_eq!(parsed.port_range_start, 3100);
763    }
764
765    #[test]
766    fn test_gc_history_entry_roundtrip() {
767        let entry = GcHistoryEntry {
768            timestamp: Utc::now(),
769            removed: 2,
770            evicted: 1,
771            freed_mb: 1500,
772            extra: HashMap::new(),
773        };
774
775        let json = serde_json::to_string(&entry).unwrap();
776        let parsed: GcHistoryEntry = serde_json::from_str(&json).unwrap();
777        assert_eq!(parsed.removed, 2);
778        assert_eq!(parsed.freed_mb, 1500);
779    }
780
781    #[test]
782    fn test_stale_worktree_entry_roundtrip() {
783        let entry = StaleWorktreeEntry {
784            original_path: "/tmp/old".to_string(),
785            branch: "old-branch".to_string(),
786            base_commit: "dead".to_string(),
787            creator_name: "bob".to_string(),
788            session_uuid: "uuid-stale".to_string(),
789            port: Some(3100),
790            last_activity: Some(Utc::now()),
791            evicted_at: Utc::now(),
792            eviction_reason: "gc".to_string(),
793            expires_at: Utc::now(),
794            extra: HashMap::new(),
795        };
796
797        let json = serde_json::to_string(&entry).unwrap();
798        let parsed: StaleWorktreeEntry = serde_json::from_str(&json).unwrap();
799        assert_eq!(parsed.branch, "old-branch");
800        assert_eq!(parsed.port, Some(3100));
801    }
802
803    #[test]
804    fn test_iso_code_home_env_override() {
805        let dir = setup_repo();
806        let custom = dir.path().join("custom-home");
807
808        // home_override takes precedence
809        let sd = state_dir(dir.path(), Some(&custom));
810        assert_eq!(sd, custom);
811    }
812
813    #[test]
814    fn test_full_prd_example_deserializes() {
815        // Canonical v2 payload covering every documented field.
816        let json = r#"{
817            "schema_version": 2,
818            "repo_id": "abc123hash",
819            "last_modified": "2026-04-13T14:22:00Z",
820            "active_worktrees": {
821                "feature-auth": {
822                    "path": "/abs/path/.worktrees/feature-auth",
823                    "branch": "feature-auth",
824                    "base_commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
825                    "state": "Active",
826                    "created_at": "2026-04-10T09:00:00Z",
827                    "last_activity": "2026-04-13T14:00:00Z",
828                    "creator_pid": 12345,
829                    "creator_name": "claude-squad",
830                    "session_uuid": "f7a3b9c1-2d4e-4f56-a789-0123456789ab",
831                    "adapter": "shell-command",
832                    "setup_complete": true,
833                    "port": 3200
834                }
835            },
836            "stale_worktrees": {
837                "old-refactor": {
838                    "original_path": "/abs/path/.worktrees/old-refactor",
839                    "branch": "refactor/db-layer",
840                    "base_commit": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
841                    "creator_name": "alice",
842                    "session_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
843                    "port": 3100,
844                    "last_activity": "2026-03-15T16:30:00Z",
845                    "evicted_at": "2026-04-01T00:00:00Z",
846                    "eviction_reason": "auto-gc: inactive >7 days",
847                    "expires_at": "2026-05-01T00:00:00Z"
848                }
849            },
850            "port_leases": {
851                "feature-auth": {
852                    "port": 3200,
853                    "branch": "feature-auth",
854                    "session_uuid": "f7a3b9c1-2d4e-4f56-a789-0123456789ab",
855                    "pid": 12345,
856                    "created_at": "2026-04-10T09:00:00Z",
857                    "expires_at": "2026-04-10T17:00:00Z",
858                    "status": "active"
859                }
860            },
861            "config_snapshot": {
862                "max_worktrees": 20,
863                "disk_threshold_percent": 90,
864                "gc_max_age_days": 7,
865                "port_range_start": 3100,
866                "port_range_end": 5100,
867                "stale_metadata_ttl_days": 30
868            },
869            "gc_history": [
870                {
871                    "timestamp": "2026-04-11T00:00:00Z",
872                    "removed": 2,
873                    "evicted": 1,
874                    "freed_mb": 1500
875                }
876            ]
877        }"#;
878
879        let state: StateV2 = serde_json::from_str(json).unwrap();
880        assert_eq!(state.schema_version, 2);
881        assert_eq!(state.active_worktrees.len(), 1);
882        assert_eq!(state.stale_worktrees.len(), 1);
883        assert_eq!(state.port_leases.len(), 1);
884        assert!(state.config_snapshot.is_some());
885        assert_eq!(state.gc_history.len(), 1);
886
887        let cs = state.config_snapshot.unwrap();
888        assert_eq!(cs.max_worktrees, 20);
889        assert_eq!(cs.port_range_start, 3100);
890    }
891}