facet_diff_core/layout/build.rs
1//! Build a Layout from a Diff.
2//!
3//! This module converts a `Diff<'mem, 'facet>` into a `Layout` that can be rendered.
4//!
5//! # Architecture
6//!
7//! The build process walks the Diff tree while simultaneously navigating the original
8//! `from` and `to` Peek values. This allows us to:
9//! - Look up unchanged field values from the original structs
10//! - Decide whether to show unchanged fields or collapse them
11//!
12//! The Diff itself only stores what changed - the original Peeks provide context.
13
14use std::borrow::Cow;
15use tracing::debug;
16
17use facet_core::{Def, NumericType, PrimitiveType, Shape, StructKind, TextualType, Type, UserType};
18use facet_reflect::Peek;
19use indextree::{Arena, NodeId};
20
21use super::{
22 Attr, DiffFlavor, ElementChange, FormatArena, FormattedValue, Layout, LayoutNode, ValueType,
23 group_changed_attrs,
24};
25use crate::{Diff, ReplaceGroup, Updates, UpdatesGroup, Value};
26
27/// Get the display name for a shape, respecting the `rename` attribute.
28fn get_shape_display_name(shape: &Shape) -> &'static str {
29 if let Some(renamed) = shape.get_builtin_attr_value::<&str>("rename") {
30 return renamed;
31 }
32 shape.type_identifier
33}
34
35/// Check if a shape has any XML namespace attributes (ns_all, rename in xml namespace, etc.)
36/// Shapes without XML attributes are "proxy types" - Rust implementation details
37/// that wouldn't exist in actual XML output.
38fn shape_has_xml_attrs(shape: &Shape) -> bool {
39 shape.attributes.iter().any(|attr| attr.ns == Some("xml"))
40}
41
42/// Get display name for XML output, prefixing proxy types with `@`.
43/// Proxy types are structs without XML namespace attributes - they're Rust
44/// implementation details (like PathData) that represent something that would
45/// be different in actual XML (like a string attribute).
46fn get_xml_display_name(shape: &Shape) -> &'static str {
47 let base_name = get_shape_display_name(shape);
48
49 // Check if this is a struct without XML attributes (a proxy type)
50 if let Type::User(UserType::Struct(_)) = shape.ty
51 && !shape_has_xml_attrs(shape)
52 {
53 // Leak the formatted string - acceptable for diff tags which are few and short-lived
54 return Box::leak(format!("@{}", base_name).into_boxed_str());
55 }
56
57 base_name
58}
59
60/// Get the display name for an enum variant, respecting the `rename` attribute.
61fn get_variant_display_name(variant: &facet_core::Variant) -> &'static str {
62 if let Some(attr) = variant.get_builtin_attr("rename")
63 && let Some(renamed) = attr.get_as::<&'static str>()
64 {
65 return renamed;
66 }
67 variant.name
68}
69
70/// Check if a value should be skipped in diff output.
71///
72/// Returns true for "falsy" values like `Option::None`, empty strings, empty vecs, etc.
73/// This is used to avoid cluttering diff output with unchanged `None` fields.
74fn should_skip_falsy(peek: Peek<'_, '_>) -> bool {
75 let shape = peek.shape();
76 // Check if the type has a truthiness function (Option<T>, Vec<T>, str, etc.)
77 if let Some(truthy_fn) = shape.truthiness_fn() {
78 // If the value is falsy, skip it
79 let is_truthy = unsafe { truthy_fn(peek.data()) };
80 let should_skip = !is_truthy;
81 debug!(
82 type_id = %shape.type_identifier,
83 is_truthy,
84 should_skip,
85 "should_skip_falsy check"
86 );
87 return should_skip;
88 }
89 false
90}
91
92/// Determine the type of a value for coloring purposes.
93fn determine_value_type(peek: Peek<'_, '_>) -> ValueType {
94 let shape = peek.shape();
95
96 // Check the Def first for special types like Option
97 if let Def::Option(_) = shape.def {
98 // Check if it's None
99 if let Ok(opt) = peek.into_option() {
100 if opt.is_none() {
101 return ValueType::Null;
102 }
103 // If Some, recurse to get inner type
104 if let Some(inner) = opt.value() {
105 return determine_value_type(inner);
106 }
107 }
108 return ValueType::Other;
109 }
110
111 // Check the Type for primitives
112 match shape.ty {
113 Type::Primitive(p) => match p {
114 PrimitiveType::Boolean => ValueType::Boolean,
115 PrimitiveType::Numeric(NumericType::Integer { .. })
116 | PrimitiveType::Numeric(NumericType::Float) => ValueType::Number,
117 PrimitiveType::Textual(TextualType::Char)
118 | PrimitiveType::Textual(TextualType::Str) => ValueType::String,
119 PrimitiveType::Never => ValueType::Null,
120 },
121 _ => ValueType::Other,
122 }
123}
124
125/// Options for building a layout from a diff.
126#[derive(Clone, Debug)]
127pub struct BuildOptions {
128 /// Maximum line width for attribute grouping.
129 pub max_line_width: usize,
130 /// Maximum number of unchanged fields to show inline.
131 /// If more than this many unchanged fields exist, collapse to "N unchanged".
132 pub max_unchanged_fields: usize,
133 /// Minimum run length to collapse unchanged sequence elements.
134 pub collapse_threshold: usize,
135 /// Precision for formatting floating-point numbers.
136 /// If set, floats are formatted with this many decimal places.
137 /// Useful when using float tolerance in comparisons.
138 pub float_precision: Option<usize>,
139}
140
141impl Default for BuildOptions {
142 fn default() -> Self {
143 Self {
144 max_line_width: 80,
145 max_unchanged_fields: 5,
146 collapse_threshold: 3,
147 float_precision: None,
148 }
149 }
150}
151
152impl BuildOptions {
153 /// Set the float precision for formatting.
154 ///
155 /// When set, all floating-point numbers will be formatted with this many
156 /// decimal places. This is useful when using float tolerance in comparisons
157 /// to ensure the display matches the tolerance level.
158 pub fn with_float_precision(mut self, precision: usize) -> Self {
159 self.float_precision = Some(precision);
160 self
161 }
162}
163
164/// Build a Layout from a Diff.
165///
166/// This is the main entry point for converting a diff into a renderable layout.
167///
168/// # Arguments
169///
170/// * `diff` - The diff to render
171/// * `from` - The original "from" value (for looking up unchanged fields)
172/// * `to` - The original "to" value (for looking up unchanged fields)
173/// * `opts` - Build options
174/// * `flavor` - The output flavor (Rust, JSON, XML)
175pub fn build_layout<'mem, 'facet, F: DiffFlavor>(
176 diff: &Diff<'mem, 'facet>,
177 from: Peek<'mem, 'facet>,
178 to: Peek<'mem, 'facet>,
179 opts: &BuildOptions,
180 flavor: &F,
181) -> Layout {
182 let mut builder = LayoutBuilder::new(opts.clone(), flavor);
183 let root_id = builder.build(diff, Some(from), Some(to));
184 builder.finish(root_id)
185}
186
187/// Internal builder state.
188struct LayoutBuilder<'f, F: DiffFlavor> {
189 /// Arena for formatted strings.
190 strings: FormatArena,
191 /// Arena for layout nodes.
192 tree: Arena<LayoutNode>,
193 /// Build options.
194 opts: BuildOptions,
195 /// Output flavor for formatting.
196 flavor: &'f F,
197}
198
199impl<'f, F: DiffFlavor> LayoutBuilder<'f, F> {
200 fn new(opts: BuildOptions, flavor: &'f F) -> Self {
201 Self {
202 strings: FormatArena::new(),
203 tree: Arena::new(),
204 opts,
205 flavor,
206 }
207 }
208
209 /// Build the layout from a diff, with optional context Peeks.
210 fn build<'mem, 'facet>(
211 &mut self,
212 diff: &Diff<'mem, 'facet>,
213 from: Option<Peek<'mem, 'facet>>,
214 to: Option<Peek<'mem, 'facet>>,
215 ) -> NodeId {
216 self.build_diff(diff, from, to, ElementChange::None)
217 }
218
219 /// Build a node from a diff with a given element change type.
220 fn build_diff<'mem, 'facet>(
221 &mut self,
222 diff: &Diff<'mem, 'facet>,
223 from: Option<Peek<'mem, 'facet>>,
224 to: Option<Peek<'mem, 'facet>>,
225 change: ElementChange,
226 ) -> NodeId {
227 match diff {
228 Diff::Equal { value } => {
229 // For equal values, render as unchanged text
230 if let Some(peek) = value {
231 self.build_peek(*peek, ElementChange::None)
232 } else {
233 // No value available, create a placeholder
234 let (span, width) = self.strings.push_str("(equal)");
235 let value = FormattedValue::new(span, width);
236 self.tree.new_node(LayoutNode::Text {
237 value,
238 change: ElementChange::None,
239 })
240 }
241 }
242 Diff::Replace { from, to } => {
243 // Create a container element with deleted and inserted children
244 let root = self.tree.new_node(LayoutNode::element("_replace"));
245
246 let from_node = self.build_peek(*from, ElementChange::Deleted);
247 let to_node = self.build_peek(*to, ElementChange::Inserted);
248
249 root.append(from_node, &mut self.tree);
250 root.append(to_node, &mut self.tree);
251
252 root
253 }
254 Diff::User {
255 from: from_shape,
256 to: _to_shape,
257 variant,
258 value,
259 } => {
260 // Handle Option<T> transparently - don't create an <Option> element wrapper
261 // Option is a Rust implementation detail that shouldn't leak into XML diff output
262 if matches!(from_shape.def, Def::Option(_))
263 && let Value::Tuple { updates } = value
264 {
265 // Unwrap from/to to get inner Option values
266 let inner_from =
267 from.and_then(|p| p.into_option().ok().and_then(|opt| opt.value()));
268 let inner_to =
269 to.and_then(|p| p.into_option().ok().and_then(|opt| opt.value()));
270
271 // Build updates without an Option wrapper
272 // Use a transparent container that just holds the children
273 return self.build_tuple_transparent(updates, inner_from, inner_to, change);
274 }
275
276 // Handle enum variants transparently - use variant name as tag
277 // This makes enums like SvgNode::Path render as <path> not <SvgNode><Path>
278 if let Some(variant_name) = *variant
279 && let Type::User(UserType::Enum(enum_ty)) = from_shape.ty
280 {
281 // Look up the variant to get the rename attribute
282 let tag =
283 if let Some(v) = enum_ty.variants.iter().find(|v| v.name == variant_name) {
284 get_variant_display_name(v)
285 } else {
286 variant_name
287 };
288 debug!(
289 tag,
290 variant_name, "Diff::User enum variant - using variant tag"
291 );
292
293 // For tuple variants (newtypes), make them transparent
294 if let Value::Tuple { updates } = value {
295 // Unwrap from/to to get inner enum values
296 let inner_from = from.and_then(|p| {
297 p.into_enum().ok().and_then(|e| e.field(0).ok().flatten())
298 });
299 let inner_to = to.and_then(|p| {
300 p.into_enum().ok().and_then(|e| e.field(0).ok().flatten())
301 });
302
303 // Build the inner content with the variant tag
304 return self
305 .build_enum_tuple_variant(tag, updates, inner_from, inner_to, change);
306 }
307
308 // For struct variants, use the variant tag directly
309 if let Value::Struct {
310 updates,
311 deletions,
312 insertions,
313 unchanged,
314 } = value
315 {
316 return self.build_struct(
317 tag, None, updates, deletions, insertions, unchanged, from, to, change,
318 );
319 }
320 }
321
322 // Get type name for the tag, respecting `rename` attribute
323 // Use get_xml_display_name to prefix proxy types with `@`
324 let tag = get_xml_display_name(from_shape);
325 debug!(%tag, variant = ?variant, value_type = ?std::mem::discriminant(value), "Diff::User");
326
327 match value {
328 Value::Struct {
329 updates,
330 deletions,
331 insertions,
332 unchanged,
333 } => self.build_struct(
334 tag, *variant, updates, deletions, insertions, unchanged, from, to, change,
335 ),
336 Value::Tuple { updates } => {
337 debug!(tag, "Value::Tuple - building tuple");
338 self.build_tuple(tag, *variant, updates, from, to, change)
339 }
340 }
341 }
342 Diff::Sequence {
343 from: _seq_shape_from,
344 to: _seq_shape_to,
345 updates,
346 } => {
347 // Get item type from the from/to Peek values passed to build_diff
348 let item_type = from
349 .and_then(|p| p.into_list_like().ok())
350 .and_then(|list| list.iter().next())
351 .or_else(|| {
352 to.and_then(|p| p.into_list_like().ok())
353 .and_then(|list| list.iter().next())
354 })
355 .map(|item| get_shape_display_name(item.shape()))
356 .unwrap_or("item");
357 self.build_sequence(updates, change, item_type)
358 }
359 }
360 }
361
362 /// Build a node from a Peek value.
363 fn build_peek(&mut self, peek: Peek<'_, '_>, change: ElementChange) -> NodeId {
364 let shape = peek.shape();
365 debug!(
366 type_id = %shape.type_identifier,
367 def = ?shape.def,
368 change = ?change,
369 "build_peek"
370 );
371
372 // Check if this is a struct we can recurse into
373 match (shape.def, shape.ty) {
374 // Handle Option<T> by unwrapping to the inner value
375 (Def::Option(_), _) => {
376 if let Ok(opt) = peek.into_option()
377 && let Some(inner) = opt.value()
378 {
379 // Recurse into the inner value
380 return self.build_peek(inner, change);
381 }
382 // None - render as null text
383 let (span, width) = self.strings.push_str("null");
384 return self.tree.new_node(LayoutNode::Text {
385 value: FormattedValue::with_type(span, width, ValueType::Null),
386 change,
387 });
388 }
389 (_, Type::User(UserType::Struct(ty))) if ty.kind == StructKind::Struct => {
390 // Build as element with fields as attributes
391 if let Ok(struct_peek) = peek.into_struct() {
392 let tag = get_xml_display_name(shape);
393 let mut attrs = Vec::new();
394
395 for (i, field) in ty.fields.iter().enumerate() {
396 if let Ok(field_value) = struct_peek.field(i) {
397 // Skip falsy values (e.g., Option::None)
398 if should_skip_falsy(field_value) {
399 continue;
400 }
401 let formatted_value = self.format_peek(field_value);
402 let attr = match change {
403 ElementChange::None => {
404 Attr::unchanged(field.name, field.name.len(), formatted_value)
405 }
406 ElementChange::Deleted => {
407 Attr::deleted(field.name, field.name.len(), formatted_value)
408 }
409 ElementChange::Inserted => {
410 Attr::inserted(field.name, field.name.len(), formatted_value)
411 }
412 ElementChange::MovedFrom | ElementChange::MovedTo => {
413 // For moved elements, show fields as unchanged
414 Attr::unchanged(field.name, field.name.len(), formatted_value)
415 }
416 };
417 attrs.push(attr);
418 }
419 }
420
421 let changed_groups = group_changed_attrs(&attrs, self.opts.max_line_width, 0);
422
423 return self.tree.new_node(LayoutNode::Element {
424 tag,
425 field_name: None,
426 attrs,
427 changed_groups,
428 change,
429 });
430 }
431 }
432 (_, Type::User(UserType::Enum(_))) => {
433 // Build enum as element with variant name as tag (respecting rename attribute)
434 debug!(type_id = %shape.type_identifier, "processing enum");
435 if let Ok(enum_peek) = peek.into_enum()
436 && let Ok(variant) = enum_peek.active_variant()
437 {
438 let tag = get_variant_display_name(variant);
439 let fields = &variant.data.fields;
440 debug!(
441 variant_name = tag,
442 fields_count = fields.len(),
443 "enum variant"
444 );
445
446 // If variant has fields, build as element with those fields
447 if !fields.is_empty() {
448 // Check for newtype pattern: single field with same-named inner type
449 // e.g., `Circle(Circle)` where we want to show Circle's fields directly
450 if fields.len() == 1
451 && let Ok(Some(inner_value)) = enum_peek.field(0)
452 {
453 let inner_shape = inner_value.shape();
454 // If it's a struct, recurse into it but use the variant name
455 if let Type::User(UserType::Struct(s)) = inner_shape.ty
456 && s.kind == StructKind::Struct
457 && let Ok(struct_peek) = inner_value.into_struct()
458 {
459 let mut attrs = Vec::new();
460
461 for (i, field) in s.fields.iter().enumerate() {
462 if let Ok(field_value) = struct_peek.field(i) {
463 // Skip falsy values (e.g., Option::None)
464 if should_skip_falsy(field_value) {
465 continue;
466 }
467 let formatted_value = self.format_peek(field_value);
468 let attr = match change {
469 ElementChange::None => Attr::unchanged(
470 field.name,
471 field.name.len(),
472 formatted_value,
473 ),
474 ElementChange::Deleted => Attr::deleted(
475 field.name,
476 field.name.len(),
477 formatted_value,
478 ),
479 ElementChange::Inserted => Attr::inserted(
480 field.name,
481 field.name.len(),
482 formatted_value,
483 ),
484 ElementChange::MovedFrom | ElementChange::MovedTo => {
485 Attr::unchanged(
486 field.name,
487 field.name.len(),
488 formatted_value,
489 )
490 }
491 };
492 attrs.push(attr);
493 }
494 }
495
496 let changed_groups =
497 group_changed_attrs(&attrs, self.opts.max_line_width, 0);
498
499 return self.tree.new_node(LayoutNode::Element {
500 tag,
501 field_name: None,
502 attrs,
503 changed_groups,
504 change,
505 });
506 }
507 }
508
509 // General case: show variant fields directly
510 let mut attrs = Vec::new();
511
512 for (i, field) in fields.iter().enumerate() {
513 if let Ok(Some(field_value)) = enum_peek.field(i) {
514 // Skip falsy values (e.g., Option::None)
515 if should_skip_falsy(field_value) {
516 continue;
517 }
518 let formatted_value = self.format_peek(field_value);
519 let attr = match change {
520 ElementChange::None => Attr::unchanged(
521 field.name,
522 field.name.len(),
523 formatted_value,
524 ),
525 ElementChange::Deleted => {
526 Attr::deleted(field.name, field.name.len(), formatted_value)
527 }
528 ElementChange::Inserted => Attr::inserted(
529 field.name,
530 field.name.len(),
531 formatted_value,
532 ),
533 ElementChange::MovedFrom | ElementChange::MovedTo => {
534 Attr::unchanged(
535 field.name,
536 field.name.len(),
537 formatted_value,
538 )
539 }
540 };
541 attrs.push(attr);
542 }
543 }
544
545 let changed_groups =
546 group_changed_attrs(&attrs, self.opts.max_line_width, 0);
547
548 return self.tree.new_node(LayoutNode::Element {
549 tag,
550 field_name: None,
551 attrs,
552 changed_groups,
553 change,
554 });
555 } else {
556 // Unit variant - just show the variant name as text
557 let (span, width) = self.strings.push_str(tag);
558 return self.tree.new_node(LayoutNode::Text {
559 value: FormattedValue::new(span, width),
560 change,
561 });
562 }
563 }
564 }
565 _ => {}
566 }
567
568 // Default: format as text
569 let formatted = self.format_peek(peek);
570 self.tree.new_node(LayoutNode::Text {
571 value: formatted,
572 change,
573 })
574 }
575
576 /// Build a struct diff as an element with attributes.
577 #[allow(clippy::too_many_arguments)]
578 fn build_struct<'mem, 'facet>(
579 &mut self,
580 tag: &'static str,
581 variant: Option<&'static str>,
582 updates: &std::collections::HashMap<Cow<'static, str>, Diff<'mem, 'facet>>,
583 deletions: &std::collections::HashMap<Cow<'static, str>, Peek<'mem, 'facet>>,
584 insertions: &std::collections::HashMap<Cow<'static, str>, Peek<'mem, 'facet>>,
585 unchanged: &std::collections::HashSet<Cow<'static, str>>,
586 from: Option<Peek<'mem, 'facet>>,
587 to: Option<Peek<'mem, 'facet>>,
588 change: ElementChange,
589 ) -> NodeId {
590 let element_tag = tag;
591
592 // If there's a variant, we should indicate it somehow.
593 // TODO: LayoutNode::Element should have an optional variant: Option<&'static str>
594 if variant.is_some() {
595 // For now, just use the tag
596 }
597
598 let mut attrs = Vec::new();
599 let mut child_nodes = Vec::new();
600
601 // Handle unchanged fields - try to get values from the original Peek
602 debug!(
603 unchanged_count = unchanged.len(),
604 updates_count = updates.len(),
605 deletions_count = deletions.len(),
606 insertions_count = insertions.len(),
607 unchanged_fields = ?unchanged.iter().collect::<Vec<_>>(),
608 updates_fields = ?updates.keys().collect::<Vec<_>>(),
609 "build_struct"
610 );
611 if !unchanged.is_empty() {
612 let unchanged_count = unchanged.len();
613
614 if unchanged_count <= self.opts.max_unchanged_fields {
615 // Show unchanged fields with their values (if we have the original Peek)
616 if let Some(from_peek) = from {
617 if let Ok(struct_peek) = from_peek.into_struct() {
618 let mut sorted_unchanged: Vec<_> = unchanged.iter().collect();
619 sorted_unchanged.sort();
620
621 for field_name in sorted_unchanged {
622 if let Ok(field_value) = struct_peek.field_by_name(field_name) {
623 let field_shape = field_value.shape();
624 debug!(
625 field_name = %field_name,
626 field_type = %field_shape.type_identifier,
627 "processing unchanged field"
628 );
629 // Skip falsy values (e.g., Option::None) in unchanged fields
630 if should_skip_falsy(field_value) {
631 debug!(field_name = %field_name, "skipping falsy field");
632 continue;
633 }
634 let formatted = self.format_peek(field_value);
635 let name_width = field_name.len();
636 let attr =
637 Attr::unchanged(field_name.clone(), name_width, formatted);
638 attrs.push(attr);
639 }
640 }
641 }
642 } else {
643 // No original Peek available - add a collapsed placeholder
644 // We'll handle this after building the element
645 }
646 }
647 // If more than max_unchanged_fields, we'll add a collapsed node as a child
648 }
649
650 // Process updates - these become changed attributes or nested children
651 let mut sorted_updates: Vec<_> = updates.iter().collect();
652 sorted_updates.sort_by(|(a, _), (b, _)| a.cmp(b));
653
654 for (field_name, field_diff) in sorted_updates {
655 // Navigate into the field in from/to Peeks for nested context
656 let field_from = from.and_then(|p| {
657 p.into_struct()
658 .ok()
659 .and_then(|s| s.field_by_name(field_name).ok())
660 });
661 let field_to = to.and_then(|p| {
662 p.into_struct()
663 .ok()
664 .and_then(|s| s.field_by_name(field_name).ok())
665 });
666
667 match field_diff {
668 Diff::Replace { from, to } => {
669 // Check if this is a complex type that should be built as children
670 let from_shape = from.shape();
671 let is_complex = match from_shape.ty {
672 Type::User(UserType::Enum(_)) => true,
673 Type::User(UserType::Struct(s)) if s.kind == StructKind::Struct => true,
674 _ => false,
675 };
676
677 if is_complex {
678 // Build from/to as separate child elements
679 let from_node = self.build_peek(*from, ElementChange::Deleted);
680 let to_node = self.build_peek(*to, ElementChange::Inserted);
681
682 // Set field name on both nodes
683 if let Cow::Borrowed(name) = field_name {
684 if let Some(node) = self.tree.get_mut(from_node)
685 && let LayoutNode::Element { field_name, .. } = node.get_mut()
686 {
687 *field_name = Some(name);
688 }
689 if let Some(node) = self.tree.get_mut(to_node)
690 && let LayoutNode::Element { field_name, .. } = node.get_mut()
691 {
692 *field_name = Some(name);
693 }
694 }
695
696 child_nodes.push(from_node);
697 child_nodes.push(to_node);
698 } else {
699 // Scalar replacement - show as changed attribute
700 let old_value = self.format_peek(*from);
701 let new_value = self.format_peek(*to);
702 let name_width = field_name.len();
703 let attr =
704 Attr::changed(field_name.clone(), name_width, old_value, new_value);
705 attrs.push(attr);
706 }
707 }
708 // Handle Option<scalar> as attribute changes, not children
709 Diff::User {
710 from: shape,
711 value: Value::Tuple { .. },
712 ..
713 } if matches!(shape.def, Def::Option(_)) => {
714 // Check if we can get scalar values from the Option
715 if let (Some(from_peek), Some(to_peek)) = (field_from, field_to) {
716 // Unwrap Option to get inner values
717 let inner_from = from_peek.into_option().ok().and_then(|opt| opt.value());
718 let inner_to = to_peek.into_option().ok().and_then(|opt| opt.value());
719
720 if let (Some(from_val), Some(to_val)) = (inner_from, inner_to) {
721 // Check if inner type is scalar (not struct/enum)
722 let is_scalar = match from_val.shape().ty {
723 Type::User(UserType::Enum(_)) => false,
724 Type::User(UserType::Struct(s)) if s.kind == StructKind::Struct => {
725 false
726 }
727 _ => true,
728 };
729
730 if is_scalar {
731 // Treat as scalar attribute change
732 let old_value = self.format_peek(from_val);
733 let new_value = self.format_peek(to_val);
734 let name_width = field_name.len();
735 let attr = Attr::changed(
736 field_name.clone(),
737 name_width,
738 old_value,
739 new_value,
740 );
741 attrs.push(attr);
742 continue;
743 }
744 }
745 }
746
747 // Fall through to child handling if not a simple scalar Option
748 let child =
749 self.build_diff(field_diff, field_from, field_to, ElementChange::None);
750 if let Cow::Borrowed(name) = field_name
751 && let Some(node) = self.tree.get_mut(child)
752 {
753 match node.get_mut() {
754 LayoutNode::Element { field_name, .. } => {
755 *field_name = Some(name);
756 }
757 LayoutNode::Sequence { field_name, .. } => {
758 *field_name = Some(name);
759 }
760 _ => {}
761 }
762 }
763 child_nodes.push(child);
764 }
765 // Handle single-field wrapper structs (like SvgStyle) as inline attributes
766 // instead of nested child elements
767 Diff::User {
768 from: inner_shape,
769 value:
770 Value::Struct {
771 updates: inner_updates,
772 deletions: inner_deletions,
773 insertions: inner_insertions,
774 unchanged: inner_unchanged,
775 },
776 ..
777 } if inner_updates.len() == 1
778 && inner_deletions.is_empty()
779 && inner_insertions.is_empty()
780 && inner_unchanged.is_empty() =>
781 {
782 // Single-field struct with one update - check if it's a scalar change
783 let (inner_field_name, inner_field_diff) = inner_updates.iter().next().unwrap();
784
785 // Check if the inner field's change is a scalar Replace
786 if let Diff::Replace {
787 from: inner_from,
788 to: inner_to,
789 } = inner_field_diff
790 {
791 // Check if inner type is scalar (not struct/enum)
792 let is_scalar = match inner_from.shape().ty {
793 Type::User(UserType::Enum(_)) => false,
794 Type::User(UserType::Struct(s)) if s.kind == StructKind::Struct => {
795 false
796 }
797 _ => true,
798 };
799
800 if is_scalar {
801 // Inline as attribute change using the parent field name
802 debug!(
803 field_name = %field_name,
804 inner_type = %inner_shape.type_identifier,
805 inner_field = %inner_field_name,
806 "inlining single-field wrapper as attribute"
807 );
808 let old_value = self.format_peek(*inner_from);
809 let new_value = self.format_peek(*inner_to);
810 let name_width = field_name.len();
811 let attr =
812 Attr::changed(field_name.clone(), name_width, old_value, new_value);
813 attrs.push(attr);
814 continue;
815 }
816 }
817
818 // Fall through to default child handling
819 let child =
820 self.build_diff(field_diff, field_from, field_to, ElementChange::None);
821 if let Cow::Borrowed(name) = field_name
822 && let Some(node) = self.tree.get_mut(child)
823 {
824 match node.get_mut() {
825 LayoutNode::Element { field_name, .. } => {
826 *field_name = Some(name);
827 }
828 LayoutNode::Sequence { field_name, .. } => {
829 *field_name = Some(name);
830 }
831 _ => {}
832 }
833 }
834 child_nodes.push(child);
835 }
836 _ => {
837 // Nested diff - build as child element or sequence
838 let child =
839 self.build_diff(field_diff, field_from, field_to, ElementChange::None);
840
841 // Set the field name on the child (only for borrowed names for now)
842 // TODO: Support owned field names for nested elements
843 if let Cow::Borrowed(name) = field_name
844 && let Some(node) = self.tree.get_mut(child)
845 {
846 match node.get_mut() {
847 LayoutNode::Element { field_name, .. } => {
848 *field_name = Some(name);
849 }
850 LayoutNode::Sequence { field_name, .. } => {
851 *field_name = Some(name);
852 }
853 _ => {}
854 }
855 }
856
857 child_nodes.push(child);
858 }
859 }
860 }
861
862 // Process deletions
863 let mut sorted_deletions: Vec<_> = deletions.iter().collect();
864 sorted_deletions.sort_by(|(a, _), (b, _)| a.cmp(b));
865
866 for (field_name, value) in sorted_deletions {
867 let formatted = self.format_peek(*value);
868 let name_width = field_name.len();
869 let attr = Attr::deleted(field_name.clone(), name_width, formatted);
870 attrs.push(attr);
871 }
872
873 // Process insertions
874 let mut sorted_insertions: Vec<_> = insertions.iter().collect();
875 sorted_insertions.sort_by(|(a, _), (b, _)| a.cmp(b));
876
877 for (field_name, value) in sorted_insertions {
878 let formatted = self.format_peek(*value);
879 let name_width = field_name.len();
880 let attr = Attr::inserted(field_name.clone(), name_width, formatted);
881 attrs.push(attr);
882 }
883
884 // Group changed attributes for alignment
885 let changed_groups = group_changed_attrs(&attrs, self.opts.max_line_width, 0);
886
887 // Create the element node
888 let node = self.tree.new_node(LayoutNode::Element {
889 tag: element_tag,
890 field_name: None, // Will be set by parent if this is a struct field
891 attrs,
892 changed_groups,
893 change,
894 });
895
896 // Add children
897 for child in child_nodes {
898 node.append(child, &mut self.tree);
899 }
900
901 // Add collapsed unchanged fields indicator if needed
902 let unchanged_count = unchanged.len();
903 if unchanged_count > self.opts.max_unchanged_fields
904 || (unchanged_count > 0 && from.is_none())
905 {
906 let collapsed = self.tree.new_node(LayoutNode::collapsed(unchanged_count));
907 node.append(collapsed, &mut self.tree);
908 }
909
910 node
911 }
912
913 /// Build a tuple diff.
914 fn build_tuple<'mem, 'facet>(
915 &mut self,
916 tag: &'static str,
917 variant: Option<&'static str>,
918 updates: &Updates<'mem, 'facet>,
919 _from: Option<Peek<'mem, 'facet>>,
920 _to: Option<Peek<'mem, 'facet>>,
921 change: ElementChange,
922 ) -> NodeId {
923 // Same variant issue as build_struct
924 if variant.is_some() {
925 // TODO: LayoutNode::Element should support variant display
926 }
927
928 // Create element for the tuple
929 let node = self.tree.new_node(LayoutNode::Element {
930 tag,
931 field_name: None,
932 attrs: Vec::new(),
933 changed_groups: Vec::new(),
934 change,
935 });
936
937 // Build children from updates (tuple items don't have specific type names)
938 self.build_updates_children(node, updates, "item");
939
940 node
941 }
942
943 /// Build a tuple diff without a wrapper element (for transparent types like Option).
944 ///
945 /// This builds the updates directly without creating a containing element.
946 /// If there's a single child, returns it directly. Otherwise returns
947 /// a transparent wrapper element.
948 fn build_tuple_transparent<'mem, 'facet>(
949 &mut self,
950 updates: &Updates<'mem, 'facet>,
951 _from: Option<Peek<'mem, 'facet>>,
952 _to: Option<Peek<'mem, 'facet>>,
953 change: ElementChange,
954 ) -> NodeId {
955 // Create a temporary container to collect children
956 let temp = self.tree.new_node(LayoutNode::Element {
957 tag: "_transparent",
958 field_name: None,
959 attrs: Vec::new(),
960 changed_groups: Vec::new(),
961 change,
962 });
963
964 // Build children into the temporary container
965 self.build_updates_children(temp, updates, "item");
966
967 // Check how many children we have
968 let children: Vec<_> = temp.children(&self.tree).collect();
969
970 if children.len() == 1 {
971 // Single child - detach it from temp and return it directly
972 let child = children[0];
973 child.detach(&mut self.tree);
974 // Remove the temporary node
975 temp.remove(&mut self.tree);
976 child
977 } else {
978 // Multiple children or none - return the container
979 // (it will render as transparent due to the "_transparent" tag)
980 temp
981 }
982 }
983
984 /// Build an enum tuple variant (newtype pattern) with the variant tag.
985 ///
986 /// For enums like `SvgNode::Path(Path)`, this:
987 /// 1. Uses the variant's renamed tag (e.g., "path") as the element name
988 /// 2. Extracts the inner struct's fields as element attributes
989 ///
990 /// This makes enum variants transparent in the diff output.
991 fn build_enum_tuple_variant<'mem, 'facet>(
992 &mut self,
993 tag: &'static str,
994 updates: &Updates<'mem, 'facet>,
995 inner_from: Option<Peek<'mem, 'facet>>,
996 inner_to: Option<Peek<'mem, 'facet>>,
997 change: ElementChange,
998 ) -> NodeId {
999 // Check if this is a single-element tuple (newtype pattern)
1000 // For newtype variants, the updates should contain a single diff for the inner value
1001 let interspersed = &updates.0;
1002
1003 // Check for a single replacement (1 removal + 1 addition) in the first update group
1004 // This handles cases where the inner struct is fully replaced
1005 if let Some(update_group) = &interspersed.first {
1006 let group_interspersed = &update_group.0;
1007
1008 // Check the first ReplaceGroup for a single replacement
1009 if let Some(replace_group) = &group_interspersed.first
1010 && replace_group.removals.len() == 1
1011 && replace_group.additions.len() == 1
1012 {
1013 let from = replace_group.removals[0];
1014 let to = replace_group.additions[0];
1015
1016 // Compare fields and only show those that actually differ
1017 let mut attrs = Vec::new();
1018
1019 if let (Ok(from_struct), Ok(to_struct)) = (from.into_struct(), to.into_struct())
1020 && let Type::User(UserType::Struct(ty)) = from.shape().ty
1021 {
1022 for (i, field) in ty.fields.iter().enumerate() {
1023 let from_value = from_struct.field(i).ok();
1024 let to_value = to_struct.field(i).ok();
1025
1026 match (from_value, to_value) {
1027 (Some(fv), Some(tv)) => {
1028 // Both present - compare formatted values
1029 let from_formatted = self.format_peek(fv);
1030 let to_formatted = self.format_peek(tv);
1031
1032 if self.strings.get(from_formatted.span)
1033 != self.strings.get(to_formatted.span)
1034 {
1035 // Values differ - show as changed
1036 attrs.push(Attr::changed(
1037 Cow::Borrowed(field.name),
1038 field.name.len(),
1039 from_formatted,
1040 to_formatted,
1041 ));
1042 } else {
1043 // Values same - show as unchanged (if not falsy)
1044 if !should_skip_falsy(fv) {
1045 attrs.push(Attr::unchanged(
1046 Cow::Borrowed(field.name),
1047 field.name.len(),
1048 from_formatted,
1049 ));
1050 }
1051 }
1052 }
1053 (Some(fv), None) => {
1054 // Only in from - deleted
1055 if !should_skip_falsy(fv) {
1056 let formatted = self.format_peek(fv);
1057 attrs.push(Attr::deleted(
1058 Cow::Borrowed(field.name),
1059 field.name.len(),
1060 formatted,
1061 ));
1062 }
1063 }
1064 (None, Some(tv)) => {
1065 // Only in to - inserted
1066 if !should_skip_falsy(tv) {
1067 let formatted = self.format_peek(tv);
1068 attrs.push(Attr::inserted(
1069 Cow::Borrowed(field.name),
1070 field.name.len(),
1071 formatted,
1072 ));
1073 }
1074 }
1075 (None, None) => {
1076 // Neither present - skip
1077 }
1078 }
1079 }
1080 }
1081
1082 let changed_groups = group_changed_attrs(&attrs, self.opts.max_line_width, 0);
1083
1084 return self.tree.new_node(LayoutNode::Element {
1085 tag,
1086 field_name: None,
1087 attrs,
1088 changed_groups,
1089 change,
1090 });
1091 }
1092 }
1093
1094 // Try to find the single nested diff
1095 let single_diff = {
1096 let mut found_diff: Option<&Diff<'mem, 'facet>> = None;
1097
1098 // Check first update group
1099 if let Some(update_group) = &interspersed.first {
1100 let group_interspersed = &update_group.0;
1101
1102 // Check for nested diffs in the first group
1103 if let Some(diffs) = &group_interspersed.last
1104 && diffs.len() == 1
1105 && found_diff.is_none()
1106 {
1107 found_diff = Some(&diffs[0]);
1108 }
1109 for (diffs, _replace) in &group_interspersed.values {
1110 if diffs.len() == 1 && found_diff.is_none() {
1111 found_diff = Some(&diffs[0]);
1112 }
1113 }
1114 }
1115
1116 found_diff
1117 };
1118
1119 // If we have a single nested diff, handle it with our variant tag
1120 if let Some(diff) = single_diff {
1121 match diff {
1122 Diff::User {
1123 value:
1124 Value::Struct {
1125 updates,
1126 deletions,
1127 insertions,
1128 unchanged,
1129 },
1130 ..
1131 } => {
1132 // Build the struct with our variant tag
1133 return self.build_struct(
1134 tag, None, updates, deletions, insertions, unchanged, inner_from, inner_to,
1135 change,
1136 );
1137 }
1138 Diff::Replace { from, to } => {
1139 // For replacements, show both values as attributes with change markers
1140 // This handles cases where the inner struct is fully different
1141 let mut attrs = Vec::new();
1142
1143 // Build attrs from the "from" struct (deleted)
1144 if let Ok(struct_peek) = from.into_struct()
1145 && let Type::User(UserType::Struct(ty)) = from.shape().ty
1146 {
1147 for (i, field) in ty.fields.iter().enumerate() {
1148 if let Ok(field_value) = struct_peek.field(i) {
1149 if should_skip_falsy(field_value) {
1150 continue;
1151 }
1152 let formatted = self.format_peek(field_value);
1153 attrs.push(Attr::deleted(
1154 Cow::Borrowed(field.name),
1155 field.name.len(),
1156 formatted,
1157 ));
1158 }
1159 }
1160 }
1161
1162 // Build attrs from the "to" struct (inserted)
1163 if let Ok(struct_peek) = to.into_struct()
1164 && let Type::User(UserType::Struct(ty)) = to.shape().ty
1165 {
1166 for (i, field) in ty.fields.iter().enumerate() {
1167 if let Ok(field_value) = struct_peek.field(i) {
1168 if should_skip_falsy(field_value) {
1169 continue;
1170 }
1171 let formatted = self.format_peek(field_value);
1172 attrs.push(Attr::inserted(
1173 Cow::Borrowed(field.name),
1174 field.name.len(),
1175 formatted,
1176 ));
1177 }
1178 }
1179 }
1180
1181 let changed_groups = group_changed_attrs(&attrs, self.opts.max_line_width, 0);
1182
1183 return self.tree.new_node(LayoutNode::Element {
1184 tag,
1185 field_name: None,
1186 attrs,
1187 changed_groups,
1188 change,
1189 });
1190 }
1191 _ => {}
1192 }
1193 }
1194
1195 // Fallback: create element with tag and build children normally
1196 let node = self.tree.new_node(LayoutNode::Element {
1197 tag,
1198 field_name: None,
1199 attrs: Vec::new(),
1200 changed_groups: Vec::new(),
1201 change,
1202 });
1203
1204 // Build children from updates
1205 self.build_updates_children(node, updates, "item");
1206
1207 node
1208 }
1209
1210 /// Build a sequence diff.
1211 fn build_sequence(
1212 &mut self,
1213 updates: &Updates<'_, '_>,
1214 change: ElementChange,
1215 item_type: &'static str,
1216 ) -> NodeId {
1217 // Create sequence node with item type info
1218 let node = self.tree.new_node(LayoutNode::Sequence {
1219 change,
1220 item_type,
1221 field_name: None,
1222 });
1223
1224 // Build children from updates
1225 self.build_updates_children(node, updates, item_type);
1226
1227 node
1228 }
1229
1230 /// Build children from an Updates structure and append to parent.
1231 ///
1232 /// This groups consecutive items by their change type (unchanged, deleted, inserted)
1233 /// and renders them on single lines with optional collapsing for long runs.
1234 /// Nested diffs (struct items with internal changes) are built as full child nodes.
1235 fn build_updates_children(
1236 &mut self,
1237 parent: NodeId,
1238 updates: &Updates<'_, '_>,
1239 _item_type: &'static str,
1240 ) {
1241 // Collect simple items (adds/removes) and nested diffs separately
1242 let mut items: Vec<(Peek<'_, '_>, ElementChange)> = Vec::new();
1243 let mut nested_diffs: Vec<&Diff<'_, '_>> = Vec::new();
1244
1245 let interspersed = &updates.0;
1246
1247 // Process first update group if present
1248 if let Some(update_group) = &interspersed.first {
1249 self.collect_updates_group_items(&mut items, &mut nested_diffs, update_group);
1250 }
1251
1252 // Process interleaved (unchanged, update) pairs
1253 for (unchanged_items, update_group) in &interspersed.values {
1254 // Add unchanged items
1255 for item in unchanged_items {
1256 items.push((*item, ElementChange::None));
1257 }
1258
1259 self.collect_updates_group_items(&mut items, &mut nested_diffs, update_group);
1260 }
1261
1262 // Process trailing unchanged items
1263 if let Some(unchanged_items) = &interspersed.last {
1264 for item in unchanged_items {
1265 items.push((*item, ElementChange::None));
1266 }
1267 }
1268
1269 tracing::debug!(
1270 items_count = items.len(),
1271 nested_diffs_count = nested_diffs.len(),
1272 "collected sequence items"
1273 );
1274
1275 // Build nested diffs as full child nodes (struct items with internal changes)
1276 for diff in nested_diffs {
1277 debug!(diff_type = ?std::mem::discriminant(diff), "building nested diff");
1278 // Get from/to Peek from the diff for context
1279 let (from_peek, to_peek) = match diff {
1280 Diff::User { .. } => {
1281 // For User diffs, we need the actual Peek values
1282 // The diff contains the shapes but we need to find the corresponding Peeks
1283 // For now, pass None - the build_diff will use the shape info
1284 (None, None)
1285 }
1286 Diff::Replace { from, to } => (Some(*from), Some(*to)),
1287 _ => (None, None),
1288 };
1289 let child = self.build_diff(diff, from_peek, to_peek, ElementChange::None);
1290 parent.append(child, &mut self.tree);
1291 }
1292
1293 // Render simple items (unchanged, adds, removes)
1294 for (item_peek, item_change) in items {
1295 let child = self.build_peek(item_peek, item_change);
1296 parent.append(child, &mut self.tree);
1297 }
1298 }
1299
1300 /// Collect items from an UpdatesGroup into the items list.
1301 /// Also returns nested diffs that need to be built as full child nodes.
1302 fn collect_updates_group_items<'a, 'mem: 'a, 'facet: 'a>(
1303 &self,
1304 items: &mut Vec<(Peek<'mem, 'facet>, ElementChange)>,
1305 nested_diffs: &mut Vec<&'a Diff<'mem, 'facet>>,
1306 group: &'a UpdatesGroup<'mem, 'facet>,
1307 ) {
1308 let interspersed = &group.0;
1309
1310 // Process first replace group if present
1311 if let Some(replace) = &interspersed.first {
1312 self.collect_replace_group_items(items, replace);
1313 }
1314
1315 // Process interleaved (diffs, replace) pairs
1316 for (diffs, replace) in &interspersed.values {
1317 // Collect nested diffs - these are struct items with internal changes
1318 for diff in diffs {
1319 nested_diffs.push(diff);
1320 }
1321 self.collect_replace_group_items(items, replace);
1322 }
1323
1324 // Process trailing diffs (if any)
1325 if let Some(diffs) = &interspersed.last {
1326 for diff in diffs {
1327 nested_diffs.push(diff);
1328 }
1329 }
1330 }
1331
1332 /// Collect items from a ReplaceGroup into the items list.
1333 fn collect_replace_group_items<'a, 'mem: 'a, 'facet: 'a>(
1334 &self,
1335 items: &mut Vec<(Peek<'mem, 'facet>, ElementChange)>,
1336 group: &'a ReplaceGroup<'mem, 'facet>,
1337 ) {
1338 // Add removals as deleted
1339 for removal in &group.removals {
1340 items.push((*removal, ElementChange::Deleted));
1341 }
1342
1343 // Add additions as inserted
1344 for addition in &group.additions {
1345 items.push((*addition, ElementChange::Inserted));
1346 }
1347 }
1348
1349 /// Format a Peek value into the arena using the flavor.
1350 fn format_peek(&mut self, peek: Peek<'_, '_>) -> FormattedValue {
1351 let shape = peek.shape();
1352 debug!(
1353 type_id = %shape.type_identifier,
1354 def = ?shape.def,
1355 "format_peek"
1356 );
1357
1358 // Unwrap Option types to format the inner value
1359 if let Def::Option(_) = shape.def
1360 && let Ok(opt) = peek.into_option()
1361 {
1362 if let Some(inner) = opt.value() {
1363 return self.format_peek(inner);
1364 }
1365 // None - format as null
1366 let (span, width) = self.strings.push_str("null");
1367 return FormattedValue::with_type(span, width, ValueType::Null);
1368 }
1369
1370 // Handle float formatting with precision if configured
1371 if let Some(precision) = self.opts.float_precision
1372 && let Type::Primitive(PrimitiveType::Numeric(NumericType::Float)) = shape.ty
1373 {
1374 // Try f64 first, then f32
1375 if let Ok(v) = peek.get::<f64>() {
1376 let formatted = format!("{:.prec$}", v, prec = precision);
1377 // Trim trailing zeros and decimal point for cleaner output
1378 let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
1379 let (span, width) = self.strings.push_str(formatted);
1380 return FormattedValue::with_type(span, width, ValueType::Number);
1381 }
1382 if let Ok(v) = peek.get::<f32>() {
1383 let formatted = format!("{:.prec$}", v, prec = precision);
1384 let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
1385 let (span, width) = self.strings.push_str(formatted);
1386 return FormattedValue::with_type(span, width, ValueType::Number);
1387 }
1388 }
1389
1390 let (span, width) = self.strings.format(|w| self.flavor.format_value(peek, w));
1391 let value_type = determine_value_type(peek);
1392 FormattedValue::with_type(span, width, value_type)
1393 }
1394
1395 /// Finish building and return the Layout.
1396 fn finish(self, root: NodeId) -> Layout {
1397 Layout {
1398 strings: self.strings,
1399 tree: self.tree,
1400 root,
1401 }
1402 }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407 use super::*;
1408 use crate::layout::render::{RenderOptions, render_to_string};
1409 use crate::layout::{RustFlavor, XmlFlavor};
1410
1411 #[test]
1412 fn test_build_equal_diff() {
1413 let value = 42i32;
1414 let peek = Peek::new(&value);
1415 let diff = Diff::Equal { value: Some(peek) };
1416
1417 let layout = build_layout(&diff, peek, peek, &BuildOptions::default(), &RustFlavor);
1418
1419 // Should produce a single text node
1420 let root = layout.get(layout.root).unwrap();
1421 assert!(matches!(root, LayoutNode::Text { .. }));
1422 }
1423
1424 #[test]
1425 fn test_build_replace_diff() {
1426 let from = 10i32;
1427 let to = 20i32;
1428 let diff = Diff::Replace {
1429 from: Peek::new(&from),
1430 to: Peek::new(&to),
1431 };
1432
1433 let layout = build_layout(
1434 &diff,
1435 Peek::new(&from),
1436 Peek::new(&to),
1437 &BuildOptions::default(),
1438 &RustFlavor,
1439 );
1440
1441 // Should produce an element with two children
1442 let root = layout.get(layout.root).unwrap();
1443 assert!(matches!(
1444 root,
1445 LayoutNode::Element {
1446 tag: "_replace",
1447 ..
1448 }
1449 ));
1450
1451 let children: Vec<_> = layout.children(layout.root).collect();
1452 assert_eq!(children.len(), 2);
1453 }
1454
1455 #[test]
1456 fn test_build_and_render_replace() {
1457 let from = 10i32;
1458 let to = 20i32;
1459 let diff = Diff::Replace {
1460 from: Peek::new(&from),
1461 to: Peek::new(&to),
1462 };
1463
1464 let layout = build_layout(
1465 &diff,
1466 Peek::new(&from),
1467 Peek::new(&to),
1468 &BuildOptions::default(),
1469 &RustFlavor,
1470 );
1471 let output = render_to_string(&layout, &RenderOptions::plain(), &XmlFlavor);
1472
1473 // Should contain both values with appropriate markers
1474 assert!(
1475 output.contains("10"),
1476 "output should contain old value: {}",
1477 output
1478 );
1479 assert!(
1480 output.contains("20"),
1481 "output should contain new value: {}",
1482 output
1483 );
1484 }
1485
1486 #[test]
1487 fn test_build_options_default() {
1488 let opts = BuildOptions::default();
1489 assert_eq!(opts.max_line_width, 80);
1490 assert_eq!(opts.max_unchanged_fields, 5);
1491 assert_eq!(opts.collapse_threshold, 3);
1492 }
1493}