Skip to main content

batuta/experiment/
research.rs

1//! Academic research support with ORCID, CRediT, and citation generation.
2//!
3//! This module provides types for academic metadata, contributor roles,
4//! and citation generation in BibTeX and CITATION.cff formats.
5
6use super::ExperimentError;
7use serde::{Deserialize, Serialize};
8
9/// ORCID identifier (Open Researcher and Contributor ID)
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Orcid(String);
12
13impl Orcid {
14    /// Create and validate an ORCID
15    pub fn new(orcid: impl Into<String>) -> Result<Self, ExperimentError> {
16        let orcid = orcid.into();
17        // ORCID format: 0000-0000-0000-000X where X can be 0-9 or X
18        // Replaced regex-lite with string validation (DEP-REDUCE)
19        if Self::is_valid_orcid(&orcid) {
20            Ok(Self(orcid))
21        } else {
22            Err(ExperimentError::InvalidOrcid(orcid))
23        }
24    }
25
26    /// Validate ORCID format without regex
27    fn is_valid_orcid(orcid: &str) -> bool {
28        let parts: Vec<&str> = orcid.split('-').collect();
29        if parts.len() != 4 {
30            return false;
31        }
32        // First 3 parts: exactly 4 digits
33        for part in parts.iter().take(3) {
34            if part.len() != 4 || !part.chars().all(|c| c.is_ascii_digit()) {
35                return false;
36            }
37        }
38        // Last part: 3 digits + (digit or X)
39        let last = parts[3];
40        if last.len() != 4 {
41            return false;
42        }
43        let chars: Vec<char> = last.chars().collect();
44        chars.iter().take(3).all(|c| c.is_ascii_digit())
45            && (chars[3].is_ascii_digit() || chars[3] == 'X')
46    }
47
48    /// Get the ORCID string
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54/// CRediT (Contributor Roles Taxonomy) roles
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum CreditRole {
57    Conceptualization,
58    DataCuration,
59    FormalAnalysis,
60    FundingAcquisition,
61    Investigation,
62    Methodology,
63    ProjectAdministration,
64    Resources,
65    Software,
66    Supervision,
67    Validation,
68    Visualization,
69    WritingOriginalDraft,
70    WritingReviewEditing,
71}
72
73/// Research contributor with ORCID and CRediT roles
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ResearchContributor {
76    /// Full name
77    pub name: String,
78    /// ORCID identifier
79    pub orcid: Option<Orcid>,
80    /// Affiliation
81    pub affiliation: String,
82    /// CRediT roles
83    pub roles: Vec<CreditRole>,
84    /// Email (optional)
85    pub email: Option<String>,
86}
87
88/// Research artifact with full academic metadata
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ResearchArtifact {
91    /// Title
92    pub title: String,
93    /// Abstract
94    pub abstract_text: String,
95    /// Contributors with roles
96    pub contributors: Vec<ResearchContributor>,
97    /// Keywords
98    pub keywords: Vec<String>,
99    /// DOI if published
100    pub doi: Option<String>,
101    /// ArXiv ID if applicable
102    pub arxiv_id: Option<String>,
103    /// License
104    pub license: String,
105    /// Creation date
106    pub created_at: String,
107    /// Associated datasets
108    pub datasets: Vec<String>,
109    /// Associated code repositories
110    pub code_repositories: Vec<String>,
111    /// Pre-registration info
112    pub pre_registration: Option<PreRegistration>,
113}
114
115/// Pre-registration for reproducible research
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PreRegistration {
118    /// Registration timestamp
119    pub timestamp: String,
120    /// Ed25519 signature of the registration
121    pub signature: String,
122    /// Public key used for signing
123    pub public_key: String,
124    /// Hash of the pre-registered hypotheses
125    pub hypotheses_hash: String,
126    /// Registry where registered (e.g., OSF, AsPredicted)
127    pub registry: String,
128    /// Registration ID
129    pub registration_id: String,
130}
131
132/// Citation metadata for BibTeX/CFF generation
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct CitationMetadata {
135    /// Citation type
136    pub citation_type: CitationType,
137    /// Title
138    pub title: String,
139    /// Authors
140    pub authors: Vec<String>,
141    /// Year
142    pub year: u16,
143    /// Month (optional)
144    pub month: Option<u8>,
145    /// DOI
146    pub doi: Option<String>,
147    /// URL
148    pub url: Option<String>,
149    /// Journal/Conference name
150    pub venue: Option<String>,
151    /// Volume
152    pub volume: Option<String>,
153    /// Pages
154    pub pages: Option<String>,
155    /// Publisher
156    pub publisher: Option<String>,
157    /// Version (for software)
158    pub version: Option<String>,
159}
160
161/// Citation type
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163pub enum CitationType {
164    Article,
165    InProceedings,
166    Book,
167    Software,
168    Dataset,
169    Misc,
170}
171
172impl CitationMetadata {
173    /// Generate BibTeX entry
174    pub fn to_bibtex(&self, key: &str) -> String {
175        let type_str = match self.citation_type {
176            CitationType::Article => "article",
177            CitationType::InProceedings => "inproceedings",
178            CitationType::Book => "book",
179            CitationType::Software => "software",
180            CitationType::Dataset => "dataset",
181            CitationType::Misc => "misc",
182        };
183
184        let mut bibtex = format!("@{}{{{},\n", type_str, key);
185        bibtex.push_str(&format!("  title = {{{}}},\n", self.title));
186        bibtex.push_str(&format!("  author = {{{}}},\n", self.authors.join(" and ")));
187        bibtex.push_str(&format!("  year = {{{}}},\n", self.year));
188
189        if let Some(month) = self.month {
190            bibtex.push_str(&format!("  month = {{{}}},\n", month));
191        }
192        if let Some(ref doi) = self.doi {
193            bibtex.push_str(&format!("  doi = {{{}}},\n", doi));
194        }
195        if let Some(ref url) = self.url {
196            bibtex.push_str(&format!("  url = {{{}}},\n", url));
197        }
198        if let Some(ref venue) = self.venue {
199            let field = match self.citation_type {
200                CitationType::Article => "journal",
201                CitationType::InProceedings => "booktitle",
202                _ => "howpublished",
203            };
204            bibtex.push_str(&format!("  {} = {{{}}},\n", field, venue));
205        }
206        if let Some(ref volume) = self.volume {
207            bibtex.push_str(&format!("  volume = {{{}}},\n", volume));
208        }
209        if let Some(ref pages) = self.pages {
210            bibtex.push_str(&format!("  pages = {{{}}},\n", pages));
211        }
212        if let Some(ref publisher) = self.publisher {
213            bibtex.push_str(&format!("  publisher = {{{}}},\n", publisher));
214        }
215        if let Some(ref version) = self.version {
216            bibtex.push_str(&format!("  version = {{{}}},\n", version));
217        }
218
219        bibtex.push('}');
220        bibtex
221    }
222
223    /// Generate CITATION.cff content
224    pub fn to_cff(&self) -> String {
225        let mut cff = String::from("cff-version: 1.2.0\n");
226        cff.push_str(&format!("title: \"{}\"\n", self.title));
227        cff.push_str("authors:\n");
228        for author in &self.authors {
229            cff.push_str(&format!("  - name: \"{}\"\n", author));
230        }
231        cff.push_str(&format!(
232            "date-released: \"{}-{:02}-01\"\n",
233            self.year,
234            self.month.unwrap_or(1)
235        ));
236
237        if let Some(ref version) = self.version {
238            cff.push_str(&format!("version: \"{}\"\n", version));
239        }
240        if let Some(ref doi) = self.doi {
241            cff.push_str(&format!("doi: \"{}\"\n", doi));
242        }
243        if let Some(ref url) = self.url {
244            cff.push_str(&format!("url: \"{}\"\n", url));
245        }
246
247        cff
248    }
249}