standout-input 9.0.0

Declarative input collection for CLI applications
Documentation
use std::sync::{Arc, Mutex};

use standout_input::env::MockStdin;
use standout_input::questionnaire::{
    AnswerSheetDiagnostic, DynamicDefault, Group, Item, Questionnaire, QuestionnaireError,
    ScalarField, ScalarKind, ValidationDiagnostic,
};
use standout_input::{
    InputSources, PromptContext, PromptResponder, PromptResponse, ScriptedResponder,
};

fn questionnaire() -> Questionnaire {
    Questionnaire::new(
        "demo.dynamic",
        vec![
            ScalarField::new("input.value_type", "Value type?", ScalarKind::String)
                .one_of(["string", "bool", "path"]),
            ScalarField::new("input.cardinality", "Cardinality?", ScalarKind::String)
                .one_of(["single", "list", "boolean"])
                .with_dynamic_default(DynamicDefault::new("1", |earlier| {
                    match earlier.get_text("input.value_type") {
                        Some("bool") => "boolean".to_string(),
                        _ => "single".to_string(),
                    }
                })),
        ],
    )
    .unwrap()
}

fn answer(sheet: &str, id: &str, answer: &str) -> String {
    let tag = format!("<id:{id}>");
    let lines: Vec<&str> = sheet.lines().collect();
    let mut out: Vec<String> = Vec::new();
    let mut found = false;
    let mut i = 0;
    while i < lines.len() {
        let line = lines[i];
        out.push(line.to_string());
        i += 1;
        if !found && line.trim_end().ends_with(&tag) {
            found = true;
            if lines.get(i).is_some_and(|next| !next.trim().is_empty()) {
                i += 1;
            }
            out.push(answer.to_string());
        }
    }
    assert!(found, "answer sheet has no question line for {tag}");
    out.join("\n") + "\n"
}

struct ResponderGuard {
    sources: InputSources,
}
impl ResponderGuard {
    fn install(responses: impl IntoIterator<Item = PromptResponse>) -> Self {
        Self::install_with(Arc::new(ScriptedResponder::new(responses)))
    }

    fn install_with(responder: Arc<dyn PromptResponder>) -> Self {
        Self {
            sources: InputSources::from_process().with_responder(responder),
        }
    }

    fn sources(&self) -> &InputSources {
        &self.sources
    }
}

#[test]
fn declaring_both_a_static_and_a_dynamic_default_is_an_error() {
    let err = Questionnaire::new(
        "demo",
        vec![ScalarField::new("a", "A?", ScalarKind::String)
            .with_default("static")
            .with_dynamic_default(DynamicDefault::new("1", |_| "dynamic".to_string()))],
    )
    .unwrap_err();
    assert!(matches!(&err, QuestionnaireError::Item { id, .. } if id == "a"));
    assert!(err
        .to_string()
        .contains("declares both a static and a dynamic default"));
}

#[test]
fn an_empty_dynamic_default_revision_is_an_error() {
    let err = Questionnaire::new(
        "demo",
        vec![ScalarField::new("a", "A?", ScalarKind::String)
            .with_dynamic_default(DynamicDefault::new("", |_| "x".to_string()))],
    )
    .unwrap_err();
    assert!(matches!(&err, QuestionnaireError::Item { id, .. } if id == "a"));
    assert!(err
        .to_string()
        .contains("attaches a dynamic default with an empty revision"));
}

fn with_revision_and_closure(
    revision: &str,
    compute: impl Fn() -> String + Send + Sync + 'static,
) -> Questionnaire {
    Questionnaire::new(
        "demo",
        vec![ScalarField::new("a", "A?", ScalarKind::String)
            .with_dynamic_default(DynamicDefault::new(revision, move |_| compute()))],
    )
    .unwrap()
}

#[test]
fn changing_the_revision_changes_the_fingerprint() {
    let one = with_revision_and_closure("1", || "x".to_string());
    let two = with_revision_and_closure("2", || "x".to_string());
    assert_ne!(one.fingerprint(), two.fingerprint());
}

