Skip to main content

sqry_core/config/
graph_config_persistence.rs

1//! Graph config persistence - atomic IO, integrity, recovery, and locking.
2//!
3//! Implements Step 3 of the Unified Graph Config Partition feature:
4//! - Atomic write protocol (crash-safe)
5//! - Integrity hashing with blake3
6//! - Recovery from corrupt/missing config files
7//! - Advisory file locking for writers
8//!
9//! # Write Protocol
10//!
11//! 1. Acquire lock
12//! 2. Read current config (if present)
13//! 3. Apply mutation
14//! 4. Serialize with deterministic ordering
15//! 5. Write to temp file + fsync
16//! 6. Rename current to `.previous`
17//! 7. Rename temp to current (atomic)
18//! 8. Fsync directory (where supported)
19//! 9. Release lock
20//!
21//! # Recovery Protocol
22//!
23//! 1. Try to load `config.json`
24//! 2. If corrupt, quarantine and try `.previous`
25//! 3. If both fail, return error requiring explicit action
26//!
27//! # Design
28//!
29//! See: `docs/development/unified-graph-config-partition/02_DESIGN.md`
30
31use std::fs::{self, File, OpenOptions};
32use std::io::Write;
33use std::path::{Path, PathBuf};
34use std::time::Duration;
35
36use chrono::Utc;
37use fs2::FileExt;
38use serde_json;
39use thiserror::Error;
40
41use super::graph_config_schema::{GraphConfigFile, SCHEMA_VERSION};
42use super::graph_config_store::{GraphConfigPaths, GraphConfigStore};
43
44/// Errors that can occur during config persistence operations
45#[derive(Debug, Error)]
46pub enum PersistenceError {
47    /// IO error
48    #[error("IO error at {path}: {source}")]
49    IoError {
50        /// Path where the IO error occurred
51        path: PathBuf,
52        /// The underlying IO error
53        #[source]
54        source: std::io::Error,
55    },
56
57    /// Failed to serialize config
58    #[error("Failed to serialize config: {0}")]
59    SerializationError(String),
60
61    /// Failed to deserialize config
62    #[error("Failed to deserialize config: {0}")]
63    DeserializationError(String),
64
65    /// Lock acquisition failed
66    #[error("Failed to acquire lock at {path} within {timeout_ms}ms")]
67    LockTimeout {
68        /// Path to the lock file
69        path: PathBuf,
70        /// Timeout duration in milliseconds
71        timeout_ms: u64,
72    },
73
74    /// Lock file is stale
75    #[error("Stale lock detected at {path}: {details}")]
76    StaleLock {
77        /// Path to the stale lock file
78        path: PathBuf,
79        /// Details about why the lock is considered stale
80        details: String,
81    },
82
83    /// Config file is corrupt
84    #[error("Corrupt config file at {path}: {reason}")]
85    CorruptConfig {
86        /// Path to the corrupt config file
87        path: PathBuf,
88        /// Reason why the config is considered corrupt
89        reason: String,
90    },
91
92    /// No usable config found after recovery attempts
93    #[error("No usable config found: {reason}")]
94    NoUsableConfig {
95        /// Reason why no usable config was found
96        reason: String,
97    },
98
99    /// Integrity mismatch
100    #[error("Integrity mismatch: expected {expected}, found {found}")]
101    IntegrityMismatch {
102        /// Expected hash value
103        expected: String,
104        /// Actual hash value found
105        found: String,
106    },
107}
108
109/// Result type for persistence operations
110pub type PersistenceResult<T> = Result<T, PersistenceError>;
111
112/// Status of integrity verification
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum IntegrityStatus {
115    /// Hash matches
116    Ok,
117    /// Hash doesn't match (possibly manual edit)
118    Mismatch,
119    /// No hash available
120    Unavailable,
121}
122
123/// Status of schema validation
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum SchemaStatus {
126    /// Schema is valid
127    Ok,
128    /// Schema is invalid
129    Invalid,
130}
131
132/// Report from loading a config file
133#[derive(Debug, Clone)]
134pub struct LoadReport {
135    /// Warnings encountered during load
136    pub warnings: Vec<String>,
137    /// Recovery actions taken
138    pub recovery_actions: Vec<String>,
139    /// Integrity verification status
140    pub integrity_status: IntegrityStatus,
141    /// Schema validation status
142    pub schema_status: SchemaStatus,
143}
144
145impl Default for LoadReport {
146    fn default() -> Self {
147        Self {
148            warnings: Vec::new(),
149            recovery_actions: Vec::new(),
150            integrity_status: IntegrityStatus::Unavailable,
151            schema_status: SchemaStatus::Ok,
152        }
153    }
154}
155
156/// Lock file content for diagnostics
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct LockInfo {
159    /// Process ID of lock holder
160    pub pid: u32,
161    /// Hostname of lock holder
162    pub hostname: String,
163    /// When the lock was acquired
164    pub acquired_at_utc: String,
165    /// Tool that acquired the lock (cli/lsp/mcp)
166    pub tool: String,
167    /// Optional command being executed
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub command: Option<String>,
170}
171
172impl Default for LockInfo {
173    fn default() -> Self {
174        Self {
175            pid: std::process::id(),
176            hostname: hostname::get().map_or_else(
177                |_| "unknown".to_string(),
178                |h| h.to_string_lossy().to_string(),
179            ),
180            acquired_at_utc: Utc::now().to_rfc3339(),
181            tool: "cli".to_string(),
182            command: None,
183        }
184    }
185}
186
187/// Configuration persistence manager
188///
189/// Provides atomic load/save operations with integrity verification
190/// and recovery support.
191pub struct ConfigPersistence {
192    paths: GraphConfigPaths,
193}
194
195impl ConfigPersistence {
196    /// Create a new persistence manager from a config store
197    #[must_use]
198    pub fn new(store: &GraphConfigStore) -> Self {
199        Self {
200            paths: store.paths().clone(),
201        }
202    }
203
204    /// Create a new persistence manager from paths
205    #[must_use]
206    pub fn from_paths(paths: GraphConfigPaths) -> Self {
207        Self { paths }
208    }
209
210    // ========================================================================
211    // Load operations
212    // ========================================================================
213
214    /// Load config with recovery support
215    ///
216    /// Returns the loaded config and a report of any recovery actions taken.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if no usable config file can be loaded or parsed.
221    pub fn load(&self) -> PersistenceResult<(GraphConfigFile, LoadReport)> {
222        let mut report = LoadReport::default();
223
224        // Try loading primary config file
225        let config_path = self.paths.config_file();
226        match Self::try_load_file(&config_path) {
227            Ok((config, file_report)) => {
228                report.warnings.extend(file_report.warnings);
229                report.integrity_status = file_report.integrity_status;
230                report.schema_status = file_report.schema_status;
231                return Ok((config, report));
232            }
233            Err(e) => {
234                report
235                    .warnings
236                    .push(format!("Failed to load config.json: {e}"));
237            }
238        }
239
240        // Try loading previous config
241        let previous_path = self.paths.previous_file();
242        if previous_path.exists() {
243            report
244                .recovery_actions
245                .push("Attempting to load config.json.previous".to_string());
246
247            match Self::try_load_file(&previous_path) {
248                Ok((config, file_report)) => {
249                    report.warnings.extend(file_report.warnings);
250                    report.integrity_status = file_report.integrity_status;
251                    report.schema_status = file_report.schema_status;
252                    report
253                        .recovery_actions
254                        .push("Recovered from config.json.previous".to_string());
255                    return Ok((config, report));
256                }
257                Err(e) => {
258                    report
259                        .warnings
260                        .push(format!("Failed to load config.json.previous: {e}"));
261                }
262            }
263        }
264
265        // No usable config found
266        Err(PersistenceError::NoUsableConfig {
267            reason: "Neither config.json nor config.json.previous could be loaded. \
268                     Run `sqry config init` to create a new config file."
269                .to_string(),
270        })
271    }
272
273    /// Try to load a specific config file
274    fn try_load_file(path: &Path) -> PersistenceResult<(GraphConfigFile, LoadReport)> {
275        let mut report = LoadReport::default();
276
277        if !path.exists() {
278            return Err(PersistenceError::IoError {
279                path: path.to_path_buf(),
280                source: std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"),
281            });
282        }
283
284        // Read file content
285        let content = fs::read_to_string(path).map_err(|e| PersistenceError::IoError {
286            path: path.to_path_buf(),
287            source: e,
288        })?;
289
290        // Parse JSON
291        let config: GraphConfigFile = serde_json::from_str(&content)
292            .map_err(|e| PersistenceError::DeserializationError(e.to_string()))?;
293
294        // Validate schema version
295        if config.schema_version != SCHEMA_VERSION {
296            report.schema_status = SchemaStatus::Invalid;
297            return Err(PersistenceError::CorruptConfig {
298                path: path.to_path_buf(),
299                reason: format!(
300                    "Incompatible schema version: expected {}, found {}",
301                    SCHEMA_VERSION, config.schema_version
302                ),
303            });
304        }
305
306        // Verify integrity
307        let computed_hash = Self::compute_integrity_hash(&config.config)?;
308        if config.integrity.normalized_hash.is_empty() {
309            report.integrity_status = IntegrityStatus::Unavailable;
310        } else if config.integrity.normalized_hash != computed_hash {
311            report.integrity_status = IntegrityStatus::Mismatch;
312            report.warnings.push(format!(
313                "Integrity hash mismatch (possibly manual edit). \
314                 Expected: {}, Found: {}",
315                config.integrity.normalized_hash, computed_hash
316            ));
317        } else {
318            report.integrity_status = IntegrityStatus::Ok;
319        }
320
321        Ok((config, report))
322    }
323
324    /// Check if config exists (either primary or previous)
325    #[must_use]
326    pub fn exists(&self) -> bool {
327        self.paths.config_file().exists() || self.paths.previous_file().exists()
328    }
329
330    // ========================================================================
331    // Save operations
332    // ========================================================================
333
334    /// Save config atomically with locking
335    ///
336    /// # Arguments
337    ///
338    /// * `config` - The config to save
339    /// * `lock_timeout_ms` - How long to wait for the lock
340    /// * `tool` - The tool performing the save (for lock info)
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if locking, serialization, or atomic write fails.
345    pub fn save(
346        &self,
347        config: &mut GraphConfigFile,
348        lock_timeout_ms: u64,
349        tool: &str,
350    ) -> PersistenceResult<()> {
351        // Ensure config directory exists
352        let config_dir = self.paths.config_dir();
353        if !config_dir.exists() {
354            fs::create_dir_all(&config_dir).map_err(|e| PersistenceError::IoError {
355                path: config_dir.clone(),
356                source: e,
357            })?;
358        }
359
360        // Acquire lock
361        let lock_guard = self.acquire_lock(lock_timeout_ms, tool)?;
362
363        // Update metadata
364        config.metadata.updated_at = Utc::now().to_rfc3339();
365
366        // Compute integrity hash
367        let hash = Self::compute_integrity_hash(&config.config)?;
368        config.integrity.normalized_hash = hash;
369        config.integrity.last_verified_at = Utc::now().to_rfc3339();
370
371        // Serialize with deterministic ordering
372        let json = serde_json::to_string_pretty(config)
373            .map_err(|e| PersistenceError::SerializationError(e.to_string()))?;
374
375        // Atomic write protocol
376        self.atomic_write(&json)?;
377
378        // Lock is released when guard is dropped
379        drop(lock_guard);
380
381        Ok(())
382    }
383
384    /// Initialize a new config file with defaults
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if the config cannot be saved.
389    pub fn init(&self, lock_timeout_ms: u64, tool: &str) -> PersistenceResult<GraphConfigFile> {
390        let mut config = GraphConfigFile::default();
391        self.save(&mut config, lock_timeout_ms, tool)?;
392        Ok(config)
393    }
394
395    // ========================================================================
396    // Atomic write implementation
397    // ========================================================================
398
399    /// Perform atomic write: temp + fsync + rename protocol
400    fn atomic_write(&self, content: &str) -> PersistenceResult<()> {
401        let config_path = self.paths.config_file();
402        let config_dir = self.paths.config_dir();
403
404        // Generate unique temp file name
405        let temp_name = format!(
406            "config.json.tmp.{}.{}",
407            std::process::id(),
408            uuid::Uuid::new_v4()
409        );
410        let temp_path = config_dir.join(&temp_name);
411
412        // Write to temp file
413        let mut temp_file = File::create(&temp_path).map_err(|e| PersistenceError::IoError {
414            path: temp_path.clone(),
415            source: e,
416        })?;
417
418        temp_file
419            .write_all(content.as_bytes())
420            .map_err(|e| PersistenceError::IoError {
421                path: temp_path.clone(),
422                source: e,
423            })?;
424
425        // Fsync temp file
426        temp_file
427            .sync_all()
428            .map_err(|e| PersistenceError::IoError {
429                path: temp_path.clone(),
430                source: e,
431            })?;
432
433        drop(temp_file);
434
435        // If config.json exists, rename to .previous
436        if config_path.exists() {
437            let previous_path = self.paths.previous_file();
438            fs::rename(&config_path, &previous_path).map_err(|e| PersistenceError::IoError {
439                path: config_path.clone(),
440                source: e,
441            })?;
442        }
443
444        // Rename temp to config.json (atomic on POSIX)
445        fs::rename(&temp_path, &config_path).map_err(|e| PersistenceError::IoError {
446            path: temp_path.clone(),
447            source: e,
448        })?;
449
450        // Fsync directory (best-effort on some platforms)
451        Self::fsync_dir(&config_dir)?;
452
453        Ok(())
454    }
455
456    /// Fsync directory for rename durability
457    #[cfg(unix)]
458    fn fsync_dir(dir: &Path) -> PersistenceResult<()> {
459        let dir_file = File::open(dir).map_err(|e| PersistenceError::IoError {
460            path: dir.to_path_buf(),
461            source: e,
462        })?;
463
464        dir_file.sync_all().map_err(|e| PersistenceError::IoError {
465            path: dir.to_path_buf(),
466            source: e,
467        })?;
468
469        Ok(())
470    }
471
472    #[cfg(not(unix))]
473    fn fsync_dir(_dir: &Path) -> PersistenceResult<()> {
474        // Directory fsync is not reliably available on all platforms
475        // Fall back to file fsync and document the limitation
476        Ok(())
477    }
478
479    // ========================================================================
480    // Integrity hashing
481    // ========================================================================
482
483    /// Compute integrity hash of the config section
484    fn compute_integrity_hash(
485        config: &super::graph_config_schema::GraphConfig,
486    ) -> PersistenceResult<String> {
487        // Serialize config section deterministically
488        let json = serde_json::to_string(config)
489            .map_err(|e| PersistenceError::SerializationError(e.to_string()))?;
490
491        // Compute blake3 hash
492        let hash = blake3::hash(json.as_bytes());
493        Ok(hash.to_hex().to_string())
494    }
495
496    // ========================================================================
497    // Locking
498    // ========================================================================
499
500    /// Acquire exclusive lock for write operations
501    fn acquire_lock(&self, timeout_ms: u64, tool: &str) -> PersistenceResult<LockGuard> {
502        let lock_path = self.paths.lock_file();
503
504        // Ensure config directory exists
505        let config_dir = self.paths.config_dir();
506        if !config_dir.exists() {
507            fs::create_dir_all(&config_dir).map_err(|e| PersistenceError::IoError {
508                path: config_dir,
509                source: e,
510            })?;
511        }
512
513        // Open or create lock file
514        let lock_file = OpenOptions::new()
515            .read(true)
516            .write(true)
517            .create(true)
518            .truncate(false)
519            .open(&lock_path)
520            .map_err(|e| PersistenceError::IoError {
521                path: lock_path.clone(),
522                source: e,
523            })?;
524
525        // Try to acquire exclusive lock with timeout
526        let timeout = Duration::from_millis(timeout_ms);
527        let start = std::time::Instant::now();
528
529        loop {
530            if let Ok(()) = lock_file.try_lock_exclusive() {
531                // Write lock info
532                let lock_info = LockInfo {
533                    tool: tool.to_string(),
534                    ..Default::default()
535                };
536                let info_json =
537                    serde_json::to_string_pretty(&lock_info).unwrap_or_else(|_| "{}".to_string());
538
539                // Truncate and write lock info
540                let _ = lock_file.set_len(0);
541                let _ = (&lock_file).write_all(info_json.as_bytes());
542                let _ = lock_file.sync_all();
543
544                return Ok(LockGuard {
545                    file: lock_file,
546                    path: lock_path,
547                });
548            }
549            if start.elapsed() >= timeout {
550                return Err(PersistenceError::LockTimeout {
551                    path: lock_path,
552                    timeout_ms,
553                });
554            }
555            std::thread::sleep(Duration::from_millis(50));
556        }
557    }
558
559    // ========================================================================
560    // Recovery operations
561    // ========================================================================
562
563    /// Quarantine a corrupt config file
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if the corrupt file cannot be moved to quarantine.
568    pub fn quarantine_corrupt(&self, path: &Path) -> PersistenceResult<PathBuf> {
569        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
570        let corrupt_path = self.paths.corrupt_file(&timestamp);
571
572        fs::rename(path, &corrupt_path).map_err(|e| PersistenceError::IoError {
573            path: path.to_path_buf(),
574            source: e,
575        })?;
576
577        Ok(corrupt_path)
578    }
579
580    /// Repair config by quarantining corrupt files and restoring from previous
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if lock acquisition or file operations fail.
585    pub fn repair(&self, lock_timeout_ms: u64) -> PersistenceResult<RepairReport> {
586        let mut report = RepairReport::default();
587
588        let _lock_guard = self.acquire_lock(lock_timeout_ms, "cli")?;
589
590        let config_path = self.paths.config_file();
591        let previous_path = self.paths.previous_file();
592
593        // Check for temp artifacts
594        let config_dir = self.paths.config_dir();
595        if let Ok(entries) = fs::read_dir(&config_dir) {
596            for entry in entries.flatten() {
597                let name = entry.file_name();
598                let name_str = name.to_string_lossy();
599                if name_str.starts_with("config.json.tmp.") {
600                    let artifact_path = entry.path();
601                    let quarantine_path = self.quarantine_corrupt(&artifact_path)?;
602                    report.quarantined.push((artifact_path, quarantine_path));
603                }
604            }
605        }
606
607        // Check if config.json is valid
608        if config_path.exists() {
609            match Self::try_load_file(&config_path) {
610                Ok(_) => {
611                    report.config_status = "valid".to_string();
612                }
613                Err(e) => {
614                    report.config_status = format!("corrupt: {e}");
615                    let quarantine_path = self.quarantine_corrupt(&config_path)?;
616                    report
617                        .quarantined
618                        .push((config_path.clone(), quarantine_path));
619
620                    // Try to promote previous
621                    if previous_path.exists() {
622                        fs::rename(&previous_path, &config_path).map_err(|e| {
623                            PersistenceError::IoError {
624                                path: previous_path.clone(),
625                                source: e,
626                            }
627                        })?;
628                        report.restored_from_previous = true;
629                    }
630                }
631            }
632        } else if previous_path.exists() {
633            // config.json missing but previous exists (power loss recovery)
634            fs::rename(&previous_path, &config_path).map_err(|e| PersistenceError::IoError {
635                path: previous_path.clone(),
636                source: e,
637            })?;
638            report.restored_from_previous = true;
639        }
640
641        Ok(report)
642    }
643}
644
645/// RAII guard for file lock
646struct LockGuard {
647    file: File,
648    #[allow(dead_code)] // Retained for debugging and future error messages
649    path: PathBuf,
650}
651
652impl Drop for LockGuard {
653    fn drop(&mut self) {
654        let _ = self.file.unlock();
655        // Don't delete lock file - it persists
656    }
657}
658
659/// Report from repair operation
660#[derive(Debug, Default)]
661pub struct RepairReport {
662    /// Status of config.json
663    pub config_status: String,
664    /// Files that were quarantined (original path, quarantine path)
665    pub quarantined: Vec<(PathBuf, PathBuf)>,
666    /// Whether config was restored from .previous
667    pub restored_from_previous: bool,
668}
669
670// ============================================================================
671// Tests
672// ============================================================================
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use tempfile::TempDir;
678
679    fn create_test_persistence() -> (TempDir, ConfigPersistence) {
680        let temp = TempDir::new().unwrap();
681        let paths = GraphConfigPaths::new(temp.path()).unwrap();
682        let persistence = ConfigPersistence::from_paths(paths);
683        (temp, persistence)
684    }
685
686    #[test]
687    fn test_init_creates_config() {
688        let (_temp, persistence) = create_test_persistence();
689
690        let config = persistence.init(5000, "test").unwrap();
691        assert_eq!(config.schema_version, SCHEMA_VERSION);
692
693        // Verify file exists
694        assert!(persistence.paths.config_file().exists());
695    }
696
697    #[test]
698    fn test_save_load_roundtrip() {
699        let (_temp, persistence) = create_test_persistence();
700
701        // Create and save config
702        let mut config = GraphConfigFile::default();
703        config.config.limits.max_results = 12345;
704        persistence.save(&mut config, 5000, "test").unwrap();
705
706        // Load and verify
707        let (loaded, report) = persistence.load().unwrap();
708        assert_eq!(loaded.config.limits.max_results, 12345);
709        assert_eq!(report.integrity_status, IntegrityStatus::Ok);
710    }
711
712    #[test]
713    fn test_integrity_hash_computed() {
714        let (_temp, persistence) = create_test_persistence();
715
716        let mut config = GraphConfigFile::default();
717        persistence.save(&mut config, 5000, "test").unwrap();
718
719        // Hash should be populated after save
720        assert!(!config.integrity.normalized_hash.is_empty());
721    }
722
723    #[test]
724    fn test_previous_file_created_on_update() {
725        let (_temp, persistence) = create_test_persistence();
726
727        // Initial save
728        let mut config = GraphConfigFile::default();
729        config.config.limits.max_results = 100;
730        persistence.save(&mut config, 5000, "test").unwrap();
731
732        // Update
733        config.config.limits.max_results = 200;
734        persistence.save(&mut config, 5000, "test").unwrap();
735
736        // Previous should exist
737        assert!(persistence.paths.previous_file().exists());
738    }
739
740    #[test]
741    fn test_load_nonexistent_returns_error() {
742        let (_temp, persistence) = create_test_persistence();
743
744        let result = persistence.load();
745        assert!(result.is_err());
746    }
747
748    #[test]
749    fn test_exists_false_when_no_config() {
750        let (_temp, persistence) = create_test_persistence();
751        assert!(!persistence.exists());
752    }
753
754    #[test]
755    fn test_exists_true_after_init() {
756        let (_temp, persistence) = create_test_persistence();
757        persistence.init(5000, "test").unwrap();
758        assert!(persistence.exists());
759    }
760
761    #[test]
762    fn test_integrity_mismatch_warning() {
763        let (_temp, persistence) = create_test_persistence();
764
765        // Create config
766        let mut config = GraphConfigFile::default();
767        persistence.save(&mut config, 5000, "test").unwrap();
768
769        // Manually modify the file to simulate manual edit
770        let config_path = persistence.paths.config_file();
771        let content = fs::read_to_string(&config_path).unwrap();
772        let modified = content.replace("5000", "9999");
773        fs::write(&config_path, modified).unwrap();
774
775        // Load should succeed but report integrity mismatch
776        let (_, report) = persistence.load().unwrap();
777        assert_eq!(report.integrity_status, IntegrityStatus::Mismatch);
778        assert!(!report.warnings.is_empty());
779    }
780
781    #[test]
782    fn test_repair_promotes_previous_when_config_missing() {
783        let (_temp, persistence) = create_test_persistence();
784
785        // Create initial config
786        let mut config = GraphConfigFile::default();
787        config.config.limits.max_results = 42;
788        persistence.save(&mut config, 5000, "test").unwrap();
789
790        // Save again to create .previous (first config becomes previous)
791        config.config.limits.max_results = 43;
792        persistence.save(&mut config, 5000, "test").unwrap();
793
794        // Verify previous exists
795        assert!(persistence.paths.previous_file().exists());
796
797        // Simulate power loss: delete config.json but keep previous
798        fs::remove_file(persistence.paths.config_file()).unwrap();
799        assert!(!persistence.paths.config_file().exists());
800        assert!(persistence.paths.previous_file().exists());
801
802        // Repair should promote previous
803        let report = persistence.repair(5000).unwrap();
804        assert!(report.restored_from_previous);
805        assert!(persistence.paths.config_file().exists());
806    }
807
808    #[test]
809    fn test_quarantine_corrupt_file() {
810        let (_temp, persistence) = create_test_persistence();
811
812        // Create a corrupt file
813        fs::create_dir_all(persistence.paths.config_dir()).unwrap();
814        let config_path = persistence.paths.config_file();
815        fs::write(&config_path, "not valid json").unwrap();
816
817        // Quarantine it
818        let quarantine_path = persistence.quarantine_corrupt(&config_path).unwrap();
819        assert!(!config_path.exists());
820        assert!(quarantine_path.exists());
821        assert!(
822            quarantine_path
823                .file_name()
824                .unwrap()
825                .to_string_lossy()
826                .contains("corrupt")
827        );
828    }
829
830    #[test]
831    fn test_lock_timeout() {
832        let (_temp, persistence) = create_test_persistence();
833
834        // First lock should succeed
835        let lock1 = persistence.acquire_lock(5000, "test1").unwrap();
836
837        // Second lock should timeout quickly
838        let result = persistence.acquire_lock(100, "test2");
839        assert!(matches!(result, Err(PersistenceError::LockTimeout { .. })));
840
841        drop(lock1);
842    }
843
844    #[test]
845    fn test_lock_released_on_drop() {
846        let (_temp, persistence) = create_test_persistence();
847
848        {
849            let _lock = persistence.acquire_lock(5000, "test1").unwrap();
850            // Lock held in this scope
851        }
852
853        // Lock should be released, second acquire should succeed
854        let _lock2 = persistence.acquire_lock(5000, "test2").unwrap();
855    }
856}