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

facet-rs / facet / 20102029499

10 Dec 2025 02:28PM UTC coverage: 58.173% (-0.4%) from 58.588%
20102029499

Pull #1220

github

web-flow
Merge d91600cf5 into eaae4b56d
Pull Request #1220: feat(cinereus): improve tree matching for leaf nodes and filter no-op moves

1583 of 2962 new or added lines in 18 files covered. (53.44%)

111 existing lines in 3 files now uncovered.

28390 of 48803 relevant lines covered (58.17%)

819.24 hits per line

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

54.25
/facet-diff-core/src/layout/render.rs
1
//! Layout rendering to output.
2

3
use std::fmt::{self, Write};
4

5
use super::backend::{AnsiBackend, ColorBackend, PlainBackend, SemanticColor};
6
use super::flavor::DiffFlavor;
7
use super::{AttrStatus, ChangedGroup, ElementChange, Layout, LayoutNode};
8
use crate::DiffSymbols;
9

10
/// Options for rendering a layout.
11
#[derive(Clone, Debug)]
12
pub struct RenderOptions<B: ColorBackend> {
13
    /// Symbols to use for diff markers.
14
    pub symbols: DiffSymbols,
15
    /// Color backend for styling output.
16
    pub backend: B,
17
    /// Indentation string (default: 2 spaces).
18
    pub indent: &'static str,
19
}
20

21
impl Default for RenderOptions<AnsiBackend> {
22
    fn default() -> Self {
1✔
23
        Self {
1✔
24
            symbols: DiffSymbols::default(),
1✔
25
            backend: AnsiBackend::default(),
1✔
26
            indent: "  ",
1✔
27
        }
1✔
28
    }
1✔
29
}
30

31
impl RenderOptions<PlainBackend> {
32
    /// Create options with plain backend (no colors).
33
    pub fn plain() -> Self {
4✔
34
        Self {
4✔
35
            symbols: DiffSymbols::default(),
4✔
36
            backend: PlainBackend,
4✔
37
            indent: "  ",
4✔
38
        }
4✔
39
    }
4✔
40
}
41

42
impl<B: ColorBackend> RenderOptions<B> {
43
    /// Create options with a custom backend.
NEW
44
    pub fn with_backend(backend: B) -> Self {
×
NEW
45
        Self {
×
NEW
46
            symbols: DiffSymbols::default(),
×
NEW
47
            backend,
×
NEW
48
            indent: "  ",
×
NEW
49
        }
×
NEW
50
    }
×
51
}
52

53
/// Render a layout to a writer.
54
///
55
/// Starts at depth 1 to provide a gutter for change prefixes (- / +).
56
pub fn render<W: Write, B: ColorBackend, F: DiffFlavor>(
5✔
57
    layout: &Layout,
5✔
58
    w: &mut W,
5✔
59
    opts: &RenderOptions<B>,
5✔
60
    flavor: &F,
5✔
61
) -> fmt::Result {
5✔
62
    render_node(layout, w, layout.root, 1, opts, flavor)
5✔
63
}
5✔
64

65
/// Render a layout to a String.
66
pub fn render_to_string<B: ColorBackend, F: DiffFlavor>(
5✔
67
    layout: &Layout,
5✔
68
    opts: &RenderOptions<B>,
5✔
69
    flavor: &F,
5✔
70
) -> String {
5✔
71
    let mut out = String::new();
5✔
72
    render(layout, &mut out, opts, flavor).expect("writing to String cannot fail");
5✔
73
    out
5✔
74
}
5✔
75

76
fn element_change_to_semantic(change: ElementChange) -> SemanticColor {
4✔
77
    match change {
4✔
NEW
78
        ElementChange::None => SemanticColor::Unchanged,
×
79
        ElementChange::Deleted => SemanticColor::Deleted,
2✔
80
        ElementChange::Inserted => SemanticColor::Inserted,
2✔
NEW
81
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
82
    }
83
}
4✔
84

