Skip to main content

agentic_memory/v3/
edge_cases.rs

1//! Edge case handlers for V3 Immortal Architecture.
2//! Handles storage failures, concurrency, data validation, platform differences,
3//! and recovery scenarios. ZERO DATA LOSS. EVER.
4
5use std::fs::{File, OpenOptions};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10// ═══════════════════════════════════════════════════════════════════
11// ERROR TYPES
12// ═══════════════════════════════════════════════════════════════════
13
14/// Storage-related errors
15#[derive(Debug)]
16pub enum StorageError {
17    /// Disk is full
18    DiskFull { needed: usize, available: usize },
19    /// No writable location found
20    NoWritableLocation,
21    /// File corruption detected
22    Corruption { details: String },
23    /// Permission denied
24    PermissionDenied { path: PathBuf, operation: String },
25    /// IO error
26    Io(std::io::Error),
27    /// Serialization error
28    Serialization(String),
29}
30
31impl std::fmt::Display for StorageError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::DiskFull { needed, available } => {
35                write!(
36                    f,
37                    "Disk full: need {} bytes, only {} available",
38                    needed, available
39                )
40            }
41            Self::NoWritableLocation => write!(f, "No writable location found"),
42            Self::Corruption { details } => write!(f, "Corruption detected: {}", details),
43            Self::PermissionDenied { path, operation } => {
44                write!(f, "Permission denied: {} on {:?}", operation, path)
45            }
46            Self::Io(e) => write!(f, "IO error: {}", e),
47            Self::Serialization(s) => write!(f, "Serialization error: {}", s),
48        }
49    }
50}
51
52impl std::error::Error for StorageError {}
53
54impl From<std::io::Error> for StorageError {
55    fn from(e: std::io::Error) -> Self {
56        match e.kind() {
57            std::io::ErrorKind::PermissionDenied => Self::PermissionDenied {
58                path: PathBuf::new(),
59                operation: "unknown".to_string(),
60            },
61            _ => Self::Io(e),
62        }
63    }
64}
65
66impl From<serde_json::Error> for StorageError {
67    fn from(e: serde_json::Error) -> Self {
68        Self::Serialization(e.to_string())
69    }
70}
71
72/// Lock-related errors
73#[derive(Debug)]
74pub enum LockError {
75    Timeout,
76    Io(std::io::Error),
77}
78
79impl std::fmt::Display for LockError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Timeout => write!(f, "Lock acquisition timed out"),
83            Self::Io(e) => write!(f, "Lock IO error: {}", e),
84        }
85    }
86}
87
88impl std::error::Error for LockError {}
89
90/// Validation errors
91#[derive(Debug)]
92pub enum ValidationError {
93    MissingField(&'static str),
94    InvalidValue {
95        field: &'static str,
96        expected: &'static str,
97        got: String,
98    },
99    EmptyValue(&'static str),
100    TooLarge {
101        field: &'static str,
102        max_bytes: usize,
103        got_bytes: usize,
104    },
105}
106
107impl std::fmt::Display for ValidationError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::MissingField(field) => write!(f, "Missing required field: {}", field),
111            Self::InvalidValue {
112                field,
113                expected,
114                got,
115            } => {
116                write!(
117                    f,
118                    "Invalid value for {}: expected {}, got {}",
119                    field, expected, got
120                )
121            }
122            Self::EmptyValue(field) => write!(f, "Empty value for required field: {}", field),
123            Self::TooLarge {
124                field,
125                max_bytes,
126                got_bytes,
127            } => {
128                write!(
129                    f,
130                    "Value too large for {}: max {} bytes, got {} bytes",
131                    field, max_bytes, got_bytes
132                )
133            }
134        }
135    }
136}
137
138impl std::error::Error for ValidationError {}
139
140// ═══════════════════════════════════════════════════════════════════
141// 1. STORAGE EDGE CASES
142// ═══════════════════════════════════════════════════════════════════
143
144/// Check available disk space before writing
145pub fn check_disk_space(path: &Path, needed: usize) -> Result<(), StorageError> {
146    // Use a heuristic: check if the parent directory exists and is writable
147    let dir = path.parent().unwrap_or(path);
148
149    // Try to estimate available space by writing a test
150    let test_file = dir.join(".space_test");
151    match std::fs::write(&test_file, vec![0u8; std::cmp::min(needed, 4096)]) {
152        Ok(_) => {
153            let _ = std::fs::remove_file(&test_file);
154            Ok(())
155        }
156        Err(e) => {
157            let _ = std::fs::remove_file(&test_file);
158            if e.kind() == std::io::ErrorKind::PermissionDenied {
159                Err(StorageError::PermissionDenied {
160                    path: dir.to_path_buf(),
161                    operation: "write".to_string(),
162                })
163            } else {
164                // Likely disk full or other error
165                Err(StorageError::DiskFull {
166                    needed,
167                    available: 0,
168                })
169            }
170        }
171    }
172}
173
174/// Atomic write: write to temp file then rename
175pub fn atomic_write(target: &Path, data: &[u8]) -> Result<(), std::io::Error> {
176    let temp_path = target.with_extension("tmp");
177
178    // Write to temp file
179    let mut file = File::create(&temp_path)?;
180    file.write_all(data)?;
181    file.sync_all()?;
182    drop(file);
183
184    // Atomic rename
185    match std::fs::rename(&temp_path, target) {
186        Ok(_) => Ok(()),
187        Err(_) if cfg!(windows) => {
188            // Windows: target may be locked; retry with backoff
189            for attempt in 0..3 {
190                std::thread::sleep(Duration::from_millis(100 * (attempt + 1)));
191                if std::fs::rename(&temp_path, target).is_ok() {
192                    return Ok(());
193                }
194            }
195            // Last resort: copy instead of rename
196            std::fs::copy(&temp_path, target)?;
197            std::fs::remove_file(&temp_path)?;
198            Ok(())
199        }
200        Err(e) => {
201            let _ = std::fs::remove_file(&temp_path);
202            Err(e)
203        }
204    }
205}
206
207/// Find a writable location from a list of candidates
208pub fn find_writable_location() -> Result<PathBuf, StorageError> {
209    let candidates = vec![
210        dirs::data_local_dir().map(|d| d.join("agentic-memory")),
211        dirs::home_dir().map(|d| d.join(".agentic-memory")),
212        Some(PathBuf::from("/tmp/agentic-memory")),
213        std::env::current_dir()
214            .ok()
215            .map(|d| d.join(".agentic-memory")),
216    ];
217
218    for candidate in candidates.into_iter().flatten() {
219        if std::fs::create_dir_all(&candidate).is_ok() {
220            // Test write
221            let test_file = candidate.join(".write_test");
222            if std::fs::write(&test_file, b"test").is_ok() {
223                let _ = std::fs::remove_file(&test_file);
224                return Ok(candidate);
225            }
226        }
227    }
228
229    Err(StorageError::NoWritableLocation)
230}
231
232/// Sanitize path for filesystem safety
233pub fn safe_path(input: &str) -> PathBuf {
234    // Handle Windows long path prefix
235    #[cfg(windows)]
236    let input = if input.len() > 250 {
237        format!("\\\\?\\{}", input)
238    } else {
239        input.to_string()
240    };
241
242    #[cfg(not(windows))]
243    let input = input.to_string();
244
245    // Hash if contains problematic characters or is too long
246    let problematic = input.contains(['<', '>', ':', '"', '|', '?', '*', '\0']);
247    let too_long = input.len() > 200;
248
249    if problematic || too_long {
250        let hash = blake3::hash(input.as_bytes());
251        PathBuf::from(format!("hashed_{}", &hash.to_hex()[..16]))
252    } else {
253        PathBuf::from(&input)
254    }
255}
256
257// ═══════════════════════════════════════════════════════════════════
258// 2. CONCURRENCY — FILE LOCKING
259// ═══════════════════════════════════════════════════════════════════
260
261/// File-based lock for concurrent access control
262pub struct FileLock {
263    _lock_file: File,
264    lock_path: PathBuf,
265}
266
267impl FileLock {
268    /// Acquire an exclusive lock with timeout
269    pub fn acquire(path: &Path, timeout: Duration) -> Result<Self, LockError> {
270        let lock_path = path.with_extension("lock");
271        let start = Instant::now();
272
273        loop {
274            // Try to create lock file exclusively
275            match OpenOptions::new()
276                .write(true)
277                .create_new(true)
278                .open(&lock_path)
279            {
280                Ok(mut file) => {
281                    // Write PID for stale lock detection
282                    let pid = std::process::id();
283                    let _ = write!(file, "{}", pid);
284                    let _ = file.sync_all();
285
286                    return Ok(Self {
287                        _lock_file: file,
288                        lock_path,
289                    });
290                }
291                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
292                    if start.elapsed() >= timeout {
293                        // Check if lock is stale before failing
294                        if Self::is_stale_lock(&lock_path) {
295                            Self::break_stale_lock(&lock_path);
296                            continue;
297                        }
298                        return Err(LockError::Timeout);
299                    }
300                    std::thread::sleep(Duration::from_millis(50));
301                }
302                Err(e) => return Err(LockError::Io(e)),
303            }
304        }
305    }
306
307    /// Check if a lock file is stale (holder crashed)
308    fn is_stale_lock(lock_path: &Path) -> bool {
309        if let Ok(metadata) = std::fs::metadata(lock_path) {
310            let age = metadata
311                .modified()
312                .ok()
313                .and_then(|t| t.elapsed().ok())
314                .unwrap_or_default();
315
316            // Lock is stale if older than 60 seconds
317            if age > Duration::from_secs(60) {
318                return true;
319            }
320
321            // Also check if PID is still alive
322            if let Ok(content) = std::fs::read_to_string(lock_path) {
323                if let Ok(pid) = content.trim().parse::<u32>() {
324                    return !is_process_alive(pid);
325                }
326            }
327        }
328        false
329    }
330
331    /// Break a stale lock
332    fn break_stale_lock(lock_path: &Path) {
333        let _ = std::fs::remove_file(lock_path);
334    }
335}
336
337impl Drop for FileLock {
338    fn drop(&mut self) {
339        let _ = std::fs::remove_file(&self.lock_path);
340    }
341}
342
343/// Check if a process is still running
344fn is_process_alive(pid: u32) -> bool {
345    #[cfg(unix)]
346    {
347        // On Unix, kill(pid, 0) checks existence without sending a signal
348        unsafe { libc_free_kill_check(pid) }
349    }
350
351    #[cfg(not(unix))]
352    {
353        // On non-Unix, assume alive if we can't check
354        let _ = pid;
355        true
356    }
357}
358
359#[cfg(unix)]
360unsafe fn libc_free_kill_check(pid: u32) -> bool {
361    // Use std::process::Command to check if process exists
362    // This avoids needing libc dependency
363    std::fs::metadata(format!("/proc/{}", pid)).is_ok()
364        || std::process::Command::new("kill")
365            .args(["-0", &pid.to_string()])
366            .output()
367            .map(|o| o.status.success())
368            .unwrap_or(true) // If we can't check, assume alive
369}
370
371// ═══════════════════════════════════════════════════════════════════
372// 3. PROJECT ISOLATION
373// ═══════════════════════════════════════════════════════════════════
374
375/// Per-project isolation for multiple Claude instances
376pub struct ProjectIsolation {
377    pub project_id: String,
378    pub project_dir: PathBuf,
379}
380
381impl ProjectIsolation {
382    /// Detect or create project isolation
383    pub fn detect_or_create() -> Self {
384        let project_id = std::env::var("CLAUDE_PROJECT_ID")
385            .ok()
386            .or_else(Self::detect_from_cwd)
387            .unwrap_or_else(Self::generate_project_id);
388
389        let project_dir = Self::project_data_dir(&project_id);
390        let _ = std::fs::create_dir_all(&project_dir);
391
392        Self {
393            project_id,
394            project_dir,
395        }
396    }
397
398    fn detect_from_cwd() -> Option<String> {
399        let cwd = std::env::current_dir().ok()?;
400        let canonical = cwd.canonicalize().ok()?;
401        let hash = blake3::hash(canonical.to_string_lossy().as_bytes());
402        Some(format!("proj_{}", &hash.to_hex()[..12]))
403    }
404
405    fn generate_project_id() -> String {
406        format!("proj_{}", &uuid::Uuid::new_v4().to_string()[..8])
407    }
408
409    fn project_data_dir(project_id: &str) -> PathBuf {
410        dirs::data_local_dir()
411            .unwrap_or_else(|| PathBuf::from("."))
412            .join("agentic-memory")
413            .join("projects")
414            .join(project_id)
415    }
416}
417
418// ═══════════════════════════════════════════════════════════════════
419// 5. DATA VALIDATION
420// ═══════════════════════════════════════════════════════════════════
421
422/// Content normalization result
423#[derive(Debug, PartialEq)]
424pub enum NormalizedContent {
425    /// Empty content
426    Empty,
427    /// Only whitespace/control characters
428    WhitespaceOnly,
429    /// Valid content (trimmed)
430    Valid(String),
431}
432
433/// Normalize content for capture
434pub fn normalize_content(content: &str) -> NormalizedContent {
435    if content.is_empty() {
436        return NormalizedContent::Empty;
437    }
438
439    // Check if ALL characters are whitespace/control before trimming
440    if content.chars().all(|c| c.is_whitespace() || c.is_control()) {
441        if content.trim().is_empty() && !content.is_empty() {
442            return NormalizedContent::WhitespaceOnly;
443        }
444        return NormalizedContent::Empty;
445    }
446
447    let trimmed = content.trim();
448    NormalizedContent::Valid(trimmed.to_string())
449}
450
451/// Content type detection for binary vs text
452#[derive(Debug, PartialEq)]
453pub enum ContentType {
454    Text,
455    Binary(&'static str), // mime type
456}
457
458/// Detect if data is text or binary
459pub fn detect_content_type(data: &[u8]) -> ContentType {
460    if data.is_empty() {
461        return ContentType::Text;
462    }
463
464    // Check for common binary signatures
465    if data.len() >= 4 {
466        match &data[0..4] {
467            [0x89, 0x50, 0x4E, 0x47] => return ContentType::Binary("image/png"),
468            [0xFF, 0xD8, 0xFF, _] => return ContentType::Binary("image/jpeg"),
469            [0x25, 0x50, 0x44, 0x46] => return ContentType::Binary("application/pdf"),
470            [0x50, 0x4B, 0x03, 0x04] => return ContentType::Binary("application/zip"),
471            [0x47, 0x49, 0x46, 0x38] => return ContentType::Binary("image/gif"),
472            [0x7F, 0x45, 0x4C, 0x46] => return ContentType::Binary("application/x-elf"),
473            _ => {}
474        }
475    }
476
477    // Check if valid UTF-8
478    match std::str::from_utf8(data) {
479        Ok(s) => {
480            // Check for excessive control characters (likely binary)
481            let control_count = s
482                .chars()
483                .filter(|c| c.is_control() && *c != '\n' && *c != '\r' && *c != '\t')
484                .count();
485            let control_ratio = control_count as f32 / s.len().max(1) as f32;
486            if control_ratio > 0.1 {
487                ContentType::Binary("application/octet-stream")
488            } else {
489                ContentType::Text
490            }
491        }
492        Err(_) => ContentType::Binary("application/octet-stream"),
493    }
494}
495
496/// Maximum single block size (10 MB)
497pub const MAX_SINGLE_BLOCK_BYTES: usize = 10 * 1024 * 1024;
498
499/// Chunk size for large content (1 MB)
500pub const CHUNK_SIZE: usize = 1024 * 1024;
501
502/// Validate and potentially chunk large content
503pub fn validate_content_size(content: &str) -> Result<(), ValidationError> {
504    if content.len() > MAX_SINGLE_BLOCK_BYTES {
505        return Err(ValidationError::TooLarge {
506            field: "content",
507            max_bytes: MAX_SINGLE_BLOCK_BYTES,
508            got_bytes: content.len(),
509        });
510    }
511    Ok(())
512}
513
514/// Validated timestamp: clamp to sane range
515pub fn validated_timestamp() -> chrono::DateTime<chrono::Utc> {
516    let now = chrono::Utc::now();
517
518    let min_valid = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
519        .unwrap()
520        .with_timezone(&chrono::Utc);
521
522    let max_valid = chrono::DateTime::parse_from_rfc3339("2100-01-01T00:00:00Z")
523        .unwrap()
524        .with_timezone(&chrono::Utc);
525
526    if now < min_valid {
527        log::warn!("System clock appears to be in the past: {:?}", now);
528        min_valid
529    } else if now > max_valid {
530        log::warn!("System clock appears to be in the future: {:?}", now);
531        max_valid
532    } else {
533        now
534    }
535}
536
537// ═══════════════════════════════════════════════════════════════════
538// 7. PLATFORM — PATH NORMALIZATION
539// ═══════════════════════════════════════════════════════════════════
540
541/// Normalize a file path for cross-platform consistency
542pub fn normalize_path(path: &str) -> String {
543    // Convert Windows separators to Unix
544    let normalized = path.replace('\\', "/");
545
546    // Remove trailing slashes
547    let normalized = normalized.trim_end_matches('/');
548
549    // Lowercase on case-insensitive systems
550    #[cfg(any(target_os = "windows", target_os = "macos"))]
551    let normalized = normalized.to_lowercase();
552
553    normalized.to_string()
554}
555
556/// Compare paths with platform-aware normalization
557pub fn paths_equal(a: &str, b: &str) -> bool {
558    normalize_path(a) == normalize_path(b)
559}
560
561// ═══════════════════════════════════════════════════════════════════
562// 8. RECOVERY — IDEMPOTENT RECOVERY MARKERS
563// ═══════════════════════════════════════════════════════════════════
564
565/// Recovery marker for idempotent crash recovery
566pub struct RecoveryMarker {
567    data_dir: PathBuf,
568}
569
570impl RecoveryMarker {
571    pub fn new(data_dir: &Path) -> Self {
572        Self {
573            data_dir: data_dir.to_path_buf(),
574        }
575    }
576
577    /// Check if recovery is needed
578    pub fn needs_recovery(&self) -> bool {
579        let in_progress = self.data_dir.join(".recovery_in_progress");
580        in_progress.exists()
581    }
582
583    /// Check if recovery already completed for current log state
584    pub fn recovery_completed(&self) -> bool {
585        let complete = self.data_dir.join(".recovery_complete");
586        let log_path = self.data_dir.join("immortal.log");
587
588        if !complete.exists() {
589            return false;
590        }
591
592        // Check if recovery marker is newer than log file
593        let complete_time = std::fs::metadata(&complete)
594            .ok()
595            .and_then(|m| m.modified().ok());
596        let log_time = std::fs::metadata(&log_path)
597            .ok()
598            .and_then(|m| m.modified().ok());
599
600        match (complete_time, log_time) {
601            (Some(ct), Some(lt)) => ct > lt,
602            _ => false,
603        }
604    }
605
606    /// Mark recovery as in progress
607    pub fn mark_in_progress(&self) {
608        let marker = self.data_dir.join(".recovery_in_progress");
609        let _ = std::fs::write(&marker, chrono::Utc::now().to_rfc3339());
610    }
611
612    /// Mark recovery as complete
613    pub fn mark_complete(&self) {
614        let complete = self.data_dir.join(".recovery_complete");
615        let in_progress = self.data_dir.join(".recovery_in_progress");
616        let _ = std::fs::write(&complete, chrono::Utc::now().to_rfc3339());
617        let _ = std::fs::remove_file(&in_progress);
618    }
619}
620
621// ═══════════════════════════════════════════════════════════════════
622// 6. INDEX CONSISTENCY
623// ═══════════════════════════════════════════════════════════════════
624
625/// Index consistency report
626#[derive(Debug, Default)]
627pub struct IndexConsistencyReport {
628    pub consistent: bool,
629    pub missing_in_temporal: Vec<u64>,
630    pub missing_in_semantic: Vec<u64>,
631    pub missing_in_entity: Vec<u64>,
632    pub total_blocks: u64,
633}
634
635// ═══════════════════════════════════════════════════════════════════
636// GHOST WRITER EDGE CASES
637// ═══════════════════════════════════════════════════════════════════
638
639/// Safe write to Claude memory directory (handles locks)
640pub fn safe_write_to_claude(target: &Path, content: &str) -> Result<(), std::io::Error> {
641    atomic_write(target, content.as_bytes())
642}
643
644/// Merge content preserving user sections marked with <!-- USER_START/END -->
645pub fn merge_preserving_user_sections(existing: &str, our_content: &str) -> String {
646    // Find user sections
647    let mut user_sections = Vec::new();
648    let mut search_from = 0;
649    while let Some(start) = existing[search_from..].find("<!-- USER_START -->") {
650        let abs_start = search_from + start;
651        if let Some(end_offset) = existing[abs_start..].find("<!-- USER_END -->") {
652            let abs_end = abs_start + end_offset + "<!-- USER_END -->".len();
653            user_sections.push(&existing[abs_start..abs_end]);
654            search_from = abs_end;
655        } else {
656            break;
657        }
658    }
659
660    if user_sections.is_empty() {
661        return our_content.to_string();
662    }
663
664    // Build merged content
665    let mut merged = our_content.to_string();
666    merged.push_str("\n\n<!-- User-defined sections preserved: -->\n");
667    for section in user_sections {
668        merged.push_str(section);
669        merged.push('\n');
670    }
671
672    merged
673}
674
675// ═══════════════════════════════════════════════════════════════════
676// TESTS
677// ═══════════════════════════════════════════════════════════════════
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use tempfile::TempDir;
683
684    // ── Storage Tests ──
685
686    #[test]
687    fn test_find_writable_location() {
688        let result = find_writable_location();
689        assert!(result.is_ok(), "Should find at least one writable location");
690    }
691
692    #[test]
693    fn test_atomic_write() {
694        let dir = TempDir::new().unwrap();
695        let target = dir.path().join("test.txt");
696        atomic_write(&target, b"Hello, world!").unwrap();
697        assert_eq!(std::fs::read_to_string(&target).unwrap(), "Hello, world!");
698    }
699
700    #[test]
701    fn test_atomic_write_overwrites() {
702        let dir = TempDir::new().unwrap();
703        let target = dir.path().join("test.txt");
704        atomic_write(&target, b"First").unwrap();
705        atomic_write(&target, b"Second").unwrap();
706        assert_eq!(std::fs::read_to_string(&target).unwrap(), "Second");
707    }
708
709    #[test]
710    fn test_safe_path_normal() {
711        let path = safe_path("/src/main.rs");
712        assert_eq!(path, PathBuf::from("/src/main.rs"));
713    }
714
715    #[test]
716    fn test_safe_path_problematic_chars() {
717        let path = safe_path("file<name>:test");
718        assert!(path.to_string_lossy().starts_with("hashed_"));
719    }
720
721    #[test]
722    fn test_safe_path_too_long() {
723        let long_name = "a".repeat(300);
724        let path = safe_path(&long_name);
725        assert!(path.to_string_lossy().starts_with("hashed_"));
726        assert!(path.to_string_lossy().len() < 100);
727    }
728
729    #[test]
730    fn test_safe_path_unicode() {
731        let path = safe_path("/src/🦀_memory.rs");
732        assert_eq!(path, PathBuf::from("/src/🦀_memory.rs"));
733    }
734
735    #[test]
736    fn test_safe_path_null_bytes() {
737        let path = safe_path("file\0name");
738        assert!(path.to_string_lossy().starts_with("hashed_"));
739    }
740
741    // ── Concurrency Tests ──
742
743    #[test]
744    fn test_file_lock_acquire_release() {
745        let dir = TempDir::new().unwrap();
746        let data_path = dir.path().join("test.dat");
747        // Create the data file first
748        std::fs::write(&data_path, "data").unwrap();
749
750        let lock_path = data_path.with_extension("lock");
751
752        {
753            let _lock = FileLock::acquire(&data_path, Duration::from_secs(2)).unwrap();
754            assert!(lock_path.exists(), "Lock file should exist while held");
755        }
756        // Lock should be released on drop
757        assert!(
758            !lock_path.exists(),
759            "Lock file should be removed after drop"
760        );
761    }
762
763    #[test]
764    fn test_file_lock_timeout() {
765        let dir = TempDir::new().unwrap();
766        let lock_path = dir.path().join("test.dat.lock");
767
768        // Create a fake lock file (simulating another process)
769        std::fs::write(&lock_path, "99999999").unwrap(); // Fake PID
770
771        let result = FileLock::acquire(&dir.path().join("test.dat"), Duration::from_millis(200));
772        // Should eventually succeed or timeout — on most systems the PID won't exist
773        // so the stale lock detection will break it
774        assert!(result.is_ok() || matches!(result, Err(LockError::Timeout)));
775    }
776
777    // ── Project Isolation Tests ──
778
779    #[test]
780    fn test_project_isolation_deterministic() {
781        let iso1 = ProjectIsolation::detect_or_create();
782        let iso2 = ProjectIsolation::detect_or_create();
783        assert_eq!(iso1.project_id, iso2.project_id);
784    }
785
786    // ── Data Validation Tests ──
787
788    #[test]
789    fn test_normalize_empty_content() {
790        assert_eq!(normalize_content(""), NormalizedContent::Empty);
791    }
792
793    #[test]
794    fn test_normalize_whitespace_only() {
795        assert_eq!(
796            normalize_content("   \t\n  "),
797            NormalizedContent::WhitespaceOnly
798        );
799    }
800
801    #[test]
802    fn test_normalize_valid_content() {
803        assert_eq!(
804            normalize_content("  Hello, world!  "),
805            NormalizedContent::Valid("Hello, world!".to_string())
806        );
807    }
808
809    #[test]
810    fn test_detect_content_type_text() {
811        assert_eq!(detect_content_type(b"Hello, world!"), ContentType::Text);
812    }
813
814    #[test]
815    fn test_detect_content_type_png() {
816        let png_header = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
817        assert_eq!(
818            detect_content_type(&png_header),
819            ContentType::Binary("image/png")
820        );
821    }
822
823    #[test]
824    fn test_detect_content_type_jpeg() {
825        let jpeg_header = [0xFF, 0xD8, 0xFF, 0xE0];
826        assert_eq!(
827            detect_content_type(&jpeg_header),
828            ContentType::Binary("image/jpeg")
829        );
830    }
831
832    #[test]
833    fn test_detect_content_type_binary() {
834        // Random bytes that aren't valid UTF-8
835        let binary = vec![0xFF, 0xFE, 0x00, 0x01, 0x80, 0x81, 0x82, 0x83];
836        assert!(matches!(
837            detect_content_type(&binary),
838            ContentType::Binary(_)
839        ));
840    }
841
842    #[test]
843    fn test_detect_content_type_empty() {
844        assert_eq!(detect_content_type(b""), ContentType::Text);
845    }
846
847    #[test]
848    fn test_validate_content_size_ok() {
849        assert!(validate_content_size("Hello").is_ok());
850    }
851
852    #[test]
853    fn test_validate_content_size_too_large() {
854        let large = "x".repeat(MAX_SINGLE_BLOCK_BYTES + 1);
855        assert!(validate_content_size(&large).is_err());
856    }
857
858    #[test]
859    fn test_validated_timestamp_sane() {
860        let ts = validated_timestamp();
861        let now = chrono::Utc::now();
862        let diff = (now - ts).num_seconds().abs();
863        assert!(diff < 5, "Timestamp should be within 5 seconds of now");
864    }
865
866    // ── Platform Tests ──
867
868    #[test]
869    fn test_normalize_path_unix() {
870        let normalized = normalize_path("/src/main.rs");
871        assert!(normalized.contains("src/main.rs"));
872    }
873
874    #[test]
875    fn test_normalize_path_windows_separators() {
876        let normalized = normalize_path("src\\main.rs");
877        assert_eq!(normalized, normalize_path("src/main.rs"));
878    }
879
880    #[test]
881    fn test_normalize_path_trailing_slash() {
882        let normalized = normalize_path("/src/dir/");
883        assert!(!normalized.ends_with('/'));
884    }
885
886    #[test]
887    fn test_paths_equal() {
888        assert!(paths_equal("src/main.rs", "src/main.rs"));
889        assert!(paths_equal("src\\main.rs", "src/main.rs"));
890    }
891
892    // ── Recovery Marker Tests ──
893
894    #[test]
895    fn test_recovery_marker_fresh() {
896        let dir = TempDir::new().unwrap();
897        let marker = RecoveryMarker::new(dir.path());
898        assert!(!marker.needs_recovery());
899        assert!(!marker.recovery_completed());
900    }
901
902    #[test]
903    fn test_recovery_marker_in_progress() {
904        let dir = TempDir::new().unwrap();
905        let marker = RecoveryMarker::new(dir.path());
906        marker.mark_in_progress();
907        assert!(marker.needs_recovery());
908    }
909
910    #[test]
911    fn test_recovery_marker_complete() {
912        let dir = TempDir::new().unwrap();
913        let marker = RecoveryMarker::new(dir.path());
914        marker.mark_in_progress();
915        marker.mark_complete();
916        assert!(!marker.needs_recovery());
917    }
918
919    // ── Ghost Writer Edge Cases ──
920
921    #[test]
922    fn test_merge_preserving_user_sections_no_sections() {
923        let result = merge_preserving_user_sections("old content", "new content");
924        assert_eq!(result, "new content");
925    }
926
927    #[test]
928    fn test_merge_preserving_user_sections_with_sections() {
929        let existing =
930            "some text\n<!-- USER_START -->\nMy custom notes\n<!-- USER_END -->\nmore text";
931        let result = merge_preserving_user_sections(existing, "new auto content");
932        assert!(result.contains("new auto content"));
933        assert!(result.contains("<!-- USER_START -->"));
934        assert!(result.contains("My custom notes"));
935        assert!(result.contains("<!-- USER_END -->"));
936    }
937
938    #[test]
939    fn test_merge_preserving_multiple_user_sections() {
940        let existing = "text\n<!-- USER_START -->\nSection 1\n<!-- USER_END -->\nmiddle\n<!-- USER_START -->\nSection 2\n<!-- USER_END -->";
941        let result = merge_preserving_user_sections(existing, "new content");
942        assert!(result.contains("Section 1"));
943        assert!(result.contains("Section 2"));
944    }
945
946    #[test]
947    fn test_safe_write_to_claude() {
948        let dir = TempDir::new().unwrap();
949        let target = dir.path().join("test.md");
950        safe_write_to_claude(&target, "# Test Content").unwrap();
951        assert_eq!(std::fs::read_to_string(&target).unwrap(), "# Test Content");
952    }
953
954    // ── Concurrent append stress test ──
955
956    #[test]
957    fn test_check_disk_space_ok() {
958        let dir = TempDir::new().unwrap();
959        let result = check_disk_space(dir.path(), 1024);
960        assert!(result.is_ok());
961    }
962}