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