85
fn render_node<W: Write, B: ColorBackend, F: DiffFlavor>(
8✔
86
    layout: &Layout,
8✔
87
    w: &mut W,
8✔
88
    node_id: indextree::NodeId,
8✔
89
    depth: usize,
8✔
90
    opts: &RenderOptions<B>,
8✔
91
    flavor: &F,
8✔
92
) -> fmt::Result {
8✔
93
    let node = layout.get(node_id).expect("node exists");
8✔
94

95
    match node {
8✔
96
        LayoutNode::Element {
97
            tag,
5✔
98
            field_name,
5✔
99
            attrs,
5✔
100
            changed_groups,
5✔
101
            change,
5✔
102
        } => {
103
            let tag = *tag;
5✔
104
            let field_name = *field_name;
5✔
105
            let change = *change;
5✔
106
            let attrs = attrs.clone();
5✔
107
            let changed_groups = changed_groups.clone();
5✔
108

109
            render_element(
5✔
110
                layout,
5✔
111
                w,
5✔
112
                node_id,
5✔
113
                depth,
5✔
114
                opts,
5✔
115
                flavor,
5✔
116
                tag,
5✔
117
                field_name,
5✔
118
                &attrs,
5✔
119
                &changed_groups,
5✔
120
                change,
5✔
121
            )
122
        }
123

124
        LayoutNode::Sequence {
NEW
125
            change,
×
NEW
126
            item_type,
×
NEW
127
            field_name,
×
128
        } => {
NEW
129
            let change = *change;
×
NEW
130
            let item_type = *item_type;
×
NEW
131
            let field_name = *field_name;
×
NEW
132
            render_sequence(
×
NEW
133
                layout, w, node_id, depth, opts, flavor, change, item_type, field_name,
×
134
            )
135
        }
136

137
        LayoutNode::Collapsed { count } => {
1✔
138
            let count = *count;
1✔
139
            write_indent(w, depth, opts)?;
1✔
140
            let comment = flavor.comment(&format!("{} unchanged", count));
1✔
141
            opts.backend
1✔
142
                .write_styled(w, &comment, SemanticColor::Comment)?;
1✔
143
            writeln!(w)
1✔
144
        }
145

146
        LayoutNode::Text { value, change } => {
2✔
147
            let text = layout.get_string(value.span);
2✔
148
            let change = *change;
2✔
149

150
            write_indent(w, depth, opts)?;
2✔
151
            if let Some(prefix) = change.prefix() {
2✔
152
                opts.backend
2✔
153
                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
2✔
154
                write!(w, " ")?;
2✔
NEW
155
            }
×
156

157
            opts.backend
2✔
158
                .write_styled(w, text, element_change_to_semantic(change))?;
2✔
159
            writeln!(w)
2✔
160
        }
161

162
        LayoutNode::ItemGroup {
NEW
163
            items,
×
NEW
164
            change,
×
NEW
165
            collapsed_suffix,
×
NEW
166
            item_type,
×
167
        } => {
NEW
168
            let items = items.clone();
×
NEW
169
            let change = *change;
×
NEW
170
            let collapsed_suffix = *collapsed_suffix;
×
NEW
171
            let item_type = *item_type;
×
172

173
            // For changed items, the prefix eats into the indent (goes in the "gutter")
NEW
174
            if let Some(prefix) = change.prefix() {
×
175
                // Write indent minus 2 chars, then prefix + space
NEW
176
                write_indent_minus_prefix(w, depth, opts)?;
×
NEW
177
                opts.backend
×
NEW
178
                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
179
                write!(w, " ")?;
×
180
            } else {
NEW
181
                write_indent(w, depth, opts)?;
×
182
            }
183

184
            // Render items with flavor separator and optional wrapping
NEW
185
            let semantic = element_change_to_semantic(change);
×
NEW
186
            for (i, item) in items.iter().enumerate() {
×
NEW
187
                if i > 0 {
×
NEW
188
                    write!(w, "{}", flavor.item_separator())?;
×
NEW
189
                }
×
NEW
190
                let raw_value = layout.get_string(item.span);
×
NEW
191
                let formatted = flavor.format_seq_item(item_type, raw_value);
×
NEW
192
                opts.backend.write_styled(w, &formatted, semantic)?;
×
193
            }
194

195
            // Render collapsed suffix if present
NEW
196
            if let Some(count) = collapsed_suffix {
×
NEW
197
                let suffix = flavor.comment(&format!("{} more", count));
×
NEW
198
                write!(w, " ")?;
×
NEW
199
                opts.backend
×
NEW
200
                    .write_styled(w, &suffix, SemanticColor::Comment)?;
×
NEW
201
            }
×
202

NEW
203
            writeln!(w)
×
204
        }
205
    }
206
}
8✔
207

