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 = 5;
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 html_anchors: HashSet<String>,
72 attribute_anchors: HashSet<String>,
76 pub file_disabled_rules: HashSet<String>,
79 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct HeadingIndex {
87 pub text: String,
89 pub auto_anchor: String,
91 pub custom_anchor: Option<String>,
93 pub line: usize,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ReferenceLinkIndex {
100 pub reference_id: String,
102 pub line: usize,
104 pub column: usize,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CrossFileLinkIndex {
111 pub target_path: String,
113 pub fragment: String,
115 pub line: usize,
117 pub column: usize,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct VulnerableAnchor {
124 pub file: PathBuf,
126 pub line: usize,
128 pub text: String,
130}
131
132impl WorkspaceIndex {
133 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn version(&self) -> u64 {
140 self.version
141 }
142
143 pub fn file_count(&self) -> usize {
145 self.files.len()
146 }
147
148 pub fn contains_file(&self, path: &Path) -> bool {
150 self.files.contains_key(path)
151 }
152
153 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
155 self.files.get(path)
156 }
157
158 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
160 self.files.insert(path, index);
161 self.version = self.version.wrapping_add(1);
162 }
163
164 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
166 self.clear_reverse_deps_for(path);
168
169 let result = self.files.remove(path);
170 if result.is_some() {
171 self.version = self.version.wrapping_add(1);
172 }
173 result
174 }
175
176 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
186 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
187
188 for (file_path, file_index) in &self.files {
189 for heading in &file_index.headings {
190 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
192 let anchor_key = heading.auto_anchor.to_lowercase();
193 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
194 file: file_path.clone(),
195 line: heading.line,
196 text: heading.text.clone(),
197 });
198 }
199 }
200 }
201
202 vulnerable
203 }
204
205 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
207 self.files
208 .iter()
209 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
210 }
211
212 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
214 self.files.iter().map(|(p, i)| (p.as_path(), i))
215 }
216
217 pub fn clear(&mut self) {
219 self.files.clear();
220 self.reverse_deps.clear();
221 self.version = self.version.wrapping_add(1);
222 }
223
224 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
231 self.clear_reverse_deps_as_source(path);
234
235 for link in &index.cross_file_links {
237 let target = self.resolve_target_path(path, &link.target_path);
238 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
239 }
240
241 self.files.insert(path.to_path_buf(), index);
242 self.version = self.version.wrapping_add(1);
243 }
244
245 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
250 self.reverse_deps
251 .get(path)
252 .map(|set| set.iter().cloned().collect())
253 .unwrap_or_default()
254 }
255
256 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
260 self.files
261 .get(path)
262 .map(|f| f.content_hash != current_hash)
263 .unwrap_or(true)
264 }
265
266 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
271 let before_count = self.files.len();
272
273 let to_remove: Vec<PathBuf> = self
275 .files
276 .keys()
277 .filter(|path| !current_files.contains(*path))
278 .cloned()
279 .collect();
280
281 for path in &to_remove {
283 self.remove_file(path);
284 }
285
286 before_count - self.files.len()
287 }
288
289 #[cfg(feature = "native")]
296 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
297 use std::fs;
298 use std::io::Write;
299
300 fs::create_dir_all(cache_dir)?;
302
303 let encoded = postcard::to_allocvec(self)
305 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
306
307 let mut cache_data = Vec::with_capacity(8 + encoded.len());
309 cache_data.extend_from_slice(CACHE_MAGIC);
310 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
311 cache_data.extend_from_slice(&encoded);
312
313 let final_path = cache_dir.join(CACHE_FILE_NAME);
315 let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
316
317 {
319 let mut file = fs::File::create(&temp_path)?;
320 file.write_all(&cache_data)?;
321 file.sync_all()?;
322 }
323
324 fs::rename(&temp_path, &final_path)?;
326
327 log::debug!(
328 "Saved workspace index to cache: {} files, {} bytes (format v{})",
329 self.files.len(),
330 cache_data.len(),
331 CACHE_FORMAT_VERSION
332 );
333
334 Ok(())
335 }
336
337 #[cfg(feature = "native")]
345 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
346 use std::fs;
347
348 let path = cache_dir.join(CACHE_FILE_NAME);
349 let data = fs::read(&path).ok()?;
350
351 if data.len() < 8 {
353 log::warn!("Workspace index cache too small, discarding");
354 let _ = fs::remove_file(&path);
355 return None;
356 }
357
358 if &data[0..4] != CACHE_MAGIC {
360 log::warn!("Workspace index cache has invalid magic header, discarding");
361 let _ = fs::remove_file(&path);
362 return None;
363 }
364
365 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
367 if version != CACHE_FORMAT_VERSION {
368 log::info!(
369 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
370 );
371 let _ = fs::remove_file(&path);
372 return None;
373 }
374
375 match postcard::from_bytes::<Self>(&data[8..]) {
377 Ok(index) => {
378 log::debug!(
379 "Loaded workspace index from cache: {} files (format v{})",
380 index.files.len(),
381 version
382 );
383 Some(index)
384 }
385 Err(e) => {
386 log::warn!("Failed to deserialize workspace index cache: {e}");
387 let _ = fs::remove_file(&path);
388 None
389 }
390 }
391 }
392
393 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
398 for deps in self.reverse_deps.values_mut() {
399 deps.remove(path);
400 }
401 self.reverse_deps.retain(|_, deps| !deps.is_empty());
403 }
404
405 fn clear_reverse_deps_for(&mut self, path: &Path) {
410 self.clear_reverse_deps_as_source(path);
412
413 self.reverse_deps.remove(path);
415 }
416
417 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
419 let source_dir = source_file.parent().unwrap_or(Path::new(""));
421
422 let target = source_dir.join(relative_target);
424
425 Self::normalize_path(&target)
427 }
428
429 fn normalize_path(path: &Path) -> PathBuf {
431 let mut components = Vec::new();
432
433 for component in path.components() {
434 match component {
435 std::path::Component::ParentDir => {
436 if !components.is_empty() {
438 components.pop();
439 }
440 }
441 std::path::Component::CurDir => {
442 }
444 _ => {
445 components.push(component);
446 }
447 }
448 }
449
450 components.iter().collect()
451 }
452}
453
454impl FileIndex {
455 pub fn new() -> Self {
457 Self::default()
458 }
459
460 pub fn with_hash(content_hash: String) -> Self {
462 Self {
463 content_hash,
464 ..Default::default()
465 }
466 }
467
468 pub fn add_heading(&mut self, heading: HeadingIndex) {
472 let index = self.headings.len();
473
474 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
476
477 if let Some(ref custom) = heading.custom_anchor {
479 self.anchor_to_heading.insert(custom.to_lowercase(), index);
480 }
481
482 self.headings.push(heading);
483 }
484
485 pub fn has_anchor(&self, anchor: &str) -> bool {
495 let lower = anchor.to_lowercase();
496 self.anchor_to_heading.contains_key(&lower)
497 || self.html_anchors.contains(&lower)
498 || self.attribute_anchors.contains(&lower)
499 }
500
501 pub fn add_html_anchor(&mut self, anchor: String) {
503 if !anchor.is_empty() {
504 self.html_anchors.insert(anchor.to_lowercase());
505 }
506 }
507
508 pub fn add_attribute_anchor(&mut self, anchor: String) {
510 if !anchor.is_empty() {
511 self.attribute_anchors.insert(anchor.to_lowercase());
512 }
513 }
514
515 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
519 self.anchor_to_heading
520 .get(&anchor.to_lowercase())
521 .and_then(|&idx| self.headings.get(idx))
522 }
523
524 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
526 self.reference_links.push(link);
527 }
528
529 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
534 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
536 return true;
537 }
538
539 if let Some(rules) = self.line_disabled_rules.get(&line) {
541 return rules.contains("*") || rules.contains(rule_name);
542 }
543
544 false
545 }
546
547 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
549 let is_duplicate = self.cross_file_links.iter().any(|existing| {
551 existing.target_path == link.target_path
552 && existing.fragment == link.fragment
553 && existing.line == link.line
554 && existing.column == link.column
555 });
556 if !is_duplicate {
557 self.cross_file_links.push(link);
558 }
559 }
560
561 pub fn add_defined_reference(&mut self, ref_id: String) {
563 self.defined_references.insert(ref_id);
564 }
565
566 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
568 self.defined_references.contains(ref_id)
569 }
570
571 pub fn hash_matches(&self, hash: &str) -> bool {
573 self.content_hash == hash
574 }
575
576 pub fn heading_count(&self) -> usize {
578 self.headings.len()
579 }
580
581 pub fn reference_link_count(&self) -> usize {
583 self.reference_links.len()
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_workspace_index_basic() {
593 let mut index = WorkspaceIndex::new();
594 assert_eq!(index.file_count(), 0);
595 assert_eq!(index.version(), 0);
596
597 let mut file_index = FileIndex::with_hash("abc123".to_string());
598 file_index.add_heading(HeadingIndex {
599 text: "Installation".to_string(),
600 auto_anchor: "installation".to_string(),
601 custom_anchor: None,
602 line: 1,
603 });
604
605 index.insert_file(PathBuf::from("docs/install.md"), file_index);
606 assert_eq!(index.file_count(), 1);
607 assert_eq!(index.version(), 1);
608
609 assert!(index.contains_file(Path::new("docs/install.md")));
610 assert!(!index.contains_file(Path::new("docs/other.md")));
611 }
612
613 #[test]
614 fn test_vulnerable_anchors() {
615 let mut index = WorkspaceIndex::new();
616
617 let mut file1 = FileIndex::new();
619 file1.add_heading(HeadingIndex {
620 text: "Getting Started".to_string(),
621 auto_anchor: "getting-started".to_string(),
622 custom_anchor: None,
623 line: 1,
624 });
625 index.insert_file(PathBuf::from("docs/guide.md"), file1);
626
627 let mut file2 = FileIndex::new();
629 file2.add_heading(HeadingIndex {
630 text: "Installation".to_string(),
631 auto_anchor: "installation".to_string(),
632 custom_anchor: Some("install".to_string()),
633 line: 1,
634 });
635 index.insert_file(PathBuf::from("docs/install.md"), file2);
636
637 let vulnerable = index.get_vulnerable_anchors();
638 assert_eq!(vulnerable.len(), 1);
639 assert!(vulnerable.contains_key("getting-started"));
640 assert!(!vulnerable.contains_key("installation"));
641
642 let anchors = vulnerable.get("getting-started").unwrap();
643 assert_eq!(anchors.len(), 1);
644 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
645 assert_eq!(anchors[0].text, "Getting Started");
646 }
647
648 #[test]
649 fn test_vulnerable_anchors_multiple_files_same_anchor() {
650 let mut index = WorkspaceIndex::new();
653
654 let mut file1 = FileIndex::new();
656 file1.add_heading(HeadingIndex {
657 text: "Installation".to_string(),
658 auto_anchor: "installation".to_string(),
659 custom_anchor: None,
660 line: 1,
661 });
662 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
663
664 let mut file2 = FileIndex::new();
666 file2.add_heading(HeadingIndex {
667 text: "Installation".to_string(),
668 auto_anchor: "installation".to_string(),
669 custom_anchor: None,
670 line: 5,
671 });
672 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
673
674 let mut file3 = FileIndex::new();
676 file3.add_heading(HeadingIndex {
677 text: "Installation".to_string(),
678 auto_anchor: "installation".to_string(),
679 custom_anchor: Some("install".to_string()),
680 line: 10,
681 });
682 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
683
684 let vulnerable = index.get_vulnerable_anchors();
685 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
687
688 let anchors = vulnerable.get("installation").unwrap();
689 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
691
692 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
694 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
695 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
696 }
697
698 #[test]
699 fn test_file_index_hash() {
700 let index = FileIndex::with_hash("hash123".to_string());
701 assert!(index.hash_matches("hash123"));
702 assert!(!index.hash_matches("other"));
703 }
704
705 #[test]
706 fn test_version_increment() {
707 let mut index = WorkspaceIndex::new();
708 assert_eq!(index.version(), 0);
709
710 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
711 assert_eq!(index.version(), 1);
712
713 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
714 assert_eq!(index.version(), 2);
715
716 index.remove_file(Path::new("a.md"));
717 assert_eq!(index.version(), 3);
718
719 index.remove_file(Path::new("nonexistent.md"));
721 assert_eq!(index.version(), 3);
722 }
723
724 #[test]
725 fn test_reverse_deps_basic() {
726 let mut index = WorkspaceIndex::new();
727
728 let mut file_a = FileIndex::new();
730 file_a.add_cross_file_link(CrossFileLinkIndex {
731 target_path: "b.md".to_string(),
732 fragment: "section".to_string(),
733 line: 10,
734 column: 5,
735 });
736 index.update_file(Path::new("docs/a.md"), file_a);
737
738 let dependents = index.get_dependents(Path::new("docs/b.md"));
740 assert_eq!(dependents.len(), 1);
741 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
742
743 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
745 assert!(a_dependents.is_empty());
746 }
747
748 #[test]
749 fn test_reverse_deps_multiple() {
750 let mut index = WorkspaceIndex::new();
751
752 let mut file_a = FileIndex::new();
754 file_a.add_cross_file_link(CrossFileLinkIndex {
755 target_path: "../b.md".to_string(),
756 fragment: "".to_string(),
757 line: 1,
758 column: 1,
759 });
760 index.update_file(Path::new("docs/sub/a.md"), file_a);
761
762 let mut file_c = FileIndex::new();
763 file_c.add_cross_file_link(CrossFileLinkIndex {
764 target_path: "b.md".to_string(),
765 fragment: "".to_string(),
766 line: 1,
767 column: 1,
768 });
769 index.update_file(Path::new("docs/c.md"), file_c);
770
771 let dependents = index.get_dependents(Path::new("docs/b.md"));
773 assert_eq!(dependents.len(), 2);
774 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
775 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
776 }
777
778 #[test]
779 fn test_reverse_deps_update_clears_old() {
780 let mut index = WorkspaceIndex::new();
781
782 let mut file_a = FileIndex::new();
784 file_a.add_cross_file_link(CrossFileLinkIndex {
785 target_path: "b.md".to_string(),
786 fragment: "".to_string(),
787 line: 1,
788 column: 1,
789 });
790 index.update_file(Path::new("docs/a.md"), file_a);
791
792 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
794
795 let mut file_a_updated = FileIndex::new();
797 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
798 target_path: "c.md".to_string(),
799 fragment: "".to_string(),
800 line: 1,
801 column: 1,
802 });
803 index.update_file(Path::new("docs/a.md"), file_a_updated);
804
805 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
807
808 let c_deps = index.get_dependents(Path::new("docs/c.md"));
810 assert_eq!(c_deps.len(), 1);
811 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
812 }
813
814 #[test]
815 fn test_reverse_deps_remove_file() {
816 let mut index = WorkspaceIndex::new();
817
818 let mut file_a = FileIndex::new();
820 file_a.add_cross_file_link(CrossFileLinkIndex {
821 target_path: "b.md".to_string(),
822 fragment: "".to_string(),
823 line: 1,
824 column: 1,
825 });
826 index.update_file(Path::new("docs/a.md"), file_a);
827
828 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
830
831 index.remove_file(Path::new("docs/a.md"));
833
834 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
836 }
837
838 #[test]
839 fn test_normalize_path() {
840 let path = Path::new("docs/sub/../other.md");
842 let normalized = WorkspaceIndex::normalize_path(path);
843 assert_eq!(normalized, PathBuf::from("docs/other.md"));
844
845 let path2 = Path::new("docs/./other.md");
847 let normalized2 = WorkspaceIndex::normalize_path(path2);
848 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
849
850 let path3 = Path::new("a/b/c/../../d.md");
852 let normalized3 = WorkspaceIndex::normalize_path(path3);
853 assert_eq!(normalized3, PathBuf::from("a/d.md"));
854 }
855
856 #[test]
857 fn test_clear_clears_reverse_deps() {
858 let mut index = WorkspaceIndex::new();
859
860 let mut file_a = FileIndex::new();
862 file_a.add_cross_file_link(CrossFileLinkIndex {
863 target_path: "b.md".to_string(),
864 fragment: "".to_string(),
865 line: 1,
866 column: 1,
867 });
868 index.update_file(Path::new("docs/a.md"), file_a);
869
870 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
872
873 index.clear();
875
876 assert_eq!(index.file_count(), 0);
878 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
879 }
880
881 #[test]
882 fn test_is_file_stale() {
883 let mut index = WorkspaceIndex::new();
884
885 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
887
888 let file_index = FileIndex::with_hash("hash123".to_string());
890 index.insert_file(PathBuf::from("docs/test.md"), file_index);
891
892 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
894
895 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
897 }
898
899 #[cfg(feature = "native")]
900 #[test]
901 fn test_cache_roundtrip() {
902 use std::fs;
903
904 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
906 let _ = fs::remove_dir_all(&temp_dir);
907 fs::create_dir_all(&temp_dir).unwrap();
908
909 let mut index = WorkspaceIndex::new();
911
912 let mut file1 = FileIndex::with_hash("abc123".to_string());
913 file1.add_heading(HeadingIndex {
914 text: "Test Heading".to_string(),
915 auto_anchor: "test-heading".to_string(),
916 custom_anchor: Some("test".to_string()),
917 line: 1,
918 });
919 file1.add_cross_file_link(CrossFileLinkIndex {
920 target_path: "./other.md".to_string(),
921 fragment: "section".to_string(),
922 line: 5,
923 column: 3,
924 });
925 index.update_file(Path::new("docs/file1.md"), file1);
926
927 let mut file2 = FileIndex::with_hash("def456".to_string());
928 file2.add_heading(HeadingIndex {
929 text: "Another Heading".to_string(),
930 auto_anchor: "another-heading".to_string(),
931 custom_anchor: None,
932 line: 1,
933 });
934 index.update_file(Path::new("docs/other.md"), file2);
935
936 index.save_to_cache(&temp_dir).expect("Failed to save cache");
938
939 assert!(temp_dir.join("workspace_index.bin").exists());
941
942 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
944
945 assert_eq!(loaded.file_count(), 2);
947 assert!(loaded.contains_file(Path::new("docs/file1.md")));
948 assert!(loaded.contains_file(Path::new("docs/other.md")));
949
950 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
952 assert_eq!(file1_loaded.content_hash, "abc123");
953 assert_eq!(file1_loaded.headings.len(), 1);
954 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
955 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
956 assert_eq!(file1_loaded.cross_file_links.len(), 1);
957 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
958
959 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
961 assert_eq!(dependents.len(), 1);
962 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
963
964 let _ = fs::remove_dir_all(&temp_dir);
966 }
967
968 #[cfg(feature = "native")]
969 #[test]
970 fn test_cache_missing_file() {
971 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
972 let _ = std::fs::remove_dir_all(&temp_dir);
973
974 let result = WorkspaceIndex::load_from_cache(&temp_dir);
976 assert!(result.is_none());
977 }
978
979 #[cfg(feature = "native")]
980 #[test]
981 fn test_cache_corrupted_file() {
982 use std::fs;
983
984 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
985 let _ = fs::remove_dir_all(&temp_dir);
986 fs::create_dir_all(&temp_dir).unwrap();
987
988 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
990
991 let result = WorkspaceIndex::load_from_cache(&temp_dir);
993 assert!(result.is_none());
994
995 assert!(!temp_dir.join("workspace_index.bin").exists());
997
998 let _ = fs::remove_dir_all(&temp_dir);
1000 }
1001
1002 #[cfg(feature = "native")]
1003 #[test]
1004 fn test_cache_invalid_magic() {
1005 use std::fs;
1006
1007 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1008 let _ = fs::remove_dir_all(&temp_dir);
1009 fs::create_dir_all(&temp_dir).unwrap();
1010
1011 let mut data = Vec::new();
1013 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();
1017
1018 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1020 assert!(result.is_none());
1021
1022 assert!(!temp_dir.join("workspace_index.bin").exists());
1024
1025 let _ = fs::remove_dir_all(&temp_dir);
1027 }
1028
1029 #[cfg(feature = "native")]
1030 #[test]
1031 fn test_cache_version_mismatch() {
1032 use std::fs;
1033
1034 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1035 let _ = fs::remove_dir_all(&temp_dir);
1036 fs::create_dir_all(&temp_dir).unwrap();
1037
1038 let mut data = Vec::new();
1040 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();
1044
1045 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1047 assert!(result.is_none());
1048
1049 assert!(!temp_dir.join("workspace_index.bin").exists());
1051
1052 let _ = fs::remove_dir_all(&temp_dir);
1054 }
1055
1056 #[cfg(feature = "native")]
1057 #[test]
1058 fn test_cache_atomic_write() {
1059 use std::fs;
1060
1061 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1063 let _ = fs::remove_dir_all(&temp_dir);
1064 fs::create_dir_all(&temp_dir).unwrap();
1065
1066 let index = WorkspaceIndex::new();
1067 index.save_to_cache(&temp_dir).expect("Failed to save");
1068
1069 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1071 assert_eq!(entries.len(), 1);
1072 assert!(temp_dir.join("workspace_index.bin").exists());
1073
1074 let _ = fs::remove_dir_all(&temp_dir);
1076 }
1077
1078 #[test]
1079 fn test_has_anchor_auto_generated() {
1080 let mut file_index = FileIndex::new();
1081 file_index.add_heading(HeadingIndex {
1082 text: "Installation Guide".to_string(),
1083 auto_anchor: "installation-guide".to_string(),
1084 custom_anchor: None,
1085 line: 1,
1086 });
1087
1088 assert!(file_index.has_anchor("installation-guide"));
1090
1091 assert!(file_index.has_anchor("Installation-Guide"));
1093 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1094
1095 assert!(!file_index.has_anchor("nonexistent"));
1097 }
1098
1099 #[test]
1100 fn test_has_anchor_custom() {
1101 let mut file_index = FileIndex::new();
1102 file_index.add_heading(HeadingIndex {
1103 text: "Installation Guide".to_string(),
1104 auto_anchor: "installation-guide".to_string(),
1105 custom_anchor: Some("install".to_string()),
1106 line: 1,
1107 });
1108
1109 assert!(file_index.has_anchor("installation-guide"));
1111
1112 assert!(file_index.has_anchor("install"));
1114 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1118 }
1119
1120 #[test]
1121 fn test_get_heading_by_anchor() {
1122 let mut file_index = FileIndex::new();
1123 file_index.add_heading(HeadingIndex {
1124 text: "Installation Guide".to_string(),
1125 auto_anchor: "installation-guide".to_string(),
1126 custom_anchor: Some("install".to_string()),
1127 line: 10,
1128 });
1129 file_index.add_heading(HeadingIndex {
1130 text: "Configuration".to_string(),
1131 auto_anchor: "configuration".to_string(),
1132 custom_anchor: None,
1133 line: 20,
1134 });
1135
1136 let heading = file_index.get_heading_by_anchor("installation-guide");
1138 assert!(heading.is_some());
1139 assert_eq!(heading.unwrap().text, "Installation Guide");
1140 assert_eq!(heading.unwrap().line, 10);
1141
1142 let heading = file_index.get_heading_by_anchor("install");
1144 assert!(heading.is_some());
1145 assert_eq!(heading.unwrap().text, "Installation Guide");
1146
1147 let heading = file_index.get_heading_by_anchor("configuration");
1149 assert!(heading.is_some());
1150 assert_eq!(heading.unwrap().text, "Configuration");
1151 assert_eq!(heading.unwrap().line, 20);
1152
1153 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1155 }
1156
1157 #[test]
1158 fn test_anchor_lookup_many_headings() {
1159 let mut file_index = FileIndex::new();
1161
1162 for i in 0..100 {
1164 file_index.add_heading(HeadingIndex {
1165 text: format!("Heading {i}"),
1166 auto_anchor: format!("heading-{i}"),
1167 custom_anchor: Some(format!("h{i}")),
1168 line: i + 1,
1169 });
1170 }
1171
1172 for i in 0..100 {
1174 assert!(file_index.has_anchor(&format!("heading-{i}")));
1175 assert!(file_index.has_anchor(&format!("h{i}")));
1176
1177 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1178 assert!(heading.is_some());
1179 assert_eq!(heading.unwrap().line, i + 1);
1180 }
1181 }
1182}