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
9fn 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 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 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 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 decode(&identifier, true).map_err(|e| e.to_string())
62 }
63 "RID" => {
64 decode(&identifier, true).map_err(|e| e.to_string())
67 }
68 "ORCID" => {
69 let cleaned = identifier.replace("-", "");
70
71 if let Err(e) = validate_mod11_2(&cleaned) {
73 return Err(format!("Invalid checksum for ORCID {}: {}", identifier, e));
74 }
75
76 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
87pub 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
141pub 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
156pub 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
168pub 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
180pub 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 if !check_orcid_number_range(&clean_match) {
198 Some(clean_match)
199 } else {
200 None
201 }
202 })
203}
204
205pub 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
218pub 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
234fn check_orcid_number_range(orcid: &str) -> bool {
237 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 let number = orcid.replace('-', "").replace(" ", "");
245
246 is_in_range(&number, RANGE1_START, RANGE1_END) || is_in_range(&number, RANGE2_START, RANGE2_END)
248}
249
250fn is_in_range(value: &str, start: &str, end: &str) -> bool {
252 value >= start && value <= end
253}
254
255pub 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
269pub 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
283pub fn validate_url(str: &str) -> String {
285 if validate_doi(str).is_some() {
287 return "DOI".to_string();
288 }
289
290 match url::Url::parse(str) {
292 Err(_) => String::new(),
293 Ok(url) => {
294 if has_disallowed_fragments(&url) {
296 return String::new();
297 }
298
299 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 else if url.scheme() == "http" || url.scheme() == "https" {
309 return "URL".to_string();
310 }
311
312 String::new()
313 }
314 }
315}
316
317fn 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
329fn is_rogue_scholar_url(url: &Url) -> bool {
331 url.scheme() == "https" && url.host_str() == Some("api.rogue-scholar.org")
332}
333
334fn is_valid_rogue_scholar_post(path_segments: &[&str]) -> bool {
336 if path_segments.len() >= 2 && path_segments[1] == "posts" {
337 if path_segments.len() == 3 {
339 return validate_uuid(path_segments[2]).is_some();
340 }
341 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
350pub 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
366pub 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
380pub 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
393pub 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
404pub 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
416pub 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
449pub 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
463pub 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
480pub 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
494pub 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
502pub 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
510pub 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
521pub 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 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 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
719pub 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
729pub 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
742pub 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
758pub 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
767pub 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
773pub 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
784pub 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
804pub 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
813pub 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: ®ex::Captures| caps[1].to_uppercase())
819 .to_string()
820}
821
822pub fn kebab_case_to_pascal_case(s: &str) -> String {
824 let camel = kebab_case_to_camel_case(s);
825 title_case(&camel)
826}
827
828pub fn normalize_string(s: &str) -> String {
830 s.nfd()
831 .filter(|c| !('\u{0300}'..='\u{036F}').contains(c))
832 .nfc()
833 .collect()
834}
835
836pub 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
850pub 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
862pub 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
882pub 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
915pub fn find_from_format_by_id(id: &str) -> &'static str {
917 if validate_doi(id).is_some() {
918 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
944pub fn find_from_format_by_ext(ext: &str) -> &'static str {
946 match ext {
947 ".bib" => "bibtex",
948 ".ris" => "ris",
949 _ => "",
950 }
951}
952
953pub 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
992pub 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); }
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}