1use papaya::HashMap as ConcurrentHashMap;
7use serde::{Deserialize, Serialize};
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::time::Instant;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct OutboundLink {
14 pub to: String,
16 pub text: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub anchor: Option<String>,
21 pub internal: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct InboundLink {
28 pub from: String,
30 pub text: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub anchor: Option<String>,
35}
36
37pub fn sort_inbound_links(links: &mut [InboundLink]) {
57 links.sort_by(|a, b| (&a.from, &a.text, &a.anchor).cmp(&(&b.from, &b.text, &b.anchor)));
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct PageLinks {
63 pub inbound: Vec<InboundLink>,
65 pub outbound: Vec<OutboundLink>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub relationships: Vec<crate::relationships::ResolvedRelationship>,
71}
72
73pub fn split_url_anchor(url: &str) -> (String, Option<String>) {
82 if let Some(hash_pos) = url.find('#') {
83 let path = url[..hash_pos].to_string();
84 let anchor = Some(url[hash_pos..].to_string());
85 (path, anchor)
86 } else {
87 (url.to_string(), None)
88 }
89}
90
91pub fn is_internal_link(url: &str) -> bool {
99 !crate::url_path::is_external_url(url)
100}
101
102pub fn normalize_url_path(url: &str) -> String {
108 let url = url.split('?').next().unwrap_or(url);
110
111 let (path, _anchor) = split_url_anchor(url);
113
114 let path = path.trim_end_matches('/');
117 if path.is_empty() {
118 return "/".to_string();
119 }
120
121 let last_component = path.rsplit('/').next().unwrap_or(path);
123 if last_component.contains('.') && !last_component.starts_with('.') {
124 path.to_string()
126 } else {
127 format!("{}/", path)
129 }
130}
131
132pub fn resolve_relative_url(base_url: &str, relative_url: &str, is_index_file: bool) -> String {
160 resolve_relative_url_parts(base_url, relative_url, is_index_file).0
161}
162
163pub fn resolve_relative_url_checked(
173 base_url: &str,
174 relative_url: &str,
175 is_index_file: bool,
176) -> Option<String> {
177 let (url, escaped) = resolve_relative_url_parts(base_url, relative_url, is_index_file);
178 (!escaped).then_some(url)
179}
180
181fn resolve_relative_url_parts(
186 base_url: &str,
187 relative_url: &str,
188 is_index_file: bool,
189) -> (String, bool) {
190 if relative_url.starts_with('/') {
192 let trimmed = relative_url.trim_end_matches('/');
193 return if trimmed.is_empty() {
194 ("/".to_string(), false)
195 } else {
196 (format!("{}/", trimmed), false)
197 };
198 }
199
200 if relative_url.starts_with('#') {
202 return (relative_url.to_string(), false);
203 }
204
205 let base_segments: Vec<&str> = base_url
206 .trim_matches('/')
207 .split('/')
208 .filter(|s| !s.is_empty())
209 .collect();
210
211 let mut segments: Vec<&str> = if is_index_file || base_segments.is_empty() {
215 base_segments
216 } else {
217 base_segments[..base_segments.len() - 1].to_vec()
218 };
219
220 let mut escaped = false;
221 for part in relative_url.split('/') {
222 match part {
223 "" | "." => {} ".." => {
225 if segments.pop().is_none() {
229 escaped = true;
230 }
231 }
232 segment => {
233 segments.push(segment);
234 }
235 }
236 }
237
238 let url = if segments.is_empty() {
239 "/".to_string()
240 } else {
241 format!("/{}/", segments.join("/"))
242 };
243
244 (url, escaped)
245}
246
247pub fn resolve_outbound_links(
261 base_url: &str,
262 links: Vec<OutboundLink>,
263 is_index_file: bool,
264) -> Vec<OutboundLink> {
265 links
266 .into_iter()
267 .filter(|link| !link.to.is_empty())
268 .map(|mut link| {
269 if link.internal && !link.to.starts_with('/') && !link.to.starts_with('#') {
270 link.to = resolve_relative_url(base_url, &link.to, is_index_file);
271 }
272 link
273 })
274 .collect()
275}
276
277#[derive(Clone)]
279struct LinkCacheEntry {
280 links: Vec<OutboundLink>,
282 inserted_at: Instant,
284 size_bytes: usize,
286}
287
288pub struct LinkCache {
293 cache: ConcurrentHashMap<String, LinkCacheEntry>,
295 current_size: AtomicUsize,
297 max_size: usize,
299}
300
301impl LinkCache {
302 pub fn new(max_size_bytes: usize) -> Self {
306 Self {
307 cache: ConcurrentHashMap::new(),
308 current_size: AtomicUsize::new(0),
309 max_size: max_size_bytes,
310 }
311 }
312
313 pub fn get(&self, url_path: &str) -> Option<Vec<OutboundLink>> {
315 if self.max_size == 0 {
316 return None;
317 }
318
319 let guard = self.cache.pin();
320 guard.get(url_path).map(|entry| {
321 tracing::debug!("link cache hit: {}", url_path);
322 entry.links.clone()
323 })
324 }
325
326 pub fn insert(&self, url_path: String, links: Vec<OutboundLink>) {
330 if self.max_size == 0 {
331 return;
332 }
333
334 let size_bytes = url_path.len()
336 + links
337 .iter()
338 .map(|l| {
339 l.to.len() + l.text.len() + l.anchor.as_ref().map(|a| a.len()).unwrap_or(0) + 32
340 })
341 .sum::<usize>()
342 + std::mem::size_of::<LinkCacheEntry>();
343
344 let entry = LinkCacheEntry {
345 links,
346 inserted_at: Instant::now(),
347 size_bytes,
348 };
349
350 let replaced_size = self
354 .cache
355 .pin()
356 .insert(url_path.clone(), entry)
357 .map_or(0, |old| old.size_bytes);
358 self.current_size
359 .fetch_sub(replaced_size, Ordering::Relaxed);
360 let new_size = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
361
362 tracing::debug!("link cached: {} ({} bytes)", url_path, size_bytes);
363
364 if new_size > self.max_size {
366 self.evict_oldest(new_size - self.max_size);
367 }
368 }
369
370 fn evict_oldest(&self, target_bytes: usize) {
372 let guard = self.cache.pin();
373 let mut entries: Vec<(String, Instant, usize)> = guard
374 .iter()
375 .map(|(k, v)| (k.clone(), v.inserted_at, v.size_bytes))
376 .collect();
377
378 entries.sort_by_key(|(_, inserted_at, _)| *inserted_at);
380
381 let mut freed = 0usize;
382 let mut evict_count = 0usize;
383
384 for (url, _, size) in entries {
385 if freed >= target_bytes {
386 break;
387 }
388 if guard.remove(&url).is_some() {
389 freed += size;
390 evict_count += 1;
391 self.current_size.fetch_sub(size, Ordering::Relaxed);
392 }
393 }
394
395 if evict_count > 0 {
396 tracing::debug!(
397 "link cache evicted {} entries ({} bytes freed)",
398 evict_count,
399 freed
400 );
401 }
402 }
403
404 pub fn invalidate_all(&self) {
408 let guard = self.cache.pin();
409 let keys: Vec<String> = guard.iter().map(|(k, _)| k.clone()).collect();
410 for key in keys {
411 guard.remove(&key);
412 }
413 self.current_size.store(0, Ordering::Relaxed);
414 tracing::debug!("link cache invalidated");
415 }
416
417 #[cfg(test)]
419 pub fn current_size(&self) -> usize {
420 self.current_size.load(Ordering::Relaxed)
421 }
422
423 #[cfg(test)]
425 pub fn len(&self) -> usize {
426 self.cache.pin().len()
427 }
428
429 #[cfg(test)]
431 pub fn is_empty(&self) -> bool {
432 self.cache.pin().is_empty()
433 }
434}
435
436type InboundBucket =
444 std::sync::Arc<std::sync::Mutex<std::collections::BTreeMap<String, InboundLink>>>;
445
446#[derive(Default)]
466pub struct InboundIndex {
467 inbound: ConcurrentHashMap<String, InboundBucket>,
469 contributions: ConcurrentHashMap<String, Vec<String>>,
471 ready: std::sync::atomic::AtomicBool,
475 ready_notify: tokio::sync::Notify,
478}
479
480impl InboundIndex {
481 pub fn new() -> Self {
482 Self::default()
483 }
484
485 pub fn is_ready(&self) -> bool {
487 self.ready.load(Ordering::Acquire)
488 }
489
490 pub fn mark_ready(&self) {
492 self.ready.store(true, Ordering::Release);
493 self.ready_notify.notify_waiters();
494 }
495
496 pub async fn wait_until_ready(&self) {
503 loop {
504 let notified = self.ready_notify.notified();
505 tokio::pin!(notified);
506 notified.as_mut().enable();
507
508 if self.is_ready() {
509 return;
510 }
511
512 notified.await;
513 }
514 }
515
516 pub fn set_page_links(&self, source_url: &str, links: &[OutboundLink]) {
522 self.withdraw(source_url);
523
524 let targets: Vec<String> = links
525 .iter()
526 .filter(|link| link.internal && !link.to.is_empty())
527 .map(|link| {
528 let bucket = {
529 let guard = self.inbound.pin();
530 guard
531 .get_or_insert_with(link.to.clone(), InboundBucket::default)
532 .clone()
533 };
534 bucket.lock().expect("inbound bucket poisoned").insert(
535 source_url.to_string(),
536 InboundLink {
537 from: source_url.to_string(),
538 text: link.text.clone(),
539 anchor: link.anchor.clone(),
540 },
541 );
542 link.to.clone()
543 })
544 .collect();
545
546 if targets.is_empty() {
547 self.contributions.pin().remove(source_url);
548 } else {
549 self.contributions
550 .pin()
551 .insert(source_url.to_string(), targets);
552 }
553 }
554
555 pub fn remove_page(&self, source_url: &str) {
557 self.withdraw(source_url);
558 self.contributions.pin().remove(source_url);
559 }
560
561 fn withdraw(&self, source_url: &str) {
563 let previous = self.contributions.pin().get(source_url).cloned();
564 let Some(previous) = previous else {
565 return;
566 };
567 let guard = self.inbound.pin();
568 for target in previous {
569 if let Some(bucket) = guard.get(&target) {
570 let mut entries = bucket.lock().expect("inbound bucket poisoned");
571 entries.remove(source_url);
572 }
576 }
577 }
578
579 pub fn get(&self, target_url: &str) -> Vec<InboundLink> {
581 let bucket = self.inbound.pin().get(target_url).cloned();
582 match bucket {
583 Some(bucket) => bucket
584 .lock()
585 .expect("inbound bucket poisoned")
586 .values()
587 .cloned()
588 .collect(),
589 None => Vec::new(),
590 }
591 }
592
593 pub fn source_count(&self, target_url: &str) -> usize {
597 self.inbound
598 .pin()
599 .get(target_url)
600 .map(|bucket| bucket.lock().expect("inbound bucket poisoned").len())
601 .unwrap_or(0)
602 }
603
604 pub fn clear(&self) {
606 self.inbound.pin().clear();
607 self.contributions.pin().clear();
608 self.ready.store(false, Ordering::Release);
609 }
610
611 pub fn target_count(&self) -> usize {
613 self.inbound
614 .pin()
615 .iter()
616 .filter(|(_, bucket)| !bucket.lock().expect("inbound bucket poisoned").is_empty())
617 .count()
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 fn link_to(to: &str, text: &str) -> OutboundLink {
628 OutboundLink {
629 to: to.to_string(),
630 text: text.to_string(),
631 anchor: None,
632 internal: true,
633 }
634 }
635
636 #[test]
637 fn inbound_index_inverts_internal_links() {
638 let index = InboundIndex::new();
639 index.set_page_links("/a/", &[link_to("/b/", "see B"), link_to("/c/", "see C")]);
640
641 let b = index.get("/b/");
642 assert_eq!(b.len(), 1);
643 assert_eq!(b[0].from, "/a/");
644 assert_eq!(b[0].text, "see B");
645 assert_eq!(index.get("/c/").len(), 1);
646 assert!(index.get("/nowhere/").is_empty());
647 }
648
649 #[test]
650 fn inbound_index_source_count_counts_distinct_pages() {
651 let index = InboundIndex::new();
652 index.set_page_links("/a/", &[link_to("/b/", "one")]);
653 index.set_page_links("/c/", &[link_to("/b/", "two")]);
654 assert_eq!(index.source_count("/b/"), 2);
655 assert_eq!(index.source_count("/b/"), index.get("/b/").len());
656 assert_eq!(index.source_count("/nowhere/"), 0);
657 index.set_page_links("/a/", &[]);
658 assert_eq!(index.source_count("/b/"), 1);
659 }
660
661 #[test]
662 fn inbound_index_ignores_external_links() {
663 let index = InboundIndex::new();
664 let external = OutboundLink {
665 to: "https://example.com/".to_string(),
666 text: "out".to_string(),
667 anchor: None,
668 internal: false,
669 };
670 index.set_page_links("/a/", &[external]);
671
672 assert!(index.get("https://example.com/").is_empty());
673 assert_eq!(index.target_count(), 0);
674 }
675
676 #[test]
680 fn inbound_index_withdraws_links_removed_by_an_edit() {
681 let index = InboundIndex::new();
682 index.set_page_links("/a/", &[link_to("/b/", "see B"), link_to("/c/", "see C")]);
683 assert_eq!(index.get("/b/").len(), 1);
684
685 index.set_page_links("/a/", &[link_to("/c/", "see C"), link_to("/d/", "see D")]);
687
688 assert!(
689 index.get("/b/").is_empty(),
690 "a link removed by an edit must stop producing a backlink"
691 );
692 assert_eq!(index.get("/c/").len(), 1);
693 assert_eq!(index.get("/d/").len(), 1);
694 }
695
696 #[test]
697 fn inbound_index_removing_a_page_drops_only_its_own_backlinks() {
698 let index = InboundIndex::new();
699 index.set_page_links("/a/", &[link_to("/target/", "from A")]);
700 index.set_page_links("/b/", &[link_to("/target/", "from B")]);
701 assert_eq!(index.get("/target/").len(), 2);
702
703 index.remove_page("/a/");
704
705 let remaining = index.get("/target/");
706 assert_eq!(remaining.len(), 1);
707 assert_eq!(remaining[0].from, "/b/");
708 }
709
710 #[test]
713 fn inbound_index_returns_backlinks_in_from_order() {
714 let index = InboundIndex::new();
715 index.set_page_links("/zeta/", &[link_to("/t/", "z")]);
716 index.set_page_links("/alpha/", &[link_to("/t/", "a")]);
717 index.set_page_links("/mid/", &[link_to("/t/", "m")]);
718
719 let froms: Vec<String> = index.get("/t/").into_iter().map(|l| l.from).collect();
720 assert_eq!(froms, vec!["/alpha/", "/mid/", "/zeta/"]);
721
722 let mut expected = index.get("/t/");
723 sort_inbound_links(&mut expected);
724 assert_eq!(index.get("/t/"), expected, "must already be sorted");
725 }
726
727 #[test]
728 fn inbound_index_clear_resets_readiness() {
729 let index = InboundIndex::new();
730 index.set_page_links("/a/", &[link_to("/b/", "see B")]);
731 index.mark_ready();
732 assert!(index.is_ready());
733
734 index.clear();
735
736 assert!(!index.is_ready());
737 assert!(index.get("/b/").is_empty());
738 }
739
740 #[tokio::test]
741 async fn inbound_index_wait_until_ready_returns_after_late_build() {
742 let index = std::sync::Arc::new(InboundIndex::new());
743
744 let waiting = std::sync::Arc::clone(&index);
745 let waiter = tokio::spawn(async move { waiting.wait_until_ready().await });
746 tokio::task::yield_now().await;
747
748 index.mark_ready();
749
750 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
751 .await
752 .expect("waiter must be woken by mark_ready")
753 .expect("waiter task panicked");
754 }
755
756 #[tokio::test]
759 async fn inbound_index_wait_until_ready_returns_immediately_when_already_ready() {
760 let index = InboundIndex::new();
761 index.mark_ready();
762
763 tokio::time::timeout(std::time::Duration::from_secs(5), index.wait_until_ready())
764 .await
765 .expect("a waiter started after readiness must not block");
766 }
767
768 #[test]
769 fn test_split_url_anchor_with_anchor() {
770 let (path, anchor) = split_url_anchor("/docs/guide/#section");
771 assert_eq!(path, "/docs/guide/");
772 assert_eq!(anchor, Some("#section".to_string()));
773 }
774
775 #[test]
776 fn test_split_url_anchor_without_anchor() {
777 let (path, anchor) = split_url_anchor("/docs/guide/");
778 assert_eq!(path, "/docs/guide/");
779 assert_eq!(anchor, None);
780 }
781
782 #[test]
783 fn test_split_url_anchor_only_anchor() {
784 let (path, anchor) = split_url_anchor("#section");
785 assert_eq!(path, "");
786 assert_eq!(anchor, Some("#section".to_string()));
787 }
788
789 #[test]
790 fn test_is_internal_link_external_https() {
791 assert!(!is_internal_link("https://example.com"));
792 assert!(!is_internal_link("http://example.com"));
793 }
794
795 #[test]
796 fn test_is_internal_link_external_protocols() {
797 assert!(!is_internal_link("mailto:test@example.com"));
798 assert!(!is_internal_link("tel:+1234567890"));
799 assert!(!is_internal_link("javascript:void(0)"));
800 assert!(!is_internal_link("data:text/html,<h1>Hi</h1>"));
801 assert!(!is_internal_link("//cdn.example.com/script.js"));
802 }
803
804 #[test]
805 fn test_is_internal_link_internal() {
806 assert!(is_internal_link("/docs/guide/"));
807 assert!(is_internal_link("../other-page/"));
808 assert!(is_internal_link("./sibling/"));
809 assert!(is_internal_link("relative-path/"));
810 assert!(is_internal_link("#anchor"));
811 }
812
813 #[test]
814 fn test_is_internal_link_schemes_missing_from_old_allowlist() {
815 assert!(!is_internal_link("ftp://ftp.example.com/pub/file.zip"));
819 assert!(!is_internal_link("ftps://ftp.example.com/pub/file.zip"));
820 assert!(!is_internal_link("magnet:?xt=urn:btih:c12fe1c06bba"));
821 assert!(!is_internal_link("sms:+15555550123"));
822 assert!(!is_internal_link("callto:+15555550123"));
823 assert!(!is_internal_link("blob:http://localhost:5220/550e8400"));
824 }
825
826 #[test]
827 fn test_is_internal_link_windows_drive_and_colon_in_path() {
828 assert!(is_internal_link("C:/win/path"));
831 assert!(is_internal_link("docs/a:b.md"));
832 }
833
834 #[test]
835 fn test_normalize_url_path_trailing_slash() {
836 assert_eq!(normalize_url_path("/docs/guide"), "/docs/guide/");
837 assert_eq!(normalize_url_path("/docs/guide/"), "/docs/guide/");
838 }
839
840 #[test]
841 fn test_normalize_url_path_file() {
842 assert_eq!(normalize_url_path("/images/photo.jpg"), "/images/photo.jpg");
843 assert_eq!(normalize_url_path("/docs/file.pdf"), "/docs/file.pdf");
844 }
845
846 #[test]
847 fn test_normalize_url_path_with_query() {
848 assert_eq!(normalize_url_path("/docs/guide/?foo=bar"), "/docs/guide/");
849 }
850
851 #[test]
852 fn test_normalize_url_path_root() {
853 assert_eq!(normalize_url_path("/"), "/");
854 assert_eq!(normalize_url_path(""), "/");
855 }
856
857 #[test]
858 fn test_link_cache_insert_and_get() {
859 let cache = LinkCache::new(1024 * 1024);
860 let links = vec![OutboundLink {
861 to: "/other/".to_string(),
862 text: "Other Page".to_string(),
863 anchor: None,
864 internal: true,
865 }];
866
867 cache.insert("/docs/".to_string(), links.clone());
868
869 let retrieved = cache.get("/docs/");
870 assert!(retrieved.is_some());
871 assert_eq!(retrieved.unwrap(), links);
872 }
873
874 #[test]
875 fn test_link_cache_miss() {
876 let cache = LinkCache::new(1024 * 1024);
877 assert!(cache.get("/nonexistent/").is_none());
878 }
879
880 #[test]
881 fn test_link_cache_size_stable_on_overwrite() {
882 let cache = LinkCache::new(1024 * 1024);
885 let links = vec![OutboundLink {
886 to: "/other/".to_string(),
887 text: "Other Page".to_string(),
888 anchor: None,
889 internal: true,
890 }];
891
892 cache.insert("/docs/".to_string(), links.clone());
893 let size_after_first = cache.current_size();
894 assert_eq!(cache.len(), 1);
895
896 for _ in 0..10 {
897 cache.insert("/docs/".to_string(), links.clone());
898 }
899
900 assert_eq!(cache.len(), 1);
901 assert_eq!(cache.current_size(), size_after_first);
902 }
903
904 #[test]
905 fn test_link_cache_size_tracks_replacement_delta() {
906 let cache = LinkCache::new(1024 * 1024);
908 let one = vec![OutboundLink {
909 to: "/a/".to_string(),
910 text: "A".to_string(),
911 anchor: None,
912 internal: true,
913 }];
914 let two = vec![
915 OutboundLink {
916 to: "/a/".to_string(),
917 text: "A".to_string(),
918 anchor: None,
919 internal: true,
920 },
921 OutboundLink {
922 to: "/b/".to_string(),
923 text: "B".to_string(),
924 anchor: None,
925 internal: true,
926 },
927 ];
928
929 cache.insert("/docs/".to_string(), one.clone());
930 let small = cache.current_size();
931 cache.insert("/docs/".to_string(), two.clone());
932 let large = cache.current_size();
933
934 assert_eq!(cache.len(), 1);
935 let expected_delta = "/b/".len() + "B".len() + 32;
938 assert_eq!(large - small, expected_delta);
939 }
940
941 #[test]
942 fn test_link_cache_invalidate_all_clears_and_resets_size() {
943 let cache = LinkCache::new(1024 * 1024);
944 let links = vec![OutboundLink {
945 to: "/other/".to_string(),
946 text: "Other".to_string(),
947 anchor: None,
948 internal: true,
949 }];
950 cache.insert("/a/".to_string(), links.clone());
951 cache.insert("/b/".to_string(), links);
952 assert_eq!(cache.len(), 2);
953 assert!(cache.current_size() > 0);
954
955 cache.invalidate_all();
956
957 assert!(cache.is_empty());
958 assert_eq!(cache.current_size(), 0);
959 }
960
961 #[test]
962 fn test_link_cache_disabled() {
963 let cache = LinkCache::new(0);
964 let links = vec![OutboundLink {
965 to: "/other/".to_string(),
966 text: "Other".to_string(),
967 anchor: None,
968 internal: true,
969 }];
970
971 cache.insert("/docs/".to_string(), links);
972 assert!(cache.get("/docs/").is_none());
973 }
974
975 #[test]
976 fn test_outbound_link_serialize() {
977 let link = OutboundLink {
978 to: "/docs/guide/".to_string(),
979 text: "Guide".to_string(),
980 anchor: Some("#intro".to_string()),
981 internal: true,
982 };
983
984 let json = serde_json::to_string(&link).unwrap();
985 assert!(json.contains("\"to\":\"/docs/guide/\""));
986 assert!(json.contains("\"text\":\"Guide\""));
987 assert!(json.contains("\"anchor\":\"#intro\""));
988 assert!(json.contains("\"internal\":true"));
989 }
990
991 #[test]
992 fn test_outbound_link_serialize_no_anchor() {
993 let link = OutboundLink {
994 to: "/docs/".to_string(),
995 text: "Docs".to_string(),
996 anchor: None,
997 internal: true,
998 };
999
1000 let json = serde_json::to_string(&link).unwrap();
1001 assert!(!json.contains("anchor"));
1003 }
1004
1005 #[test]
1006 fn test_page_links_default() {
1007 let links = PageLinks::default();
1008 assert!(links.inbound.is_empty());
1009 assert!(links.outbound.is_empty());
1010 }
1011
1012 fn inbound(from: &str, text: &str, anchor: Option<&str>) -> InboundLink {
1013 InboundLink {
1014 from: from.to_string(),
1015 text: text.to_string(),
1016 anchor: anchor.map(str::to_string),
1017 }
1018 }
1019
1020 #[test]
1021 fn test_sort_inbound_links_orders_by_source() {
1022 let mut links = vec![
1023 inbound("/zeta/", "Zeta", None),
1024 inbound("/docs/alpha/", "Alpha", None),
1025 inbound("/docs/beta/", "Beta", None),
1026 ];
1027 sort_inbound_links(&mut links);
1028
1029 let sources: Vec<&str> = links.iter().map(|l| l.from.as_str()).collect();
1030 assert_eq!(sources, ["/docs/alpha/", "/docs/beta/", "/zeta/"]);
1031 }
1032
1033 #[test]
1037 fn test_sort_inbound_links_is_order_independent() {
1038 let mut forward = vec![
1039 inbound("/a/", "First", None),
1040 inbound("/b/", "Second", None),
1041 inbound("/c/", "Third", None),
1042 ];
1043 let mut reversed: Vec<InboundLink> = forward.iter().rev().cloned().collect();
1044
1045 sort_inbound_links(&mut forward);
1046 sort_inbound_links(&mut reversed);
1047
1048 assert_eq!(forward, reversed);
1049 }
1050
1051 #[test]
1055 fn test_sort_inbound_links_breaks_ties_on_text_then_anchor() {
1056 let mut links = vec![
1057 inbound("/src/", "Beta", None),
1058 inbound("/src/", "Alpha", Some("#z")),
1059 inbound("/src/", "Alpha", Some("#a")),
1060 inbound("/src/", "Alpha", None),
1061 ];
1062 sort_inbound_links(&mut links);
1063
1064 let keys: Vec<(&str, Option<&str>)> = links
1065 .iter()
1066 .map(|l| (l.text.as_str(), l.anchor.as_deref()))
1067 .collect();
1068 assert_eq!(
1070 keys,
1071 [
1072 ("Alpha", None),
1073 ("Alpha", Some("#a")),
1074 ("Alpha", Some("#z")),
1075 ("Beta", None),
1076 ]
1077 );
1078 }
1079
1080 #[test]
1081 fn test_sort_inbound_links_handles_empty() {
1082 let mut links: Vec<InboundLink> = Vec::new();
1083 sort_inbound_links(&mut links);
1084 assert!(links.is_empty());
1085 }
1086
1087 #[test]
1088 fn test_resolve_relative_url_index_page_sibling() {
1089 assert_eq!(resolve_relative_url("/modes/", "gui/", true), "/modes/gui/");
1091 assert_eq!(
1092 resolve_relative_url("/reference/", "cli/", true),
1093 "/reference/cli/"
1094 );
1095 assert_eq!(
1096 resolve_relative_url("/getting-started/", "quickstart/", true),
1097 "/getting-started/quickstart/"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_resolve_relative_url_nonindex_sibling() {
1103 assert_eq!(
1106 resolve_relative_url("/docs/guide/", "intro/", false),
1107 "/docs/intro/"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_resolve_relative_url_nonindex_parent_traversal() {
1113 assert_eq!(
1116 resolve_relative_url("/docs/sub/guide/", "../other/", false),
1117 "/docs/other/"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_resolve_relative_url_index_parent_traversal() {
1123 assert_eq!(
1126 resolve_relative_url("/modes/", "../other/", true),
1127 "/other/"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_resolve_relative_url_absolute_unchanged() {
1133 assert_eq!(resolve_relative_url("/modes/", "/other/", true), "/other/");
1135 assert_eq!(
1136 resolve_relative_url("/docs/guide/", "/other/", false),
1137 "/other/"
1138 );
1139 assert_eq!(resolve_relative_url("/modes/", "/", true), "/");
1140 }
1141
1142 #[test]
1143 fn test_resolve_relative_url_anchor_only() {
1144 assert_eq!(
1145 resolve_relative_url("/modes/", "#section", true),
1146 "#section"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_resolve_relative_url_to_root() {
1152 assert_eq!(resolve_relative_url("/source/", "../", false), "/");
1154 assert_eq!(resolve_relative_url("/docs/guide/", "../../", false), "/");
1155 assert_eq!(resolve_relative_url("/modes/", "../", true), "/");
1156 }
1157
1158 #[test]
1159 fn test_resolve_outbound_links_drops_anchor_only_link() {
1160 let links = vec![OutboundLink {
1165 to: String::new(),
1166 text: "Top".to_string(),
1167 anchor: Some("#top".to_string()),
1168 internal: true,
1169 }];
1170
1171 let resolved = resolve_outbound_links("/docs/guide/", links, false);
1172
1173 assert!(
1174 resolved.is_empty(),
1175 "anchor-only link must not become an outbound link: {resolved:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_resolve_outbound_links_keeps_real_links_alongside_anchor_only() {
1181 let links = vec![
1182 OutboundLink {
1183 to: String::new(),
1184 text: "Top".to_string(),
1185 anchor: Some("#top".to_string()),
1186 internal: true,
1187 },
1188 OutboundLink {
1189 to: "intro/".to_string(),
1190 text: "Intro".to_string(),
1191 anchor: None,
1192 internal: true,
1193 },
1194 ];
1195
1196 let resolved = resolve_outbound_links("/docs/guide/", links, false);
1197
1198 assert_eq!(resolved.len(), 1);
1199 assert_eq!(resolved[0].to, "/docs/intro/");
1200 }
1201
1202 #[test]
1203 fn test_resolve_relative_url_checked_accepts_in_root_links() {
1204 let cases = [
1207 ("/modes/", "gui/", true),
1208 ("/docs/guide/", "intro/", false),
1209 ("/docs/sub/guide/", "../other/", false),
1210 ("/docs/sub/guide/", "../../other/", false),
1211 ("/modes/", "../", true),
1212 ("/docs/guide/", "/other/", false),
1213 ("/modes/", "#section", true),
1214 ];
1215
1216 for (base, relative, is_index) in cases {
1217 assert_eq!(
1218 resolve_relative_url_checked(base, relative, is_index),
1219 Some(resolve_relative_url(base, relative, is_index)),
1220 "{relative} from {base} must resolve without an escape"
1221 );
1222 }
1223 }
1224
1225 #[test]
1230 fn test_resolve_relative_url_checked_rejects_above_root_traversal() {
1231 let cases = [
1232 ("/docs/guide/", "../../../escape/", false),
1233 ("/docs/", "../../escape/", true),
1234 ("/", "../escape/", true),
1235 ("/note/", "../../", false),
1236 ("/source/", "../../", true),
1239 ];
1240
1241 for (base, relative, is_index) in cases {
1242 assert_eq!(
1243 resolve_relative_url_checked(base, relative, is_index),
1244 None,
1245 "{relative} from {base} escapes the site root and must be reported"
1246 );
1247 assert!(resolve_relative_url(base, relative, is_index).starts_with('/'));
1250 }
1251 }
1252
1253 #[test]
1254 fn test_resolve_relative_url_nonindex_nested_parent_traversal() {
1255 assert_eq!(
1257 resolve_relative_url("/docs/guide/", "../../other/", false),
1258 "/other/"
1259 );
1260 }
1261}