1use std::fmt::Write as _;
10
11use crate::diff::{BibDiff, ChangedEntry, Entry, FieldChange};
12
13const 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
22struct 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#[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
137fn 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
164pub 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
172fn 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
184fn 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 let s = "αβγδεζηθ";
293 let out = truncate(s, 4);
294 assert!(out.chars().count() <= 4);
295 assert!(out.ends_with('…'));
296 }
297}