1use 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#[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 #[serde(flatten)]
42 pub extra: HashMap<String, Value>,
43}
44
45#[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 #[serde(flatten)]
68 pub extra: HashMap<String, Value>,
69}
70
71#[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 #[serde(flatten)]
91 pub extra: HashMap<String, Value>,
92}
93
94#[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 #[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#[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 #[serde(flatten)]
135 pub extra: HashMap<String, Value>,
136}
137
138impl StateV2 {
141 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
157pub 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
169pub fn state_dir(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
172 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
182pub fn state_json_path(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
184 state_dir(repo_root, home_override).join("state.json")
185}
186
187pub fn state_lock_path(repo_root: &Path, home_override: Option<&Path>) -> PathBuf {
189 state_dir(repo_root, home_override).join("state.lock")
190}
191
192pub 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
199pub 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
239pub 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 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 {
262 let mut file = fs::File::create(&tmp_path)?;
263 file.write_all(json.as_bytes())?;
264 file.sync_all()?; }
266
267 fs::rename(&tmp_path, &final_path)?;
269
270 Ok(())
271}
272
273pub 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
292fn 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 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
328const DEFAULT_LOCK_TIMEOUT_MS: u64 = 30_000;
331
332pub 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
349pub 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 }
368
369#[cfg(test)]
372mod tests {
373 use super::*;
374 use tempfile::TempDir;
375
376 fn setup_repo() -> TempDir {
377 let dir = TempDir::new().unwrap();
378 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); }
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 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 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 let json = serde_json::to_string_pretty(&state).unwrap();
472
473 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 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 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 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 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 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 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 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 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}