208
#[allow(clippy::too_many_arguments)]
209
fn render_element<W: Write, B: ColorBackend, F: DiffFlavor>(
5✔
210
    layout: &Layout,
5✔
211
    w: &mut W,
5✔
212
    node_id: indextree::NodeId,
5✔
213
    depth: usize,
5✔
214
    opts: &RenderOptions<B>,
5✔
215
    flavor: &F,
5✔
216
    tag: &str,
5✔
217
    field_name: Option<&str>,
5✔
218
    attrs: &[super::Attr],
5✔
219
    changed_groups: &[ChangedGroup],
5✔
220
    change: ElementChange,
5✔
221
) -> fmt::Result {
5✔
222
    let has_attr_changes = !changed_groups.is_empty()
5✔
223
        || attrs.iter().any(|a| {
2✔
NEW
224
            matches!(
×
NEW
225
                a.status,
×
226
                AttrStatus::Deleted { .. } | AttrStatus::Inserted { .. }
227
            )
NEW
228
        });
×
229

230
    let children: Vec<_> = layout.children(node_id).collect();
5✔
231
    let has_children = !children.is_empty();
5✔
232

233
    let tag_color = match change {
5✔
234
        ElementChange::None => SemanticColor::Structure,
5✔
NEW
235
        ElementChange::Deleted => SemanticColor::Deleted,
×
NEW
236
        ElementChange::Inserted => SemanticColor::Inserted,
×
NEW
237
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
238
    };
239

240
    // Opening tag/struct
241
    write_indent(w, depth, opts)?;
5✔
242
    if let Some(prefix) = change.prefix() {
5✔
NEW
243
        opts.backend
×
NEW
244
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
245
        write!(w, " ")?;
×
246
    }
5✔
247

248
    // Render field name prefix if this element is a struct field (e.g., "point: " for Rust)
249
    // Uses format_child_open which handles the difference between:
250
    // - Rust/JSON: `field_name: `
251
    // - XML: `` (empty - nested elements don't use attribute syntax)
252
    if let Some(name) = field_name {
5✔
NEW
253
        let prefix = flavor.format_child_open(name);
×
NEW
254
        if !prefix.is_empty() {
×
NEW
255
            opts.backend
×
NEW
256
                .write_styled(w, &prefix, SemanticColor::Unchanged)?;
×
NEW
257
        }
×
258
    }
5✔
259

260
    let open = flavor.struct_open(tag);
5✔
261
    opts.backend.write_styled(w, &open, tag_color)?;
5✔
262

263
    // Render type comment in muted color if present
264
    if let Some(comment) = flavor.type_comment(tag) {
5✔
NEW
265
        write!(w, " ")?;
×
NEW
266
        opts.backend
×
NEW
267
            .write_styled(w, &comment, SemanticColor::Comment)?;
×
268
    }
5✔
269

270
    if has_attr_changes {
5✔
271
        // Multi-line attribute format
272
        writeln!(w)?;
3✔
273

274
        // Render changed groups as -/+ line pairs
275
        for group in changed_groups {
3✔
276
            render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
3✔
277
        }
278

279
        // Render deleted attributes (prefix uses indent gutter)
280
        for (i, attr) in attrs.iter().enumerate() {
3✔
281
            if let AttrStatus::Deleted { value } = &attr.status {
3✔
282
                // Skip if already in a changed group
NEW
283
                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
×
NEW
284
                    continue;
×
NEW
285
                }
×
NEW
286
                write_indent_minus_prefix(w, depth + 1, opts)?;
×
NEW
287
                opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
×
NEW
288
                write!(w, " ")?;
×
NEW
289
                render_attr_deleted(layout, w, opts, flavor, &attr.name, value)?;
×
290
                // Trailing comma (muted)
NEW
291
                opts.backend.write_styled(
×
NEW
292
                    w,
×
NEW
293
                    flavor.trailing_separator(),
×
NEW
294
                    SemanticColor::Comment,
×
NEW
295
                )?;
×
NEW
296
                writeln!(w)?;
×
297
            }
3✔
298
        }
299

300
        // Render inserted attributes (prefix uses indent gutter)
301
        for (i, attr) in attrs.iter().enumerate() {
3✔
302
            if let AttrStatus::Inserted { value } = &attr.status {
3✔
NEW
303
                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
×
NEW
304
                    continue;
×
NEW
305
                }
×
NEW
306
                write_indent_minus_prefix(w, depth + 1, opts)?;
×
NEW
307
                opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
×
NEW
308
                write!(w, " ")?;
×
NEW
309
                render_attr_inserted(layout, w, opts, flavor, &attr.name, value)?;
×
310
                // Trailing comma (muted)
NEW
311
                opts.backend.write_styled(
×
NEW
312
                    w,
×
NEW
313
                    flavor.trailing_separator(),
×
NEW
314
                    SemanticColor::Comment,
×
NEW
315
                )?;
×
NEW
316
                writeln!(w)?;
×
317
            }
3✔
318
        }