#[test]
fn the_closure_itself_does_not_affect_the_fingerprint() {
    let one = with_revision_and_closure("1", || "x".to_string());
    let other = with_revision_and_closure("1", || "an entirely different value".to_string());
    assert_eq!(one.fingerprint(), other.fingerprint());
}

#[test]
fn a_dynamic_default_cannot_collide_with_a_static_default() {
    let dynamic = with_revision_and_closure("v", || "v".to_string());
    let static_default = Questionnaire::new(
        "demo",
        vec![ScalarField::new("a", "A?", ScalarKind::String).with_default("v")],
    )
    .unwrap();
    assert_ne!(dynamic.fingerprint(), static_default.fingerprint());
}

#[test]
fn a_revision_bump_invalidates_previously_rendered_sheets() {
    let old = with_revision_and_closure("1", || "x".to_string());
    let new = with_revision_and_closure("2", || "x".to_string());
    let sheet = answer(&old.render_answer_sheet(), "a", "hello");
    let diagnostics = new.parse_answer_sheet(&sheet).unwrap_err();
    assert!(matches!(
        &diagnostics[..],
        [AnswerSheetDiagnostic::Incompatible { message }] if message.contains("fingerprint")
    ));
}

#[test]
fn dynamic_default_fields_render_with_an_empty_answer_region() {
    let q = questionnaire();
    let sheet = q.render_answer_sheet();
    let lines: Vec<&str> = sheet.lines().collect();
    let question = lines
        .iter()
        .position(|l| l.trim_end().ends_with("<id:input.cardinality>"))
        .unwrap();
    assert!(lines
        .get(question + 1)
        .is_none_or(|next| next.trim().is_empty()));
}

#[test]
fn a_blank_answer_resolves_through_the_computed_default_from_a_sheet() {
    let q = questionnaire();
    let sheet = answer(&q.render_answer_sheet(), "input.value_type", "bool");
    let raw = q
        .read_answer_sheet_stdin_with(&MockStdin::piped(sheet.clone()))
        .unwrap();
    let answers = q.decode_answers(&raw).unwrap();
    assert_eq!(answers.get_text("input.cardinality"), Some("boolean"));

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("answers.txt");
    std::fs::write(&path, sheet).unwrap();
    let from_file = q
        .decode_answers(&q.read_answer_sheet_file(&path).unwrap())
        .unwrap();
    assert_eq!(from_file, answers);

    let sheet = answer(&q.render_answer_sheet(), "input.value_type", "string");
    let raw = q
        .read_answer_sheet_stdin_with(&MockStdin::piped(sheet))
        .unwrap();
    let answers = q.decode_answers(&raw).unwrap();
    assert_eq!(answers.get_text("input.cardinality"), Some("single"));
}

#[test]
fn a_blank_interactive_entry_resolves_through_the_computed_default() {
    let q = questionnaire();
    let _guard = ResponderGuard::install([PromptResponse::text("bool"), PromptResponse::Skip]);
    let interactive = q
        .decode_answers(&q.collect_interactive_from(_guard.sources()).unwrap())
        .unwrap();
    assert_eq!(interactive.get_text("input.cardinality"), Some("boolean"));

    let sheet = answer(&q.render_answer_sheet(), "input.value_type", "bool");
    let batch = q
        .decode_answers(
            &q.read_answer_sheet_stdin_with(&MockStdin::piped(sheet))
                .unwrap(),
        )
        .unwrap();
    assert_eq!(interactive, batch);
}

#[test]
fn an_entered_answer_overrides_the_computed_default() {
    let q = questionnaire();
    let _guard =
        ResponderGuard::install([PromptResponse::text("bool"), PromptResponse::text("list")]);
    let answers = q
        .decode_answers(&q.collect_interactive_from(_guard.sources()).unwrap())
        .unwrap();
    assert_eq!(answers.get_text("input.cardinality"), Some("list"));
}

