facet_diff_core/
display.rs

1//! Display implementations for diff types.
2
3use std::fmt::{Display, Write};
4
5use confusables::Confusable;
6use facet_pretty::{PrettyPrinter, tokyo_night};
7use facet_reflect::Peek;
8use owo_colors::OwoColorize;
9
10use crate::{Diff, ReplaceGroup, Updates, UpdatesGroup, Value};
11
12/// Format text for deletions
13fn deleted(s: &str) -> String {
14    format!("{}", s.color(tokyo_night::DELETION))
15}
16
17/// Format text for insertions
18fn inserted(s: &str) -> String {
19    format!("{}", s.color(tokyo_night::INSERTION))
20}
21
22/// Format muted text (unchanged indicators, structural equality)
23fn muted(s: &str) -> String {
24    format!("{}", s.color(tokyo_night::MUTED))
25}
26
27/// Format field name
28fn field(s: &str) -> String {
29    format!("{}", s.color(tokyo_night::FIELD_NAME))
30}
31
32/// Format punctuation as dimmed
33fn punct(s: &str) -> String {
34    format!("{}", s.color(tokyo_night::COMMENT))
35}
36
37struct PadAdapter<'a, 'b: 'a> {
38    fmt: &'a mut std::fmt::Formatter<'b>,
39    on_newline: bool,
40    indent: &'static str,
41}
42
43impl<'a, 'b> PadAdapter<'a, 'b> {
44    fn new_indented(fmt: &'a mut std::fmt::Formatter<'b>) -> Self {
45        Self {
46            fmt,
47            on_newline: true,
48            indent: "    ",
49        }
50    }
51}
52
53impl<'a, 'b> Write for PadAdapter<'a, 'b> {
54    fn write_str(&mut self, s: &str) -> std::fmt::Result {
55        for line in s.split_inclusive('\n') {
56            if self.on_newline {
57                self.fmt.write_str(self.indent)?;
58            }
59
60            self.on_newline = line.ends_with('\n');
61
62            self.fmt.write_str(line)?;
63        }
64
65        Ok(())
66    }
67
68    fn write_char(&mut self, c: char) -> std::fmt::Result {
69        if self.on_newline {
70            self.fmt.write_str(self.indent)?;
71        }
72
73        self.on_newline = c == '\n';
74        self.fmt.write_char(c)
75    }
76}
77
78/// Simple equality check for display purposes.
79/// This is used when rendering ReplaceGroup to check if values are equal.
80fn peek_eq<'mem, 'facet>(a: Peek<'mem, 'facet>, b: Peek<'mem, 'facet>) -> bool {
81    a.shape().id == b.shape().id && a.shape().is_partial_eq() && a == b
82}
83
84impl<'mem, 'facet> Display for Diff<'mem, 'facet> {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Diff::Equal { value: _ } => {
88                write!(f, "{}", muted("(structurally equal)"))
89            }
90            Diff::Replace { from, to } => {
91                let printer = PrettyPrinter::default()
92                    .with_colors(false)
93                    .with_minimal_option_names(true);
94
95                // Check if both values are strings and visually confusable
96                // Note: is_confusable_with is directional - check both directions
97                if let (Some(from_str), Some(to_str)) = (from.as_str(), to.as_str())
98                    && (from_str.is_confusable_with(to_str) || to_str.is_confusable_with(from_str))
99                {
100                    // Show the strings with character-level diff
101                    // Don't wrap in muted() since the explanation has its own colors
102                    write!(
103                        f,
104                        "{} → {}\n{}",
105                        deleted(&printer.format_peek(*from)),
106                        inserted(&printer.format_peek(*to)),
107                        explain_confusable_differences(from_str, to_str)
108                    )?;
109                    return Ok(());
110                }
111
112                // Show value change inline: old → new
113                write!(
114                    f,
115                    "{} → {}",
116                    deleted(&printer.format_peek(*from)),
117                    inserted(&printer.format_peek(*to))
118                )
119            }
120            Diff::User {
121                from: _,
122                to: _,
123                variant,
124                value,
125            } => {
126                let printer = PrettyPrinter::default()
127                    .with_colors(false)
128                    .with_minimal_option_names(true);
129
130                // Show variant if present (e.g., "Some" for Option::Some)
131                if let Some(variant) = variant {
132                    write!(f, "{}", variant.bold())?;
133                }
134
135                let has_prefix = variant.is_some();
136
137                match value {
138                    Value::Struct {
139                        updates,
140                        deletions,
141                        insertions,
142                        unchanged,
143                    } => {
144                        if updates.is_empty() && deletions.is_empty() && insertions.is_empty() {
145                            return write!(f, "{}", muted("(structurally equal)"));
146                        }
147
148                        if has_prefix {
149                            writeln!(f, " {}", punct("{"))?;
150                        } else {
151                            writeln!(f, "{}", punct("{"))?;
152                        }
153                        let mut indent = PadAdapter::new_indented(f);
154
155                        // Show unchanged fields indicator first
156                        let unchanged_count = unchanged.len();
157                        if unchanged_count > 0 {
158                            let label = if unchanged_count == 1 {
159                                "field"
160                            } else {
161                                "fields"
162                            };
163                            writeln!(
164                                indent,
165                                "{}",
166                                muted(&format!(".. {unchanged_count} unchanged {label}"))
167                            )?;
168                        }
169
170                        // Sort fields for deterministic output
171                        let mut updates: Vec<_> = updates.iter().collect();
172                        updates.sort_by(|(a, _), (b, _)| a.cmp(b));
173                        for (fld, update) in updates {
174                            writeln!(indent, "{}{} {update}", field(fld), punct(":"))?;
175                        }
176
177                        let mut deletions: Vec<_> = deletions.iter().collect();
178                        deletions.sort_by(|(a, _), (b, _)| a.cmp(b));
179                        for (fld, value) in deletions {
180                            writeln!(
181                                indent,
182                                "{} {}{} {}",
183                                deleted("-"),
184                                field(fld),
185                                punct(":"),
186                                deleted(&printer.format_peek(*value))
187                            )?;
188                        }
189
190                        let mut insertions: Vec<_> = insertions.iter().collect();
191                        insertions.sort_by(|(a, _), (b, _)| a.cmp(b));
192                        for (fld, value) in insertions {
193                            writeln!(
194                                indent,
195                                "{} {}{} {}",
196                                inserted("+"),
197                                field(fld),
198                                punct(":"),
199                                inserted(&printer.format_peek(*value))
200                            )?;
201                        }
202
203                        write!(f, "{}", punct("}"))
204                    }
205                    Value::Tuple { updates } => {
206                        // No changes in tuple
207                        if updates.is_empty() {
208                            return write!(f, "{}", muted("(structurally equal)"));
209                        }
210                        // For single-element tuples (like Option::Some), try to be concise
211                        if updates.is_single_replace() {
212                            if has_prefix {
213                                f.write_str(" ")?;
214                            }
215                            write!(f, "{updates}")
216                        } else {
217                            f.write_str(if has_prefix { " (\n" } else { "(\n" })?;
218                            let mut indent = PadAdapter::new_indented(f);
219                            write!(indent, "{updates}")?;
220                            f.write_str(")")
221                        }
222                    }
223                }
224            }
225            Diff::Sequence {
226                from: _,
227                to: _,
228                updates,
229            } => {
230                if updates.is_empty() {
231                    write!(f, "{}", muted("(structurally equal)"))
232                } else {
233                    writeln!(f, "{}", punct("["))?;
234                    let mut indent = PadAdapter::new_indented(f);
235                    write!(indent, "{updates}")?;
236                    write!(f, "{}", punct("]"))
237                }
238            }
239        }
240    }
241}
242
243impl<'mem, 'facet> Updates<'mem, 'facet> {
244    /// Check if this is a single replace operation (useful for Option::Some)
245    pub fn is_single_replace(&self) -> bool {
246        self.0.first.is_some() && self.0.values.is_empty() && self.0.last.is_none()
247    }
248
249    /// Check if there are no changes (everything is unchanged)
250    pub fn is_empty(&self) -> bool {
251        self.0.first.is_none() && self.0.values.is_empty()
252    }
253}
254
255impl<'mem, 'facet> Display for Updates<'mem, 'facet> {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        if let Some(update) = &self.0.first {
258            update.fmt(f)?;
259        }
260
261        for (values, update) in &self.0.values {
262            // Collapse kept values into ".. N unchanged items"
263            let count = values.len();
264            if count > 0 {
265                let label = if count == 1 { "item" } else { "items" };
266                writeln!(f, "{}", muted(&format!(".. {count} unchanged {label}")))?;
267            }
268            update.fmt(f)?;
269        }
270
271        if let Some(values) = &self.0.last {
272            // Collapse trailing kept values
273            let count = values.len();
274            if count > 0 {
275                let label = if count == 1 { "item" } else { "items" };
276                writeln!(f, "{}", muted(&format!(".. {count} unchanged {label}")))?;
277            }
278        }
279
280        Ok(())
281    }
282}
283
284impl<'mem, 'facet> Display for ReplaceGroup<'mem, 'facet> {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        let printer = PrettyPrinter::default()
287            .with_colors(false)
288            .with_minimal_option_names(true);
289
290        // If it's a 1-to-1 replacement, check for equality
291        if self.removals.len() == 1 && self.additions.len() == 1 {
292            let from = self.removals[0];
293            let to = self.additions[0];
294
295            if peek_eq(from, to) {
296                // Values are equal, show muted
297                return writeln!(f, "{}", muted(&printer.format_peek(from)));
298            }
299
300            // Show value change inline: old → new
301            return writeln!(
302                f,
303                "{} → {}",
304                deleted(&printer.format_peek(from)),
305                inserted(&printer.format_peek(to))
306            );
307        }
308
309        // Otherwise show as - / + lines with consistent indentation
310        for remove in &self.removals {
311            writeln!(
312                f,
313                "{}",
314                deleted(&format!("- {}", printer.format_peek(*remove)))
315            )?;
316        }
317
318        for add in &self.additions {
319            writeln!(
320                f,
321                "{}",
322                inserted(&format!("+ {}", printer.format_peek(*add)))
323            )?;
324        }
325
326        Ok(())
327    }
328}
329
330/// Write a sequence of diffs, collapsing Equal diffs into ".. N unchanged items"
331fn write_diff_sequence(
332    f: &mut std::fmt::Formatter<'_>,
333    diffs: &[Diff<'_, '_>],
334) -> std::fmt::Result {
335    let mut i = 0;
336    while i < diffs.len() {
337        // Count consecutive Equal diffs
338        let mut equal_count = 0;
339        while i + equal_count < diffs.len() {
340            if matches!(diffs[i + equal_count], Diff::Equal { .. }) {
341                equal_count += 1;
342            } else {
343                break;
344            }
345        }
346
347        if equal_count > 0 {
348            // Collapse Equal diffs
349            let label = if equal_count == 1 { "item" } else { "items" };
350            writeln!(
351                f,
352                "{}",
353                muted(&format!(".. {equal_count} unchanged {label}"))
354            )?;
355            i += equal_count;
356        } else {
357            // Show the non-Equal diff
358            writeln!(f, "{}", diffs[i])?;
359            i += 1;
360        }
361    }
362    Ok(())
363}
364
365impl<'mem, 'facet> Display for UpdatesGroup<'mem, 'facet> {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        if let Some(update) = &self.0.first {
368            update.fmt(f)?;
369        }
370
371        for (values, update) in &self.0.values {
372            write_diff_sequence(f, values)?;
373            update.fmt(f)?;
374        }
375
376        if let Some(values) = &self.0.last {
377            write_diff_sequence(f, values)?;
378        }
379
380        Ok(())
381    }
382}
383
384/// Format a character for display with its Unicode codepoint.
385fn format_char_with_codepoint(c: char) -> String {
386    // For printable ASCII characters (except space), show the character directly
387    if c.is_ascii_graphic() {
388        format!("'{}' (U+{:04X})", c, c as u32)
389    } else {
390        // For non-printable chars, show the escaped form (codepoint is visible in the escape)
391        format!("'\\u{{{:04X}}}'", c as u32)
392    }
393}
394
395/// Explain the confusable differences between two strings that look identical.
396/// Shows character-level differences with full Unicode codepoints.
397fn explain_confusable_differences(left: &str, right: &str) -> String {
398    use std::fmt::Write;
399
400    // Find character-level differences
401    let left_chars: Vec<char> = left.chars().collect();
402    let right_chars: Vec<char> = right.chars().collect();
403
404    let mut out = String::new();
405
406    // Find all positions where characters differ
407    let mut diffs: Vec<(usize, char, char)> = Vec::new();
408
409    let max_len = left_chars.len().max(right_chars.len());
410    for i in 0..max_len {
411        let lc = left_chars.get(i);
412        let rc = right_chars.get(i);
413
414        match (lc, rc) {
415            (Some(&l), Some(&r)) if l != r => {
416                diffs.push((i, l, r));
417            }
418            (Some(&l), None) => {
419                // Character only in left (will show as deletion)
420                diffs.push((i, l, '\0'));
421            }
422            (None, Some(&r)) => {
423                // Character only in right (will show as insertion)
424                diffs.push((i, '\0', r));
425            }
426            _ => {}
427        }
428    }
429
430    if diffs.is_empty() {
431        return muted("(strings are identical)");
432    }
433
434    writeln!(
435        out,
436        "{}",
437        muted(&format!(
438            "(strings are visually confusable but differ in {} position{}):",
439            diffs.len(),
440            if diffs.len() == 1 { "" } else { "s" }
441        ))
442    )
443    .unwrap();
444
445    for (pos, lc, rc) in &diffs {
446        if *lc == '\0' {
447            writeln!(
448                out,
449                "  [{}]: (missing) vs {}",
450                pos,
451                inserted(&format_char_with_codepoint(*rc))
452            )
453            .unwrap();
454        } else if *rc == '\0' {
455            writeln!(
456                out,
457                "  [{}]: {} vs (missing)",
458                pos,
459                deleted(&format_char_with_codepoint(*lc))
460            )
461            .unwrap();
462        } else {
463            writeln!(
464                out,
465                "  [{}]: {} vs {}",
466                pos,
467                deleted(&format_char_with_codepoint(*lc)),
468                inserted(&format_char_with_codepoint(*rc))
469            )
470            .unwrap();
471        }
472    }
473
474    out.trim_end().to_string()
475}