standout-input 9.0.0

Declarative input collection for CLI applications
Documentation
use std::fmt::Write as _;

use sha2::{Digest, Sha256};

use super::definition::{Constraint, Group, Item, ScalarField};

const CANONICAL_FORM_VERSION: &str = "3";

fn esc(text: &str) -> String {
    text.replace('\\', "\\\\")
        .replace('|', "\\|")
        .replace(',', "\\,")
        .replace('\n', "\\n")
}

pub(crate) fn compute_fingerprint(questionnaire_id: &str, items: &[Item]) -> String {
    let mut entries: Vec<(String, String)> = Vec::new();
    collect_entries(items, None, &mut entries);
    entries.sort_by(|a, b| a.0.cmp(&b.0));

    let mut canonical = format!(
        "standout-answers-canonical {CANONICAL_FORM_VERSION}\nquestionnaire={questionnaire_id}\n"
    );
    for (_, entry) in entries {
        canonical.push_str(&entry);
        canonical.push('\n');
    }
    let digest = Sha256::digest(canonical.as_bytes());
    let mut out = String::with_capacity(7 + digest.len() * 2);
    out.push_str("sha256:");
    for byte in digest {
        let _ = write!(out, "{byte:02x}");
    }
    out
}

fn collect_entries(items: &[Item], parent: Option<&str>, entries: &mut Vec<(String, String)>) {
    for item in items {
        match item {
            Item::Field(field) => {
                entries.push((field.id().to_string(), field_entry(field, parent)));
            }
            Item::Group(group) => {
                let Group {
                    id,
                    prompt: _,
                    children,
                    repeat,
                } = group;
                let mut entry = format!("group={id}");
                if let Some(parent) = parent {
                    let _ = write!(entry, "|parent={parent}");
                }
                if let Some(repeat) = repeat {
                    let _ = write!(entry, "|repeat_min={}", repeat.min());
                    if let Some(max) = repeat.max() {
                        let _ = write!(entry, "|repeat_max={max}");
                    }
                }
                entries.push((id.clone(), entry));
                collect_entries(children, Some(id), entries);
            }
        }
    }
}

fn field_entry(field: &ScalarField, parent: Option<&str>) -> String {
    let ScalarField {
        id,
        prompt: _,
        kind,
        optional,
        default,
        dynamic_default,
        constraint,
        condition,
        validator,
    } = field;
    let mut entry = format!("field={id}|kind={}|optional={optional}", kind.name());
    if let Some(parent) = parent {
        let _ = write!(entry, "|parent={parent}");
    }
    if let Some(default) = default {
        let _ = write!(entry, "|default={}", esc(default));
    }
    if let Some(dynamic) = dynamic_default {
        let _ = write!(entry, "|default_revision={}", esc(dynamic.revision()));
    }
    if let Some(Constraint::OneOf(choices)) = constraint {
        let mut sorted_choices: Vec<&String> = choices.iter().collect();
        sorted_choices.sort();
        let joined: Vec<String> = sorted_choices.iter().map(|c| esc(c)).collect();
        let _ = write!(entry, "|one_of={}", joined.join(","));
    }
    if let Some(condition) = condition {
        let _ = write!(
            entry,
            "|active_when={}={}",
            condition.controller(),
            esc(condition.expected())
        );
    }
    if let Some(validator) = validator {
        let _ = write!(entry, "|validator={}", esc(validator.revision()));
    }
    entry
}