• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

facet-rs / facet / 20109865285

10 Dec 2025 06:54PM UTC coverage: 57.826% (+0.1%) from 57.724%
20109865285

Pull #1231

github

web-flow
Merge 9573b4ba3 into 99cf12767
Pull Request #1231: refactor: use facet-diff's tree diffing in facet-assert

195 of 237 new or added lines in 4 files covered. (82.28%)

223 existing lines in 5 files now uncovered.

28389 of 49094 relevant lines covered (57.83%)

19391187.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.67
/facet-assert/src/same.rs
1
//! Structural sameness checking for Facet types.
2

3
use confusables::Confusable;
4
use facet_core::Facet;
5
use facet_diff::FacetDiff;
6
use facet_diff_core::{Diff, Path, PathSegment as DiffPathSegment};
7
use facet_pretty::PrettyPrinter;
8
use facet_reflect::{Peek, ScalarType};
9
use std::borrow::Cow;
10

11
/// Options for customizing structural comparison behavior.
12
///
13
/// Use the builder pattern to configure options:
14
///
15
/// ```
16
/// use facet_assert::SameOptions;
17
///
18
/// let options = SameOptions::new()
19
///     .float_tolerance(1e-6);
20
/// ```
21
#[derive(Debug, Clone, Default)]
22
pub struct SameOptions {
23
    /// Tolerance for floating-point comparisons.
24
    /// If set, two floats are considered equal if their absolute difference
25
    /// is less than or equal to this value.
26
    float_tolerance: Option<f64>,
27
}
28

29
impl SameOptions {
30
    /// Create a new `SameOptions` with default settings (exact comparison).
31
    pub fn new() -> Self {
10✔
32
        Self::default()
10✔
33
    }
10✔
34

35
    /// Set the tolerance for floating-point comparisons.
36
    ///
37
    /// When set, two `f32` or `f64` values are considered equal if:
38
    /// `|left - right| <= tolerance`
39
    ///
40
    /// # Example
41
    ///
42
    /// ```
43
    /// use facet_assert::{assert_same_with, SameOptions};
44
    ///
45
    /// let a = 1.0000001_f64;
46
    /// let b = 1.0000002_f64;
47
    ///
48
    /// // This would fail with exact comparison:
49
    /// // assert_same!(a, b);
50
    ///
51
    /// // But passes with tolerance:
52
    /// assert_same_with!(a, b, SameOptions::new().float_tolerance(1e-6));
53
    /// ```
54
    pub fn float_tolerance(mut self, tolerance: f64) -> Self {
10✔
55
        self.float_tolerance = Some(tolerance);
10✔
56
        self
10✔
57
    }
10✔
58
}
59

60
/// Result of checking if two values are structurally the same.
61
pub enum Sameness {
62
    /// The values are structurally the same.
63
    Same,
64
    /// The values differ - contains a formatted diff.
65
    Different(String),
66
    /// Encountered an opaque type that cannot be compared.
67
    Opaque {
68
        /// The type name of the opaque type.
69
        type_name: &'static str,
70
    },
71
}
72

73
/// Check if two Facet values are structurally the same.
74
///
75
/// This does NOT require `PartialEq` - it walks the structure via reflection.
76
/// Two values are "same" if they have the same structure and values, even if
77
/// they have different type names.
78
///
79
/// Returns [`Sameness::Opaque`] if either value contains an opaque type.
80
pub fn check_same<'f, T: Facet<'f>, U: Facet<'f>>(left: &T, right: &U) -> Sameness {
33✔
81
    check_same_with(left, right, SameOptions::default())
33✔
82
}
33✔
83

84
/// Check if two Facet values are structurally the same, with custom options.
85
///
86
/// Like [`check_same`], but allows configuring comparison behavior via [`SameOptions`].
87
///
88
/// # Example
89
///
90
/// ```
91
/// use facet_assert::{check_same_with, SameOptions, Sameness};
92
///
93
/// let a = 1.0000001_f64;
94
/// let b = 1.0000002_f64;
95
///
96
/// // With tolerance, these are considered the same
97
/// let options = SameOptions::new().float_tolerance(1e-6);
98
/// assert!(matches!(check_same_with(&a, &b, options), Sameness::Same));
99
/// ```
100
pub fn check_same_with<'f, T: Facet<'f>, U: Facet<'f>>(
39✔
101
    left: &T,
39✔
102
    right: &U,
39✔
103
    options: SameOptions,
