Skip to main content

commonmeta/
utils.rs

1use lazy_static::lazy_static;
2use regex::Regex;
3use unicode_normalization::UnicodeNormalization;
4use url::Url;
5
6use crate::crockford::decode;
7use crate::doi_utils::{normalize_doi, validate_doi};
8
9/// Validates the checksum of a string using the ISO 7064 Mod 11-2 algorithm.
10fn validate_mod11_2(input: &str) -> Result<(), String> {
11    if !input.chars().all(|c| c.is_ascii_digit() || c == 'X') {
12        return Err("Invalid characters in input".to_string());
13    }
14
15    // the last character is the checksum
16    let checksum_char = input.chars().last().unwrap();
17    let body = &input[..input.len() - 1];
18
19    let mut m = 0;
20
21    for c in body.chars() {
22        let d = c.to_digit(10).unwrap() as i32;
23
24        m = ((m + d) * 2) % 11;
25    }
26
27    let check_value = (12 - m) % 11;
28    let expected_char = if check_value == 10 {
29        'X'
30    } else {
31        char::from_digit(check_value as u32, 10).unwrap()
32    };
33
34    // compare with expected checksum
35    if checksum_char == expected_char {
36        Ok(())
37    } else {
38        Err("Invalid checksum".to_string())
39    }
40}
41
42pub fn decode_id(id: &str) -> Result<i64, String> {
43    let (identifier, identifier_type) = validate_id(id);
44
45    match identifier_type {
46        "DOI" => {
47            // the format of a DOI is a prefix and a suffix separated by a slash
48            // the prefix starts with 10. and is followed by 4-5 digits
49            // the suffix is a string of characters and is not case-sensitive
50            // suffixes from Rogue Scholar are base32-encoded numbers with checksums
51            let parts: Vec<&str> = identifier.split('/').collect();
52            if parts.len() < 2 {
53                return Err(format!("Invalid DOI format: {}", id));
54            }
55            let suffix = parts[1];
56            decode(suffix, true).map_err(|e| e.to_string())
57        }
58        "ROR" => {
59            // ROR ID is a 9-character string that starts with 0
60            // and is a base32-encoded number with a mod 97-1
61            decode(&identifier, true).map_err(|e| e.to_string())
62        }
63        "RID" => {
64            // RID is a 10-character string with a hyphen after five digits.
65            // It is a base32-encoded numbers with checksum.
66            decode(&identifier, true).map_err(|e| e.to_string())
67        }
68        "ORCID" => {
69            let cleaned = identifier.replace("-", "");
70
71            // Verify checksum using iso7064 mod 11-2
72            if let Err(e) = validate_mod11_2(&cleaned) {
73                return Err(format!("Invalid checksum for ORCID {}: {}", identifier, e));
74            }
75
76            // Parse the identifier without the checksum
77            let number_str = &cleaned[..cleaned.len() - 1];
78            match number_str.parse::<i64>() {
79                Ok(n) => Ok(n),
80                Err(e) => Err(format!("Failed to parse ORCID: {}", e)),
81            }
82        }
83        _ => Err(format!("identifier {} not recognized", id)),
84    }
85}
86
87/// Validates an identifier and returns the identifier and its type.
88/// Type can be: DOI, UUID, PMID, PMCID, OpenAlex, ORCID, ROR, GRID,
89/// RID, Wikidata, ISNI, ISSN, Crossref Funder ID, URL, or "".
90pub fn validate_id(id: &str) -> (String, &'static str) {
91    if let Some(fundref) = validate_crossref_funder_id(id) {
92        return (fundref, "Crossref Funder ID");
93    }
94    if let Some(doi) = validate_doi(id) {
95        return (doi, "DOI");
96    }
97    if let Some(uuid) = validate_uuid(id) {
98        return (uuid, "UUID");
99    }
100    if let Some(pmid) = validate_pmid(id) {
101        return (pmid, "PMID");
102    }
103    if let Some(pmcid) = validate_pmcid(id) {
104        return (pmcid, "PMCID");
105    }
106    if let Some(openalex) = validate_openalex(id) {
107        return (openalex, "OpenAlex");
108    }
109    if let Some(orcid) = validate_orcid(id) {
110        return (orcid, "ORCID");
111    }
112    if let Some(ror) = validate_ror(id) {
113        return (ror, "ROR");
114    }
115    if let Some(grid) = validate_grid(id) {
116        return (grid, "GRID");
117    }
118    if let Some(rid) = validate_rid(id) {
119        return (rid, "RID");
120    }
121    if let Some(wikidata) = validate_wikidata(id) {
122        return (wikidata, "Wikidata");
123    }
124    if let Some(isni) = validate_isni(id) {
125        return (isni, "ISNI");
126    }
127    if let Some(issn) = validate_issn(id) {
128        return (issn, "ISSN");
129    }
130
131    match validate_url(id).as_str() {
132        "DOI" => return (id.to_string(), "DOI"),
133        "JSONFEEDID" => return (id.to_string(), "JSONFEEDID"),
134        "URL" => return (id.to_string(), "URL"),
135        _ => {}
136    }
137
138    (String::new(), "")
139}
140
141/// Validates an identifier and additionally returns its category.
142/// Category: "Work", "Person", "Organization", "Contributor", "All", or "".
143pub fn validate_id_category(id: &str) -> (String, &'static str, &'static str) {
144    let (pid, type_) = validate_id(id);
145    let category = match type_ {
146        "ROR" | "Crossref Funder ID" | "GRID" => "Organization",
147        "ORCID" => "Person",
148        "ISNI" => "Contributor",
149        "DOI" | "PMID" | "PMCID" => "Work",
150        "Wikidata" | "OpenAlex" | "URL" | "UUID" => "All",
151        _ => "",
152    };
153    (pid, type_, category)
154}
155
156/// Validates a Crossref Funder ID
157pub fn validate_crossref_funder_id(fundref: &str) -> Option<String> {
158    lazy_static! {
159        static ref RE: Regex =
160            Regex::new(r"^(?:https?://doi\.org/)?(?:10\.13039/)?((501)?1000[0-9]{5})$").unwrap();
161    }
162
163    RE.captures(fundref)
164        .and_then(|captures| captures.get(1))
165        .map(|m| m.as_str().to_string())
166}
167
168/// Validates a GRID ID
169/// GRID ID is a string prefixed with grid followed by dot number dot string
170pub fn validate_grid(grid: &str) -> Option<String> {
171    lazy_static! {
172      static ref RE: Regex = Regex::new(r"^(?:(?:http|https)://(?:(?:www)?\.)?grid\.ac/)?(?:institutes/)?(grid\.[0-9]+\.[a-f0-9]{1,2})$").unwrap();
173  }
174
175    RE.captures(grid)
176        .and_then(|captures| captures.get(1))
177        .map(|m| m.as_str().to_string())
178}
179
180/// Validates an ISNI
181/// ISNI is a 16-character string in blocks of four
182/// optionally separated by hyphens or spaces and NOT
183/// between 0000-0001-5000-0007 and 0000-0003-5000-0001,
184/// or between 0009-0000-0000-0000 and 0009-0010-0000-0000
185/// (the ranged reserved for ORCID).
186pub fn validate_isni(isni: &str) -> Option<String> {
187    lazy_static! {
188      static ref RE: Regex = Regex::new(r"^(?:(?:http|https)://(?:(?:www)?\.)?isni\.org/)?(?:isni/)?(0000[ -]?00\d{2}[ -]?\d{4}[ -]?\d{3}[0-9X]+)$").unwrap();
189    }
190
191    RE.captures(isni)
192        .and_then(|captures| captures.get(1))
193        .and_then(|m| {
194            let clean_match = m.as_str().replace(" ", "").replace("-", "");
195
196            // Return None if it's in the ORCID range
197            if !check_orcid_number_range(&clean_match) {
198                Some(clean_match)
199            } else {
200                None
201            }
202        })
203}
204
205/// Validates an ISSN
206pub fn validate_issn(issn: &str) -> Option<String> {
207    lazy_static! {
208        static ref RE: Regex =
209            Regex::new(r"^(?:https://portal\.issn\.org/resource/ISSN/)?(\d{4}\-\d{3}(\d|x|X))$")
210                .unwrap();
211    }
212
213    RE.captures(issn)
214        .and_then(|captures| captures.get(1))
215        .map(|m| m.as_str().to_string())
216}
217
218/// Validates an ORCID
219/// ORCID is a 16-character string in blocks of four
220/// separated by hyphens between
221/// 0000-0001-5000-0007 and 0000-0003-5000-0001,
222/// or between 0009-0000-0000-0000 and 0009-0010-0000-0000.
223pub fn validate_orcid(orcid: &str) -> Option<String> {
224    lazy_static! {
225        static ref RE: Regex = Regex::new(r"^(?:(?:http|https)://(?:(?:www|sandbox)?\.)?orcid\.org/)?(000[09][ -]000[123][ -]\d{4}[ -]\d{3}[0-9X]+)$").unwrap();
226    }
227
228    RE.captures(orcid)
229        .and_then(|captures| captures.get(1))
230        .filter(|m| check_orcid_number_range(m.as_str()))
231        .map(|m| m.as_str().to_string())
232}
233
234/// Check if ORCID is in the range 0000-0001-5000-0007 and 0000-0003-5000-0001
235/// or between 0009-0000-0000-0000 and 0009-0010-0000-0000
236fn check_orcid_number_range(orcid: &str) -> bool {
237    // ORCID ranges
238    const RANGE1_START: &str = "0000000150000007";
239    const RANGE1_END: &str = "0000000350000001";
240    const RANGE2_START: &str = "0009000000000000";
241    const RANGE2_END: &str = "0009001000000000";
242
243    // Remove any spaces and hyphens
244    let number = orcid.replace('-', "").replace(" ", "");
245
246    // Check if the ORCID is in either of the valid ranges
247    is_in_range(&number, RANGE1_START, RANGE1_END) || is_in_range(&number, RANGE2_START, RANGE2_END)
248}
249
250/// Helper function to check if a string is within a specific range
251fn is_in_range(value: &str, start: &str, end: &str) -> bool {
252    value >= start && value <= end
253}
254
255/// Validates a RID
256/// RID is the unique identifier used by the InvenioRDM platform
257pub fn validate_rid(rid: &str) -> Option<String> {
258    lazy_static! {
259        static ref RE: Regex = Regex::new(r"^[0-9A-Z]{5}-[0-9A-Z]{3}[0-9]{2}$").unwrap();
260    }
261
262    if RE.is_match(rid) {
263        Some(rid.to_string())
264    } else {
265        None
266    }
267}
268
269/// Validates a ROR ID
270/// The ROR ID starts with 0 followed by a 6-character
271/// alphanumeric string which is base32-encoded and a 2-digit checksum.
272pub fn validate_ror(ror: &str) -> Option<String> {
273    lazy_static! {
274        static ref RE: Regex =
275            Regex::new(r"^(?:(?:http|https)://ror\.org/)?(0[0-9a-z]{6}\d{2})$").unwrap();
276    }
277
278    RE.captures(ror)
279        .and_then(|captures| captures.get(1))
280        .map(|m| m.as_str().to_string())
281}
282
283/// Validates a URL and checks if it is a DOI
284pub fn validate_url(str: &str) -> String {
285    // Check if it's a DOI
286    if validate_doi(str).is_some() {
287        return "DOI".to_string();
288    }
289
290    // Try to parse as URL
291    match url::Url::parse(str) {
292        Err(_) => String::new(),
293        Ok(url) => {
294            // Check for disallowed URL fragments
295            if has_disallowed_fragments(&url) {
296                return String::new();
297            }
298
299            // Handle Rogue Scholar URLs
300            if is_rogue_scholar_url(&url) {
301                let path_segments: Vec<&str> = url.path().split('/').collect();
302
303                if is_valid_rogue_scholar_post(&path_segments) {
304                    return "JSONFEEDID".to_string();
305                }
306            }
307            // Handle standard HTTP(S) URLs
308            else if url.scheme() == "http" || url.scheme() == "https" {
309                return "URL".to_string();
310            }
311
312            String::new()
313        }
314    }
315}
316
317/// Checks if URL has disallowed fragments
318fn has_disallowed_fragments(url: &Url) -> bool {
319    let disallowed_fragments = [";origin=", ";jsessionid="];
320
321    for fragment in &disallowed_fragments {
322        if url.as_str().contains(fragment) {
323            return true;
324        }
325    }
326    false
327}
328
329/// Checks if URL is from Rogue Scholar
330fn is_rogue_scholar_url(url: &Url) -> bool {
331    url.scheme() == "https" && url.host_str() == Some("api.rogue-scholar.org")
332}
333
334/// Validates if path segments represent a valid Rogue Scholar post
335fn is_valid_rogue_scholar_post(path_segments: &[&str]) -> bool {
336    if path_segments.len() >= 2 && path_segments[1] == "posts" {
337        // UUID-based post path
338        if path_segments.len() == 3 {
339            return validate_uuid(path_segments[2]).is_some();
340        }
341        // DOI-based post path
342        else if path_segments.len() == 4 {
343            let doi = format!("{}/{}", path_segments[2], path_segments[3]);
344            return validate_doi(&doi).is_some();
345        }
346    }
347    false
348}
349
350/// Validates a UUID
351pub fn validate_uuid(uuid: &str) -> Option<String> {
352    lazy_static! {
353        static ref RE: Regex = Regex::new(
354            r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$"
355        )
356        .unwrap();
357    }
358
359    if RE.is_match(uuid) {
360        Some(uuid.to_string())
361    } else {
362        None
363    }
364}
365
366/// Validates a Wikidata item ID
367/// Wikidata item ID is a string prefixed with Q followed by a number
368pub fn validate_wikidata(wikidata: &str) -> Option<String> {
369    lazy_static! {
370        static ref RE: Regex =
371            Regex::new(r"^(?:(?:http|https)://(?:(?:www)?\.)?wikidata\.org/wiki/)?(Q\d+)$")
372                .unwrap();
373    }
374
375    RE.captures(wikidata)
376        .and_then(|captures| captures.get(1))
377        .map(|m| m.as_str().to_string())
378}
379
380/// Validates an OpenAlex ID.
381/// First letter indicates resource type (A author, F funder, I institution,
382/// P publisher, S source, W work), followed by 8-10 digits.
383pub fn validate_openalex(openalex: &str) -> Option<String> {
384    lazy_static! {
385        static ref RE: Regex =
386            Regex::new(r"^(?:(?:http|https)://openalex\.org/)?([AFIPSW]\d{8,10})$").unwrap();
387    }
388    RE.captures(openalex)
389        .and_then(|c| c.get(1))
390        .map(|m| m.as_str().to_string())
391}
392
393/// Validates a PubMed ID (PMID).
394pub fn validate_pmid(pmid: &str) -> Option<String> {
395    lazy_static! {
396        static ref RE: Regex =
397            Regex::new(r"^(?:(?:http|https)://pubmed\.ncbi\.nlm\.nih\.gov/)?(\d{4,8})$").unwrap();
398    }
399    RE.captures(pmid)
400        .and_then(|c| c.get(1))
401        .map(|m| m.as_str().to_string())
402}
403
404/// Validates a PubMed Central ID (PMCID).
405pub fn validate_pmcid(pmcid: &str) -> Option<String> {
406    lazy_static! {
407        static ref RE: Regex =
408            Regex::new(r"^(?:(?:http|https)://www\.ncbi\.nlm\.nih\.gov/pmc/articles/)?(\d{4,8})$")
409                .unwrap();
410    }
411    RE.captures(pmcid)
412        .and_then(|c| c.get(1))
413        .map(|m| m.as_str().to_string())
414}
415
416// ── Normalizers ───────────────────────────────────────────────────────────────
417
418/// Normalizes any PID: DOI → canonical URL, UUID, Wikidata, or plain URL.
419pub fn normalize_id(pid: &str) -> String {
420    let doi = normalize_doi(pid);
421    if !doi.is_empty() {
422        return doi;
423    }
424    if let Some(uuid) = validate_uuid(pid) {
425        return uuid;
426    }
427    if let Some(wikidata) = validate_wikidata(pid) {
428        return format!("https://www.wikidata.org/wiki/{}", wikidata);
429    }
430    match Url::parse(pid) {
431        Err(_) => String::new(),
432        Ok(mut u) => {
433            if u.scheme().is_empty() {
434                return String::new();
435            }
436            if u.scheme() == "http" {
437                let _ = u.set_scheme("https");
438            }
439            let s = u.to_string();
440            if s.ends_with('/') {
441                s[..s.len() - 1].to_string()
442            } else {
443                s
444            }
445        }
446    }
447}
448
449/// Normalizes a work identifier (DOI, UUID, URL, Wikidata).
450pub fn normalize_work_id(id: &str) -> String {
451    let (pid, type_, category) = validate_id_category(id);
452    if !["Work", "All"].contains(&category) {
453        return String::new();
454    }
455    match type_ {
456        "DOI" => normalize_doi(&pid),
457        "UUID" | "URL" => pid,
458        "Wikidata" => format!("https://www.wikidata.org/wiki/{}", pid),
459        _ => String::new(),
460    }
461}
462
463/// Normalizes an organization identifier (ROR, Crossref Funder ID, GRID,
464/// Wikidata, ISNI).
465pub fn normalize_organization_id(id: &str) -> String {
466    let (pid, type_, category) = validate_id_category(id);
467    if !["Organization", "Contributor", "All"].contains(&category) {
468        return String::new();
469    }
470    match type_ {
471        "ROR" => format!("https://ror.org/{}", pid),
472        "Crossref Funder ID" => format!("https://doi.org/{}", pid),
473        "GRID" => format!("https://grid.ac/institutes/{}", pid),
474        "Wikidata" => format!("https://www.wikidata.org/wiki/{}", pid),
475        "ISNI" => format!("https://isni.org/isni/{}", pid),
476        _ => String::new(),
477    }
478}
479
480/// Normalizes a person identifier (ORCID, ISNI, Wikidata).
481pub fn normalize_person_id(id: &str) -> String {
482    let (pid, type_, category) = validate_id_category(id);
483    if !["Person", "Contributor", "All"].contains(&category) {
484        return String::new();
485    }
486    match type_ {
487        "ORCID" => format!("https://orcid.org/{}", pid),
488        "ISNI" => format!("https://isni.org/isni/{}", pid),
489        "Wikidata" => format!("https://www.wikidata.org/wiki/{}", pid),
490        _ => String::new(),
491    }
492}
493
494/// Returns a normalized ORCID URL.
495pub fn normalize_orcid(orcid: &str) -> String {
496    match validate_orcid(orcid) {
497        Some(id) => format!("https://orcid.org/{}", id),
498        None => String::new(),
499    }
500}
501
502/// Returns a normalized ROR URL.
503pub fn normalize_ror(ror: &str) -> String {
504    match validate_ror(ror) {
505        Some(id) => format!("https://ror.org/{}", id),
506        None => String::new(),
507    }
508}
509
510/// Normalizes a URL: upgrades http→https when `secure`, lowercases when `lower`.
511pub fn normalize_url(s: &str, secure: bool, lower: bool) -> Option<String> {
512    let mut u = Url::parse(s).ok()?;
513    u.host_str()?;
514    if secure && u.scheme() == "http" {
515        let _ = u.set_scheme("https");
516    }
517    let result = u.to_string();
518    Some(if lower { result.to_lowercase() } else { result })
519}
520
521/// Normalizes a Creative Commons license URL to the canonical `/legalcode` form.
522/// Returns `(normalized_url, true)` on success, `("", false)` otherwise.
523pub fn normalize_cc_url(url_: &str) -> (String, bool) {
524    lazy_static! {
525        static ref CC_MAP: std::collections::HashMap<&'static str, &'static str> = {
526            let mut m = std::collections::HashMap::new();
527            m.insert(
528                "https://creativecommons.org/licenses/by/1.0",
529                "https://creativecommons.org/licenses/by/1.0/legalcode",
530            );
531            m.insert(
532                "https://creativecommons.org/licenses/by/2.0",
533                "https://creativecommons.org/licenses/by/2.0/legalcode",
534            );
535            m.insert(
536                "https://creativecommons.org/licenses/by/2.5",
537                "https://creativecommons.org/licenses/by/2.5/legalcode",
538            );
539            m.insert(
540                "https://creativecommons.org/licenses/by/3.0",
541                "https://creativecommons.org/licenses/by/3.0/legalcode",
542            );
543            m.insert(
544                "https://creativecommons.org/licenses/by/3.0/us",
545                "https://creativecommons.org/licenses/by/3.0/legalcode",
546            );
547            m.insert(
548                "https://creativecommons.org/licenses/by/4.0",
549                "https://creativecommons.org/licenses/by/4.0/legalcode",
550            );
551            m.insert(
552                "https://creativecommons.org/licenses/by-nc/1.0",
553                "https://creativecommons.org/licenses/by-nc/1.0/legalcode",
554            );
555            m.insert(
556                "https://creativecommons.org/licenses/by-nc/2.0",
557                "https://creativecommons.org/licenses/by-nc/2.0/legalcode",
558            );
559            m.insert(
560                "https://creativecommons.org/licenses/by-nc/2.5",
561                "https://creativecommons.org/licenses/by-nc/2.5/legalcode",
562            );
563            m.insert(
564                "https://creativecommons.org/licenses/by-nc/3.0",
565                "https://creativecommons.org/licenses/by-nc/3.0/legalcode",
566            );
567            m.insert(
568                "https://creativecommons.org/licenses/by-nc/4.0",
569                "https://creativecommons.org/licenses/by-nc/4.0/legalcode",
570            );
571            m.insert(
572                "https://creativecommons.org/licenses/by-nd-nc/1.0",
573                "https://creativecommons.org/licenses/by-nd-nc/1.0/legalcode",
574            );
575            m.insert(
576                "https://creativecommons.org/licenses/by-nd-nc/2.0",
577                "https://creativecommons.org/licenses/by-nd-nc/2.0/legalcode",
578            );
579            m.insert(
580                "https://creativecommons.org/licenses/by-nd-nc/2.5",
581                "https://creativecommons.org/licenses/by-nd-nc/2.5/legalcode",
582            );
583            m.insert(
584                "https://creativecommons.org/licenses/by-nd-nc/3.0",
585                "https://creativecommons.org/licenses/by-nd-nc/3.0/legalcode",
586            );
587            m.insert(
588                "https://creativecommons.org/licenses/by-nd-nc/4.0",
589                "https://creativecommons.org/licenses/by-nd-nc/4.0/legalcode",
590            );
591            m.insert(
592                "https://creativecommons.org/licenses/by-nc-sa/1.0",
593                "https://creativecommons.org/licenses/by-nc-sa/1.0/legalcode",
594            );
595            m.insert(
596                "https://creativecommons.org/licenses/by-nc-sa/2.0",
597                "https://creativecommons.org/licenses/by-nc-sa/2.0/legalcode",
598            );
599            m.insert(
600                "https://creativecommons.org/licenses/by-nc-sa/2.5",
601                "https://creativecommons.org/licenses/by-nc-sa/2.5/legalcode",
602            );
603            m.insert(
604                "https://creativecommons.org/licenses/by-nc-sa/3.0",
605                "https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode",
606            );
607            m.insert(
608                "https://creativecommons.org/licenses/by-nc-sa/3.0/us",
609                "https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode",
610            );
611            m.insert(
612                "https://creativecommons.org/licenses/by-nc-sa/4.0",
613                "https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode",
614            );
615            m.insert(
616                "https://creativecommons.org/licenses/by-nd/1.0",
617                "https://creativecommons.org/licenses/by-nd/1.0/legalcode",
618            );
619            m.insert(
620                "https://creativecommons.org/licenses/by-nd/2.0",
621                "https://creativecommons.org/licenses/by-nd/2.0/legalcode",
622            );
623            m.insert(
624                "https://creativecommons.org/licenses/by-nd/2.5",
625                "https://creativecommons.org/licenses/by-nd/2.5/legalcode",
626            );
627            m.insert(
628                "https://creativecommons.org/licenses/by-nd/3.0",
629                "https://creativecommons.org/licenses/by-nd/3.0/legalcode",
630            );
631            m.insert(
632                "https://creativecommons.org/licenses/by-nd/4.0",
633                "https://creativecommons.org/licenses/by-nd/2.0/legalcode",
634            );
635            m.insert(
636                "https://creativecommons.org/licenses/by-sa/1.0",
637                "https://creativecommons.org/licenses/by-sa/1.0/legalcode",
638            );
639            m.insert(
640                "https://creativecommons.org/licenses/by-sa/2.0",
641                "https://creativecommons.org/licenses/by-sa/2.0/legalcode",
642            );
643            m.insert(
644                "https://creativecommons.org/licenses/by-sa/2.5",
645                "https://creativecommons.org/licenses/by-sa/2.5/legalcode",
646            );
647            m.insert(
648                "https://creativecommons.org/licenses/by-sa/3.0",
649                "https://creativecommons.org/licenses/by-sa/3.0/legalcode",
650            );
651            m.insert(
652                "https://creativecommons.org/licenses/by-sa/4.0",
653                "https://creativecommons.org/licenses/by-sa/4.0/legalcode",
654            );
655            m.insert(
656                "https://creativecommons.org/licenses/by-nc-nd/1.0",
657                "https://creativecommons.org/licenses/by-nc-nd/1.0/legalcode",
658            );
659            m.insert(
660                "https://creativecommons.org/licenses/by-nc-nd/2.0",
661                "https://creativecommons.org/licenses/by-nc-nd/2.0/legalcode",
662            );
663            m.insert(
664                "https://creativecommons.org/licenses/by-nc-nd/2.5",
665                "https://creativecommons.org/licenses/by-nc-nd/2.5/legalcode",
666            );
667            m.insert(
668                "https://creativecommons.org/licenses/by-nc-nd/3.0",
669                "https://creativecommons.org/licenses/by-nc-nd/3.0/legalcode",
670            );
671            m.insert(
672                "https://creativecommons.org/licenses/by-nc-nd/4.0",
673                "https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode",
674            );
675            m.insert(
676                "https://creativecommons.org/licenses/publicdomain",
677                "https://creativecommons.org/licenses/publicdomain/",
678            );
679            m.insert(
680                "https://creativecommons.org/publicdomain/zero/1.0",
681                "https://creativecommons.org/publicdomain/zero/1.0/legalcode",
682            );
683            m
684        };
685    }
686
687    if url_.is_empty() {
688        return (String::new(), false);
689    }
690    let normalized = match normalize_url(url_, true, false) {
691        Some(u) => u,
692        None => return (String::new(), false),
693    };
694    let mut u = match Url::parse(&normalized) {
695        Ok(u) => u,
696        Err(_) => return (String::new(), false),
697    };
698    // strip trailing slash when no query string
699    if u.query().is_none() {
700        let path = u.path().to_string();
701        if path.len() > 1 && path.ends_with('/') {
702            u.set_path(&path[..path.len() - 1]);
703        }
704    }
705    let key = u.to_string();
706    if let Some(v) = CC_MAP.get(key.as_str()) {
707        return (v.to_string(), true);
708    }
709    // Some providers (e.g. DataCite) return URLs that already end with /legalcode.
710    // Strip that suffix and try again.
711    let stripped = key.strip_suffix("/legalcode").unwrap_or(key.as_str());
712    match CC_MAP.get(stripped) {
713        Some(v) => (v.to_string(), true),
714        None => (String::new(), false),
715    }
716}
717
718
719// ── ISSN / slug / community helpers ──────────────────────────────────────────
720
721/// Returns an ISSN expressed as a portal.issn.org URL.
722pub fn issn_as_url(issn: &str) -> String {
723    if issn.is_empty() {
724        return String::new();
725    }
726    format!("https://portal.issn.org/resource/ISSN/{}", issn)
727}
728
729/// Returns a community slug as a Rogue Scholar API URL.
730pub fn community_slug_as_url(slug: &str, host: &str) -> String {
731    if slug.is_empty() {
732        return String::new();
733    }
734    let h = if host.is_empty() {
735        "rogue-scholar.org"
736    } else {
737        host
738    };
739    format!("https://{}/api/communities/{}", h, slug)
740}
741
742// ── String utilities ──────────────────────────────────────────────────────────
743
744/// Strips HTML, allowing only safe inline elements.
745pub fn sanitize(html: &str) -> String {
746    let allowed: std::collections::HashSet<&str> =
747        ["b", "br", "code", "em", "i", "sub", "sup", "strong"]
748            .iter()
749            .copied()
750            .collect();
751    let clean = ammonia::Builder::new()
752        .tags(allowed)
753        .clean(html)
754        .to_string();
755    clean.trim_matches('\n').to_string()
756}
757
758/// Uppercases only the first character of a string.
759pub fn title_case(s: &str) -> String {
760    let mut c = s.chars();
761    match c.next() {
762        None => String::new(),
763        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
764    }
765}
766
767/// Removes duplicate elements from a Vec while preserving order.
768pub fn dedupe_slice<T: Eq + std::hash::Hash + Clone>(v: Vec<T>) -> Vec<T> {
769    let mut seen = std::collections::HashSet::new();
770    v.into_iter().filter(|x| seen.insert(x.clone())).collect()
771}
772
773/// Converts a PascalCase / camelCase string to "Title Words" form.
774pub fn camel_case_to_words(s: &str) -> String {
775    lazy_static! {
776        static ref RE1: Regex = Regex::new("(.)([A-Z][a-z]+)").unwrap();
777        static ref RE2: Regex = Regex::new("([a-z0-9])([A-Z])").unwrap();
778    }
779    let words = RE1.replace_all(s, "${1} ${2}");
780    let words = RE2.replace_all(&words, "${1} ${2}");
781    title_case(&words.to_lowercase())
782}
783
784/// Converts "words in a string" to camelCase.
785pub fn words_to_camel_case(s: &str) -> String {
786    lazy_static! {
787        static ref RE1: Regex = Regex::new("(.)([A-Z][a-z]+)").unwrap();
788        static ref RE2: Regex = Regex::new("([a-z0-9])([A-Z])").unwrap();
789    }
790    let words = RE1.replace_all(s, "${1} ${2}");
791    let words = RE2.replace_all(&words, "${1} ${2}");
792    let pascal: String = words.split_whitespace().map(title_case).collect::<String>();
793    let pascal = pascal.replace([' ', '-'], "");
794    if pascal.is_empty() {
795        return pascal;
796    }
797    let mut chars = pascal.chars();
798    match chars.next() {
799        None => String::new(),
800        Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
801    }
802}
803
804/// Lowercases the first character of a PascalCase string (PascalCase → camelCase).
805pub fn camel_case_string(s: &str) -> String {
806    let mut chars = s.chars();
807    match chars.next() {
808        None => String::new(),
809        Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
810    }
811}
812
813/// Converts kebab-case to camelCase.
814pub fn kebab_case_to_camel_case(s: &str) -> String {
815    lazy_static! {
816        static ref RE: Regex = Regex::new("-([a-z])").unwrap();
817    }
818    RE.replace_all(s, |caps: &regex::Captures| caps[1].to_uppercase())
819        .to_string()
820}
821
822/// Converts kebab-case to PascalCase.
823pub fn kebab_case_to_pascal_case(s: &str) -> String {
824    let camel = kebab_case_to_camel_case(s);
825    title_case(&camel)
826}
827
828/// Unicode-normalizes a string: NFD decomposition, strip combining diacritics, NFC recompose.
829pub fn normalize_string(s: &str) -> String {
830    s.nfd()
831        .filter(|c| !('\u{0300}'..='\u{036F}').contains(c))
832        .nfc()
833        .collect()
834}
835
836/// Converts a string to a URL slug: normalize, keep only lowercase letters/digits.
837pub fn string_to_slug(s: &str) -> String {
838    normalize_string(s)
839        .chars()
840        .filter_map(|c| {
841            if c.is_alphanumeric() {
842                Some(c.to_lowercase().next().unwrap_or(c))
843            } else {
844                None
845            }
846        })
847        .collect()
848}
849
850/// Inserts `sep` every `n` characters.
851pub fn split_string(s: &str, n: usize, sep: &str) -> String {
852    if n == 0 {
853        return s.to_string();
854    }
855    s.as_bytes()
856        .chunks(n)
857        .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
858        .collect::<Vec<_>>()
859        .join(sep)
860}
861
862/// Returns a language code in the requested format.
863/// `format`: "iso639-3" for 3-letter code, "name" for English name, otherwise ISO 639-1 alpha-2.
864/// Accepts alpha-2, alpha-3, or English name as input.
865pub fn get_language(lang: &str, format: &str) -> String {
866    if lang.is_empty() {
867        return String::new();
868    }
869    let found = isolang::Language::from_639_1(lang)
870        .or_else(|| isolang::Language::from_639_3(lang))
871        .or_else(|| isolang::Language::from_name(lang));
872    match found {
873        None => String::new(),
874        Some(l) => match format {
875            "iso639-3" => l.to_639_3().to_string(),
876            "name" => l.to_name().to_string(),
877            _ => l.to_639_1().unwrap_or_default().to_string(),
878        },
879    }
880}
881
882// ── Format detection ──────────────────────────────────────────────────────────
883
884/// Auto-detects the commonmeta reader format from various hints.
885pub fn find_from_format(
886    pid: Option<&str>,
887    str_: Option<&str>,
888    ext: Option<&str>,
889    filename: Option<&str>,
890) -> &'static str {
891    if let Some(p) = pid
892        && !p.is_empty()
893    {
894        return find_from_format_by_id(p);
895    }
896    if let (Some(s), Some(e)) = (str_, ext)
897        && !s.is_empty()
898        && !e.is_empty()
899    {
900        return find_from_format_by_ext(e);
901    }
902    if let Some(s) = str_
903        && !s.is_empty()
904    {
905        return find_from_format_by_string(s);
906    }
907    if let Some(f) = filename
908        && !f.is_empty()
909    {
910        return find_from_format_by_filename(f);
911    }
912    "datacite"
913}
914
915/// Detects format by PID (DOI, URL patterns).
916pub fn find_from_format_by_id(id: &str) -> &'static str {
917    if validate_doi(id).is_some() {
918        // TODO: query DOI RA to distinguish crossref / datacite
919        return "crossref";
920    }
921    if id.ends_with("codemeta.json") {
922        return "codemeta";
923    }
924    if id.ends_with("CITATION.cff") || id.contains("github.com") {
925        return "cff";
926    }
927    if id.contains("jsonfeed") {
928        return "jsonfeed";
929    }
930    lazy_static! {
931        static ref RE_ROGUE: Regex =
932            Regex::new(r"^https:/(/)?api\.rogue-scholar\.org/posts/(.+)$").unwrap();
933        static ref RE_INVENIO: Regex = Regex::new(r"^https:/(/)(.+)/(api/)?records/(.+)$").unwrap();
934    }
935    if RE_ROGUE.is_match(id) {
936        return "jsonfeed";
937    }
938    if RE_INVENIO.is_match(id) {
939        return "inveniordm";
940    }
941    "schemaorg"
942}
943
944/// Detects format by file extension.
945pub fn find_from_format_by_ext(ext: &str) -> &'static str {
946    match ext {
947        ".bib" => "bibtex",
948        ".ris" => "ris",
949        _ => "",
950    }
951}
952
953/// Detects format by parsing the JSON string and examining key fields.
954pub fn find_from_format_by_string(s: &str) -> &'static str {
955    let data: serde_json::Value = match serde_json::from_str(s) {
956        Ok(v) => v,
957        Err(_) => return "",
958    };
959    if let Some(v) = data.get("schema_version").and_then(|v| v.as_str())
960        && v.starts_with("https://commonmeta.org")
961    {
962        return "commonmeta";
963    }
964    if let Some(v) = data.get("@context").and_then(|v| v.as_str()) {
965        if v == "http://schema.org" {
966            return "schemaorg";
967        }
968        if v.contains("codemeta") {
969            return "codemeta";
970        }
971    }
972    if data.get("guid").is_some() {
973        return "jsonfeed";
974    }
975    if let Some(v) = data.get("schemaVersion").and_then(|v| v.as_str())
976        && v.starts_with("http://datacite.org/schema/kernel")
977    {
978        return "datacite";
979    }
980    if data.get("source").and_then(|v| v.as_str()) == Some("Crossref") {
981        return "crossref";
982    }
983    if data.get("conceptdoi").is_some() {
984        return "inveniordm";
985    }
986    if data.get("credit_metadata").is_some() {
987        return "kbase";
988    }
989    ""
990}
991
992/// Detects format by filename.
993pub fn find_from_format_by_filename(filename: &str) -> &'static str {
994    if filename == "CITATION.cff" {
995        return "cff";
996    }
997    ""
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn test_validate_openalex() {
1006        assert_eq!(validate_openalex("W1234567890"), Some("W1234567890".into()));
1007        assert_eq!(
1008            validate_openalex("https://openalex.org/W1234567890"),
1009            Some("W1234567890".into())
1010        );
1011        assert_eq!(validate_openalex("X123"), None);
1012    }
1013
1014    #[test]
1015    fn test_validate_pmid() {
1016        assert_eq!(validate_pmid("12345678"), Some("12345678".into()));
1017        assert_eq!(
1018            validate_pmid("https://pubmed.ncbi.nlm.nih.gov/12345678"),
1019            Some("12345678".into())
1020        );
1021        assert_eq!(validate_pmid("123"), None); // too short
1022    }
1023
1024    #[test]
1025    fn test_normalize_orcid() {
1026        assert_eq!(
1027            normalize_orcid("0000-0001-5000-0007"),
1028            "https://orcid.org/0000-0001-5000-0007"
1029        );
1030        assert_eq!(normalize_orcid("not-an-orcid"), "");
1031    }
1032
1033    #[test]
1034    fn test_normalize_ror() {
1035        assert_eq!(
1036            normalize_ror("https://ror.org/0521rfr06"),
1037            "https://ror.org/0521rfr06"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_issn_as_url() {
1043        assert_eq!(
1044            issn_as_url("1234-5678"),
1045            "https://portal.issn.org/resource/ISSN/1234-5678"
1046        );
1047        assert_eq!(issn_as_url(""), "");
1048    }
1049
1050    #[test]
1051    fn test_community_slug_as_url() {
1052        assert_eq!(
1053            community_slug_as_url("my-blog", ""),
1054            "https://rogue-scholar.org/api/communities/my-blog"
1055        );
1056        assert_eq!(
1057            community_slug_as_url("blog", "example.org"),
1058            "https://example.org/api/communities/blog"
1059        );
1060    }
1061
1062    #[test]
1063    fn test_camel_case_string() {
1064        assert_eq!(camel_case_string("IsVersionOf"), "isVersionOf");
1065        assert_eq!(camel_case_string("HasPreprint"), "hasPreprint");
1066        assert_eq!(camel_case_string(""), "");
1067    }
1068
1069    #[test]
1070    fn test_kebab_case_to_camel_case() {
1071        assert_eq!(kebab_case_to_camel_case("foo-bar-baz"), "fooBarBaz");
1072        assert_eq!(kebab_case_to_pascal_case("foo-bar"), "FooBar");
1073    }
1074
1075    #[test]
1076    fn test_normalize_string() {
1077        assert_eq!(normalize_string("Héllo Wörld"), "Hello World");
1078        assert_eq!(normalize_string("café"), "cafe");
1079    }
1080
1081    #[test]
1082    fn test_string_to_slug() {
1083        assert_eq!(string_to_slug("Héllo Wörld!"), "helloworld");
1084        assert_eq!(string_to_slug("café au lait"), "cafeaulait");
1085    }
1086
1087    #[test]
1088    fn test_split_string() {
1089        assert_eq!(split_string("1234567890", 4, "-"), "1234-5678-90");
1090        assert_eq!(split_string("abcdef", 2, "_"), "ab_cd_ef");
1091    }
1092
1093    #[test]
1094    fn test_get_language() {
1095        assert_eq!(get_language("en", "iso639-3"), "eng");
1096        assert_eq!(get_language("deu", ""), "de");
1097        assert_eq!(get_language("French", "iso639-3"), "fra");
1098        assert_eq!(get_language("xyz", ""), "");
1099    }
1100
1101    #[test]
1102    fn test_normalize_cc_url() {
1103        let (url, ok) = normalize_cc_url("https://creativecommons.org/licenses/by/4.0/");
1104        assert!(ok);
1105        assert_eq!(url, "https://creativecommons.org/licenses/by/4.0/legalcode");
1106
1107        let (_, ok) = normalize_cc_url("https://example.com/license");
1108        assert!(!ok);
1109    }
1110
1111    #[test]
1112    fn test_dedupe_slice() {
1113        assert_eq!(dedupe_slice(vec![1, 2, 2, 3, 1]), vec![1, 2, 3]);
1114        assert_eq!(dedupe_slice(vec!["a", "b", "a"]), vec!["a", "b"]);
1115    }
1116
1117    #[test]
1118    fn test_find_from_format_by_string() {
1119        let json = r#"{"schema_version":"https://commonmeta.org/commonmeta_v0.14","id":"x"}"#;
1120        assert_eq!(find_from_format_by_string(json), "commonmeta");
1121
1122        let json = r#"{"guid":"abc-123","url":"https://example.com"}"#;
1123        assert_eq!(find_from_format_by_string(json), "jsonfeed");
1124    }
1125
1126    #[test]
1127    fn test_validate_id_category() {
1128        let (id, type_, cat) = validate_id_category("https://ror.org/0521rfr06");
1129        assert_eq!(type_, "ROR");
1130        assert_eq!(cat, "Organization");
1131        assert_eq!(id, "0521rfr06");
1132
1133        let (_, type_, cat) = validate_id_category("https://orcid.org/0000-0001-5000-0007");
1134        assert_eq!(type_, "ORCID");
1135        assert_eq!(cat, "Person");
1136    }
1137
1138    #[test]
1139    fn test_validate_orcid_parity_cases() {
1140        let cases = [
1141            (
1142                "http://orcid.org/0000-0002-2590-225X",
1143                Some("0000-0002-2590-225X"),
1144            ),
1145            (
1146                "https://orcid.org/0000-0002-1825-0097",
1147                Some("0000-0002-1825-0097"),
1148            ),
1149            ("0000-0002-1825-0097", Some("0000-0002-1825-0097")),
1150            (
1151                "https://sandbox.orcid.org/0000-0002-1825-0097",
1152                Some("0000-0002-1825-0097"),
1153            ),
1154            ("0000-0002-1825-009", None),
1155        ];
1156
1157        for (input, expected) in cases {
1158            assert_eq!(validate_orcid(input).as_deref(), expected, "input: {input}");
1159        }
1160    }
1161
1162    #[test]
1163    fn test_validate_isni_parity_cases() {
1164        let cases = [
1165            (
1166                "https://isni.org/isni/0000000121122291",
1167                Some("0000000121122291"),
1168            ),
1169            (
1170                "https://isni.org/isni/0000 0001 2112 2291",
1171                Some("0000000121122291"),
1172            ),
1173            ("0000-0001-2112-2291", Some("0000000121122291")),
1174            ("https://isni.org/isni/000000021825009", None),
1175        ];
1176
1177        for (input, expected) in cases {
1178            assert_eq!(validate_isni(input).as_deref(), expected, "input: {input}");
1179        }
1180    }
1181
1182    #[test]
1183    fn test_validate_wikidata_parity_cases() {
1184        let cases = [
1185            ("https://www.wikidata.org/wiki/Q7186", Some("Q7186")),
1186            ("https://www.wikidata.org/wiki/Q251061", Some("Q251061")),
1187            ("Q251061", Some("Q251061")),
1188            ("https://www.wikidata.org/wiki/Property:P610", None),
1189        ];
1190
1191        for (input, expected) in cases {
1192            assert_eq!(
1193                validate_wikidata(input).as_deref(),
1194                expected,
1195                "input: {input}"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn test_validate_ror_parity_cases() {
1202        let cases = [
1203            ("https://ror.org/0342dzm54", Some("0342dzm54")),
1204            ("0342dzm54", Some("0342dzm54")),
1205            ("invalid", None),
1206        ];
1207
1208        for (input, expected) in cases {
1209            assert_eq!(validate_ror(input).as_deref(), expected, "input: {input}");
1210        }
1211    }
1212
1213    #[test]
1214    fn test_validate_crossref_funder_id_parity_cases() {
1215        let cases = [
1216            (
1217                "https://doi.org/10.13039/501100000155",
1218                Some("501100000155"),
1219            ),
1220            ("10.13039/501100000155", Some("501100000155")),
1221            ("100010540", Some("100010540")),
1222            ("not-a-funder-id", None),
1223        ];
1224
1225        for (input, expected) in cases {
1226            assert_eq!(
1227                validate_crossref_funder_id(input).as_deref(),
1228                expected,
1229                "input: {input}"
1230            );
1231        }
1232    }
1233
1234    #[test]
1235    fn test_validate_url_and_id_parity_cases() {
1236        assert_eq!(
1237            validate_url("https://elifesciences.org/articles/91729"),
1238            "URL"
1239        );
1240        assert_eq!(validate_url("https://doi.org/10.7554/eLife.91729.3"), "DOI");
1241        assert_eq!(validate_url("10.7554/eLife.91729.3"), "DOI");
1242        assert_eq!(validate_url("https://doi.org/10.1101"), "URL");
1243        assert_eq!(validate_url("10.1101"), "");
1244
1245        let (_, id_type) = validate_id("https://isni.org/isni/0000000121122291");
1246        assert_eq!(id_type, "ISNI");
1247
1248        let (_, id_type) = validate_id("https://orcid.org/0000-0002-1825-0097");
1249        assert_eq!(id_type, "ORCID");
1250
1251        let (_, id_type) =
1252            validate_id("https://datadryad.org/stash/dataset/doi:10.5061/dryad.8515");
1253        assert_eq!(id_type, "URL");
1254    }
1255
1256    #[test]
1257    fn test_find_from_format_helpers_parity_cases() {
1258        assert_eq!(find_from_format_by_ext(".bib"), "bibtex");
1259        assert_eq!(find_from_format_by_ext(".ris"), "ris");
1260        assert_eq!(find_from_format_by_ext(".json"), "");
1261
1262        assert_eq!(find_from_format_by_filename("CITATION.cff"), "cff");
1263        assert_eq!(find_from_format_by_filename("citation.cff"), "");
1264    }
1265}