1const SUPPORTED_HOSTS: &[&str] = &[
6 "archiveofourown.org",
7 "www.archiveofourown.org",
8 "fanfiction.net",
9 "www.fanfiction.net",
10 "royalroad.com",
11 "www.royalroad.com",
12 "fictionpress.com",
13 "www.fictionpress.com",
14 "spacebattles.com",
15 "forums.spacebattles.com",
16 "sufficientvelocity.com",
17 "forums.sufficientvelocity.com",
18 "questionablequesting.com",
19 "forums.questionablequesting.com",
20 "wattpad.com",
21 "www.wattpad.com",
22 "quotev.com",
23 "www.quotev.com",
24 "hp-fanficarchive.com",
25 "www.hp-fanficarchive.com",
26 "fimfiction.net",
27 "www.fimfiction.net",
28 "tthfanfic.org",
29 "www.tthfanfic.org",
30];
31
32pub fn extract_fanfic_url(text: &str) -> Option<String> {
35 for word in text.split_whitespace() {
37 let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '/' && c != ':'
38 && c != '.' && c != '-' && c != '_' && c != '?' && c != '=' && c != '&');
39 let lower = cleaned.to_ascii_lowercase();
40 if lower.starts_with("http://") || lower.starts_with("https://") {
41 if let Ok(parsed) = url::Url::parse(&cleaned) {
42 if let Some(host) = parsed.host_str() {
43 if SUPPORTED_HOSTS.iter().any(|h| host == *h || host.ends_with(h)) {
44 return Some(cleaned.to_string());
45 }
46 }
47 }
48 }
49 }
50 None
51}
52
53pub fn normalize_url(raw: &str) -> String {
55 let raw = raw.trim();
56 if raw.starts_with("http://") || raw.starts_with("https://") {
57 raw.to_string()
58 } else {
59 format!("https://{raw}")
60 }
61}
62
63pub fn format_words(words: i64) -> String {
65 if words >= 1_000_000 {
66 format!("{:.1}M", words as f64 / 1_000_000.0)
67 } else if words >= 1_000 {
68 format!("{:.1}k", words as f64 / 1_000.0)
69 } else {
70 words.to_string()
71 }
72}
73
74pub fn truncate(s: &str, max: usize) -> String {
76 if s.chars().count() <= max {
77 s.to_string()
78 } else {
79 let cut: String = s.chars().take(max).collect();
80 format!("{cut}…")
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn extracts_ao3_url() {
90 let text = "check out https://archiveofourown.org/works/12345/chapters/1 !";
91 assert_eq!(
92 extract_fanfic_url(text).as_deref(),
93 Some("https://archiveofourown.org/works/12345/chapters/1")
94 );
95 }
96
97 #[test]
98 fn extracts_ffn_url() {
99 let text = "Read https://www.fanfiction.net/s/12345678/1/ it's good";
100 assert_eq!(
101 extract_fanfic_url(text).as_deref(),
102 Some("https://www.fanfiction.net/s/12345678/1/")
103 );
104 }
105
106 #[test]
107 fn extracts_royalroad() {
108 let text = "https://www.royalroad.com/fiction/12345/test";
109 assert_eq!(
110 extract_fanfic_url(text).as_deref(),
111 Some("https://www.royalroad.com/fiction/12345/test")
112 );
113 }
114
115 #[test]
116 fn no_url_no_match() {
117 assert_eq!(extract_fanfic_url("just some text no links"), None);
118 assert_eq!(extract_fanfic_url("https://example.com/not-fic"), None);
119 }
120
121 #[test]
122 fn normalizes_bare_domain() {
123 assert_eq!(normalize_url("archiveofourown.org/works/1"), "https://archiveofourown.org/works/1");
124 assert_eq!(normalize_url("https://a.com/x"), "https://a.com/x");
125 }
126
127 #[test]
128 fn formats_words() {
129 assert_eq!(format_words(999), "999");
130 assert_eq!(format_words(1_200), "1.2k");
131 assert_eq!(format_words(1_500_000), "1.5M");
132 }
133
134 #[test]
135 fn truncates() {
136 assert_eq!(truncate("hello", 10), "hello");
137 assert_eq!(truncate("hello world", 5), "hello…");
138 }
139}