319

320
        // Render unchanged attributes on one line
321
        let unchanged: Vec<_> = attrs
3✔
322
            .iter()
3✔
323
            .filter(|a| matches!(a.status, AttrStatus::Unchanged { .. }))
3✔
324
            .collect();
3✔
325
        if !unchanged.is_empty() {
3✔
NEW
326
            write_indent(w, depth + 1, opts)?;
×
NEW
327
            for (i, attr) in unchanged.iter().enumerate() {
×
NEW
328
                if i > 0 {
×
NEW
329
                    write!(w, "{}", flavor.field_separator())?;
×
NEW
330
                }
×
NEW
331
                if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
332
                    render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
333
                }
×
334
            }
335
            // Trailing comma (muted)
NEW
336
            opts.backend
×
NEW
337
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Comment)?;
×
NEW
338
            writeln!(w)?;
×
339
        }
3✔
340

341
        // Closing bracket
342
        write_indent(w, depth, opts)?;
3✔
343
        if has_children {
3✔
NEW
344
            let open_close = flavor.struct_open_close();
×
NEW
345
            opts.backend.write_styled(w, open_close, tag_color)?;
×
346
        } else {
347
            let close = flavor.struct_close(tag, true);
3✔
348
            opts.backend.write_styled(w, &close, tag_color)?;
3✔
349
        }
350
        writeln!(w)?;
3✔
351
    } else if has_children && !attrs.is_empty() {
2✔
352
        // Unchanged attributes with children: put attrs on their own lines
NEW
353
        writeln!(w)?;
×
NEW
354
        for attr in attrs.iter() {
×
NEW
355
            write_indent(w, depth + 1, opts)?;
×
NEW
356
            if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
357
                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
358
            }
×
359
            // Trailing comma (muted)
NEW
360
            opts.backend
×
NEW
361
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Comment)?;
×
NEW
362
            writeln!(w)?;
×
363
        }
364
        // Close the opening (e.g., ">" for XML) - only if non-empty
NEW
365
        let open_close = flavor.struct_open_close();
×
NEW
366
        if !open_close.is_empty() {
×
NEW
367
            write_indent(w, depth, opts)?;
×
NEW
368
            opts.backend.write_styled(w, open_close, tag_color)?;
×
NEW
369
            writeln!(w)?;
×
NEW
370
        }
×
371
    } else {
372
        // Inline attributes (no changes, no children) or no attrs
373
        for (i, attr) in attrs.iter().enumerate() {
2✔
NEW
374
            if i > 0 {
×
NEW
375
                write!(w, "{}", flavor.field_separator())?;
×
376
            } else {
NEW
377
                write!(w, " ")?;
×
378
            }
NEW
379
            if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
380
                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
381
            }
×
382
        }
383

384
        if has_children {
2✔
385
            // Close the opening tag (e.g., ">" for XML)
386
            let open_close = flavor.struct_open_close();
2✔
387
            opts.backend.write_styled(w, open_close, tag_color)?;
2✔
388
        } else {
389
            // Self-closing
NEW
390
            let close = flavor.struct_close(tag, true);
×
NEW
391
            opts.backend.write_styled(w, &close, tag_color)?;
×
392
        }
