Skip to main content

qa_spec/
runner.rs

1use serde_json::{Map, Value};
2
3use crate::{FormSpec, RenderPayload, StoreOp, ValidationResult, build_render_payload, validate};
4
5/// Versioned deterministic plan produced by runner planning functions.
6#[derive(Debug, Clone)]
7pub struct QaPlanV1 {
8    pub plan_version: u16,
9    pub form_id: String,
10    pub validated_patch: Value,
11    pub validation: ValidationResult,
12    pub payload: RenderPayload,
13    pub effects: Vec<StoreOp>,
14    pub warnings: Vec<String>,
15    pub errors: Vec<String>,
16}
17
18impl QaPlanV1 {
19    pub fn is_valid(&self) -> bool {
20        self.validation.valid
21    }
22}
23
24/// Build a deterministic plan for patch submission without applying side effects.
25pub fn plan_submit_patch(
26    spec: &FormSpec,
27    ctx: &Value,
28    answers: &Value,
29    question_id: &str,
30    value: Value,
31) -> QaPlanV1 {
32    let mut patched = answers.as_object().cloned().unwrap_or_default();
33    patched.insert(question_id.to_string(), value);
34    build_plan(spec, ctx, Value::Object(patched))
35}
36
37/// Build a deterministic plan for submit-all without applying side effects.
38pub fn plan_submit_all(spec: &FormSpec, ctx: &Value, answers: &Value) -> QaPlanV1 {
39    build_plan(spec, ctx, answers.clone())
40}
41
42/// Build a deterministic next-step plan for the current answers/context.
43pub fn plan_next(spec: &FormSpec, ctx: &Value, answers: &Value) -> QaPlanV1 {
44    build_plan(spec, ctx, normalize_answers(answers))
45}
46
47fn build_plan(spec: &FormSpec, ctx: &Value, answers: Value) -> QaPlanV1 {
48    let validation = validate(spec, &answers);
49    let payload = build_render_payload(spec, ctx, &answers);
50    let effects = if validation.valid {
51        spec.store.clone()
52    } else {
53        Vec::new()
54    };
55
56    let mut errors = Vec::new();
57    if !validation.valid {
58        errors.extend(validation.errors.iter().map(|error| {
59            format!(
60                "{}: {}",
61                error.path.clone().unwrap_or_default(),
62                error.message
63            )
64        }));
65        errors.extend(
66            validation
67                .missing_required
68                .iter()
69                .map(|field| format!("missing required: {}", field)),
70        );
71        errors.extend(
72            validation
73                .unknown_fields
74                .iter()
75                .map(|field| format!("unknown field: {}", field)),
76        );
77    }
78
79    QaPlanV1 {
80        plan_version: 1,
81        form_id: spec.id.clone(),
82        validated_patch: answers,
83        validation,
84        payload,
85        effects,
86        warnings: Vec::new(),
87        errors,
88    }
89}
90
91/// Executes plan effects into the provided store context value.
92pub fn execute_plan_effects(
93    plan: &QaPlanV1,
94    store_ctx: &mut crate::StoreContext,
95    secrets_policy: Option<&crate::spec::form::SecretsPolicy>,
96    secrets_host_available: bool,
97) -> Result<(), crate::StoreError> {
98    if !plan.is_valid() {
99        return Ok(());
100    }
101    store_ctx.answers = plan.validated_patch.clone();
102    store_ctx.apply_ops(&plan.effects, secrets_policy, secrets_host_available)
103}
104
105/// Canonicalize incoming answers into an object payload.
106pub fn normalize_answers(answers: &Value) -> Value {
107    Value::Object(
108        answers
109            .as_object()
110            .cloned()
111            .unwrap_or_else(Map::<String, Value>::new),
112    )
113}