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 = 3;
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 pub file_disabled_rules: HashSet<String>,
72 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct HeadingIndex {
80 pub text: String,
82 pub auto_anchor: String,
84 pub custom_anchor: Option<String>,
86 pub line: usize,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ReferenceLinkIndex {
93 pub reference_id: String,
95 pub line: usize,
97 pub column: usize,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CrossFileLinkIndex {
104 pub target_path: String,
106 pub fragment: String,
108 pub line: usize,
110 pub column: usize,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct VulnerableAnchor {
117 pub file: PathBuf,
119 pub line: usize,
121 pub text: String,
123}
124
125impl WorkspaceIndex {
126 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn version(&self) -> u64 {
133 self.version
134 }
135
136 pub fn file_count(&self) -> usize {
138 self.files.len()
139 }
140
141 pub fn contains_file(&self, path: &Path) -> bool {
143 self.files.contains_key(path)
144 }
145
146 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
148 self.files.get(path)
149 }
150
151 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
153 self.files.insert(path, index);
154 self.version = self.version.wrapping_add(1);
155 }
156
157 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
159 self.clear_reverse_deps_for(path);
161
162 let result = self.files.remove(path);
163 if result.is_some() {
164 self.version = self.version.wrapping_add(1);
165 }
166 result
167 }
168
169 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
179 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
180
181 for (file_path, file_index) in &self.files {
182 for heading in &file_index.headings {
183 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
185 let anchor_key = heading.auto_anchor.to_lowercase();
186 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
187 file: file_path.clone(),
188 line: heading.line,
189 text: heading.text.clone(),
190 });
191 }
192 }
193 }
194
195 vulnerable
196 }
197
198 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
200 self.files
201 .iter()
202 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
203 }
204
205 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
207 self.files.iter().map(|(p, i)| (p.as_path(), i))
208 }
209
210 pub fn clear(&mut self) {
212 self.files.clear();
213 self.reverse_deps.clear();
214 self.version = self.version.wrapping_add(1);
215 }
216
217 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
224 self.clear_reverse_deps_as_source(path);
227
228 for link in &index.cross_file_links {
230 let target = self.resolve_target_path(path, &link.target_path);
231 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
232 }
233
234 self.files.insert(path.to_path_buf(), index);
235 self.version = self.version.wrapping_add(1);
236 }
237
238 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
243 self.reverse_deps
244 .get(path)
245 .map(|set| set.iter().cloned().collect())
246 .unwrap_or_default()
247 }
248
249 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
253 self.files
254 .get(path)
255 .map(|f| f.content_hash != current_hash)
256 .unwrap_or(true)
257 }
258
259 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
264 let before_count = self.files.len();
265
266 let to_remove: Vec<PathBuf> = self
268 .files
269 .keys()
270 .filter(|path| !current_files.contains(*path))
271 .cloned()
272 .collect();
273
274 for path in &to_remove {
276 self.remove_file(path);
277 }
278
279 before_count - self.files.len()
280 }
281
282 #[cfg(feature = "native")]
289 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
290 use std::fs;
291 use std::io::Write;
292
293 fs::create_dir_all(cache_dir)?;
295
296 let encoded = bincode::serde::encode_to_vec(self, bincode::config::standard())
298 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
299
300 let mut cache_data = Vec::with_capacity(8 + encoded.len());
302 cache_data.extend_from_slice(CACHE_MAGIC);
303 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
304 cache_data.extend_from_slice(&encoded);
305
306 let final_path = cache_dir.join(CACHE_FILE_NAME);
308 let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
309
310 {
312 let mut file = fs::File::create(&temp_path)?;
313 file.write_all(&cache_data)?;
314 file.sync_all()?;
315 }
316
317 fs::rename(&temp_path, &final_path)?;
319
320 log::debug!(
321 "Saved workspace index to cache: {} files, {} bytes (format v{})",
322 self.files.len(),
323 cache_data.len(),
324 CACHE_FORMAT_VERSION
325 );
326
327 Ok(())
328 }
329
330 #[cfg(feature = "native")]
338 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
339 use std::fs;
340
341 let path = cache_dir.join(CACHE_FILE_NAME);
342 let data = fs::read(&path).ok()?;
343
344 if data.len() < 8 {
346 log::warn!("Workspace index cache too small, discarding");
347 let _ = fs::remove_file(&path);
348 return None;
349 }
350
351 if &data[0..4] != CACHE_MAGIC {
353 log::warn!("Workspace index cache has invalid magic header, discarding");
354 let _ = fs::remove_file(&path);
355 return None;
356 }
357
358 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
360 if version != CACHE_FORMAT_VERSION {
361 log::info!(
362 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
363 );
364 let _ = fs::remove_file(&path);
365 return None;
366 }
367
368 match bincode::serde::decode_from_slice(&data[8..], bincode::config::standard()) {
370 Ok((index, _bytes_read)) => {
371 let index: Self = index;
372 log::debug!(
373 "Loaded workspace index from cache: {} files (format v{})",
374 index.files.len(),
375 version
376 );
377 Some(index)
378 }
379 Err(e) => {
380 log::warn!("Failed to deserialize workspace index cache: {e}");
381 let _ = fs::remove_file(&path);
382 None
383 }
384 }
385 }
386
387 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
392 for deps in self.reverse_deps.values_mut() {
393 deps.remove(path);
394 }
395 self.reverse_deps.retain(|_, deps| !deps.is_empty());
397 }
398
399 fn clear_reverse_deps_for(&mut self, path: &Path) {
404 self.clear_reverse_deps_as_source(path);
406
407 self.reverse_deps.remove(path);
409 }
410
411 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
413 let source_dir = source_file.parent().unwrap_or(Path::new(""));
415
416 let target = source_dir.join(relative_target);
418
419 Self::normalize_path(&target)
421 }
422
423 fn normalize_path(path: &Path) -> PathBuf {
425 let mut components = Vec::new();
426
427 for component in path.components() {
428 match component {
429 std::path::Component::ParentDir => {
430 if !components.is_empty() {
432 components.pop();
433 }
434 }
435 std::path::Component::CurDir => {
436 }
438 _ => {
439 components.push(component);
440 }
441 }
442 }
443
444 components.iter().collect()
445 }
446}
447
448impl FileIndex {
449 pub fn new() -> Self {
451 Self::default()
452 }
453
454 pub fn with_hash(content_hash: String) -> Self {
456 Self {
457 content_hash,
458 ..Default::default()
459 }
460 }
461
462 pub fn add_heading(&mut self, heading: HeadingIndex) {
466 let index = self.headings.len();
467
468 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
470
471 if let Some(ref custom) = heading.custom_anchor {
473 self.anchor_to_heading.insert(custom.to_lowercase(), index);
474 }
475
476 self.headings.push(heading);
477 }
478
479 pub fn has_anchor(&self, anchor: &str) -> bool {
484 self.anchor_to_heading.contains_key(&anchor.to_lowercase())
485 }
486
487 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
491 self.anchor_to_heading
492 .get(&anchor.to_lowercase())
493 .and_then(|&idx| self.headings.get(idx))
494 }
495
496 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
498 self.reference_links.push(link);
499 }
500
501 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
506 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
508 return true;
509 }
510
511 if let Some(rules) = self.line_disabled_rules.get(&line) {
513 return rules.contains("*") || rules.contains(rule_name);
514 }
515
516 false
517 }
518
519 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
521 let is_duplicate = self.cross_file_links.iter().any(|existing| {
523 existing.target_path == link.target_path
524 && existing.fragment == link.fragment
525 && existing.line == link.line
526 && existing.column == link.column
527 });
528 if !is_duplicate {
529 self.cross_file_links.push(link);
530 }
531 }
532
533 pub fn add_defined_reference(&mut self, ref_id: String) {
535 self.defined_references.insert(ref_id);
536 }
537
538 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
540 self.defined_references.contains(ref_id)
541 }
542
543 pub fn hash_matches(&self, hash: &str) -> bool {
545 self.content_hash == hash
546 }
547
548 pub fn heading_count(&self) -> usize {
550 self.headings.len()
551 }
552
553 pub fn reference_link_count(&self) -> usize {
555 self.reference_links.len()
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn test_workspace_index_basic() {
565 let mut index = WorkspaceIndex::new();
566 assert_eq!(index.file_count(), 0);
567 assert_eq!(index.version(), 0);
568
569 let mut file_index = FileIndex::with_hash("abc123".to_string());
570 file_index.add_heading(HeadingIndex {
571 text: "Installation".to_string(),
572 auto_anchor: "installation".to_string(),
573 custom_anchor: None,
574 line: 1,
575 });
576
577 index.insert_file(PathBuf::from("docs/install.md"), file_index);
578 assert_eq!(index.file_count(), 1);
579 assert_eq!(index.version(), 1);
580
581 assert!(index.contains_file(Path::new("docs/install.md")));
582 assert!(!index.contains_file(Path::new("docs/other.md")));
583 }
584
585 #[test]
586 fn test_vulnerable_anchors() {
587 let mut index = WorkspaceIndex::new();
588
589 let mut file1 = FileIndex::new();
591 file1.add_heading(HeadingIndex {
592 text: "Getting Started".to_string(),
593 auto_anchor: "getting-started".to_string(),
594 custom_anchor: None,
595 line: 1,
596 });
597 index.insert_file(PathBuf::from("docs/guide.md"), file1);
598
599 let mut file2 = FileIndex::new();
601 file2.add_heading(HeadingIndex {
602 text: "Installation".to_string(),
603 auto_anchor: "installation".to_string(),
604 custom_anchor: Some("install".to_string()),
605 line: 1,
606 });
607 index.insert_file(PathBuf::from("docs/install.md"), file2);
608
609 let vulnerable = index.get_vulnerable_anchors();
610 assert_eq!(vulnerable.len(), 1);
611 assert!(vulnerable.contains_key("getting-started"));
612 assert!(!vulnerable.contains_key("installation"));
613
614 let anchors = vulnerable.get("getting-started").unwrap();
615 assert_eq!(anchors.len(), 1);
616 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
617 assert_eq!(anchors[0].text, "Getting Started");
618 }
619
620 #[test]
621 fn test_vulnerable_anchors_multiple_files_same_anchor() {
622 let mut index = WorkspaceIndex::new();
625
626 let mut file1 = FileIndex::new();
628 file1.add_heading(HeadingIndex {
629 text: "Installation".to_string(),
630 auto_anchor: "installation".to_string(),
631 custom_anchor: None,
632 line: 1,
633 });
634 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
635
636 let mut file2 = FileIndex::new();
638 file2.add_heading(HeadingIndex {
639 text: "Installation".to_string(),
640 auto_anchor: "installation".to_string(),
641 custom_anchor: None,
642 line: 5,
643 });
644 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
645
646 let mut file3 = FileIndex::new();
648 file3.add_heading(HeadingIndex {
649 text: "Installation".to_string(),
650 auto_anchor: "installation".to_string(),
651 custom_anchor: Some("install".to_string()),
652 line: 10,
653 });
654 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
655
656 let vulnerable = index.get_vulnerable_anchors();
657 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
659
660 let anchors = vulnerable.get("installation").unwrap();
661 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
663
664 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
666 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
667 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
668 }
669
670 #[test]
671 fn test_file_index_hash() {
672 let index = FileIndex::with_hash("hash123".to_string());
673 assert!(index.hash_matches("hash123"));
674 assert!(!index.hash_matches("other"));
675 }
676
677 #[test]
678 fn test_version_increment() {
679 let mut index = WorkspaceIndex::new();
680 assert_eq!(index.version(), 0);
681
682 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
683 assert_eq!(index.version(), 1);
684
685 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
686 assert_eq!(index.version(), 2);
687
688 index.remove_file(Path::new("a.md"));
689 assert_eq!(index.version(), 3);
690
691 index.remove_file(Path::new("nonexistent.md"));
693 assert_eq!(index.version(), 3);
694 }
695
696 #[test]
697 fn test_reverse_deps_basic() {
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: "section".to_string(),
705 line: 10,
706 column: 5,
707 });
708 index.update_file(Path::new("docs/a.md"), file_a);
709
710 let dependents = index.get_dependents(Path::new("docs/b.md"));
712 assert_eq!(dependents.len(), 1);
713 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
714
715 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
717 assert!(a_dependents.is_empty());
718 }
719
720 #[test]
721 fn test_reverse_deps_multiple() {
722 let mut index = WorkspaceIndex::new();
723
724 let mut file_a = FileIndex::new();
726 file_a.add_cross_file_link(CrossFileLinkIndex {
727 target_path: "../b.md".to_string(),
728 fragment: "".to_string(),
729 line: 1,
730 column: 1,
731 });
732 index.update_file(Path::new("docs/sub/a.md"), file_a);
733
734 let mut file_c = FileIndex::new();
735 file_c.add_cross_file_link(CrossFileLinkIndex {
736 target_path: "b.md".to_string(),
737 fragment: "".to_string(),
738 line: 1,
739 column: 1,
740 });
741 index.update_file(Path::new("docs/c.md"), file_c);
742
743 let dependents = index.get_dependents(Path::new("docs/b.md"));
745 assert_eq!(dependents.len(), 2);
746 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
747 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
748 }
749
750 #[test]
751 fn test_reverse_deps_update_clears_old() {
752 let mut index = WorkspaceIndex::new();
753
754 let mut file_a = FileIndex::new();
756 file_a.add_cross_file_link(CrossFileLinkIndex {
757 target_path: "b.md".to_string(),
758 fragment: "".to_string(),
759 line: 1,
760 column: 1,
761 });
762 index.update_file(Path::new("docs/a.md"), file_a);
763
764 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
766
767 let mut file_a_updated = FileIndex::new();
769 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
770 target_path: "c.md".to_string(),
771 fragment: "".to_string(),
772 line: 1,
773 column: 1,
774 });
775 index.update_file(Path::new("docs/a.md"), file_a_updated);
776
777 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
779
780 let c_deps = index.get_dependents(Path::new("docs/c.md"));
782 assert_eq!(c_deps.len(), 1);
783 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
784 }
785
786 #[test]
787 fn test_reverse_deps_remove_file() {
788 let mut index = WorkspaceIndex::new();
789
790 let mut file_a = FileIndex::new();
792 file_a.add_cross_file_link(CrossFileLinkIndex {
793 target_path: "b.md".to_string(),
794 fragment: "".to_string(),
795 line: 1,
796 column: 1,
797 });
798 index.update_file(Path::new("docs/a.md"), file_a);
799
800 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
802
803 index.remove_file(Path::new("docs/a.md"));
805
806 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
808 }
809
810 #[test]
811 fn test_normalize_path() {
812 let path = Path::new("docs/sub/../other.md");
814 let normalized = WorkspaceIndex::normalize_path(path);
815 assert_eq!(normalized, PathBuf::from("docs/other.md"));
816
817 let path2 = Path::new("docs/./other.md");
819 let normalized2 = WorkspaceIndex::normalize_path(path2);
820 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
821
822 let path3 = Path::new("a/b/c/../../d.md");
824 let normalized3 = WorkspaceIndex::normalize_path(path3);
825 assert_eq!(normalized3, PathBuf::from("a/d.md"));
826 }
827
828 #[test]
829 fn test_clear_clears_reverse_deps() {
830 let mut index = WorkspaceIndex::new();
831
832 let mut file_a = FileIndex::new();
834 file_a.add_cross_file_link(CrossFileLinkIndex {
835 target_path: "b.md".to_string(),
836 fragment: "".to_string(),
837 line: 1,
838 column: 1,
839 });
840 index.update_file(Path::new("docs/a.md"), file_a);
841
842 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
844
845 index.clear();
847
848 assert_eq!(index.file_count(), 0);
850 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
851 }
852
853 #[test]
854 fn test_is_file_stale() {
855 let mut index = WorkspaceIndex::new();
856
857 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
859
860 let file_index = FileIndex::with_hash("hash123".to_string());
862 index.insert_file(PathBuf::from("docs/test.md"), file_index);
863
864 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
866
867 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
869 }
870
871 #[cfg(feature = "native")]
872 #[test]
873 fn test_cache_roundtrip() {
874 use std::fs;
875
876 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
878 let _ = fs::remove_dir_all(&temp_dir);
879 fs::create_dir_all(&temp_dir).unwrap();
880
881 let mut index = WorkspaceIndex::new();
883
884 let mut file1 = FileIndex::with_hash("abc123".to_string());
885 file1.add_heading(HeadingIndex {
886 text: "Test Heading".to_string(),
887 auto_anchor: "test-heading".to_string(),
888 custom_anchor: Some("test".to_string()),
889 line: 1,
890 });
891 file1.add_cross_file_link(CrossFileLinkIndex {
892 target_path: "./other.md".to_string(),
893 fragment: "section".to_string(),
894 line: 5,
895 column: 3,
896 });
897 index.update_file(Path::new("docs/file1.md"), file1);
898
899 let mut file2 = FileIndex::with_hash("def456".to_string());
900 file2.add_heading(HeadingIndex {
901 text: "Another Heading".to_string(),
902 auto_anchor: "another-heading".to_string(),
903 custom_anchor: None,
904 line: 1,
905 });
906 index.update_file(Path::new("docs/other.md"), file2);
907
908 index.save_to_cache(&temp_dir).expect("Failed to save cache");
910
911 assert!(temp_dir.join("workspace_index.bin").exists());
913
914 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
916
917 assert_eq!(loaded.file_count(), 2);
919 assert!(loaded.contains_file(Path::new("docs/file1.md")));
920 assert!(loaded.contains_file(Path::new("docs/other.md")));
921
922 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
924 assert_eq!(file1_loaded.content_hash, "abc123");
925 assert_eq!(file1_loaded.headings.len(), 1);
926 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
927 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
928 assert_eq!(file1_loaded.cross_file_links.len(), 1);
929 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
930
931 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
933 assert_eq!(dependents.len(), 1);
934 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
935
936 let _ = fs::remove_dir_all(&temp_dir);
938 }
939
940 #[cfg(feature = "native")]
941 #[test]
942 fn test_cache_missing_file() {
943 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
944 let _ = std::fs::remove_dir_all(&temp_dir);
945
946 let result = WorkspaceIndex::load_from_cache(&temp_dir);
948 assert!(result.is_none());
949 }
950
951 #[cfg(feature = "native")]
952 #[test]
953 fn test_cache_corrupted_file() {
954 use std::fs;
955
956 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
957 let _ = fs::remove_dir_all(&temp_dir);
958 fs::create_dir_all(&temp_dir).unwrap();
959
960 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
962
963 let result = WorkspaceIndex::load_from_cache(&temp_dir);
965 assert!(result.is_none());
966
967 assert!(!temp_dir.join("workspace_index.bin").exists());
969
970 let _ = fs::remove_dir_all(&temp_dir);
972 }
973
974 #[cfg(feature = "native")]
975 #[test]
976 fn test_cache_invalid_magic() {
977 use std::fs;
978
979 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
980 let _ = fs::remove_dir_all(&temp_dir);
981 fs::create_dir_all(&temp_dir).unwrap();
982
983 let mut data = Vec::new();
985 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();
989
990 let result = WorkspaceIndex::load_from_cache(&temp_dir);
992 assert!(result.is_none());
993
994 assert!(!temp_dir.join("workspace_index.bin").exists());
996
997 let _ = fs::remove_dir_all(&temp_dir);
999 }
1000
1001 #[cfg(feature = "native")]
1002 #[test]
1003 fn test_cache_version_mismatch() {
1004 use std::fs;
1005
1006 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1007 let _ = fs::remove_dir_all(&temp_dir);
1008 fs::create_dir_all(&temp_dir).unwrap();
1009
1010 let mut data = Vec::new();
1012 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();
1016
1017 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1019 assert!(result.is_none());
1020
1021 assert!(!temp_dir.join("workspace_index.bin").exists());
1023
1024 let _ = fs::remove_dir_all(&temp_dir);
1026 }
1027
1028 #[cfg(feature = "native")]
1029 #[test]
1030 fn test_cache_atomic_write() {
1031 use std::fs;
1032
1033 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1035 let _ = fs::remove_dir_all(&temp_dir);
1036 fs::create_dir_all(&temp_dir).unwrap();
1037
1038 let index = WorkspaceIndex::new();
1039 index.save_to_cache(&temp_dir).expect("Failed to save");
1040
1041 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1043 assert_eq!(entries.len(), 1);
1044 assert!(temp_dir.join("workspace_index.bin").exists());
1045
1046 let _ = fs::remove_dir_all(&temp_dir);
1048 }
1049
1050 #[test]
1051 fn test_has_anchor_auto_generated() {
1052 let mut file_index = FileIndex::new();
1053 file_index.add_heading(HeadingIndex {
1054 text: "Installation Guide".to_string(),
1055 auto_anchor: "installation-guide".to_string(),
1056 custom_anchor: None,
1057 line: 1,
1058 });
1059
1060 assert!(file_index.has_anchor("installation-guide"));
1062
1063 assert!(file_index.has_anchor("Installation-Guide"));
1065 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1066
1067 assert!(!file_index.has_anchor("nonexistent"));
1069 }
1070
1071 #[test]
1072 fn test_has_anchor_custom() {
1073 let mut file_index = FileIndex::new();
1074 file_index.add_heading(HeadingIndex {
1075 text: "Installation Guide".to_string(),
1076 auto_anchor: "installation-guide".to_string(),
1077 custom_anchor: Some("install".to_string()),
1078 line: 1,
1079 });
1080
1081 assert!(file_index.has_anchor("installation-guide"));
1083
1084 assert!(file_index.has_anchor("install"));
1086 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1090 }
1091
1092 #[test]
1093 fn test_get_heading_by_anchor() {
1094 let mut file_index = FileIndex::new();
1095 file_index.add_heading(HeadingIndex {
1096 text: "Installation Guide".to_string(),
1097 auto_anchor: "installation-guide".to_string(),
1098 custom_anchor: Some("install".to_string()),
1099 line: 10,
1100 });
1101 file_index.add_heading(HeadingIndex {
1102 text: "Configuration".to_string(),
1103 auto_anchor: "configuration".to_string(),
1104 custom_anchor: None,
1105 line: 20,
1106 });
1107
1108 let heading = file_index.get_heading_by_anchor("installation-guide");
1110 assert!(heading.is_some());
1111 assert_eq!(heading.unwrap().text, "Installation Guide");
1112 assert_eq!(heading.unwrap().line, 10);
1113
1114 let heading = file_index.get_heading_by_anchor("install");
1116 assert!(heading.is_some());
1117 assert_eq!(heading.unwrap().text, "Installation Guide");
1118
1119 let heading = file_index.get_heading_by_anchor("configuration");
1121 assert!(heading.is_some());
1122 assert_eq!(heading.unwrap().text, "Configuration");
1123 assert_eq!(heading.unwrap().line, 20);
1124
1125 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1127 }
1128
1129 #[test]
1130 fn test_anchor_lookup_many_headings() {
1131 let mut file_index = FileIndex::new();
1133
1134 for i in 0..100 {
1136 file_index.add_heading(HeadingIndex {
1137 text: format!("Heading {i}"),
1138 auto_anchor: format!("heading-{i}"),
1139 custom_anchor: Some(format!("h{i}")),
1140 line: i + 1,
1141 });
1142 }
1143
1144 for i in 0..100 {
1146 assert!(file_index.has_anchor(&format!("heading-{i}")));
1147 assert!(file_index.has_anchor(&format!("h{i}")));
1148
1149 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1150 assert!(heading.is_some());
1151 assert_eq!(heading.unwrap().line, i + 1);
1152 }
1153 }
1154}