393
        writeln!(w)?;
2✔
394
    }
395

396
    // Children
397
    for child_id in children {
5✔
398
        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
3✔
399
    }
400

401
    // Closing tag (if we have children, we already printed opening part above)
402
    if has_children {
5✔
403
        write_indent(w, depth, opts)?;
2✔
404
        if let Some(prefix) = change.prefix() {
2✔
NEW
405
            opts.backend
×
NEW
406
                .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
407
            write!(w, " ")?;
×
408
        }
2✔
409
        let close = flavor.struct_close(tag, false);
2✔
410
        opts.backend.write_styled(w, &close, tag_color)?;
2✔
411
        writeln!(w)?;
2✔
412
    }
3✔
413

414
    Ok(())
5✔
415
}
5✔
416

417
#[allow(clippy::too_many_arguments)]
NEW
418
fn render_sequence<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
419
    layout: &Layout,
×
NEW
420
    w: &mut W,
×
NEW
421
    node_id: indextree::NodeId,
×
NEW
422
    depth: usize,
×
NEW
423
    opts: &RenderOptions<B>,
×
NEW
424
    flavor: &F,
×
NEW
425
    change: ElementChange,
×
NEW
426
    _item_type: &str, // Item type available for future use (items use it via ItemGroup)
×
NEW
427
    field_name: Option<&str>,
×
NEW
428
) -> fmt::Result {
×
NEW
429
    let children: Vec<_> = layout.children(node_id).collect();
×
430

NEW
431
    let tag_color = match change {
×
NEW
432
        ElementChange::None => SemanticColor::Structure,
×
NEW
433
        ElementChange::Deleted => SemanticColor::Deleted,
×
NEW
434
        ElementChange::Inserted => SemanticColor::Inserted,
×
NEW
435
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
436
    };
437

438
    // Empty sequences: render on single line
NEW
439
    if children.is_empty() {
×
440
        // Always render empty sequences with field name (e.g., "elements: []")
441
        // Only skip if unchanged AND no field name
NEW
442
        if change == ElementChange::None && field_name.is_none() {
×
NEW
443
            return Ok(());
×
NEW
444
        }
×
445

NEW
446
        write_indent(w, depth, opts)?;
×
NEW
447
        if let Some(prefix) = change.prefix() {
×
NEW
448
            opts.backend
×
NEW
449
                .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
450
            write!(w, " ")?;
×
NEW
451
        }
×
452

453
        // Open and close with optional field name
NEW
454
        if let Some(name) = field_name {
×
NEW
455
            let open = flavor.format_seq_field_open(name);
×
NEW
456
            let close = flavor.format_seq_field_close(name);
×
NEW
457
            opts.backend.write_styled(w, &open, tag_color)?;
×
NEW
458
            opts.backend.write_styled(w, &close, tag_color)?;
×
459
        } else {
NEW
460
            let open = flavor.seq_open();
×
NEW
461
            let close = flavor.seq_close();
×
NEW
462
            opts.backend.write_styled(w, &open, tag_color)?;
×
NEW
463
            opts.backend.write_styled(w, &close, tag_color)?;
×
464
        }
465

466
        // Trailing comma for fields
NEW
467
        if field_name.is_some() {
×
NEW
468
            opts.backend
×
NEW
469
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Comment)?;
×
NEW
470
        }
×
NEW
471
        writeln!(w)?;
×
NEW
472
        return Ok(());
×
NEW
473
    }
×
474

475
    // Opening bracket with optional field name
NEW
476
    write_indent(w, depth, opts)?;
×
NEW
477
    if let Some(prefix) = change.prefix() {
×
NEW
478
        opts.backend
×
NEW
479
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
480
        write!(w, " ")?;
×
NEW
481
    }
×
482

483
    // Open with optional field name
NEW
484
    if let Some(name) = field_name {
×
NEW
485
        let open = flavor.format_seq_field_open(name);
×
NEW
486
        opts.backend.write_styled(w, &open, tag_color)?;
×
487
    } else {
NEW
488
        let open = flavor.seq_open();
×
NEW
489
        opts.backend.write_styled(w, &open, tag_color)?;
×
490
    }
