Skip to main content

fastpaper/
output.rs

1use crate::sources::Paper;
2
3/// Format papers as CSV.
4pub fn to_csv(papers: &[Paper]) -> String {
5    let mut out = String::from("id,title,authors,year,doi,url\n");
6    for p in papers {
7        out.push_str(&format!(
8            "{},{},{},{},{},{}\n",
9            csv_escape(&p.id),
10            csv_escape(&p.title),
11            csv_escape(&p.authors.join(";")),
12            p.year.map(|y| y.to_string()).unwrap_or_default(),
13            csv_escape(p.doi.as_deref().unwrap_or("")),
14            csv_escape(p.url.as_deref().unwrap_or("")),
15        ));
16    }
17    out
18}
19
20fn csv_escape(field: &str) -> String {
21    if field.contains(',') || field.contains('"') || field.contains('\n') {
22        format!("\"{}\"", field.replace('"', "\"\""))
23    } else {
24        field.to_string()
25    }
26}
27
28/// Format papers as BibTeX entries.
29pub fn to_bibtex(papers: &[Paper]) -> String {
30    let mut out = String::new();
31    for p in papers {
32        out.push_str(&format!("@article{{{},\n", p.id));
33        out.push_str(&format!("  title = {{{}}},\n", p.title));
34        out.push_str(&format!("  author = {{{}}},\n", p.authors.join(" and ")));
35        if let Some(year) = p.year {
36            out.push_str(&format!("  year = {{{}}},\n", year));
37        }
38        if let Some(ref doi) = p.doi {
39            out.push_str(&format!("  doi = {{{}}},\n", doi));
40        }
41        out.push_str("}\n");
42    }
43    out
44}
45
46/// Format papers as a human-readable table.
47pub fn to_table(papers: &[Paper]) -> String {
48    if papers.is_empty() {
49        return "No results found".to_string();
50    }
51    let mut out = String::new();
52    for (i, p) in papers.iter().enumerate() {
53        if i > 0 {
54            out.push('\n');
55        }
56        out.push_str(&format!("[{}] {}", p.id, p.title));
57        let mut meta = Vec::new();
58        if let Some(year) = p.year {
59            meta.push(year.to_string());
60        }
61        if !p.authors.is_empty() {
62            meta.push(p.authors.join(", "));
63        }
64        if !meta.is_empty() {
65            out.push_str(&format!("\n    {}", meta.join(" | ")));
66        }
67    }
68    out.push('\n');
69    out
70}
71
72/// Format papers as a JSON search result string.
73pub fn to_json(papers: &[Paper]) -> String {
74    let result = serde_json::json!({
75        "source": papers.first().map(|p| p.source.as_str()).unwrap_or(""),
76        "results": papers,
77    });
78    serde_json::to_string_pretty(&result).unwrap()
79}
80
81/// Format papers as newline-delimited JSON: one paper object per line.
82///
83/// `--format jsonl` has been an accepted value all along but silently rendered
84/// the human table, since the dispatcher had no branch for it.
85pub fn to_jsonl(papers: &[Paper]) -> String {
86    let mut out = String::new();
87    for paper in papers {
88        out.push_str(&serde_json::to_string(paper).unwrap());
89        out.push('\n');
90    }
91    out
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn sample_paper() -> Paper {
99        Paper {
100            id: "2301.08745".to_string(),
101            title: "Attention Is All You Need".to_string(),
102            authors: vec!["Alice Smith".to_string(), "Bob Jones".to_string()],
103            abstract_text: Some("We propose a new architecture.".to_string()),
104            year: Some(2023),
105            doi: Some("10.48550/arXiv.2301.08745".to_string()),
106            url: Some("https://arxiv.org/abs/2301.08745".to_string()),
107            pdf_url: Some("https://arxiv.org/pdf/2301.08745".to_string()),
108            venue: Some("arXiv preprint".to_string()),
109            citations: None,
110            fields: vec!["cs.CL".to_string()],
111            open_access: Some(true),
112            source: "arxiv".to_string(),
113        }
114    }
115
116    #[test]
117    fn to_json_returns_valid_json() {
118        let json = to_json(&[sample_paper()]);
119        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
120        assert!(parsed.is_object());
121    }
122
123    #[test]
124    fn to_json_has_source_field() {
125        let json = to_json(&[sample_paper()]);
126        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
127        assert_eq!(v["source"], "arxiv");
128    }
129
130    #[test]
131    fn to_json_has_results_array() {
132        let json = to_json(&[sample_paper()]);
133        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
134        let results = v["results"].as_array().expect("results should be array");
135        assert_eq!(results.len(), 1);
136    }
137
138    #[test]
139    fn to_json_paper_has_expected_fields() {
140        let json = to_json(&[sample_paper()]);
141        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
142        let paper = &v["results"][0];
143        assert_eq!(paper["id"], "2301.08745");
144        assert_eq!(paper["title"], "Attention Is All You Need");
145        assert_eq!(paper["authors"][0], "Alice Smith");
146        assert_eq!(paper["year"], 2023);
147        assert_eq!(paper["doi"], "10.48550/arXiv.2301.08745");
148        assert_eq!(paper["url"], "https://arxiv.org/abs/2301.08745");
149    }
150
151    #[test]
152    fn to_json_empty_papers() {
153        let json = to_json(&[]);
154        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
155        let results = v["results"].as_array().expect("results should be array");
156        assert!(results.is_empty());
157    }
158
159    #[test]
160    fn to_csv_first_line_is_header() {
161        let csv = to_csv(&[sample_paper()]);
162        let first_line = csv.lines().next().unwrap();
163        assert_eq!(first_line, "id,title,authors,year,doi,url");
164    }
165
166    #[test]
167    fn to_csv_second_line_contains_id() {
168        let csv = to_csv(&[sample_paper()]);
169        let second_line = csv.lines().nth(1).unwrap();
170        assert!(second_line.starts_with("2301.08745,"));
171    }
172
173    #[test]
174    fn to_csv_authors_joined_by_semicolon() {
175        let csv = to_csv(&[sample_paper()]);
176        let second_line = csv.lines().nth(1).unwrap();
177        assert!(second_line.contains("Alice Smith;Bob Jones"));
178    }
179
180    #[test]
181    fn to_csv_quotes_field_with_comma() {
182        let mut paper = sample_paper();
183        paper.title = "Attention, Transformers, and You".to_string();
184        let csv = to_csv(&[paper]);
185        let second_line = csv.lines().nth(1).unwrap();
186        assert!(second_line.contains("\"Attention, Transformers, and You\""));
187    }
188
189    #[test]
190    fn to_csv_empty_returns_header_only() {
191        let csv = to_csv(&[]);
192        let lines: Vec<&str> = csv.lines().collect();
193        assert_eq!(lines.len(), 1);
194        assert_eq!(lines[0], "id,title,authors,year,doi,url");
195    }
196
197    #[test]
198    fn to_bibtex_contains_article_tag() {
199        let bib = to_bibtex(&[sample_paper()]);
200        assert!(bib.contains("@article{"));
201    }
202
203    #[test]
204    fn to_bibtex_contains_title() {
205        let bib = to_bibtex(&[sample_paper()]);
206        assert!(bib.contains("title = {Attention Is All You Need}"));
207    }
208
209    #[test]
210    fn to_bibtex_contains_author() {
211        let bib = to_bibtex(&[sample_paper()]);
212        assert!(bib.contains("author = {Alice Smith and Bob Jones}"));
213    }
214
215    #[test]
216    fn to_bibtex_contains_year() {
217        let bib = to_bibtex(&[sample_paper()]);
218        assert!(bib.contains("year = {2023}"));
219    }
220
221    #[test]
222    fn to_bibtex_contains_doi() {
223        let bib = to_bibtex(&[sample_paper()]);
224        assert!(bib.contains("doi = {10.48550/arXiv.2301.08745}"));
225    }
226
227    #[test]
228    fn to_bibtex_no_doi_when_missing() {
229        let mut paper = sample_paper();
230        paper.doi = None;
231        let bib = to_bibtex(&[paper]);
232        assert!(!bib.contains("doi ="));
233    }
234
235    #[test]
236    fn to_bibtex_empty_returns_empty_string() {
237        let bib = to_bibtex(&[]);
238        assert!(bib.is_empty());
239    }
240
241    #[test]
242    fn to_table_contains_title() {
243        let table = to_table(&[sample_paper()]);
244        assert!(table.contains("Attention Is All You Need"));
245    }
246
247    #[test]
248    fn to_table_contains_id() {
249        let table = to_table(&[sample_paper()]);
250        assert!(table.contains("2301.08745"));
251    }
252
253    #[test]
254    fn to_table_contains_year() {
255        let table = to_table(&[sample_paper()]);
256        assert!(table.contains("2023"));
257    }
258
259    #[test]
260    fn to_table_multiple_papers_on_separate_lines() {
261        let mut p2 = sample_paper();
262        p2.id = "2312.00001".to_string();
263        p2.title = "Second Paper".to_string();
264        let table = to_table(&[sample_paper(), p2]);
265        assert!(table.contains("Attention Is All You Need"));
266        assert!(table.contains("Second Paper"));
267    }
268
269    #[test]
270    fn to_table_empty_shows_no_results() {
271        let table = to_table(&[]);
272        assert_eq!(table, "No results found");
273    }
274
275    #[test]
276    fn to_jsonl_emits_one_line_per_paper() {
277        let out = to_jsonl(&[sample_paper(), sample_paper()]);
278        assert_eq!(out.lines().count(), 2);
279    }
280
281    #[test]
282    fn to_jsonl_lines_are_standalone_json_objects() {
283        let out = to_jsonl(&[sample_paper()]);
284        let line = out.lines().next().unwrap();
285        let v: serde_json::Value = serde_json::from_str(line).unwrap();
286        assert_eq!(v["title"], "Attention Is All You Need");
287        assert_eq!(v["id"], "2301.08745");
288    }
289
290    #[test]
291    fn to_jsonl_of_nothing_is_empty() {
292        assert_eq!(to_jsonl(&[]), "");
293    }
294}