Skip to main content

oxihuman_export/
ris_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! RIS reference format export.
6
7/// Reference type tags used in RIS format.
8#[derive(Debug, Clone, PartialEq)]
9pub enum RisType {
10    Journal,
11    Book,
12    Conference,
13    Report,
14    Thesis,
15    Generic,
16}
17
18impl RisType {
19    /// RIS type code string.
20    pub fn code(&self) -> &'static str {
21        match self {
22            Self::Journal => "JOUR",
23            Self::Book => "BOOK",
24            Self::Conference => "CONF",
25            Self::Report => "RPRT",
26            Self::Thesis => "THES",
27            Self::Generic => "GEN",
28        }
29    }
30}
31
32/// A single RIS record field (tag + value).
33#[derive(Debug, Clone)]
34pub struct RisField {
35    pub tag: String,
36    pub value: String,
37}
38
39/// A single RIS reference record.
40#[derive(Debug, Clone)]
41pub struct RisRecord {
42    pub ref_type: RisType,
43    pub fields: Vec<RisField>,
44}
45
46impl RisRecord {
47    /// Create a new empty record of the given type.
48    pub fn new(ref_type: RisType) -> Self {
49        Self {
50            ref_type,
51            fields: Vec::new(),
52        }
53    }
54
55    /// Add a field to the record.
56    pub fn add_field(&mut self, tag: impl Into<String>, value: impl Into<String>) {
57        self.fields.push(RisField {
58            tag: tag.into(),
59            value: value.into(),
60        });
61    }
62
63    /// Retrieve the first value for a given tag.
64    pub fn get_field(&self, tag: &str) -> Option<&str> {
65        self.fields
66            .iter()
67            .find(|f| f.tag == tag)
68            .map(|f| f.value.as_str())
69    }
70}
71
72/// A collection of RIS records.
73#[derive(Debug, Clone, Default)]
74pub struct RisDatabase {
75    pub records: Vec<RisRecord>,
76}
77
78impl RisDatabase {
79    /// Add a record.
80    pub fn add_record(&mut self, record: RisRecord) {
81        self.records.push(record);
82    }
83
84    /// Count of records.
85    pub fn record_count(&self) -> usize {
86        self.records.len()
87    }
88}
89
90/// Render a single RIS record to string.
91pub fn render_record(rec: &RisRecord) -> String {
92    let mut out = format!("TY  - {}\n", rec.ref_type.code());
93    for field in &rec.fields {
94        out.push_str(&format!("{}  - {}\n", field.tag, field.value));
95    }
96    out.push_str("ER  - \n");
97    out
98}
99
100/// Render the full RIS database.
101pub fn render_ris(db: &RisDatabase) -> String {
102    db.records
103        .iter()
104        .map(render_record)
105        .collect::<Vec<_>>()
106        .join("\n")
107}
108
109/// Validate that records have at least a title field (TI or T1).
110pub fn validate_record(rec: &RisRecord) -> bool {
111    rec.get_field("TI").is_some() || rec.get_field("T1").is_some()
112}
113
114/// Count records of a specific type.
115pub fn count_by_type(db: &RisDatabase, ref_type: &RisType) -> usize {
116    db.records
117        .iter()
118        .filter(|r| &r.ref_type == ref_type)
119        .count()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn sample_record() -> RisRecord {
127        let mut r = RisRecord::new(RisType::Journal);
128        r.add_field("TI", "Test Title");
129        r.add_field("AU", "Smith, J.");
130        r.add_field("PY", "2026");
131        r
132    }
133
134    #[test]
135    fn type_code() {
136        assert_eq!(RisType::Journal.code(), "JOUR");
137    }
138
139    #[test]
140    fn get_field_found() {
141        assert_eq!(sample_record().get_field("TI"), Some("Test Title"));
142    }
143
144    #[test]
145    fn get_field_missing() {
146        assert!(sample_record().get_field("ZZ").is_none());
147    }
148
149    #[test]
150    fn record_count() {
151        let mut db = RisDatabase::default();
152        db.add_record(sample_record());
153        assert_eq!(db.record_count(), 1);
154    }
155
156    #[test]
157    fn render_record_ty_line() {
158        let s = render_record(&sample_record());
159        assert!(s.starts_with("TY  - JOUR"));
160    }
161
162    #[test]
163    fn render_record_er_line() {
164        assert!(render_record(&sample_record()).contains("ER  - "));
165    }
166
167    #[test]
168    fn validate_ok() {
169        assert!(validate_record(&sample_record()));
170    }
171
172    #[test]
173    fn validate_no_title() {
174        let r = RisRecord::new(RisType::Generic);
175        assert!(!validate_record(&r));
176    }
177
178    #[test]
179    fn count_by_type_correct() {
180        let mut db = RisDatabase::default();
181        db.add_record(sample_record());
182        db.add_record(RisRecord::new(RisType::Book));
183        assert_eq!(count_by_type(&db, &RisType::Journal), 1);
184    }
185
186    #[test]
187    fn render_ris_contains_type() {
188        let mut db = RisDatabase::default();
189        db.add_record(sample_record());
190        assert!(render_ris(&db).contains("JOUR"));
191    }
192}