1use std::fs::{File, OpenOptions};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10#[derive(Debug)]
16pub enum StorageError {
17 DiskFull { needed: usize, available: usize },
19 NoWritableLocation,
21 Corruption { details: String },
23 PermissionDenied { path: PathBuf, operation: String },
25 Io(std::io::Error),
27 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#[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#[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
140pub fn check_disk_space(path: &Path, needed: usize) -> Result<(), StorageError> {
146 let dir = path.parent().unwrap_or(path);
148
149 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 Err(StorageError::DiskFull {
166 needed,
167 available: 0,
168 })
169 }
170 }
171 }
172}
173
174pub fn atomic_write(target: &Path, data: &[u8]) -> Result<(), std::io::Error> {
176 let temp_path = target.with_extension("tmp");
177
178 let mut file = File::create(&temp_path)?;
180 file.write_all(data)?;
181 file.sync_all()?;
182 drop(file);
183
184 match std::fs::rename(&temp_path, target) {
186 Ok(_) => Ok(()),
187 Err(_) if cfg!(windows) => {
188 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 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
207pub 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 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
232pub fn safe_path(input: &str) -> PathBuf {
234 #[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 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
257pub struct FileLock {
263 _lock_file: File,
264 lock_path: PathBuf,
265}
266
267impl FileLock {
268 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 match OpenOptions::new()
276 .write(true)
277 .create_new(true)
278 .open(&lock_path)
279 {
280 Ok(mut file) => {
281 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 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 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 if age > Duration::from_secs(60) {
318 return true;
319 }
320
321 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 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
343fn is_process_alive(pid: u32) -> bool {
345 #[cfg(unix)]
346 {
347 unsafe { libc_free_kill_check(pid) }
349 }
350
351 #[cfg(not(unix))]
352 {
353 let _ = pid;
355 true
356 }
357}
358
359#[cfg(unix)]
360unsafe fn libc_free_kill_check(pid: u32) -> bool {
361 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) }
370
371pub struct ProjectIsolation {
377 pub project_id: String,
378 pub project_dir: PathBuf,
379}
380
381impl ProjectIsolation {
382 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#[derive(Debug, PartialEq)]
424pub enum NormalizedContent {
425 Empty,
427 WhitespaceOnly,
429 Valid(String),
431}
432
433pub fn normalize_content(content: &str) -> NormalizedContent {
435 if content.is_empty() {
436 return NormalizedContent::Empty;
437 }
438
439 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#[derive(Debug, PartialEq)]
453pub enum ContentType {
454 Text,
455 Binary(&'static str), }
457
458pub fn detect_content_type(data: &[u8]) -> ContentType {
460 if data.is_empty() {
461 return ContentType::Text;
462 }
463
464 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 match std::str::from_utf8(data) {
479 Ok(s) => {
480 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
496pub const MAX_SINGLE_BLOCK_BYTES: usize = 10 * 1024 * 1024;
498
499pub const CHUNK_SIZE: usize = 1024 * 1024;
501
502pub 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
514pub 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
537pub fn normalize_path(path: &str) -> String {
543 let normalized = path.replace('\\', "/");
545
546 let normalized = normalized.trim_end_matches('/');
548
549 #[cfg(any(target_os = "windows", target_os = "macos"))]
551 let normalized = normalized.to_lowercase();
552
553 normalized.to_string()
554}
555
556pub fn paths_equal(a: &str, b: &str) -> bool {
558 normalize_path(a) == normalize_path(b)
559}
560
561pub 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 pub fn needs_recovery(&self) -> bool {
579 let in_progress = self.data_dir.join(".recovery_in_progress");
580 in_progress.exists()
581 }
582
583 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 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 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 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#[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
635pub fn safe_write_to_claude(target: &Path, content: &str) -> Result<(), std::io::Error> {
641 atomic_write(target, content.as_bytes())
642}
643
644pub fn merge_preserving_user_sections(existing: &str, our_content: &str) -> String {
646 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 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#[cfg(test)]
680mod tests {
681 use super::*;
682 use tempfile::TempDir;
683
684 #[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 #[test]
744 fn test_file_lock_acquire_release() {
745 let dir = TempDir::new().unwrap();
746 let data_path = dir.path().join("test.dat");
747 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 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 std::fs::write(&lock_path, "99999999").unwrap(); let result = FileLock::acquire(&dir.path().join("test.dat"), Duration::from_millis(200));
772 assert!(result.is_ok() || matches!(result, Err(LockError::Timeout)));
775 }
776
777 #[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 #[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 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 #[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 #[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 #[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 #[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}