Skip to main content

scitadel_export/
snapshot.rs

1//! Pure write-side of `bib snapshot` (#178), reusable from CLI + TUI.
2//!
3//! The CLI command in `scitadel-cli` already loads a question's
4//! shortlist + papers + tags from the DB, then renders + writes the
5//! `.bib` (or CSL-JSON) file plus the `.scitadel-bib.lock` sidecar.
6//! The TUI needs to do the exact same write step from already-loaded
7//! data when the user presses `E` on the Question Dashboard
8//! (#135 sub-feature B).
9//!
10//! Lifting the rendering + I/O into this module guarantees both
11//! surfaces produce byte-identical artifacts — there is no second copy
12//! of the format-routing or sidecar-construction logic.
13
14use std::path::{Path, PathBuf};
15
16use scitadel_core::models::Paper;
17
18use crate::sidecar::BibLockfile;
19use crate::{export_bibtex_with_tags, export_csl_json_with_tags};
20
21/// Sidecar suffix appended to the snapshot's output path. One sidecar
22/// per `(question, output_file)` keeps scope focused — see #178
23/// "Sidecar location collision" pitfall.
24pub const SIDECAR_SUFFIX: &str = ".scitadel-bib.lock";
25
26/// Output flavor for [`write_snapshot`]. The string discriminants live
27/// on [`crate::sidecar`] so verify and snapshot can never disagree
28/// about the wire value of the format field.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SnapshotFormat {
31    BibTeX,
32    CslJson,
33}
34
35impl SnapshotFormat {
36    /// Suggested file extension, including the leading dot. Used by
37    /// callers building default output paths.
38    #[must_use]
39    pub fn extension(self) -> &'static str {
40        match self {
41            Self::BibTeX => ".bib",
42            Self::CslJson => ".json",
43        }
44    }
45}
46
47/// Default filename (path component, no directory) for a given
48/// snapshot format. Mirrors the CLI's `--output` fallback so `E` from
49/// the TUI without typing anything produces the same filename you'd
50/// get from `bib snapshot <id>` with no `--output`.
51#[must_use]
52pub fn default_filename_for_format(format: SnapshotFormat) -> &'static Path {
53    match format {
54        SnapshotFormat::CslJson => Path::new("paper.json"),
55        SnapshotFormat::BibTeX => Path::new("paper.bib"),
56    }
57}
58
59/// Path of the `.scitadel-bib.lock` sidecar that should sit next to
60/// `bib`. The sidecar's filename is exactly `<bib>.scitadel-bib.lock`
61/// — see `bib snapshot` docs.
62#[must_use]
63pub fn sidecar_path_for(bib: &Path) -> PathBuf {
64    let mut s = bib.as_os_str().to_owned();
65    s.push(SIDECAR_SUFFIX);
66    PathBuf::from(s)
67}
68
69/// What [`write_snapshot`] just wrote to disk. The TUI surfaces the
70/// counts in a status-bar toast; tests assert the on-disk artifacts
71/// against the returned paths.
72#[derive(Debug, Clone)]
73pub struct SnapshotOutcome {
74    /// Path the rendered bibliography was written to (verbatim from the
75    /// caller; sidecar is computed from this).
76    pub output_path: PathBuf,
77    /// Path of the `.scitadel-bib.lock` sidecar; `None` when the caller
78    /// asked us to skip the sidecar via `write_lockfile = false`.
79    pub sidecar_path: Option<PathBuf>,
80    /// Number of `Paper` rows the bibliography includes. Drives the
81    /// "exported: paper.bib (N entries)" toast in the TUI.
82    pub entry_count: usize,
83}
84
85/// Write a snapshot to disk from already-loaded inputs.
86///
87/// `papers` and `paper_ids` are expected to be in shortlist order.
88/// `tags_for(paper_id)` returns the per-paper BibTeX tags (groups);
89/// callers without tags pass a closure that always returns `vec![]`.
90///
91/// # Errors
92///
93/// Returns any [`std::io::Error`] from writing the bibliography or the
94/// sidecar. Lockfile JSON serialization errors are wrapped in
95/// `io::ErrorKind::Other`.
96#[allow(clippy::too_many_arguments)]
97pub fn write_snapshot<F>(
98    output_path: &Path,
99    question_id: &str,
100    reader: &str,
101    papers: &[Paper],
102    paper_ids: &[String],
103    tags_for: F,
104    format: SnapshotFormat,
105    write_lockfile: bool,
106) -> std::io::Result<SnapshotOutcome>
107where
108    F: Fn(&str) -> Vec<String>,
109{
110    let (content, lock) = match format {
111        SnapshotFormat::BibTeX => {
112            let c = export_bibtex_with_tags(papers, &tags_for);
113            let l = BibLockfile::new_bibtex(question_id, reader, paper_ids, &c);
114            (c, l)
115        }
116        SnapshotFormat::CslJson => {
117            let c = export_csl_json_with_tags(papers, &tags_for);
118            let l = BibLockfile::new_csl_json(question_id, reader, paper_ids, &c);
119            (c, l)
120        }
121    };
122
123    std::fs::write(output_path, &content)?;
124
125    let sidecar_path = if write_lockfile {
126        let path = sidecar_path_for(output_path);
127        let json = lock.to_json().map_err(std::io::Error::other)?;
128        std::fs::write(&path, json)?;
129        Some(path)
130    } else {
131        None
132    };
133
134    Ok(SnapshotOutcome {
135        output_path: output_path.to_path_buf(),
136        sidecar_path,
137        entry_count: papers.len(),
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use scitadel_core::models::{Paper, PaperId};
145
146    fn paper(id: &str, title: &str, year: i32) -> Paper {
147        let mut p = Paper::new(title);
148        p.id = PaperId::from(id);
149        p.authors = vec!["Doe, J.".into()];
150        p.year = Some(year);
151        p
152    }
153
154    #[test]
155    fn sidecar_path_appends_suffix() {
156        let p = sidecar_path_for(Path::new("out/paper.bib"));
157        assert_eq!(p, PathBuf::from("out/paper.bib.scitadel-bib.lock"));
158    }
159
160    #[test]
161    fn default_filename_matches_format() {
162        assert_eq!(
163            default_filename_for_format(SnapshotFormat::BibTeX),
164            Path::new("paper.bib")
165        );
166        assert_eq!(
167            default_filename_for_format(SnapshotFormat::CslJson),
168            Path::new("paper.json")
169        );
170    }
171
172    #[test]
173    fn write_snapshot_bibtex_writes_both_files() {
174        let dir = tempfile::tempdir().unwrap();
175        let out = dir.path().join("p.bib");
176        let papers = vec![paper("p-1", "On Attention", 2017)];
177        let ids = vec!["p-1".to_string()];
178
179        let outcome = write_snapshot(
180            &out,
181            "q-x",
182            "lars",
183            &papers,
184            &ids,
185            |_| vec![],
186            SnapshotFormat::BibTeX,
187            true,
188        )
189        .unwrap();
190
191        assert_eq!(outcome.entry_count, 1);
192        assert_eq!(outcome.output_path, out);
193        let sidecar = outcome.sidecar_path.unwrap();
194        assert_eq!(sidecar, out.with_extension("bib.scitadel-bib.lock"));
195        let bib_bytes = std::fs::read_to_string(&out).unwrap();
196        assert!(bib_bytes.contains("On Attention"), "got: {bib_bytes}");
197        let lock_bytes = std::fs::read_to_string(&sidecar).unwrap();
198        let lock: BibLockfile = serde_json::from_str(&lock_bytes).unwrap();
199        assert_eq!(lock.question_id, "q-x");
200        assert_eq!(lock.format, "bibtex");
201    }
202
203    #[test]
204    fn write_snapshot_csl_json_writes_both_files() {
205        let dir = tempfile::tempdir().unwrap();
206        let out = dir.path().join("p.json");
207        let papers = vec![paper("p-1", "On Attention", 2017)];
208        let ids = vec!["p-1".to_string()];
209
210        let outcome = write_snapshot(
211            &out,
212            "q-x",
213            "lars",
214            &papers,
215            &ids,
216            |_| vec![],
217            SnapshotFormat::CslJson,
218            true,
219        )
220        .unwrap();
221
222        let lock: BibLockfile =
223            serde_json::from_str(&std::fs::read_to_string(outcome.sidecar_path.unwrap()).unwrap())
224                .unwrap();
225        assert_eq!(lock.format, "csl-json");
226    }
227
228    #[test]
229    fn write_snapshot_skips_sidecar_when_requested() {
230        let dir = tempfile::tempdir().unwrap();
231        let out = dir.path().join("p.bib");
232        let papers = vec![paper("p-1", "T", 2020)];
233        let ids = vec!["p-1".to_string()];
234
235        let outcome = write_snapshot(
236            &out,
237            "q",
238            "r",
239            &papers,
240            &ids,
241            |_| vec![],
242            SnapshotFormat::BibTeX,
243            false,
244        )
245        .unwrap();
246
247        assert!(outcome.sidecar_path.is_none());
248        assert!(out.exists());
249    }
250}