Skip to main content

qa_spec/
progress.rs

1use serde_json::{Map, Value};
2
3use crate::spec::form::FormSpec;
4use crate::spec::question::QuestionSpec;
5use crate::store::StoreTarget;
6use crate::visibility::VisibilityMap;
7
8/// Encapsulates runtime state for progress evaluation.
9#[derive(Debug, Clone)]
10pub struct ProgressContext {
11    answers: Map<String, Value>,
12    config: Value,
13    state: Value,
14    payload_out: Value,
15    secrets: Value,
16}
17
18impl ProgressContext {
19    pub fn new(answers: Value, ctx: &Value) -> Self {
20        let answers_map = answers.as_object().cloned().unwrap_or_default();
21        let default = || Value::Object(Map::new());
22        Self {
23            answers: answers_map,
24            config: ctx.get("config").cloned().unwrap_or_else(default),
25            state: ctx.get("state").cloned().unwrap_or_else(default),
26            payload_out: ctx.get("payload_out").cloned().unwrap_or_else(default),
27            secrets: ctx.get("secrets").cloned().unwrap_or_else(default),
28        }
29    }
30
31    fn has_target(&self, target: StoreTarget, key: &str) -> bool {
32        match target {
33            StoreTarget::Answers => self.answers.contains_key(key),
34            StoreTarget::Config => self.config.get(key).is_some(),
35            StoreTarget::State => self.state.get(key).is_some(),
36            StoreTarget::PayloadOut => self.payload_out.get(key).is_some(),
37            StoreTarget::Secrets => self.secrets.get(key).is_some(),
38        }
39    }
40
41    pub fn answered_count(&self, spec: &FormSpec, visibility: &VisibilityMap) -> usize {
42        spec.questions
43            .iter()
44            .filter(|question| {
45                visibility.get(&question.id).copied().unwrap_or(true)
46                    && is_answered(question, self, spec.progress_policy.as_ref())
47            })
48            .count()
49    }
50}
51
52pub fn next_question(
53    spec: &FormSpec,
54    ctx: &ProgressContext,
55    visibility: &VisibilityMap,
56) -> Option<String> {
57    let progress_policy = spec.progress_policy.as_ref().copied().unwrap_or_default();
58
59    for question in &spec.questions {
60        if !visibility.get(&question.id).copied().unwrap_or(true) {
61            continue;
62        }
63
64        if should_skip(question, ctx, &progress_policy) {
65            continue;
66        }
67
68        return Some(question.id.clone());
69    }
70
71    None
72}
73
74fn should_skip(
75    question: &QuestionSpec,
76    ctx: &ProgressContext,
77    policy: &crate::spec::form::ProgressPolicy,
78) -> bool {
79    if question
80        .policy
81        .skip_if_present_in
82        .iter()
83        .any(|target| ctx.has_target(*target, &question.id))
84    {
85        return true;
86    }
87
88    if policy.skip_answered && is_answered(question, ctx, Some(policy)) {
89        return true;
90    }
91
92    false
93}
94
95fn is_answered(
96    question: &QuestionSpec,
97    ctx: &ProgressContext,
98    policy: Option<&crate::spec::form::ProgressPolicy>,
99) -> bool {
100    let has_answer = ctx.answers.contains_key(&question.id);
101    let defaults_policy = policy
102        .copied()
103        .unwrap_or_else(crate::spec::form::ProgressPolicy::default);
104
105    if has_answer {
106        return true;
107    }
108
109    if defaults_policy.autofill_defaults && question.default_value.is_some() {
110        if question.policy.editable_if_from_default {
111            return false;
112        }
113        return defaults_policy.treat_default_as_answered;
114    }
115
116    false
117}