Skip to main content

scitadel_export/
diff_format.rs

1//! Renderers for [`crate::diff::BibDiff`]: hand-rolled ANSI text and
2//! structured JSON (#135 sub-feature C).
3//!
4//! Hand-rolled ANSI deliberately — adding a color crate for three
5//! escape codes (green / red / yellow) would just be dependency churn.
6//! Color is gated by the caller (TTY detection lives at the CLI layer
7//! so we keep this module pure).
8
9use std::fmt::Write as _;
10
11use crate::diff::{BibDiff, ChangedEntry, Entry, FieldChange};
12
13/// ANSI escape codes used by the text renderer. Matched per-call to
14/// the user's color preference — when `use_color=false` the renderer
15/// emits empty strings and the diff is plain text.
16const GREEN: &str = "\x1b[32m";
17const RED: &str = "\x1b[31m";
18const YELLOW: &str = "\x1b[33m";
19const BOLD: &str = "\x1b[1m";
20const RESET: &str = "\x1b[0m";
21
22/// Bundle of ANSI codes the text renderer needs. Resolved once at the
23/// top of [`render_text`] from `use_color`; sub-helpers take a
24/// reference so we don't shuttle five `&str`s through every signature.
25struct Palette {
26    add: &'static str,
27    remove: &'static str,
28    change: &'static str,
29    bold: &'static str,
30    reset: &'static str,
31}
32
33impl Palette {
34    fn new(use_color: bool) -> Self {
35        if use_color {
36            Self {
37                add: GREEN,
38                remove: RED,
39                change: YELLOW,
40                bold: BOLD,
41                reset: RESET,
42            }
43        } else {
44            Self {
45                add: "",
46                remove: "",
47                change: "",
48                bold: "",
49                reset: "",
50            }
51        }
52    }
53}
54
55/// Render a `BibDiff` as a human-readable text report. `header_a` /
56/// `header_b` are free-form labels (e.g. file paths or "question
57/// abc123") that go in the "=== bib diff: <a> vs <b> ===" line.
58#[must_use]
59pub fn render_text(diff: &BibDiff, header_a: &str, header_b: &str, use_color: bool) -> String {
60    let mut out = String::new();
61    let p = Palette::new(use_color);
62    let bold = p.bold;
63    let reset = p.reset;
64
65    let _ = writeln!(
66        out,
67        "{bold}=== bib diff: {header_a} vs {header_b} ==={reset}"
68    );
69    if diff.is_empty() {
70        out.push_str("\nNo differences.\n");
71        return out;
72    }
73
74    let _ = writeln!(out, "\n{bold}ADDED ({}):{reset}", diff.added.len());
75    if diff.added.is_empty() {
76        out.push_str("  (none)\n");
77    } else {
78        for e in &diff.added {
79            let _ = writeln!(out, "  {}+ {}{}", p.add, entry_summary(e), p.reset);
80        }
81    }
82
83    let _ = writeln!(out, "\n{bold}REMOVED ({}):{reset}", diff.removed.len());
84    if diff.removed.is_empty() {
85        out.push_str("  (none)\n");
86    } else {
87        for e in &diff.removed {
88            let _ = writeln!(out, "  {}- {}{}", p.remove, entry_summary(e), p.reset);
89        }
90    }
91
92    let _ = writeln!(out, "\n{bold}CHANGED ({}):{reset}", diff.changed.len());
93    if diff.changed.is_empty() {
94        out.push_str("  (none)\n");
95    } else {
96        for ce in &diff.changed {
97            render_changed(&mut out, ce, &p);
98        }
99    }
100    out
101}
102
103fn render_changed(out: &mut String, ce: &ChangedEntry, p: &Palette) {
104    if let Some(prev) = &ce.before_citekey {
105        let _ = writeln!(
106            out,
107            "  {}~ {} (was {prev}){}",
108            p.change, ce.citekey, p.reset
109        );
110    } else {
111        let _ = writeln!(out, "  {}~ {}{}", p.change, ce.citekey, p.reset);
112    }
113    let widest = ce
114        .field_changes
115        .iter()
116        .map(|fc| fc.field.len())
117        .max()
118        .unwrap_or(0);
119    for fc in &ce.field_changes {
120        render_field_change(out, fc, widest, p);
121    }
122}
123
124fn render_field_change(out: &mut String, fc: &FieldChange, widest: usize, p: &Palette) {
125    let pad = " ".repeat(widest.saturating_sub(fc.field.len()));
126    let _ = writeln!(
127        out,
128        "    {}{}{}{pad}: {} → {}",
129        p.change,
130        fc.field,
131        p.reset,
132        elide(fc.before.as_deref()),
133        elide(fc.after.as_deref()),
134    );
135}
136
137/// One-line summary like
138/// `smith2024quantum  Smith, J. (2024) "Quantum X" — DOI:10.1234/...`.
139/// Optional fields are gracefully omitted.
140fn entry_summary(e: &Entry) -> String {
141    let mut s = String::new();
142    s.push_str(&e.citekey);
143    s.push_str("  ");
144    if let Some(first_author) = e.authors.first() {
145        s.push_str(first_author);
146    }
147    if let Some(y) = e.year {
148        if !s.ends_with("  ") {
149            s.push(' ');
150        }
151        let _ = write!(s, "({y})");
152    }
153    if let Some(t) = &e.title {
154        let _ = write!(s, " \"{}\"", truncate(t, 80));
155    }
156    if let Some(d) = &e.doi {
157        let _ = write!(s, " — DOI:{d}");
158    } else if let Some(a) = &e.arxiv_id {
159        let _ = write!(s, " — arXiv:{a}");
160    }
161    s
162}
163
164/// Render a `BibDiff` as structured JSON. Just `serde_json::to_string_pretty`
165/// — kept here so the CLI / MCP have one canonical entry point.
166pub fn render_json(diff: &BibDiff) -> Result<String, serde_json::Error> {
167    let mut s = serde_json::to_string_pretty(diff)?;
168    s.push('\n');
169    Ok(s)
170}
171
172/// Truncate a string to `max` chars (NOT bytes — Unicode-safe). Adds
173/// an ellipsis if truncation actually happened.
174fn truncate(s: &str, max: usize) -> String {
175    let count = s.chars().count();
176    if count <= max {
177        s.to_string()
178    } else {
179        let kept: String = s.chars().take(max.saturating_sub(1)).collect();
180        format!("{kept}…")
181    }
182}
183
184/// Render `Option<&str>` for the before/after columns. `None` prints
185/// as `<none>` so the user can distinguish "field absent" from
186/// "field is empty string".
187fn elide(s: Option<&str>) -> String {
188    match s {
189        None => "<none>".to_string(),
190        Some("") => "<empty>".to_string(),
191        Some(v) => truncate(v, 80),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::diff::{ChangedEntry, FieldChange};
199
200    fn full_entry(k: &str, t: &str) -> Entry {
201        Entry {
202            citekey: k.into(),
203            title: Some(t.into()),
204            year: Some(2024),
205            authors: vec!["Smith, J.".into()],
206            doi: Some("10.1/abc".into()),
207            ..Default::default()
208        }
209    }
210
211    fn sample_diff() -> BibDiff {
212        BibDiff {
213            added: vec![full_entry("a-add", "Added")],
214            removed: vec![full_entry("a-rem", "Removed")],
215            changed: vec![ChangedEntry {
216                citekey: "k".into(),
217                before_citekey: None,
218                field_changes: vec![FieldChange {
219                    field: "title".into(),
220                    before: Some("Old".into()),
221                    after: Some("New".into()),
222                }],
223            }],
224        }
225    }
226
227    #[test]
228    fn text_no_color_has_no_ansi_escapes() {
229        let out = render_text(&sample_diff(), "A", "B", false);
230        assert!(!out.contains('\x1b'), "ANSI must not appear: {out:?}");
231    }
232
233    #[test]
234    fn text_with_color_has_ansi_escapes() {
235        let out = render_text(&sample_diff(), "A", "B", true);
236        assert!(out.contains("\x1b["), "ANSI required when use_color=true");
237        assert!(out.contains(GREEN), "added uses green");
238        assert!(out.contains(RED), "removed uses red");
239        assert!(out.contains(YELLOW), "changed/field uses yellow");
240        assert!(out.contains(RESET), "must reset color");
241    }
242
243    #[test]
244    fn text_renders_section_counts() {
245        let out = render_text(&sample_diff(), "A", "B", false);
246        assert!(out.contains("ADDED (1):"));
247        assert!(out.contains("REMOVED (1):"));
248        assert!(out.contains("CHANGED (1):"));
249    }
250
251    #[test]
252    fn text_renders_field_change_arrow() {
253        let out = render_text(&sample_diff(), "A", "B", false);
254        assert!(out.contains("title"), "field name present");
255        assert!(out.contains("Old → New"), "before → after rendered");
256    }
257
258    #[test]
259    fn text_renders_no_diff_message_when_empty() {
260        let d = BibDiff::default();
261        let out = render_text(&d, "A", "B", false);
262        assert!(out.contains("No differences"));
263    }
264
265    #[test]
266    fn text_renders_renamed_citekey() {
267        let mut d = sample_diff();
268        d.changed[0].before_citekey = Some("old-key".into());
269        let out = render_text(&d, "A", "B", false);
270        assert!(out.contains("(was old-key)"), "rename hint must appear");
271    }
272
273    #[test]
274    fn json_round_trip() {
275        let d = sample_diff();
276        let json = render_json(&d).unwrap();
277        let back: BibDiff = serde_json::from_str(&json).unwrap();
278        assert_eq!(d, back);
279    }
280
281    #[test]
282    fn elide_distinguishes_none_and_empty() {
283        assert_eq!(elide(None), "<none>");
284        assert_eq!(elide(Some("")), "<empty>");
285        assert_eq!(elide(Some("hello")), "hello");
286    }
287
288    #[test]
289    fn truncate_unicode_safe() {
290        // Greek alpha is one char but two bytes — make sure we don't
291        // panic on a byte-boundary slice.
292        let s = "αβγδεζηθ";
293        let out = truncate(s, 4);
294        assert!(out.chars().count() <= 4);
295        assert!(out.ends_with('…'));
296    }
297}