Skip to main content

animsmith_core/
collection.rs

1//! Format-neutral validated values for collection-manifest V1.
2//!
3//! The CLI owns manifest-byte limits, TOML parsing, rooted filesystem access,
4//! and collection execution. This module deliberately owns only the immutable
5//! declaration vocabulary that those layers exchange.
6
7use std::collections::BTreeSet;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES, DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
13    DependencyResourceKeyV1,
14};
15
16/// Immutable schema identity for collection manifest V1.
17pub const COLLECTION_MANIFEST_V1_ID: &str = "urn:animsmith:schema:collection-manifest:1";
18/// Immutable schema version for collection manifest V1.
19pub const COLLECTION_MANIFEST_V1_SCHEMA_VERSION: u32 = 1;
20/// Immutable identity of the output-independent collection manifest V1 budgets.
21pub const COLLECTION_MANIFEST_V1_BUDGET_ID: &str = "urn:animsmith:collection-manifest-budget:1";
22/// Maximum manifest bytes accepted by the frontend before TOML decoding.
23pub const COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
24/// Maximum source rows in one manifest.
25pub const COLLECTION_MANIFEST_V1_MAX_SOURCES: usize = 4_096;
26/// Maximum clip rows in one manifest.
27pub const COLLECTION_MANIFEST_V1_MAX_CLIPS: usize = 4_096;
28/// Maximum runtime-set rows in one manifest.
29pub const COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS: usize = 4_096;
30/// Maximum members across all runtime sets in one manifest.
31pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS: usize = 16_384;
32/// Maximum aggregate declaration work retained by V1 validation.
33pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK: usize = 24_576;
34/// Maximum UTF-8 bytes in a collection id, source key, clip id, or set id.
35pub const COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES: usize = 255;
36/// Maximum UTF-8 bytes in an exact embedded take-name witness.
37pub const COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES: usize = 4_096;
38
39/// Serializable immutable budget record shared by collection-manifest V1 consumers.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41pub struct CollectionManifestBudgetV1 {
42    id: &'static str,
43    max_manifest_bytes: u64,
44    max_sources: usize,
45    max_clips: usize,
46    max_runtime_sets: usize,
47    max_aggregate_members: usize,
48    max_aggregate_work: usize,
49    max_identifier_bytes: usize,
50    max_take_name_bytes: usize,
51    max_path_bytes: usize,
52    max_path_components: usize,
53}
54
55impl CollectionManifestBudgetV1 {
56    /// Return the immutable V1 budget values.
57    pub const fn v1() -> Self {
58        Self {
59            id: COLLECTION_MANIFEST_V1_BUDGET_ID,
60            max_manifest_bytes: COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
61            max_sources: COLLECTION_MANIFEST_V1_MAX_SOURCES,
62            max_clips: COLLECTION_MANIFEST_V1_MAX_CLIPS,
63            max_runtime_sets: COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
64            max_aggregate_members: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
65            max_aggregate_work: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
66            max_identifier_bytes: COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES,
67            max_take_name_bytes: COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
68            max_path_bytes: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
69            max_path_components: DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
70        }
71    }
72
73    /// Immutable budget identity.
74    pub const fn id(self) -> &'static str {
75        self.id
76    }
77    /// Maximum manifest input bytes.
78    pub const fn max_manifest_bytes(self) -> u64 {
79        self.max_manifest_bytes
80    }
81    /// Maximum source rows.
82    pub const fn max_sources(self) -> usize {
83        self.max_sources
84    }
85    /// Maximum clip rows.
86    pub const fn max_clips(self) -> usize {
87        self.max_clips
88    }
89    /// Maximum runtime-set rows.
90    pub const fn max_runtime_sets(self) -> usize {
91        self.max_runtime_sets
92    }
93    /// Maximum aggregate runtime-set memberships.
94    pub const fn max_aggregate_members(self) -> usize {
95        self.max_aggregate_members
96    }
97    /// Maximum aggregate declaration work.
98    pub const fn max_aggregate_work(self) -> usize {
99        self.max_aggregate_work
100    }
101    /// Maximum collection/source/logical identifier bytes.
102    pub const fn max_identifier_bytes(self) -> usize {
103        self.max_identifier_bytes
104    }
105    /// Maximum expected embedded take-name bytes.
106    pub const fn max_take_name_bytes(self) -> usize {
107        self.max_take_name_bytes
108    }
109    /// Maximum safe declared-path bytes.
110    pub const fn max_path_bytes(self) -> usize {
111        self.max_path_bytes
112    }
113    /// Maximum safe declared-path components.
114    pub const fn max_path_components(self) -> usize {
115        self.max_path_components
116    }
117}
118
119/// A V1 collection manifest was malformed or exceeded a frozen bound.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[non_exhaustive]
122pub enum CollectionManifestError {
123    /// A required text field was empty or exceeded its byte bound.
124    #[error("invalid {field}: expected nonempty UTF-8 text no longer than {max} bytes")]
125    InvalidText {
126        /// Stable field path.
127        field: &'static str,
128        /// Maximum accepted byte length.
129        max: usize,
130    },
131    /// An identifier did not satisfy the V1 lowercase ASCII grammar.
132    #[error("invalid {field}: expected V1 lowercase ASCII identifier")]
133    InvalidIdentifier {
134        /// Stable field path.
135        field: &'static str,
136    },
137    /// A bounded row collection exceeded its limit.
138    #[error("{field} has {found} rows, exceeding V1 limit {max}")]
139    TooManyRows {
140        /// Stable collection field.
141        field: &'static str,
142        /// Observed row count.
143        found: usize,
144        /// Maximum accepted count.
145        max: usize,
146    },
147    /// A required source or clip array was empty.
148    #[error("{field} must contain at least one row")]
149    EmptyRows {
150        /// Stable collection field.
151        field: &'static str,
152    },
153    /// Aggregate member work exceeded its V1 limit.
154    #[error("runtime_sets members total {found} exceeds V1 limit {max}")]
155    TooManyMembers {
156        /// Observed aggregate member count.
157        found: usize,
158        /// Maximum accepted count.
159        max: usize,
160    },
161    /// Aggregate declaration work exceeded its V1 limit.
162    #[error("collection manifest aggregate work {found} exceeds V1 limit {max}")]
163    TooMuchWork {
164        /// Observed aggregate work count.
165        found: usize,
166        /// Maximum accepted work count.
167        max: usize,
168    },
169    /// A source key, clip id, set id, binding, or member was repeated.
170    #[error("duplicate {field} {value:?}")]
171    Duplicate {
172        /// Stable duplicate category.
173        field: &'static str,
174        /// Stable rejected value.
175        value: String,
176    },
177    /// A clip named an undeclared source key.
178    #[error("clip {clip_id:?} references undeclared source {source_key:?}")]
179    DanglingSource {
180        /// Logical clip identifier.
181        clip_id: String,
182        /// Missing source key.
183        source_key: String,
184    },
185    /// A runtime set named an undeclared logical clip id.
186    #[error("runtime set {set_id:?} references undeclared member {member:?}")]
187    DanglingMember {
188        /// Runtime-set identifier.
189        set_id: String,
190        /// Missing logical clip id.
191        member: String,
192    },
193    /// A runtime set did not contain at least two distinct members.
194    #[error("runtime set {set_id:?} needs at least two members, found {found}")]
195    TooFewMembers {
196        /// Runtime-set identifier.
197        set_id: String,
198        /// Received member count.
199        found: usize,
200    },
201    /// A clip or runtime-set id escaped the declared collection namespace.
202    #[error("{field} {value:?} must start with collection id {collection_id:?} followed by '/'")]
203    OutsideCollectionNamespace {
204        /// Stable field path.
205        field: &'static str,
206        /// Rejected id.
207        value: String,
208        /// Declared collection id.
209        collection_id: String,
210    },
211    /// A digest pin was not exactly 64 lowercase hexadecimal characters.
212    #[error("expected_sha256 must be exactly 64 lowercase hexadecimal digits")]
213    InvalidDigest,
214}
215
216/// One lowercase ASCII namespace token used by V1 collection ids and source keys.
217#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
218#[serde(transparent)]
219pub struct CollectionIdV1(String);
220
221impl CollectionIdV1 {
222    /// Construct one V1 collection id or source key token.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`CollectionManifestError::InvalidIdentifier`] when `value` is
227    /// not one valid V1 token.
228    pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
229        let value = value.into();
230        validate_token("collection_id", &value)?;
231        Ok(Self(value))
232    }
233
234    /// Exact declared spelling.
235    pub fn as_str(&self) -> &str {
236        &self.0
237    }
238}
239
240/// Manifest-local source-key token.
241#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
242#[serde(transparent)]
243pub struct CollectionSourceKeyV1(String);
244
245impl CollectionSourceKeyV1 {
246    /// Construct one V1 manifest-local source key.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`CollectionManifestError::InvalidIdentifier`] when `value` is
251    /// not one valid V1 token.
252    pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
253        let value = value.into();
254        validate_token("source.key", &value)?;
255        Ok(Self(value))
256    }
257
258    /// Exact declared spelling.
259    pub fn as_str(&self) -> &str {
260        &self.0
261    }
262}
263
264/// Opaque namespaced logical clip or runtime-set identifier.
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
266#[serde(transparent)]
267pub struct CollectionLogicalIdV1(String);
268
269impl CollectionLogicalIdV1 {
270    /// Construct one V1 logical identifier.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`CollectionManifestError::InvalidIdentifier`] when `value` is
275    /// not a slash-separated V1 identifier with at least two tokens.
276    pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
277        let value = value.into();
278        if value.len() > COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES
279            || value.split('/').count() < 2
280            || value.split('/').any(|token| !is_valid_token(token))
281        {
282            return Err(CollectionManifestError::InvalidIdentifier {
283                field: "logical_id",
284            });
285        }
286        Ok(Self(value))
287    }
288
289    /// Exact declared spelling.
290    pub fn as_str(&self) -> &str {
291        &self.0
292    }
293}
294
295/// An optional asserted lowercase SHA-256 digest for one source.
296#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
297#[serde(transparent)]
298pub struct CollectionDigestPinV1(String);
299
300impl CollectionDigestPinV1 {
301    /// Construct one exact SHA-256 digest pin.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`CollectionManifestError::InvalidDigest`] when the value is
306    /// not 64 lowercase hexadecimal digits.
307    pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
308        let value = value.into();
309        if value.len() != 64
310            || !value
311                .bytes()
312                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
313        {
314            return Err(CollectionManifestError::InvalidDigest);
315        }
316        Ok(Self(value))
317    }
318
319    /// Lowercase hexadecimal SHA-256 text.
320    pub fn as_str(&self) -> &str {
321        &self.0
322    }
323}
324
325/// One source declaration, with safe relative locators but no host I/O state.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
327pub struct CollectionSourceV1 {
328    key: CollectionSourceKeyV1,
329    path: DependencyResourceKeyV1,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    config: Option<DependencyResourceKeyV1>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    expected_sha256: Option<CollectionDigestPinV1>,
334}
335
336impl CollectionSourceV1 {
337    /// Construct one source declaration.
338    pub fn new(
339        key: CollectionSourceKeyV1,
340        path: DependencyResourceKeyV1,
341        config: Option<DependencyResourceKeyV1>,
342        expected_sha256: Option<CollectionDigestPinV1>,
343    ) -> Self {
344        Self {
345            key,
346            path,
347            config,
348            expected_sha256,
349        }
350    }
351
352    /// Manifest-local source key.
353    pub fn key(&self) -> &CollectionSourceKeyV1 {
354        &self.key
355    }
356
357    /// Safe source locator resolved under the optional input root.
358    pub fn path(&self) -> &DependencyResourceKeyV1 {
359        &self.path
360    }
361
362    /// Optional safe config locator resolved under the manifest directory.
363    pub fn config(&self) -> Option<&DependencyResourceKeyV1> {
364        self.config.as_ref()
365    }
366
367    /// Optional asserted source digest.
368    pub fn expected_sha256(&self) -> Option<&CollectionDigestPinV1> {
369        self.expected_sha256.as_ref()
370    }
371}
372
373/// One declared logical-to-physical take binding.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct CollectionClipV1 {
376    id: CollectionLogicalIdV1,
377    source: CollectionSourceKeyV1,
378    take_index: u32,
379    take_name: String,
380}
381
382impl CollectionClipV1 {
383    /// Construct one exact source-local take witness.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`CollectionManifestError::InvalidText`] when the exact
388    /// expected embedded take name is empty or exceeds its V1 byte limit.
389    pub fn new(
390        id: CollectionLogicalIdV1,
391        source: CollectionSourceKeyV1,
392        take_index: u32,
393        take_name: impl Into<String>,
394    ) -> Result<Self, CollectionManifestError> {
395        let take_name = take_name.into();
396        validate_text(
397            "clips.take_name",
398            &take_name,
399            COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
400        )?;
401        Ok(Self {
402            id,
403            source,
404            take_index,
405            take_name,
406        })
407    }
408
409    /// Durable logical id.
410    pub fn id(&self) -> &CollectionLogicalIdV1 {
411        &self.id
412    }
413
414    /// Declared source key.
415    pub fn source(&self) -> &CollectionSourceKeyV1 {
416        &self.source
417    }
418
419    /// Zero-based source-local take index.
420    pub const fn take_index(&self) -> u32 {
421        self.take_index
422    }
423
424    /// Exact expected embedded take name at [`Self::take_index`].
425    pub fn take_name(&self) -> &str {
426        &self.take_name
427    }
428}
429
430/// Closed V1 runtime-set membership vocabulary.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
432#[serde(rename_all = "kebab-case")]
433pub enum CollectionRuntimeSetKindV1 {
434    /// A gait-related collection.
435    GaitGroup,
436    /// A synchronized-action collection.
437    SyncGroup,
438    /// A directional blend collection.
439    DirectionalBlend,
440    /// A speed blend collection.
441    SpeedBlend,
442    /// A transition-chain collection.
443    TransitionChain,
444    /// A mask-composition collection.
445    MaskComposition,
446    /// A retargeting collection.
447    RetargetGroup,
448    /// A paired interaction collection.
449    PairedInteraction,
450    /// A motion-database collection.
451    MotionDatabase,
452}
453
454/// One ordered collection runtime-set declaration.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
456pub struct CollectionRuntimeSetV1 {
457    id: CollectionLogicalIdV1,
458    kind: CollectionRuntimeSetKindV1,
459    members: Vec<CollectionLogicalIdV1>,
460}
461
462impl CollectionRuntimeSetV1 {
463    /// Construct one ordered runtime-set declaration.
464    pub fn new(
465        id: CollectionLogicalIdV1,
466        kind: CollectionRuntimeSetKindV1,
467        members: Vec<CollectionLogicalIdV1>,
468    ) -> Self {
469        Self { id, kind, members }
470    }
471
472    /// Durable runtime-set id.
473    pub fn id(&self) -> &CollectionLogicalIdV1 {
474        &self.id
475    }
476
477    /// Closed V1 membership kind.
478    pub const fn kind(&self) -> CollectionRuntimeSetKindV1 {
479        self.kind
480    }
481
482    /// Declared member order, retained without sorting.
483    pub fn members(&self) -> &[CollectionLogicalIdV1] {
484        &self.members
485    }
486}
487
488/// Fully validated V1 collection declaration, canonically ordered by stable ids.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
490pub struct CollectionManifestV1 {
491    schema: &'static str,
492    schema_version: u32,
493    collection_id: CollectionIdV1,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    input_root: Option<DependencyResourceKeyV1>,
496    sources: Vec<CollectionSourceV1>,
497    clips: Vec<CollectionClipV1>,
498    runtime_sets: Vec<CollectionRuntimeSetV1>,
499}
500
501impl CollectionManifestV1 {
502    /// Construct and validate one V1 manifest.
503    ///
504    /// Sources, clips, and runtime sets are sorted by their stable ids. A set's
505    /// member order remains exactly as declared.
506    ///
507    /// # Errors
508    ///
509    /// Returns [`CollectionManifestError`] when a declaration is incomplete,
510    /// ambiguous, dangling, outside the collection namespace, or exceeds a
511    /// frozen V1 bound.
512    pub fn new(
513        collection_id: CollectionIdV1,
514        input_root: Option<DependencyResourceKeyV1>,
515        mut sources: Vec<CollectionSourceV1>,
516        mut clips: Vec<CollectionClipV1>,
517        mut runtime_sets: Vec<CollectionRuntimeSetV1>,
518    ) -> Result<Self, CollectionManifestError> {
519        validate_rows("sources", sources.len(), COLLECTION_MANIFEST_V1_MAX_SOURCES)?;
520        validate_rows("clips", clips.len(), COLLECTION_MANIFEST_V1_MAX_CLIPS)?;
521        validate_rows(
522            "runtime_sets",
523            runtime_sets.len(),
524            COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
525        )?;
526        if sources.is_empty() {
527            return Err(CollectionManifestError::EmptyRows { field: "sources" });
528        }
529        if clips.is_empty() {
530            return Err(CollectionManifestError::EmptyRows { field: "clips" });
531        }
532
533        sources.sort_by(|left, right| left.key.cmp(&right.key));
534        clips.sort_by(|left, right| left.id.cmp(&right.id));
535        runtime_sets.sort_by(|left, right| left.id.cmp(&right.id));
536
537        let mut source_keys = BTreeSet::new();
538        for source in &sources {
539            if !source_keys.insert(source.key.clone()) {
540                return Err(CollectionManifestError::Duplicate {
541                    field: "source key",
542                    value: source.key.0.clone(),
543                });
544            }
545        }
546
547        let namespace = format!("{}/", collection_id.as_str());
548        let mut clip_ids = BTreeSet::new();
549        let mut bindings = BTreeSet::new();
550        for clip in &clips {
551            validate_namespace("clips.id", &clip.id, &collection_id, &namespace)?;
552            if !source_keys.contains(&clip.source) {
553                return Err(CollectionManifestError::DanglingSource {
554                    clip_id: clip.id.0.clone(),
555                    source_key: clip.source.0.clone(),
556                });
557            }
558            if !clip_ids.insert(clip.id.clone()) {
559                return Err(CollectionManifestError::Duplicate {
560                    field: "clip id",
561                    value: clip.id.0.clone(),
562                });
563            }
564            if !bindings.insert((clip.source.clone(), clip.take_index)) {
565                return Err(CollectionManifestError::Duplicate {
566                    field: "source/take binding",
567                    value: format!("{}:{}", clip.source.as_str(), clip.take_index),
568                });
569            }
570        }
571
572        let mut total_members = 0usize;
573        let mut aggregate_work = sources
574            .len()
575            .checked_add(clips.len())
576            .and_then(|value| value.checked_add(runtime_sets.len()))
577            .ok_or(CollectionManifestError::TooMuchWork {
578                found: usize::MAX,
579                max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
580            })?;
581        let mut set_ids = BTreeSet::new();
582        for runtime_set in &runtime_sets {
583            validate_namespace(
584                "runtime_sets.id",
585                &runtime_set.id,
586                &collection_id,
587                &namespace,
588            )?;
589            if !set_ids.insert(runtime_set.id.clone()) {
590                return Err(CollectionManifestError::Duplicate {
591                    field: "runtime set id",
592                    value: runtime_set.id.0.clone(),
593                });
594            }
595            if runtime_set.members.len() < 2 {
596                return Err(CollectionManifestError::TooFewMembers {
597                    set_id: runtime_set.id.0.clone(),
598                    found: runtime_set.members.len(),
599                });
600            }
601            total_members = total_members.checked_add(runtime_set.members.len()).ok_or(
602                CollectionManifestError::TooManyMembers {
603                    found: usize::MAX,
604                    max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
605                },
606            )?;
607            aggregate_work = aggregate_work
608                .checked_add(runtime_set.members.len())
609                .ok_or(CollectionManifestError::TooMuchWork {
610                    found: usize::MAX,
611                    max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
612                })?;
613            if aggregate_work > COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK {
614                return Err(CollectionManifestError::TooMuchWork {
615                    found: aggregate_work,
616                    max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
617                });
618            }
619            if total_members > COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS {
620                return Err(CollectionManifestError::TooManyMembers {
621                    found: total_members,
622                    max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
623                });
624            }
625            let mut members = BTreeSet::new();
626            for member in &runtime_set.members {
627                if !members.insert(member.clone()) {
628                    return Err(CollectionManifestError::Duplicate {
629                        field: "runtime set member",
630                        value: format!("{}:{}", runtime_set.id.as_str(), member.as_str()),
631                    });
632                }
633                if !clip_ids.contains(member) {
634                    return Err(CollectionManifestError::DanglingMember {
635                        set_id: runtime_set.id.0.clone(),
636                        member: member.0.clone(),
637                    });
638                }
639            }
640        }
641
642        Ok(Self {
643            schema: COLLECTION_MANIFEST_V1_ID,
644            schema_version: COLLECTION_MANIFEST_V1_SCHEMA_VERSION,
645            collection_id,
646            input_root,
647            sources,
648            clips,
649            runtime_sets,
650        })
651    }
652
653    /// Immutable V1 schema identity.
654    pub const fn schema(&self) -> &'static str {
655        self.schema
656    }
657
658    /// Immutable V1 schema version.
659    pub const fn schema_version(&self) -> u32 {
660        self.schema_version
661    }
662
663    /// Collection namespace token.
664    pub fn collection_id(&self) -> &CollectionIdV1 {
665        &self.collection_id
666    }
667
668    /// Optional safe source-root locator below the manifest directory.
669    pub fn input_root(&self) -> Option<&DependencyResourceKeyV1> {
670        self.input_root.as_ref()
671    }
672
673    /// Sources in canonical source-key order.
674    pub fn sources(&self) -> &[CollectionSourceV1] {
675        &self.sources
676    }
677
678    /// Clips in canonical logical-id order.
679    pub fn clips(&self) -> &[CollectionClipV1] {
680        &self.clips
681    }
682
683    /// Runtime sets in canonical logical-id order; each set retains member order.
684    pub fn runtime_sets(&self) -> &[CollectionRuntimeSetV1] {
685        &self.runtime_sets
686    }
687}
688
689fn validate_token(field: &'static str, value: &str) -> Result<(), CollectionManifestError> {
690    if value.len() > COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES || !is_valid_token(value) {
691        return Err(CollectionManifestError::InvalidIdentifier { field });
692    }
693    Ok(())
694}
695
696fn is_valid_token(value: &str) -> bool {
697    let bytes = value.as_bytes();
698    bytes
699        .first()
700        .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
701        && bytes.iter().skip(1).all(|byte| {
702            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
703        })
704}
705
706fn validate_text(
707    field: &'static str,
708    value: &str,
709    max: usize,
710) -> Result<(), CollectionManifestError> {
711    if value.is_empty() || value.len() > max {
712        return Err(CollectionManifestError::InvalidText { field, max });
713    }
714    Ok(())
715}
716
717fn validate_rows(
718    field: &'static str,
719    found: usize,
720    max: usize,
721) -> Result<(), CollectionManifestError> {
722    if found > max {
723        return Err(CollectionManifestError::TooManyRows { field, found, max });
724    }
725    Ok(())
726}
727
728fn validate_namespace(
729    field: &'static str,
730    id: &CollectionLogicalIdV1,
731    collection_id: &CollectionIdV1,
732    namespace: &str,
733) -> Result<(), CollectionManifestError> {
734    if !id.as_str().starts_with(namespace) {
735        return Err(CollectionManifestError::OutsideCollectionNamespace {
736            field,
737            value: id.0.clone(),
738            collection_id: collection_id.0.clone(),
739        });
740    }
741    Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::ResourceKeySyntaxV1;
748
749    fn key(value: &str) -> DependencyResourceKeyV1 {
750        DependencyResourceKeyV1::from_source_str(value, ResourceKeySyntaxV1::ParserRelativePath)
751            .unwrap()
752    }
753
754    fn source(value: &str) -> CollectionSourceV1 {
755        source_at(value, "motion.fbx")
756    }
757
758    fn source_at(value: &str, path: &str) -> CollectionSourceV1 {
759        CollectionSourceV1::new(
760            CollectionSourceKeyV1::new(value).unwrap(),
761            key(path),
762            None,
763            None,
764        )
765    }
766
767    fn clip(id: &str, source: &str, take_index: u32) -> CollectionClipV1 {
768        CollectionClipV1::new(
769            CollectionLogicalIdV1::new(id).unwrap(),
770            CollectionSourceKeyV1::new(source).unwrap(),
771            take_index,
772            "Take 001",
773        )
774        .unwrap()
775    }
776
777    fn set(id: &str, members: &[&str]) -> CollectionRuntimeSetV1 {
778        CollectionRuntimeSetV1::new(
779            CollectionLogicalIdV1::new(id).unwrap(),
780            CollectionRuntimeSetKindV1::GaitGroup,
781            members
782                .iter()
783                .map(|member| CollectionLogicalIdV1::new(*member).unwrap())
784                .collect(),
785        )
786    }
787
788    fn manifest(
789        sources: Vec<CollectionSourceV1>,
790        clips: Vec<CollectionClipV1>,
791        sets: Vec<CollectionRuntimeSetV1>,
792    ) -> Result<CollectionManifestV1, CollectionManifestError> {
793        CollectionManifestV1::new(
794            CollectionIdV1::new("com.example.pack").unwrap(),
795            None,
796            sources,
797            clips,
798            sets,
799        )
800    }
801
802    #[test]
803    fn valid_manifest_canonicalizes_rows_but_preserves_member_order() {
804        let forward = "com.example.pack/locomotion/forward";
805        let left = "com.example.pack/locomotion/left";
806        let value = manifest(
807            vec![source("zebra"), source("alpha")],
808            vec![clip(left, "alpha", 0), clip(forward, "zebra", 1)],
809            vec![
810                set("com.example.pack/sets/zebra", &[forward, left]),
811                set("com.example.pack/sets/alpha", &[left, forward]),
812            ],
813        )
814        .unwrap();
815        assert_eq!(value.sources()[0].key().as_str(), "alpha");
816        assert_eq!(value.clips()[0].id().as_str(), forward);
817        assert_eq!(
818            value.runtime_sets()[0].id().as_str(),
819            "com.example.pack/sets/alpha"
820        );
821        assert_eq!(value.runtime_sets()[0].members()[0].as_str(), left);
822        assert_eq!(value.runtime_sets()[0].members()[1].as_str(), forward);
823        assert_eq!(value.schema(), COLLECTION_MANIFEST_V1_ID);
824        assert_eq!(
825            value.schema_version(),
826            COLLECTION_MANIFEST_V1_SCHEMA_VERSION
827        );
828
829        let shuffled = manifest(
830            vec![source("alpha"), source("zebra")],
831            vec![clip(forward, "zebra", 1), clip(left, "alpha", 0)],
832            vec![
833                set("com.example.pack/sets/alpha", &[left, forward]),
834                set("com.example.pack/sets/zebra", &[forward, left]),
835            ],
836        )
837        .unwrap();
838        assert_eq!(value, shuffled);
839    }
840
841    #[test]
842    fn source_rename_does_not_change_logical_identity() {
843        let id = "com.example.pack/locomotion/walk";
844        let before = manifest(
845            vec![source("old-file")],
846            vec![clip(id, "old-file", 0)],
847            vec![],
848        )
849        .unwrap();
850        let after = manifest(
851            vec![source("renamed-file")],
852            vec![clip(id, "renamed-file", 0)],
853            vec![],
854        )
855        .unwrap();
856        assert_eq!(before.clips()[0].id(), after.clips()[0].id());
857        assert_ne!(before.sources()[0].key(), after.sources()[0].key());
858    }
859
860    #[test]
861    fn exact_take_names_and_distinct_indices_are_retained() {
862        let id = "com.example.pack/locomotion/walk";
863        let other = "com.example.pack/locomotion/run";
864        let value = manifest(
865            vec![source("loco")],
866            vec![clip(id, "loco", 0), clip(other, "loco", 1)],
867            vec![],
868        )
869        .unwrap();
870        assert_eq!(value.clips()[0].take_name(), "Take 001");
871        assert_eq!(value.clips()[1].take_name(), "Take 001");
872        assert_eq!(value.clips()[0].take_index(), 1);
873        assert_eq!(value.clips()[1].take_index(), 0);
874    }
875
876    #[test]
877    fn rejects_duplicate_and_dangling_declarations() {
878        let id = "com.example.pack/locomotion/walk";
879        let other = "com.example.pack/locomotion/run";
880        assert!(matches!(
881            manifest(
882                vec![source_at("a", "first.fbx"), source_at("a", "second.fbx")],
883                vec![clip(id, "a", 0)],
884                vec![]
885            ),
886            Err(CollectionManifestError::Duplicate {
887                field: "source key",
888                ..
889            })
890        ));
891        assert!(matches!(
892            manifest(
893                vec![source("a")],
894                vec![clip(id, "a", 0), clip(id, "a", 1)],
895                vec![]
896            ),
897            Err(CollectionManifestError::Duplicate {
898                field: "clip id",
899                ..
900            })
901        ));
902        assert!(matches!(
903            manifest(
904                vec![source("a")],
905                vec![clip(id, "a", 7), clip(other, "a", 7)],
906                vec![]
907            ),
908            Err(CollectionManifestError::Duplicate {
909                field: "source/take binding",
910                ..
911            })
912        ));
913        assert!(matches!(
914            manifest(vec![source("a")], vec![clip(id, "missing", 0)], vec![]),
915            Err(CollectionManifestError::DanglingSource { .. })
916        ));
917        assert!(matches!(
918            manifest(
919                vec![source("a")],
920                vec![
921                    clip(id, "a", 0),
922                    clip(other, "a", 1),
923                    clip("com.example.pack/locomotion/jump", "a", 2),
924                ],
925                vec![set("com.example.pack/sets/a", &[id, other, id])]
926            ),
927            Err(CollectionManifestError::Duplicate {
928                field: "runtime set member",
929                ..
930            })
931        ));
932        assert!(matches!(
933            manifest(
934                vec![source("a")],
935                vec![clip(id, "a", 0)],
936                vec![set(
937                    "com.example.pack/sets/a",
938                    &[id, "com.example.pack/locomotion/missing"]
939                )]
940            ),
941            Err(CollectionManifestError::DanglingMember { .. })
942        ));
943        assert!(matches!(
944            manifest(
945                vec![source("a")],
946                vec![clip(id, "a", 0)],
947                vec![set("com.example.pack/sets/a", &[id])]
948            ),
949            Err(CollectionManifestError::TooFewMembers { .. })
950        ));
951        assert!(matches!(
952            manifest(
953                vec![source("a")],
954                vec![clip(id, "a", 0), clip(other, "a", 1)],
955                vec![
956                    set("com.example.pack/sets/a", &[id, other]),
957                    CollectionRuntimeSetV1::new(
958                        CollectionLogicalIdV1::new("com.example.pack/sets/a").unwrap(),
959                        CollectionRuntimeSetKindV1::SyncGroup,
960                        vec![
961                            CollectionLogicalIdV1::new(other).unwrap(),
962                            CollectionLogicalIdV1::new(id).unwrap(),
963                        ],
964                    ),
965                ]
966            ),
967            Err(CollectionManifestError::Duplicate {
968                field: "runtime set id",
969                ..
970            })
971        ));
972        assert!(matches!(
973            manifest(
974                vec![source("a")],
975                vec![clip(id, "a", 0)],
976                vec![set("com.example.pack/sets/empty", &[])]
977            ),
978            Err(CollectionManifestError::TooFewMembers { found: 0, .. })
979        ));
980    }
981
982    #[test]
983    fn rejects_namespace_and_digest_violations() {
984        for outside in [
985            "other.pack/locomotion/walk",
986            "other/com.example.pack/locomotion/walk",
987        ] {
988            assert!(matches!(
989                manifest(vec![source("a")], vec![clip(outside, "a", 0)], vec![]),
990                Err(CollectionManifestError::OutsideCollectionNamespace { .. })
991            ));
992        }
993        assert!(CollectionDigestPinV1::new("A".repeat(64)).is_err());
994        assert!(CollectionDigestPinV1::new("0".repeat(63)).is_err());
995        assert!(CollectionDigestPinV1::new("0".repeat(65)).is_err());
996        assert!(CollectionDigestPinV1::new("g".repeat(64)).is_err());
997    }
998
999    #[test]
1000    fn logical_ids_enforce_grammar_and_exact_byte_bound() {
1001        let exact = format!(
1002            "a/{}",
1003            "x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 2)
1004        );
1005        assert_eq!(exact.len(), COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES);
1006        assert!(CollectionLogicalIdV1::new(exact).is_ok());
1007
1008        let over = format!(
1009            "a/{}",
1010            "x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 1)
1011        );
1012        assert_eq!(over.len(), COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES + 1);
1013        assert!(CollectionLogicalIdV1::new(over).is_err());
1014
1015        for invalid in ["single", "a/Upper", "a/unicodé", "a//empty", "a/.", "a/.."] {
1016            assert!(
1017                CollectionLogicalIdV1::new(invalid).is_err(),
1018                "{invalid:?} must not satisfy logical-id grammar"
1019            );
1020        }
1021    }
1022
1023    #[test]
1024    fn runtime_set_kind_wire_vocabulary_is_closed() {
1025        for kind in [
1026            "gait-group",
1027            "sync-group",
1028            "directional-blend",
1029            "speed-blend",
1030            "transition-chain",
1031            "mask-composition",
1032            "retarget-group",
1033            "paired-interaction",
1034            "motion-database",
1035        ] {
1036            serde_json::from_value::<CollectionRuntimeSetKindV1>(serde_json::json!(kind))
1037                .unwrap_or_else(|error| panic!("closed kind {kind:?} must decode: {error}"));
1038        }
1039        assert!(
1040            serde_json::from_value::<CollectionRuntimeSetKindV1>(serde_json::json!("typo"))
1041                .is_err()
1042        );
1043    }
1044
1045    #[test]
1046    fn preserves_unicode_take_names_and_reuses_safe_path_policy() {
1047        let value = CollectionClipV1::new(
1048            CollectionLogicalIdV1::new("com.example.pack/locomotion/walk").unwrap(),
1049            CollectionSourceKeyV1::new("walk").unwrap(),
1050            0,
1051            "Pas \u{00e9} 001",
1052        )
1053        .unwrap();
1054        assert_eq!(value.take_name(), "Pas \u{00e9} 001");
1055        for unsafe_path in [
1056            "/absolute.fbx",
1057            "a\\b.fbx",
1058            "../escape.fbx",
1059            "https://x/y.fbx",
1060        ] {
1061            assert!(
1062                DependencyResourceKeyV1::from_source_str(
1063                    unsafe_path,
1064                    ResourceKeySyntaxV1::ParserRelativePath,
1065                )
1066                .is_err()
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn identifiers_and_take_name_have_exact_bounds() {
1073        let token = format!(
1074            "a{}",
1075            "x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 1)
1076        );
1077        assert!(CollectionIdV1::new(token).is_ok());
1078        assert!(
1079            CollectionIdV1::new(format!(
1080                "a{}",
1081                "x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES)
1082            ))
1083            .is_err()
1084        );
1085        assert!(
1086            CollectionClipV1::new(
1087                CollectionLogicalIdV1::new("com.example/a").unwrap(),
1088                CollectionSourceKeyV1::new("a").unwrap(),
1089                0,
1090                "x".repeat(COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES)
1091            )
1092            .is_ok()
1093        );
1094        assert!(
1095            CollectionClipV1::new(
1096                CollectionLogicalIdV1::new("com.example/a").unwrap(),
1097                CollectionSourceKeyV1::new("a").unwrap(),
1098                0,
1099                "x".repeat(COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES + 1)
1100            )
1101            .is_err()
1102        );
1103        assert_eq!(
1104            CollectionClipV1::new(
1105                CollectionLogicalIdV1::new("com.example/a").unwrap(),
1106                CollectionSourceKeyV1::new("a").unwrap(),
1107                u32::MAX,
1108                "Take 001",
1109            )
1110            .unwrap()
1111            .take_index(),
1112            u32::MAX
1113        );
1114    }
1115
1116    #[test]
1117    fn budget_record_exposes_every_frozen_core_limit() {
1118        let budget = CollectionManifestBudgetV1::v1();
1119        assert_eq!(budget.id(), "urn:animsmith:collection-manifest-budget:1");
1120        assert_eq!(budget.max_manifest_bytes(), 8_388_608);
1121        assert_eq!(budget.max_sources(), 4_096);
1122        assert_eq!(budget.max_clips(), 4_096);
1123        assert_eq!(budget.max_runtime_sets(), 4_096);
1124        assert_eq!(budget.max_aggregate_members(), 16_384);
1125        assert_eq!(budget.max_aggregate_work(), 24_576);
1126        assert_eq!(budget.max_identifier_bytes(), 255);
1127        assert_eq!(budget.max_take_name_bytes(), 4_096);
1128        assert_eq!(budget.max_path_bytes(), 4_096);
1129        assert_eq!(budget.max_path_components(), 128);
1130        assert_eq!(
1131            serde_json::to_value(budget).unwrap(),
1132            serde_json::json!({
1133                "id": COLLECTION_MANIFEST_V1_BUDGET_ID,
1134                "max_manifest_bytes": COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
1135                "max_sources": COLLECTION_MANIFEST_V1_MAX_SOURCES,
1136                "max_clips": COLLECTION_MANIFEST_V1_MAX_CLIPS,
1137                "max_runtime_sets": COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
1138                "max_aggregate_members": COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
1139                "max_aggregate_work": COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
1140                "max_identifier_bytes": COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES,
1141                "max_take_name_bytes": COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
1142                "max_path_bytes": DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
1143                "max_path_components": DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
1144            })
1145        );
1146    }
1147
1148    #[test]
1149    fn rows_and_aggregate_members_have_exact_bounds() {
1150        let id = "com.example.pack/locomotion/walk";
1151        let sources = (0..COLLECTION_MANIFEST_V1_MAX_SOURCES)
1152            .map(|index| source(&format!("s{index}")))
1153            .collect();
1154        assert!(manifest(sources, vec![clip(id, "s0", 0)], vec![]).is_ok());
1155        let too_many_sources = (0..=COLLECTION_MANIFEST_V1_MAX_SOURCES)
1156            .map(|index| source(&format!("s{index}")))
1157            .collect();
1158        assert!(matches!(
1159            manifest(too_many_sources, vec![clip(id, "s0", 0)], vec![]),
1160            Err(CollectionManifestError::TooManyRows {
1161                field: "sources",
1162                ..
1163            })
1164        ));
1165
1166        let clips: Vec<_> = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
1167            .map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
1168            .collect();
1169        assert!(manifest(vec![source("a")], clips, vec![]).is_ok());
1170        let too_many_clips: Vec<_> = (0..=COLLECTION_MANIFEST_V1_MAX_CLIPS)
1171            .map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
1172            .collect();
1173        assert!(matches!(
1174            manifest(vec![source("a")], too_many_clips, vec![]),
1175            Err(CollectionManifestError::TooManyRows { field: "clips", .. })
1176        ));
1177
1178        let members: Vec<_> = (0..4)
1179            .map(|index| format!("com.example.pack/m/{index}"))
1180            .collect();
1181        let member_refs: Vec<_> = members.iter().map(String::as_str).collect();
1182        let member_clips = members
1183            .iter()
1184            .enumerate()
1185            .map(|(index, value)| clip(value, "a", index as u32))
1186            .collect();
1187        let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
1188            .map(|index| set(&format!("com.example.pack/sets/{index}"), &member_refs))
1189            .collect();
1190        assert!(manifest(vec![source("a")], member_clips, sets).is_ok());
1191        let too_many_sets = (0..=COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
1192            .map(|index| set(&format!("com.example.pack/sets/{index}"), &member_refs))
1193            .collect();
1194        let member_clips = members
1195            .iter()
1196            .enumerate()
1197            .map(|(index, value)| clip(value, "a", index as u32))
1198            .collect();
1199        assert!(matches!(
1200            manifest(vec![source("a")], member_clips, too_many_sets),
1201            Err(CollectionManifestError::TooManyRows {
1202                field: "runtime_sets",
1203                ..
1204            })
1205        ));
1206
1207        let overflow_members = (0..5)
1208            .map(|index| format!("com.example.pack/x/{index}"))
1209            .collect::<Vec<_>>();
1210        let overflow_refs = overflow_members
1211            .iter()
1212            .map(String::as_str)
1213            .collect::<Vec<_>>();
1214        let overflow_clips = overflow_members
1215            .iter()
1216            .enumerate()
1217            .map(|(index, value)| clip(value, "a", index as u32))
1218            .collect();
1219        let overflow_sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
1220            .map(|index| set(&format!("com.example.pack/sets/{index}"), &overflow_refs))
1221            .collect();
1222        assert!(matches!(
1223            manifest(vec![source("a")], overflow_clips, overflow_sets),
1224            Err(CollectionManifestError::TooManyMembers { .. })
1225        ));
1226    }
1227
1228    #[test]
1229    fn aggregate_work_has_an_exact_boundary_independent_of_membership() {
1230        let ids: Vec<_> = (0..4)
1231            .map(|index| format!("com.example.pack/c/{index}"))
1232            .collect();
1233        let members: Vec<_> = ids.iter().map(String::as_str).collect();
1234        let sources = (0..COLLECTION_MANIFEST_V1_MAX_SOURCES)
1235            .map(|index| source(&format!("s{index}")))
1236            .collect();
1237        let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
1238            .map(|index| clip(&format!("com.example.pack/c/{index}"), "s0", index as u32))
1239            .collect::<Vec<_>>();
1240        let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
1241            .map(|index| set(&format!("com.example.pack/sets/{index}"), &members[..3]))
1242            .collect();
1243        assert!(manifest(sources, clips, sets).is_ok());
1244
1245        let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
1246            .map(|index| clip(&format!("com.example.pack/c/{index}"), "s0", index as u32))
1247            .collect::<Vec<_>>();
1248        let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
1249            .map(|index| set(&format!("com.example.pack/sets/{index}"), &members))
1250            .collect();
1251        assert!(matches!(
1252            manifest(vec![source("s0")], clips, sets),
1253            Err(CollectionManifestError::TooMuchWork { .. })
1254        ));
1255    }
1256
1257    #[test]
1258    fn aggregate_members_have_an_exact_boundary_independent_of_work() {
1259        let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
1260            .map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
1261            .collect::<Vec<_>>();
1262        let members = clips
1263            .iter()
1264            .map(|clip| clip.id().as_str())
1265            .collect::<Vec<_>>();
1266        let sets = (0..4)
1267            .map(|index| set(&format!("com.example.pack/sets/{index}"), &members))
1268            .collect();
1269        assert!(manifest(vec![source("a")], clips, sets).is_ok());
1270
1271        let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
1272            .map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
1273            .collect::<Vec<_>>();
1274        let members = clips
1275            .iter()
1276            .map(|clip| clip.id().as_str())
1277            .collect::<Vec<_>>();
1278        let sets = vec![
1279            set("com.example.pack/sets/0", &members),
1280            set("com.example.pack/sets/1", &members),
1281            set("com.example.pack/sets/2", &members),
1282            set("com.example.pack/sets/3", &members[..members.len() - 1]),
1283            set("com.example.pack/sets/4", &members[..2]),
1284        ];
1285        assert!(matches!(
1286            manifest(vec![source("a")], clips, sets),
1287            Err(CollectionManifestError::TooManyMembers { .. })
1288        ));
1289    }
1290}