1use serde::{Deserialize, Serialize};
22use std::collections::{HashMap, HashSet};
23use std::path::{Path, PathBuf};
24
25#[cfg(feature = "native")]
27const CACHE_MAGIC: &[u8; 4] = b"RWSI";
28
29#[cfg(feature = "native")]
31const CACHE_FORMAT_VERSION: u32 = 2;
32
33#[cfg(feature = "native")]
35const CACHE_FILE_NAME: &str = "workspace_index.bin";
36
37#[derive(Debug, Default, Clone, Serialize, Deserialize)]
42pub struct WorkspaceIndex {
43 files: HashMap<PathBuf, FileIndex>,
45 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
48 version: u64,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct FileIndex {
55 pub headings: Vec<HeadingIndex>,
57 pub reference_links: Vec<ReferenceLinkIndex>,
59 pub cross_file_links: Vec<CrossFileLinkIndex>,
61 pub defined_references: HashSet<String>,
64 pub content_hash: String,
66 anchor_to_heading: HashMap<String, usize>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct HeadingIndex {
74 pub text: String,
76 pub auto_anchor: String,
78 pub custom_anchor: Option<String>,
80 pub line: usize,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ReferenceLinkIndex {
87 pub reference_id: String,
89 pub line: usize,
91 pub column: usize,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CrossFileLinkIndex {
98 pub target_path: String,
100 pub fragment: String,
102 pub line: usize,
104 pub column: usize,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct VulnerableAnchor {
111 pub file: PathBuf,
113 pub line: usize,
115 pub text: String,
117}
118
119impl WorkspaceIndex {
120 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn version(&self) -> u64 {
127 self.version
128 }
129
130 pub fn file_count(&self) -> usize {
132 self.files.len()
133 }
134
135 pub fn contains_file(&self, path: &Path) -> bool {
137 self.files.contains_key(path)
138 }
139
140 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
142 self.files.get(path)
143 }
144
145 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
147 self.files.insert(path, index);
148 self.version = self.version.wrapping_add(1);
149 }
150
151 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
153 self.clear_reverse_deps_for(path);
155
156 let result = self.files.remove(path);
157 if result.is_some() {
158 self.version = self.version.wrapping_add(1);
159 }
160 result
161 }
162
163 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
173 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
174
175 for (file_path, file_index) in &self.files {
176 for heading in &file_index.headings {
177 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
179 let anchor_key = heading.auto_anchor.to_lowercase();
180 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
181 file: file_path.clone(),
182 line: heading.line,
183 text: heading.text.clone(),
184 });
185 }
186 }
187 }
188
189 vulnerable
190 }
191
192 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
194 self.files
195 .iter()
196 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
197 }
198
199 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
201 self.files.iter().map(|(p, i)| (p.as_path(), i))
202 }
203
204 pub fn clear(&mut self) {
206 self.files.clear();
207 self.reverse_deps.clear();
208 self.version = self.version.wrapping_add(1);
209 }
210
211 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
218 self.clear_reverse_deps_as_source(path);
221
222 for link in &index.cross_file_links {
224 let target = self.resolve_target_path(path, &link.target_path);
225 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
226 }
227
228 self.files.insert(path.to_path_buf(), index);
229 self.version = self.version.wrapping_add(1);
230 }
231
232 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
237 self.reverse_deps
238 .get(path)
239 .map(|set| set.iter().cloned().collect())
240 .unwrap_or_default()
241 }
242
243 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
247 self.files
248 .get(path)
249 .map(|f| f.content_hash != current_hash)
250 .unwrap_or(true)
251 }
252
253 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
258 let before_count = self.files.len();
259
260 let to_remove: Vec<PathBuf> = self
262 .files
263 .keys()
264 .filter(|path| !current_files.contains(*path))
265 .cloned()
266 .collect();
267
268 for path in &to_remove {
270 self.remove_file(path);
271 }
272
273 before_count - self.files.len()
274 }
275
276 #[cfg(feature = "native")]
283 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
284 use std::fs;
285 use std::io::Write;
286
287 fs::create_dir_all(cache_dir)?;
289
290 let encoded = bincode::serialize(self)
292 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
293
294 let mut cache_data = Vec::with_capacity(8 + encoded.len());
296 cache_data.extend_from_slice(CACHE_MAGIC);
297 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
298 cache_data.extend_from_slice(&encoded);
299
300 let final_path = cache_dir.join(CACHE_FILE_NAME);
302 let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
303
304 {
306 let mut file = fs::File::create(&temp_path)?;
307 file.write_all(&cache_data)?;
308 file.sync_all()?;
309 }
310
311 fs::rename(&temp_path, &final_path)?;
313
314 log::debug!(
315 "Saved workspace index to cache: {} files, {} bytes (format v{})",
316 self.files.len(),
317 cache_data.len(),
318 CACHE_FORMAT_VERSION
319 );
320
321 Ok(())
322 }
323
324 #[cfg(feature = "native")]
332 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
333 use std::fs;
334
335 let path = cache_dir.join(CACHE_FILE_NAME);
336 let data = fs::read(&path).ok()?;
337
338 if data.len() < 8 {
340 log::warn!("Workspace index cache too small, discarding");
341 let _ = fs::remove_file(&path);
342 return None;
343 }
344
345 if &data[0..4] != CACHE_MAGIC {
347 log::warn!("Workspace index cache has invalid magic header, discarding");
348 let _ = fs::remove_file(&path);
349 return None;
350 }
351
352 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
354 if version != CACHE_FORMAT_VERSION {
355 log::info!(
356 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
357 );
358 let _ = fs::remove_file(&path);
359 return None;
360 }
361
362 match bincode::deserialize(&data[8..]) {
364 Ok(index) => {
365 let index: Self = index;
366 log::debug!(
367 "Loaded workspace index from cache: {} files (format v{})",
368 index.files.len(),
369 version
370 );
371 Some(index)
372 }
373 Err(e) => {
374 log::warn!("Failed to deserialize workspace index cache: {e}");
375 let _ = fs::remove_file(&path);
376 None
377 }
378 }
379 }
380
381 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
386 for deps in self.reverse_deps.values_mut() {
387 deps.remove(path);
388 }
389 self.reverse_deps.retain(|_, deps| !deps.is_empty());
391 }
392
393 fn clear_reverse_deps_for(&mut self, path: &Path) {
398 self.clear_reverse_deps_as_source(path);
400
401 self.reverse_deps.remove(path);
403 }
404
405 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
407 let source_dir = source_file.parent().unwrap_or(Path::new(""));
409
410 let target = source_dir.join(relative_target);
412
413 Self::normalize_path(&target)
415 }
416
417 fn normalize_path(path: &Path) -> PathBuf {
419 let mut components = Vec::new();
420
421 for component in path.components() {
422 match component {
423 std::path::Component::ParentDir => {
424 if !components.is_empty() {
426 components.pop();
427 }
428 }
429 std::path::Component::CurDir => {
430 }
432 _ => {
433 components.push(component);
434 }
435 }
436 }
437
438 components.iter().collect()
439 }
440}
441
442impl FileIndex {
443 pub fn new() -> Self {
445 Self::default()
446 }
447
448 pub fn with_hash(content_hash: String) -> Self {
450 Self {
451 content_hash,
452 ..Default::default()
453 }
454 }
455
456 pub fn add_heading(&mut self, heading: HeadingIndex) {
460 let index = self.headings.len();
461
462 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
464
465 if let Some(ref custom) = heading.custom_anchor {
467 self.anchor_to_heading.insert(custom.to_lowercase(), index);
468 }
469
470 self.headings.push(heading);
471 }
472
473 pub fn has_anchor(&self, anchor: &str) -> bool {
478 self.anchor_to_heading.contains_key(&anchor.to_lowercase())
479 }
480
481 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
485 self.anchor_to_heading
486 .get(&anchor.to_lowercase())
487 .and_then(|&idx| self.headings.get(idx))
488 }
489
490 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
492 self.reference_links.push(link);
493 }
494
495 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
497 let is_duplicate = self.cross_file_links.iter().any(|existing| {
499 existing.target_path == link.target_path
500 && existing.fragment == link.fragment
501 && existing.line == link.line
502 && existing.column == link.column
503 });
504 if !is_duplicate {
505 self.cross_file_links.push(link);
506 }
507 }
508
509 pub fn add_defined_reference(&mut self, ref_id: String) {
511 self.defined_references.insert(ref_id);
512 }
513
514 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
516 self.defined_references.contains(ref_id)
517 }
518
519 pub fn hash_matches(&self, hash: &str) -> bool {
521 self.content_hash == hash
522 }
523
524 pub fn heading_count(&self) -> usize {
526 self.headings.len()
527 }
528
529 pub fn reference_link_count(&self) -> usize {
531 self.reference_links.len()
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn test_workspace_index_basic() {
541 let mut index = WorkspaceIndex::new();
542 assert_eq!(index.file_count(), 0);
543 assert_eq!(index.version(), 0);
544
545 let mut file_index = FileIndex::with_hash("abc123".to_string());
546 file_index.add_heading(HeadingIndex {
547 text: "Installation".to_string(),
548 auto_anchor: "installation".to_string(),
549 custom_anchor: None,
550 line: 1,
551 });
552
553 index.insert_file(PathBuf::from("docs/install.md"), file_index);
554 assert_eq!(index.file_count(), 1);
555 assert_eq!(index.version(), 1);
556
557 assert!(index.contains_file(Path::new("docs/install.md")));
558 assert!(!index.contains_file(Path::new("docs/other.md")));
559 }
560
561 #[test]
562 fn test_vulnerable_anchors() {
563 let mut index = WorkspaceIndex::new();
564
565 let mut file1 = FileIndex::new();
567 file1.add_heading(HeadingIndex {
568 text: "Getting Started".to_string(),
569 auto_anchor: "getting-started".to_string(),
570 custom_anchor: None,
571 line: 1,
572 });
573 index.insert_file(PathBuf::from("docs/guide.md"), file1);
574
575 let mut file2 = FileIndex::new();
577 file2.add_heading(HeadingIndex {
578 text: "Installation".to_string(),
579 auto_anchor: "installation".to_string(),
580 custom_anchor: Some("install".to_string()),
581 line: 1,
582 });
583 index.insert_file(PathBuf::from("docs/install.md"), file2);
584
585 let vulnerable = index.get_vulnerable_anchors();
586 assert_eq!(vulnerable.len(), 1);
587 assert!(vulnerable.contains_key("getting-started"));
588 assert!(!vulnerable.contains_key("installation"));
589
590 let anchors = vulnerable.get("getting-started").unwrap();
591 assert_eq!(anchors.len(), 1);
592 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
593 assert_eq!(anchors[0].text, "Getting Started");
594 }
595
596 #[test]
597 fn test_vulnerable_anchors_multiple_files_same_anchor() {
598 let mut index = WorkspaceIndex::new();
601
602 let mut file1 = FileIndex::new();
604 file1.add_heading(HeadingIndex {
605 text: "Installation".to_string(),
606 auto_anchor: "installation".to_string(),
607 custom_anchor: None,
608 line: 1,
609 });
610 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
611
612 let mut file2 = FileIndex::new();
614 file2.add_heading(HeadingIndex {
615 text: "Installation".to_string(),
616 auto_anchor: "installation".to_string(),
617 custom_anchor: None,
618 line: 5,
619 });
620 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
621
622 let mut file3 = FileIndex::new();
624 file3.add_heading(HeadingIndex {
625 text: "Installation".to_string(),
626 auto_anchor: "installation".to_string(),
627 custom_anchor: Some("install".to_string()),
628 line: 10,
629 });
630 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
631
632 let vulnerable = index.get_vulnerable_anchors();
633 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
635
636 let anchors = vulnerable.get("installation").unwrap();
637 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
639
640 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
642 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
643 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
644 }
645
646 #[test]
647 fn test_file_index_hash() {
648 let index = FileIndex::with_hash("hash123".to_string());
649 assert!(index.hash_matches("hash123"));
650 assert!(!index.hash_matches("other"));
651 }
652
653 #[test]
654 fn test_version_increment() {
655 let mut index = WorkspaceIndex::new();
656 assert_eq!(index.version(), 0);
657
658 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
659 assert_eq!(index.version(), 1);
660
661 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
662 assert_eq!(index.version(), 2);
663
664 index.remove_file(Path::new("a.md"));
665 assert_eq!(index.version(), 3);
666
667 index.remove_file(Path::new("nonexistent.md"));
669 assert_eq!(index.version(), 3);
670 }
671
672 #[test]
673 fn test_reverse_deps_basic() {
674 let mut index = WorkspaceIndex::new();
675
676 let mut file_a = FileIndex::new();
678 file_a.add_cross_file_link(CrossFileLinkIndex {
679 target_path: "b.md".to_string(),
680 fragment: "section".to_string(),
681 line: 10,
682 column: 5,
683 });
684 index.update_file(Path::new("docs/a.md"), file_a);
685
686 let dependents = index.get_dependents(Path::new("docs/b.md"));
688 assert_eq!(dependents.len(), 1);
689 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
690
691 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
693 assert!(a_dependents.is_empty());
694 }
695
696 #[test]
697 fn test_reverse_deps_multiple() {
698 let mut index = WorkspaceIndex::new();
699
700 let mut file_a = FileIndex::new();
702 file_a.add_cross_file_link(CrossFileLinkIndex {
703 target_path: "../b.md".to_string(),
704 fragment: "".to_string(),
705 line: 1,
706 column: 1,
707 });
708 index.update_file(Path::new("docs/sub/a.md"), file_a);
709
710 let mut file_c = FileIndex::new();
711 file_c.add_cross_file_link(CrossFileLinkIndex {
712 target_path: "b.md".to_string(),
713 fragment: "".to_string(),
714 line: 1,
715 column: 1,
716 });
717 index.update_file(Path::new("docs/c.md"), file_c);
718
719 let dependents = index.get_dependents(Path::new("docs/b.md"));
721 assert_eq!(dependents.len(), 2);
722 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
723 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
724 }
725
726 #[test]
727 fn test_reverse_deps_update_clears_old() {
728 let mut index = WorkspaceIndex::new();
729
730 let mut file_a = FileIndex::new();
732 file_a.add_cross_file_link(CrossFileLinkIndex {
733 target_path: "b.md".to_string(),
734 fragment: "".to_string(),
735 line: 1,
736 column: 1,
737 });
738 index.update_file(Path::new("docs/a.md"), file_a);
739
740 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
742
743 let mut file_a_updated = FileIndex::new();
745 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
746 target_path: "c.md".to_string(),
747 fragment: "".to_string(),
748 line: 1,
749 column: 1,
750 });
751 index.update_file(Path::new("docs/a.md"), file_a_updated);
752
753 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
755
756 let c_deps = index.get_dependents(Path::new("docs/c.md"));
758 assert_eq!(c_deps.len(), 1);
759 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
760 }
761
762 #[test]
763 fn test_reverse_deps_remove_file() {
764 let mut index = WorkspaceIndex::new();
765
766 let mut file_a = FileIndex::new();
768 file_a.add_cross_file_link(CrossFileLinkIndex {
769 target_path: "b.md".to_string(),
770 fragment: "".to_string(),
771 line: 1,
772 column: 1,
773 });
774 index.update_file(Path::new("docs/a.md"), file_a);
775
776 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
778
779 index.remove_file(Path::new("docs/a.md"));
781
782 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
784 }
785
786 #[test]
787 fn test_normalize_path() {
788 let path = Path::new("docs/sub/../other.md");
790 let normalized = WorkspaceIndex::normalize_path(path);
791 assert_eq!(normalized, PathBuf::from("docs/other.md"));
792
793 let path2 = Path::new("docs/./other.md");
795 let normalized2 = WorkspaceIndex::normalize_path(path2);
796 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
797
798 let path3 = Path::new("a/b/c/../../d.md");
800 let normalized3 = WorkspaceIndex::normalize_path(path3);
801 assert_eq!(normalized3, PathBuf::from("a/d.md"));
802 }
803
804 #[test]
805 fn test_clear_clears_reverse_deps() {
806 let mut index = WorkspaceIndex::new();
807
808 let mut file_a = FileIndex::new();
810 file_a.add_cross_file_link(CrossFileLinkIndex {
811 target_path: "b.md".to_string(),
812 fragment: "".to_string(),
813 line: 1,
814 column: 1,
815 });
816 index.update_file(Path::new("docs/a.md"), file_a);
817
818 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
820
821 index.clear();
823
824 assert_eq!(index.file_count(), 0);
826 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
827 }
828
829 #[test]
830 fn test_is_file_stale() {
831 let mut index = WorkspaceIndex::new();
832
833 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
835
836 let file_index = FileIndex::with_hash("hash123".to_string());
838 index.insert_file(PathBuf::from("docs/test.md"), file_index);
839
840 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
842
843 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
845 }
846
847 #[cfg(feature = "native")]
848 #[test]
849 fn test_cache_roundtrip() {
850 use std::fs;
851
852 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
854 let _ = fs::remove_dir_all(&temp_dir);
855 fs::create_dir_all(&temp_dir).unwrap();
856
857 let mut index = WorkspaceIndex::new();
859
860 let mut file1 = FileIndex::with_hash("abc123".to_string());
861 file1.add_heading(HeadingIndex {
862 text: "Test Heading".to_string(),
863 auto_anchor: "test-heading".to_string(),
864 custom_anchor: Some("test".to_string()),
865 line: 1,
866 });
867 file1.add_cross_file_link(CrossFileLinkIndex {
868 target_path: "./other.md".to_string(),
869 fragment: "section".to_string(),
870 line: 5,
871 column: 3,
872 });
873 index.update_file(Path::new("docs/file1.md"), file1);
874
875 let mut file2 = FileIndex::with_hash("def456".to_string());
876 file2.add_heading(HeadingIndex {
877 text: "Another Heading".to_string(),
878 auto_anchor: "another-heading".to_string(),
879 custom_anchor: None,
880 line: 1,
881 });
882 index.update_file(Path::new("docs/other.md"), file2);
883
884 index.save_to_cache(&temp_dir).expect("Failed to save cache");
886
887 assert!(temp_dir.join("workspace_index.bin").exists());
889
890 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
892
893 assert_eq!(loaded.file_count(), 2);
895 assert!(loaded.contains_file(Path::new("docs/file1.md")));
896 assert!(loaded.contains_file(Path::new("docs/other.md")));
897
898 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
900 assert_eq!(file1_loaded.content_hash, "abc123");
901 assert_eq!(file1_loaded.headings.len(), 1);
902 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
903 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
904 assert_eq!(file1_loaded.cross_file_links.len(), 1);
905 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
906
907 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
909 assert_eq!(dependents.len(), 1);
910 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
911
912 let _ = fs::remove_dir_all(&temp_dir);
914 }
915
916 #[cfg(feature = "native")]
917 #[test]
918 fn test_cache_missing_file() {
919 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
920 let _ = std::fs::remove_dir_all(&temp_dir);
921
922 let result = WorkspaceIndex::load_from_cache(&temp_dir);
924 assert!(result.is_none());
925 }
926
927 #[cfg(feature = "native")]
928 #[test]
929 fn test_cache_corrupted_file() {
930 use std::fs;
931
932 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
933 let _ = fs::remove_dir_all(&temp_dir);
934 fs::create_dir_all(&temp_dir).unwrap();
935
936 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
938
939 let result = WorkspaceIndex::load_from_cache(&temp_dir);
941 assert!(result.is_none());
942
943 assert!(!temp_dir.join("workspace_index.bin").exists());
945
946 let _ = fs::remove_dir_all(&temp_dir);
948 }
949
950 #[cfg(feature = "native")]
951 #[test]
952 fn test_cache_invalid_magic() {
953 use std::fs;
954
955 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
956 let _ = fs::remove_dir_all(&temp_dir);
957 fs::create_dir_all(&temp_dir).unwrap();
958
959 let mut data = Vec::new();
961 data.extend_from_slice(b"XXXX"); data.extend_from_slice(&1u32.to_le_bytes()); data.extend_from_slice(&[0; 100]); fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
965
966 let result = WorkspaceIndex::load_from_cache(&temp_dir);
968 assert!(result.is_none());
969
970 assert!(!temp_dir.join("workspace_index.bin").exists());
972
973 let _ = fs::remove_dir_all(&temp_dir);
975 }
976
977 #[cfg(feature = "native")]
978 #[test]
979 fn test_cache_version_mismatch() {
980 use std::fs;
981
982 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
983 let _ = fs::remove_dir_all(&temp_dir);
984 fs::create_dir_all(&temp_dir).unwrap();
985
986 let mut data = Vec::new();
988 data.extend_from_slice(b"RWSI"); data.extend_from_slice(&999u32.to_le_bytes()); data.extend_from_slice(&[0; 100]); fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
992
993 let result = WorkspaceIndex::load_from_cache(&temp_dir);
995 assert!(result.is_none());
996
997 assert!(!temp_dir.join("workspace_index.bin").exists());
999
1000 let _ = fs::remove_dir_all(&temp_dir);
1002 }
1003
1004 #[cfg(feature = "native")]
1005 #[test]
1006 fn test_cache_atomic_write() {
1007 use std::fs;
1008
1009 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1011 let _ = fs::remove_dir_all(&temp_dir);
1012 fs::create_dir_all(&temp_dir).unwrap();
1013
1014 let index = WorkspaceIndex::new();
1015 index.save_to_cache(&temp_dir).expect("Failed to save");
1016
1017 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1019 assert_eq!(entries.len(), 1);
1020 assert!(temp_dir.join("workspace_index.bin").exists());
1021
1022 let _ = fs::remove_dir_all(&temp_dir);
1024 }
1025
1026 #[test]
1027 fn test_has_anchor_auto_generated() {
1028 let mut file_index = FileIndex::new();
1029 file_index.add_heading(HeadingIndex {
1030 text: "Installation Guide".to_string(),
1031 auto_anchor: "installation-guide".to_string(),
1032 custom_anchor: None,
1033 line: 1,
1034 });
1035
1036 assert!(file_index.has_anchor("installation-guide"));
1038
1039 assert!(file_index.has_anchor("Installation-Guide"));
1041 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1042
1043 assert!(!file_index.has_anchor("nonexistent"));
1045 }
1046
1047 #[test]
1048 fn test_has_anchor_custom() {
1049 let mut file_index = FileIndex::new();
1050 file_index.add_heading(HeadingIndex {
1051 text: "Installation Guide".to_string(),
1052 auto_anchor: "installation-guide".to_string(),
1053 custom_anchor: Some("install".to_string()),
1054 line: 1,
1055 });
1056
1057 assert!(file_index.has_anchor("installation-guide"));
1059
1060 assert!(file_index.has_anchor("install"));
1062 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1066 }
1067
1068 #[test]
1069 fn test_get_heading_by_anchor() {
1070 let mut file_index = FileIndex::new();
1071 file_index.add_heading(HeadingIndex {
1072 text: "Installation Guide".to_string(),
1073 auto_anchor: "installation-guide".to_string(),
1074 custom_anchor: Some("install".to_string()),
1075 line: 10,
1076 });
1077 file_index.add_heading(HeadingIndex {
1078 text: "Configuration".to_string(),
1079 auto_anchor: "configuration".to_string(),
1080 custom_anchor: None,
1081 line: 20,
1082 });
1083
1084 let heading = file_index.get_heading_by_anchor("installation-guide");
1086 assert!(heading.is_some());
1087 assert_eq!(heading.unwrap().text, "Installation Guide");
1088 assert_eq!(heading.unwrap().line, 10);
1089
1090 let heading = file_index.get_heading_by_anchor("install");
1092 assert!(heading.is_some());
1093 assert_eq!(heading.unwrap().text, "Installation Guide");
1094
1095 let heading = file_index.get_heading_by_anchor("configuration");
1097 assert!(heading.is_some());
1098 assert_eq!(heading.unwrap().text, "Configuration");
1099 assert_eq!(heading.unwrap().line, 20);
1100
1101 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1103 }
1104
1105 #[test]
1106 fn test_anchor_lookup_many_headings() {
1107 let mut file_index = FileIndex::new();
1109
1110 for i in 0..100 {
1112 file_index.add_heading(HeadingIndex {
1113 text: format!("Heading {i}"),
1114 auto_anchor: format!("heading-{i}"),
1115 custom_anchor: Some(format!("h{i}")),
1116 line: i + 1,
1117 });
1118 }
1119
1120 for i in 0..100 {
1122 assert!(file_index.has_anchor(&format!("heading-{i}")));
1123 assert!(file_index.has_anchor(&format!("h{i}")));
1124
1125 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1126 assert!(heading.is_some());
1127 assert_eq!(heading.unwrap().line, i + 1);
1128 }
1129 }
1130}