Skip to main content

acorn/analyzer/discovery/
mod.rs

1//! Artifact discovery, resolution, persistence, and grouping helpers.
2use 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/// Typed metadata attached to an artifact candidate.
41#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
42#[serde(untagged)]
43pub enum Candidate {
44    /// Provider-confirmed website associated with an artifact.
45    Website {
46        /// Brief description of the linked content.
47        description: String,
48        /// Absolute website URL.
49        url: String,
50    },
51    /// Explicitly identified operational contact.
52    Contact {
53        /// Persistent person identifier.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        identifier: Option<String>,
56        /// Contact email address.
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        email: Option<String>,
59    },
60}
61/// Serializable gather record
62#[derive(Clone, Debug, Serialize)]
63#[serde(tag = "record", rename_all = "lowercase")]
64pub enum Record {
65    /// Analysis check included in a gather report
66    Check {
67        /// Check category
68        category: CheckCategory,
69        /// Optional check location
70        locator: Option<String>,
71        /// Check diagnostic
72        message: String,
73        /// Check severity
74        severity: CheckSeverity,
75        /// Whether the check passed
76        success: bool,
77        /// Optional source URI
78        uri: Option<String>,
79    },
80    /// Persistent identifier discovered in a source document
81    Discovery {
82        /// Normalized identifier value
83        identifier: String,
84        /// Identifier type
85        identifier_type: PID,
86        /// Optional serialized resolver metadata
87        metadata: Option<String>,
88        /// Resolver status
89        resolution_status: ResolutionStatus,
90        /// Source URI
91        source: String,
92        /// Source document format
93        source_format: String,
94    },
95}
96/// Citation format used for resolved DOI output.
97#[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    /// Institute of Electrical and Electronics Engineers.
102    #[default]
103    Ieee,
104    /// American Psychological Association.
105    Apa,
106    /// Chicago Manual of Style.
107    Chicago,
108    /// Harvard author-date style.
109    Harvard,
110    /// Modern Language Association.
111    Mla,
112    /// Vancouver numeric style.
113    Vancouver,
114}
115/// Remote metadata provider used by gather.
116#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
117#[serde(rename_all = "lowercase")]
118pub enum RemoteProvider {
119    /// DOE CODE from the Office of Scientific and Technical Information.
120    Osti,
121}
122/// Outcome state for remote identifier resolution.
123#[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    /// Identifier resolution was not requested.
128    #[default]
129    NotRequested,
130    /// Identifier resolution failed.
131    Failed,
132    /// Identifier resolution succeeded.
133    Resolved,
134    /// No resolver is implemented for this identifier type.
135    Unsupported,
136}
137/// Typed terminal outcome of a resolution attempt.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum ResolutionOutcome<T> {
140    /// Resolution was not requested.
141    NotRequested,
142    /// No resolver is implemented for the value.
143    Unsupported,
144    /// Resolution succeeded with metadata.
145    Resolved(T),
146    /// Resolution failed with a diagnostic.
147    Failed(String),
148}
149/// State emitted while gathering and resolving discoveries.
150#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, strum::Display)]
151#[serde(rename_all = "lowercase")]
152#[strum(serialize_all = "lowercase")]
153pub enum LifecycleState {
154    /// A discovery or provider match was found.
155    Found,
156    /// Metadata resolution succeeded.
157    Resolved,
158    /// No resolver supports the discovery.
159    Unsupported,
160    /// Metadata resolution failed.
161    Failed,
162    /// A canonical candidate was created.
163    Created,
164    /// An existing candidate was enriched.
165    Enriched,
166    /// The candidate already contained the gathered evidence.
167    Unchanged,
168    /// Candidate evidence conflicted with stored data.
169    Conflict,
170}
171/// Features exposed by a remote gather provider.
172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173pub struct ProviderCapabilities {
174    /// Entity views supported by the provider.
175    pub entities: Vec<RemoteEntity>,
176    /// Whether organization-scoped searches are supported.
177    pub organization_filter: bool,
178    /// Whether offset, limit, and all-page retrieval are supported.
179    pub pagination: bool,
180}
181/// Provider-neutral remote search request.
182#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
183#[builder(start_fn = init)]
184pub struct RemoteSearchRequest {
185    /// Provider to search.
186    pub provider: RemoteProvider,
187    /// Entity view to return.
188    pub entity: RemoteEntity,
189    /// Direct search expressions.
190    #[builder(default)]
191    pub queries: Vec<String>,
192    /// Optional organization filter.
193    pub organization: Option<String>,
194    /// Optional ROR associated with the organization filter.
195    pub organization_ror: Option<String>,
196    /// Organization relationship filter.
197    #[builder(default)]
198    pub organization_role: RemoteOrganizationRole,
199    /// Upstream page size.
200    #[builder(default = 20)]
201    pub limit: usize,
202    /// Upstream offset.
203    #[builder(default)]
204    pub offset: usize,
205    /// Retrieve all pages.
206    #[builder(default)]
207    pub all: bool,
208}
209/// Provider-neutral remote search match.
210#[derive(Builder, Clone, Debug, Deserialize, Serialize)]
211#[builder(start_fn = init, on(String, into))]
212pub struct RemoteMatch {
213    /// Entity kind.
214    pub entity: RemoteEntity,
215    /// Provider-native stable identifier.
216    pub identifier: String,
217    /// Human-readable label.
218    pub title: String,
219    /// Optional persistent identifier.
220    pub pid: Option<String>,
221    /// Optional canonical link.
222    pub url: Option<String>,
223    /// Provider-native metadata retained for structured output and details.
224    pub metadata: serde_json::Value,
225    /// Provider-supplied project description.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub description: Option<String>,
228    /// Additional provider-confirmed project links.
229    #[builder(default = Vec::new())]
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub websites: Vec<Candidate>,
232    /// Provider-supplied project keywords.
233    #[builder(default = Vec::new())]
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub keywords: Vec<Keyword>,
236    /// Organizations identified as project sponsors.
237    #[builder(default = Vec::new())]
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub sponsors: Vec<String>,
240    /// Organizations identified as project partners.
241    #[builder(default = Vec::new())]
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub partners: Vec<String>,
244    /// Related research activity identifiers.
245    #[builder(default = Vec::new())]
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub related: Vec<String>,
248    /// Programming languages reported by the repository provider.
249    #[builder(default = Vec::new())]
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub technology: Vec<String>,
252    /// Optional metadata-provider outcome requested for raw resolution.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub resolution: Option<RemoteResolution>,
255}
256/// Provider-neutral remote search response.
257#[derive(Clone, Debug, Deserialize, Serialize)]
258pub struct RemoteSearchResponse {
259    /// Provider that produced the results.
260    pub provider: RemoteProvider,
261    /// Total upstream records matched.
262    pub total: usize,
263    /// Upstream offset.
264    pub offset: usize,
265    /// Whether another page is available.
266    pub has_more: bool,
267    /// Normalized matches.
268    pub matches: Vec<RemoteMatch>,
269    #[serde(skip)]
270    resolution_checks: Vec<Check>,
271}
272/// Candidate artifact record before standard-specific mapping
273#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
274#[builder(start_fn = init, on(String, into))]
275pub struct ArtifactCandidate {
276    /// Identifiers associated with this artifact
277    pub identifiers: Vec<Identifier>,
278    /// Resolver-proven canonical URL
279    pub canonical_url: Option<String>,
280    /// Enriched title
281    pub title: Option<String>,
282    /// Provider-supplied project description.
283    #[serde(default)]
284    pub description: Option<String>,
285    /// Enriched author display names
286    pub authors: Vec<String>,
287    /// Provider-native project identifiers, including their provider prefix.
288    pub provider_ids: Vec<String>,
289    /// Evidence supporting mapped fields and project relationships.
290    pub provenance: Vec<serde_json::Value>,
291    /// Additional provider-confirmed project links.
292    #[serde(default)]
293    pub websites: Vec<Candidate>,
294    /// Provider-supplied project keywords.
295    #[serde(default)]
296    pub keywords: Vec<Keyword>,
297    /// Organizations identified as project sponsors.
298    #[serde(default)]
299    pub sponsors: Vec<String>,
300    /// Organizations identified as project partners.
301    #[serde(default)]
302    pub partners: Vec<String>,
303    /// Related research activity identifiers.
304    #[serde(default)]
305    pub related: Vec<String>,
306    /// Programming languages and technologies associated with the artifact.
307    #[serde(default)]
308    pub technology: Vec<String>,
309    /// Explicit operational contact asserted by a metadata provider.
310    #[serde(default)]
311    pub contact: Option<Candidate>,
312}
313/// Resolver outcome attached to a remote gather match.
314#[derive(Builder, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
315#[builder(start_fn = init, on(String, into))]
316pub struct RemoteResolution {
317    /// Metadata provider name.
318    pub provider: String,
319    /// Resolution status.
320    pub status: ResolutionStatus,
321    /// Full provider metadata when resolution succeeded.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub metadata: Option<serde_json::Value>,
324    /// Provider diagnostic when resolution failed.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub error: Option<String>,
327}
328/// Gather command options shared with discovery analysis
329#[derive(Clone, Copy, Debug)]
330pub struct Options<'a> {
331    /// Citation format used for resolved raw DOI output
332    pub citation_format: CitationFormat,
333    /// Optional database path
334    pub database_path: &'a Option<PathBuf>,
335    /// Optional input inclusion pattern
336    pub filter: &'a Option<String>,
337    /// Report format
338    pub format: Option<OutputFormat>,
339    /// Optional input exclusion pattern
340    pub ignore: &'a Option<String>,
341    /// Input locations
342    pub input: &'a [String],
343    /// Maximum directory traversal depth
344    pub max_depth: Option<usize>,
345    /// Whether to gather merge request files
346    pub merge_request: bool,
347    /// Whether local database persistence is disabled
348    pub no_local_database: bool,
349    /// Whether network access is disabled
350    pub offline: bool,
351    /// Whether inputs are being repeatedly observed for changes.
352    pub watching: bool,
353    /// Optional report output path
354    pub output: &'a Option<PathBuf>,
355    /// Whether identifiers should be resolved
356    pub resolve: bool,
357    /// Optional analysis standard
358    pub standard: &'a Option<Standard>,
359    /// Literal text inputs
360    pub text: &'a [String],
361    /// Whether analysis output should be quiet
362    pub quiet: bool,
363    /// Whether checks use compact output and the discovery table is suppressed.
364    pub terse: bool,
365    /// Effective check visibility level; `None` suppresses checks.
366    pub verbosity: Option<u8>,
367    /// Optional remote metadata search.
368    pub remote: Option<&'a RemoteSearchRequest>,
369}
370/// Gather failure after report processing.
371#[derive(Clone, Copy, Debug, Eq, PartialEq)]
372pub struct OutputFailure {
373    silent: bool,
374}
375/// Candidate persistence totals included in gather reports.
376#[derive(Clone, Debug, Default, Serialize)]
377struct PersistenceCounts {
378    created: usize,
379    enriched: usize,
380    unchanged: usize,
381    conflicts: usize,
382}
383/// Gather discoveries and the source documents from which they were derived
384#[derive(Clone, Debug, Default, Serialize)]
385#[serde(transparent)]
386pub struct Records(Vec<Record>, #[serde(skip)] Vec<SourceDocument>);
387/// Structured gather report
388#[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    /// Return sorted exact project identities suitable for persistence
419    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    /// Enrich this candidate from any overt GitHub or configured GitLab repository URL.
431    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    /// Build partial RAD JSON from fields that map without information loss
449    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    /// Enrich this candidate with losslessly mapped resolver metadata
503    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    /// Return the contact email when this is a contact candidate.
703    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    /// Resolve the active citation style, applying CLI precedence before the environment override.
767    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    /// Return every accepted configuration value.
792    pub const fn values() -> &'static [&'static str] {
793        Self::VARIANTS
794    }
795}
796impl DeserializeMetadata for api::citeas::Citations {}
797impl Identifier {
798    /// Discover every supported persistent identifier
799    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    /// Return whether path or literal text input was supplied.
855    pub fn has_input(&self) -> bool {
856        !(self.input.is_empty() && self.text.is_empty())
857    }
858    /// Return local filesystem roots observed by gather watch mode.
859    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    /// Create a gather failure, optionally suppressing the top-level diagnostic.
871    pub const fn new(silent: bool) -> Self {
872        Self { silent }
873    }
874    /// Return whether the top-level CLI diagnostic should be suppressed.
875    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    /// Analyze the retained source documents and return checks and temporary paths
1069    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    /// Convert project-like discoveries into exact-identity artifact candidates.
1104    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    /// Return the number of discovered records
1118    pub fn len(&self) -> usize {
1119        self.0.len()
1120    }
1121    /// Return whether no records were discovered
1122    pub fn is_empty(&self) -> bool {
1123        self.0.is_empty()
1124    }
1125    /// Persist discoveries and return checks for failed writes
1126    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    /// Persist exact-identity project candidates and return results plus failure checks.
1159    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    /// Resolve supported identifiers through their metadata providers
1190    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    /// Describe the entity views and filters supported by this provider.
1270    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    /// Return whether neither a direct query nor an organization filter was supplied.
1312    pub fn is_empty(&self) -> bool {
1313        self.queries.is_empty() && self.organization.as_deref().is_none_or(str::is_empty)
1314    }
1315    /// Return whether the selected provider supports the requested entity.
1316    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    /// Run the remote search and emit its gather report
1326    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    /// Fetch and optionally resolve normalized remote search responses without producing output or persistence side effects.
1333    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    /// Process previously fetched remote responses using gather output and persistence options.
1344    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    /// Search the configured remote provider
1426    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    /// Merge another response, retaining the first occurrence of each provider identifier.
1451    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    /// Persist project matches as canonical research activity candidates, optionally associated with an organization ROR.
1473    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    /// Build a report from checks, discoveries, and the number of loaded inputs.
1531    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    /// Select the citation format used by resolved raw output.
1544    pub fn with_citation_format(self, citation_format: CitationFormat) -> Self {
1545        Self { citation_format, ..self }
1546    }
1547    /// Attach remote search results to this report.
1548    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    /// Attach canonical candidate persistence results to this report.
1568    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    /// Serialize the report in the selected format.
1580    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    /// Build the title used for terminal table output.
1652    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    /// Convert the report to terminal table headers and rows.
1659    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    /// Return the serializable status represented by this outcome.
1711    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}
1760/// Discover and normalize supported identifiers from prose or metadata text
1761pub 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}
1772/// Group artifacts only when canonical identifiers or enriched metadata prove equivalence
1773pub 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;