shape/
lib.rs

1mod accepts;
2mod case_enum;
3mod child_shape;
4mod display;
5mod from_json;
6mod hashing;
7mod helpers;
8mod merge;
9mod meta;
10pub mod name;
11
12pub mod graphql;
13pub mod location;
14#[cfg(test)]
15mod tests;
16mod visitor;
17
18use std::hash::Hash;
19use std::hash::Hasher;
20use std::iter::empty;
21
22pub use accepts::ShapeMismatch;
23pub use case_enum::Error;
24pub use case_enum::ShapeCase;
25pub use helpers::OffsetRange;
26use helpers::Ref;
27use indexmap::IndexMap;
28use indexmap::IndexSet;
29use meta::ShapeMeta;
30pub use visitor::ShapeVisitor;
31
32use crate::case_enum::all::all;
33use crate::case_enum::one::one;
34use crate::location::Location;
35use crate::merge::MergeSet;
36use crate::name::Name;
37use crate::name::WeakScope;
38
39/// The `shape::Shape` struct provides a recursive, immutable, reference-counted
40/// tree/DAG format for representing and enforcing common structures and usage
41/// patterns of JSON-like data.
42///
43/// The `Shape` system is not bound to any particular programming language, so
44/// it does not inherit a data model that it must represent and defend, yet it
45/// must adopt/assume _some_ concrete data model, since a type system without a
46/// data model to enforce is as useful as a straitjacket on a coat rack. JSON
47/// was chosen for its relative simplicity, its ubiquity as a data interchange
48/// format used across programming languages, and because JSON is often used in
49/// scenarios without a static type system to help catch errors before runtime.
50///
51/// The `Shape` system has no source syntax for denoting shapes directly, but
52/// you can use the `Shape::*` helper functions to create shapes
53/// programmatically, in Rust. `Shape::pretty_print()` provides a human-readable
54/// representation of a `Shape` for debugging and testing purposes.
55///
56/// All in all, this _Static `Shape` System_ (SSS) supports the following
57/// type-theoretic features:
58///
59/// - [x] Primitive shapes: `Bool`, `String`, `Int`, `Float`, `Null`
60/// - [x] Singleton primitive shapes: `true`, `false`, `"hello"`, `42`, `null`
61/// - [x] `Array` shapes, supporting both static tuples and dynamic lists
62/// - [x] `Object` shapes, supporting both static fields and dynamic string keys
63/// - [x] `One<S1, S2, ...>` union shapes, representing a set of shape
64///   alternatives
65/// - [x] `All<S1, S2, ...>` intersection shapes, representing a set
66///   simultaneous requirements
67/// - [x] `shape.field(name)` and `shape.item(index)` methods for accessing the
68///   shape of a subproperty of a shape
69/// - [x] `Name` shape references, with support for symbolic subproperty shape
70///   access
71/// - [x] `Error` shapes, representing a failure of shape processing, with
72///   support for chains of errors and partial shape data
73/// - [x] `None` shapes, representing the absence of a value (helpful for
74///   representing optionality of shapes)
75/// - [x] `subshape.satisfies(supershape)` and `supershape.accepts(subshape)`
76///   methods for testing shape relationships
77/// - [x] `shape.accepts_json(json)` method for testing whether concrete JSON
78///   data satisfies some expected shape
79/// - [x] `shape.pretty_print()` method for debugging and testing
80
81#[derive(Clone, Eq)]
82// [`Shape`] enforces the simplification of [`ShapeCase`] variants, because
83// there is no way to create a [`Shape`] without simplifying the input
84// [`ShapeCase`]. This is a very useful invariant because it allows each
85// [`ShapeCase`] to assume its immediate [`Shape`] children have already been
86// simplified.
87//
88// In addition simplification, [`Shape`] supports testing shape-shape acceptance
89// (or the equivalent inverse, satisfaction) with `super.accepts(sub)` and/or
90// `sub.satisfies(super)`. See also `shape.accepts_json(json)` for testing
91// whether concrete JSON data satisfies some expected `shape`.
92//
93// In the future, we may internalize/canonize shapes to reduce memory usage,
94// especially for well-known shapes like `Bool` and `Int` and `String`. This
95// would require either thread safety (is `type Ref<T> = std::sync::Arc<T>`
96// enough?) or maintaining per-thread canonical shape tables.
97pub struct Shape {
98    // This field is private, but if you want to match against an immutable
99    // reference to the `ShapeCase` variant, use `match shape.case() { ... }`.
100    case: Ref<ShapeCase>,
101
102    /// The combination of locations which, combined, produce this shape.
103    ///
104    /// Many cases will only have a single location, but when shapes are
105    /// simplified, their locations are all retained in the result.
106    ///
107    /// Currently [`ShapeMeta::Loc(Location)`] is the only variant here, but we
108    /// can add other kinds of metadata in the future.
109    meta: Ref<ShapeMeta>,
110}
111
112impl PartialEq for Shape {
113    fn eq(&self, other: &Self) -> bool {
114        self.case == other.case
115    }
116}
117
118impl Hash for Shape {
119    fn hash<H: Hasher>(&self, state: &mut H) {
120        // Since the PartialEq implementation ignores self.locations, so must
121        // the Hash implementation.
122        self.case.hash(state);
123    }
124}
125
126impl Shape {
127    /// Create a `Shape` from a [`ShapeCase`] variant.
128    ///
129    /// This method is crate-private to help enforce some invariants.
130    pub(crate) fn new(case: ShapeCase, locations: impl IntoIterator<Item = Location>) -> Shape {
131        let meta = ShapeMeta::new(&case, locations, []);
132        Shape {
133            case: Ref::new(case),
134            meta: Ref::new(meta),
135        }
136    }
137
138    /// Create a `Shape` from a [`ShapeCase`] variant with errors attached.
139    pub(crate) fn new_with_errors(
140        case: ShapeCase,
141        locations: impl IntoIterator<Item = Location>,
142        errors: impl IntoIterator<Item = Error>,
143    ) -> Shape {
144        let meta = ShapeMeta::new_with_errors(&case, locations, [], errors);
145        Shape {
146            case: Ref::new(case),
147            meta: Ref::new(meta),
148        }
149    }
150
151    /// When boolean helper methods like `.is_none()` and `.is_null()` are not
152    /// enough, you can match against the underlying [`ShapeCase`] by obtaining an
153    /// immutable `&ShapeCase` reference using the `shape.case()` method.
154    #[must_use]
155    pub fn case(&self) -> &ShapeCase {
156        self.case.as_ref()
157    }
158
159    /// Returns an iterator over all [`Location`]s associated with this shape.
160    pub fn locations(&self) -> impl Iterator<Item = &Location> {
161        let self_locs = self.meta.locations();
162
163        let unique_locs: IndexSet<&Location> = match self.case() {
164            ShapeCase::One(shapes) => self_locs
165                .chain(shapes.iter().flat_map(|s| s.meta.locations()))
166                .collect(),
167            ShapeCase::All(shapes) => self_locs
168                .chain(shapes.iter().flat_map(|s| s.meta.locations()))
169                .collect(),
170            _ => self_locs.collect(),
171        };
172
173        unique_locs.into_iter()
174    }
175
176    /// Returns an iterator over all [`Name`]s associated with this shape.
177    pub fn names(&self) -> impl Iterator<Item = &Name> {
178        self.meta.names()
179    }
180
181    pub fn nested_base_names(&self) -> impl Iterator<Item = &str> {
182        self.meta.nested_base_names()
183    }
184
185    /// Returns a [`Shape`] that accepts any boolean value, `true` or `false`.
186    #[must_use]
187    pub fn bool(locations: impl IntoIterator<Item = Location>) -> Self {
188        Self::new(ShapeCase::Bool(None), locations)
189    }
190
191    /// Returns a [`Shape`] that accepts only the specified boolean value.
192    #[must_use]
193    pub fn bool_value(value: bool, locations: impl IntoIterator<Item = Location>) -> Self {
194        Self::new(ShapeCase::Bool(Some(value)), locations)
195    }
196
197    /// Returns a [`Shape`] that accepts any string value.
198    #[must_use]
199    pub fn string(locations: impl IntoIterator<Item = Location>) -> Self {
200        Self::new(ShapeCase::String(None), locations)
201    }
202
203    /// Returns a [`Shape`] that accepts only the specified string value.
204    #[must_use]
205    pub fn string_value(value: &str, locations: impl IntoIterator<Item = Location>) -> Self {
206        Self::new(ShapeCase::String(Some(value.to_string())), locations)
207    }
208
209    /// Returns a [`Shape`] that accepts any integer value.
210    #[must_use]
211    pub fn int(locations: impl IntoIterator<Item = Location>) -> Self {
212        Self::new(ShapeCase::Int(None), locations)
213    }
214
215    /// Returns a [`Shape`] that accepts only the specified integer value.
216    #[must_use]
217    pub fn int_value(value: i64, locations: impl IntoIterator<Item = Location>) -> Self {
218        Self::new(ShapeCase::Int(Some(value)), locations)
219    }
220
221    /// Returns a [`Shape`] that accepts any floating point value.
222    #[must_use]
223    pub fn float(locations: impl IntoIterator<Item = Location>) -> Self {
224        Self::new(ShapeCase::Float, locations)
225    }
226
227    /// Returns a [`Shape`] that accepts only the JSON `null` value.
228    #[must_use]
229    pub fn null(locations: impl IntoIterator<Item = Location>) -> Self {
230        Self::new(ShapeCase::Null, locations)
231    }
232
233    #[must_use]
234    pub fn is_null(&self) -> bool {
235        self.case.is_null()
236    }
237
238    /// Returns a symbolic reference to a named shape, potentially not yet
239    /// defined.
240    ///
241    /// In order to add items to the subpath of this named shape, call the
242    /// `.field(name)` and/or `.item(index)` methods.
243    ///
244    /// Note that variable shapes are represented by [`ShapeCase::Name`] where the
245    /// name string includes the initial `$` character.
246    #[must_use]
247    pub fn name(name: &str, locations: impl IntoIterator<Item = Location>) -> Self {
248        let locations = locations.into_iter().collect::<Vec<_>>();
249        Self::new(
250            ShapeCase::Name(
251                name::Name::base(name.to_string(), locations.clone()),
252                WeakScope::none(),
253            ),
254            locations.clone(),
255        )
256    }
257
258    /// Useful for obtaining the kind of [`IndexMap`] this library uses for the
259    /// [`ShapeCase::Object`] variant.
260    #[must_use]
261    pub fn empty_map() -> IndexMap<String, Self> {
262        IndexMap::new()
263    }
264
265    /// Returns a [`Shape`] that accepts any object shape, regardless of the other
266    /// shape's `fields` or `rest` shape, because an empty object shape `{}`
267    /// imposes no expectations on other objects (except that they are objects).
268    ///
269    /// In the other direction, an empty object shape `{}` can satisfy itself or
270    /// any `Dict<V>` shape (where the `Dict` may be dynamically empty), but
271    /// cannot satisfy any object shape with non-empty `fields`.
272    #[must_use]
273    pub fn empty_object(locations: impl IntoIterator<Item = Location>) -> Self {
274        Shape::new(
275            ShapeCase::Object {
276                fields: Shape::empty_map(),
277                rest: Shape::none(),
278            },
279            locations,
280        )
281    }
282
283    /// To get a compatible empty mutable [`IndexMap`] without directly
284    /// depending on the [`indexmap`] crate yourself, use [`Shape::empty_map()`].
285    #[must_use]
286    pub fn object(
287        fields: IndexMap<String, Shape>,
288        rest: Shape,
289        locations: impl IntoIterator<Item = Location>,
290    ) -> Self {
291        Shape::new(ShapeCase::Object { fields, rest }, locations)
292    }
293
294    /// Returns a [`Shape`] that accepts any object shape with the given static
295    /// fields, with no dynamic fields considered.
296    #[must_use]
297    pub fn record(
298        fields: IndexMap<String, Shape>,
299        locations: impl IntoIterator<Item = Location>,
300    ) -> Self {
301        Shape::object(fields, Shape::none(), locations)
302    }
303
304    /// Returns a [`Shape`] that accepts any dictionary-like object with dynamic
305    /// string properties having a given value shape.
306    #[must_use]
307    pub fn dict(value_shape: Shape, locations: impl IntoIterator<Item = Location>) -> Self {
308        Shape::object(Shape::empty_map(), value_shape, locations)
309    }
310
311    /// Arrays, tuples, and lists are all manifestations of the same underlying
312    /// [`ShapeCase::Array`] representation.
313    pub fn array(
314        prefix: impl IntoIterator<Item = Shape>,
315        tail: Shape,
316        locations: impl IntoIterator<Item = Location>,
317    ) -> Self {
318        let prefix = prefix.into_iter().collect();
319        Self::new(ShapeCase::Array { prefix, tail }, locations)
320    }
321
322    /// A tuple is a [`ShapeCase::Array`] with statically known (though possibly
323    /// empty) element shapes and no dynamic tail shape.
324    pub fn tuple(
325        shapes: impl IntoIterator<Item = Shape>,
326        locations: impl IntoIterator<Item = Location>,
327    ) -> Self {
328        Shape::array(shapes, Shape::none(), locations)
329    }
330
331    /// A `List<S>` is a [`ShapeCase::Array`] with an empty static `prefix` and a
332    /// dynamic element shape `S`.
333    #[must_use]
334    pub fn list(of: Shape, locations: impl IntoIterator<Item = Location>) -> Self {
335        Shape::array(empty(), of, locations)
336    }
337
338    /// Returns a [`ShapeCase::One`] union of the given shapes, simplified.
339    ///
340    /// Note that `locations` in this case should _not_ refer to each individual inner shape, but
341    /// to the thing that caused all of these shapes to be combined, like maybe a `->match`. If
342    /// there is no obvious cause to point users to, then the location should be empty.
343    pub fn one(
344        shapes: impl IntoIterator<Item = Shape>,
345        locations: impl IntoIterator<Item = Location>,
346    ) -> Self {
347        one(shapes.into_iter(), locations.into_iter().collect())
348    }
349
350    /// Returns a [`ShapeCase::All`] intersection of the given shapes, simplified.
351    ///
352    /// Note that `locations` in this case should _not_ refer to each individual inner shape, but
353    /// to the thing that caused all of these shapes to be combined, like maybe a `IntfA & IntfB`.
354    /// If there is no obvious cause to point users to, then the location should be empty.
355    pub fn all(
356        shapes: impl IntoIterator<Item = Shape>,
357        locations: impl IntoIterator<Item = Location>,
358    ) -> Self {
359        all(shapes.into_iter(), locations.into_iter().collect())
360    }
361
362    /// Returns a shape that accepts any JSON value (including [`ShapeCase::None`]
363    /// and [`ShapeCase::Unknown`]), and is not accepted by any shape other than itself.
364    #[must_use]
365    pub fn unknown(locations: impl IntoIterator<Item = Location>) -> Self {
366        Self::new(ShapeCase::Unknown, locations)
367    }
368
369    #[must_use]
370    pub fn is_unknown(&self) -> bool {
371        matches!(self.case(), ShapeCase::Unknown)
372    }
373
374    /// Returns a shape representing the absence of a JSON value, which is
375    /// satisfied/accepted only by itself.
376    ///
377    /// Because this represents the absence of a value, it shouldn't have a location. Basically,
378    /// nothing can produce none alone, and if it were a union, that union would have its own
379    /// location.
380    #[must_use]
381    pub fn none() -> Self {
382        Self::new(ShapeCase::None, [])
383    }
384
385    #[must_use]
386    pub fn is_none(&self) -> bool {
387        self.case.is_none()
388    }
389
390    /// Report a failure of shape processing. Creates an Unknown shape with
391    /// the error attached as metadata.
392    #[must_use]
393    pub fn error(
394        message: impl Into<String>,
395        locations: impl IntoIterator<Item = Location>,
396    ) -> Self {
397        let locations: Vec<_> = locations.into_iter().collect();
398        Self::new_with_errors(
399            ShapeCase::Unknown,
400            locations,
401            [Error {
402                message: message.into(),
403            }],
404        )
405    }
406
407    /// Returns true if this shape has any errors attached directly (not nested).
408    #[deprecated(
409        since = "0.7.0",
410        note = "use `has_errors()` for recursive check or `has_own_errors()` for own-only"
411    )]
412    #[must_use]
413    pub fn is_error(&self) -> bool {
414        self.meta.has_errors()
415    }
416
417    /// Returns true if this shape has errors attached to it (not nested children).
418    #[must_use]
419    pub fn has_own_errors(&self) -> bool {
420        self.meta.has_errors()
421    }
422
423    /// Returns true if this shape or any nested child has errors.
424    #[must_use]
425    pub fn has_errors(&self) -> bool {
426        if self.has_own_errors() {
427            return true;
428        }
429        match self.case() {
430            ShapeCase::Array { prefix, tail } => {
431                prefix.iter().any(Shape::has_errors) || tail.has_errors()
432            }
433            ShapeCase::Object { fields, rest } => {
434                fields.values().any(Shape::has_errors) || rest.has_errors()
435            }
436            ShapeCase::One(one) => one.iter().any(Shape::has_errors),
437            ShapeCase::All(all) => all.iter().any(Shape::has_errors),
438            // Name references are not followed (could cause cycles)
439            // Leaf cases have no children to traverse
440            ShapeCase::Name(_, _)
441            | ShapeCase::Bool(_)
442            | ShapeCase::String(_)
443            | ShapeCase::Int(_)
444            | ShapeCase::Float
445            | ShapeCase::Null
446            | ShapeCase::Unknown
447            | ShapeCase::None => false,
448        }
449    }
450
451    /// Iterate over errors attached to this shape (not nested children).
452    pub fn own_errors(&self) -> impl Iterator<Item = &Error> {
453        self.meta.errors()
454    }
455
456    /// Recursively collect all errors from this shape and its nested children.
457    /// This traverses Array elements, Object fields, One/All variants, but does
458    /// NOT follow Name references (to avoid cycles and because named shapes are
459    /// conceptually separate).
460    #[must_use]
461    pub fn errors(&self) -> Vec<&Error> {
462        let mut errors: Vec<&Error> = self.own_errors().collect();
463
464        match self.case() {
465            ShapeCase::Array { prefix, tail } => {
466                for child in prefix {
467                    errors.extend(child.errors());
468                }
469                errors.extend(tail.errors());
470            }
471            ShapeCase::Object { fields, rest } => {
472                for child in fields.values() {
473                    errors.extend(child.errors());
474                }
475                errors.extend(rest.errors());
476            }
477            ShapeCase::One(one) => {
478                for child in one.iter() {
479                    errors.extend(child.errors());
480                }
481            }
482            ShapeCase::All(all) => {
483                for child in all.iter() {
484                    errors.extend(child.errors());
485                }
486            }
487            // Name references are not followed (could cause cycles)
488            // Leaf cases have no children to traverse
489            ShapeCase::Name(_, _)
490            | ShapeCase::Bool(_)
491            | ShapeCase::String(_)
492            | ShapeCase::Int(_)
493            | ShapeCase::Float
494            | ShapeCase::Null
495            | ShapeCase::Unknown
496            | ShapeCase::None => {}
497        }
498
499        errors
500    }
501
502    /// Report a failure of shape processing associated with a
503    /// partial/best-guess shape that may still be useful. The error is
504    /// attached to the partial shape as metadata.
505    #[must_use]
506    pub fn error_with_partial(
507        message: impl Into<String>,
508        partial: Shape,
509        locations: impl IntoIterator<Item = Location>,
510    ) -> Self {
511        partial
512            .with_error(Error {
513                message: message.into(),
514            })
515            .with_locations(locations.into_iter().collect::<Vec<_>>().iter())
516    }
517
518    /// Clone the shape with an additional error attached.
519    #[must_use]
520    pub fn with_error(mut self, error: Error) -> Self {
521        Ref::make_mut(&mut self.meta).add_error(error);
522        self
523    }
524
525    /// Clone the shape, adding the provided `locations` to the existing locations.
526    #[must_use]
527    pub fn with_locations<'a>(mut self, locations: impl IntoIterator<Item = &'a Location>) -> Self {
528        for loc in locations {
529            if !self.meta.has_location(loc) {
530                Ref::make_mut(&mut self.meta).add_location(loc);
531            }
532        }
533        self
534    }
535}
536
537#[cfg(test)]
538mod test_errors {
539    use super::*;
540
541    #[test]
542    fn error_shape_has_error() {
543        let error_shape = Shape::error("Expected a string", []);
544        let errors = error_shape.errors();
545        assert_eq!(errors.len(), 1);
546        assert_eq!(errors[0].message, "Expected a string");
547        assert!(error_shape.has_errors());
548    }
549
550    #[test]
551    fn error_with_partial_preserves_shape() {
552        let error_shape = Shape::error_with_partial("Parse failed", Shape::bool([]), []);
553        // The shape should be Bool with an error attached
554        assert!(matches!(error_shape.case(), ShapeCase::Bool(None)));
555        assert!(error_shape.has_errors());
556        let errors = error_shape.errors();
557        assert_eq!(errors.len(), 1);
558        assert_eq!(errors[0].message, "Parse failed");
559    }
560
561    #[test]
562    fn multiple_errors_can_attach() {
563        let shape = Shape::bool([])
564            .with_error(Error {
565                message: "First error".to_string(),
566            })
567            .with_error(Error {
568                message: "Second error".to_string(),
569            });
570        let errors = shape.errors();
571        assert_eq!(errors.len(), 2);
572        assert_eq!(errors[0].message, "First error");
573        assert_eq!(errors[1].message, "Second error");
574    }
575
576    #[test]
577    fn plain_shapes_have_no_errors() {
578        let int_shape = Shape::int([]);
579        assert!(!int_shape.has_errors());
580        assert_eq!(int_shape.errors().len(), 0);
581    }
582
583    #[test]
584    fn errors_collects_from_nested_shapes() {
585        // Create an array with an error on a nested element
586        let error_element = Shape::string([]).with_error(Error {
587            message: "nested error".to_string(),
588        });
589        let array = Shape::array([], error_element.clone(), []);
590
591        // own_errors() should be empty on the array itself
592        assert!(!array.has_own_errors());
593        assert!(array.own_errors().next().is_none());
594
595        // errors() should find the nested error (recursive)
596        let all = array.errors();
597        assert_eq!(all.len(), 1);
598        assert_eq!(all[0].message, "nested error");
599
600        // has_errors() should return true (recursive check)
601        assert!(array.has_errors());
602    }
603
604    #[test]
605    fn errors_collects_from_object_fields() {
606        let error_field = Shape::int([]).with_error(Error {
607            message: "field error".to_string(),
608        });
609        let mut fields = Shape::empty_map();
610        fields.insert("count".to_string(), error_field);
611        let obj = Shape::object(fields, Shape::none(), []);
612
613        // No own errors on obj, but nested field has one
614        assert!(!obj.has_own_errors());
615        assert!(obj.has_errors());
616
617        let all = obj.errors();
618        assert_eq!(all.len(), 1);
619        assert_eq!(all[0].message, "field error");
620    }
621
622    #[test]
623    fn errors_collects_from_multiple_levels() {
624        // Object with error -> array with error -> element with error
625        let deep_error = Shape::bool([]).with_error(Error {
626            message: "deep".to_string(),
627        });
628        let array_with_error = Shape::array([], deep_error, []).with_error(Error {
629            message: "middle".to_string(),
630        });
631        let mut fields = Shape::empty_map();
632        fields.insert("items".to_string(), array_with_error);
633        let obj = Shape::object(fields, Shape::none(), []).with_error(Error {
634            message: "top".to_string(),
635        });
636
637        let all = obj.errors();
638        assert_eq!(all.len(), 3);
639        // Errors collected in traversal order: top, middle, deep
640        assert_eq!(all[0].message, "top");
641        assert_eq!(all[1].message, "middle");
642        assert_eq!(all[2].message, "deep");
643    }
644
645    #[test]
646    fn has_errors_short_circuits() {
647        // Should return true as soon as it finds an error
648        let clean_shape = Shape::int([]);
649        assert!(!clean_shape.has_errors());
650
651        let error_shape = Shape::error("oops", []);
652        assert!(error_shape.has_errors());
653    }
654
655    #[test]
656    fn errors_and_names_single_each() {
657        // One error, one name
658        let shape = Shape::string([])
659            .with_error(Error {
660                message: "invalid value".to_string(),
661            })
662            .with_base_name("MyString", []);
663
664        assert!(shape.has_errors());
665        assert_eq!(shape.errors().len(), 1);
666        assert!(shape.has_base_name("MyString"));
667
668        assert_eq!(shape.pretty_print(), r#"String (err "invalid value")"#);
669        assert_eq!(shape.pretty_print_without_errors(), "String");
670        assert_eq!(
671            shape.pretty_print_with_names(),
672            r#"String (err "invalid value") (aka MyString)"#
673        );
674    }
675
676    #[test]
677    fn multiple_errors_no_names() {
678        let shape = Shape::int([])
679            .with_error(Error {
680                message: "first error".to_string(),
681            })
682            .with_error(Error {
683                message: "second error".to_string(),
684            });
685
686        assert!(shape.has_errors());
687        assert_eq!(shape.errors().len(), 2);
688        assert_eq!(shape.names().count(), 0);
689
690        assert_eq!(
691            shape.pretty_print(),
692            r#"Int (err "first error", "second error")"#
693        );
694        assert_eq!(shape.pretty_print_without_errors(), "Int");
695        assert_eq!(
696            shape.pretty_print_with_names(),
697            r#"Int (err "first error", "second error")"#
698        );
699    }
700
701    #[test]
702    fn no_errors_multiple_names() {
703        // Create a shape, apply one name, then apply another
704        // Note: with_base_name propagates names to children, but for a leaf
705        // shape like Int, multiple calls add multiple names
706        let shape = Shape::int([])
707            .with_base_name("Count", [])
708            .with_base_name("Total", []);
709
710        assert!(!shape.has_errors());
711        assert!(shape.has_base_name("Count"));
712        assert!(shape.has_base_name("Total"));
713
714        assert_eq!(shape.pretty_print(), "Int");
715        assert_eq!(shape.pretty_print_without_errors(), "Int");
716        // Names are collected via MergeSet, order may vary
717        let with_names = shape.pretty_print_with_names();
718        assert!(with_names.contains("(aka"));
719        assert!(with_names.contains("Count"));
720        assert!(with_names.contains("Total"));
721    }
722
723    #[test]
724    fn multiple_errors_multiple_names() {
725        let shape = Shape::bool([])
726            .with_error(Error {
727                message: "err1".to_string(),
728            })
729            .with_error(Error {
730                message: "err2".to_string(),
731            })
732            .with_base_name("Flag", [])
733            .with_base_name("Toggle", []);
734
735        assert!(shape.has_errors());
736        assert_eq!(shape.errors().len(), 2);
737        assert!(shape.has_base_name("Flag"));
738        assert!(shape.has_base_name("Toggle"));
739
740        // pretty_print shows errors only
741        assert_eq!(shape.pretty_print(), r#"Bool (err "err1", "err2")"#);
742
743        // pretty_print_without_errors shows neither
744        assert_eq!(shape.pretty_print_without_errors(), "Bool");
745
746        // pretty_print_with_names shows errors first, then names
747        let with_names = shape.pretty_print_with_names();
748        assert!(with_names.starts_with(r#"Bool (err "err1", "err2") (aka "#));
749        assert!(with_names.contains("Flag"));
750        assert!(with_names.contains("Toggle"));
751    }
752
753    #[test]
754    fn nested_errors_and_names() {
755        // Create an object with a field that has both error and name
756        let field_with_error = Shape::string([])
757            .with_error(Error {
758                message: "field error".to_string(),
759            })
760            .with_base_name("FieldName", []);
761
762        let mut fields = Shape::empty_map();
763        fields.insert("field".to_string(), field_with_error);
764        let obj = Shape::object(fields, Shape::none(), [])
765            .with_error(Error {
766                message: "object error".to_string(),
767            })
768            .with_base_name("MyObject", []);
769
770        // Object has its own error, field has its own error
771        assert!(obj.has_own_errors());
772        assert!(obj.has_errors());
773        // errors() is recursive - should find both
774        assert_eq!(obj.errors().len(), 2);
775
776        // Check object-level pretty print
777        let pp = obj.pretty_print();
778        assert!(pp.contains(r#"(err "object error")"#));
779        assert!(pp.contains(r#"(err "field error")"#));
780
781        // With names shows errors first, then names at each level
782        let with_names = obj.pretty_print_with_names();
783        assert!(with_names.contains("MyObject"));
784        assert!(with_names.contains("FieldName"));
785    }
786}