39✔
104
) -> Sameness {
39✔
105
    // Use facet-diff to compute the diff
106
    let diff = left.diff(right);
39✔
107

108
    // Convert the diff to our DiffLine format
109
    let mut converter = DiffConverter::new(options);
39✔
110
    converter.process_diff(&diff, &Path::default());
39✔
111

112
    if converter.diffs.is_empty() {
39✔
113
        Sameness::Same
26✔
114
    } else {
115
        Sameness::Different(converter.into_output())
13✔
116
    }
117
}
39✔
118

119
/// Converter from facet-diff's Diff to facet-assert's DiffLine format
120
struct DiffConverter {
121
    /// Differences found, stored as lines
122
    diffs: Vec<DiffLine>,
123
    /// Comparison options
124
    options: SameOptions,
125
}
126

127
enum DiffLine {
128
    Changed {
129
        path: String,
130
        left: String,
131
        right: String,
132
    },
133
    OnlyLeft {
134
        path: String,
135
        value: String,
136
    },
137
    OnlyRight {
138
        path: String,
139
        value: String,
140
    },
141
}
142

143
impl DiffConverter {
144
    fn new(options: SameOptions) -> Self {
43✔
145
        Self {
43✔
146
            diffs: Vec::new(),
43✔
147
            options,
43✔
148
        }
43✔
149
    }
43✔
150

151
    fn format_value(peek: Peek<'_, '_>) -> String {
29✔
152
        let printer = PrettyPrinter::default()
29✔
153
            .with_colors(false)
29✔
154
            .with_minimal_option_names(true);
29✔
155
        printer.format_peek(peek).to_string()
29✔
156
    }
29✔
157

158
    fn format_path(path: &Path) -> String {
15✔
159
        if path.0.is_empty() {
15✔
160
            "root".to_string()
5✔
161
        } else {
162
            let mut s = String::new();
10✔
163
            for seg in &path.0 {
10✔
164
                match seg {
10✔
165
                    DiffPathSegment::Field(name) => s.push_str(&format!(".{}", name)),
3✔
166
                    DiffPathSegment::Index(i) => s.push_str(&format!("[{}]", i)),
7✔
NEW
167
                    DiffPathSegment::Variant(name) => s.push_str(&format!("::{}", name)),
×
NEW
168
                    DiffPathSegment::Key(k) => s.push_str(&format!("[{:?}]", k)),
×
169
                }
170
            }
171
            s
10✔
172
        }
173
    }
15✔
174

175
    fn record_changed(&mut self, path: String, left: String, right: String) {
14✔
176
        self.diffs.push(DiffLine::Changed { path, left, right });
14✔
177
    }
14✔
178

179
    fn record_only_left(&mut self, path: String, value: String) {
1✔
180
        self.diffs.push(DiffLine::OnlyLeft { path, value });
1✔
181
    }
1✔
182

NEW
183
    fn record_only_right(&mut self, path: String, value: String) {
×
NEW
184
        self.diffs.push(DiffLine::OnlyRight { path, value });
×
UNCOV
185
    }
×
186

187
    fn process_diff(&mut self, diff: &Diff<'_, '_>, current_path: &Path) {
1,276✔
188
        match diff {
1,276✔
189
            Diff::Equal { .. } => {
12✔
190
                // No difference, nothing to record
12✔
191
            }
12✔
192
            Diff::Replace { from, to } => {
18✔
193
                // Check if float tolerance applies
194
                if self.options.float_tolerance.is_some() {
18✔
195
                    if let (Some(from_f64), Some(to_f64)) =
11✔
196
                        (self.try_extract_float(*from), self.try_extract_float(*to))
11✔
197
                    {
198
                        if self.floats_equal(from_f64, to_f64) {
11✔
199
                            return; // Equal within tolerance
10✔
200
                        }
1✔
UNCOV
201
                    }
×
202
                }
7✔
203

204
                let path_str = Self::format_path(current_path);
8✔
205
                let left_str = Self::format_value(*from);
8✔
206
                let right_str = Self::format_value(*to);
8✔
207
                self.record_changed(path_str, left_str, right_str);
8✔
208
            }
209
            Diff::User { value, variant, .. } => {
953✔
210
                // Add variant to path if present
211
                let mut path = current_path.clone();
953✔
212
                if let Some(variant_name) = variant {
953✔
213
                    path.push(DiffPathSegment::Variant(Cow::Borrowed(*variant_name)));
598✔
214
                }
598✔
215

216
                self.process_value(value, &path);
953✔
217
            }
218
            Diff::Sequence { updates, .. } => {
293✔
219
                self.process_updates(updates, current_path);
293✔
220
            }
293✔
221
        }
222
    }
1,276✔
223

224
    fn process_value(&mut self, value: &facet_diff_core::Value<'_, '_>, current_path: &Path) {
953✔
225
        use facet_diff_core::Value;
226

227
        match value {
953✔
228
            Value::Tuple { updates } => {
307✔
229
                self.process_updates(updates, current_path);
307✔
230
            }
307✔
231
            Value::Struct {
232
                updates,
646✔
233
                deletions,
646✔
234
                insertions,
646✔
235
                ..
236
            } => {
237
                // Process field updates
238
                for (field_name, field_diff) in updates {
854✔
239
                    let mut path = current_path.clone();
854✔
240
                    path.push(DiffPathSegment::Field(field_name.clone()));
854✔
241
                    self.process_diff(field_diff, &path);
854✔
242
                }
854✔
243

244
                // Process deletions
245
                for (field_name, peek) in deletions {
646✔
NEW
246
                    let mut path = current_path.clone();
×
NEW
247
                    path.push(DiffPathSegment::Field(field_name.clone()));
×
NEW
248
                    let path_str = Self::format_path(&path);
×
NEW
249
                    let value_str = Self::format_value(*peek);
×
UNCOV
250
                    self.record_only_left(path_str, value_str);
×
NEW
251
                }
×
252

253
                // Process insertions
254
                for (field_name, peek) in insertions {
646✔
NEW
255
                    let mut path = current_path.clone();
×
NEW
256
                    path.push(DiffPathSegment::Field(field_name.clone()));
×
NEW
257
                    let path_str = Self::format_path(&path);
×
NEW
258
                    let value_str = Self::format_value(*peek);
×
UNCOV
259
                    self.record_only_right(path_str, value_str);
×
UNCOV
260
                }
×
261
            }
262
        }
263
    }
953✔
264

265
    fn process_updates(&mut self, updates: &facet_diff_core::Updates<'_, '_>, current_path: &Path) {
600✔
266
        let mut index = 0;
600✔
267

268
        // Process first update group if present
269
        if let Some(update_group) = &updates.0.first {
600✔
270
            self.process_update_group(update_group, current_path, &mut index);
356✔
271
        }
356✔
272

273
        // Process alternating unchanged values and update groups
274
        for (unchanged, update_group) in &updates.0.values {
600✔
275
            // Skip unchanged items
4✔
276
            index += unchanged.len();
4✔
277
            // Process the update group (contains replace groups interspersed with diffs)
4✔
278
            self.process_update_group(update_group, current_path, &mut index);
4✔
279
        }
4✔
280

281
        // Process trailing unchanged items if present
282
        if let Some(unchanged) = &updates.0.last {
600✔
283
            let _ = index + unchanged.len(); // just for tracking, not used
4✔
284
        }
596✔
285
    }
600✔
286

287
    fn process_update_group(
360✔
288
        &mut self,
360✔
289
        update_group: &facet_diff_core::UpdatesGroup<'_, '_>,
360✔
290
        current_path: &Path,
360✔
291
        index: &mut usize,
360✔
292
    ) {
360✔
293
        // Process first replace group if present
294
        if let Some(replace_group) = &update_group.0.first {
360✔
295
            self.process_replace_group(replace_group, current_path, index);
6✔
296
        }
354✔
297

298
        // Process alternating diffs and replace groups
299
        for (diffs, replace_group) in &update_group.0.values {
360✔
300
            // Process nested diffs
NEW
301
            for diff in diffs {
×
NEW
302
                let mut path = current_path.clone();
×
NEW
303
                path.push(DiffPathSegment::Index(*index));
×
UNCOV
304
                self.process_diff(diff, &path);
×
NEW
305
                *index += 1;
×
NEW
306
            }
×
307
            // Process replace group
UNCOV
308
            self.process_replace_group(replace_group, current_path, index);
×
309
        }
310

311
        // Process trailing diffs if present
312
        if let Some(diffs) = &update_group.0.last {
360✔
313
            for diff in diffs {
379✔
314
                let mut path = current_path.clone();
379✔
315
                path.push(DiffPathSegment::Index(*index));
379✔
316
                self.process_diff(diff, &path);
379✔
317
                *index += 1;
379✔
318
            }
379✔
319
        }
6✔
320
    }
360✔
321

322
    fn process_replace_group(
6✔
323
        &mut self,
6✔
324
        replace_group: &facet_diff_core::ReplaceGroup<'_, '_>,
6✔
325
        current_path: &Path,
6✔
326
        index: &mut usize,
6✔
327
    ) {
6✔
328
        // If both sides have the same number of items, try to pair them up for comparison
329
        if replace_group.removals.len() == replace_group.additions.len() {
6✔
330
            for i in 0..replace_group.removals.len() {
9✔
331
                let from = replace_group.removals[i];
9✔
332
                let to = replace_group.additions[i];
9✔
333

334
                // Check if they're floats within tolerance
335
                let is_equal_within_tolerance = if self.options.float_tolerance.is_some() {
9✔
336
                    if let (Some(from_f64), Some(to_f64)) =
3✔
337
                        (self.try_extract_float(from), self.try_extract_float(to))
3✔
338
                    {
339
                        self.floats_equal(from_f64, to_f64)
3✔
340
                    } else {
NEW
341
                        false
×
342
                    }
343
                } else {
344
                    false
6✔
345
                };
346

347
                if !is_equal_within_tolerance {
9✔
348
                    // Record as a change
6✔
349
                    let mut path = current_path.clone();
6✔
350
                    path.push(DiffPathSegment::Index(*index));
6✔
351
                    let path_str = Self::format_path(&path);
6✔
352
                    let left_str = Self::format_value(from);
6✔
353
                    let right_str = Self::format_value(to);
6✔
354
                    self.record_changed(path_str, left_str, right_str);
6✔
355
                }
6✔
356
                *index += 1;
9✔
357
            }
358
            return;
5✔
359
        }
1✔
360

361
        // Different lengths - record as separate removals and additions
362
        // Record all removed items
363
        for from_peek in &replace_group.removals {
1✔
364
            let mut path = current_path.clone();
1✔
365
            path.push(DiffPathSegment::Index(*index));
1✔
366
            let path_str = Self::format_path(&path);
1✔
367
            let value_str = Self::format_value(*from_peek);
1✔
368
            self.record_only_left(path_str, value_str);
1✔
369
            *index += 1;
1✔
370
        }
1✔
371

372
        // Record all added items (use the starting index)
373
        let start_index = *index - replace_group.removals.len();
1✔
374
        for (i, to_peek) in replace_group.additions.iter().enumerate() {
1✔
NEW
375
            let mut path = current_path.clone();
×
NEW
376
            path.push(DiffPathSegment::Index(start_index + i));
×
NEW
377
            let path_str = Self::format_path(&path);
×
NEW
378
            let value_str = Self::format_value(*to_peek);
×
NEW
379
            self.record_only_right(path_str, value_str);
×
NEW
380
        }
×
381

382
        // Adjust index for net change
383
        if replace_group.additions.len() > replace_group.removals.len() {
1✔
NEW
384
            *index = start_index + replace_group.additions.len();
×
385
        } else {
1✔
386
            *index = start_index + replace_group.removals.len();
1✔
387
        }
1✔
388
    }
6✔
389

390
    /// Compare two f64 values, using tolerance if configured.
391
    fn floats_equal(&self, left: f64, right: f64) -> bool {
14✔
392
        if let Some(tolerance) = self.options.float_tolerance {
14✔
393
            (left - right).abs() <= tolerance
14✔
394
        } else {
NEW
395
            left == right
×
396
        }
397
    }
14✔
398

399
    /// Try to extract f64 from a Peek value if it's a float.
400
    fn try_extract_float(&self, peek: Peek<'_, '_>) -> Option<f64> {
28✔
401
        match peek.scalar_type()? {
28✔
402
            ScalarType::F64 => Some(*peek.get::<f64>().ok()?),
26✔
403
            ScalarType::F32 => Some(*peek.get::<f32>().ok()? as f64),
2✔
NEW
404
            _ => None,
×
405
        }
406
    }
28✔
407

408
    fn into_output(self) -> String {
13✔
409
        use std::fmt::Write;
410

411
        let mut out = String::new();
13✔
412

413
        for diff in self.diffs {
15✔
414
            match diff {
15✔
415
                DiffLine::Changed { path, left, right } => {
14✔
416
                    writeln!(out, "\x1b[1m{path}\x1b[0m:").unwrap();
14✔
417
                    writeln!(out, "  \x1b[31m- {left}\x1b[0m").unwrap();
14✔
418
                    writeln!(out, "  \x1b[32m+ {right}\x1b[0m").unwrap();
14✔
419

420
                    // Check if the strings are confusable (look identical but differ)
421
                    // Use the confusables crate for detection, then show character-level diff
422
                    let left_normalized = left.replace_confusable();
14✔
423
                    let right_normalized = right.replace_confusable();
14✔
424
                    if left_normalized == right_normalized
14✔
NEW
425
                        && let Some(explanation) = explain_confusable_differences(&left, &right)
×
NEW
426
                    {
×
NEW
427
                        writeln!(out, "  \x1b[33m{}\x1b[0m", explanation).unwrap();
×
428
                    }
14✔
429
                }
430
                DiffLine::OnlyLeft { path, value } => {
1✔
431
                    writeln!(out, "\x1b[1m{path}\x1b[0m (only in left):").unwrap();
1✔
432
                    writeln!(out, "  \x1b[31m- {value}\x1b[0m").unwrap();
1✔
433
                }
1✔
NEW
434
                DiffLine::OnlyRight { path, value } => {
×
UNCOV
435
                    writeln!(out, "\x1b[1m{path}\x1b[0m (only in right):").unwrap();
×
NEW
436
                    writeln!(out, "  \x1b[32m+ {value}\x1b[0m").unwrap();
×
NEW
437
                }
×
438
            }
439
        }
440

441
        out
13✔
442
    }
13✔
443
}
444

