use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{
DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES, DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
DependencyResourceKeyV1,
};
pub const COLLECTION_MANIFEST_V1_ID: &str = "urn:animsmith:schema:collection-manifest:1";
pub const COLLECTION_MANIFEST_V1_SCHEMA_VERSION: u32 = 1;
pub const COLLECTION_MANIFEST_V1_BUDGET_ID: &str = "urn:animsmith:collection-manifest-budget:1";
pub const COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
pub const COLLECTION_MANIFEST_V1_MAX_SOURCES: usize = 4_096;
pub const COLLECTION_MANIFEST_V1_MAX_CLIPS: usize = 4_096;
pub const COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS: usize = 4_096;
pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS: usize = 16_384;
pub const COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK: usize = 24_576;
pub const COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES: usize = 255;
pub const COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES: usize = 4_096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct CollectionManifestBudgetV1 {
id: &'static str,
max_manifest_bytes: u64,
max_sources: usize,
max_clips: usize,
max_runtime_sets: usize,
max_aggregate_members: usize,
max_aggregate_work: usize,
max_identifier_bytes: usize,
max_take_name_bytes: usize,
max_path_bytes: usize,
max_path_components: usize,
}
impl CollectionManifestBudgetV1 {
pub const fn v1() -> Self {
Self {
id: COLLECTION_MANIFEST_V1_BUDGET_ID,
max_manifest_bytes: COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
max_sources: COLLECTION_MANIFEST_V1_MAX_SOURCES,
max_clips: COLLECTION_MANIFEST_V1_MAX_CLIPS,
max_runtime_sets: COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
max_aggregate_members: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
max_aggregate_work: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
max_identifier_bytes: COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES,
max_take_name_bytes: COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
max_path_bytes: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
max_path_components: DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
}
}
pub const fn id(self) -> &'static str {
self.id
}
pub const fn max_manifest_bytes(self) -> u64 {
self.max_manifest_bytes
}
pub const fn max_sources(self) -> usize {
self.max_sources
}
pub const fn max_clips(self) -> usize {
self.max_clips
}
pub const fn max_runtime_sets(self) -> usize {
self.max_runtime_sets
}
pub const fn max_aggregate_members(self) -> usize {
self.max_aggregate_members
}
pub const fn max_aggregate_work(self) -> usize {
self.max_aggregate_work
}
pub const fn max_identifier_bytes(self) -> usize {
self.max_identifier_bytes
}
pub const fn max_take_name_bytes(self) -> usize {
self.max_take_name_bytes
}
pub const fn max_path_bytes(self) -> usize {
self.max_path_bytes
}
pub const fn max_path_components(self) -> usize {
self.max_path_components
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CollectionManifestError {
#[error("invalid {field}: expected nonempty UTF-8 text no longer than {max} bytes")]
InvalidText {
field: &'static str,
max: usize,
},
#[error("invalid {field}: expected V1 lowercase ASCII identifier")]
InvalidIdentifier {
field: &'static str,
},
#[error("{field} has {found} rows, exceeding V1 limit {max}")]
TooManyRows {
field: &'static str,
found: usize,
max: usize,
},
#[error("{field} must contain at least one row")]
EmptyRows {
field: &'static str,
},
#[error("runtime_sets members total {found} exceeds V1 limit {max}")]
TooManyMembers {
found: usize,
max: usize,
},
#[error("collection manifest aggregate work {found} exceeds V1 limit {max}")]
TooMuchWork {
found: usize,
max: usize,
},
#[error("duplicate {field} {value:?}")]
Duplicate {
field: &'static str,
value: String,
},
#[error("clip {clip_id:?} references undeclared source {source_key:?}")]
DanglingSource {
clip_id: String,
source_key: String,
},
#[error("runtime set {set_id:?} references undeclared member {member:?}")]
DanglingMember {
set_id: String,
member: String,
},
#[error("runtime set {set_id:?} needs at least two members, found {found}")]
TooFewMembers {
set_id: String,
found: usize,
},
#[error("{field} {value:?} must start with collection id {collection_id:?} followed by '/'")]
OutsideCollectionNamespace {
field: &'static str,
value: String,
collection_id: String,
},
#[error("expected_sha256 must be exactly 64 lowercase hexadecimal digits")]
InvalidDigest,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct CollectionIdV1(String);
impl CollectionIdV1 {
pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
let value = value.into();
validate_token("collection_id", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct CollectionSourceKeyV1(String);
impl CollectionSourceKeyV1 {
pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
let value = value.into();
validate_token("source.key", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct CollectionLogicalIdV1(String);
impl CollectionLogicalIdV1 {
pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
let value = value.into();
if value.len() > COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES
|| value.split('/').count() < 2
|| value.split('/').any(|token| !is_valid_token(token))
{
return Err(CollectionManifestError::InvalidIdentifier {
field: "logical_id",
});
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct CollectionDigestPinV1(String);
impl CollectionDigestPinV1 {
pub fn new(value: impl Into<String>) -> Result<Self, CollectionManifestError> {
let value = value.into();
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
return Err(CollectionManifestError::InvalidDigest);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CollectionSourceV1 {
key: CollectionSourceKeyV1,
path: DependencyResourceKeyV1,
#[serde(skip_serializing_if = "Option::is_none")]
config: Option<DependencyResourceKeyV1>,
#[serde(skip_serializing_if = "Option::is_none")]
expected_sha256: Option<CollectionDigestPinV1>,
}
impl CollectionSourceV1 {
pub fn new(
key: CollectionSourceKeyV1,
path: DependencyResourceKeyV1,
config: Option<DependencyResourceKeyV1>,
expected_sha256: Option<CollectionDigestPinV1>,
) -> Self {
Self {
key,
path,
config,
expected_sha256,
}
}
pub fn key(&self) -> &CollectionSourceKeyV1 {
&self.key
}
pub fn path(&self) -> &DependencyResourceKeyV1 {
&self.path
}
pub fn config(&self) -> Option<&DependencyResourceKeyV1> {
self.config.as_ref()
}
pub fn expected_sha256(&self) -> Option<&CollectionDigestPinV1> {
self.expected_sha256.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CollectionClipV1 {
id: CollectionLogicalIdV1,
source: CollectionSourceKeyV1,
take_index: u32,
take_name: String,
}
impl CollectionClipV1 {
pub fn new(
id: CollectionLogicalIdV1,
source: CollectionSourceKeyV1,
take_index: u32,
take_name: impl Into<String>,
) -> Result<Self, CollectionManifestError> {
let take_name = take_name.into();
validate_text(
"clips.take_name",
&take_name,
COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
)?;
Ok(Self {
id,
source,
take_index,
take_name,
})
}
pub fn id(&self) -> &CollectionLogicalIdV1 {
&self.id
}
pub fn source(&self) -> &CollectionSourceKeyV1 {
&self.source
}
pub const fn take_index(&self) -> u32 {
self.take_index
}
pub fn take_name(&self) -> &str {
&self.take_name
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CollectionRuntimeSetKindV1 {
GaitGroup,
SyncGroup,
DirectionalBlend,
SpeedBlend,
TransitionChain,
MaskComposition,
RetargetGroup,
PairedInteraction,
MotionDatabase,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CollectionRuntimeSetV1 {
id: CollectionLogicalIdV1,
kind: CollectionRuntimeSetKindV1,
members: Vec<CollectionLogicalIdV1>,
}
impl CollectionRuntimeSetV1 {
pub fn new(
id: CollectionLogicalIdV1,
kind: CollectionRuntimeSetKindV1,
members: Vec<CollectionLogicalIdV1>,
) -> Self {
Self { id, kind, members }
}
pub fn id(&self) -> &CollectionLogicalIdV1 {
&self.id
}
pub const fn kind(&self) -> CollectionRuntimeSetKindV1 {
self.kind
}
pub fn members(&self) -> &[CollectionLogicalIdV1] {
&self.members
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CollectionManifestV1 {
schema: &'static str,
schema_version: u32,
collection_id: CollectionIdV1,
#[serde(skip_serializing_if = "Option::is_none")]
input_root: Option<DependencyResourceKeyV1>,
sources: Vec<CollectionSourceV1>,
clips: Vec<CollectionClipV1>,
runtime_sets: Vec<CollectionRuntimeSetV1>,
}
impl CollectionManifestV1 {
pub fn new(
collection_id: CollectionIdV1,
input_root: Option<DependencyResourceKeyV1>,
mut sources: Vec<CollectionSourceV1>,
mut clips: Vec<CollectionClipV1>,
mut runtime_sets: Vec<CollectionRuntimeSetV1>,
) -> Result<Self, CollectionManifestError> {
validate_rows("sources", sources.len(), COLLECTION_MANIFEST_V1_MAX_SOURCES)?;
validate_rows("clips", clips.len(), COLLECTION_MANIFEST_V1_MAX_CLIPS)?;
validate_rows(
"runtime_sets",
runtime_sets.len(),
COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
)?;
if sources.is_empty() {
return Err(CollectionManifestError::EmptyRows { field: "sources" });
}
if clips.is_empty() {
return Err(CollectionManifestError::EmptyRows { field: "clips" });
}
sources.sort_by(|left, right| left.key.cmp(&right.key));
clips.sort_by(|left, right| left.id.cmp(&right.id));
runtime_sets.sort_by(|left, right| left.id.cmp(&right.id));
let mut source_keys = BTreeSet::new();
for source in &sources {
if !source_keys.insert(source.key.clone()) {
return Err(CollectionManifestError::Duplicate {
field: "source key",
value: source.key.0.clone(),
});
}
}
let namespace = format!("{}/", collection_id.as_str());
let mut clip_ids = BTreeSet::new();
let mut bindings = BTreeSet::new();
for clip in &clips {
validate_namespace("clips.id", &clip.id, &collection_id, &namespace)?;
if !source_keys.contains(&clip.source) {
return Err(CollectionManifestError::DanglingSource {
clip_id: clip.id.0.clone(),
source_key: clip.source.0.clone(),
});
}
if !clip_ids.insert(clip.id.clone()) {
return Err(CollectionManifestError::Duplicate {
field: "clip id",
value: clip.id.0.clone(),
});
}
if !bindings.insert((clip.source.clone(), clip.take_index)) {
return Err(CollectionManifestError::Duplicate {
field: "source/take binding",
value: format!("{}:{}", clip.source.as_str(), clip.take_index),
});
}
}
let mut total_members = 0usize;
let mut aggregate_work = sources
.len()
.checked_add(clips.len())
.and_then(|value| value.checked_add(runtime_sets.len()))
.ok_or(CollectionManifestError::TooMuchWork {
found: usize::MAX,
max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
})?;
let mut set_ids = BTreeSet::new();
for runtime_set in &runtime_sets {
validate_namespace(
"runtime_sets.id",
&runtime_set.id,
&collection_id,
&namespace,
)?;
if !set_ids.insert(runtime_set.id.clone()) {
return Err(CollectionManifestError::Duplicate {
field: "runtime set id",
value: runtime_set.id.0.clone(),
});
}
if runtime_set.members.len() < 2 {
return Err(CollectionManifestError::TooFewMembers {
set_id: runtime_set.id.0.clone(),
found: runtime_set.members.len(),
});
}
total_members = total_members.checked_add(runtime_set.members.len()).ok_or(
CollectionManifestError::TooManyMembers {
found: usize::MAX,
max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
},
)?;
aggregate_work = aggregate_work
.checked_add(runtime_set.members.len())
.ok_or(CollectionManifestError::TooMuchWork {
found: usize::MAX,
max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
})?;
if aggregate_work > COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK {
return Err(CollectionManifestError::TooMuchWork {
found: aggregate_work,
max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
});
}
if total_members > COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS {
return Err(CollectionManifestError::TooManyMembers {
found: total_members,
max: COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
});
}
let mut members = BTreeSet::new();
for member in &runtime_set.members {
if !members.insert(member.clone()) {
return Err(CollectionManifestError::Duplicate {
field: "runtime set member",
value: format!("{}:{}", runtime_set.id.as_str(), member.as_str()),
});
}
if !clip_ids.contains(member) {
return Err(CollectionManifestError::DanglingMember {
set_id: runtime_set.id.0.clone(),
member: member.0.clone(),
});
}
}
}
Ok(Self {
schema: COLLECTION_MANIFEST_V1_ID,
schema_version: COLLECTION_MANIFEST_V1_SCHEMA_VERSION,
collection_id,
input_root,
sources,
clips,
runtime_sets,
})
}
pub const fn schema(&self) -> &'static str {
self.schema
}
pub const fn schema_version(&self) -> u32 {
self.schema_version
}
pub fn collection_id(&self) -> &CollectionIdV1 {
&self.collection_id
}
pub fn input_root(&self) -> Option<&DependencyResourceKeyV1> {
self.input_root.as_ref()
}
pub fn sources(&self) -> &[CollectionSourceV1] {
&self.sources
}
pub fn clips(&self) -> &[CollectionClipV1] {
&self.clips
}
pub fn runtime_sets(&self) -> &[CollectionRuntimeSetV1] {
&self.runtime_sets
}
}
fn validate_token(field: &'static str, value: &str) -> Result<(), CollectionManifestError> {
if value.len() > COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES || !is_valid_token(value) {
return Err(CollectionManifestError::InvalidIdentifier { field });
}
Ok(())
}
fn is_valid_token(value: &str) -> bool {
let bytes = value.as_bytes();
bytes
.first()
.is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
&& bytes.iter().skip(1).all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
})
}
fn validate_text(
field: &'static str,
value: &str,
max: usize,
) -> Result<(), CollectionManifestError> {
if value.is_empty() || value.len() > max {
return Err(CollectionManifestError::InvalidText { field, max });
}
Ok(())
}
fn validate_rows(
field: &'static str,
found: usize,
max: usize,
) -> Result<(), CollectionManifestError> {
if found > max {
return Err(CollectionManifestError::TooManyRows { field, found, max });
}
Ok(())
}
fn validate_namespace(
field: &'static str,
id: &CollectionLogicalIdV1,
collection_id: &CollectionIdV1,
namespace: &str,
) -> Result<(), CollectionManifestError> {
if !id.as_str().starts_with(namespace) {
return Err(CollectionManifestError::OutsideCollectionNamespace {
field,
value: id.0.clone(),
collection_id: collection_id.0.clone(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ResourceKeySyntaxV1;
fn key(value: &str) -> DependencyResourceKeyV1 {
DependencyResourceKeyV1::from_source_str(value, ResourceKeySyntaxV1::ParserRelativePath)
.unwrap()
}
fn source(value: &str) -> CollectionSourceV1 {
source_at(value, "motion.fbx")
}
fn source_at(value: &str, path: &str) -> CollectionSourceV1 {
CollectionSourceV1::new(
CollectionSourceKeyV1::new(value).unwrap(),
key(path),
None,
None,
)
}
fn clip(id: &str, source: &str, take_index: u32) -> CollectionClipV1 {
CollectionClipV1::new(
CollectionLogicalIdV1::new(id).unwrap(),
CollectionSourceKeyV1::new(source).unwrap(),
take_index,
"Take 001",
)
.unwrap()
}
fn set(id: &str, members: &[&str]) -> CollectionRuntimeSetV1 {
CollectionRuntimeSetV1::new(
CollectionLogicalIdV1::new(id).unwrap(),
CollectionRuntimeSetKindV1::GaitGroup,
members
.iter()
.map(|member| CollectionLogicalIdV1::new(*member).unwrap())
.collect(),
)
}
fn manifest(
sources: Vec<CollectionSourceV1>,
clips: Vec<CollectionClipV1>,
sets: Vec<CollectionRuntimeSetV1>,
) -> Result<CollectionManifestV1, CollectionManifestError> {
CollectionManifestV1::new(
CollectionIdV1::new("com.example.pack").unwrap(),
None,
sources,
clips,
sets,
)
}
#[test]
fn valid_manifest_canonicalizes_rows_but_preserves_member_order() {
let forward = "com.example.pack/locomotion/forward";
let left = "com.example.pack/locomotion/left";
let value = manifest(
vec![source("zebra"), source("alpha")],
vec![clip(left, "alpha", 0), clip(forward, "zebra", 1)],
vec![
set("com.example.pack/sets/zebra", &[forward, left]),
set("com.example.pack/sets/alpha", &[left, forward]),
],
)
.unwrap();
assert_eq!(value.sources()[0].key().as_str(), "alpha");
assert_eq!(value.clips()[0].id().as_str(), forward);
assert_eq!(
value.runtime_sets()[0].id().as_str(),
"com.example.pack/sets/alpha"
);
assert_eq!(value.runtime_sets()[0].members()[0].as_str(), left);
assert_eq!(value.runtime_sets()[0].members()[1].as_str(), forward);
assert_eq!(value.schema(), COLLECTION_MANIFEST_V1_ID);
assert_eq!(
value.schema_version(),
COLLECTION_MANIFEST_V1_SCHEMA_VERSION
);
let shuffled = manifest(
vec![source("alpha"), source("zebra")],
vec![clip(forward, "zebra", 1), clip(left, "alpha", 0)],
vec![
set("com.example.pack/sets/alpha", &[left, forward]),
set("com.example.pack/sets/zebra", &[forward, left]),
],
)
.unwrap();
assert_eq!(value, shuffled);
}
#[test]
fn source_rename_does_not_change_logical_identity() {
let id = "com.example.pack/locomotion/walk";
let before = manifest(
vec![source("old-file")],
vec![clip(id, "old-file", 0)],
vec![],
)
.unwrap();
let after = manifest(
vec![source("renamed-file")],
vec![clip(id, "renamed-file", 0)],
vec![],
)
.unwrap();
assert_eq!(before.clips()[0].id(), after.clips()[0].id());
assert_ne!(before.sources()[0].key(), after.sources()[0].key());
}
#[test]
fn exact_take_names_and_distinct_indices_are_retained() {
let id = "com.example.pack/locomotion/walk";
let other = "com.example.pack/locomotion/run";
let value = manifest(
vec![source("loco")],
vec![clip(id, "loco", 0), clip(other, "loco", 1)],
vec![],
)
.unwrap();
assert_eq!(value.clips()[0].take_name(), "Take 001");
assert_eq!(value.clips()[1].take_name(), "Take 001");
assert_eq!(value.clips()[0].take_index(), 1);
assert_eq!(value.clips()[1].take_index(), 0);
}
#[test]
fn rejects_duplicate_and_dangling_declarations() {
let id = "com.example.pack/locomotion/walk";
let other = "com.example.pack/locomotion/run";
assert!(matches!(
manifest(
vec![source_at("a", "first.fbx"), source_at("a", "second.fbx")],
vec![clip(id, "a", 0)],
vec![]
),
Err(CollectionManifestError::Duplicate {
field: "source key",
..
})
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 0), clip(id, "a", 1)],
vec![]
),
Err(CollectionManifestError::Duplicate {
field: "clip id",
..
})
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 7), clip(other, "a", 7)],
vec![]
),
Err(CollectionManifestError::Duplicate {
field: "source/take binding",
..
})
));
assert!(matches!(
manifest(vec![source("a")], vec![clip(id, "missing", 0)], vec![]),
Err(CollectionManifestError::DanglingSource { .. })
));
assert!(matches!(
manifest(
vec![source("a")],
vec![
clip(id, "a", 0),
clip(other, "a", 1),
clip("com.example.pack/locomotion/jump", "a", 2),
],
vec![set("com.example.pack/sets/a", &[id, other, id])]
),
Err(CollectionManifestError::Duplicate {
field: "runtime set member",
..
})
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 0)],
vec![set(
"com.example.pack/sets/a",
&[id, "com.example.pack/locomotion/missing"]
)]
),
Err(CollectionManifestError::DanglingMember { .. })
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 0)],
vec![set("com.example.pack/sets/a", &[id])]
),
Err(CollectionManifestError::TooFewMembers { .. })
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 0), clip(other, "a", 1)],
vec![
set("com.example.pack/sets/a", &[id, other]),
CollectionRuntimeSetV1::new(
CollectionLogicalIdV1::new("com.example.pack/sets/a").unwrap(),
CollectionRuntimeSetKindV1::SyncGroup,
vec![
CollectionLogicalIdV1::new(other).unwrap(),
CollectionLogicalIdV1::new(id).unwrap(),
],
),
]
),
Err(CollectionManifestError::Duplicate {
field: "runtime set id",
..
})
));
assert!(matches!(
manifest(
vec![source("a")],
vec![clip(id, "a", 0)],
vec![set("com.example.pack/sets/empty", &[])]
),
Err(CollectionManifestError::TooFewMembers { found: 0, .. })
));
}
#[test]
fn rejects_namespace_and_digest_violations() {
for outside in [
"other.pack/locomotion/walk",
"other/com.example.pack/locomotion/walk",
] {
assert!(matches!(
manifest(vec![source("a")], vec![clip(outside, "a", 0)], vec![]),
Err(CollectionManifestError::OutsideCollectionNamespace { .. })
));
}
assert!(CollectionDigestPinV1::new("A".repeat(64)).is_err());
assert!(CollectionDigestPinV1::new("0".repeat(63)).is_err());
assert!(CollectionDigestPinV1::new("0".repeat(65)).is_err());
assert!(CollectionDigestPinV1::new("g".repeat(64)).is_err());
}
#[test]
fn logical_ids_enforce_grammar_and_exact_byte_bound() {
let exact = format!(
"a/{}",
"x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 2)
);
assert_eq!(exact.len(), COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES);
assert!(CollectionLogicalIdV1::new(exact).is_ok());
let over = format!(
"a/{}",
"x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 1)
);
assert_eq!(over.len(), COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES + 1);
assert!(CollectionLogicalIdV1::new(over).is_err());
for invalid in ["single", "a/Upper", "a/unicodé", "a//empty", "a/.", "a/.."] {
assert!(
CollectionLogicalIdV1::new(invalid).is_err(),
"{invalid:?} must not satisfy logical-id grammar"
);
}
}
#[test]
fn runtime_set_kind_wire_vocabulary_is_closed() {
for kind in [
"gait-group",
"sync-group",
"directional-blend",
"speed-blend",
"transition-chain",
"mask-composition",
"retarget-group",
"paired-interaction",
"motion-database",
] {
serde_json::from_value::<CollectionRuntimeSetKindV1>(serde_json::json!(kind))
.unwrap_or_else(|error| panic!("closed kind {kind:?} must decode: {error}"));
}
assert!(
serde_json::from_value::<CollectionRuntimeSetKindV1>(serde_json::json!("typo"))
.is_err()
);
}
#[test]
fn preserves_unicode_take_names_and_reuses_safe_path_policy() {
let value = CollectionClipV1::new(
CollectionLogicalIdV1::new("com.example.pack/locomotion/walk").unwrap(),
CollectionSourceKeyV1::new("walk").unwrap(),
0,
"Pas \u{00e9} 001",
)
.unwrap();
assert_eq!(value.take_name(), "Pas \u{00e9} 001");
for unsafe_path in [
"/absolute.fbx",
"a\\b.fbx",
"../escape.fbx",
"https://x/y.fbx",
] {
assert!(
DependencyResourceKeyV1::from_source_str(
unsafe_path,
ResourceKeySyntaxV1::ParserRelativePath,
)
.is_err()
);
}
}
#[test]
fn identifiers_and_take_name_have_exact_bounds() {
let token = format!(
"a{}",
"x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES - 1)
);
assert!(CollectionIdV1::new(token).is_ok());
assert!(
CollectionIdV1::new(format!(
"a{}",
"x".repeat(COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES)
))
.is_err()
);
assert!(
CollectionClipV1::new(
CollectionLogicalIdV1::new("com.example/a").unwrap(),
CollectionSourceKeyV1::new("a").unwrap(),
0,
"x".repeat(COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES)
)
.is_ok()
);
assert!(
CollectionClipV1::new(
CollectionLogicalIdV1::new("com.example/a").unwrap(),
CollectionSourceKeyV1::new("a").unwrap(),
0,
"x".repeat(COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES + 1)
)
.is_err()
);
assert_eq!(
CollectionClipV1::new(
CollectionLogicalIdV1::new("com.example/a").unwrap(),
CollectionSourceKeyV1::new("a").unwrap(),
u32::MAX,
"Take 001",
)
.unwrap()
.take_index(),
u32::MAX
);
}
#[test]
fn budget_record_exposes_every_frozen_core_limit() {
let budget = CollectionManifestBudgetV1::v1();
assert_eq!(budget.id(), "urn:animsmith:collection-manifest-budget:1");
assert_eq!(budget.max_manifest_bytes(), 8_388_608);
assert_eq!(budget.max_sources(), 4_096);
assert_eq!(budget.max_clips(), 4_096);
assert_eq!(budget.max_runtime_sets(), 4_096);
assert_eq!(budget.max_aggregate_members(), 16_384);
assert_eq!(budget.max_aggregate_work(), 24_576);
assert_eq!(budget.max_identifier_bytes(), 255);
assert_eq!(budget.max_take_name_bytes(), 4_096);
assert_eq!(budget.max_path_bytes(), 4_096);
assert_eq!(budget.max_path_components(), 128);
assert_eq!(
serde_json::to_value(budget).unwrap(),
serde_json::json!({
"id": COLLECTION_MANIFEST_V1_BUDGET_ID,
"max_manifest_bytes": COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES,
"max_sources": COLLECTION_MANIFEST_V1_MAX_SOURCES,
"max_clips": COLLECTION_MANIFEST_V1_MAX_CLIPS,
"max_runtime_sets": COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS,
"max_aggregate_members": COLLECTION_MANIFEST_V1_MAX_AGGREGATE_MEMBERS,
"max_aggregate_work": COLLECTION_MANIFEST_V1_MAX_AGGREGATE_WORK,
"max_identifier_bytes": COLLECTION_MANIFEST_V1_MAX_IDENTIFIER_BYTES,
"max_take_name_bytes": COLLECTION_MANIFEST_V1_MAX_TAKE_NAME_BYTES,
"max_path_bytes": DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
"max_path_components": DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
})
);
}
#[test]
fn rows_and_aggregate_members_have_exact_bounds() {
let id = "com.example.pack/locomotion/walk";
let sources = (0..COLLECTION_MANIFEST_V1_MAX_SOURCES)
.map(|index| source(&format!("s{index}")))
.collect();
assert!(manifest(sources, vec![clip(id, "s0", 0)], vec![]).is_ok());
let too_many_sources = (0..=COLLECTION_MANIFEST_V1_MAX_SOURCES)
.map(|index| source(&format!("s{index}")))
.collect();
assert!(matches!(
manifest(too_many_sources, vec![clip(id, "s0", 0)], vec![]),
Err(CollectionManifestError::TooManyRows {
field: "sources",
..
})
));
let clips: Vec<_> = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
.collect();
assert!(manifest(vec![source("a")], clips, vec![]).is_ok());
let too_many_clips: Vec<_> = (0..=COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
.collect();
assert!(matches!(
manifest(vec![source("a")], too_many_clips, vec![]),
Err(CollectionManifestError::TooManyRows { field: "clips", .. })
));
let members: Vec<_> = (0..4)
.map(|index| format!("com.example.pack/m/{index}"))
.collect();
let member_refs: Vec<_> = members.iter().map(String::as_str).collect();
let member_clips = members
.iter()
.enumerate()
.map(|(index, value)| clip(value, "a", index as u32))
.collect();
let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &member_refs))
.collect();
assert!(manifest(vec![source("a")], member_clips, sets).is_ok());
let too_many_sets = (0..=COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &member_refs))
.collect();
let member_clips = members
.iter()
.enumerate()
.map(|(index, value)| clip(value, "a", index as u32))
.collect();
assert!(matches!(
manifest(vec![source("a")], member_clips, too_many_sets),
Err(CollectionManifestError::TooManyRows {
field: "runtime_sets",
..
})
));
let overflow_members = (0..5)
.map(|index| format!("com.example.pack/x/{index}"))
.collect::<Vec<_>>();
let overflow_refs = overflow_members
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let overflow_clips = overflow_members
.iter()
.enumerate()
.map(|(index, value)| clip(value, "a", index as u32))
.collect();
let overflow_sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &overflow_refs))
.collect();
assert!(matches!(
manifest(vec![source("a")], overflow_clips, overflow_sets),
Err(CollectionManifestError::TooManyMembers { .. })
));
}
#[test]
fn aggregate_work_has_an_exact_boundary_independent_of_membership() {
let ids: Vec<_> = (0..4)
.map(|index| format!("com.example.pack/c/{index}"))
.collect();
let members: Vec<_> = ids.iter().map(String::as_str).collect();
let sources = (0..COLLECTION_MANIFEST_V1_MAX_SOURCES)
.map(|index| source(&format!("s{index}")))
.collect();
let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "s0", index as u32))
.collect::<Vec<_>>();
let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &members[..3]))
.collect();
assert!(manifest(sources, clips, sets).is_ok());
let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "s0", index as u32))
.collect::<Vec<_>>();
let sets = (0..COLLECTION_MANIFEST_V1_MAX_RUNTIME_SETS)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &members))
.collect();
assert!(matches!(
manifest(vec![source("s0")], clips, sets),
Err(CollectionManifestError::TooMuchWork { .. })
));
}
#[test]
fn aggregate_members_have_an_exact_boundary_independent_of_work() {
let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
.collect::<Vec<_>>();
let members = clips
.iter()
.map(|clip| clip.id().as_str())
.collect::<Vec<_>>();
let sets = (0..4)
.map(|index| set(&format!("com.example.pack/sets/{index}"), &members))
.collect();
assert!(manifest(vec![source("a")], clips, sets).is_ok());
let clips = (0..COLLECTION_MANIFEST_V1_MAX_CLIPS)
.map(|index| clip(&format!("com.example.pack/c/{index}"), "a", index as u32))
.collect::<Vec<_>>();
let members = clips
.iter()
.map(|clip| clip.id().as_str())
.collect::<Vec<_>>();
let sets = vec![
set("com.example.pack/sets/0", &members),
set("com.example.pack/sets/1", &members),
set("com.example.pack/sets/2", &members),
set("com.example.pack/sets/3", &members[..members.len() - 1]),
set("com.example.pack/sets/4", &members[..2]),
];
assert!(matches!(
manifest(vec![source("a")], clips, sets),
Err(CollectionManifestError::TooManyMembers { .. })
));
}
}