1use std::path::Path;
7
8use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
9use crate::error::{Error, Result};
10use crate::table::{TableAlign, TableData, convert_table_pages};
11
12const MAX_RIS_BYTES: u64 = 128 * 1024 * 1024;
13const MAX_RIS_LINES: usize = 2_000_000;
14const MAX_RIS_LINE_BYTES: usize = 1 << 20;
15const MAX_RIS_RECORDS: usize = 100_000;
16const MAX_RIS_FIELD_BYTES: usize = 64 * 1024;
17const MAX_RIS_RENDERED_BYTES: usize = 64 * 1024 * 1024;
18const MAX_RIS_AUTHORS: usize = 512;
19const MAX_RIS_KEYWORDS: usize = 512;
20const MAX_RIS_PREVIEW: usize = 512;
21
22pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
23 let Ok(text) = std::str::from_utf8(prefix) else {
24 return false;
25 };
26 text.lines()
27 .map(str::trim)
28 .find(|line| !line.is_empty())
29 .is_some_and(|line| is_tag_line(line, "TY"))
30}
31
32pub(crate) fn convert(
33 path: &Path,
34 options: &ConvertOptions,
35 sink: &mut dyn PageConsumer,
36) -> Result<Vec<String>> {
37 let bytes = read_limited_file(
38 path,
39 options.max_input_bytes.min(MAX_RIS_BYTES),
40 "RIS input",
41 )?;
42 let text = String::from_utf8(bytes)
43 .map_err(|error| Error::InvalidInput(format!("RIS input must be UTF-8/ASCII: {error}")))?;
44 let (mut table, warnings) = parse_ris(&text)?;
45 let mut page_sink = RisPageSink {
46 inner: sink,
47 warnings: &warnings,
48 };
49 convert_table_pages(&mut table, "ris", options, &mut page_sink)?;
50 Ok(warnings)
51}
52
53struct RisPageSink<'a> {
54 inner: &'a mut dyn PageConsumer,
55 warnings: &'a [String],
56}
57
58impl PageConsumer for RisPageSink<'_> {
59 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
60 page.source_format = "ris".into();
61 page.title = "RIS bibliography".into();
62 page.description =
63 "RIS citation fields are displayed inertly without DOI or URL lookup".into();
64 for warning in self.warnings {
65 page.warn(warning.clone());
66 }
67 self.inner.consume(page)
68 }
69}
70
71#[derive(Default)]
72struct Record {
73 kind: String,
74 title: String,
75 authors: Vec<String>,
76 year: String,
77 journal: String,
78 doi: String,
79 url: String,
80 abstract_text: String,
81 keywords: Vec<String>,
82 pages: String,
83 unknown_fields: usize,
84 last_tag: Option<String>,
85}
86
87fn parse_ris(text: &str) -> Result<(TableData, Vec<String>)> {
88 if text.len() as u64 > MAX_RIS_BYTES {
89 return Err(Error::LimitExceeded(format!(
90 "RIS input exceeds {MAX_RIS_BYTES} bytes"
91 )));
92 }
93 let lines = text.lines().collect::<Vec<_>>();
94 if lines.len() > MAX_RIS_LINES {
95 return Err(Error::LimitExceeded(format!(
96 "RIS input exceeds {MAX_RIS_LINES} lines"
97 )));
98 }
99 let mut rows = Vec::new();
100 let mut warnings = Vec::new();
101 let mut current = None::<Record>;
102 let mut record_number = 0usize;
103 let mut rendered_bytes = 0usize;
104 for (line_number, original) in lines.iter().enumerate() {
105 if original.len() > MAX_RIS_LINE_BYTES {
106 return Err(Error::LimitExceeded(format!(
107 "RIS line {} exceeds {MAX_RIS_LINE_BYTES} bytes",
108 line_number + 1
109 )));
110 }
111 let line = original.trim_end_matches('\r');
112 if line.trim().is_empty() {
113 continue;
114 }
115 if is_tag_line(line, "TY") {
116 if let Some(previous) = current.take() {
117 warnings.push(format!(
118 "RIS record before line {} was missing ER; it was finalized at the next TY",
119 line_number + 1
120 ));
121 finalize_record(
122 previous,
123 &mut rows,
124 &mut warnings,
125 &mut record_number,
126 &mut rendered_bytes,
127 )?;
128 }
129 current = Some(Record {
130 kind: tag_value(line).to_owned(),
131 ..Record::default()
132 });
133 if current
134 .as_ref()
135 .is_some_and(|record| record.kind.is_empty())
136 {
137 return Err(Error::InvalidInput(format!(
138 "RIS line {} TY has an empty reference type",
139 line_number + 1
140 )));
141 }
142 current.as_mut().unwrap().last_tag = Some("TY".into());
143 continue;
144 }
145 let Some(record) = current.as_mut() else {
146 return Err(Error::InvalidInput(format!(
147 "RIS line {} appeared before TY",
148 line_number + 1
149 )));
150 };
151 if is_tag_line(line, "ER") {
152 let record = current.take().expect("current record exists");
153 finalize_record(
154 record,
155 &mut rows,
156 &mut warnings,
157 &mut record_number,
158 &mut rendered_bytes,
159 )?;
160 continue;
161 }
162 if line.as_bytes().starts_with(b" ") {
163 let continuation = line.get(6..).unwrap_or_default().trim();
164 if continuation.is_empty() {
165 continue;
166 }
167 append_continuation(record, continuation)?;
168 continue;
169 }
170 if !valid_tag_line(line) {
171 return Err(Error::InvalidInput(format!(
172 "RIS line {} is not a canonical XX - value field",
173 line_number + 1
174 )));
175 }
176 let tag = line.get(..2).unwrap_or_default().to_ascii_uppercase();
177 let value = tag_value(line).trim();
178 validate_field(value, "RIS field")?;
179 apply_field(record, &tag, value)?;
180 record.last_tag = Some(tag);
181 }
182 if let Some(record) = current.take() {
183 warnings.push("RIS final record was missing ER and was finalized at EOF".into());
184 finalize_record(
185 record,
186 &mut rows,
187 &mut warnings,
188 &mut record_number,
189 &mut rendered_bytes,
190 )?;
191 }
192 if rows.is_empty() {
193 return Err(Error::InvalidInput("RIS input contains no records".into()));
194 }
195 if warnings
196 .iter()
197 .any(|warning| warning.contains("unknown RIS"))
198 { }
199 let headers = [
200 "Record", "Type", "Title", "Authors", "Year", "Journal", "Pages", "DOI", "URL", "Keywords",
201 "Abstract",
202 ]
203 .into_iter()
204 .map(str::to_owned)
205 .collect();
206 let alignments = vec![
207 TableAlign::Right,
208 TableAlign::Center,
209 TableAlign::Left,
210 TableAlign::Left,
211 TableAlign::Right,
212 TableAlign::Left,
213 TableAlign::Left,
214 TableAlign::Left,
215 TableAlign::Left,
216 TableAlign::Left,
217 TableAlign::Left,
218 ];
219 Ok((
220 TableData {
221 headers,
222 rows,
223 alignments,
224 raw_source: String::new(),
225 },
226 dedup_warnings(warnings),
227 ))
228}
229
230fn apply_field(record: &mut Record, tag: &str, value: &str) -> Result<()> {
231 match tag {
232 "TY" => record.kind = value.to_owned(),
233 "TI" | "T1" => append_value(&mut record.title, value, "RIS title")?,
234 "AU" | "A1" => {
235 if record.authors.len() >= MAX_RIS_AUTHORS {
236 return Err(Error::LimitExceeded(format!(
237 "RIS exceeds {MAX_RIS_AUTHORS} authors per record"
238 )));
239 }
240 record.authors.push(value.to_owned());
241 }
242 "PY" | "Y1" => append_value(&mut record.year, value, "RIS year")?,
243 "JO" | "JF" | "T2" | "J2" => append_value(&mut record.journal, value, "RIS journal")?,
244 "DO" => append_value(&mut record.doi, value, "RIS DOI")?,
245 "UR" => append_value(&mut record.url, value, "RIS URL")?,
246 "AB" | "N2" => append_value(&mut record.abstract_text, value, "RIS abstract")?,
247 "KW" => {
248 if record.keywords.len() >= MAX_RIS_KEYWORDS {
249 return Err(Error::LimitExceeded(format!(
250 "RIS exceeds {MAX_RIS_KEYWORDS} keywords per record"
251 )));
252 }
253 record.keywords.push(value.to_owned());
254 }
255 "SP" => append_value(&mut record.pages, value, "RIS start page")?,
256 "EP" => {
257 if !record.pages.is_empty() {
258 record.pages.push('-');
259 }
260 record.pages.push_str(value);
261 validate_field(&record.pages, "RIS pages")?;
262 }
263 "ER" => {}
264 _ => record.unknown_fields = record.unknown_fields.saturating_add(1),
265 }
266 Ok(())
267}
268
269fn append_continuation(record: &mut Record, value: &str) -> Result<()> {
270 match record.last_tag.as_deref() {
271 Some("TI") | Some("T1") => append_value(&mut record.title, value, "RIS title"),
272 Some("PY") | Some("Y1") => append_value(&mut record.year, value, "RIS year"),
273 Some("JO") | Some("JF") | Some("T2") | Some("J2") => {
274 append_value(&mut record.journal, value, "RIS journal")
275 }
276 Some("DO") => append_value(&mut record.doi, value, "RIS DOI"),
277 Some("UR") => append_value(&mut record.url, value, "RIS URL"),
278 Some("AB") | Some("N2") => append_value(&mut record.abstract_text, value, "RIS abstract"),
279 _ => Ok(()),
280 }
281}
282
283fn finalize_record(
284 record: Record,
285 rows: &mut Vec<Vec<String>>,
286 warnings: &mut Vec<String>,
287 record_number: &mut usize,
288 rendered_bytes: &mut usize,
289) -> Result<()> {
290 *record_number = (*record_number)
291 .checked_add(1)
292 .ok_or_else(|| Error::LimitExceeded("RIS record count overflowed".into()))?;
293 if *record_number > MAX_RIS_RECORDS {
294 return Err(Error::LimitExceeded(format!(
295 "RIS exceeds {MAX_RIS_RECORDS} records"
296 )));
297 }
298 if record.unknown_fields > 0 {
299 warnings.push(format!(
300 "unknown RIS fields in record {} were kept inert",
301 *record_number
302 ));
303 }
304 let authors = record.authors.join("; ");
305 let keywords = record.keywords.join("; ");
306 let abstract_preview = preview_text(&record.abstract_text);
307 let values = vec![
308 record_number.to_string(),
309 record.kind,
310 record.title,
311 authors,
312 record.year,
313 record.journal,
314 record.pages,
315 record.doi,
316 record.url,
317 keywords,
318 abstract_preview,
319 ];
320 let bytes = values.iter().map(String::len).sum::<usize>();
321 *rendered_bytes = (*rendered_bytes)
322 .checked_add(bytes)
323 .ok_or_else(|| Error::LimitExceeded("RIS rendered text byte count overflowed".into()))?;
324 if *rendered_bytes > MAX_RIS_RENDERED_BYTES {
325 return Err(Error::LimitExceeded(format!(
326 "RIS rendered text exceeds {MAX_RIS_RENDERED_BYTES} bytes"
327 )));
328 }
329 rows.push(values);
330 Ok(())
331}
332
333fn valid_tag_line(line: &str) -> bool {
334 line.len() >= 6
335 && line.as_bytes().get(0..2).is_some_and(|tag| {
336 tag.iter()
337 .all(|byte| byte.is_ascii_alphabetic() || byte.is_ascii_digit())
338 })
339 && line.as_bytes().get(2..4) == Some(b" ")
340 && line.as_bytes().get(4) == Some(&b'-')
341 && line.as_bytes().get(5) == Some(&b' ')
342}
343fn is_tag_line(line: &str, tag: &str) -> bool {
344 line.len() >= 5
345 && line
346 .get(..2)
347 .is_some_and(|value| value.eq_ignore_ascii_case(tag))
348 && line.as_bytes().get(2..4) == Some(b" ")
349 && line.as_bytes().get(4) == Some(&b'-')
350}
351fn tag_value(line: &str) -> &str {
352 line.get(5..).unwrap_or_default()
353}
354fn append_value(target: &mut String, value: &str, context: &str) -> Result<()> {
355 if !target.is_empty() {
356 target.push(' ');
357 }
358 target.push_str(value);
359 validate_field(target, context)
360}
361fn preview_text(value: &str) -> String {
362 if value.len() <= MAX_RIS_PREVIEW {
363 value.to_owned()
364 } else {
365 let mut end = MAX_RIS_PREVIEW;
366 while !value.is_char_boundary(end) {
367 end -= 1;
368 }
369 format!("{}…", &value[..end])
370 }
371}
372fn validate_field(value: &str, context: &str) -> Result<()> {
373 if value.len() > MAX_RIS_FIELD_BYTES {
374 return Err(Error::LimitExceeded(format!(
375 "{context} exceeds {MAX_RIS_FIELD_BYTES} bytes"
376 )));
377 }
378 if value.chars().any(|character| character.is_control()) {
379 return Err(Error::InvalidInput(format!(
380 "{context} contains a control character"
381 )));
382 }
383 Ok(())
384}
385fn dedup_warnings(warnings: Vec<String>) -> Vec<String> {
386 let mut seen = std::collections::HashSet::new();
387 warnings
388 .into_iter()
389 .filter(|warning| seen.insert(warning.clone()))
390 .collect()
391}