445
/// Format a character for display with its Unicode codepoint and visual representation.
UNCOV
446
fn format_char_with_codepoint(c: char) -> String {
×
447
    // For printable ASCII characters (except space), show the character directly
448
    if c.is_ascii_graphic() {
×
UNCOV
449
        format!("'{}' (U+{:04X})", c, c as u32)
×
450
    } else {
451
        // For everything else, show escaped form with codepoint
UNCOV
452
        format!("'\\u{{{:04X}}}' (U+{:04X})", c as u32, c as u32)
×
453
    }
454
}
×
455

456
/// Explain the confusable differences between two strings that look identical.
457
/// Uses the `confusables` crate for detection, then shows character-level diff.
UNCOV
458
fn explain_confusable_differences(left: &str, right: &str) -> Option<String> {
×
459
    // Strings must be different but normalize to the same skeleton
460
    if left == right {
×
UNCOV
461
        return None;
×
462
    }
×
463

464
    // Find character-level differences
UNCOV
465
    let left_chars: Vec<char> = left.chars().collect();
×
UNCOV
466
    let right_chars: Vec<char> = right.chars().collect();
×
467

468
    use std::fmt::Write;
UNCOV
469
    let mut out = String::new();
×
470

471
    // Find all positions where characters differ
UNCOV
472
    let mut diffs: Vec<(usize, char, char)> = Vec::new();
×
473

474
    let max_len = left_chars.len().max(right_chars.len());
×
UNCOV
475
    for i in 0..max_len {
×
476
        let lc = left_chars.get(i);
×
477
        let rc = right_chars.get(i);
×
478

479
        match (lc, rc) {
×
UNCOV
480
            (Some(&l), Some(&r)) if l != r => {
×
481
                diffs.push((i, l, r));
×
482
            }
×
483
            (Some(&l), None) => {
×
484
                // Character only in left (will show as deletion)
×
485
                diffs.push((i, l, '\0'));
×
486
            }
×
487
            (None, Some(&r)) => {
×
488
                // Character only in right (will show as insertion)
×
489
                diffs.push((i, '\0', r));
×
490
            }
×
491
            _ => {}
×
492
        }
493
    }
494

UNCOV
495
    if diffs.is_empty() {
×
UNCOV
496
        return None;
×
497
    }
×
498

499
    writeln!(
×
UNCOV
500
        out,
×
501
        "(strings are visually confusable but differ in {} position{}):",
502
        diffs.len(),
×
UNCOV
503
        if diffs.len() == 1 { "" } else { "s" }
×
504
    )
505
    .ok()?;
×
506

507
    for (pos, lc, rc) in &diffs {
×
UNCOV
508
        if *lc == '\0' {
×
509
            writeln!(
×
510
                out,
×
511
                "  [{}]: (missing) vs {}",
512
                pos,
UNCOV
513
                format_char_with_codepoint(*rc)
×
514
            )
515
            .ok()?;
×
UNCOV
516
        } else if *rc == '\0' {
×
517
            writeln!(
×
518
                out,
×
519
                "  [{}]: {} vs (missing)",
520
                pos,
UNCOV
521
                format_char_with_codepoint(*lc)
×
522
            )
523
            .ok()?;
×
524
        } else {
525
            writeln!(
×
UNCOV
526
                out,
×
527
                "  [{}]: {} vs {}",
528
                pos,
UNCOV
529
                format_char_with_codepoint(*lc),
×
UNCOV
530
                format_char_with_codepoint(*rc)
×
531
            )
532
            .ok()?;
×
533
        }
534
    }
535

UNCOV
536
    Some(out.trim_end().to_string())
×
UNCOV
537
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc