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

facet-rs / facet / 20111913541

10 Dec 2025 08:11PM UTC coverage: 57.688% (-0.04%) from 57.724%
20111913541

push

github

fasterthanlime
mh

28366 of 49171 relevant lines covered (57.69%)

6269.04 hits per line

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

63.18
/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, diff_new_peek};
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✔
167
                    DiffPathSegment::Variant(name) => s.push_str(&format!("::{}", name)),
×
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

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

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

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

214
                self.process_value(value, &path);
7✔
215
            }
216
            Diff::Sequence { updates, .. } => {
6✔
217
                self.process_updates(updates, current_path);
6✔
218
            }
6✔
219
        }
220
    }
53✔
221

222
    fn process_value(&mut self, value: &facet_diff_core::Value<'_, '_>, current_path: &Path) {
7✔
223
        use facet_diff_core::Value;
224

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

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

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

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

266
        // Process first update group if present
267
        if let Some(update_group) = &updates.0.first {
6✔
268
            self.process_update_group(update_group, current_path, &mut index);
2✔
269
        }
4✔
270

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

279
        // Process trailing unchanged items if present
280
        if let Some(unchanged) = &updates.0.last {
6✔
281
            let _ = index + unchanged.len(); // just for tracking, not used
×
282
        }
6✔
283
    }
6✔
284

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

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

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

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

332
                // First check if values are actually equal
333
                let diff = diff_new_peek(from, to);
9✔
334
                if diff.is_equal() {
9✔
335
                    *index += 1;
×
336
                    continue;
×
337
                }
9✔
338

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

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

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

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

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

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

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

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

413
        let mut out = String::new();
13✔
414

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

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

443
        out
13✔
444
    }
13✔
445
}
446

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

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

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

470
    use std::fmt::Write;
471
    let mut out = String::new();
×
472

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

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

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

497
    if diffs.is_empty() {
×
498
        return None;
×
499
    }
×
500

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

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

538
    Some(out.trim_end().to_string())
×
539
}
×
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