facet_diff/
diff.rs

1// TODO: Consider using an approach similar to `morph` (bearcove's fork of difftastic)
2// to compute and display the optimal diff path for complex structural changes.
3
4use std::borrow::Cow;
5use std::collections::{HashMap, HashSet};
6
7use facet::{Def, DynValueKind, StructKind, Type, UserType};
8use facet_core::Facet;
9use facet_diff_core::{Diff, Path, PathSegment, Updates, Value};
10use facet_reflect::{HasFields, Peek, ScalarType};
11
12use crate::sequences;
13
14/// Configuration options for diff computation
15#[derive(Debug, Clone, Default)]
16pub struct DiffOptions {
17    /// Tolerance for floating-point comparisons.
18    /// If set, two floats are considered equal if their absolute difference
19    /// is less than or equal to this value.
20    pub float_tolerance: Option<f64>,
21}
22
23impl DiffOptions {
24    /// Create a new `DiffOptions` with default settings.
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Set the tolerance for floating-point comparisons.
30    pub fn with_float_tolerance(mut self, tolerance: f64) -> Self {
31        self.float_tolerance = Some(tolerance);
32        self
33    }
34}
35
36/// Extension trait that provides a [`diff`](FacetDiff::diff) method for `Facet` types
37pub trait FacetDiff<'f>: Facet<'f> {
38    /// Computes the difference between two values that implement `Facet`
39    fn diff<'a, U: Facet<'f>>(&'a self, other: &'a U) -> Diff<'a, 'f>;
40}
41
42impl<'f, T: Facet<'f>> FacetDiff<'f> for T {
43    fn diff<'a, U: Facet<'f>>(&'a self, other: &'a U) -> Diff<'a, 'f> {
44        diff_new(self, other)
45    }
46}
47
48/// Computes the difference between two values that implement `Facet`
49pub fn diff_new<'mem, 'facet, T: Facet<'facet>, U: Facet<'facet>>(
50    from: &'mem T,
51    to: &'mem U,
52) -> Diff<'mem, 'facet> {
53    diff_new_peek(Peek::new(from), Peek::new(to))
54}
55
56/// Computes the difference between two `Peek` values with options
57pub fn diff_new_peek_with_options<'mem, 'facet>(
58    from: Peek<'mem, 'facet>,
59    to: Peek<'mem, 'facet>,
60    options: &DiffOptions,
61) -> Diff<'mem, 'facet> {
62    // Dereference pointers/references to compare the underlying values
63    let from = deref_if_pointer(from);
64    let to = deref_if_pointer(to);
65
66    // Check for equality if both shapes have the same type_identifier and implement PartialEq
67    // This handles cases where shapes are structurally equivalent but have different IDs
68    // (e.g., after deserialization)
69    let same_type = from.shape().type_identifier == to.shape().type_identifier;
70    let from_has_partialeq = from.shape().is_partial_eq();
71    let to_has_partialeq = to.shape().is_partial_eq();
72    let values_equal = from == to;
73
74    // Check float tolerance if configured
75    let float_equal = options
76        .float_tolerance
77        .map(|tol| check_float_tolerance(from, to, tol))
78        .unwrap_or(false);
79
80    // log::trace!(
81    //     "diff_new_peek: type={} same_type={} from_has_partialeq={} to_has_partialeq={} values_equal={}",
82    //     from.shape().type_identifier,
83    //     same_type,
84    //     from_has_partialeq,
85    //     to_has_partialeq,
86    //     values_equal
87    // );
88
89    if same_type && from_has_partialeq && to_has_partialeq && (values_equal || float_equal) {
90        return Diff::Equal { value: Some(from) };
91    }
92
93    match (
94        (from.shape().def, from.shape().ty),
95        (to.shape().def, to.shape().ty),
96    ) {
97        ((_, Type::User(UserType::Struct(from_ty))), (_, Type::User(UserType::Struct(to_ty))))
98            if from_ty.kind == to_ty.kind =>
99        {
100            let from_ty = from.into_struct().unwrap();
101            let to_ty = to.into_struct().unwrap();
102
103            let value = if [StructKind::Tuple, StructKind::TupleStruct].contains(&from_ty.ty().kind)
104            {
105                let from = from_ty.fields().map(|x| x.1).collect();
106                let to = to_ty.fields().map(|x| x.1).collect();
107
108                let updates = sequences::diff_with_options(from, to, options);
109
110                Value::Tuple { updates }
111            } else {
112                let mut updates = HashMap::new();
113                let mut deletions = HashMap::new();
114                let mut insertions = HashMap::new();
115                let mut unchanged = HashSet::new();
116
117                for (field, from) in from_ty.fields() {
118                    if let Ok(to) = to_ty.field_by_name(field.name) {
119                        let diff = diff_new_peek_with_options(from, to, options);
120                        if diff.is_equal() {
121                            unchanged.insert(Cow::Borrowed(field.name));
122                        } else {
123                            updates.insert(Cow::Borrowed(field.name), diff);
124                        }
125                    } else {
126                        deletions.insert(Cow::Borrowed(field.name), from);
127                    }
128                }
129
130                for (field, to) in to_ty.fields() {
131                    if from_ty.field_by_name(field.name).is_err() {
132                        insertions.insert(Cow::Borrowed(field.name), to);
133                    }
134                }
135                Value::Struct {
136                    updates,
137                    deletions,
138                    insertions,
139                    unchanged,
140                }
141            };
142
143            // If there are no changes, return Equal instead of User
144            let is_empty = match &value {
145                Value::Tuple { updates } => updates.is_empty(),
146                Value::Struct {
147                    updates,
148                    deletions,
149                    insertions,
150                    ..
151                } => updates.is_empty() && deletions.is_empty() && insertions.is_empty(),
152            };
153            if is_empty {
154                return Diff::Equal { value: Some(from) };
155            }
156
157            Diff::User {
158                from: from.shape(),
159                to: to.shape(),
160                variant: None,
161                value,
162            }
163        }
164        ((_, Type::User(UserType::Enum(_))), (_, Type::User(UserType::Enum(_)))) => {
165            let from_enum = from.into_enum().unwrap();
166            let to_enum = to.into_enum().unwrap();
167
168            let from_variant = from_enum.active_variant().unwrap();
169            let to_variant = to_enum.active_variant().unwrap();
170
171            if from_variant.name != to_variant.name
172                || from_variant.data.kind != to_variant.data.kind
173            {
174                return Diff::Replace { from, to };
175            }
176
177            let value =
178                if [StructKind::Tuple, StructKind::TupleStruct].contains(&from_variant.data.kind) {
179                    let from = from_enum.fields().map(|x| x.1).collect();
180                    let to = to_enum.fields().map(|x| x.1).collect();
181
182                    let updates = sequences::diff_with_options(from, to, options);
183
184                    Value::Tuple { updates }
185                } else {
186                    let mut updates = HashMap::new();
187                    let mut deletions = HashMap::new();
188                    let mut insertions = HashMap::new();
189                    let mut unchanged = HashSet::new();
190
191                    for (field, from) in from_enum.fields() {
192                        if let Ok(Some(to)) = to_enum.field_by_name(field.name) {
193                            let diff = diff_new_peek_with_options(from, to, options);
194                            if diff.is_equal() {
195                                unchanged.insert(Cow::Borrowed(field.name));
196                            } else {
197                                updates.insert(Cow::Borrowed(field.name), diff);
198                            }
199                        } else {
200                            deletions.insert(Cow::Borrowed(field.name), from);
201                        }
202                    }
203
204                    for (field, to) in to_enum.fields() {
205                        if !from_enum
206                            .field_by_name(field.name)
207                            .is_ok_and(|x| x.is_some())
208                        {
209                            insertions.insert(Cow::Borrowed(field.name), to);
210                        }
211                    }
212
213                    Value::Struct {
214                        updates,
215                        deletions,
216                        insertions,
217                        unchanged,
218                    }
219                };
220
221            // If there are no changes, return Equal instead of User
222            let is_empty = match &value {
223                Value::Tuple { updates } => updates.is_empty(),
224                Value::Struct {
225                    updates,
226                    deletions,
227                    insertions,
228                    ..
229                } => updates.is_empty() && deletions.is_empty() && insertions.is_empty(),
230            };
231            if is_empty {
232                return Diff::Equal { value: Some(from) };
233            }
234
235            Diff::User {
236                from: from_enum.shape(),
237                to: to_enum.shape(),
238                variant: Some(from_variant.name),
239                value,
240            }
241        }
242        ((Def::Option(_), _), (Def::Option(_), _)) => {
243            let from_option = from.into_option().unwrap();
244            let to_option = to.into_option().unwrap();
245
246            let (Some(from_value), Some(to_value)) = (from_option.value(), to_option.value())
247            else {
248                return Diff::Replace { from, to };
249            };
250
251            // Use sequences::diff to properly handle nested diffs
252            let updates = sequences::diff_with_options(vec![from_value], vec![to_value], options);
253
254            if updates.is_empty() {
255                return Diff::Equal { value: Some(from) };
256            }
257
258            Diff::User {
259                from: from.shape(),
260                to: to.shape(),
261                variant: Some("Some"),
262                value: Value::Tuple { updates },
263            }
264        }
265        (
266            (Def::List(_) | Def::Slice(_), _) | (_, Type::Sequence(_)),
267            (Def::List(_) | Def::Slice(_), _) | (_, Type::Sequence(_)),
268        ) => {
269            let from_list = from.into_list_like().unwrap();
270            let to_list = to.into_list_like().unwrap();
271
272            let updates = sequences::diff_with_options(
273                from_list.iter().collect::<Vec<_>>(),
274                to_list.iter().collect::<Vec<_>>(),
275                options,
276            );
277
278            if updates.is_empty() {
279                return Diff::Equal { value: Some(from) };
280            }
281
282            Diff::Sequence {
283                from: from.shape(),
284                to: to.shape(),
285                updates,
286            }
287        }
288        ((Def::DynamicValue(_), _), (Def::DynamicValue(_), _)) => {
289            diff_dynamic_values(from, to, options)
290        }
291        // DynamicValue vs concrete type
292        ((Def::DynamicValue(_), _), _) => diff_dynamic_vs_concrete(from, to, false, options),
293        (_, (Def::DynamicValue(_), _)) => diff_dynamic_vs_concrete(to, from, true, options),
294        _ => Diff::Replace { from, to },
295    }
296}
297
298/// Computes the difference between two `Peek` values (backward compatibility wrapper)
299pub fn diff_new_peek<'mem, 'facet>(
300    from: Peek<'mem, 'facet>,
301    to: Peek<'mem, 'facet>,
302) -> Diff<'mem, 'facet> {
303    diff_new_peek_with_options(from, to, &DiffOptions::default())
304}
305
306/// Diff two dynamic values (like `facet_value::Value`)
307fn diff_dynamic_values<'mem, 'facet>(
308    from: Peek<'mem, 'facet>,
309    to: Peek<'mem, 'facet>,
310    options: &DiffOptions,
311) -> Diff<'mem, 'facet> {
312    let from_dyn = from.into_dynamic_value().unwrap();
313    let to_dyn = to.into_dynamic_value().unwrap();
314
315    let from_kind = from_dyn.kind();
316    let to_kind = to_dyn.kind();
317
318    // If kinds differ, just return Replace
319    if from_kind != to_kind {
320        return Diff::Replace { from, to };
321    }
322
323    match from_kind {
324        DynValueKind::Null => Diff::Equal { value: Some(from) },
325        DynValueKind::Bool => {
326            if from_dyn.as_bool() == to_dyn.as_bool() {
327                Diff::Equal { value: Some(from) }
328            } else {
329                Diff::Replace { from, to }
330            }
331        }
332        DynValueKind::Number => {
333            // Compare numbers - try exact integer comparison first, then float
334            let same = match (from_dyn.as_i64(), to_dyn.as_i64()) {
335                (Some(l), Some(r)) => l == r,
336                _ => match (from_dyn.as_u64(), to_dyn.as_u64()) {
337                    (Some(l), Some(r)) => l == r,
338                    _ => match (from_dyn.as_f64(), to_dyn.as_f64()) {
339                        (Some(l), Some(r)) => l == r,
340                        _ => false,
341                    },
342                },
343            };
344            if same {
345                Diff::Equal { value: Some(from) }
346            } else {
347                Diff::Replace { from, to }
348            }
349        }
350        DynValueKind::String => {
351            if from_dyn.as_str() == to_dyn.as_str() {
352                Diff::Equal { value: Some(from) }
353            } else {
354                Diff::Replace { from, to }
355            }
356        }
357        DynValueKind::Bytes => {
358            if from_dyn.as_bytes() == to_dyn.as_bytes() {
359                Diff::Equal { value: Some(from) }
360            } else {
361                Diff::Replace { from, to }
362            }
363        }
364        DynValueKind::Array => {
365            // Use the sequence diff algorithm for arrays
366            let from_iter = from_dyn.array_iter();
367            let to_iter = to_dyn.array_iter();
368
369            let from_elems: Vec<_> = from_iter.map(|i| i.collect()).unwrap_or_default();
370            let to_elems: Vec<_> = to_iter.map(|i| i.collect()).unwrap_or_default();
371
372            let updates = sequences::diff_with_options(from_elems, to_elems, options);
373
374            if updates.is_empty() {
375                return Diff::Equal { value: Some(from) };
376            }
377
378            Diff::Sequence {
379                from: from.shape(),
380                to: to.shape(),
381                updates,
382            }
383        }
384        DynValueKind::Object => {
385            // Treat objects like struct diffs
386            let from_len = from_dyn.object_len().unwrap_or(0);
387            let to_len = to_dyn.object_len().unwrap_or(0);
388
389            let mut updates = HashMap::new();
390            let mut deletions = HashMap::new();
391            let mut insertions = HashMap::new();
392            let mut unchanged = HashSet::new();
393
394            // Collect keys from `from`
395            let mut from_keys: HashMap<String, Peek<'mem, 'facet>> = HashMap::new();
396            for i in 0..from_len {
397                if let Some((key, value)) = from_dyn.object_get_entry(i) {
398                    from_keys.insert(key.to_owned(), value);
399                }
400            }
401
402            // Collect keys from `to`
403            let mut to_keys: HashMap<String, Peek<'mem, 'facet>> = HashMap::new();
404            for i in 0..to_len {
405                if let Some((key, value)) = to_dyn.object_get_entry(i) {
406                    to_keys.insert(key.to_owned(), value);
407                }
408            }
409
410            // Compare entries
411            for (key, from_value) in &from_keys {
412                if let Some(to_value) = to_keys.get(key) {
413                    let diff = diff_new_peek_with_options(*from_value, *to_value, options);
414                    if diff.is_equal() {
415                        unchanged.insert(Cow::Owned(key.clone()));
416                    } else {
417                        updates.insert(Cow::Owned(key.clone()), diff);
418                    }
419                } else {
420                    deletions.insert(Cow::Owned(key.clone()), *from_value);
421                }
422            }
423
424            for (key, to_value) in &to_keys {
425                if !from_keys.contains_key(key) {
426                    insertions.insert(Cow::Owned(key.clone()), *to_value);
427                }
428            }
429
430            let is_empty = updates.is_empty() && deletions.is_empty() && insertions.is_empty();
431            if is_empty {
432                return Diff::Equal { value: Some(from) };
433            }
434
435            Diff::User {
436                from: from.shape(),
437                to: to.shape(),
438                variant: None,
439                value: Value::Struct {
440                    updates,
441                    deletions,
442                    insertions,
443                    unchanged,
444                },
445            }
446        }
447        DynValueKind::DateTime => {
448            // Compare datetime by their components
449            if from_dyn.as_datetime() == to_dyn.as_datetime() {
450                Diff::Equal { value: Some(from) }
451            } else {
452                Diff::Replace { from, to }
453            }
454        }
455        DynValueKind::QName | DynValueKind::Uuid => {
456            // For QName and Uuid, compare by their raw representation
457            // Since they have the same kind, we can only compare by Replace semantics
458            Diff::Replace { from, to }
459        }
460    }
461}
462
463/// Diff a DynamicValue against a concrete type
464/// `dyn_peek` is the DynamicValue, `concrete_peek` is the concrete type
465/// `swapped` indicates if the original from/to were swapped (true means dyn_peek is actually "to")
466fn diff_dynamic_vs_concrete<'mem, 'facet>(
467    dyn_peek: Peek<'mem, 'facet>,
468    concrete_peek: Peek<'mem, 'facet>,
469    swapped: bool,
470    options: &DiffOptions,
471) -> Diff<'mem, 'facet> {
472    // Determine actual from/to based on swapped flag
473    let (from_peek, to_peek) = if swapped {
474        (concrete_peek, dyn_peek)
475    } else {
476        (dyn_peek, concrete_peek)
477    };
478    let dyn_val = dyn_peek.into_dynamic_value().unwrap();
479    let dyn_kind = dyn_val.kind();
480
481    // Try to match based on the DynamicValue's kind
482    match dyn_kind {
483        DynValueKind::Bool => {
484            if concrete_peek
485                .get::<bool>()
486                .ok()
487                .is_some_and(|&v| dyn_val.as_bool() == Some(v))
488            {
489                return Diff::Equal {
490                    value: Some(from_peek),
491                };
492            }
493        }
494        DynValueKind::Number => {
495            let is_equal =
496                // Try signed integers
497                concrete_peek.get::<i8>().ok().is_some_and(|&v| dyn_val.as_i64() == Some(v as i64))
498                || concrete_peek.get::<i16>().ok().is_some_and(|&v| dyn_val.as_i64() == Some(v as i64))
499                || concrete_peek.get::<i32>().ok().is_some_and(|&v| dyn_val.as_i64() == Some(v as i64))
500                || concrete_peek.get::<i64>().ok().is_some_and(|&v| dyn_val.as_i64() == Some(v))
501                || concrete_peek.get::<isize>().ok().is_some_and(|&v| dyn_val.as_i64() == Some(v as i64))
502                // Try unsigned integers
503                || concrete_peek.get::<u8>().ok().is_some_and(|&v| dyn_val.as_u64() == Some(v as u64))
504                || concrete_peek.get::<u16>().ok().is_some_and(|&v| dyn_val.as_u64() == Some(v as u64))
505                || concrete_peek.get::<u32>().ok().is_some_and(|&v| dyn_val.as_u64() == Some(v as u64))
506                || concrete_peek.get::<u64>().ok().is_some_and(|&v| dyn_val.as_u64() == Some(v))
507                || concrete_peek.get::<usize>().ok().is_some_and(|&v| dyn_val.as_u64() == Some(v as u64))
508                // Try floats
509                || concrete_peek.get::<f32>().ok().is_some_and(|&v| dyn_val.as_f64() == Some(v as f64))
510                || concrete_peek.get::<f64>().ok().is_some_and(|&v| dyn_val.as_f64() == Some(v));
511            if is_equal {
512                return Diff::Equal {
513                    value: Some(from_peek),
514                };
515            }
516        }
517        DynValueKind::String => {
518            if concrete_peek
519                .as_str()
520                .is_some_and(|s| dyn_val.as_str() == Some(s))
521            {
522                return Diff::Equal {
523                    value: Some(from_peek),
524                };
525            }
526        }
527        DynValueKind::Array => {
528            // Try to diff as sequences if the concrete type is list-like
529            if let Ok(concrete_list) = concrete_peek.into_list_like() {
530                let dyn_elems: Vec<_> = dyn_val
531                    .array_iter()
532                    .map(|i| i.collect())
533                    .unwrap_or_default();
534                let concrete_elems: Vec<_> = concrete_list.iter().collect();
535
536                // Use correct order based on swapped flag
537                let (from_elems, to_elems) = if swapped {
538                    (concrete_elems, dyn_elems)
539                } else {
540                    (dyn_elems, concrete_elems)
541                };
542                let updates = sequences::diff_with_options(from_elems, to_elems, options);
543
544                if updates.is_empty() {
545                    return Diff::Equal {
546                        value: Some(from_peek),
547                    };
548                }
549
550                return Diff::Sequence {
551                    from: from_peek.shape(),
552                    to: to_peek.shape(),
553                    updates,
554                };
555            }
556        }
557        DynValueKind::Object => {
558            // Try to diff as struct if the concrete type is a struct
559            if let Ok(concrete_struct) = concrete_peek.into_struct() {
560                let dyn_len = dyn_val.object_len().unwrap_or(0);
561
562                let mut updates = HashMap::new();
563                let mut deletions = HashMap::new();
564                let mut insertions = HashMap::new();
565                let mut unchanged = HashSet::new();
566
567                // Collect keys from dynamic object
568                let mut dyn_keys: HashMap<String, Peek<'mem, 'facet>> = HashMap::new();
569                for i in 0..dyn_len {
570                    if let Some((key, value)) = dyn_val.object_get_entry(i) {
571                        dyn_keys.insert(key.to_owned(), value);
572                    }
573                }
574
575                // Compare with concrete struct fields
576                // When swapped, dyn is "to" and concrete is "from", so we need to swap the diff direction
577                for (key, dyn_value) in &dyn_keys {
578                    if let Ok(concrete_value) = concrete_struct.field_by_name(key) {
579                        let diff = if swapped {
580                            diff_new_peek_with_options(concrete_value, *dyn_value, options)
581                        } else {
582                            diff_new_peek_with_options(*dyn_value, concrete_value, options)
583                        };
584                        if diff.is_equal() {
585                            unchanged.insert(Cow::Owned(key.clone()));
586                        } else {
587                            updates.insert(Cow::Owned(key.clone()), diff);
588                        }
589                    } else {
590                        // Field in dyn but not in concrete
591                        // If swapped: dyn is "to", so this is an insertion
592                        // If not swapped: dyn is "from", so this is a deletion
593                        if swapped {
594                            insertions.insert(Cow::Owned(key.clone()), *dyn_value);
595                        } else {
596                            deletions.insert(Cow::Owned(key.clone()), *dyn_value);
597                        }
598                    }
599                }
600
601                for (field, concrete_value) in concrete_struct.fields() {
602                    if !dyn_keys.contains_key(field.name) {
603                        // Field in concrete but not in dyn
604                        // If swapped: concrete is "from", so this is a deletion
605                        // If not swapped: concrete is "to", so this is an insertion
606                        if swapped {
607                            deletions.insert(Cow::Borrowed(field.name), concrete_value);
608                        } else {
609                            insertions.insert(Cow::Borrowed(field.name), concrete_value);
610                        }
611                    }
612                }
613
614                let is_empty = updates.is_empty() && deletions.is_empty() && insertions.is_empty();
615                if is_empty {
616                    return Diff::Equal {
617                        value: Some(from_peek),
618                    };
619                }
620
621                return Diff::User {
622                    from: from_peek.shape(),
623                    to: to_peek.shape(),
624                    variant: None,
625                    value: Value::Struct {
626                        updates,
627                        deletions,
628                        insertions,
629                        unchanged,
630                    },
631                };
632            }
633        }
634        // For other kinds (Null, Bytes, DateTime), fall through to Replace
635        _ => {}
636    }
637
638    Diff::Replace {
639        from: from_peek,
640        to: to_peek,
641    }
642}
643
644/// Extract a float value from a Peek, handling both f32 and f64
645fn try_extract_float(peek: Peek) -> Option<f64> {
646    match peek.scalar_type()? {
647        ScalarType::F64 => Some(*peek.get::<f64>().ok()?),
648        ScalarType::F32 => Some(*peek.get::<f32>().ok()? as f64),
649        _ => None,
650    }
651}
652
653/// Check if two Peek values are equal within the specified float tolerance
654fn check_float_tolerance(from: Peek, to: Peek, tolerance: f64) -> bool {
655    match (try_extract_float(from), try_extract_float(to)) {
656        (Some(f1), Some(f2)) => (f1 - f2).abs() <= tolerance,
657        _ => false,
658    }
659}
660
661/// Dereference a pointer/reference to get the underlying value
662fn deref_if_pointer<'mem, 'facet>(peek: Peek<'mem, 'facet>) -> Peek<'mem, 'facet> {
663    if let Ok(ptr) = peek.into_pointer()
664        && let Some(target) = ptr.borrow_inner()
665    {
666        return deref_if_pointer(target);
667    }
668    peek
669}
670
671/// Collect all leaf-level changes with their paths.
672///
673/// This walks the diff tree recursively and collects every terminal change
674/// (scalar replacements) along with the path to reach them. This is useful
675/// for compact display: if there's only one leaf change deep in a tree,
676/// you can show `path.to.field: old → new` instead of nested structure.
677pub fn collect_leaf_changes<'mem, 'facet>(
678    diff: &Diff<'mem, 'facet>,
679) -> Vec<LeafChange<'mem, 'facet>> {
680    let mut changes = Vec::new();
681    collect_leaf_changes_inner(diff, Path::new(), &mut changes);
682    changes
683}
684
685fn collect_leaf_changes_inner<'mem, 'facet>(
686    diff: &Diff<'mem, 'facet>,
687    path: Path,
688    changes: &mut Vec<LeafChange<'mem, 'facet>>,
689) {
690    match diff {
691        Diff::Equal { .. } => {
692            // No change
693        }
694        Diff::Replace { from, to } => {
695            // This is a leaf change
696            changes.push(LeafChange {
697                path,
698                kind: LeafChangeKind::Replace {
699                    from: *from,
700                    to: *to,
701                },
702            });
703        }
704        Diff::User {
705            value,
706            variant,
707            from,
708            ..
709        } => {
710            // For Option::Some, skip the variant in the path since it's implied
711            // (the value exists, so it's Some)
712            let is_option = matches!(from.def, Def::Option(_));
713
714            let base_path = if let Some(v) = variant {
715                if is_option && *v == "Some" {
716                    path // Skip "::Some" for options
717                } else {
718                    path.with(PathSegment::Variant(Cow::Borrowed(*v)))
719                }
720            } else {
721                path
722            };
723
724            match value {
725                Value::Struct {
726                    updates,
727                    deletions,
728                    insertions,
729                    ..
730                } => {
731                    // Recurse into field updates
732                    for (field, diff) in updates {
733                        let field_path = base_path.with(PathSegment::Field(field.clone()));
734                        collect_leaf_changes_inner(diff, field_path, changes);
735                    }
736                    // Deletions are leaf changes
737                    for (field, peek) in deletions {
738                        let field_path = base_path.with(PathSegment::Field(field.clone()));
739                        changes.push(LeafChange {
740                            path: field_path,
741                            kind: LeafChangeKind::Delete { value: *peek },
742                        });
743                    }
744                    // Insertions are leaf changes
745                    for (field, peek) in insertions {
746                        let field_path = base_path.with(PathSegment::Field(field.clone()));
747                        changes.push(LeafChange {
748                            path: field_path,
749                            kind: LeafChangeKind::Insert { value: *peek },
750                        });
751                    }
752                }
753                Value::Tuple { updates } => {
754                    // For single-element tuples (like Option::Some), skip the index
755                    if is_option {
756                        // Recurse directly without adding [0]
757                        collect_from_updates_for_single_elem(&base_path, updates, changes);
758                    } else {
759                        collect_from_updates(&base_path, updates, changes);
760                    }
761                }
762            }
763        }
764        Diff::Sequence { updates, .. } => {
765            collect_from_updates(&path, updates, changes);
766        }
767    }
768}
769
770/// Special handling for single-element tuples (like Option::Some)
771/// where we want to skip the `[0]` index in the path.
772fn collect_from_updates_for_single_elem<'mem, 'facet>(
773    base_path: &Path,
774    updates: &Updates<'mem, 'facet>,
775    changes: &mut Vec<LeafChange<'mem, 'facet>>,
776) {
777    // For single-element tuples, we expect exactly one change
778    // Just use base_path directly instead of adding [0]
779    if let Some(update_group) = &updates.0.first {
780        // Process the first replace group if present
781        if let Some(replace) = &update_group.0.first
782            && replace.removals.len() == 1
783            && replace.additions.len() == 1
784        {
785            let from = replace.removals[0];
786            let to = replace.additions[0];
787            let nested = diff_new_peek(from, to);
788            if matches!(nested, Diff::Replace { .. }) {
789                changes.push(LeafChange {
790                    path: base_path.clone(),
791                    kind: LeafChangeKind::Replace { from, to },
792                });
793            } else {
794                collect_leaf_changes_inner(&nested, base_path.clone(), changes);
795            }
796            return;
797        }
798        // Handle nested diffs
799        if let Some(diffs) = &update_group.0.last {
800            for diff in diffs {
801                collect_leaf_changes_inner(diff, base_path.clone(), changes);
802            }
803            return;
804        }
805    }
806    // Fallback: use regular handling
807    collect_from_updates(base_path, updates, changes);
808}
809
810fn collect_from_updates<'mem, 'facet>(
811    base_path: &Path,
812    updates: &Updates<'mem, 'facet>,
813    changes: &mut Vec<LeafChange<'mem, 'facet>>,
814) {
815    // Walk through the interspersed structure to collect changes with correct indices
816    let mut index = 0;
817
818    // Process first update group if present
819    if let Some(update_group) = &updates.0.first {
820        collect_from_update_group(base_path, update_group, &mut index, changes);
821    }
822
823    // Process interleaved (unchanged, update) pairs
824    for (unchanged, update_group) in &updates.0.values {
825        index += unchanged.len();
826        collect_from_update_group(base_path, update_group, &mut index, changes);
827    }
828
829    // Trailing unchanged items don't add changes
830}
831
832fn collect_from_update_group<'mem, 'facet>(
833    base_path: &Path,
834    group: &crate::UpdatesGroup<'mem, 'facet>,
835    index: &mut usize,
836    changes: &mut Vec<LeafChange<'mem, 'facet>>,
837) {
838    // Process first replace group if present
839    if let Some(replace) = &group.0.first {
840        collect_from_replace_group(base_path, replace, index, changes);
841    }
842
843    // Process interleaved (diffs, replace) pairs
844    for (diffs, replace) in &group.0.values {
845        for diff in diffs {
846            let elem_path = base_path.with(PathSegment::Index(*index));
847            collect_leaf_changes_inner(diff, elem_path, changes);
848            *index += 1;
849        }
850        collect_from_replace_group(base_path, replace, index, changes);
851    }
852
853    // Process trailing diffs
854    if let Some(diffs) = &group.0.last {
855        for diff in diffs {
856            let elem_path = base_path.with(PathSegment::Index(*index));
857            collect_leaf_changes_inner(diff, elem_path, changes);
858            *index += 1;
859        }
860    }
861}
862
863fn collect_from_replace_group<'mem, 'facet>(
864    base_path: &Path,
865    group: &crate::ReplaceGroup<'mem, 'facet>,
866    index: &mut usize,
867    changes: &mut Vec<LeafChange<'mem, 'facet>>,
868) {
869    // For replace groups, we have removals and additions
870    // If counts match, treat as 1:1 replacements at the same index
871    // Otherwise, show as deletions followed by insertions
872
873    if group.removals.len() == group.additions.len() {
874        // 1:1 replacements
875        for (from, to) in group.removals.iter().zip(group.additions.iter()) {
876            let elem_path = base_path.with(PathSegment::Index(*index));
877            // Check if this is actually a nested diff
878            let nested = diff_new_peek(*from, *to);
879            if matches!(nested, Diff::Replace { .. }) {
880                changes.push(LeafChange {
881                    path: elem_path,
882                    kind: LeafChangeKind::Replace {
883                        from: *from,
884                        to: *to,
885                    },
886                });
887            } else {
888                collect_leaf_changes_inner(&nested, elem_path, changes);
889            }
890            *index += 1;
891        }
892    } else {
893        // Mixed deletions and insertions
894        for from in &group.removals {
895            let elem_path = base_path.with(PathSegment::Index(*index));
896            changes.push(LeafChange {
897                path: elem_path.clone(),
898                kind: LeafChangeKind::Delete { value: *from },
899            });
900            *index += 1;
901        }
902        // Insertions happen at current index
903        for to in &group.additions {
904            let elem_path = base_path.with(PathSegment::Index(*index));
905            changes.push(LeafChange {
906                path: elem_path,
907                kind: LeafChangeKind::Insert { value: *to },
908            });
909            *index += 1;
910        }
911    }
912}
913
914/// A single leaf-level change in a diff, with path information.
915#[derive(Debug, Clone)]
916pub struct LeafChange<'mem, 'facet> {
917    /// The path from root to this change
918    pub path: Path,
919    /// The kind of change
920    pub kind: LeafChangeKind<'mem, 'facet>,
921}
922
923/// The kind of leaf change.
924#[derive(Debug, Clone)]
925pub enum LeafChangeKind<'mem, 'facet> {
926    /// A value was replaced
927    Replace {
928        /// The old value
929        from: Peek<'mem, 'facet>,
930        /// The new value
931        to: Peek<'mem, 'facet>,
932    },
933    /// A value was deleted
934    Delete {
935        /// The deleted value
936        value: Peek<'mem, 'facet>,
937    },
938    /// A value was inserted
939    Insert {
940        /// The inserted value
941        value: Peek<'mem, 'facet>,
942    },
943}
944
945impl<'mem, 'facet> LeafChange<'mem, 'facet> {
946    /// Format this change without colors.
947    pub fn format_plain(&self) -> String {
948        use facet_pretty::PrettyPrinter;
949
950        let printer = PrettyPrinter::default()
951            .with_colors(false)
952            .with_minimal_option_names(true);
953
954        let mut out = String::new();
955
956        // Show path if non-empty
957        if !self.path.0.is_empty() {
958            out.push_str(&format!("{}: ", self.path));
959        }
960
961        match &self.kind {
962            LeafChangeKind::Replace { from, to } => {
963                out.push_str(&format!(
964                    "{} → {}",
965                    printer.format_peek(*from),
966                    printer.format_peek(*to)
967                ));
968            }
969            LeafChangeKind::Delete { value } => {
970                out.push_str(&format!("- {}", printer.format_peek(*value)));
971            }
972            LeafChangeKind::Insert { value } => {
973                out.push_str(&format!("+ {}", printer.format_peek(*value)));
974            }
975        }
976
977        out
978    }
979
980    /// Format this change with colors.
981    pub fn format_colored(&self) -> String {
982        use facet_pretty::{PrettyPrinter, tokyo_night};
983        use owo_colors::OwoColorize;
984
985        let printer = PrettyPrinter::default()
986            .with_colors(false)
987            .with_minimal_option_names(true);
988
989        let mut out = String::new();
990
991        // Show path if non-empty (in field name color)
992        if !self.path.0.is_empty() {
993            out.push_str(&format!(
994                "{}: ",
995                format!("{}", self.path).color(tokyo_night::FIELD_NAME)
996            ));
997        }
998
999        match &self.kind {
1000            LeafChangeKind::Replace { from, to } => {
1001                out.push_str(&format!(
1002                    "{} {} {}",
1003                    printer.format_peek(*from).color(tokyo_night::DELETION),
1004                    "→".color(tokyo_night::COMMENT),
1005                    printer.format_peek(*to).color(tokyo_night::INSERTION)
1006                ));
1007            }
1008            LeafChangeKind::Delete { value } => {
1009                out.push_str(&format!(
1010                    "{} {}",
1011                    "-".color(tokyo_night::DELETION),
1012                    printer.format_peek(*value).color(tokyo_night::DELETION)
1013                ));
1014            }
1015            LeafChangeKind::Insert { value } => {
1016                out.push_str(&format!(
1017                    "{} {}",
1018                    "+".color(tokyo_night::INSERTION),
1019                    printer.format_peek(*value).color(tokyo_night::INSERTION)
1020                ));
1021            }
1022        }
1023
1024        out
1025    }
1026}
1027
1028impl<'mem, 'facet> std::fmt::Display for LeafChange<'mem, 'facet> {
1029    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030        write!(f, "{}", self.format_plain())
1031    }
1032}
1033
1034/// Configuration for diff formatting.
1035#[derive(Debug, Clone)]
1036pub struct DiffFormat {
1037    /// Use colors in output
1038    pub colors: bool,
1039    /// Maximum number of changes before switching to summary mode
1040    pub max_inline_changes: usize,
1041    /// Whether to use compact (path-based) format for few changes
1042    pub prefer_compact: bool,
1043}
1044
1045impl Default for DiffFormat {
1046    fn default() -> Self {
1047        Self {
1048            colors: true,
1049            max_inline_changes: 10,
1050            prefer_compact: true,
1051        }
1052    }
1053}
1054
1055/// Format the diff with the given configuration.
1056///
1057/// This chooses between compact (path-based) and tree (nested) format
1058/// based on the number of changes and the configuration.
1059pub fn format_diff(diff: &Diff<'_, '_>, config: &DiffFormat) -> String {
1060    if matches!(diff, Diff::Equal { .. }) {
1061        return if config.colors {
1062            use facet_pretty::tokyo_night;
1063            use owo_colors::OwoColorize;
1064            "(no changes)".color(tokyo_night::MUTED).to_string()
1065        } else {
1066            "(no changes)".to_string()
1067        };
1068    }
1069
1070    let changes = collect_leaf_changes(diff);
1071
1072    if changes.is_empty() {
1073        return if config.colors {
1074            use facet_pretty::tokyo_night;
1075            use owo_colors::OwoColorize;
1076            "(no changes)".color(tokyo_night::MUTED).to_string()
1077        } else {
1078            "(no changes)".to_string()
1079        };
1080    }
1081
1082    // Use compact format if preferred and we have few changes
1083    if config.prefer_compact && changes.len() <= config.max_inline_changes {
1084        let mut out = String::new();
1085        for (i, change) in changes.iter().enumerate() {
1086            if i > 0 {
1087                out.push('\n');
1088            }
1089            if config.colors {
1090                out.push_str(&change.format_colored());
1091            } else {
1092                out.push_str(&change.format_plain());
1093            }
1094        }
1095        return out;
1096    }
1097
1098    // Fall back to tree format for many changes
1099    if changes.len() > config.max_inline_changes {
1100        let mut out = String::new();
1101
1102        // Show first few changes
1103        for (i, change) in changes.iter().take(config.max_inline_changes).enumerate() {
1104            if i > 0 {
1105                out.push('\n');
1106            }
1107            if config.colors {
1108                out.push_str(&change.format_colored());
1109            } else {
1110                out.push_str(&change.format_plain());
1111            }
1112        }
1113
1114        // Show summary of remaining
1115        let remaining = changes.len() - config.max_inline_changes;
1116        if remaining > 0 {
1117            out.push('\n');
1118            let summary = format!(
1119                "... and {} more change{}",
1120                remaining,
1121                if remaining == 1 { "" } else { "s" }
1122            );
1123            if config.colors {
1124                use facet_pretty::tokyo_night;
1125                use owo_colors::OwoColorize;
1126                out.push_str(&summary.color(tokyo_night::MUTED).to_string());
1127            } else {
1128                out.push_str(&summary);
1129            }
1130        }
1131        return out;
1132    }
1133
1134    // Default: use Display impl (tree format)
1135    format!("{diff}")
1136}
1137
1138/// Format the diff with default configuration.
1139pub fn format_diff_default(diff: &Diff<'_, '_>) -> String {
1140    format_diff(diff, &DiffFormat::default())
1141}
1142
1143/// Format the diff in compact mode (path-based, no tree structure).
1144pub fn format_diff_compact(diff: &Diff<'_, '_>) -> String {
1145    format_diff(
1146        diff,
1147        &DiffFormat {
1148            prefer_compact: true,
1149            max_inline_changes: usize::MAX,
1150            ..Default::default()
1151        },
1152    )
1153}
1154
1155/// Format the diff in compact mode without colors.
1156pub fn format_diff_compact_plain(diff: &Diff<'_, '_>) -> String {
1157    format_diff(
1158        diff,
1159        &DiffFormat {
1160            colors: false,
1161            prefer_compact: true,
1162            max_inline_changes: usize::MAX,
1163        },
1164    )
1165}