Skip to main content

appcore_filemaker/
source_build.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: source_build.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded source build contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use crate::source::*;
16use crate::source_layout::{convert_exclusions, validate_exclusions, validate_layout_source};
17use crate::source_page_build::{
18    append_page_layer_elements, resolve_page, validate_page, validate_page_layer_elements,
19};
20use crate::source_style::convert_style;
21use crate::source_table::convert_table;
22use crate::source_text::{convert_text_options, validate_text_options};
23use crate::source_transform::{convert_transform, validate_transform};
24use crate::{
25    AiPolicy, CollisionPolicy, CollisionResolution, DataField, DataSchema, DataType, ElementId,
26    ElementIr, ElementKind, ErrorCode, FileMakerError, GeometryIr, PathCommandIr, PresetRegistry,
27    Provenance, RegionIr, ResourceLimits, Result, TemplateIr, TextOverflow, FILEMAKER_SCHEMA_V1,
28};
29
30impl TemplateSourceV1 {
31    /// Parses a bounded strict YAML document and validates the exact schema version.
32    pub fn parse_yaml(bytes: &[u8], limits: &ResourceLimits) -> Result<Self> {
33        limits.validate()?;
34        ResourceLimits::check("template bytes", bytes.len(), limits.max_template_bytes)?;
35        let source: Self = serde_yaml::from_slice(bytes).map_err(|error| {
36            FileMakerError::new(ErrorCode::SchemaSyntax, format!("invalid YAML: {error}"))
37        })?;
38        source.validate(limits)?;
39        Ok(source)
40    }
41
42    /// Validates frontend-only invariants before include/component expansion.
43    pub fn validate(&self, limits: &ResourceLimits) -> Result<()> {
44        if self.filemaker != FILEMAKER_SCHEMA_V1 {
45            return Err(FileMakerError::new(
46                ErrorCode::SchemaVersion,
47                format!(
48                    "unsupported filemaker schema `{}`; expected `{FILEMAKER_SCHEMA_V1}`",
49                    self.filemaker
50                ),
51            ));
52        }
53        validate_safe_name("template ID", &self.id)?;
54        if self.includes.len() > limits.max_include_depth {
55            return Err(FileMakerError::new(
56                ErrorCode::LimitExceeded,
57                "top-level include count exceeds include depth budget",
58            ));
59        }
60        let mut count = self.exclusions.len();
61        let mut path_commands = 0_usize;
62        validate_elements(&self.elements, limits, &mut count, &mut path_commands)?;
63        if let Some(page) = &self.page {
64            if page.has_layer_elements() && self.model != ModelKind::Document {
65                return Err(FileMakerError::new(
66                    ErrorCode::SchemaField,
67                    "page layers are valid only for the document model",
68                ));
69            }
70            for elements in page.element_lists() {
71                validate_elements(elements, limits, &mut count, &mut path_commands)?;
72                validate_page_layer_elements(elements)?;
73            }
74        }
75        for component in self.components.values() {
76            validate_elements(&component.elements, limits, &mut count, &mut path_commands)?;
77            for slot in component.slots.values() {
78                validate_elements(slot, limits, &mut count, &mut path_commands)?;
79            }
80        }
81        validate_page(self.page.as_ref())?;
82        validate_exclusions(&self.exclusions, limits.max_elements)?;
83        validate_ai_policy(self.ai.as_ref())?;
84        Ok(())
85    }
86
87    /// Expands the already validated source into a Rust-native template IR.
88    ///
89    /// Includes and components with arguments are handled by `Compiler`; this
90    /// direct conversion accepts only a source that is already self-contained.
91    pub fn to_ir(&self, presets: &PresetRegistry, limits: &ResourceLimits) -> Result<TemplateIr> {
92        self.validate(limits)?;
93        if !self.includes.is_empty() {
94            return Err(FileMakerError::new(
95                ErrorCode::SchemaField,
96                "direct IR conversion requires includes to be expanded",
97            ));
98        }
99        let (page_size, orientation, page_template) =
100            resolve_page(&self.id, self.page.as_ref(), presets)?;
101        let mut elements = Vec::with_capacity(self.elements.len());
102        for element in &self.elements {
103            elements.push(element_to_ir(element, "template", limits)?);
104        }
105        if let Some(page) = &self.page {
106            append_page_layer_elements(page, limits, &mut elements, element_to_ir)?;
107        }
108        let ir = TemplateIr {
109            id: self.id.clone(),
110            model: self.model,
111            page_size,
112            orientation,
113            page_template,
114            collision: self.collision.as_ref().map(convert_collision).transpose()?,
115            page_collision: self
116                .page
117                .as_ref()
118                .and_then(|page| page.collision.as_ref())
119                .map(convert_collision)
120                .transpose()?,
121            guides: self.guides.clone(),
122            regions: convert_regions(&self.regions)?,
123            exclusions: convert_exclusions(&self.exclusions),
124            data_schema: convert_data_schema(&self.data_schema),
125            ai_policy: convert_ai_policy(self.ai.as_ref()),
126            elements,
127        };
128        ir.validate(limits.max_elements)?;
129        Ok(ir)
130    }
131}
132
133pub(crate) fn self_contained_element_to_ir(
134    source: &ElementSource,
135    limits: &ResourceLimits,
136) -> Result<ElementIr> {
137    limits.validate()?;
138    let mut count = 0;
139    let mut path_commands = 0;
140    validate_elements(
141        std::slice::from_ref(source),
142        limits,
143        &mut count,
144        &mut path_commands,
145    )?;
146    element_to_ir(source, "element", limits)
147}
148
149fn validate_ai_policy(policy: Option<&AiSourcePolicy>) -> Result<()> {
150    let Some(policy) = policy else {
151        return Ok(());
152    };
153    if policy.purpose.len() > 1_024
154        || policy.rules.len() > 64
155        || policy.rules.iter().any(|rule| rule.len() > 1_024)
156        || policy.editable.len() > 10_000
157        || policy.locked.len() > 10_000
158    {
159        return Err(FileMakerError::new(
160            ErrorCode::SchemaField,
161            "AI policy exceeds its bounded text or ID limits",
162        ));
163    }
164    for id in policy.editable.iter().chain(&policy.locked) {
165        ElementId::new(id)?;
166    }
167    Ok(())
168}
169
170fn convert_ai_policy(policy: Option<&AiSourcePolicy>) -> AiPolicy {
171    policy.map_or_else(AiPolicy::default, |policy| AiPolicy {
172        purpose: policy.purpose.clone(),
173        rules: policy.rules.clone(),
174        editable: policy.editable.iter().cloned().collect(),
175        locked: policy.locked.iter().cloned().collect(),
176    })
177}
178
179fn validate_elements(
180    elements: &[ElementSource],
181    limits: &ResourceLimits,
182    count: &mut usize,
183    path_commands: &mut usize,
184) -> Result<()> {
185    for element in elements {
186        *count = count.saturating_add(1);
187        if *count > limits.max_elements {
188            return Err(FileMakerError::new(
189                ErrorCode::LimitExceeded,
190                "source element count exceeds configured limit",
191            ));
192        }
193        ElementId::new(element.id.clone())?;
194        let kind = if element.element_type != "slot" {
195            Some(ElementKind::parse(&element.element_type)?)
196        } else {
197            None
198        };
199        *path_commands = path_commands.saturating_add(element.path.len());
200        if *path_commands > limits.max_path_commands {
201            return Err(FileMakerError::new(
202                ErrorCode::LimitExceeded,
203                "source path command count exceeds configured limit",
204            ));
205        }
206        if matches!(kind, Some(ElementKind::Path | ElementKind::Polygon)) && element.path.is_empty()
207        {
208            return Err(FileMakerError::new(
209                ErrorCode::SchemaField,
210                "path and polygon elements require path commands",
211            )
212            .at(element.id.clone()));
213        }
214        if !element.path.is_empty()
215            && !matches!(
216                kind,
217                Some(ElementKind::Path | ElementKind::Polygon | ElementKind::Line)
218            )
219        {
220            return Err(FileMakerError::new(
221                ErrorCode::SchemaField,
222                "path commands are only valid on line, path, or polygon elements",
223            )
224            .at(element.id.clone()));
225        }
226        if let Some(text) = &element.text {
227            ResourceLimits::check("text bytes", text.len(), limits.max_text_bytes)?;
228        }
229        element.image.validate()?;
230        validate_transform(&element.transform)?;
231        validate_text_options(&element.text_options)?;
232        validate_layout_source(element)?;
233        validate_style_rules(element)?;
234        if element.text_options != TextSourceOptions::default() {
235            match kind {
236                Some(ElementKind::Text) => {}
237                Some(ElementKind::Table)
238                    if element.text_options.overflow == TextOverflow::Wrap
239                        && element.text_options.max_lines.is_none() => {}
240                Some(ElementKind::Table) => {
241                    return Err(FileMakerError::new(
242                        ErrorCode::SchemaField,
243                        "table text_options support min_font_size, line_height, and writing_mode",
244                    )
245                    .at(element.id.clone()))
246                }
247                _ => {
248                    return Err(FileMakerError::new(
249                        ErrorCode::SchemaField,
250                        "text_options are only valid on text and table elements",
251                    )
252                    .at(element.id.clone()))
253                }
254            }
255        }
256        if (kind == Some(ElementKind::Table)) != element.table.is_some() {
257            return Err(FileMakerError::new(
258                ErrorCode::SchemaField,
259                "type table requires `table`, which is invalid on every other element type",
260            )
261            .at(element.id.clone()));
262        }
263        if kind == Some(ElementKind::Table) && element.binding.is_none() {
264            return Err(FileMakerError::new(
265                ErrorCode::SchemaField,
266                "table elements require an array binding",
267            )
268            .at(element.id.clone()));
269        }
270        if kind == Some(ElementKind::Table)
271            && (!element.children.is_empty() || !element.slots.is_empty())
272        {
273            return Err(FileMakerError::new(
274                ErrorCode::SchemaField,
275                "table elements cannot contain children or slots",
276            )
277            .at(element.id.clone()));
278        }
279        validate_elements(&element.children, limits, count, path_commands)?;
280        for slot in element.slots.values() {
281            validate_elements(slot, limits, count, path_commands)?;
282        }
283    }
284    Ok(())
285}
286
287fn validate_style_rules(element: &ElementSource) -> Result<()> {
288    if element.style_rules.len() > 64 {
289        return Err(FileMakerError::new(
290            ErrorCode::LimitExceeded,
291            "element conditional style count exceeds 64",
292        )
293        .at(element.id.clone()));
294    }
295    for rule in &element.style_rules {
296        if rule.when.is_empty() {
297            return Err(FileMakerError::new(
298                ErrorCode::SchemaField,
299                "conditional style expression cannot be empty",
300            )
301            .at(element.id.clone()));
302        }
303        crate::Expression::parse(&rule.when).map_err(|error| error.at(element.id.clone()))?;
304    }
305    Ok(())
306}
307
308fn element_to_ir(
309    source: &ElementSource,
310    logical_source: &str,
311    limits: &ResourceLimits,
312) -> Result<ElementIr> {
313    if source.component.is_some() {
314        return Err(FileMakerError::new(
315            ErrorCode::SchemaField,
316            "direct IR conversion requires components to be expanded",
317        )
318        .at(source.id.clone()));
319    }
320    let mut children = Vec::with_capacity(source.children.len());
321    for child in &source.children {
322        children.push(element_to_ir(child, logical_source, limits)?);
323    }
324    if let Some(text) = &source.text {
325        ResourceLimits::check("text bytes", text.len(), limits.max_text_bytes)?;
326    }
327    Ok(ElementIr {
328        id: ElementId::new(source.id.clone())?,
329        kind: ElementKind::parse(&source.element_type)?,
330        geometry: GeometryIr {
331            x: source.x,
332            y: source.y,
333            width: source.width,
334            height: source.height,
335            constraints: source.constraints,
336            align_x: source.align_x,
337            align_y: source.align_y,
338            region: source.region.clone(),
339            anchors: source.anchors.clone(),
340        },
341        transform: convert_transform(source.transform)?,
342        text: source.text.clone(),
343        text_options: convert_text_options(source.text_options),
344        table: source
345            .table
346            .as_ref()
347            .map(|table| convert_table(table, limits))
348            .transpose()?,
349        asset: source.asset.clone(),
350        path: source.path.iter().map(convert_path_command).collect(),
351        image: source.image,
352        style: convert_style(&source.style)?,
353        style_rules: source
354            .style_rules
355            .iter()
356            .map(|rule| {
357                Ok(crate::ElementStyleRule {
358                    when: rule.when.clone(),
359                    style: convert_style(&rule.style)?,
360                })
361            })
362            .collect::<Result<Vec<_>>>()?,
363        layout: source.layout,
364        distribute: source.distribute,
365        gap: source.gap,
366        collision: source
367            .collision
368            .as_ref()
369            .map(convert_collision)
370            .transpose()?,
371        children,
372        locked: source.locked,
373        hidden: source.hidden,
374        layer: source.layer.clone(),
375        z_index: source.z_index,
376        binding: source.binding.clone(),
377        when: source.when.clone(),
378        repeat: source.repeat.clone(),
379        provenance: Provenance {
380            source: source
381                .provenance_source
382                .clone()
383                .unwrap_or_else(|| logical_source.to_owned()),
384            components: source.provenance_components.clone(),
385            styles: source.styles.clone(),
386            patches: Vec::new(),
387        },
388        page_placement: None,
389    })
390}
391
392fn convert_path_command(source: &PathCommandSource) -> PathCommandIr {
393    match *source {
394        PathCommandSource::Move { x, y } => PathCommandIr::Move { x, y },
395        PathCommandSource::Line { x, y } => PathCommandIr::Line { x, y },
396        PathCommandSource::Curve {
397            x1,
398            y1,
399            x2,
400            y2,
401            x,
402            y,
403        } => PathCommandIr::Curve {
404            x1,
405            y1,
406            x2,
407            y2,
408            x,
409            y,
410        },
411        PathCommandSource::Close => PathCommandIr::Close,
412    }
413}
414
415fn convert_regions(source: &BTreeMap<String, RegionSource>) -> Result<BTreeMap<String, RegionIr>> {
416    source
417        .iter()
418        .map(|(name, region)| {
419            Ok((
420                name.clone(),
421                RegionIr {
422                    x: region.x,
423                    y: region.y,
424                    width: region.width,
425                    height: region.height,
426                    collision: region
427                        .collision
428                        .as_ref()
429                        .map(convert_collision)
430                        .transpose()?,
431                },
432            ))
433        })
434        .collect()
435}
436
437fn convert_data_schema(source: &BTreeMap<String, DataFieldSource>) -> DataSchema {
438    source
439        .iter()
440        .map(|(name, field)| {
441            let data_type = match field.data_type {
442                DataTypeSource::String => DataType::String,
443                DataTypeSource::Integer => DataType::Integer,
444                DataTypeSource::Decimal => DataType::Decimal,
445                DataTypeSource::Boolean => DataType::Boolean,
446                DataTypeSource::Date => DataType::Date,
447                DataTypeSource::DateTime => DataType::DateTime,
448                DataTypeSource::Duration => DataType::Duration,
449                DataTypeSource::Currency => DataType::Currency,
450                DataTypeSource::Array => DataType::Array,
451                DataTypeSource::Object => DataType::Object,
452                DataTypeSource::Null => DataType::Null,
453            };
454            (
455                name.clone(),
456                DataField {
457                    data_type,
458                    nullable: field.nullable,
459                    computed: field.computed.clone(),
460                },
461            )
462        })
463        .collect()
464}
465
466fn convert_collision(source: &CollisionSource) -> Result<CollisionPolicy> {
467    let advanced = match source {
468        CollisionSource::Enabled(enabled) => {
469            return Ok(CollisionPolicy {
470                enabled: *enabled,
471                ..CollisionPolicy::default()
472            });
473        }
474        CollisionSource::Advanced(advanced) => advanced,
475    };
476    let resolution = match advanced.policy.as_str() {
477        "push" => CollisionResolution::Push,
478        "error" => CollisionResolution::Error,
479        "overlay" => CollisionResolution::Overlay,
480        "next_page" => CollisionResolution::NextPage,
481        "shrink" => CollisionResolution::Shrink,
482        _ => {
483            return Err(FileMakerError::new(
484                ErrorCode::SchemaField,
485                format!("unknown collision policy `{}`", advanced.policy),
486            ))
487        }
488    };
489    Ok(CollisionPolicy {
490        enabled: advanced.enabled,
491        group: advanced.group.clone(),
492        collides_with: advanced.collides_with.iter().cloned().collect(),
493        ignore: advanced.ignore.iter().cloned().collect(),
494        priority: advanced.priority,
495        movable: advanced.movable,
496        bounds: advanced.bounds,
497        resolution,
498    })
499}
500
501fn validate_safe_name(label: &str, value: &str) -> Result<()> {
502    if value.is_empty()
503        || value.len() > 128
504        || !value
505            .bytes()
506            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
507    {
508        return Err(FileMakerError::new(
509            ErrorCode::SchemaField,
510            format!("{label} is invalid"),
511        ));
512    }
513    Ok(())
514}