Skip to main content

animsmith_engine/
resolver.rs

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
12/// Maximum UTF-8 byte count of a V1 source-transform path.
13pub const SOURCE_TRANSFORM_PATH_MAX_BYTES: usize = 4_096;
14
15/// Find one exact registry tuple without aliases, ranges, or fallback.
16///
17/// # Errors
18///
19/// Returns [`ResolutionError::UnknownProfile`] when no tuple is exactly equal.
20pub 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
29/// Validate an engine declaration without inspecting an asset.
30///
31/// With neither selection nor settings this returns `Ok(None)`, preserving
32/// engine-neutral behavior. Every declaration is otherwise validated,
33/// including settings beneath selectors that may match no eventual clip.
34///
35/// # Errors
36///
37/// Returns a typed configuration error for settings without a selection, an
38/// unknown tuple/key, wrong scope, not-applicable setting, invalid value, or
39/// missing required document setting.
40pub 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/// Validated profile and declarations that require no asset I/O.
94#[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    /// Exact immutable selected profile.
103    pub const fn profile(&self) -> &'static EngineProfile {
104        self.profile
105    }
106
107    /// Fully materialized required document settings.
108    pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
109        &self.document_settings
110    }
111
112    /// Resolve the authoritative input format and actual clip names.
113    ///
114    /// Non-exact globs apply in lexical selector order, then an exact clip-name
115    /// table applies last. Overlay is field-by-field. The result contains one
116    /// sorted row per actual clip, including repeated clip names.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ResolutionError::UnacceptedInputFormat`] outside the profile's
121    /// bounded V1 input set, [`ResolutionError::MissingRequiredSetting`] when
122    /// any real clip lacks an applicable required-without-default setting, or
123    /// [`ResolutionError::ResolvedSettingsContract`] when the fully
124    /// materialized settings exceed the V1 row/text bounds.
125    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    /// Resolve input from a borrowed, exactly sized clip-name iterator.
134    ///
135    /// This entry point lets loaders reject an oversized actual-clip inventory
136    /// before cloning or traversing any names.
137    ///
138    /// # Errors
139    ///
140    /// Returns the same typed errors as [`Self::resolve_input`]. An iterator
141    /// longer than the V1 collection bound is rejected before its first item is
142    /// consumed.
143    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/// Fully materialized settings for one real clip.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct ResolvedClipSettings {
216    clip_name: String,
217    settings: BTreeMap<SettingId, SettingValue>,
218}
219
220impl ResolvedClipSettings {
221    /// Actual clip name supplied by the caller.
222    pub fn clip_name(&self) -> &str {
223        &self.clip_name
224    }
225
226    /// Fully materialized applicable clip settings.
227    pub const fn settings(&self) -> &BTreeMap<SettingId, SettingValue> {
228        &self.settings
229    }
230}
231
232/// Input-validated profile and fully materialized deterministic settings.
233#[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    /// Exact immutable selected profile.
244    pub const fn profile(&self) -> &'static EngineProfile {
245        self.profile
246    }
247
248    /// Authoritative accepted source format.
249    pub const fn source_format(&self) -> SourceFormatV1 {
250        self.source_format
251    }
252
253    /// Fully materialized document settings.
254    pub const fn document_settings(&self) -> &BTreeMap<SettingId, SettingValue> {
255        &self.document_settings
256    }
257
258    /// Fully materialized clip rows in lexical actual-name order.
259    pub fn clip_settings(&self) -> &[ResolvedClipSettings] {
260        &self.clips
261    }
262
263    /// Canonical settings SHA-256 plus byte count.
264    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}