NEW
491
    writeln!(w)?;
×
492

493
    // Children
NEW
494
    for child_id in children {
×
NEW
495
        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
×
496
    }
497

498
    // Closing bracket
NEW
499
    write_indent(w, depth, opts)?;
×
NEW
500
    if let Some(prefix) = change.prefix() {
×
NEW
501
        opts.backend
×
NEW
502
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
503
        write!(w, " ")?;
×
NEW
504
    }
×
505

506
    // Close with optional field name
NEW
507
    if let Some(name) = field_name {
×
NEW
508
        let close = flavor.format_seq_field_close(name);
×
NEW
509
        opts.backend.write_styled(w, &close, tag_color)?;
×
510
    } else {
NEW
511
        let close = flavor.seq_close();
×
NEW
512
        opts.backend.write_styled(w, &close, tag_color)?;
×
513
    }
514

515
    // Trailing comma for fields
NEW
516
    if field_name.is_some() {
×
NEW
517
        opts.backend
×
NEW
518
            .write_styled(w, flavor.trailing_separator(), SemanticColor::Comment)?;
×
NEW
519
    }
×
NEW
520
    writeln!(w)?;
×
521

NEW
522
    Ok(())
×
NEW
523
}
×
524

525
fn render_changed_group<W: Write, B: ColorBackend, F: DiffFlavor>(
3✔
526
    layout: &Layout,
3✔
527
    w: &mut W,
3✔
528
    depth: usize,
3✔
529
    opts: &RenderOptions<B>,
3✔
530
    flavor: &F,
3✔
531
    attrs: &[super::Attr],
3✔
532
    group: &ChangedGroup,
3✔
533
) -> fmt::Result {
3✔
534
    // Minus line (prefix uses indent gutter)
535
    write_indent_minus_prefix(w, depth, opts)?;
3✔
536
    opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
3✔
537
    write!(w, " ")?;
3✔
538

539
    let last_idx = group.attr_indices.len().saturating_sub(1);
3✔
540
    for (i, &idx) in group.attr_indices.iter().enumerate() {
3✔
541
        if i > 0 {
3✔
NEW
542
            write!(w, "{}", flavor.field_separator())?;
×
543
        }
3✔
544
        let attr = &attrs[idx];
3✔
545
        if let AttrStatus::Changed { old, new } = &attr.status {
3✔
546
            // Each field padded to max of its own old/new value width
547
            let field_max_width = old.width.max(new.width);
3✔
548
            write!(w, "{}", flavor.format_field_prefix(&attr.name))?;
3✔
549
            let old_str = layout.get_string(old.span);
3✔
550
            opts.backend
3✔
551
                .write_styled(w, old_str, SemanticColor::Deleted)?;
3✔
552
            write!(w, "{}", flavor.format_field_suffix())?;
3✔
553
            // Pad to align with the + line's value (only between fields, not at end)
554
            if i < last_idx {
3✔
NEW
555
                let value_padding = field_max_width.saturating_sub(old.width);
×
NEW
556
                for _ in 0..value_padding {
×
NEW
557
                    write!(w, " ")?;
×
558
                }
559
            }
3✔
NEW
560
        }
×
561
    }
562
    writeln!(w)?;
3✔
563

564
    // Plus line (prefix uses indent gutter)
565
    write_indent_minus_prefix(w, depth, opts)?;
3✔
566
    opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
3✔
567
    write!(w, " ")?;
3✔
568

569
    for (i, &idx) in group.attr_indices.iter().enumerate() {
3✔
570
        if i > 0 {
3✔
NEW
571
            write!(w, "{}", flavor.field_separator())?;
×
572
        }
3✔
573
        let attr = &attrs[idx];
3✔
574
        if let AttrStatus::Changed { old, new } = &attr.status {
3✔
575
            // Each field padded to max of its own old/new value width
576
            let field_max_width = old.width.max(new.width);
3✔
577
            write!(w, "{}", flavor.format_field_prefix(&attr.name))?;
3✔
578
            let new_str = layout.get_string(new.span);
3✔
579
            opts.backend
3✔
580
                .write_styled(w, new_str, SemanticColor::Inserted)?;
3✔
581
            write!(w, "{}", flavor.format_field_suffix())?;
3✔
582
            // Pad to align with the - line's value (only between fields, not at end)
583
            if i < last_idx {
3✔
NEW
584
                let value_padding = field_max_width.saturating_sub(new.width);
×
NEW
585
                for _ in 0..value_padding {
×
NEW
586
                    write!(w, " ")?;
×
587
                }
588
            }
3✔
NEW
589
        }
×
590
    }
591
    writeln!(w)?;
3✔
592

593
    Ok(())
3✔
594
}
3✔
595

