Skip to main content

animsmith_core/
transition_family.rs

1//! Format-neutral transition-family declaration V1 values.
2//!
3//! The CLI owns strict, bounded TOML decoding. This module owns the closed
4//! declaration vocabulary, its validation, deterministic family ordering, and
5//! the distinct exact-source and normalized-JCS identities. It deliberately
6//! does not evaluate poses, load documents, or integrate the declaration into
7//! [`crate::Config`].
8
9use serde::Serialize;
10use std::collections::BTreeSet;
11use std::io::{self, Write};
12
13use crate::{CollectionIdV1, CollectionLogicalIdV1, CollectionSourceKeyV1, InputIdentity};
14
15/// Schema identity for a transition-family declaration.
16pub const TRANSITION_FAMILY_V1_ID: &str = "urn:animsmith:schema:transition-family:1";
17/// Schema version for a transition-family declaration.
18pub const TRANSITION_FAMILY_V1_SCHEMA_VERSION: u32 = 1;
19/// Maximum exact TOML source bytes accepted by V1.
20pub const TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
21/// Maximum normalized RFC 8785 JCS bytes accepted by V1.
22pub const TRANSITION_FAMILY_V1_MAX_NORMALIZED_BYTES: u64 = 8 * 1024 * 1024;
23/// Maximum table/array or JSON object/array depth, including the root.
24pub const TRANSITION_FAMILY_V1_MAX_DEPTH: usize = 16;
25/// Maximum family declarations in one owner.
26pub const TRANSITION_FAMILY_V1_MAX_FAMILIES: usize = 4_096;
27/// Maximum ordered members in one family.
28pub const TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY: usize = 4_096;
29/// Maximum ordered members across one declaration.
30pub const TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS: usize = 16_384;
31/// Maximum UTF-8 bytes in an authored non-identifier string.
32pub const TRANSITION_FAMILY_V1_MAX_STRING_BYTES: usize = 4_096;
33/// Maximum UTF-8 bytes in a document-local family identifier.
34pub const TRANSITION_FAMILY_V1_MAX_DOCUMENT_FAMILY_ID_BYTES: usize = 255;
35
36/// Selects the endpoint boundary a later evaluator will compare.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum TransitionFamilyBoundaryV1 {
40    /// Compare entry poses at normalized time zero.
41    Entry,
42    /// Compare exit poses at normalized time one.
43    Exit,
44    /// Compare both entry and exit poses.
45    Both,
46}
47
48/// The fixed V1 skeleton-local basis.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50pub struct TransitionFamilyBasisV1 {
51    translation: &'static str,
52    rotation: &'static str,
53    time: &'static str,
54}
55
56impl TransitionFamilyBasisV1 {
57    /// Construct the only basis accepted by transition-family V1.
58    #[must_use]
59    pub const fn skeleton_local() -> Self {
60        Self {
61            translation: "skeleton-local-metres",
62            rotation: "skeleton-local-degrees",
63            time: "normalized-clip",
64        }
65    }
66}
67
68/// Unit-bearing tolerances for a transition family.
69#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
70pub struct TransitionFamilyTolerancesV1 {
71    translation_m: f64,
72    rotation_deg: f64,
73    time_normalized: f64,
74}
75
76impl TransitionFamilyTolerancesV1 {
77    /// Construct finite, non-negative V1 tolerances.
78    pub fn new(
79        translation_m: f64,
80        rotation_deg: f64,
81        time_normalized: f64,
82    ) -> Result<Self, TransitionFamilyError> {
83        for (field, value) in [
84            ("translation_m", translation_m),
85            ("rotation_deg", rotation_deg),
86            ("time_normalized", time_normalized),
87        ] {
88            if !value.is_finite() || value < 0.0 {
89                return Err(TransitionFamilyError::InvalidTolerance { field });
90            }
91        }
92        Ok(Self {
93            translation_m,
94            rotation_deg,
95            time_normalized,
96        })
97    }
98
99    /// Translation tolerance in skeleton-local metres.
100    pub const fn translation_m(self) -> f64 {
101        self.translation_m
102    }
103    /// Rotation tolerance in skeleton-local degrees.
104    pub const fn rotation_deg(self) -> f64 {
105        self.rotation_deg
106    }
107    /// Time tolerance in normalized clip time.
108    pub const fn time_normalized(self) -> f64 {
109        self.time_normalized
110    }
111}
112
113/// One exact embedded take witness in a document-local family.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct DocumentTransitionFamilyMemberV1 {
116    take_index: u64,
117    take_name: String,
118}
119
120impl DocumentTransitionFamilyMemberV1 {
121    /// Construct one bounded, non-empty document take witness.
122    pub fn new(take_index: u64, take_name: String) -> Result<Self, TransitionFamilyError> {
123        validate_text("take_name", &take_name, false)?;
124        Ok(Self {
125            take_index,
126            take_name,
127        })
128    }
129    /// Exact embedded take index witness.
130    pub const fn take_index(&self) -> u64 {
131        self.take_index
132    }
133    /// Exact embedded take-name witness.
134    pub fn take_name(&self) -> &str {
135        &self.take_name
136    }
137}
138
139/// One manifest-bound logical clip and source/take witness in a collection family.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141pub struct CollectionTransitionFamilyMemberV1 {
142    logical_id: CollectionLogicalIdV1,
143    source: CollectionSourceKeyV1,
144    take_index: u64,
145    take_name: String,
146}
147
148impl CollectionTransitionFamilyMemberV1 {
149    /// Construct one bounded collection member witness.
150    pub fn new(
151        logical_id: CollectionLogicalIdV1,
152        source: CollectionSourceKeyV1,
153        take_index: u64,
154        take_name: String,
155    ) -> Result<Self, TransitionFamilyError> {
156        validate_text("take_name", &take_name, false)?;
157        Ok(Self {
158            logical_id,
159            source,
160            take_index,
161            take_name,
162        })
163    }
164    /// Manifest logical clip identity.
165    pub fn logical_id(&self) -> &CollectionLogicalIdV1 {
166        &self.logical_id
167    }
168    /// Manifest-local source identity.
169    pub fn source(&self) -> &CollectionSourceKeyV1 {
170        &self.source
171    }
172    /// Exact embedded take index witness.
173    pub const fn take_index(&self) -> u64 {
174        self.take_index
175    }
176    /// Exact embedded take-name witness.
177    pub fn take_name(&self) -> &str {
178        &self.take_name
179    }
180}
181
182/// One document-owned transition family.
183#[derive(Debug, Clone, PartialEq, Serialize)]
184pub struct DocumentTransitionFamilyV1 {
185    family_id: String,
186    boundary: TransitionFamilyBoundaryV1,
187    basis: TransitionFamilyBasisV1,
188    tolerances: TransitionFamilyTolerancesV1,
189    members: Vec<DocumentTransitionFamilyMemberV1>,
190}
191
192impl DocumentTransitionFamilyV1 {
193    /// Construct one document family while preserving declared member order.
194    pub fn new(
195        family_id: String,
196        boundary: TransitionFamilyBoundaryV1,
197        tolerances: TransitionFamilyTolerancesV1,
198        members: Vec<DocumentTransitionFamilyMemberV1>,
199    ) -> Result<Self, TransitionFamilyError> {
200        validate_document_family_id(&family_id)?;
201        validate_document_members(&members)?;
202        Ok(Self {
203            family_id,
204            boundary,
205            basis: TransitionFamilyBasisV1::skeleton_local(),
206            tolerances,
207            members,
208        })
209    }
210    /// Stable document-local family identifier.
211    pub fn family_id(&self) -> &str {
212        &self.family_id
213    }
214    /// Selected future evaluation boundary.
215    pub const fn boundary(&self) -> TransitionFamilyBoundaryV1 {
216        self.boundary
217    }
218    /// Fixed V1 coordinate basis.
219    pub const fn basis(&self) -> TransitionFamilyBasisV1 {
220        self.basis
221    }
222    /// Declared unit-bearing tolerances.
223    pub const fn tolerances(&self) -> TransitionFamilyTolerancesV1 {
224        self.tolerances
225    }
226    /// Members in exact declared order.
227    pub fn members(&self) -> &[DocumentTransitionFamilyMemberV1] {
228        &self.members
229    }
230}
231
232/// One collection-owned transition family.
233#[derive(Debug, Clone, PartialEq, Serialize)]
234pub struct CollectionTransitionFamilyV1 {
235    family_id: CollectionLogicalIdV1,
236    boundary: TransitionFamilyBoundaryV1,
237    basis: TransitionFamilyBasisV1,
238    tolerances: TransitionFamilyTolerancesV1,
239    members: Vec<CollectionTransitionFamilyMemberV1>,
240}
241
242impl CollectionTransitionFamilyV1 {
243    /// Construct one collection family while preserving declared member order.
244    pub fn new(
245        family_id: CollectionLogicalIdV1,
246        boundary: TransitionFamilyBoundaryV1,
247        tolerances: TransitionFamilyTolerancesV1,
248        members: Vec<CollectionTransitionFamilyMemberV1>,
249    ) -> Result<Self, TransitionFamilyError> {
250        validate_collection_members(&members)?;
251        Ok(Self {
252            family_id,
253            boundary,
254            basis: TransitionFamilyBasisV1::skeleton_local(),
255            tolerances,
256            members,
257        })
258    }
259    /// Stable collection logical family identifier.
260    pub fn family_id(&self) -> &CollectionLogicalIdV1 {
261        &self.family_id
262    }
263    /// Selected future evaluation boundary.
264    pub const fn boundary(&self) -> TransitionFamilyBoundaryV1 {
265        self.boundary
266    }
267    /// Fixed V1 coordinate basis.
268    pub const fn basis(&self) -> TransitionFamilyBasisV1 {
269        self.basis
270    }
271    /// Declared unit-bearing tolerances.
272    pub const fn tolerances(&self) -> TransitionFamilyTolerancesV1 {
273        self.tolerances
274    }
275    /// Members in exact declared order.
276    pub fn members(&self) -> &[CollectionTransitionFamilyMemberV1] {
277        &self.members
278    }
279}
280
281/// Exact collection-manifest binding carried by a collection declaration.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
283pub struct TransitionFamilyManifestIdentityV1 {
284    collection_id: CollectionIdV1,
285    #[serde(rename = "manifest_input_identity")]
286    input: InputIdentity,
287}
288
289impl TransitionFamilyManifestIdentityV1 {
290    /// Construct a collection id and exact manifest-byte identity binding.
291    pub fn new(
292        collection_id: CollectionIdV1,
293        input: InputIdentity,
294    ) -> Result<Self, TransitionFamilyError> {
295        if input.bytes() > crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES {
296            return Err(TransitionFamilyError::ManifestTooLarge);
297        }
298        Ok(Self {
299            collection_id,
300            input,
301        })
302    }
303    /// Bound collection identifier.
304    pub fn collection_id(&self) -> &CollectionIdV1 {
305        &self.collection_id
306    }
307    /// Bound exact manifest-byte identity.
308    pub const fn input(&self) -> &InputIdentity {
309        &self.input
310    }
311}
312
313/// A fully validated document or collection declaration, without source bytes.
314#[derive(Debug, Clone, PartialEq, Serialize)]
315#[serde(tag = "scope", rename_all = "kebab-case")]
316#[non_exhaustive]
317pub enum TransitionFamilyDeclarationV1 {
318    /// Families owned by one document config.
319    Document {
320        /// Fixed V1 schema identifier retained by the typed contract.
321        #[serde(skip)]
322        schema: &'static str,
323        /// Fixed V1 schema version retained by the typed contract.
324        #[serde(skip)]
325        schema_version: u32,
326        /// Families sorted by stable family id, with member order preserved.
327        families: Vec<DocumentTransitionFamilyV1>,
328    },
329    /// Families bound to one collection manifest.
330    Collection {
331        /// Fixed V1 schema identifier retained by the typed contract.
332        #[serde(skip)]
333        schema: &'static str,
334        /// Fixed V1 schema version retained by the typed contract.
335        #[serde(skip)]
336        schema_version: u32,
337        /// Exact collection-manifest binding.
338        #[serde(flatten)]
339        manifest: TransitionFamilyManifestIdentityV1,
340        /// Families sorted by stable family id, with member order preserved.
341        families: Vec<CollectionTransitionFamilyV1>,
342    },
343}
344
345impl TransitionFamilyDeclarationV1 {
346    /// Construct a document declaration. An empty list is a valid no-family configuration.
347    pub fn document(
348        mut families: Vec<DocumentTransitionFamilyV1>,
349    ) -> Result<Self, TransitionFamilyError> {
350        validate_family_count(families.len())?;
351        families.sort_by(|left, right| left.family_id.cmp(&right.family_id));
352        if families
353            .windows(2)
354            .any(|pair| pair[0].family_id == pair[1].family_id)
355        {
356            return Err(TransitionFamilyError::DuplicateFamily);
357        }
358        validate_aggregate_document(&families)?;
359        Ok(Self::Document {
360            schema: TRANSITION_FAMILY_V1_ID,
361            schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
362            families,
363        })
364    }
365
366    /// Construct a non-empty collection declaration.
367    pub fn collection(
368        manifest: TransitionFamilyManifestIdentityV1,
369        mut families: Vec<CollectionTransitionFamilyV1>,
370    ) -> Result<Self, TransitionFamilyError> {
371        if families.is_empty() {
372            return Err(TransitionFamilyError::EmptyCollection);
373        }
374        validate_family_count(families.len())?;
375        let owner_prefix = format!("{}/", manifest.collection_id.as_str());
376        if families
377            .iter()
378            .any(|family| !family.family_id.as_str().starts_with(&owner_prefix))
379        {
380            return Err(TransitionFamilyError::CollectionFamilyOutsideOwner);
381        }
382        if families
383            .iter()
384            .flat_map(|family| family.members.iter())
385            .any(|member| !member.logical_id.as_str().starts_with(&owner_prefix))
386        {
387            return Err(TransitionFamilyError::CollectionMemberOutsideOwner);
388        }
389        families.sort_by(|left, right| left.family_id.cmp(&right.family_id));
390        if families
391            .windows(2)
392            .any(|pair| pair[0].family_id == pair[1].family_id)
393        {
394            return Err(TransitionFamilyError::DuplicateFamily);
395        }
396        validate_aggregate_collection(&families)?;
397        Ok(Self::Collection {
398            schema: TRANSITION_FAMILY_V1_ID,
399            schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
400            manifest,
401            families,
402        })
403    }
404
405    /// Canonically serialize this closed declaration as bounded RFC 8785 JCS bytes.
406    pub fn normalized_jcs(&self) -> Result<Vec<u8>, TransitionFamilyError> {
407        // Revalidate here too: enum construction remains possible in this
408        // crate, and a public serialization boundary must never bless a
409        // forged, non-canonical declaration with a stable digest.
410        self.clone()
411            .canonicalize()?
412            .normalized_jcs_bounded(TRANSITION_FAMILY_V1_MAX_NORMALIZED_BYTES as usize)
413    }
414
415    /// Document families, when this is a document declaration.
416    pub fn document_families(&self) -> Option<&[DocumentTransitionFamilyV1]> {
417        match self {
418            Self::Document { families, .. } => Some(families),
419            Self::Collection { .. } => None,
420        }
421    }
422
423    /// Collection families, when this is a collection declaration.
424    pub fn collection_families(&self) -> Option<&[CollectionTransitionFamilyV1]> {
425        match self {
426            Self::Document { .. } => None,
427            Self::Collection { families, .. } => Some(families),
428        }
429    }
430
431    fn normalized_jcs_bounded(&self, maximum: usize) -> Result<Vec<u8>, TransitionFamilyError> {
432        let mut output = BoundedWriter::new(maximum);
433        let wire = match self {
434            Self::Document { families, .. } => TransitionFamilyNormalizedWire::Document {
435                schema: TRANSITION_FAMILY_V1_ID,
436                schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
437                scope: "document",
438                families,
439            },
440            Self::Collection {
441                manifest, families, ..
442            } => TransitionFamilyNormalizedWire::Collection {
443                schema: TRANSITION_FAMILY_V1_ID,
444                schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
445                scope: "collection",
446                collection_id: manifest.collection_id(),
447                manifest_input_identity: manifest.input(),
448                families,
449            },
450        };
451        serde_jcs::to_writer(&mut output, &wire)
452            .map_err(|_| TransitionFamilyError::NormalizedTooLarge)?;
453        Ok(output.into_inner())
454    }
455
456    fn canonicalize(self) -> Result<Self, TransitionFamilyError> {
457        match self {
458            Self::Document { families, .. } => Self::document(families),
459            Self::Collection {
460                manifest, families, ..
461            } => Self::collection(manifest, families),
462        }
463    }
464}
465
466#[derive(Serialize)]
467#[serde(untagged)]
468enum TransitionFamilyNormalizedWire<'a> {
469    Document {
470        schema: &'static str,
471        schema_version: u32,
472        scope: &'static str,
473        families: &'a [DocumentTransitionFamilyV1],
474    },
475    Collection {
476        schema: &'static str,
477        schema_version: u32,
478        scope: &'static str,
479        collection_id: &'a CollectionIdV1,
480        manifest_input_identity: &'a InputIdentity,
481        families: &'a [CollectionTransitionFamilyV1],
482    },
483}
484
485/// Declaration plus the exact source identity and independently normalized identity.
486#[derive(Debug, Clone, PartialEq)]
487pub struct TransitionFamilyDeclarationInputV1 {
488    declaration: TransitionFamilyDeclarationV1,
489    source_identity: InputIdentity,
490    normalized_identity: InputIdentity,
491}
492
493impl TransitionFamilyDeclarationInputV1 {
494    /// Bind an already validated declaration to exact source bytes and bounded JCS identity.
495    pub fn new(
496        declaration: TransitionFamilyDeclarationV1,
497        source: &[u8],
498    ) -> Result<Self, TransitionFamilyError> {
499        if source.len() as u64 > TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES {
500            return Err(TransitionFamilyError::SourceTooLarge);
501        }
502        // `TransitionFamilyDeclarationV1` is non-exhaustive to external
503        // callers, but this identity boundary also replays the constructors
504        // so any internal/deserialization route cannot preserve an invalid or
505        // non-canonical declaration under a new source identity.
506        let declaration = declaration.canonicalize()?;
507        let normalized_identity = InputIdentity::from_bytes(&declaration.normalized_jcs()?);
508        Ok(Self {
509            declaration,
510            source_identity: InputIdentity::from_bytes(source),
511            normalized_identity,
512        })
513    }
514    /// Validated typed declaration.
515    pub fn declaration(&self) -> &TransitionFamilyDeclarationV1 {
516        &self.declaration
517    }
518    /// Exact TOML source-byte identity.
519    pub fn source_identity(&self) -> &InputIdentity {
520        &self.source_identity
521    }
522    /// Independently normalized RFC 8785 JCS identity.
523    pub fn normalized_identity(&self) -> &InputIdentity {
524        &self.normalized_identity
525    }
526}
527
528/// Typed validation failure for transition-family V1 declarations.
529#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
530#[non_exhaustive]
531pub enum TransitionFamilyError {
532    /// The exact source exceeded the V1 byte cap.
533    #[error("transition-family source exceeds the V1 byte cap")]
534    SourceTooLarge,
535    /// The normalized JCS representation exceeded the V1 byte cap.
536    #[error("transition-family normalized declaration exceeds the V1 byte cap")]
537    NormalizedTooLarge,
538    /// An authored field was malformed, empty, or exceeded its byte cap.
539    #[error("invalid transition-family {field}")]
540    InvalidText {
541        /// Closed field label whose text was invalid.
542        field: &'static str,
543    },
544    /// A document family id did not meet the V1 grammar.
545    #[error("invalid document transition-family identifier")]
546    InvalidDocumentFamilyId,
547    /// A tolerance was non-finite or negative.
548    #[error("invalid transition-family tolerance {field}")]
549    InvalidTolerance {
550        /// Closed tolerance label whose number was invalid.
551        field: &'static str,
552    },
553    /// One family did not have at least two members.
554    #[error("transition family requires at least two members")]
555    TooFewMembers,
556    /// One family exceeded its member cap.
557    #[error("transition family exceeds the member cap")]
558    TooManyMembers,
559    /// The declaration exceeded its family cap.
560    #[error("transition declaration exceeds the family cap")]
561    TooManyFamilies,
562    /// The declaration exceeded its aggregate member cap.
563    #[error("transition declaration exceeds the aggregate member cap")]
564    TooManyAggregateMembers,
565    /// A member witness appeared more than once in a family.
566    #[error("duplicate transition-family member")]
567    DuplicateMember,
568    /// A family id appeared more than once in an owner.
569    #[error("duplicate transition-family family id")]
570    DuplicateFamily,
571    /// A collection envelope must contain at least one family.
572    #[error("collection transition declaration requires at least one family")]
573    EmptyCollection,
574    /// A collection family id was outside its bound collection namespace.
575    #[error("collection transition-family id is outside its collection owner")]
576    CollectionFamilyOutsideOwner,
577    /// A collection member logical id was outside its bound collection namespace.
578    #[error("collection transition-family member is outside its collection owner")]
579    CollectionMemberOutsideOwner,
580    /// The embedded manifest byte identity exceeds the manifest cap.
581    #[error("bound collection manifest is too large")]
582    ManifestTooLarge,
583}
584
585fn validate_text(
586    field: &'static str,
587    value: &str,
588    allow_empty: bool,
589) -> Result<(), TransitionFamilyError> {
590    if (!allow_empty && value.is_empty()) || value.len() > TRANSITION_FAMILY_V1_MAX_STRING_BYTES {
591        return Err(TransitionFamilyError::InvalidText { field });
592    }
593    Ok(())
594}
595
596fn validate_document_family_id(value: &str) -> Result<(), TransitionFamilyError> {
597    if value.is_empty() || value.len() > TRANSITION_FAMILY_V1_MAX_DOCUMENT_FAMILY_ID_BYTES {
598        return Err(TransitionFamilyError::InvalidDocumentFamilyId);
599    }
600    let mut bytes = value.bytes();
601    let Some(first) = bytes.next() else {
602        return Err(TransitionFamilyError::InvalidDocumentFamilyId);
603    };
604    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
605        return Err(TransitionFamilyError::InvalidDocumentFamilyId);
606    }
607    if bytes.any(|byte| {
608        !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-'))
609    }) {
610        return Err(TransitionFamilyError::InvalidDocumentFamilyId);
611    }
612    Ok(())
613}
614
615fn validate_document_members(
616    members: &[DocumentTransitionFamilyMemberV1],
617) -> Result<(), TransitionFamilyError> {
618    validate_member_count(members.len())?;
619    let mut seen = BTreeSet::new();
620    for member in members {
621        if !seen.insert((member.take_index, member.take_name.as_str())) {
622            return Err(TransitionFamilyError::DuplicateMember);
623        }
624    }
625    Ok(())
626}
627
628fn validate_collection_members(
629    members: &[CollectionTransitionFamilyMemberV1],
630) -> Result<(), TransitionFamilyError> {
631    validate_member_count(members.len())?;
632    let mut seen = BTreeSet::new();
633    for member in members {
634        if !seen.insert(member.logical_id.clone()) {
635            return Err(TransitionFamilyError::DuplicateMember);
636        }
637    }
638    Ok(())
639}
640
641fn validate_member_count(count: usize) -> Result<(), TransitionFamilyError> {
642    if count < 2 {
643        return Err(TransitionFamilyError::TooFewMembers);
644    }
645    if count > TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY {
646        return Err(TransitionFamilyError::TooManyMembers);
647    }
648    Ok(())
649}
650
651fn validate_family_count(count: usize) -> Result<(), TransitionFamilyError> {
652    if count > TRANSITION_FAMILY_V1_MAX_FAMILIES {
653        Err(TransitionFamilyError::TooManyFamilies)
654    } else {
655        Ok(())
656    }
657}
658
659fn validate_aggregate_document(
660    families: &[DocumentTransitionFamilyV1],
661) -> Result<(), TransitionFamilyError> {
662    if families
663        .iter()
664        .map(|family| family.members.len())
665        .sum::<usize>()
666        > TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS
667    {
668        Err(TransitionFamilyError::TooManyAggregateMembers)
669    } else {
670        Ok(())
671    }
672}
673
674fn validate_aggregate_collection(
675    families: &[CollectionTransitionFamilyV1],
676) -> Result<(), TransitionFamilyError> {
677    if families
678        .iter()
679        .map(|family| family.members.len())
680        .sum::<usize>()
681        > TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS
682    {
683        Err(TransitionFamilyError::TooManyAggregateMembers)
684    } else {
685        Ok(())
686    }
687}
688
689struct BoundedWriter {
690    bytes: Vec<u8>,
691    maximum: usize,
692}
693impl BoundedWriter {
694    fn new(maximum: usize) -> Self {
695        Self {
696            bytes: Vec::new(),
697            maximum,
698        }
699    }
700    fn into_inner(self) -> Vec<u8> {
701        self.bytes
702    }
703}
704impl Write for BoundedWriter {
705    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
706        let total = self
707            .bytes
708            .len()
709            .checked_add(bytes.len())
710            .ok_or_else(|| io::Error::other("normalized transition declaration too large"))?;
711        if total > self.maximum {
712            return Err(io::Error::other(
713                "normalized transition declaration too large",
714            ));
715        }
716        self.bytes.extend_from_slice(bytes);
717        Ok(bytes.len())
718    }
719    fn flush(&mut self) -> io::Result<()> {
720        Ok(())
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    fn tolerances() -> TransitionFamilyTolerancesV1 {
729        TransitionFamilyTolerancesV1::new(0.05, 5.0, 0.0).unwrap()
730    }
731    fn family(id: &str, names: &[&str]) -> DocumentTransitionFamilyV1 {
732        DocumentTransitionFamilyV1::new(
733            id.into(),
734            TransitionFamilyBoundaryV1::Both,
735            tolerances(),
736            names
737                .iter()
738                .enumerate()
739                .map(|(index, name)| {
740                    DocumentTransitionFamilyMemberV1::new(index as u64, (*name).into()).unwrap()
741                })
742                .collect(),
743        )
744        .unwrap()
745    }
746
747    #[test]
748    fn document_sorting_preserves_declared_member_order_and_distinct_identities() {
749        let declaration = TransitionFamilyDeclarationV1::document(vec![
750            family("z", &["Z", "A"]),
751            family("a", &["B", "C"]),
752        ])
753        .unwrap();
754        let TransitionFamilyDeclarationV1::Document { families, .. } = &declaration else {
755            panic!("document declaration")
756        };
757        assert_eq!(
758            families
759                .iter()
760                .map(|family| family.family_id())
761                .collect::<Vec<_>>(),
762            ["a", "z"]
763        );
764        assert_eq!(
765            families[1]
766                .members()
767                .iter()
768                .map(|member| member.take_name())
769                .collect::<Vec<_>>(),
770            ["Z", "A"]
771        );
772        let input = TransitionFamilyDeclarationInputV1::new(declaration, b"[source]").unwrap();
773        assert_ne!(input.source_identity(), input.normalized_identity());
774    }
775
776    #[test]
777    fn rejects_invalid_tolerances_identifiers_and_duplicate_members() {
778        assert!(TransitionFamilyTolerancesV1::new(f64::NAN, 0.0, 0.0).is_err());
779        assert_eq!(
780            TransitionFamilyTolerancesV1::new(-0.01, 0.0, 0.0),
781            Err(TransitionFamilyError::InvalidTolerance {
782                field: "translation_m"
783            })
784        );
785        assert!(
786            DocumentTransitionFamilyV1::new(
787                "Upper".into(),
788                TransitionFamilyBoundaryV1::Entry,
789                tolerances(),
790                vec![]
791            )
792            .is_err()
793        );
794        assert!(
795            DocumentTransitionFamilyV1::new(
796                "ok".into(),
797                TransitionFamilyBoundaryV1::Entry,
798                tolerances(),
799                vec![
800                    DocumentTransitionFamilyMemberV1::new(0, "same".into()).unwrap(),
801                    DocumentTransitionFamilyMemberV1::new(0, "same".into()).unwrap()
802                ]
803            )
804            .is_err()
805        );
806        assert_eq!(
807            DocumentTransitionFamilyV1::new(
808                "ok".into(),
809                TransitionFamilyBoundaryV1::Entry,
810                tolerances(),
811                vec![DocumentTransitionFamilyMemberV1::new(0, "one".into()).unwrap()]
812            ),
813            Err(TransitionFamilyError::TooFewMembers)
814        );
815        assert!(
816            DocumentTransitionFamilyV1::new(
817                "bad/slash".into(),
818                TransitionFamilyBoundaryV1::Entry,
819                tolerances(),
820                vec![
821                    DocumentTransitionFamilyMemberV1::new(0, "one".into()).unwrap(),
822                    DocumentTransitionFamilyMemberV1::new(1, "two".into()).unwrap(),
823                ]
824            )
825            .is_err()
826        );
827        assert!(
828            DocumentTransitionFamilyV1::new(
829                "tuple".into(),
830                TransitionFamilyBoundaryV1::Entry,
831                tolerances(),
832                vec![
833                    DocumentTransitionFamilyMemberV1::new(0, "same-name".into()).unwrap(),
834                    DocumentTransitionFamilyMemberV1::new(1, "same-name".into()).unwrap(),
835                ]
836            )
837            .is_ok()
838        );
839    }
840
841    #[test]
842    fn collection_families_and_members_must_share_the_manifest_namespace() {
843        let manifest = TransitionFamilyManifestIdentityV1::new(
844            CollectionIdV1::new("com.example").unwrap(),
845            InputIdentity::from_bytes(b"manifest"),
846        )
847        .unwrap();
848        let members = |first: &str, second: &str| {
849            vec![
850                CollectionTransitionFamilyMemberV1::new(
851                    CollectionLogicalIdV1::new(first).unwrap(),
852                    CollectionSourceKeyV1::new("source").unwrap(),
853                    0,
854                    "one".into(),
855                )
856                .unwrap(),
857                CollectionTransitionFamilyMemberV1::new(
858                    CollectionLogicalIdV1::new(second).unwrap(),
859                    CollectionSourceKeyV1::new("source").unwrap(),
860                    1,
861                    "two".into(),
862                )
863                .unwrap(),
864            ]
865        };
866        let family = |members| {
867            CollectionTransitionFamilyV1::new(
868                CollectionLogicalIdV1::new("com.example/family").unwrap(),
869                TransitionFamilyBoundaryV1::Entry,
870                tolerances(),
871                members,
872            )
873            .unwrap()
874        };
875        let outside_family = CollectionTransitionFamilyV1::new(
876            CollectionLogicalIdV1::new("other.example/family").unwrap(),
877            TransitionFamilyBoundaryV1::Entry,
878            tolerances(),
879            members("com.example/one", "com.example/two"),
880        )
881        .unwrap();
882        assert_eq!(
883            TransitionFamilyDeclarationV1::collection(manifest.clone(), vec![outside_family]),
884            Err(TransitionFamilyError::CollectionFamilyOutsideOwner)
885        );
886        assert_eq!(
887            TransitionFamilyDeclarationV1::collection(
888                manifest.clone(),
889                vec![family(members("com.example/one", "other.example/two"))]
890            ),
891            Err(TransitionFamilyError::CollectionMemberOutsideOwner)
892        );
893        assert!(
894            TransitionFamilyDeclarationV1::collection(
895                manifest,
896                vec![family(members("com.example/one", "com.example/two"))]
897            )
898            .is_ok()
899        );
900    }
901
902    #[test]
903    fn direct_identity_and_manifest_bounds_refuse_first_excess() {
904        let declaration =
905            TransitionFamilyDeclarationV1::document(vec![family("ok", &["A", "B"])]).unwrap();
906        assert_eq!(
907            TransitionFamilyDeclarationInputV1::new(
908                declaration,
909                &vec![b'x'; TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES as usize + 1]
910            ),
911            Err(TransitionFamilyError::SourceTooLarge)
912        );
913        assert_eq!(
914            TransitionFamilyManifestIdentityV1::new(
915                CollectionIdV1::new("com.example").unwrap(),
916                InputIdentity::from_sha256_digest(
917                    [0; 32],
918                    crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES + 1
919                )
920            ),
921            Err(TransitionFamilyError::ManifestTooLarge)
922        );
923    }
924
925    #[test]
926    fn limits_are_inclusive_and_the_first_aggregate_member_over_is_rejected() {
927        let exact_member_count = (0..TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY)
928            .map(|index| {
929                DocumentTransitionFamilyMemberV1::new(index as u64, format!("take-{index}"))
930                    .unwrap()
931            })
932            .collect();
933        assert!(
934            DocumentTransitionFamilyV1::new(
935                "exact".into(),
936                TransitionFamilyBoundaryV1::Entry,
937                tolerances(),
938                exact_member_count,
939            )
940            .is_ok()
941        );
942
943        let family = |id: String, members| {
944            DocumentTransitionFamilyV1::new(
945                id,
946                TransitionFamilyBoundaryV1::Entry,
947                tolerances(),
948                (0..members)
949                    .map(|index| {
950                        DocumentTransitionFamilyMemberV1::new(index as u64, format!("{index}"))
951                            .unwrap()
952                    })
953                    .collect(),
954            )
955            .unwrap()
956        };
957        let mut families = (0..4)
958            .map(|index| family(format!("family-{index}"), 4_096))
959            .collect::<Vec<_>>();
960        families.push(family("family-over".into(), 2));
961        assert_eq!(
962            TransitionFamilyDeclarationV1::document(families),
963            Err(TransitionFamilyError::TooManyAggregateMembers)
964        );
965    }
966
967    #[test]
968    fn normalized_jcs_is_exact_and_uses_the_bounded_writer_seam() {
969        let declaration =
970            TransitionFamilyDeclarationV1::document(vec![family("walk", &["Walk", "Run"])])
971                .unwrap();
972        assert_eq!(
973            String::from_utf8(declaration.normalized_jcs().unwrap()).unwrap(),
974            "{\"families\":[{\"basis\":{\"rotation\":\"skeleton-local-degrees\",\"time\":\"normalized-clip\",\"translation\":\"skeleton-local-metres\"},\"boundary\":\"both\",\"family_id\":\"walk\",\"members\":[{\"take_index\":0,\"take_name\":\"Walk\"},{\"take_index\":1,\"take_name\":\"Run\"}],\"tolerances\":{\"rotation_deg\":5,\"time_normalized\":0,\"translation_m\":0.05}}],\"schema\":\"urn:animsmith:schema:transition-family:1\",\"schema_version\":1,\"scope\":\"document\"}"
975        );
976        assert_eq!(
977            declaration.normalized_jcs_bounded(1),
978            Err(TransitionFamilyError::NormalizedTooLarge)
979        );
980        let input = TransitionFamilyDeclarationInputV1::new(declaration, b"exact source").unwrap();
981        assert_eq!(input.normalized_identity().bytes(), 408);
982        assert_eq!(
983            input.normalized_identity().sha256(),
984            "f9b6b85b4fa066324b6c9bbe2ea0fe1ff6c217b3175ef964054b08c03b327cd0"
985        );
986    }
987
988    #[test]
989    fn source_identity_boundary_revalidates_internal_declaration_construction() {
990        let invalid = TransitionFamilyDeclarationV1::Document {
991            schema: "forged",
992            schema_version: 99,
993            families: vec![family("same", &["A", "B"]), family("same", &["C", "D"])],
994        };
995        assert_eq!(
996            invalid.normalized_jcs(),
997            Err(TransitionFamilyError::DuplicateFamily)
998        );
999        assert_eq!(
1000            TransitionFamilyDeclarationInputV1::new(invalid, b"source"),
1001            Err(TransitionFamilyError::DuplicateFamily)
1002        );
1003        let noncanonical = TransitionFamilyDeclarationV1::Document {
1004            schema: "forged",
1005            schema_version: 99,
1006            families: vec![family("z", &["Z", "A"]), family("a", &["B", "C"])],
1007        };
1008        let bound = TransitionFamilyDeclarationInputV1::new(noncanonical, b"source").unwrap();
1009        assert_eq!(
1010            bound
1011                .declaration()
1012                .document_families()
1013                .unwrap()
1014                .iter()
1015                .map(|family| family.family_id())
1016                .collect::<Vec<_>>(),
1017            ["a", "z"]
1018        );
1019    }
1020
1021    #[test]
1022    fn collection_sorting_preserves_declared_member_order() {
1023        let member = |id: &str, take_name: &str| {
1024            CollectionTransitionFamilyMemberV1::new(
1025                CollectionLogicalIdV1::new(id).unwrap(),
1026                CollectionSourceKeyV1::new("source").unwrap(),
1027                0,
1028                take_name.into(),
1029            )
1030            .unwrap()
1031        };
1032        let family = |id: &str, members| {
1033            CollectionTransitionFamilyV1::new(
1034                CollectionLogicalIdV1::new(id).unwrap(),
1035                TransitionFamilyBoundaryV1::Entry,
1036                tolerances(),
1037                members,
1038            )
1039            .unwrap()
1040        };
1041        let manifest = TransitionFamilyManifestIdentityV1::new(
1042            CollectionIdV1::new("com.example").unwrap(),
1043            InputIdentity::from_bytes(b"manifest"),
1044        )
1045        .unwrap();
1046        let declaration = TransitionFamilyDeclarationV1::collection(
1047            manifest,
1048            vec![
1049                family(
1050                    "com.example/z",
1051                    vec![
1052                        member("com.example/z-first", "Z"),
1053                        member("com.example/z-last", "A"),
1054                    ],
1055                ),
1056                family(
1057                    "com.example/a",
1058                    vec![
1059                        member("com.example/a-first", "B"),
1060                        member("com.example/a-last", "C"),
1061                    ],
1062                ),
1063            ],
1064        )
1065        .unwrap();
1066        let TransitionFamilyDeclarationV1::Collection { families, .. } = declaration else {
1067            panic!("collection")
1068        };
1069        assert_eq!(families[0].family_id().as_str(), "com.example/a");
1070        assert_eq!(families[1].members()[0].take_name(), "Z");
1071        assert_eq!(families[1].members()[1].take_name(), "A");
1072    }
1073}