Skip to main content

presolve_compiler/
runtime_form_artifact.rs

1//! I15 public compiler-generated Forms runtime artifact.
2//!
3//! This module is deliberately a projection of immutable compiler products.
4//! It does not inspect source syntax or create runtime state.
5
6use std::collections::BTreeSet;
7
8use serde::Serialize;
9
10use crate::{
11    semantic_type_text, ApplicationSemanticModel, FormControlCompatibility, FormIrOperation,
12    ValidationRuleArgument, RUNTIME_FORM_REGISTRY_VERSION,
13};
14
15/// Version 6 adds the integrity-bound Form submission capability registry.
16pub const RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION: u32 = 6;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct RuntimeFormsArtifact {
20    pub schema_version: u32,
21    pub registry_version: u32,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub standard_schema_module: Option<RuntimeFormsArtifactStandardSchemaModule>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub submission_capability_module: Option<RuntimeFormsArtifactSubmissionCapabilityModule>,
26    pub forms: Vec<RuntimeFormsArtifactForm>,
27    pub instances: Vec<RuntimeFormsArtifactInstance>,
28    /// Instance-qualified executable submit-host records. These are the only
29    /// runtime authority for locating and handling a native submit event.
30    pub hosts: Vec<RuntimeFormsArtifactHost>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct RuntimeFormsArtifactSubmissionCapabilityModule {
35    pub path: String,
36    pub capabilities: Vec<RuntimeFormsArtifactSubmissionCapability>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
40pub struct RuntimeFormsArtifactSubmissionCapability {
41    pub id: String,
42    pub module_specifier: String,
43    pub package: String,
44    pub version: String,
45    pub integrity: String,
46    pub export: String,
47    pub runtime_module: String,
48    pub resume_policy: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RuntimeFormsArtifactStandardSchemaModule {
53    pub path: String,
54    pub validators: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct RuntimeFormsArtifactForm {
59    pub id: String,
60    /// Non-authoritative authored name for developer tooling only.
61    pub debug_name: String,
62    pub fields: Vec<RuntimeFormsArtifactField>,
63    pub bindings: Vec<RuntimeFormsArtifactBinding>,
64    pub validation_rules: Vec<RuntimeFormsArtifactRule>,
65    pub validation_dependencies: Vec<RuntimeFormsArtifactDependency>,
66    pub submission: Option<RuntimeFormsArtifactSubmission>,
67    pub serialization: RuntimeFormsArtifactSerialization,
68    pub reset: RuntimeFormsArtifactReset,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct RuntimeFormsArtifactField {
73    pub id: String,
74    /// Non-authoritative authored name for developer tooling only.
75    pub debug_name: String,
76    /// Exact compiler-issued serialized leaf path.
77    pub path: Vec<String>,
78    pub semantic_type: String,
79    pub initial_value: crate::SerializableValue,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct RuntimeFormsArtifactBinding {
84    pub id: String,
85    pub control_anchor: String,
86    pub field: String,
87    pub channel: String,
88    pub normalization: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct RuntimeFormsArtifactRule {
93    pub id: String,
94    pub target_field: String,
95    pub kind: String,
96    pub argument: RuntimeFormsArtifactRuleArgument,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub dependency: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum RuntimeFormsArtifactRuleArgument {
104    None,
105    Number { value: String },
106    Length { value: u64 },
107    Pattern { value: String },
108    Field { field: String },
109    StandardSchema { validator: String },
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RuntimeFormsArtifactDependency {
114    pub id: String,
115    pub source_field: String,
116    pub target_field: String,
117    pub rule: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121pub struct RuntimeFormsArtifactSubmission {
122    pub plan: String,
123    pub action_batch: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub capability: Option<String>,
126    pub validation_rules: Vec<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct RuntimeFormsArtifactSerialization {
131    pub plan: String,
132    pub format: String,
133    pub fields: Vec<String>,
134    pub status: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct RuntimeFormsArtifactReset {
139    pub plan: String,
140    pub fields: Vec<String>,
141    pub schedule_validation: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RuntimeFormsArtifactInstance {
146    pub id: String,
147    pub form: String,
148    pub component_instance: String,
149    pub field_slots: Vec<RuntimeFormsArtifactFieldSlots>,
150    pub aggregate_validation_slot: String,
151    pub submission_slot: String,
152    pub programs: RuntimeFormsArtifactPrograms,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct RuntimeFormsArtifactHost {
157    pub id: String,
158    pub host_anchor: String,
159    pub form: String,
160    pub form_instance: String,
161    pub submission_plan: String,
162    pub submit_action: String,
163    pub action_batch: String,
164    pub serialization_plan: String,
165    pub event: String,
166    pub prevent_default: bool,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct RuntimeFormsArtifactFieldSlots {
171    pub field: String,
172    pub value: String,
173    pub dirty: String,
174    pub touched: String,
175    pub validation: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct RuntimeFormsArtifactPrograms {
180    pub initialize: Vec<String>,
181    pub input: Vec<RuntimeFormsArtifactFieldProgram>,
182    pub blur: Vec<RuntimeFormsArtifactFieldProgram>,
183    pub reset: Vec<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct RuntimeFormsArtifactFieldProgram {
188    pub field: String,
189    pub operations: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct RuntimeFormsArtifactValidation {
194    pub diagnostics: Vec<String>,
195    pub is_valid: bool,
196}
197
198/// Project every executable I0-I14 Forms product into the versioned public
199/// artifact. All identity-bearing fields are canonical IDs; authored names are
200/// kept in explicit non-authoritative debug fields only.
201///
202/// # Panics
203///
204/// Panics when a prior I10 or I11 product failed to provide its mandatory
205/// per-Form plan. That is an internal staged-compiler invariant violation.
206#[must_use]
207#[allow(clippy::too_many_lines)]
208pub fn build_runtime_forms_artifact(model: &ApplicationSemanticModel) -> RuntimeFormsArtifact {
209    let forms = model
210        .forms
211        .values()
212        .map(|form| {
213            let fields = model
214                .form_fields
215                .values()
216                .filter(|field| field.owner_form == form.id)
217                .map(|field| RuntimeFormsArtifactField {
218                    id: field.id.to_string(),
219                    debug_name: field.name.clone(),
220                    path: field.path.clone(),
221                    semantic_type: semantic_type_text(&field.semantic_type),
222                    initial_value: field.initial_value.clone(),
223                })
224                .collect::<Vec<_>>();
225            let bindings = model
226                .form_field_bindings
227                .values()
228                .filter(|binding| binding.form == form.id)
229                .map(|binding| RuntimeFormsArtifactBinding {
230                    id: binding.id.to_string(),
231                    control_anchor: binding.control_entity.to_string(),
232                    field: binding.field.to_string(),
233                    channel: format!("{:?}", binding.channel),
234                    normalization: normalization_name(binding.compatibility),
235                })
236                .collect();
237            let validation_rules = model
238                .validation_rules
239                .values()
240                .filter(|rule| rule.owner_form == form.id)
241                .map(|rule| RuntimeFormsArtifactRule {
242                    id: rule.id.to_string(),
243                    target_field: rule.target_field.to_string(),
244                    kind: format!("{:?}", rule.kind),
245                    argument: runtime_rule_argument(&rule.argument),
246                    dependency: rule.dependency.as_ref().map(ToString::to_string),
247                })
248                .collect();
249            let validation_dependencies = model
250                .validation_dependency_plans
251                .dependencies
252                .values()
253                .filter(|dependency| dependency.form == form.id)
254                .map(|dependency| RuntimeFormsArtifactDependency {
255                    id: dependency.id.to_string(),
256                    source_field: dependency.source_field.to_string(),
257                    target_field: dependency.target_field.to_string(),
258                    rule: dependency.dependent_rule.to_string(),
259                })
260                .collect();
261            let submission =
262                model
263                    .submissions
264                    .plan(&form.id)
265                    .map(|plan| RuntimeFormsArtifactSubmission {
266                        plan: plan.id.as_str().to_string(),
267                        action_batch: plan.action_batch.to_string(),
268                        capability: plan
269                            .capability
270                            .as_ref()
271                            .map(|capability| capability.id.clone()),
272                        validation_rules: plan
273                            .validation_rules
274                            .iter()
275                            .map(ToString::to_string)
276                            .collect(),
277                    });
278            let serialization = model
279                .serialization
280                .plans
281                .get(&crate::SerializationPlanId::for_form(&form.id))
282                .expect("I10 creates one serialization plan per valid Form");
283            let reset = model
284                .reset
285                .plans
286                .get(&crate::ResetPlanId::for_form(&form.id))
287                .expect("I11 creates one reset plan per valid Form");
288            RuntimeFormsArtifactForm {
289                id: form.id.to_string(),
290                debug_name: form.name.clone(),
291                fields,
292                bindings,
293                validation_rules,
294                validation_dependencies,
295                submission,
296                serialization: RuntimeFormsArtifactSerialization {
297                    plan: serialization.id.as_str().to_string(),
298                    format: format!("{:?}", serialization.format),
299                    fields: serialization
300                        .fields
301                        .iter()
302                        .map(|field| field.field.to_string())
303                        .collect(),
304                    status: format!("{:?}", serialization.status),
305                },
306                reset: RuntimeFormsArtifactReset {
307                    plan: reset.id.as_str().to_string(),
308                    fields: reset
309                        .operations
310                        .iter()
311                        .map(|operation| operation.field.to_string())
312                        .collect(),
313                    schedule_validation: reset.schedule_validation,
314                },
315            }
316        })
317        .collect::<Vec<_>>();
318    let standard_schema_validators = forms
319        .iter()
320        .flat_map(|form| &form.validation_rules)
321        .filter_map(|rule| match &rule.argument {
322            RuntimeFormsArtifactRuleArgument::StandardSchema { validator } => {
323                Some(validator.clone())
324            }
325            _ => None,
326        })
327        .collect::<BTreeSet<_>>()
328        .into_iter()
329        .collect::<Vec<_>>();
330    let submission_capabilities = model
331        .submissions
332        .plans
333        .values()
334        .filter_map(|plan| plan.capability.as_ref())
335        .map(|capability| RuntimeFormsArtifactSubmissionCapability {
336            id: capability.id.clone(),
337            module_specifier: capability.module_specifier.clone(),
338            package: capability.package.clone(),
339            version: capability.version.clone(),
340            integrity: capability.integrity.clone(),
341            export: capability.export.clone(),
342            runtime_module: capability.runtime_module.clone(),
343            resume_policy: capability.resume_policy.clone(),
344        })
345        .collect::<BTreeSet<_>>()
346        .into_iter()
347        .collect::<Vec<_>>();
348    let instances = model
349        .optimized_form_ir
350        .optimized
351        .instances
352        .values()
353        .map(|instance| RuntimeFormsArtifactInstance {
354            id: instance.id.to_string(),
355            form: instance.form.to_string(),
356            component_instance: instance.component_instance.to_string(),
357            field_slots: instance
358                .storage
359                .value
360                .iter()
361                .map(|(field, value)| RuntimeFormsArtifactFieldSlots {
362                    field: field.to_string(),
363                    value: value.as_str().to_string(),
364                    dirty: instance.storage.dirty[field].as_str().to_string(),
365                    touched: instance.storage.touched[field].as_str().to_string(),
366                    validation: instance.storage.validation[field].as_str().to_string(),
367                })
368                .collect(),
369            aggregate_validation_slot: instance.storage.aggregate.as_str().to_string(),
370            submission_slot: instance.storage.submission.as_str().to_string(),
371            programs: RuntimeFormsArtifactPrograms {
372                initialize: operations(&instance.initialize),
373                input: field_programs(&instance.input),
374                blur: field_programs(&instance.blur),
375                reset: operations(&instance.reset),
376            },
377        })
378        .collect();
379    let hosts = model
380        .submission_hosts
381        .values()
382        .flat_map(|host| {
383            model
384                .optimized_form_ir
385                .optimized
386                .instances
387                .values()
388                .filter(move |instance| {
389                    instance.form == host.form
390                        && model
391                            .component_instance_plan
392                            .instances
393                            .get(&instance.component_instance)
394                            .is_some_and(|component| component.component == host.component)
395                })
396                .map(move |instance| RuntimeFormsArtifactHost {
397                    id: host.id.to_string(),
398                    host_anchor: runtime_anchor_for_element(
399                        model,
400                        &host.owner_template,
401                        &host.owner_template_element,
402                    )
403                    .expect("valid host has exact template anchor"),
404                    form: host.form.to_string(),
405                    form_instance: instance.id.to_string(),
406                    submission_plan: host.submission_plan.as_str().to_string(),
407                    submit_action: host.submit_action.to_string(),
408                    action_batch: host.action_batch.to_string(),
409                    serialization_plan: host.serialization_plan.as_str().to_string(),
410                    event: host.event.to_string(),
411                    prevent_default: host.prevent_default,
412                })
413        })
414        .collect();
415    let artifact = RuntimeFormsArtifact {
416        schema_version: RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION,
417        registry_version: RUNTIME_FORM_REGISTRY_VERSION,
418        standard_schema_module: (!standard_schema_validators.is_empty()).then_some(
419            RuntimeFormsArtifactStandardSchemaModule {
420                path: "/presolve.validators.js".into(),
421                validators: standard_schema_validators,
422            },
423        ),
424        submission_capability_module: (!submission_capabilities.is_empty()).then_some(
425            RuntimeFormsArtifactSubmissionCapabilityModule {
426                path: "/presolve.form-submissions.js".into(),
427                capabilities: submission_capabilities,
428            },
429        ),
430        forms,
431        instances,
432        hosts,
433    };
434    debug_assert!(validate_runtime_forms_artifact(&artifact).is_valid);
435    artifact
436}
437
438#[allow(clippy::items_after_statements)]
439fn runtime_anchor_for_element(
440    model: &ApplicationSemanticModel,
441    template_id: &crate::SemanticId,
442    element: &crate::SemanticId,
443) -> Option<String> {
444    let template = model
445        .templates
446        .iter()
447        .find(|template| &template.id == template_id)?;
448    fn find(
449        element: &crate::ElementNode,
450        template: &crate::TemplateNode,
451        path: &str,
452        target: &crate::SemanticId,
453    ) -> Option<String> {
454        if template.id.template_entity("element", path) == *target {
455            return Some(element.id.0.clone());
456        }
457        for (index, child) in element.children.iter().enumerate() {
458            if let crate::TemplateChild::Element(child) = child {
459                if let Some(anchor) = find(child, template, &format!("{path}.{index}"), target) {
460                    return Some(anchor);
461                }
462            }
463        }
464        None
465    }
466    template
467        .root
468        .as_ref()
469        .and_then(|root| find(root, template, "root", element))
470}
471
472#[must_use]
473pub fn validate_runtime_forms_artifact(
474    artifact: &RuntimeFormsArtifact,
475) -> RuntimeFormsArtifactValidation {
476    let mut diagnostics = Vec::new();
477    if artifact.schema_version != RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION {
478        diagnostics.push("unsupported Forms artifact schema".to_string());
479    }
480    let forms = artifact
481        .forms
482        .iter()
483        .map(|form| form.id.as_str())
484        .collect::<BTreeSet<_>>();
485    if forms.len() != artifact.forms.len() {
486        diagnostics.push("duplicate Form definition".to_string());
487    }
488    let instances = artifact
489        .instances
490        .iter()
491        .map(|instance| instance.id.as_str())
492        .collect::<BTreeSet<_>>();
493    if instances.len() != artifact.instances.len() {
494        diagnostics.push("duplicate Form instance".to_string());
495    }
496    let expected_standard_schema_validators = artifact
497        .forms
498        .iter()
499        .flat_map(|form| &form.validation_rules)
500        .filter_map(|rule| match &rule.argument {
501            RuntimeFormsArtifactRuleArgument::StandardSchema { validator } => {
502                Some(validator.as_str())
503            }
504            _ => None,
505        })
506        .collect::<BTreeSet<_>>();
507    match &artifact.standard_schema_module {
508        Some(module)
509            if module.path == "/presolve.validators.js"
510                && module.validators.len() == expected_standard_schema_validators.len()
511                && module
512                    .validators
513                    .iter()
514                    .map(String::as_str)
515                    .collect::<BTreeSet<_>>()
516                    == expected_standard_schema_validators => {}
517        None if expected_standard_schema_validators.is_empty() => {}
518        _ => diagnostics.push("invalid Standard Schema runtime module".to_string()),
519    }
520    let expected_submission_capabilities = artifact
521        .forms
522        .iter()
523        .filter_map(|form| form.submission.as_ref()?.capability.as_deref())
524        .collect::<BTreeSet<_>>();
525    match &artifact.submission_capability_module {
526        Some(module)
527            if module.path == "/presolve.form-submissions.js"
528                && module.capabilities.len() == expected_submission_capabilities.len()
529                && module
530                    .capabilities
531                    .iter()
532                    .map(|capability| capability.id.as_str())
533                    .collect::<BTreeSet<_>>()
534                    == expected_submission_capabilities
535                && module.capabilities.iter().all(|capability| {
536                    !capability.id.is_empty()
537                        && !capability.module_specifier.is_empty()
538                        && !capability.package.is_empty()
539                        && !capability.version.is_empty()
540                        && capability.integrity.starts_with("sha256:")
541                        && !capability.export.is_empty()
542                        && !capability.runtime_module.is_empty()
543                        && capability.resume_policy == "cold_fallback"
544                }) => {}
545        None if expected_submission_capabilities.is_empty() => {}
546        _ => diagnostics.push("invalid Form submission capability module".to_string()),
547    }
548    for instance in &artifact.instances {
549        if !forms.contains(instance.form.as_str()) {
550            diagnostics.push(format!("unknown Form for instance {}", instance.id));
551        }
552    }
553    for form in &artifact.forms {
554        let field_ids = form
555            .fields
556            .iter()
557            .map(|field| field.id.as_str())
558            .collect::<BTreeSet<_>>();
559        let paths = form
560            .fields
561            .iter()
562            .map(|field| &field.path)
563            .collect::<Vec<_>>();
564        if paths.iter().any(|path| {
565            !(1..=16).contains(&path.len())
566                || path
567                    .iter()
568                    .any(|segment| !is_canonical_form_field_path_segment(segment))
569        }) {
570            diagnostics.push(format!("invalid Form Field path for {}", form.id));
571        }
572        if paths.iter().enumerate().any(|(index, path)| {
573            paths
574                .iter()
575                .skip(index + 1)
576                .any(|other| form_field_paths_conflict(path, other))
577        }) {
578            diagnostics.push(format!("conflicting Form Field path for {}", form.id));
579        }
580        for rule in &form.validation_rules {
581            if !field_ids.contains(rule.target_field.as_str())
582                || !runtime_rule_argument_matches(rule)
583                || rule
584                    .dependency
585                    .as_ref()
586                    .is_some_and(|dependency| !field_ids.contains(dependency.as_str()))
587            {
588                diagnostics.push(format!("invalid Form validation rule {}", rule.id));
589            }
590        }
591    }
592    for host in &artifact.hosts {
593        if !instances.contains(host.form_instance.as_str())
594            || !forms.contains(host.form.as_str())
595            || host.event != "submit"
596        {
597            diagnostics.push(format!("invalid submission host {}", host.id));
598        }
599    }
600    RuntimeFormsArtifactValidation {
601        is_valid: diagnostics.is_empty(),
602        diagnostics,
603    }
604}
605
606fn runtime_rule_argument(argument: &ValidationRuleArgument) -> RuntimeFormsArtifactRuleArgument {
607    match argument {
608        ValidationRuleArgument::None => RuntimeFormsArtifactRuleArgument::None,
609        ValidationRuleArgument::Number(value) => RuntimeFormsArtifactRuleArgument::Number {
610            value: value.clone(),
611        },
612        ValidationRuleArgument::Length(value) => {
613            RuntimeFormsArtifactRuleArgument::Length { value: *value }
614        }
615        ValidationRuleArgument::Pattern(value) => RuntimeFormsArtifactRuleArgument::Pattern {
616            value: value.clone(),
617        },
618        ValidationRuleArgument::Field(field) => RuntimeFormsArtifactRuleArgument::Field {
619            field: field.to_string(),
620        },
621        ValidationRuleArgument::StandardSchema { validator } => {
622            RuntimeFormsArtifactRuleArgument::StandardSchema {
623                validator: validator.clone(),
624            }
625        }
626    }
627}
628
629fn runtime_rule_argument_matches(rule: &RuntimeFormsArtifactRule) -> bool {
630    match (&*rule.kind, &rule.argument) {
631        ("Required" | "Email", RuntimeFormsArtifactRuleArgument::None) => rule.dependency.is_none(),
632        ("Min" | "Max", RuntimeFormsArtifactRuleArgument::Number { value }) => {
633            rule.dependency.is_none() && value.parse::<f64>().is_ok_and(|value| value.is_finite())
634        }
635        ("MinLength" | "MaxLength", RuntimeFormsArtifactRuleArgument::Length { .. }) => {
636            rule.dependency.is_none()
637        }
638        ("Pattern", RuntimeFormsArtifactRuleArgument::Pattern { value }) => {
639            rule.dependency.is_none() && presolve_parser::is_valid_ecmascript_pattern(value)
640        }
641        ("Equals" | "NotEquals", RuntimeFormsArtifactRuleArgument::Field { field }) => {
642            rule.dependency.as_ref() == Some(field)
643        }
644        ("StandardSchema", RuntimeFormsArtifactRuleArgument::StandardSchema { validator }) => {
645            rule.dependency.is_none() && !validator.is_empty()
646        }
647        _ => false,
648    }
649}
650
651fn is_canonical_form_field_path_segment(segment: &str) -> bool {
652    let mut characters = segment.chars();
653    matches!(characters.next(), Some(character) if character == '_' || character.is_ascii_alphabetic())
654        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
655}
656
657fn form_field_paths_conflict(left: &[String], right: &[String]) -> bool {
658    (left.len() <= right.len() && left.iter().zip(right).all(|(left, right)| left == right))
659        || (right.len() <= left.len() && right.iter().zip(left).all(|(left, right)| left == right))
660}
661
662#[must_use]
663/// # Panics
664///
665/// Panics only if an internal Forms artifact cannot serialize, which indicates
666/// a compiler implementation defect.
667pub fn runtime_forms_artifact_json(artifact: &RuntimeFormsArtifact) -> String {
668    serde_json::to_string_pretty(artifact).expect("Forms artifact serializes deterministically")
669        + "\n"
670}
671
672fn normalization_name(compatibility: FormControlCompatibility) -> String {
673    match compatibility {
674        FormControlCompatibility::Compatible(normalization) => format!("{normalization:?}"),
675        FormControlCompatibility::Incompatible => "Incompatible".to_string(),
676    }
677}
678
679fn operations(operations: &[FormIrOperation]) -> Vec<String> {
680    operations
681        .iter()
682        .map(|operation| format!("{operation:?}"))
683        .collect()
684}
685
686fn field_programs(
687    programs: &std::collections::BTreeMap<crate::FieldId, Vec<FormIrOperation>>,
688) -> Vec<RuntimeFormsArtifactFieldProgram> {
689    programs
690        .iter()
691        .map(
692            |(field, program_operations)| RuntimeFormsArtifactFieldProgram {
693                field: field.to_string(),
694                operations: operations(program_operations),
695            },
696        )
697        .collect()
698}
699
700#[cfg(test)]
701mod tests {
702    #[test]
703    fn emits_schema_v6_with_typed_validation_arguments_and_canonical_references() {
704        let parsed = presolve_parser::parse_file(
705            "src/X.tsx",
706            r#"@component("x")class X{@form()form!:Form;@validate(minLength(2))@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
707        );
708        let asm = crate::build_application_semantic_model(&parsed);
709        let artifact = super::build_runtime_forms_artifact(&asm);
710        assert_eq!(artifact.schema_version, 6);
711        assert_eq!(artifact.registry_version, 1);
712        assert_eq!(artifact.forms.len(), 1);
713        assert_eq!(artifact.instances.len(), 1);
714        assert_eq!(
715            artifact.forms[0].validation_rules[0].argument,
716            super::RuntimeFormsArtifactRuleArgument::Length { value: 2 }
717        );
718        assert!(super::validate_runtime_forms_artifact(&artifact).is_valid);
719        assert!(super::runtime_forms_artifact_json(&artifact).contains("field-binding"));
720
721        let mut standard_schema = artifact.clone();
722        let validator = "module:src/X.tsx/validation-rule-candidate:42".to_string();
723        standard_schema.forms[0].validation_rules[0].kind = "StandardSchema".to_string();
724        standard_schema.forms[0].validation_rules[0].argument =
725            super::RuntimeFormsArtifactRuleArgument::StandardSchema {
726                validator: validator.clone(),
727            };
728        standard_schema.standard_schema_module =
729            Some(super::RuntimeFormsArtifactStandardSchemaModule {
730                path: "/presolve.validators.js".to_string(),
731                validators: vec![validator],
732            });
733        assert!(super::validate_runtime_forms_artifact(&standard_schema).is_valid);
734
735        let mut malformed = artifact;
736        malformed.forms[0].validation_rules[0].argument =
737            super::RuntimeFormsArtifactRuleArgument::None;
738        assert!(!super::validate_runtime_forms_artifact(&malformed).is_valid);
739
740        let mut malformed_module = standard_schema;
741        malformed_module
742            .standard_schema_module
743            .as_mut()
744            .expect("Standard Schema module")
745            .path = "/other.js".to_string();
746        assert!(!super::validate_runtime_forms_artifact(&malformed_module).is_valid);
747    }
748
749    #[test]
750    fn rejects_noncanonical_and_prefix_conflicting_field_paths() {
751        let parsed = presolve_parser::parse_file(
752            "src/X.tsx",
753            r#"@component("x")class X{@form()form!:Form;@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
754        );
755        let asm = crate::build_application_semantic_model(&parsed);
756        let mut artifact = super::build_runtime_forms_artifact(&asm);
757        let field = artifact.forms[0].fields[0].clone();
758        artifact.forms[0].fields[0].path = vec!["invalid-path".to_string()];
759        assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
760
761        artifact.forms[0].fields[0].path = vec!["address".to_string()];
762        let mut nested = field;
763        nested.path = vec!["address".to_string(), "street".to_string()];
764        artifact.forms[0].fields.push(nested);
765        assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
766    }
767
768    #[test]
769    fn forms_products_are_byte_deterministic_when_input_files_are_reversed() {
770        let a = r#"@component("a") class A { @form() @serialize("json") profile!: Form; @field(this.profile) name = ""; @action() @submit(this.profile) save(): void {} render() { return <form form={this.profile}><input field={this.name}/></form>; } }"#;
771        let b = r#"@component("b") class B { @form() @serialize("url-encoded") search!: Form; @field(this.search) query = ""; @action() @submit(this.search) save(): void {} render() { return <form form={this.search}><input field={this.query}/></form>; } }"#;
772        let first = crate::CompilationUnit::parse_sources([("src/A.tsx", a), ("src/B.tsx", b)]);
773        let second = crate::CompilationUnit::parse_sources([("src/B.tsx", b), ("src/A.tsx", a)]);
774        let first = crate::build_application_semantic_model_for_unit(&first);
775        let second = crate::build_application_semantic_model_for_unit(&second);
776
777        assert_eq!(
778            super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&first)),
779            super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&second)),
780        );
781        assert_eq!(
782            crate::template_manifest_json(&crate::build_template_manifest_from_asm(&first)),
783            crate::template_manifest_json(&crate::build_template_manifest_from_asm(&second)),
784        );
785        assert_eq!(
786            crate::resume_manifest_json(&crate::build_resume_manifest(&first)),
787            crate::resume_manifest_json(&crate::build_resume_manifest(&second)),
788        );
789    }
790}