#[test]
fn a_computed_default_that_violates_a_constraint_is_a_diagnostic() {
    let q = Questionnaire::new(
        "demo",
        vec![ScalarField::new("a", "A?", ScalarKind::String)
            .one_of(["x", "y"])
            .with_dynamic_default(DynamicDefault::new("1", |_| "not-a-choice".to_string()))],
    )
    .unwrap();
    let sheet = q.render_answer_sheet();
    let raw = q
        .read_answer_sheet_stdin_with(&MockStdin::piped(sheet))
        .unwrap();
    let diagnostics = q.decode_answers(&raw).unwrap_err();
    assert!(matches!(
        &diagnostics[..],
        [ValidationDiagnostic::Field { id, message }]
            if id == "a" && message.contains("must be one of")
    ));
}

#[test]
fn dynamic_defaults_resolve_per_occurrence_in_repeatable_groups() {
    let q = Questionnaire::new(
        "demo.repeats",
        vec![Item::from(
            Group::new(
                "inputs",
                "Describe an input.",
                vec![
                    ScalarField::new("inputs.value_type", "Type?", ScalarKind::String),
                    ScalarField::new("inputs.cardinality", "Cardinality?", ScalarKind::String)
                        .with_dynamic_default(DynamicDefault::new("1", |earlier| {
                            match earlier.get_text("inputs.value_type") {
                                Some("bool") => "boolean".to_string(),
                                _ => "single".to_string(),
                            }
                        })),
                ],
            )
            .repeatable(1),
        )],
    )
    .unwrap();

    let sheet = q.render_answer_sheet();
    let block_start = sheet.find("Describe an input.").unwrap();
    let block = sheet[block_start..].to_string();
    let first = sheet.replace("<id:inputs.value_type>\n", "<id:inputs.value_type>\nbool\n");
    let second = block.replace(
        "<id:inputs.value_type>\n",
        "<id:inputs.value_type>\nstring\n",
    );
    let document = format!("{first}\n{second}");
    let raw = q
        .read_answer_sheet_stdin_with(&MockStdin::piped(document))
        .unwrap();
    let answers = q.decode_answers(&raw).unwrap();
    assert_eq!(answers.get_text("inputs[0].cardinality"), Some("boolean"));
    assert_eq!(answers.get_text("inputs[1].cardinality"), Some("single"));
}

#[test]
fn a_dependency_on_a_later_or_unknown_field_reads_as_none() {
    let q = Questionnaire::new(
        "demo",
        vec![
            ScalarField::new("a", "A?", ScalarKind::String).with_dynamic_default(
                DynamicDefault::new("1", |earlier| {
                    assert!(earlier.get("b").is_none());
                    assert!(earlier.get("no.such.field").is_none());
                    "fallback".to_string()
                }),
            ),
            ScalarField::new("b", "B?", ScalarKind::String).optional(),
        ],
    )
    .unwrap();
    let sheet = q.render_answer_sheet();
    let raw = q
        .read_answer_sheet_stdin_with(&MockStdin::piped(sheet))
        .unwrap();
    let answers = q.decode_answers(&raw).unwrap();
    assert_eq!(answers.get_text("a"), Some("fallback"));
}

struct RecordingResponder {
    messages: Mutex<Vec<String>>,
    responses: Mutex<std::collections::VecDeque<PromptResponse>>,
}

impl PromptResponder for RecordingResponder {
    fn respond(&self, ctx: PromptContext<'_>) -> PromptResponse {
        self.messages.lock().unwrap().push(ctx.message.to_string());
        self.responses
            .lock()
            .unwrap()
            .pop_front()
            .expect("unexpected extra prompt")
    }
}

#[test]
fn the_interactive_prompt_displays_the_computed_default() {
    let q = questionnaire();
    let responder = Arc::new(RecordingResponder {
        messages: Mutex::new(Vec::new()),
        responses: Mutex::new(
            [PromptResponse::text("bool"), PromptResponse::Skip]
                .into_iter()
                .collect(),
        ),
    });
    let _guard = ResponderGuard::install_with(responder.clone());
    q.collect_interactive_from(_guard.sources()).unwrap();

    let messages = responder.messages.lock().unwrap();
    let cardinality_prompt = messages
        .iter()
        .find(|m| m.starts_with("Cardinality?"))
        .expect("the cardinality question prompted");
    assert!(
        cardinality_prompt.contains("[default: boolean]"),
        "prompt should show the computed default: {cardinality_prompt:?}"
    );
}