1use 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
16pub const COLLECTION_MANIFEST_V1_ID: &str = "urn:animsmith:schema:collection-manifest:1";
18pub const COLLECTION_MANIFEST_V1_SCHEMA_VERSION: u32 = 1;
20pub const COLLECTION_MANIFEST_V1_BUDGET_ID: &str = "urn:animsmith:collection-manifest-budget:1";
22pub const COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
24pub const COLLECTION_MANIFEST_V1_MAX_SOURCES: usize = 4_096;
26pub const COLLECTION_MANIFEST_V1_MAX_CLIPS: usize = 4_096;
28pub const COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS: usize = 4_096;
30pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS: usize = 16_384;
32pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK: usize = 24_576;
34pub const COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES: usize = 255;
36pub const COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES: usize = 4_096;
38
39#[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 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 pub const fn id(self) -> &'static str {
75 self.id
76 }
77 pub const fn max_manifest_bytes(self) -> u64 {
79 self.max_manifest_bytes
80 }
81 pub const fn max_sources(self) -> usize {
83 self.max_sources
84 }
85 pub const fn max_clips(self) -> usize {
87 self.max_clips
88 }
89 pub const fn max_runtime_sets(self) -> usize {
91 self.max_runtime_sets
92 }
93 pub const fn max_aggregate_members(self) -> usize {
95 self.max_aggregate_members
96 }
97 pub const fn max_aggregate_work(self) -> usize {
99 self.max_aggregate_work
100 }
101 pub const fn max_identifier_bytes(self) -> usize {
103 self.max_identifier_bytes
104 }
105 pub const fn max_take_name_bytes(self) -> usize {
107 self.max_take_name_bytes
108 }
109 pub const fn max_path_bytes(self) -> usize {
111 self.max_path_bytes
112 }
113 pub const fn max_path_components(self) -> usize {
115 self.max_path_components
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[non_exhaustive]
122pub enum CollectionManifestError {
123 #[error("invalid {field}: expected nonempty UTF-8 text no longer than {max} bytes")]
125 InvalidText {
126 field: &'static str,
128 max: usize,
130 },
131 #[error("invalid {field}: expected V1 lowercase ASCII identifier")]
133 InvalidIdentifier {
134 field: &'static str,
136 },
137 #[error("{field} has {found} rows, exceeding V1 limit {max}")]
139 TooManyRows {
140 field: &'static str,
142 found: usize,
144 max: usize,
146 },
147 #[error("{field} must contain at least one row")]
149 EmptyRows {
150 field: &'static str,
152 },
153 #[error("runtime_sets members total {found} exceeds V1 limit {max}")]
155 TooManyMembers {
156 found: usize,
158 max: usize,
160 },
161 #[error("collection manifest aggregate work {found} exceeds V1 limit {max}")]
163 TooMuchWork {
164 found: usize,
166 max: usize,
168 },
169 #[error("duplicate {field} {value:?}")]
171 Duplicate {
172 field: &'static str,
174 value: String,
176 },
177 #[error("clip {clip_id:?} references undeclared source {source_key:?}")]
179 DanglingSource {
180 clip_id: String,
182 source_key: String,
184 },
185 #[error("runtime set {set_id:?} references undeclared member {member:?}")]
187 DanglingMember {
188 set_id: String,
190 member: String,
192 },
193 #[error("runtime set {set_id:?} needs at least two members, found {found}")]
195 TooFewMembers {
196 set_id: String,
198 found: usize,
200 },
201 #[error("{field} {value:?} must start with collection id {collection_id:?} followed by '/'")]
203 OutsideCollectionNamespace {
204 field: &'static str,
206 value: String,
208 collection_id: String,
210 },
211 #[error("expected_sha256 must be exactly 64 lowercase hexadecimal digits")]
213 InvalidDigest,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
218#[serde(transparent)]
219pub struct CollectionIdV1(String);
220
221impl CollectionIdV1 {
222 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 pub fn as_str(&self) -> &str {
236 &self.0
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
242#[serde(transparent)]
243pub struct CollectionSourceKeyV1(String);
244
245impl CollectionSourceKeyV1 {
246 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 pub fn as_str(&self) -> &str {
260 &self.0
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
266#[serde(transparent)]
267pub struct CollectionLogicalIdV1(String);
268
269impl CollectionLogicalIdV1 {
270 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 pub fn as_str(&self) -> &str {
291 &self.0
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
297#[serde(transparent)]
298pub struct CollectionDigestPinV1(String);
299
300impl CollectionDigestPinV1 {
301 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 pub fn as_str(&self) -> &str {
321 &self.0
322 }
323}
324
325#[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 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 pub fn key(&self) -> &CollectionSourceKeyV1 {
354 &self.key
355 }
356
357 pub fn path(&self) -> &DependencyResourceKeyV1 {
359 &self.path
360 }
361
362 pub fn config(&self) -> Option<&DependencyResourceKeyV1> {
364 self.config.as_ref()
365 }
366
367 pub fn expected_sha256(&self) -> Option<&CollectionDigestPinV1> {
369 self.expected_sha256.as_ref()
370 }
371}
372
373#[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 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 pub fn id(&self) -> &CollectionLogicalIdV1 {
411 &self.id
412 }
413
414 pub fn source(&self) -> &CollectionSourceKeyV1 {
416 &self.source
417 }
418
419 pub const fn take_index(&self) -> u32 {
421 self.take_index
422 }
423
424 pub fn take_name(&self) -> &str {
426 &self.take_name
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
432#[serde(rename_all = "kebab-case")]
433pub enum CollectionRuntimeSetKindV1 {
434 GaitGroup,
436 SyncGroup,
438 DirectionalBlend,
440 SpeedBlend,
442 TransitionChain,
444 MaskComposition,
446 RetargetGroup,
448 PairedInteraction,
450 MotionDatabase,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
456pub struct CollectionRuntimeSetV1 {
457 id: CollectionLogicalIdV1,
458 kind: CollectionRuntimeSetKindV1,
459 members: Vec<CollectionLogicalIdV1>,
460}
461
462impl CollectionRuntimeSetV1 {
463 pub fn new(
465 id: CollectionLogicalIdV1,
466 kind: CollectionRuntimeSetKindV1,
467 members: Vec<CollectionLogicalIdV1>,
468 ) -> Self {
469 Self { id, kind, members }
470 }
471
472 pub fn id(&self) -> &CollectionLogicalIdV1 {
474 &self.id
475 }
476
477 pub const fn kind(&self) -> CollectionRuntimeSetKindV1 {
479 self.kind
480 }
481
482 pub fn members(&self) -> &[CollectionLogicalIdV1] {
484 &self.members
485 }
486}
487
488#[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 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 pub const fn schema(&self) -> &'static str {
655 self.schema
656 }
657
658 pub const fn schema_version(&self) -> u32 {
660 self.schema_version
661 }
662
663 pub fn collection_id(&self) -> &CollectionIdV1 {
665 &self.collection_id
666 }
667
668 pub fn input_root(&self) -> Option<&DependencyResourceKeyV1> {
670 self.input_root.as_ref()
671 }
672
673 pub fn sources(&self) -> &[CollectionSourceV1] {
675 &self.sources
676 }
677
678 pub fn clips(&self) -> &[CollectionClipV1] {
680 &self.clips
681 }
682
683 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}