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}