1use crate::canonical::settings_identity;
2use crate::{
3 DefaultStatus, EngineDeclaration, EngineProfile, InvalidSettingReason, ProfileSelection,
4 ResolutionError, SettingApplicability, SettingDomain, SettingId, SettingLocation, SettingMap,
5 SettingScope, SettingValue, profiles_v1,
6};
7use animsmith_core::{
8 ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS, EngineContractError, InputIdentity, SourceFormatV1,
9};
10use std::collections::BTreeMap;
11
12pub const SOURCE_TRANSFORM_PATH_MAX_BYTES: usize = 4_096;
14
15pub fn lookup_profile(
21 selection: &ProfileSelection,
22) -> Result<&'static EngineProfile, ResolutionError> {
23 profiles_v1()
24 .iter()
25 .find(|profile| profile.selection() == selection)
26 .ok_or_else(|| ResolutionError::UnknownProfile(selection.clone()))
27}
28
29pub fn resolve_static(
41 declaration: EngineDeclaration,
42) -> Result<Option<StaticResolution>, ResolutionError> {
43 let EngineDeclaration {
44 selection,
45 document_settings,
46 clip_settings,
47 } = declaration;
48 let Some(selection) = selection else {
49 if document_settings.is_some() || !clip_settings.is_empty() {
50 return Err(ResolutionError::SettingsWithoutSelection);
51 }
52 return Ok(None);
53 };
54 let profile = lookup_profile(&selection)?;
55
56 let document_settings = validate_map(
57 profile,
58 document_settings.unwrap_or_default(),
59 SettingScope::Document,
60 SettingLocation::Document,
61 )?;
62 for descriptor in profile.setting_descriptors() {
63 if descriptor.applicability() == SettingApplicability::Applicable
64 && descriptor.scope() == SettingScope::Document
65 && descriptor.default_status() == DefaultStatus::RequiredWithoutDefault
66 && !document_settings.contains_key(&descriptor.id())
67 {
68 return Err(ResolutionError::MissingRequiredSetting {
69 setting: descriptor.id(),
70 location: SettingLocation::Document,
71 });
72 }
73 }
74
75 let mut validated_clips = BTreeMap::new();
76 for (selector, settings) in clip_settings {
77 let values = validate_map(
78 profile,
79 settings,
80 SettingScope::Clip,
81 SettingLocation::ClipSelector(selector.clone()),
82 )?;
83 validated_clips.insert(selector, values);
84 }
85
86 Ok(Some(StaticResolution {
87 profile,
88 document_settings,
89 clip_overlays: validated_clips,
90 }))
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct StaticResolution {
96 profile: &'static EngineProfile,
97 document_settings: BTreeMap<SettingId, SettingValue>,
98 clip_overlays: BTreeMap<String, BTreeMap<SettingId, SettingValue>>,
99}
100
101impl StaticResolution {
102 pub const fn profile(&self) -> &'static EngineProfile {
104 self.profile
105 }
106
107 pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
109 &self.document_settings
110 }
111
112 pub fn resolve_input(
126 &self,
127 source_format: SourceFormatV1,
128 clip_names: &[String],
129 ) -> Result<ResolvedProfile, ResolutionError> {
130 self.resolve_input_iter(source_format, clip_names.iter().map(String::as_str))
131 }
132
133 pub fn resolve_input_iter<'a>(
144 &self,
145 source_format: SourceFormatV1,
146 clip_names: impl ExactSizeIterator<Item = &'a str>,
147 ) -> Result<ResolvedProfile, ResolutionError> {
148 if !self.profile.accepted_inputs().contains(&source_format) {
149 return Err(ResolutionError::UnacceptedInputFormat {
150 selection: self.profile.selection().clone(),
151 format: source_format,
152 });
153 }
154
155 let clip_count = clip_names.len();
156 if clip_count > ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS {
157 return Err(ResolutionError::ResolvedSettingsContract(
158 EngineContractError::TooManyRows {
159 field: "settings.clips",
160 found: clip_count,
161 max: ENGINE_CONTRACT_V1_MAX_COLLECTION_ROWS,
162 },
163 ));
164 }
165
166 let mut clips = Vec::with_capacity(clip_count);
167 for clip_name in clip_names {
168 let mut materialized = BTreeMap::new();
169 for (selector, settings) in &self.clip_overlays {
170 if selector != clip_name && animsmith_core::config::glob_match(selector, clip_name)
171 {
172 materialized.extend(settings.iter().map(|(id, value)| (*id, value.clone())));
173 }
174 }
175 if let Some(exact) = self.clip_overlays.get(clip_name) {
176 materialized.extend(exact.iter().map(|(id, value)| (*id, value.clone())));
177 }
178 for descriptor in self.profile.setting_descriptors() {
179 if descriptor.applicability() == SettingApplicability::Applicable
180 && descriptor.scope() == SettingScope::Clip
181 && descriptor.default_status() == DefaultStatus::RequiredWithoutDefault
182 && !materialized.contains_key(&descriptor.id())
183 {
184 return Err(ResolutionError::MissingRequiredSetting {
185 setting: descriptor.id(),
186 location: SettingLocation::ClipSelector(clip_name.to_owned()),
187 });
188 }
189 }
190 clips.push(ResolvedClipSettings {
191 clip_name: clip_name.to_owned(),
192 settings: materialized,
193 });
194 }
195 clips.sort_by(|left, right| left.clip_name.cmp(&right.clip_name));
196 let identity = settings_identity(
197 self.profile,
198 &self.document_settings,
199 clips
200 .iter()
201 .map(|clip| (clip.clip_name.as_str(), &clip.settings)),
202 )?;
203 Ok(ResolvedProfile {
204 profile: self.profile,
205 source_format,
206 document_settings: self.document_settings.clone(),
207 clips,
208 settings_identity: identity,
209 })
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct ResolvedClipSettings {
216 clip_name: String,
217 settings: BTreeMap<SettingId, SettingValue>,
218}
219
220impl ResolvedClipSettings {
221 pub fn clip_name(&self) -> &str {
223 &self.clip_name
224 }
225
226 pub const fn settings(&self) -> &BTreeMap<SettingId, SettingValue> {
228 &self.settings
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ResolvedProfile {
235 profile: &'static EngineProfile,
236 source_format: SourceFormatV1,
237 document_settings: BTreeMap<SettingId, SettingValue>,
238 clips: Vec<ResolvedClipSettings>,
239 settings_identity: InputIdentity,
240}
241
242impl ResolvedProfile {
243 pub const fn profile(&self) -> &'static EngineProfile {
245 self.profile
246 }
247
248 pub const fn source_format(&self) -> SourceFormatV1 {
250 self.source_format
251 }
252
253 pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
255 &self.document_settings
256 }
257
258 pub fn clip_settings(&self) -> &[ResolvedClipSettings] {
260 &self.clips
261 }
262
263 pub const fn settings_identity(&self) -> &InputIdentity {
265 &self.settings_identity
266 }
267}
268
269fn validate_map(
270 profile: &EngineProfile,
271 settings: SettingMap,
272 supplied_scope: SettingScope,
273 location: SettingLocation,
274) -> Result<BTreeMap<SettingId, SettingValue>, ResolutionError> {
275 let mut validated = BTreeMap::new();
276 for (key, value) in settings {
277 let Some(id) = SettingId::from_str(&key) else {
278 return Err(ResolutionError::UnknownSetting {
279 key,
280 location: location.clone(),
281 });
282 };
283 let Some(descriptor) = profile.setting_descriptor(id) else {
284 return Err(ResolutionError::UnknownSetting {
285 key,
286 location: location.clone(),
287 });
288 };
289 if descriptor.applicability() == SettingApplicability::NotApplicable {
290 return Err(ResolutionError::NotApplicable {
291 setting: id,
292 location: location.clone(),
293 });
294 }
295 if descriptor.scope() != supplied_scope {
296 return Err(ResolutionError::WrongScope {
297 setting: id,
298 expected: descriptor.scope(),
299 found: supplied_scope,
300 location: location.clone(),
301 });
302 }
303 validate_value(id, descriptor.domain(), &value, location.clone())?;
304 validated.insert(id, value);
305 }
306 Ok(validated)
307}
308
309fn validate_value(
310 setting: SettingId,
311 domain: SettingDomain,
312 value: &SettingValue,
313 location: SettingLocation,
314) -> Result<(), ResolutionError> {
315 let domain_matches = matches!(
316 (domain, value),
317 (SettingDomain::Boolean, SettingValue::Boolean(_))
318 | (SettingDomain::BakeOrExtract, SettingValue::BakeOrExtract(_))
319 | (
320 SettingDomain::SourceTransformPath,
321 SettingValue::SourceTransformPath(_)
322 )
323 );
324 if !domain_matches {
325 return Err(ResolutionError::InvalidSettingValue {
326 setting,
327 location,
328 reason: InvalidSettingReason::WrongDomain {
329 expected: domain,
330 found: value.kind(),
331 },
332 });
333 }
334 if let SettingValue::SourceTransformPath(path) = value {
335 let reason = if path.is_empty() {
336 Some(InvalidSettingReason::EmptyPath)
337 } else if path.len() > SOURCE_TRANSFORM_PATH_MAX_BYTES {
338 Some(InvalidSettingReason::PathTooLong {
339 bytes: path.len(),
340 limit: SOURCE_TRANSFORM_PATH_MAX_BYTES,
341 })
342 } else if path.starts_with('/') {
343 Some(InvalidSettingReason::AbsolutePath)
344 } else if path.chars().any(char::is_control) {
345 Some(InvalidSettingReason::ControlCharacter)
346 } else if path.split('/').any(str::is_empty) {
347 Some(InvalidSettingReason::EmptyPathSegment)
348 } else if path.split('/').any(|segment| matches!(segment, "." | "..")) {
349 Some(InvalidSettingReason::DotPathSegment)
350 } else {
351 None
352 };
353 if let Some(reason) = reason {
354 return Err(ResolutionError::InvalidSettingValue {
355 setting,
356 location,
357 reason,
358 });
359 }
360 }
361 Ok(())
362}