Derived Questionnaires

Derived questionnaires let an application describe a form once as Rust types and let Standout provide the answer-sheet command surface around it.

Use this when a command needs a multi-question setup flow, an editable answer sheet, or repeatable automation through --answers FILE and --answers -. For lower-level control over the same runtime model, use the hand-built standout_input::questionnaire::Questionnaire builder API directly.

Define the Answer Type

Derive Questionnaire on named-field structs. The container #[question(id = "...")] is the stable questionnaire identity written into every answer sheet. Field doc comments become prompts; field names become stable IDs unless #[question(id = "...")] overrides them.

#![allow(unused)]
fn main() {
use std::path::PathBuf;

#[derive(Debug, Clone, PartialEq, Eq, standout::Questionnaire)]
#[question(id = "demo.import")]
struct ImportAnswers {
    /// What is the project name?
    #[question(validate = validate_name, revision = "project-name.v1")]
    project_name: String,

    /// Where is the manifest?
    manifest: PathBuf,

    /// Add release notes.
    #[question(prose)]
    notes: String,

    /// Which output format should be generated?
    #[question(choice, default = "json")]
    format: OutputFormat,
}
}

The derive lowers to standout-input's public builder and implements typed filling from decoded answers. It does not use serde, so serde field renames on the same struct cannot change questionnaire IDs.

Choice Enums

Derive QuestionnaireChoices on unit-variant enums used by #[question(choice)] fields.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, standout::QuestionnaireChoices)]
enum OutputFormat {
    #[question(rename = "json")]
    Json,
    #[question(rename = "yaml")]
    Yaml,
    #[question(rename = "plain-text")]
    PlainText,
}
}

Every variant declares its user-facing spelling with #[question(rename = "...")]; a variant without one is a compile error, so the accepted answer strings are always explicit in the source. The enum is the single source for the rendered hint, allowed choices, FromStr, and Display.

Field Shapes

Supported scalar field types are String, PathBuf, and bool. Option<T> makes a scalar or choice field optional. A nested questionnaire struct lowers to a group, and Vec<NestedStruct> lowers to a repeatable group.

Vec<T> over a scalar element type (for example Vec<String>) is a compile error: collect a list as a String field and split it in application code, or model the items as a Vec of a nested questionnaire struct.

Useful field attributes:

AttributeMeaning
id = "..."Override the stable field or group ID.
default = "..."Static default answer text.
default_with = path, revision = "..."Dynamic default from earlier answers.
validate = path, revision = "..."Field validator hook.
active_when(field = "...", is = "...")Conditional Option<T> field; the controller names a field of the same struct.
choiceTreat an enum as a choice field, not a nested struct.
proseTreat a String as multiline text.
min = N, max = MBounds for repeatable groups.

Static and dynamic defaults are mutually exclusive. default_with and validate both require a non-empty revision; if both hooks are on the same field, the one revision identifies both hook contracts for the fingerprint. Bump it whenever either hook changes which answers are accepted or supplied.

Hook signatures are plain function paths:

#![allow(unused)]
fn main() {
use standout::input::questionnaire::{AnswerValue, EarlierAnswers};

fn default_name(answers: &EarlierAnswers<'_>) -> String {
    answers.get_text("project_name").unwrap_or("demo").to_string()
}

fn validate_name(value: &AnswerValue) -> Result<(), String> {
    let text = value.as_text().unwrap_or_default();
    text.chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
        .then_some(())
        .ok_or_else(|| "name may only contain letters, numbers, hyphens, or underscores".into())
}
}

active_when is intentionally bounded. It only applies to Option<T> fields, and its controller must be an earlier scalar or choice field of the same derived struct, named by its Rust field name; a name that does not resolve within the struct is a compile error. The derive resolves the name through any explicit id remapping, so renaming a group with #[question(id = "project")] also remaps children to paths such as project.name.

Wire a Command

With #[derive(Dispatch)], attach the questionnaire to the command variant:

#![allow(unused)]
fn main() {
#[derive(clap::Subcommand, standout::cli::Dispatch)]
#[dispatch(handlers = handlers)]
enum Commands {
    #[dispatch(questionnaire = ImportAnswers, template = "import.jinja")]
    Import,
}
}

The framework injects the reserved command surface:

myapp import questions
myapp import questions --file answers.txt
myapp import --answers answers.txt
myapp import --answers - --yes

questions renders the blank answer sheet and has no side effects. --answers FILE reads one completed sheet from a named file. --answers - reads one completed sheet from piped stdin. With no --answers, the framework collects the same questionnaire interactively.

Sources never merge. A file or stdin submission replaces interactive question collection; after collection, every path goes through the same decoding, dynamic defaults, validators, and whole-form rules.

The same configuration is available through CommandConfig:

#![allow(unused)]
fn main() {
use standout::cli::App;

let app = App::builder()
    .command_with("import", handlers::import, |cfg| {
        cfg.template("import.jinja")
            .questionnaire_with_form_and_review::<ImportAnswers, _, _>(
                validate_form,
                write_review,
            )
    })?
    .build()?;
}

Use questionnaire::<T>() when field-level validation is enough. Use questionnaire_with_form::<T, _>(form) for cross-field rules that return Vec<FormError>. Use questionnaire_with_form_and_review::<T, _, _>(form, review) when the user must see an application review before the confirmation gate.

Read Answers in the Handler

Bring CommandContextInput into scope and read the typed questionnaire value:

#![allow(unused)]
fn main() {
use standout::cli::{CommandContext, CommandContextInput, HandlerResult, Output};

fn import(_matches: &clap::ArgMatches, ctx: &CommandContext) -> HandlerResult<serde_json::Value> {
    let answers: &ImportAnswers = ctx.questionnaire()?;
    Ok(Output::Render(serde_json::json!({
        "project": answers.project_name.as_str(),
    })))
}
}

The handler runs only after questionnaire resolution, field decoding, whole-form rules, optional review, and confirmation have succeeded. Keep side effects in the handler or later so a rejected confirmation writes nothing.

Confirmation and Warnings

Questionnaire commands get --yes from the framework. Without it, Standout asks for an exact yes on an attended controlling terminal after any configured review. Piped stdin never confirms a run, EOF never confirms a run, and a missing attended terminal is an error.

Accepted answer sheets can still produce warnings, for example when answer text contains a suspected <id: fragment. Standout queues these as framework warnings: App::run renders them after the primary output, and standout-test::TestHarness exposes them through TestResult::warnings().

Builder Alternative

The derive is sugar over the same public runtime model described in Questionnaire Answer Sheets. Use the builder API when the definition is not known at compile time or when a standalone library wants to render and decode answer sheets without depending on standout-macros or standout command wiring.