NEW
596
fn render_attr_unchanged<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
597
    layout: &Layout,
×
NEW
598
    w: &mut W,
×
NEW
599
    opts: &RenderOptions<B>,
×
NEW
600
    flavor: &F,
×
NEW
601
    name: &str,
×
NEW
602
    value: &super::FormattedValue,
×
NEW
603
) -> fmt::Result {
×
NEW
604
    let value_str = layout.get_string(value.span);
×
NEW
605
    let formatted = flavor.format_field(name, value_str);
×
NEW
606
    opts.backend
×
NEW
607
        .write_styled(w, &formatted, SemanticColor::Unchanged)
×
NEW
608
}
×
609

NEW
610
fn render_attr_deleted<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
611
    layout: &Layout,
×
NEW
612
    w: &mut W,
×
NEW
613
    opts: &RenderOptions<B>,
×
NEW
614
    flavor: &F,
×
NEW
615
    name: &str,
×
NEW
616
    value: &super::FormattedValue,
×
NEW
617
) -> fmt::Result {
×
NEW
618
    let value_str = layout.get_string(value.span);
×
619
    // Entire field is colored red for deleted
NEW
620
    let formatted = flavor.format_field(name, value_str);
×
NEW
621
    opts.backend
×
NEW
622
        .write_styled(w, &formatted, SemanticColor::Deleted)
×
NEW
623
}
×
624

NEW
625
fn render_attr_inserted<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
626
    layout: &Layout,
×
NEW
627
    w: &mut W,
×
NEW
628
    opts: &RenderOptions<B>,
×
NEW
629
    flavor: &F,
×
NEW
630
    name: &str,
×
NEW
631
    value: &super::FormattedValue,
×
NEW
632
) -> fmt::Result {
×
NEW
633
    let value_str = layout.get_string(value.span);
×
634
    // Entire field is colored green for inserted
NEW
635
    let formatted = flavor.format_field(name, value_str);
×
NEW
636
    opts.backend
×
NEW
637
        .write_styled(w, &formatted, SemanticColor::Inserted)
×
NEW
638
}
×
639

640
fn write_indent<W: Write, B: ColorBackend>(
13✔
641
    w: &mut W,
13✔
642
    depth: usize,
13✔
643
    opts: &RenderOptions<B>,
13✔
644
) -> fmt::Result {
13✔
645
    for _ in 0..depth {
13✔
646
        write!(w, "{}", opts.indent)?;
17✔
647
    }
648
    Ok(())
13✔
649
}
13✔
650

651
/// Write indent minus 2 characters for the prefix gutter.
652
/// The "- " or "+ " prefix will occupy those 2 characters.
653
fn write_indent_minus_prefix<W: Write, B: ColorBackend>(
6✔
654
    w: &mut W,
6✔
655
    depth: usize,
6✔
656
    opts: &RenderOptions<B>,
6✔
657
) -> fmt::Result {
6✔
658
    let total_indent = depth * opts.indent.len();
6✔
659
    let gutter_indent = total_indent.saturating_sub(2);
6✔
660
    for _ in 0..gutter_indent {
6✔
661
        write!(w, " ")?;
16✔
662
    }
663
    Ok(())
6✔
664
}
6✔
665

