Skip to main content

standout_input/questionnaire/
definition.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use super::decode::{check_field_text, AnswerValue, EarlierAnswers};
5use super::fingerprint::compute_fingerprint;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ScalarKind {
9    String,
10    Text,
11    Bool,
12    Path,
13}
14
15impl ScalarKind {
16    pub(crate) fn name(self) -> &'static str {
17        match self {
18            ScalarKind::String => "string",
19            ScalarKind::Text => "text",
20            ScalarKind::Bool => "bool",
21            ScalarKind::Path => "path",
22        }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Constraint {
28    OneOf(Vec<String>),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Condition {
33    pub(crate) controller: String,
34    pub(crate) expected: String,
35}
36
37impl Condition {
38    pub fn controller(&self) -> &str {
39        &self.controller
40    }
41
42    pub fn expected(&self) -> &str {
43        &self.expected
44    }
45}
46
47type ValidatorCheck = Arc<dyn Fn(&AnswerValue) -> Result<(), String> + Send + Sync>;
48
49#[derive(Clone)]
50pub struct FieldValidator {
51    revision: String,
52    check: ValidatorCheck,
53}
54
55impl FieldValidator {
56    pub fn new(
57        revision: impl Into<String>,
58        check: impl Fn(&AnswerValue) -> Result<(), String> + Send + Sync + 'static,
59    ) -> Self {
60        Self {
61            revision: revision.into(),
62            check: Arc::new(check),
63        }
64    }
65
66    pub fn revision(&self) -> &str {
67        &self.revision
68    }
69
70    pub(crate) fn check(&self, value: &AnswerValue) -> Result<(), String> {
71        (self.check)(value)
72    }
73}
74
75impl std::fmt::Debug for FieldValidator {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("FieldValidator")
78            .field("revision", &self.revision)
79            .finish_non_exhaustive()
80    }
81}
82
83impl PartialEq for FieldValidator {
84    fn eq(&self, other: &Self) -> bool {
85        self.revision == other.revision
86    }
87}
88
89impl Eq for FieldValidator {}
90
91type DefaultCompute = Arc<dyn Fn(&EarlierAnswers<'_>) -> String + Send + Sync>;
92
93#[derive(Clone)]
94pub struct DynamicDefault {
95    revision: String,
96    compute: DefaultCompute,
97}
98
99impl DynamicDefault {
100    pub fn new(
101        revision: impl Into<String>,
102        compute: impl Fn(&EarlierAnswers<'_>) -> String + Send + Sync + 'static,
103    ) -> Self {
104        Self {
105            revision: revision.into(),
106            compute: Arc::new(compute),
107        }
108    }
109
110    pub fn revision(&self) -> &str {
111        &self.revision
112    }
113
114    pub(crate) fn compute(&self, earlier: &EarlierAnswers<'_>) -> String {
115        (self.compute)(earlier)
116    }
117}
118
119impl std::fmt::Debug for DynamicDefault {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct("DynamicDefault")
122            .field("revision", &self.revision)
123            .finish_non_exhaustive()
124    }
125}
126
127impl PartialEq for DynamicDefault {
128    fn eq(&self, other: &Self) -> bool {
129        self.revision == other.revision
130    }
131}
132
133impl Eq for DynamicDefault {}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ScalarField {
137    pub(crate) id: String,
138    pub(crate) prompt: String,
139    pub(crate) kind: ScalarKind,
140    pub(crate) optional: bool,
141    pub(crate) default: Option<String>,
142    pub(crate) dynamic_default: Option<DynamicDefault>,
143    pub(crate) constraint: Option<Constraint>,
144    pub(crate) condition: Option<Condition>,
145    pub(crate) validator: Option<FieldValidator>,
146}
147
148impl ScalarField {
149    pub fn new(id: impl Into<String>, prompt: impl Into<String>, kind: ScalarKind) -> Self {
150        Self {
151            id: id.into(),
152            prompt: prompt.into(),
153            kind,
154            optional: false,
155            default: None,
156            dynamic_default: None,
157            constraint: None,
158            condition: None,
159            validator: None,
160        }
161    }
162
163    pub fn optional(mut self) -> Self {
164        self.optional = true;
165        self
166    }
167
168    pub fn with_default(mut self, default: impl Into<String>) -> Self {
169        self.default = Some(default.into());
170        self
171    }
172
173    pub fn with_dynamic_default(mut self, dynamic_default: DynamicDefault) -> Self {
174        self.dynamic_default = Some(dynamic_default);
175        self
176    }
177
178    pub fn one_of(mut self, choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
179        self.constraint = Some(Constraint::OneOf(
180            choices.into_iter().map(Into::into).collect(),
181        ));
182        self
183    }
184
185    pub fn active_when(
186        mut self,
187        controller: impl Into<String>,
188        expected: impl Into<String>,
189    ) -> Self {
190        self.condition = Some(Condition {
191            controller: controller.into(),
192            expected: expected.into(),
193        });
194        self
195    }
196
197    pub fn with_validator(mut self, validator: FieldValidator) -> Self {
198        self.validator = Some(validator);
199        self
200    }
201
202    pub fn id(&self) -> &str {
203        &self.id
204    }
205
206    pub fn prompt(&self) -> &str {
207        &self.prompt
208    }
209
210    pub fn kind(&self) -> ScalarKind {
211        self.kind
212    }
213
214    pub fn is_optional(&self) -> bool {
215        self.optional
216    }
217
218    pub fn default(&self) -> Option<&str> {
219        self.default.as_deref()
220    }
221
222    pub fn dynamic_default(&self) -> Option<&DynamicDefault> {
223        self.dynamic_default.as_ref()
224    }
225
226    pub fn constraint(&self) -> Option<&Constraint> {
227        self.constraint.as_ref()
228    }
229
230    pub fn condition(&self) -> Option<&Condition> {
231        self.condition.as_ref()
232    }
233
234    pub fn validator(&self) -> Option<&FieldValidator> {
235        self.validator.as_ref()
236    }
237
238    pub(crate) fn type_hint(&self) -> String {
239        let mut hint = match &self.constraint {
240            Some(Constraint::OneOf(choices)) => join_or(choices),
241            None => self.kind.name().to_string(),
242        };
243        if self.optional {
244            hint.push_str(", optional");
245        }
246        if let Some(condition) = &self.condition {
247            hint.push_str(&format!(
248                "; only when {} is {}",
249                condition.controller, condition.expected
250            ));
251        }
252        hint
253    }
254}
255
256fn join_or(choices: &[String]) -> String {
257    match choices {
258        [] => String::new(),
259        [one] => one.clone(),
260        [a, b] => format!("{a} or {b}"),
261        [head @ .., last] => format!("{}, or {last}", head.join(", ")),
262    }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct Repeat {
267    pub(crate) min: usize,
268    pub(crate) max: Option<usize>,
269}
270
271impl Repeat {
272    pub fn min(&self) -> usize {
273        self.min
274    }
275
276    pub fn max(&self) -> Option<usize> {
277        self.max
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct Group {
283    pub(crate) id: String,
284    pub(crate) prompt: String,
285    pub(crate) children: Vec<Item>,
286    pub(crate) repeat: Option<Repeat>,
287}
288
289impl Group {
290    pub fn new(
291        id: impl Into<String>,
292        prompt: impl Into<String>,
293        children: impl IntoIterator<Item = impl Into<Item>>,
294    ) -> Self {
295        Self {
296            id: id.into(),
297            prompt: prompt.into(),
298            children: children.into_iter().map(Into::into).collect(),
299            repeat: None,
300        }
301    }
302
303    pub fn repeatable(mut self, min: usize) -> Self {
304        self.repeat = Some(Repeat { min, max: None });
305        self
306    }
307
308    pub fn max_occurrences(mut self, max: usize) -> Self {
309        match &mut self.repeat {
310            Some(repeat) => repeat.max = Some(max),
311            None => {
312                self.repeat = Some(Repeat {
313                    min: 0,
314                    max: Some(max),
315                })
316            }
317        }
318        self
319    }
320
321    pub fn id(&self) -> &str {
322        &self.id
323    }
324
325    pub fn prompt(&self) -> &str {
326        &self.prompt
327    }
328
329    pub fn children(&self) -> &[Item] {
330        &self.children
331    }
332
333    pub fn repeat(&self) -> Option<Repeat> {
334        self.repeat
335    }
336
337    pub(crate) fn def_prefix(&self) -> String {
338        format!("{}.", self.id)
339    }
340
341    pub(crate) fn type_hint(&self) -> String {
342        match self.repeat {
343            None => "section".to_string(),
344            Some(Repeat { min, max: None }) => {
345                format!("repeatable section, minimum {min}")
346            }
347            Some(Repeat {
348                min,
349                max: Some(max),
350            }) => format!("repeatable section, minimum {min}, maximum {max}"),
351        }
352    }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum Item {
357    Field(ScalarField),
358    Group(Group),
359}
360
361impl From<ScalarField> for Item {
362    fn from(field: ScalarField) -> Self {
363        Item::Field(field)
364    }
365}
366
367impl From<Group> for Item {
368    fn from(group: Group) -> Self {
369        Item::Group(group)
370    }
371}
372
373impl Item {
374    pub fn id(&self) -> &str {
375        match self {
376            Item::Field(field) => field.id(),
377            Item::Group(group) => group.id(),
378        }
379    }
380}
381
382pub(crate) fn path_join(prefix: &str, segment: &str) -> String {
383    if prefix.is_empty() {
384        segment.to_string()
385    } else {
386        format!("{prefix}.{segment}")
387    }
388}
389
390pub(crate) fn child_segment<'a>(def_prefix: &str, id: &'a str) -> &'a str {
391    id.strip_prefix(def_prefix).unwrap_or(id)
392}
393
394#[derive(Debug, thiserror::Error, PartialEq, Eq)]
395pub enum QuestionnaireError {
396    #[error("{reason}")]
397    Structure { reason: String },
398
399    #[error("{reason}")]
400    Item { id: String, reason: String },
401}
402
403impl QuestionnaireError {
404    pub(crate) fn structure(reason: impl Into<String>) -> Self {
405        Self::Structure {
406            reason: reason.into(),
407        }
408    }
409
410    pub(crate) fn item(id: impl Into<String>, reason: impl Into<String>) -> Self {
411        Self::Item {
412            id: id.into(),
413            reason: reason.into(),
414        }
415    }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct Questionnaire {
420    id: String,
421    items: Vec<Item>,
422    meta: HashMap<String, NodeMeta>,
423    fingerprint: String,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub(crate) struct NodeMeta {
428    pub(crate) parent: Option<String>,
429    pub(crate) group: bool,
430}
431
432struct FieldInfo {
433    dfs: usize,
434    chain: Vec<String>,
435    kind: ScalarKind,
436    constraint: Option<Constraint>,
437}
438
439fn valid_id(id: &str) -> bool {
440    !id.is_empty()
441        && id
442            .chars()
443            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
444}
445
446impl Questionnaire {
447    pub fn new(
448        id: impl Into<String>,
449        items: Vec<impl Into<Item>>,
450    ) -> Result<Self, QuestionnaireError> {
451        let id = id.into();
452        if !valid_id(&id) {
453            return Err(QuestionnaireError::structure(format!(
454                "Invalid questionnaire ID '{id}': IDs must be non-empty and use only a-z, 0-9, '.', '_', '-'."
455            )));
456        }
457        let mut items: Vec<Item> = items.into_iter().map(Into::into).collect();
458        if items.is_empty() {
459            return Err(QuestionnaireError::structure(
460                "A questionnaire must declare at least one item (field or group).",
461            ));
462        }
463
464        let mut meta = HashMap::new();
465        let mut field_info = HashMap::new();
466        collect_structure(
467            &items,
468            None,
469            &mut Vec::new(),
470            &mut meta,
471            &mut field_info,
472            &mut 0,
473        )?;
474
475        validate_fields(&mut items, &meta, &field_info)?;
476
477        let fingerprint = compute_fingerprint(&id, &items);
478        Ok(Self {
479            id,
480            items,
481            meta,
482            fingerprint,
483        })
484    }
485
486    pub fn id(&self) -> &str {
487        &self.id
488    }
489
490    pub fn items(&self) -> &[Item] {
491        &self.items
492    }
493
494    pub fn fingerprint(&self) -> &str {
495        &self.fingerprint
496    }
497
498    pub(crate) fn group_def(&self, id: &str) -> Option<&Group> {
499        find_group(&self.items, id)
500    }
501
502    pub(crate) fn node_meta(&self, id: &str) -> Option<&NodeMeta> {
503        self.meta.get(id)
504    }
505}
506
507fn find_group<'a>(items: &'a [Item], id: &str) -> Option<&'a Group> {
508    items.iter().find_map(|item| match item {
509        Item::Field(_) => None,
510        Item::Group(group) if group.id == id => Some(group),
511        Item::Group(group) => find_group(&group.children, id),
512    })
513}
514
515fn collect_structure(
516    items: &[Item],
517    parent: Option<&str>,
518    chain: &mut Vec<String>,
519    meta: &mut HashMap<String, NodeMeta>,
520    field_info: &mut HashMap<String, FieldInfo>,
521    dfs: &mut usize,
522) -> Result<(), QuestionnaireError> {
523    for item in items {
524        let item_id = item.id();
525        if !valid_id(item_id) {
526            return Err(QuestionnaireError::structure(format!(
527                "Invalid ID '{item_id}': IDs must be non-empty and use only a-z, 0-9, '.', '_', '-'."
528            )));
529        }
530        if meta.contains_key(item_id) {
531            return Err(QuestionnaireError::structure(format!(
532                "Duplicate ID '{item_id}': stable IDs must be unique within a questionnaire."
533            )));
534        }
535        if let Some(parent) = parent {
536            let prefix = format!("{parent}.");
537            if !item_id.starts_with(&prefix) || item_id.len() == prefix.len() {
538                return Err(QuestionnaireError::structure(format!(
539                    "Item '{item_id}' inside group '{parent}' must extend the group's ID ('{parent}.<segment>') so submitted occurrence paths stay derivable from definition IDs."
540                )));
541            }
542        }
543        meta.insert(
544            item_id.to_string(),
545            NodeMeta {
546                parent: parent.map(str::to_string),
547                group: matches!(item, Item::Group(_)),
548            },
549        );
550        *dfs += 1;
551        match item {
552            Item::Field(field) => {
553                field_info.insert(
554                    field.id.clone(),
555                    FieldInfo {
556                        dfs: *dfs,
557                        chain: chain.clone(),
558                        kind: field.kind,
559                        constraint: field.constraint.clone(),
560                    },
561                );
562            }
563            Item::Group(group) => {
564                if group.children.is_empty() {
565                    return Err(QuestionnaireError::structure(format!(
566                        "Group '{}' declares no children: a group must contain at least one field or group.",
567                        group.id
568                    )));
569                }
570                if let Some(repeat) = group.repeat {
571                    if repeat.min == 0 {
572                        return Err(QuestionnaireError::item(
573                            group.id.clone(),
574                            format!("Invalid repeat bounds on group '{}': the minimum must be at least 1 — rendering emits exactly the minimum number of blocks, and a sheet needs one complete block to copy (declare repeatable(min) before max_occurrences)", group.id),
575                        ));
576                    }
577                    if let Some(max) = repeat.max {
578                        if max < repeat.min {
579                            return Err(QuestionnaireError::item(
580                                group.id.clone(),
581                                format!(
582                                    "Invalid repeat bounds on group '{}': the maximum ({max}) is below the minimum ({})",
583                                    group.id, repeat.min
584                                ),
585                            ));
586                        }
587                    }
588                }
589                chain.push(group.id.clone());
590                collect_structure(
591                    &group.children,
592                    Some(&group.id),
593                    chain,
594                    meta,
595                    field_info,
596                    dfs,
597                )?;
598                chain.pop();
599            }
600        }
601    }
602    Ok(())
603}
604
605fn validate_fields(
606    items: &mut [Item],
607    meta: &HashMap<String, NodeMeta>,
608    field_info: &HashMap<String, FieldInfo>,
609) -> Result<(), QuestionnaireError> {
610    for item in items {
611        match item {
612            Item::Field(field) => {
613                validate_constraint(field)?;
614                let field_id = field.id.clone();
615                if let Some(condition) = &mut field.condition {
616                    validate_condition(&field_id, condition, meta, field_info)?;
617                }
618                if let Some(validator) = &field.validator {
619                    if validator.revision().is_empty() {
620                        return Err(QuestionnaireError::item(
621                            field.id.clone(),
622                            format!("Field '{}' attaches a validator with an empty revision: the revision is the validator's semantic identity and must be non-empty.", field.id),
623                        ));
624                    }
625                }
626                validate_default(field)?;
627            }
628            Item::Group(group) => {
629                validate_fields(&mut group.children, meta, field_info)?;
630            }
631        }
632    }
633    Ok(())
634}
635
636fn validate_constraint(field: &ScalarField) -> Result<(), QuestionnaireError> {
637    let Some(Constraint::OneOf(choices)) = &field.constraint else {
638        return Ok(());
639    };
640    let invalid = |reason: &str| {
641        QuestionnaireError::item(
642            field.id.clone(),
643            format!("Invalid constraint on field '{}': {reason}", field.id),
644        )
645    };
646    if field.kind == ScalarKind::Bool {
647        return Err(invalid("a bool field cannot declare choices"));
648    }
649    if choices.is_empty() {
650        return Err(invalid("the choice list is empty"));
651    }
652    let mut unique = HashSet::new();
653    for choice in choices {
654        if choice.trim().is_empty() || choice.contains('\n') {
655            return Err(invalid("choices must be non-blank single lines"));
656        }
657        if choice != choice.trim() {
658            return Err(invalid(
659                "choices must carry no outer whitespace (answers are trimmed before matching, so such a choice is unsatisfiable)",
660            ));
661        }
662        if !unique.insert(choice.as_str()) {
663            return Err(invalid("choices must be unique"));
664        }
665    }
666    Ok(())
667}
668
669fn validate_condition(
670    field_id: &str,
671    condition: &mut Condition,
672    meta: &HashMap<String, NodeMeta>,
673    field_info: &HashMap<String, FieldInfo>,
674) -> Result<(), QuestionnaireError> {
675    let invalid = |reason: String| {
676        QuestionnaireError::item(
677            field_id,
678            format!("Invalid condition on field '{field_id}': {reason}"),
679        )
680    };
681    let dependent = &field_info[field_id];
682    let Some(controller) = field_info.get(&condition.controller) else {
683        if meta.contains_key(&condition.controller) {
684            return Err(invalid(format!(
685                "controller '{}' is a group; a controller must be a scalar field",
686                condition.controller
687            )));
688        }
689        return Err(QuestionnaireError::item(
690            field_id,
691            format!(
692                "Field '{field_id}' is conditioned on unknown field '{}'.",
693                condition.controller
694            ),
695        ));
696    };
697    let enclosing = controller.chain.len() <= dependent.chain.len()
698        && dependent.chain[..controller.chain.len()] == controller.chain[..];
699    if !enclosing {
700        return Err(QuestionnaireError::item(
701            field_id,
702            format!(
703                "Field '{field_id}' is conditioned on '{}', which is not in an enclosing scope. A controller must be declared in the same group as the dependent field or in one of its enclosing groups.",
704                condition.controller
705            ),
706        ));
707    }
708    if controller.dfs > dependent.dfs {
709        return Err(QuestionnaireError::item(
710            field_id,
711            format!(
712                "Field '{field_id}' is conditioned on '{}', which is declared after it. Declare the controlling field first.",
713                condition.controller
714            ),
715        ));
716    }
717    let controller_kind = &controller.kind;
718    let controller_constraint = &controller.constraint;
719    if *controller_kind == ScalarKind::Bool {
720        match super::decode::parse_bool(&condition.expected) {
721            Some(value) => condition.expected = if value { "true" } else { "false" }.to_string(),
722            None => {
723                return Err(invalid(format!(
724                    "controller '{}' is a bool, but the expected value is not a yes/no value",
725                    condition.controller
726                )))
727            }
728        }
729    } else if let Some(Constraint::OneOf(choices)) = controller_constraint {
730        if !choices.contains(&condition.expected) {
731            return Err(invalid(format!(
732                "controller '{}' never accepts the expected value (its choices are: {})",
733                condition.controller,
734                choices.join(", ")
735            )));
736        }
737    } else if condition.expected.is_empty() || condition.expected != condition.expected.trim() {
738        return Err(invalid(format!(
739            "controller '{}' never decodes to the expected value (decoded answers are non-blank and carry no outer whitespace)",
740            condition.controller
741        )));
742    }
743    Ok(())
744}
745
746fn validate_default(field: &ScalarField) -> Result<(), QuestionnaireError> {
747    if let Some(dynamic) = &field.dynamic_default {
748        if field.default.is_some() {
749            return Err(QuestionnaireError::item(
750                field.id.clone(),
751                format!("Field '{}' declares both a static and a dynamic default: a field takes one or the other, never both.", field.id),
752            ));
753        }
754        if dynamic.revision().is_empty() {
755            return Err(QuestionnaireError::item(
756                field.id.clone(),
757                format!("Field '{}' attaches a dynamic default with an empty revision: the revision is the dynamic default's semantic identity and must be non-empty.", field.id),
758            ));
759        }
760    }
761    let Some(default) = &field.default else {
762        return Ok(());
763    };
764    let invalid = |reason: String| {
765        QuestionnaireError::item(
766            field.id.clone(),
767            format!("Invalid default on field '{}': {reason}", field.id),
768        )
769    };
770    if default.trim().is_empty() {
771        return Err(invalid("a default must be non-blank".to_string()));
772    }
773    if default != default.trim() {
774        return Err(invalid(
775            "a default must carry no outer whitespace (parsed answers are trimmed, so it could never survive a render/parse round trip)"
776                .to_string(),
777        ));
778    }
779    if default.contains('\n') {
780        return Err(invalid(
781            "a default must be a single line (it renders pre-filled below the question line)"
782                .to_string(),
783        ));
784    }
785    if let Err(diagnostic) = check_field_text(field, field.id(), default) {
786        return Err(invalid(format!(
787            "the default does not decode cleanly: {diagnostic}"
788        )));
789    }
790    Ok(())
791}