1use super::check::CheckKind;
3use super::{analyze_paths, link_check, Analysis, Check, CheckCategory, CheckOptions, CheckSeverity, OutputFormat, Render, Standard};
4use crate::io::api::citeas::ToCitations;
5use crate::io::api::{self, Configuration};
6use crate::io::database::schema::{IdentifierRow, Table};
7use crate::io::database::{CandidateAction, CandidatePersistence, Database, Operations, Provenance, ResearchActivityCandidate};
8use crate::io::document::SourceDocument;
9use crate::io::{standard_project_folder, write_file, ApiResult};
10use crate::prelude::{io, temp_dir, IsTerminal, Path, PathBuf};
11pub use crate::schema::discovery::{RemoteEntity, RemoteOrganizationRole};
12use crate::schema::pid::{self, Identifier, Patent, PersistentIdentifierParse, ARK, ARXIV, DOI, ISBN, ORCID, PID, RAID, ROR};
13use crate::schema::{ControlledVocabulary, Keyword};
14use crate::util::{merge_unique, values_as_table, Label, StringConversion};
15use crate::{check, check_err};
16use crate::{Location, Repository};
17use alloc::collections::BTreeSet;
18use async_trait::async_trait;
19use bon::Builder;
20use color_eyre::eyre::Report as EyreReport;
21use core::{fmt, iter::once, str::FromStr};
22use futures::future::join_all;
23use itertools::Itertools;
24use jiff::Timestamp;
25use owo_colors::OwoColorize;
26use serde::{Deserialize, Serialize};
27use strum::{IntoEnumIterator, VariantNames};
28use tracing::warn;
29
30pub mod osti;
31
32trait DeserializeMetadata: for<'de> Deserialize<'de> {
33 fn deserialize_metadata(value: Option<&str>) -> Option<Self>
34 where
35 Self: Sized,
36 {
37 value.and_then(|value| serde_json::from_str(value).ok())
38 }
39}
40#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
42#[serde(untagged)]
43pub enum Candidate {
44 Website {
46 description: String,
48 url: String,
50 },
51 Contact {
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 identifier: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 email: Option<String>,
59 },
60}
61#[derive(Clone, Debug, Serialize)]
63#[serde(tag = "record", rename_all = "lowercase")]
64pub enum Record {
65 Check {
67 category: CheckCategory,
69 locator: Option<String>,
71 message: String,
73 severity: CheckSeverity,
75 success: bool,
77 uri: Option<String>,
79 },
80 Discovery {
82 identifier: String,
84 identifier_type: PID,
86 metadata: Option<String>,
88 resolution_status: ResolutionStatus,
90 source: String,
92 source_format: String,
94 },
95}
96#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, strum::EnumIter, VariantNames)]
98#[serde(rename_all = "lowercase")]
99#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
100pub enum CitationFormat {
101 #[default]
103 Ieee,
104 Apa,
106 Chicago,
108 Harvard,
110 Mla,
112 Vancouver,
114}
115#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
117#[serde(rename_all = "lowercase")]
118pub enum RemoteProvider {
119 Osti,
121}
122#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, strum::Display)]
124#[serde(rename_all = "kebab-case")]
125#[strum(serialize_all = "kebab-case")]
126pub enum ResolutionStatus {
127 #[default]
129 NotRequested,
130 Failed,
132 Resolved,
134 Unsupported,
136}
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum ResolutionOutcome<T> {
140 NotRequested,
142 Unsupported,
144 Resolved(T),
146 Failed(String),
148}
149#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, strum::Display)]
151#[serde(rename_all = "lowercase")]
152#[strum(serialize_all = "lowercase")]
153pub enum LifecycleState {
154 Found,
156 Resolved,
158 Unsupported,
160 Failed,
162 Created,
164 Enriched,
166 Unchanged,
168 Conflict,
170}
171#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173pub struct ProviderCapabilities {
174 pub entities: Vec<RemoteEntity>,
176 pub organization_filter: bool,
178 pub pagination: bool,
180}
181#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
183#[builder(start_fn = init)]
184pub struct RemoteSearchRequest {
185 pub provider: RemoteProvider,
187 pub entity: RemoteEntity,
189 #[builder(default)]
191 pub queries: Vec<String>,
192 pub organization: Option<String>,
194 pub organization_ror: Option<String>,
196 #[builder(default)]
198 pub organization_role: RemoteOrganizationRole,
199 #[builder(default = 20)]
201 pub limit: usize,
202 #[builder(default)]
204 pub offset: usize,
205 #[builder(default)]
207 pub all: bool,
208}
209#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
211#[builder(start_fn = init, on(String, into))]
212pub struct RemoteMatch {
213 pub entity: RemoteEntity,
215 pub identifier: String,
217 pub title: String,
219 pub pid: Option<String>,
221 pub url: Option<String>,
223 pub metadata: serde_json::Value,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub description: Option<String>,
228 #[builder(default = Vec::new())]
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub websites: Vec<Candidate>,
232 #[builder(default = Vec::new())]
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub keywords: Vec<Keyword>,
236 #[builder(default = Vec::new())]
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub sponsors: Vec<String>,
240 #[builder(default = Vec::new())]
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub partners: Vec<String>,
244 #[builder(default = Vec::new())]
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub related: Vec<String>,
248 #[builder(default = Vec::new())]
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub technology: Vec<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub resolution: Option<RemoteResolution>,
255}
256#[derive(Clone, Debug, Deserialize, Serialize)]
258pub struct RemoteSearchResponse {
259 pub provider: RemoteProvider,
261 pub total: usize,
263 pub offset: usize,
265 pub has_more: bool,
267 pub matches: Vec<RemoteMatch>,
269 #[serde(skip)]
270 resolution_checks: Vec<Check>,
271}
272#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
274#[builder(start_fn = init, on(String, into))]
275pub struct ArtifactCandidate {
276 pub identifiers: Vec<Identifier>,
278 pub canonical_url: Option<String>,
280 pub title: Option<String>,
282 #[serde(default)]
284 pub description: Option<String>,
285 pub authors: Vec<String>,
287 pub provider_ids: Vec<String>,
289 pub provenance: Vec<serde_json::Value>,
291 #[serde(default)]
293 pub websites: Vec<Candidate>,
294 #[serde(default)]
296 pub keywords: Vec<Keyword>,
297 #[serde(default)]
299 pub sponsors: Vec<String>,
300 #[serde(default)]
302 pub partners: Vec<String>,
303 #[serde(default)]
305 pub related: Vec<String>,
306 #[serde(default)]
308 pub technology: Vec<String>,
309 #[serde(default)]
311 pub contact: Option<Candidate>,
312}
313#[derive(Builder, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
315#[builder(start_fn = init, on(String, into))]
316pub struct RemoteResolution {
317 pub provider: String,
319 pub status: ResolutionStatus,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub metadata: Option<serde_json::Value>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub error: Option<String>,
327}
328#[derive(Clone, Copy, Debug)]
330pub struct Options<'a> {
331 pub citation_format: CitationFormat,
333 pub database_path: &'a Option<PathBuf>,
335 pub filter: &'a Option<String>,
337 pub format: Option<OutputFormat>,
339 pub ignore: &'a Option<String>,
341 pub input: &'a [String],
343 pub max_depth: Option<usize>,
345 pub merge_request: bool,
347 pub no_local_database: bool,
349 pub offline: bool,
351 pub watching: bool,
353 pub output: &'a Option<PathBuf>,
355 pub resolve: bool,
357 pub standard: &'a Option<Standard>,
359 pub text: &'a [String],
361 pub quiet: bool,
363 pub terse: bool,
365 pub verbosity: Option<u8>,
367 pub remote: Option<&'a RemoteSearchRequest>,
369}
370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
372pub struct OutputFailure {
373 silent: bool,
374}
375#[derive(Clone, Debug, Default, Serialize)]
377struct PersistenceCounts {
378 created: usize,
379 enriched: usize,
380 unchanged: usize,
381 conflicts: usize,
382}
383#[derive(Clone, Debug, Default, Serialize)]
385#[serde(transparent)]
386pub struct Records(Vec<Record>, #[serde(skip)] Vec<SourceDocument>);
387#[derive(Builder, Clone, Debug, Serialize)]
389#[builder(builder_type(vis = "pub(crate)"), start_fn(name = init, vis = "pub(crate)"))]
390pub struct Report {
391 checks: Vec<Record>,
392 discoveries: Records,
393 #[builder(default)]
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 remote: Vec<RemoteSearchResponse>,
396 #[builder(default)]
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 candidates: Vec<CandidatePersistence>,
399 summary: Summary,
400 #[builder(default)]
401 #[serde(skip)]
402 citation_format: CitationFormat,
403}
404#[derive(Builder, Clone, Debug, Serialize)]
405#[builder(builder_type(vis = "pub(self)"), start_fn(name = init, vis = "pub(self)"))]
406pub(crate) struct Summary {
407 discoveries: usize,
408 failures: usize,
409 inputs: usize,
410 #[builder(default)]
411 #[serde(default, skip_serializing_if = "is_zero")]
412 matches: usize,
413 #[builder(default)]
414 #[serde(default, skip_serializing_if = "PersistenceCounts::is_empty")]
415 candidates: PersistenceCounts,
416}
417impl ArtifactCandidate {
418 pub fn identity_keys(&self) -> Vec<String> {
420 self.identifiers
421 .iter()
422 .filter(|identifier| identifier.kind.is_project_identifier())
423 .map(Identifier::identity_key)
424 .chain(self.provider_ids.iter().map(|value| Identifier::normalize(value)))
425 .filter(|value| !value.is_empty())
426 .collect::<BTreeSet<_>>()
427 .into_iter()
428 .collect()
429 }
430 pub(crate) async fn enrich_repository(self, options: Option<api::gitlab::Options>) -> Self {
432 let domain = options.clone().unwrap_or_else(api::gitlab::Options::from_env).domain().to_string();
433 let repositories = candidate_repositories(self.canonical_url.as_deref(), &self.websites, &domain);
434 match Repository::technology(&repositories, options).await {
435 | Some(technology) => Self { technology, ..self },
436 | None => self,
437 }
438 }
439 fn identifier_values(&self, kinds: &[PID]) -> Option<serde_json::Value> {
440 let values = self
441 .identifiers
442 .iter()
443 .filter(|identifier| kinds.contains(&identifier.kind))
444 .map(|identifier| serde_json::Value::String(identifier.value.clone()))
445 .collect::<Vec<_>>();
446 (!values.is_empty()).then_some(serde_json::Value::Array(values))
447 }
448 pub fn to_partial_rad_json(&self) -> serde_json::Value {
450 let canonical_websites = self.canonical_url.as_ref().map(|url| Candidate::Website {
451 description: "Provider-confirmed project URL".to_string(),
452 url: url.clone(),
453 });
454 let websites = merge_websites(canonical_websites, self.websites.iter().cloned())
455 .into_iter()
456 .filter_map(|website| serde_json::to_value(website).ok())
457 .collect::<Vec<_>>();
458 let keywords = ControlledVocabulary::normalize("keywords", self.keywords.iter().cloned()).into_values();
459 let technology = ControlledVocabulary::normalize("technology", self.technology.iter().cloned()).into_values();
460 let pairs: [(&str, &[PID]); 5] = [
461 ("doi", &[PID::DOI, PID::ARXIV]),
462 ("books", &[PID::ISBN]),
463 ("patents", &[PID::Patent]),
464 ("raid", &[PID::RAID]),
465 ("ror", &[PID::ROR]),
466 ];
467 let Self {
468 sponsors, partners, related, ..
469 } = self;
470 let metadata = pairs
471 .into_iter()
472 .filter_map(|(field, kind)| self.identifier_values(kind).map(|values| (field.to_string(), values)))
473 .chain((!websites.is_empty()).then(|| ("websites".to_string(), serde_json::Value::Array(websites))))
474 .chain(metadata_values("keywords", &keywords))
475 .chain(metadata_values("technology", &technology))
476 .chain(metadata_values("sponsors", sponsors))
477 .chain(metadata_values("partners", partners))
478 .chain(metadata_values("related", related))
479 .collect::<serde_json::Map<_, _>>();
480 let title = self
481 .title
482 .as_ref()
483 .filter(|value| !value.trim().is_empty())
484 .map(|title| ("title".to_string(), serde_json::Value::String(title.clone())));
485 let fields = [
486 (!metadata.is_empty()).then(|| ("meta".to_string(), serde_json::Value::Object(metadata))),
487 title,
488 self.description
489 .as_ref()
490 .filter(|value| !value.trim().is_empty())
491 .map(|value| ("notes".to_string(), serde_json::Value::String(value.clone()))),
492 self.contact.as_ref().and_then(|contact| {
493 let value = serde_json::to_value(contact).unwrap_or_default();
494 value
495 .as_object()
496 .is_some_and(|fields| !fields.is_empty())
497 .then(|| ("contact".to_string(), value))
498 }),
499 ];
500 serde_json::Value::Object(fields.into_iter().flatten().collect())
501 }
502 pub fn resolved(self, metadata: Option<&str>, identifier_type: &PID) -> Self {
504 match identifier_type {
505 | PID::ARXIV | PID::DOI => match api::citeas::Citations::deserialize_metadata(metadata) {
506 | Some(value) => self.with_citeas(value),
507 | None => self,
508 },
509 | PID::RAID => match Vec::<pid::raid::Metadata>::deserialize_metadata(metadata) {
510 | Some(records) => self.with_raid(records),
511 | None => self,
512 },
513 | _ => self,
514 }
515 }
516 fn with_citeas(self, citations: api::citeas::Citations) -> Self {
517 let api::citeas::Metadata {
518 title, doi, url, categories, ..
519 } = citations.metadata;
520 let identifier = Identifier::new(doi.clone())
521 .normalized()
522 .filter(|identifier| !self.identifiers.contains(identifier));
523 let prov = Provenance::Citeas {
524 doi,
525 title: title.clone(),
526 project_url: url.clone(),
527 };
528 let provenance = self
529 .provenance
530 .into_iter()
531 .chain(once(serde_json::to_value(prov).unwrap_or_default()))
532 .collect();
533 Self {
534 provenance,
535 identifiers: self.identifiers.into_iter().chain(identifier).collect(),
536 canonical_url: (!url.trim().is_empty()).then_some(url).or(self.canonical_url),
537 title: (!title.trim().is_empty()).then_some(title).or(self.title),
538 keywords: ControlledVocabulary::normalize("keywords", self.keywords.into_iter().chain(categories)).into_values(),
539 ..self
540 }
541 }
542 fn with_raid(self, records: Vec<pid::raid::Metadata>) -> Self {
543 let extractor = pid::raid::Extractor::new(&records);
544 let title = extractor.title().or(self.title);
545 let description = records
546 .iter()
547 .flat_map(|record| record.description.iter().flatten())
548 .filter(|description| {
549 description
550 .description_type
551 .as_ref()
552 .is_some_and(|kind| matches!(kind.id, pid::raid::DescriptionType::Primary | pid::raid::DescriptionType::Brief))
553 })
554 .find_map(|description| description.text.as_ref().filter(|value| !value.trim().is_empty()))
555 .cloned()
556 .or(self.description);
557 let identifiers = merge_unique(
558 self.identifiers,
559 records
560 .iter()
561 .flat_map(|record| record.alternate_identifier.iter().flatten())
562 .filter_map(|identifier| Identifier::new(&identifier.id).normalized())
563 .chain(
564 records
565 .iter()
566 .flat_map(|record| record.organization.iter().flatten())
567 .filter_map(|organization| Identifier::new(&organization.id).normalized()),
568 ),
569 );
570 let alternate_websites = records
571 .iter()
572 .flat_map(|record| record.alternate_url.iter().flatten())
573 .map(|url| Candidate::Website {
574 description: "RAiD alternate URL".to_string(),
575 url: url.url().to_string(),
576 });
577 let websites = merge_websites(self.websites, alternate_websites);
578 let values = records
579 .iter()
580 .flat_map(|record| record.subject.iter().flatten())
581 .flat_map(|subject| subject.keyword.iter().flatten())
582 .map(|keyword| keyword.text.clone());
583 let keyword_candidates = self.keywords.into_iter().chain(values);
584 let keywords = ControlledVocabulary::normalize("keywords", keyword_candidates).into_values();
585 let related_candidates = records
586 .iter()
587 .flat_map(|record| record.related_raid.iter().flatten())
588 .filter_map(|related| Identifier::new(&related.id).normalized())
589 .filter(|identifier| identifier.kind == PID::RAID)
590 .map(|identifier| identifier.value);
591 let related = merge_unique(self.related, related_candidates);
592 let sponsors = merge_unique(self.sponsors, extractor.organization_names(true));
593 let partners = merge_unique(self.partners, extractor.organization_names(false));
594 let contact = extractor
595 .contact()
596 .map(|(identifier, email)| Candidate::Contact {
597 identifier: identifier
598 .as_deref()
599 .and_then(|value| Identifier::new(value).normalized())
600 .filter(|identifier| identifier.kind == PID::ORCID)
601 .map(|identifier| identifier.value),
602 email,
603 })
604 .filter(Candidate::is_populated_contact)
605 .or(self.contact);
606 let prov = Provenance::Raid { records };
607 let provenance = self
608 .provenance
609 .into_iter()
610 .chain(once(serde_json::to_value(prov).unwrap_or_default()))
611 .collect();
612 Self {
613 identifiers,
614 title,
615 description,
616 websites,
617 keywords,
618 sponsors,
619 partners,
620 related,
621 contact,
622 provenance,
623 ..self
624 }
625 }
626 fn merge(self, candidate: Self) -> Self {
627 let identifiers = merge_unique(self.identifiers, candidate.identifiers);
628 let provider_ids = merge_unique(self.provider_ids, candidate.provider_ids);
629 let provenance = self.provenance.into_iter().chain(candidate.provenance).unique().collect();
630 let technology = ControlledVocabulary::normalize("technology", self.technology.into_iter().chain(candidate.technology)).into_values();
631 let websites = merge_websites(self.websites, candidate.websites);
632 let keywords = ControlledVocabulary::normalize("keywords", self.keywords.into_iter().chain(candidate.keywords)).into_values();
633 let sponsors = merge_unique(self.sponsors, candidate.sponsors);
634 let partners = merge_unique(self.partners, candidate.partners);
635 let related = merge_unique(self.related, candidate.related);
636 Self {
637 identifiers,
638 canonical_url: self.canonical_url.or(candidate.canonical_url),
639 title: self.title.or(candidate.title),
640 description: self.description.or(candidate.description),
641 authors: if self.authors.is_empty() { candidate.authors } else { self.authors },
642 provider_ids,
643 provenance,
644 websites,
645 keywords,
646 sponsors,
647 partners,
648 related,
649 technology,
650 contact: self.contact.or(candidate.contact),
651 }
652 }
653}
654impl<'a> TryFrom<&'a Record> for ArtifactCandidate {
655 type Error = &'a Record;
656
657 fn try_from(record: &'a Record) -> Result<Self, Self::Error> {
658 match record {
659 | Record::Discovery {
660 identifier,
661 identifier_type,
662 metadata,
663 resolution_status,
664 source,
665 source_format,
666 } => {
667 let kind = identifier_type.clone();
668 kind.is_project_identifier()
669 .then(|| {
670 let value = Identifier {
671 kind,
672 value: identifier.clone(),
673 };
674 let normalized = value.normalized().unwrap_or_else(|| Identifier::new(identifier.clone()));
675 let provenance = Provenance::Discovery {
676 source: source.clone(),
677 source_format: source_format.clone(),
678 identifier: identifier.clone(),
679 resolution_status: resolution_status.to_string(),
680 observed_at: Timestamp::now().to_string(),
681 };
682 let candidate = Self {
683 identifiers: vec![normalized],
684 provenance: vec![serde_json::to_value(provenance).unwrap_or_default()],
685 ..Self::default()
686 };
687 candidate.resolved(metadata.as_deref(), identifier_type)
688 })
689 .ok_or(record)
690 }
691 | Record::Check { .. } => Err(record),
692 }
693 }
694}
695impl Candidate {
696 pub(crate) fn url(&self) -> Option<&str> {
697 match self {
698 | Self::Website { url, .. } => Some(url),
699 | Self::Contact { .. } => None,
700 }
701 }
702 pub fn email(&self) -> Option<&str> {
704 match self {
705 | Self::Contact { email, .. } => email.as_deref(),
706 | Self::Website { .. } => None,
707 }
708 }
709 fn is_populated_contact(&self) -> bool {
710 matches!(self, Self::Contact { identifier, email } if identifier.is_some() || email.is_some())
711 }
712}
713impl fmt::Display for CitationFormat {
714 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
715 let value = match self {
716 | Self::Ieee => "ieee",
717 | Self::Apa => "apa",
718 | Self::Chicago => "chicago",
719 | Self::Harvard => "harvard",
720 | Self::Mla => "mla",
721 | Self::Vancouver => "vancouver",
722 };
723 formatter.write_str(value)
724 }
725}
726impl From<&str> for CitationFormat {
727 fn from(value: &str) -> Self {
728 value.trim().parse().unwrap_or_default()
729 }
730}
731impl FromStr for CitationFormat {
732 type Err = String;
733 fn from_str(value: &str) -> Result<Self, Self::Err> {
734 Self::iter()
735 .zip(Self::VARIANTS)
736 .find_map(|(format, variant)| variant.eq_ignore_ascii_case(value.trim()).then_some(format))
737 .ok_or_else(|| value.to_string())
738 }
739}
740impl CitationFormat {
741 fn matches(self, citation: &api::citeas::Citation) -> bool {
742 let values = [&citation.style_shortname, &citation.style_fullname].map(|value| {
743 value
744 .chars()
745 .filter(|character| character.is_ascii_alphanumeric())
746 .flat_map(char::to_lowercase)
747 .collect::<String>()
748 });
749 values.into_iter().any(|value| match self {
750 | Self::Apa => value == "apa" || value.starts_with("americanpsychologicalassociation"),
751 | Self::Chicago => value.starts_with("chicago") || value.contains("chicagomanualofstyle"),
752 | Self::Harvard => value.contains("harvard"),
753 | Self::Ieee => value == "ieee" || value.contains("instituteofelectricalandelectronicsengineers"),
754 | Self::Mla => value == "mla" || value.contains("modernlanguageassociation"),
755 | Self::Vancouver => value.contains("vancouver"),
756 })
757 }
758 fn citation(self, citations: &api::citeas::Citations) -> Option<String> {
759 citations
760 .citations
761 .iter()
762 .find(|citation| self.matches(citation))
763 .map(|citation| citation.text.split_whitespace().join(" "))
764 .filter(|citation| !citation.is_empty())
765 }
766 pub fn resolve(raw: bool, resolve: bool, explicit: Option<Self>) -> Self {
768 match (raw && resolve, explicit) {
769 | (true, Some(format)) => format,
770 | (true, None) => dotenvy::var(crate::util::constants::env::CITATION_FORMAT)
771 .ok()
772 .filter(|value| !value.trim().is_empty())
773 .map(|value| match value.parse::<Self>() {
774 | Ok(format) => format,
775 | Err(_) => {
776 let fallback = Self::default();
777 let reason = format!("('{value}' is unsupported format)");
778 warn!(
779 "=> {} {} {}",
780 Label::using(),
781 fallback.to_string().to_uppercase().yellow(),
782 reason.dimmed()
783 );
784 fallback
785 }
786 })
787 .unwrap_or_default(),
788 | (false, _) => Self::default(),
789 }
790 }
791 pub const fn values() -> &'static [&'static str] {
793 Self::VARIANTS
794 }
795}
796impl DeserializeMetadata for api::citeas::Citations {}
797impl Identifier {
798 pub fn find_all(content: &str) -> Vec<Self> {
800 let raid = content
801 .split_whitespace()
802 .filter(|value| value.to_ascii_lowercase().contains("raid"))
803 .filter_map(|value| {
804 Self {
805 kind: PID::RAID,
806 value: value.to_string(),
807 }
808 .normalized()
809 })
810 .collect::<Vec<_>>();
811 let parsed = PID::iter()
812 .filter(|kind| kind.is_discoverable() && !kind.is_raid() && !kind.is_url())
813 .flat_map(|kind| kind.find_all(content))
814 .filter(|identifier| identifier.kind != PID::DOI || !raid.iter().any(|value| value.value == identifier.value));
815 raid.iter()
816 .cloned()
817 .chain(parsed)
818 .fold(Vec::new(), |identifiers, identifier| match identifiers.contains(&identifier) {
819 | true => identifiers,
820 | false => identifiers.into_iter().chain(once(identifier)).collect(),
821 })
822 }
823}
824impl From<CandidateAction> for LifecycleState {
825 fn from(value: CandidateAction) -> Self {
826 match value {
827 | CandidateAction::Created => Self::Created,
828 | CandidateAction::Enriched => Self::Enriched,
829 | CandidateAction::Unchanged => Self::Unchanged,
830 | CandidateAction::Conflict => Self::Conflict,
831 }
832 }
833}
834impl LifecycleState {
835 fn check(self, locator: String, context: Option<String>, uri: Option<String>) -> Check {
836 let (success, severity) = match self {
837 | Self::Failed => (false, CheckSeverity::Error),
838 | Self::Conflict | Self::Unsupported => (true, CheckSeverity::Warning),
839 | _ => (true, CheckSeverity::Info),
840 };
841 check!(
842 CheckCategory::Link,
843 success,
844 severity: severity,
845 message: self.to_string(),
846 locator: locator,
847 maybe_context: context,
848 maybe_uri: uri,
849 )
850 .with_kind(CheckKind::Lifecycle)
851 }
852}
853impl Options<'_> {
854 pub fn has_input(&self) -> bool {
856 !(self.input.is_empty() && self.text.is_empty())
857 }
858 pub fn roots(&self) -> Vec<PathBuf> {
860 let default = (!self.has_input() && self.remote.is_none()).then_some(".");
861 self.input
862 .iter()
863 .map(String::as_str)
864 .chain(default)
865 .filter_map(|value| Location::from(value).local_path())
866 .collect()
867 }
868}
869impl OutputFailure {
870 pub const fn new(silent: bool) -> Self {
872 Self { silent }
873 }
874 pub const fn is_silent(self) -> bool {
876 self.silent
877 }
878}
879impl fmt::Display for OutputFailure {
880 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
881 formatter.write_str("ACORN gather found one or more failures")
882 }
883}
884impl core::error::Error for OutputFailure {}
885impl PersistenceCounts {
886 fn from_results(results: &[CandidatePersistence]) -> Self {
887 results.iter().fold(Self::default(), |counts, result| match result.action {
888 | CandidateAction::Created => Self {
889 created: counts.created.saturating_add(1),
890 ..counts
891 },
892 | CandidateAction::Enriched => Self {
893 enriched: counts.enriched.saturating_add(1),
894 ..counts
895 },
896 | CandidateAction::Unchanged => Self {
897 unchanged: counts.unchanged.saturating_add(1),
898 ..counts
899 },
900 | CandidateAction::Conflict => Self {
901 conflicts: counts.conflicts.saturating_add(1),
902 ..counts
903 },
904 })
905 }
906 fn is_empty(&self) -> bool {
907 self.created == 0 && self.enriched == 0 && self.unchanged == 0 && self.conflicts == 0
908 }
909}
910impl PID {
911 fn find_all(&self, content: &str) -> Vec<Identifier> {
912 match self {
913 | Self::ARK => ARK::find_all(content).into_iter().map(Identifier::from).collect(),
914 | Self::ARXIV => ARXIV::find_all(content).into_iter().map(Identifier::from).collect(),
915 | Self::DOI => DOI::find_all(content).into_iter().map(Identifier::from).collect(),
916 | Self::ISBN => ISBN::find_all(content).into_iter().map(Identifier::from).collect(),
917 | Self::ORCID => ORCID::find_all(content).into_iter().map(Identifier::from).collect(),
918 | Self::Patent => Patent::find_all(content).into_iter().map(Identifier::from).collect(),
919 | Self::RAID => RAID::find_all(content).into_iter().map(Identifier::from).collect(),
920 | Self::ROR => ROR::find_all(content).into_iter().map(Identifier::from).collect(),
921 | _ => Vec::new(),
922 }
923 }
924}
925impl Record {
926 async fn resolve(self) -> Self {
927 match self {
928 | Self::Discovery {
929 identifier,
930 identifier_type,
931 source,
932 source_format,
933 ..
934 } => {
935 let resolution = match identifier_type {
936 | PID::ARXIV => ResolutionOutcome::from(ARXIV::from_string(&identifier).to_citations().await),
937 | PID::DOI => ResolutionOutcome::from(DOI::from_string(&identifier).to_citations().await),
938 | PID::ORCID => ResolutionOutcome::from(
939 api::orcid::record(&api::orcid::Options::from_env().with_identifier(identifier.clone()))
940 .await
941 .map(api::orcid::SearchResponse::from),
942 ),
943 | PID::RAID => {
944 ResolutionOutcome::from(api::raid::record(&api::raid::Options::from_env().with_identifier(identifier.clone())).await)
945 }
946 | PID::ROR => ResolutionOutcome::from(api::ror::record(&api::ror::Options::from_env().with_identifier(identifier.clone())).await),
947 | _ => ResolutionOutcome::Unsupported,
948 };
949 let (metadata, resolution_status) = resolution.into_parts();
950 Self::Discovery {
951 identifier,
952 identifier_type,
953 metadata,
954 resolution_status,
955 source,
956 source_format,
957 }
958 }
959 | record => record,
960 }
961 }
962 fn with_source(self, source: &SourceDocument) -> Self {
963 match self {
964 | Self::Discovery {
965 identifier,
966 identifier_type,
967 metadata,
968 resolution_status,
969 ..
970 } => Self::Discovery {
971 identifier,
972 identifier_type,
973 metadata,
974 resolution_status,
975 source: source.source.clone(),
976 source_format: source.format.clone(),
977 },
978 | record => record,
979 }
980 }
981 fn serialize(&self) -> String {
982 match self {
983 | Self::Discovery {
984 identifier,
985 identifier_type,
986 source,
987 ..
988 } => format!("- **{identifier_type}** `{identifier}` ({source})"),
989 | Self::Check {
990 category, message, severity, ..
991 } => format!("- **{severity}** {category}: {message}"),
992 }
993 }
994 fn resolved_raw_value(&self, citation_format: CitationFormat) -> Result<String, String> {
995 match self {
996 | Self::Discovery {
997 identifier,
998 identifier_type,
999 metadata: Some(metadata),
1000 resolution_status,
1001 ..
1002 } if *resolution_status == ResolutionStatus::Resolved => serde_json::from_str(metadata)
1003 .map_err(|_| format!("Invalid resolver metadata for {identifier}"))
1004 .and_then(|metadata| resolved_pid_output(identifier, identifier_type.clone(), &metadata, citation_format)),
1005 | Self::Discovery { identifier, .. } => Ok(identifier.clone()),
1006 | Self::Check { .. } => Ok(String::new()),
1007 }
1008 }
1009}
1010impl From<Identifier> for Record {
1011 fn from(identifier: Identifier) -> Self {
1012 Self::Discovery {
1013 identifier: identifier.value,
1014 identifier_type: identifier.kind,
1015 metadata: None,
1016 resolution_status: ResolutionStatus::NotRequested,
1017 source: String::new(),
1018 source_format: String::new(),
1019 }
1020 }
1021}
1022impl From<&Check> for Record {
1023 fn from(value: &Check) -> Self {
1024 Self::Check {
1025 category: value.category.clone(),
1026 locator: value.locator.clone(),
1027 message: value.message.clone(),
1028 severity: value.severity.clone(),
1029 success: value.success,
1030 uri: value.uri.clone(),
1031 }
1032 }
1033}
1034#[async_trait]
1035impl Analysis for Records {
1036 fn standard() -> Standard {
1037 Standard::Text
1038 }
1039 fn output_path(path: &Path, _data: &Self) -> PathBuf {
1040 path.to_path_buf()
1041 }
1042}
1043impl Records {
1044 fn link_checks(&self) -> Vec<Check> {
1045 self.0
1046 .iter()
1047 .flat_map(|record| match record {
1048 | Record::Discovery {
1049 identifier,
1050 metadata,
1051 resolution_status,
1052 source,
1053 ..
1054 } => {
1055 let found = LifecycleState::Found.check(identifier.clone(), None, Some(source.clone()));
1056 let outcome = match resolution_status {
1057 | ResolutionStatus::NotRequested => None,
1058 | ResolutionStatus::Resolved => Some(LifecycleState::Resolved.check(identifier.clone(), None, Some(source.clone()))),
1059 | ResolutionStatus::Unsupported => Some(LifecycleState::Unsupported.check(identifier.clone(), None, Some(source.clone()))),
1060 | ResolutionStatus::Failed => Some(LifecycleState::Failed.check(identifier.clone(), metadata.clone(), Some(source.clone()))),
1061 };
1062 once(found).chain(outcome).collect()
1063 }
1064 | Record::Check { .. } => Vec::new(),
1065 })
1066 .collect()
1067 }
1068 pub async fn analyze(&self, options: Options<'_>) -> (Vec<Check>, Vec<PathBuf>) {
1070 let Options {
1071 offline, standard, quiet, ..
1072 } = options;
1073 let sources: &[SourceDocument] = self.into();
1074 let materialized = sources
1075 .iter()
1076 .map(|source| {
1077 let path = standard_project_folder("gather", Some(temp_dir())).with_extension("md");
1078 match write_file(path.clone(), source.content.clone()) {
1079 | Ok(()) => (Some(path), None),
1080 | Err(why) => (
1081 None,
1082 Some(check_err!(CheckCategory::Schema, message: why.to_string(), uri: source.source.clone())),
1083 ),
1084 }
1085 })
1086 .collect::<Vec<_>>();
1087 let paths = materialized.iter().filter_map(|value| value.0.clone()).collect::<Vec<_>>();
1088 let write_checks = materialized.into_iter().filter_map(|value| value.1);
1089 let options = CheckOptions::init()
1090 .all(true)
1091 .disable_website_checks(true)
1092 .offline(offline)
1093 .quiet(quiet)
1094 .skip(vec!["prose".to_string(), "readability".to_string()])
1095 .standard(match standard.unwrap_or_default() {
1096 | Standard::ResearchActivityData | Standard::Docx => Standard::Text,
1097 | value => value,
1098 })
1099 .build();
1100 let report = analyze_paths(&paths, &options).await.with_checks(self.link_checks(), &options);
1101 (write_checks.chain(report.checks()).collect(), paths)
1102 }
1103 pub fn candidates(&self) -> Vec<ArtifactCandidate> {
1105 self.0
1106 .iter()
1107 .filter_map(|record| ArtifactCandidate::try_from(record).ok())
1108 .fold(Vec::new(), |grouped, candidate| {
1109 let keys = candidate.identity_keys();
1110 let (matching, remaining): (Vec<_>, Vec<_>) = grouped
1111 .into_iter()
1112 .partition(|existing: &ArtifactCandidate| existing.identity_keys().iter().any(|key| keys.contains(key)));
1113 let merged = matching.into_iter().fold(candidate, |merged, existing| existing.merge(merged));
1114 remaining.into_iter().chain(once(merged)).collect()
1115 })
1116 }
1117 pub fn len(&self) -> usize {
1119 self.0.len()
1120 }
1121 pub fn is_empty(&self) -> bool {
1123 self.0.is_empty()
1124 }
1125 pub fn persist(&self, database_path: &Option<PathBuf>) -> Vec<Check> {
1127 let database = Database::<Table>::from_path(database_path.clone());
1128 let discovered_at = Timestamp::now();
1129 self.0
1130 .iter()
1131 .filter_map(|record| match record {
1132 | Record::Discovery {
1133 identifier,
1134 identifier_type,
1135 metadata,
1136 resolution_status,
1137 source,
1138 source_format,
1139 } => {
1140 let value = IdentifierRow::init()
1141 .discovered_at(discovered_at)
1142 .identifier(identifier.clone())
1143 .identifier_type(identifier_type.as_str())
1144 .maybe_metadata(metadata.clone())
1145 .resolution_status(resolution_status.to_string())
1146 .source(source.clone())
1147 .source_format(source_format.clone())
1148 .build();
1149 database
1150 .insert(value)
1151 .err()
1152 .map(|why| check_err!(CheckCategory::Schema, message: why.to_string(), uri: source.clone()))
1153 }
1154 | Record::Check { .. } => None,
1155 })
1156 .collect()
1157 }
1158 pub fn persist_candidates(&self, database_path: &Option<PathBuf>) -> (Vec<CandidatePersistence>, Vec<Check>) {
1160 let database = Database::<Table>::from_path(database_path.clone());
1161 self.candidates()
1162 .into_iter()
1163 .filter_map(|candidate| {
1164 let locator = candidate.identity_keys().first().cloned().unwrap_or_else(|| "candidate".to_string());
1165 let uri = candidate
1166 .provenance
1167 .first()
1168 .and_then(|value| value.get("source"))
1169 .and_then(serde_json::Value::as_str)
1170 .unwrap_or("gather")
1171 .to_string();
1172 ResearchActivityCandidate::try_from(candidate).ok().map(|value| (locator, uri, value))
1173 })
1174 .fold((Vec::new(), Vec::new()), |(mut results, mut checks), (locator, uri, value)| {
1175 match database.create_or_enrich(value) {
1176 | Ok(result) => {
1177 checks.push(LifecycleState::from(result.action).check(locator, result.iid.clone(), Some(uri)));
1178 results.push(result);
1179 }
1180 | Err(why) => checks.push(check_err!(
1181 CheckCategory::Schema,
1182 message: why.to_string(),
1183 uri: uri,
1184 )),
1185 }
1186 (results, checks)
1187 })
1188 }
1189 pub async fn resolve(self) -> Self {
1191 Self(join_all(self.0.into_iter().map(Record::resolve)).await, self.1)
1192 }
1193}
1194impl From<&[SourceDocument]> for Records {
1195 fn from(sources: &[SourceDocument]) -> Self {
1196 Self::from(sources.to_vec())
1197 }
1198}
1199impl From<Vec<SourceDocument>> for Records {
1200 fn from(sources: Vec<SourceDocument>) -> Self {
1201 let records = sources
1202 .iter()
1203 .flat_map(|source| {
1204 Identifier::find_all(&source.content)
1205 .into_iter()
1206 .map(|identifier| Record::from(identifier).with_source(source))
1207 .collect::<Vec<_>>()
1208 })
1209 .collect();
1210 Self(records, sources)
1211 }
1212}
1213impl<'a> From<&'a Records> for &'a [SourceDocument] {
1214 fn from(discoveries: &'a Records) -> Self {
1215 discoveries.1.as_slice()
1216 }
1217}
1218impl RemoteMatch {
1219 fn normalized_pid(&self) -> Option<Identifier> {
1220 self.pid.as_deref().and_then(|identifier| Identifier::new(identifier).normalized())
1221 }
1222 fn normalized_identifier(&self) -> String {
1223 self.normalized_pid().map_or_else(
1224 || self.pid.clone().unwrap_or_else(|| self.identifier.clone()),
1225 |identifier| identifier.value,
1226 )
1227 }
1228 fn resolved_raw_value(&self, citation_format: CitationFormat) -> String {
1229 let identifier = self.normalized_identifier();
1230 let kind = self.normalized_pid().map_or(PID::Unknown, |identifier| identifier.kind);
1231 self.resolution
1232 .as_ref()
1233 .filter(|resolution| resolution.status == ResolutionStatus::Resolved)
1234 .and_then(|resolution| resolution.metadata.as_ref())
1235 .and_then(|metadata| resolved_pid_output(&identifier, kind, metadata, citation_format).ok())
1236 .unwrap_or(identifier)
1237 }
1238 fn link_checks(&self) -> Vec<Check> {
1239 let identifier = self.normalized_identifier();
1240 let found = LifecycleState::Found.check(identifier.clone(), Some(self.title.clone()), self.url.clone());
1241 let outcome = self.resolution.as_ref().and_then(|resolution| match resolution.status {
1242 | ResolutionStatus::Resolved => {
1243 Some(LifecycleState::Resolved.check(identifier.clone(), Some(resolution.provider.clone()), self.url.clone()))
1244 }
1245 | ResolutionStatus::Failed => Some(LifecycleState::Failed.check(identifier.clone(), resolution.error.clone(), self.url.clone())),
1246 | ResolutionStatus::NotRequested => None,
1247 | ResolutionStatus::Unsupported => Some(LifecycleState::Unsupported.check(identifier.clone(), None, self.url.clone())),
1248 });
1249 once(found).chain(outcome).collect()
1250 }
1251}
1252#[async_trait]
1253impl Analysis for RemoteMatch {
1254 fn standard() -> Standard {
1255 Standard::Text
1256 }
1257 fn output_path(path: &Path, _data: &Self) -> PathBuf {
1258 path.to_path_buf()
1259 }
1260}
1261impl fmt::Display for RemoteProvider {
1262 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1263 formatter.write_str(match self {
1264 | Self::Osti => "osti",
1265 })
1266 }
1267}
1268impl RemoteProvider {
1269 pub fn capabilities(self) -> ProviderCapabilities {
1271 match self {
1272 | Self::Osti => ProviderCapabilities {
1273 entities: vec![RemoteEntity::Project, RemoteEntity::Person, RemoteEntity::Organization],
1274 organization_filter: true,
1275 pagination: true,
1276 },
1277 }
1278 }
1279}
1280impl RemoteResolution {
1281 fn from_result<T: Serialize>(provider: &str, result: ApiResult<T>) -> Self {
1282 match result {
1283 | Ok(metadata) => match serde_json::to_value(metadata) {
1284 | Ok(metadata) => Self::init()
1285 .provider(provider)
1286 .status(ResolutionStatus::Resolved)
1287 .metadata(metadata)
1288 .build(),
1289 | Err(why) => Self::init()
1290 .provider(provider)
1291 .status(ResolutionStatus::Failed)
1292 .error(why.to_string())
1293 .build(),
1294 },
1295 | Err(why) => Self::init()
1296 .provider(provider)
1297 .status(ResolutionStatus::Failed)
1298 .error(why.to_string())
1299 .build(),
1300 }
1301 }
1302 fn from_error(provider: &str, error: impl fmt::Display) -> Self {
1303 Self::init()
1304 .provider(provider)
1305 .status(ResolutionStatus::Failed)
1306 .error(error.to_string())
1307 .build()
1308 }
1309}
1310impl RemoteSearchRequest {
1311 pub fn is_empty(&self) -> bool {
1313 self.queries.is_empty() && self.organization.as_deref().is_none_or(str::is_empty)
1314 }
1315 pub fn supports_entity(&self) -> bool {
1317 self.provider.capabilities().entities.contains(&self.entity)
1318 }
1319 fn queries(&self) -> Vec<String> {
1320 match self.queries.is_empty() {
1321 | true => vec![String::new()],
1322 | false => self.queries.clone(),
1323 }
1324 }
1325 pub async fn run(&self, options: Options<'_>) -> ApiResult<()> {
1327 match self.fetch(options.resolve, options.offline).await {
1328 | Ok(responses) => self.process(responses, options),
1329 | Err(why) => Err(why),
1330 }
1331 }
1332 pub async fn fetch(&self, resolve: bool, offline: bool) -> ApiResult<Vec<RemoteSearchResponse>> {
1334 match (offline, self.is_empty()) {
1335 | (true, _) => Err(color_eyre::eyre::eyre!("--{} cannot be used with --offline", self.provider)),
1336 | (_, true) => Err(color_eyre::eyre::eyre!("--{} requires a query or --organization", self.provider)),
1337 | (false, false) => match (resolve, self.search().await) {
1338 | (true, Ok(responses)) => Ok(join_all(responses.into_iter().map(RemoteSearchResponse::resolve)).await),
1339 | (_, responses) => responses,
1340 },
1341 }
1342 }
1343 pub fn process(&self, responses: Vec<RemoteSearchResponse>, options: Options<'_>) -> ApiResult<()> {
1345 let Options {
1346 citation_format,
1347 database_path,
1348 format,
1349 no_local_database,
1350 offline,
1351 output,
1352 quiet,
1353 resolve,
1354 terse,
1355 verbosity,
1356 ..
1357 } = options;
1358 match offline {
1359 | true => Err(color_eyre::eyre::eyre!("--{} cannot be used with --offline", self.provider)),
1360 | false => {
1361 let inputs = self.queries.len().max(usize::from(self.organization.is_some()));
1362 let checks = responses
1363 .iter()
1364 .flat_map(|response| {
1365 resolve
1366 .then_some(response.resolution_checks.iter().cloned())
1367 .into_iter()
1368 .flatten()
1369 .chain(response.matches.iter().flat_map(RemoteMatch::link_checks))
1370 })
1371 .collect::<Vec<_>>();
1372 let candidates = match no_local_database {
1373 | true => Ok(Vec::new()),
1374 | false => {
1375 let database = Database::<Table>::from_path(database_path.clone());
1376 responses
1377 .clone()
1378 .into_iter()
1379 .map(|response| response.persist(&database, self.organization_ror.as_deref()))
1380 .collect::<ApiResult<Vec<_>>>()
1381 .map(|results| results.into_iter().flatten().collect())
1382 }
1383 }?;
1384 let persistence_checks = candidates.iter().map(|result| {
1385 let locator = result.iid.clone().unwrap_or_else(|| "candidate".to_string());
1386 let context = Some(self.provider.to_string());
1387 LifecycleState::from(result.action).check(locator, context, None)
1388 });
1389 let checks = checks.into_iter().chain(persistence_checks).collect::<Vec<_>>();
1390 let report = Report::new(&checks, Records::default(), inputs)
1391 .with_remote(responses)
1392 .with_candidates(candidates)
1393 .with_citation_format(citation_format);
1394 let format = format.unwrap_or_else(|| match (terse, output.is_none() && io::stdout().is_terminal()) {
1395 | (true, _) | (false, true) => OutputFormat::Console,
1396 | (false, false) => OutputFormat::Json,
1397 });
1398 let check_options = CheckOptions::init()
1399 .filter_by_verbosity(true)
1400 .quiet(quiet)
1401 .terse(terse)
1402 .format(format)
1403 .maybe_verbosity(verbosity)
1404 .build();
1405 checks.render(&check_options);
1406 let result = match output {
1407 | _ if terse && format == OutputFormat::Console => Ok(()),
1408 | Some(path) => report.serialize(format).and_then(|serialized| write_file(path.clone(), serialized)),
1409 | None if quiet => Ok(()),
1410 | None => report.serialize(format).map(|serialized| {
1411 if !serialized.is_empty() {
1412 println!("{serialized}");
1413 }
1414 }),
1415 };
1416 result.and_then(|_| match checks.iter().any(Check::is_failure) {
1417 | true => Err(color_eyre::eyre::eyre!(OutputFailure::new(
1418 format == OutputFormat::Raw && verbosity.is_none_or(|level| level <= 1)
1419 ))),
1420 | false => Ok(()),
1421 })
1422 }
1423 }
1424 }
1425 pub async fn search(&self) -> ApiResult<Vec<RemoteSearchResponse>> {
1427 match (self.supports_entity(), self.provider) {
1428 | (false, _) => Err(color_eyre::eyre::eyre!("{} does not support {} searches", self.provider, self.entity)),
1429 | (true, RemoteProvider::Osti) => self.search_osti().await,
1430 }
1431 }
1432 async fn search_osti(&self) -> ApiResult<Vec<RemoteSearchResponse>> {
1433 let options = api::osti::Options::from(self.clone());
1434 let futures = self.queries().into_iter().map(|query| {
1435 let options = options.clone().with_query(query);
1436 async move { api::osti::search(&options).await.and_then(RemoteSearchResponse::from_osti) }
1437 });
1438 let responses = join_all(futures).await.into_iter().collect::<ApiResult<Vec<_>>>();
1439 match responses.map(|responses| responses.into_iter().reduce(RemoteSearchResponse::merge)) {
1440 | Ok(Some(response)) => {
1441 let responses = vec![response];
1442 Ok(responses)
1443 }
1444 | Ok(None) => Ok(Vec::new()),
1445 | Err(why) => Err(why),
1446 }
1447 }
1448}
1449impl RemoteSearchResponse {
1450 pub fn merge(self, other: Self) -> Self {
1452 let matches = self
1453 .matches
1454 .into_iter()
1455 .chain(other.matches)
1456 .fold((BTreeSet::new(), Vec::new()), |(mut seen, mut matches), value| {
1457 if seen.insert((value.entity, value.identifier.clone())) {
1458 matches.push(value);
1459 }
1460 (seen, matches)
1461 })
1462 .1;
1463 Self {
1464 provider: self.provider,
1465 total: self.total.saturating_add(other.total),
1466 offset: self.offset,
1467 has_more: self.has_more || other.has_more,
1468 matches,
1469 resolution_checks: self.resolution_checks.into_iter().chain(other.resolution_checks).collect(),
1470 }
1471 }
1472 pub fn persist(self, database: &Database<Table>, organization_ror: Option<&str>) -> ApiResult<Vec<CandidatePersistence>> {
1474 let RemoteSearchResponse { provider, matches, .. } = self;
1475 matches
1476 .into_iter()
1477 .filter_map(|value| match value.entity {
1478 | RemoteEntity::Project => {
1479 let identifiers = value
1480 .pid
1481 .as_deref()
1482 .and_then(|identifier| Identifier::new(identifier).normalized())
1483 .into_iter()
1484 .chain(organization_ror.and_then(|ror| Identifier::new(ror).normalized()))
1485 .collect();
1486 let prov = Provenance::Osti {
1487 provider_identifier: value.identifier.clone(),
1488 entity: value.entity,
1489 metadata: value.metadata.clone(),
1490 observed_at: Timestamp::now().to_string(),
1491 };
1492 let RemoteMatch {
1493 identifier,
1494 keywords,
1495 sponsors,
1496 partners,
1497 related,
1498 technology,
1499 title,
1500 url,
1501 websites,
1502 description,
1503 ..
1504 } = value;
1505 let candidate = ArtifactCandidate::init()
1506 .identifiers(identifiers)
1507 .maybe_canonical_url(url)
1508 .title(title)
1509 .maybe_description(description)
1510 .authors(Vec::new())
1511 .provider_ids(vec![format!("{provider}-project:{identifier}")])
1512 .provenance(vec![serde_json::to_value(prov).unwrap_or_default()])
1513 .websites(websites)
1514 .keywords(keywords)
1515 .sponsors(sponsors)
1516 .partners(partners)
1517 .related(related)
1518 .technology(technology)
1519 .build();
1520 Some(candidate)
1521 }
1522 | RemoteEntity::Person | RemoteEntity::Organization | RemoteEntity::Repository => None,
1523 })
1524 .filter_map(|candidate| ResearchActivityCandidate::try_from(candidate).ok())
1525 .map(|candidate| database.create_or_enrich(candidate))
1526 .collect()
1527 }
1528}
1529impl Report {
1530 pub fn new(checks: &[Check], discoveries: Records, inputs: usize) -> Self {
1532 let summary = Summary::init()
1533 .discoveries(discoveries.len())
1534 .failures(checks.iter().filter(|check| check.is_failure()).count())
1535 .inputs(inputs)
1536 .build();
1537 Self::init()
1538 .checks(checks.iter().map(Record::from).collect())
1539 .discoveries(discoveries)
1540 .summary(summary)
1541 .build()
1542 }
1543 pub fn with_citation_format(self, citation_format: CitationFormat) -> Self {
1545 Self { citation_format, ..self }
1546 }
1547 pub fn with_remote(self, remote: Vec<RemoteSearchResponse>) -> Self {
1549 let Self {
1550 checks,
1551 discoveries,
1552 summary,
1553 candidates,
1554 citation_format,
1555 ..
1556 } = self;
1557 let matches = remote.iter().map(|response| response.matches.len()).sum();
1558 Self {
1559 checks,
1560 discoveries,
1561 remote,
1562 candidates,
1563 summary: Summary { matches, ..summary },
1564 citation_format,
1565 }
1566 }
1567 pub fn with_candidates(self, candidates: Vec<CandidatePersistence>) -> Self {
1569 let counts = PersistenceCounts::from_results(&candidates);
1570 Self {
1571 candidates,
1572 summary: Summary {
1573 candidates: counts,
1574 ..self.summary
1575 },
1576 ..self
1577 }
1578 }
1579 pub fn serialize(&self, format: OutputFormat) -> ApiResult<String> {
1581 match format {
1582 | OutputFormat::Console => {
1583 let (headers, rows) = self.table();
1584 Ok(values_as_table(headers, rows, Some(self.title())))
1585 }
1586 | OutputFormat::Json => serde_json::to_string_pretty(self).map_err(EyreReport::from),
1587 | OutputFormat::Markdown => {
1588 let Summary {
1589 inputs,
1590 discoveries: discovery_count,
1591 matches,
1592 failures,
1593 ..
1594 } = &self.summary;
1595 let discoveries = self
1596 .discoveries
1597 .0
1598 .iter()
1599 .filter(|record| matches!(record, Record::Discovery { .. }))
1600 .map(Record::serialize)
1601 .collect::<Vec<_>>()
1602 .join("\n");
1603 let checks = self
1604 .checks
1605 .iter()
1606 .filter(|record| matches!(record, Record::Check { .. }))
1607 .map(Record::serialize)
1608 .collect::<Vec<_>>()
1609 .join("\n");
1610 Ok(format!(
1611 "# ACORN gather\n\n## Summary\n\n- Inputs: {}\n- Discoveries: {}\n- Matches: {}\n- Failures: {}\n\n## Discoveries\n\n{}\n\n## Remote matches\n\n{}\n\n## Checks\n\n{}",
1612 inputs,
1613 discovery_count,
1614 matches,
1615 failures,
1616 discoveries,
1617 self.remote
1618 .iter()
1619 .flat_map(|response| response.matches.iter().map(move |value| format!("- **{}** {}: {}", response.provider, value.identifier, value.title)))
1620 .collect::<Vec<_>>()
1621 .join("\n"),
1622 checks,
1623 ))
1624 }
1625 | OutputFormat::Raw => {
1626 let output = self
1627 .discoveries
1628 .0
1629 .iter()
1630 .filter_map(|record| match record {
1631 | Record::Discovery { identifier, .. } => Some((
1632 identifier.clone(),
1633 record.resolved_raw_value(self.citation_format).unwrap_or_else(|_| identifier.clone()),
1634 )),
1635 | Record::Check { .. } => None,
1636 })
1637 .chain(self.remote.iter().flat_map(|response| {
1638 response
1639 .matches
1640 .iter()
1641 .map(|value| (value.normalized_identifier(), value.resolved_raw_value(self.citation_format)))
1642 }))
1643 .unique_by(|(identifier, _)| identifier.clone())
1644 .map(|(_, value)| value)
1645 .join("\n");
1646 Ok(output)
1647 }
1648 | OutputFormat::Yaml => serde_norway::to_string(self).map_err(EyreReport::from),
1649 }
1650 }
1651 pub fn title(&self) -> String {
1653 format!(
1654 "ACORN gather: {} inputs, {} discoveries, {} matches, {} failures",
1655 self.summary.inputs, self.summary.discoveries, self.summary.matches, self.summary.failures
1656 )
1657 }
1658 pub fn table(&self) -> (Vec<&'static str>, Vec<Vec<String>>) {
1660 let discoveries = self.discoveries.0.iter().filter_map(|record| match record {
1661 | Record::Discovery {
1662 identifier,
1663 identifier_type,
1664 resolution_status,
1665 source,
1666 ..
1667 } => Some(vec![
1668 "discovery".to_string(),
1669 format!("{identifier_type}: {identifier}"),
1670 resolution_status.to_string(),
1671 source.clone(),
1672 ]),
1673 | Record::Check { .. } => None,
1674 });
1675 let remote = self.remote.iter().flat_map(|response| {
1676 response.matches.iter().map(|value| {
1677 let label = match value.identifier == value.title {
1678 | true => value.title.clone(),
1679 | false => format!("{}: {}", value.identifier, value.title),
1680 };
1681 vec![
1682 format!("{} {:?}", response.provider, value.entity).to_ascii_lowercase(),
1683 label,
1684 match response.has_more {
1685 | true => format!("{} of {}", response.matches.len(), response.total),
1686 | false => "complete".to_string(),
1687 },
1688 value.url.clone().unwrap_or_default(),
1689 ]
1690 })
1691 });
1692 (vec!["Type", "Value", "Status", "Source"], discoveries.chain(remote).collect())
1693 }
1694}
1695impl TryFrom<ArtifactCandidate> for ResearchActivityCandidate {
1696 type Error = ArtifactCandidate;
1697
1698 fn try_from(candidate: ArtifactCandidate) -> Result<Self, Self::Error> {
1699 let keys = candidate.identity_keys();
1700 match keys.is_empty() {
1701 | true => Err(candidate),
1702 | false => {
1703 let rad_json = candidate.to_partial_rad_json();
1704 Ok(Self::new(rad_json, keys, candidate.provenance))
1705 }
1706 }
1707 }
1708}
1709impl<T> ResolutionOutcome<T> {
1710 pub const fn status(&self) -> ResolutionStatus {
1712 match self {
1713 | Self::NotRequested => ResolutionStatus::NotRequested,
1714 | Self::Unsupported => ResolutionStatus::Unsupported,
1715 | Self::Resolved(_) => ResolutionStatus::Resolved,
1716 | Self::Failed(_) => ResolutionStatus::Failed,
1717 }
1718 }
1719}
1720impl ResolutionOutcome<String> {
1721 fn into_parts(self) -> (Option<String>, ResolutionStatus) {
1722 let status = self.status();
1723 let metadata = match self {
1724 | Self::Resolved(metadata) | Self::Failed(metadata) => Some(metadata),
1725 | Self::NotRequested | Self::Unsupported => None,
1726 };
1727 (metadata, status)
1728 }
1729}
1730impl<T> From<ApiResult<T>> for ResolutionOutcome<String>
1731where
1732 T: Serialize,
1733{
1734 fn from(result: ApiResult<T>) -> Self {
1735 match result {
1736 | Ok(value) => match serde_json::to_string(&value) {
1737 | Ok(metadata) => Self::Resolved(metadata),
1738 | Err(why) => Self::Failed(why.to_string()),
1739 },
1740 | Err(why) => Self::Failed(why.to_string()),
1741 }
1742 }
1743}
1744impl DeserializeMetadata for api::orcid::SearchResponse {}
1745impl DeserializeMetadata for Vec<pid::raid::Metadata> {}
1746pub(super) fn candidate_repositories(canonical_url: Option<&str>, websites: &[Candidate], domain: &str) -> Vec<Repository> {
1747 candidate_urls(canonical_url, websites)
1748 .into_iter()
1749 .filter_map(|url| Repository::from_remote(url, domain))
1750 .unique_by(|repository| repository.location().to_string())
1751 .collect()
1752}
1753fn candidate_urls<'a>(canonical_url: Option<&'a str>, websites: &'a [Candidate]) -> Vec<&'a str> {
1754 canonical_url
1755 .into_iter()
1756 .chain(websites.iter().filter_map(Candidate::url))
1757 .unique()
1758 .collect()
1759}
1760pub fn discover_identifiers(content: &str) -> Vec<Identifier> {
1762 content
1763 .split_whitespace()
1764 .filter_map(|value| Identifier::new(value).normalized())
1765 .fold(Vec::new(), |mut identifiers, identifier| {
1766 if !identifiers.contains(&identifier) {
1767 identifiers.push(identifier);
1768 }
1769 identifiers
1770 })
1771}
1772pub fn group_artifacts(candidates: Vec<ArtifactCandidate>) -> Vec<ArtifactCandidate> {
1774 candidates.into_iter().fold(Vec::<ArtifactCandidate>::new(), |mut grouped, candidate| {
1775 match grouped.iter_mut().find(|existing| same_artifact(existing, &candidate)) {
1776 | Some(existing) => *existing = existing.clone().merge(candidate),
1777 | None => grouped.push(candidate),
1778 }
1779 grouped
1780 })
1781}
1782fn is_zero(value: &usize) -> bool {
1783 *value == 0
1784}
1785fn metadata_values(field: &str, values: &[String]) -> Option<(String, serde_json::Value)> {
1786 (!values.is_empty()).then(|| (field.to_string(), serde_json::json!(values)))
1787}
1788fn merge_websites(left: impl IntoIterator<Item = Candidate>, right: impl IntoIterator<Item = Candidate>) -> Vec<Candidate> {
1789 left.into_iter()
1790 .chain(right)
1791 .filter(|candidate| candidate.url().is_some())
1792 .unique_by(|candidate| candidate.url().unwrap_or_default().to_string())
1793 .collect()
1794}
1795fn normalized_authors(values: &[String]) -> Vec<String> {
1796 let mut values = values.iter().map(|value| value.as_str().normalized()).collect::<Vec<_>>();
1797 values.sort();
1798 values
1799}
1800fn resolved_pid_output(identifier: &str, kind: PID, metadata: &serde_json::Value, citation_format: CitationFormat) -> Result<String, String> {
1801 match kind {
1802 | PID::ARXIV | PID::DOI => serde_json::from_value::<api::citeas::Citations>(metadata.clone())
1803 .ok()
1804 .and_then(|citations| citation_format.citation(&citations))
1805 .ok_or_else(|| format!("{citation_format} citation not found for {} {identifier}", kind.as_str())),
1806 | PID::ORCID => serde_json::from_value::<api::orcid::SearchResponse>(metadata.clone())
1807 .ok()
1808 .and_then(|response| {
1809 response.results.into_iter().find_map(|profile| {
1810 let name = match (profile.given_names, profile.family_names) {
1811 | (Some(given), Some(family)) if !given.trim().is_empty() && !family.trim().is_empty() => {
1812 Some(format!("{} {}", given.trim(), family.trim()))
1813 }
1814 | _ => profile.credit_name.map(|name| name.trim().to_string()).filter(|name| !name.is_empty()),
1815 };
1816 name.map(|name| format!("{name} ({identifier})"))
1817 })
1818 })
1819 .ok_or_else(|| format!("Public name not found for ORCID {identifier}")),
1820 | _ => Ok(identifier.to_string()),
1821 }
1822}
1823fn same_artifact(left: &ArtifactCandidate, right: &ArtifactCandidate) -> bool {
1824 let canonical_match = left
1825 .identifiers
1826 .iter()
1827 .filter(|identifier| matches!(identifier.kind, PID::DOI | PID::URL))
1828 .any(|identifier| right.identifiers.contains(identifier))
1829 || left
1830 .canonical_url
1831 .as_ref()
1832 .zip(right.canonical_url.as_ref())
1833 .is_some_and(|(left, right)| left == right);
1834 let metadata_match = left
1835 .title
1836 .as_ref()
1837 .zip(right.title.as_ref())
1838 .filter(|(left, right)| left.as_str().normalized() == right.as_str().normalized())
1839 .is_some()
1840 && !left.authors.is_empty()
1841 && normalized_authors(&left.authors) == normalized_authors(&right.authors);
1842 canonical_match || metadata_match
1843}
1844#[cfg(test)]
1845mod tests;