666
#[cfg(test)]
667
mod tests {
668
    use indextree::Arena;
669

670
    use super::*;
671
    use crate::layout::{Attr, FormatArena, FormattedValue, Layout, LayoutNode, XmlFlavor};
672

673
    fn make_test_layout() -> Layout {
2✔
674
        let mut strings = FormatArena::new();
2✔
675
        let tree = Arena::new();
2✔
676

677
        // Create a simple element with one changed attribute
678
        let (red_span, red_width) = strings.push_str("red");
2✔
679
        let (blue_span, blue_width) = strings.push_str("blue");
2✔
680

681
        let fill_attr = Attr::changed(
2✔
682
            "fill",
683
            4,
684
            FormattedValue::new(red_span, red_width),
2✔
685
            FormattedValue::new(blue_span, blue_width),
2✔
686
        );
687

688
        let attrs = vec![fill_attr];
2✔
689
        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
2✔
690

691
        let root = LayoutNode::Element {
2✔
692
            tag: "rect",
2✔
693
            field_name: None,
2✔
694
            attrs,
2✔
695
            changed_groups,
2✔
696
            change: ElementChange::None,
2✔
697
        };
2✔
698

699
        Layout::new(strings, tree, root)
2✔
700
    }
2✔
701

702
    #[test]
703
    fn test_render_simple_change() {
1✔
704
        let layout = make_test_layout();
1✔
705
        let opts = RenderOptions::plain();
1✔
706
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
707

708
        assert!(output.contains("<rect"));
1✔
709
        assert!(output.contains("- fill=\"red\""));
1✔
710
        assert!(output.contains("+ fill=\"blue\""));
1✔
711
        assert!(output.contains("/>"));
1✔
712
    }
1✔
713

714
    #[test]
715
    fn test_render_collapsed() {
1✔
716
        let strings = FormatArena::new();
1✔
717
        let tree = Arena::new();
1✔
718

719
        let root = LayoutNode::collapsed(5);
1✔
720
        let layout = Layout::new(strings, tree, root);
1✔
721

722
        let opts = RenderOptions::plain();
1✔
723
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
724

725
        assert!(output.contains("<!-- 5 unchanged -->"));
1✔
726
    }
1✔
727

728
    #[test]
729
    fn test_render_with_children() {
1✔
730
        let mut strings = FormatArena::new();
1✔
731
        let mut tree = Arena::new();
1✔
732

733
        // Parent element
734
        let parent = tree.new_node(LayoutNode::Element {
1✔
735
            tag: "svg",
1✔
736
            field_name: None,
1✔
737
            attrs: vec![],
1✔
738
            changed_groups: vec![],
1✔
739
            change: ElementChange::None,
1✔
740
        });
1✔
741

742
        // Child element with change
743
        let (red_span, red_width) = strings.push_str("red");
1✔
744
        let (blue_span, blue_width) = strings.push_str("blue");
1✔
745

746
        let fill_attr = Attr::changed(
1✔
747
            "fill",
748
            4,
749
            FormattedValue::new(red_span, red_width),
1✔
750
            FormattedValue::new(blue_span, blue_width),
1✔
751
        );
752
        let attrs = vec![fill_attr];
1✔
753
        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1✔
754

755
        let child = tree.new_node(LayoutNode::Element {
1✔
756
            tag: "rect",
1✔
757
            field_name: None,
1✔
758
            attrs,
1✔
759
            changed_groups,
1✔
760
            change: ElementChange::None,
1✔
761
        });
1✔
762

763
        parent.append(child, &mut tree);
1✔
764

765
        let layout = Layout {
1✔
766
            strings,
1✔
767
            tree,
1✔
768
            root: parent,
1✔
769
        };
1✔
770

771
        let opts = RenderOptions::plain();
1✔
772
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
773

774
        assert!(output.contains("<svg>"));
1✔
775
        assert!(output.contains("</svg>"));
1✔
776
        assert!(output.contains("<rect"));
1✔
777
    }
1✔
778

779
    #[test]
780
    fn test_ansi_backend_produces_escapes() {
1✔
781
        let layout = make_test_layout();
1✔
782
        let opts = RenderOptions::default();
1✔
783
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
784

785
        // Should contain ANSI escape codes
786
        assert!(
1✔
787
            output.contains("\x1b["),
1✔
788
            "output should contain ANSI escapes"
789
        );
790
    }
1✔
791
}
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