paperboy 0.5.5

A Rust TUI API tester
//! Binding a report's declared [`PARAM`](crate::report::flow::ParamDecl)s
//! against the values chosen for one particular run.
//!
//! A parameter's value comes from one of two places: what the run was given, or
//! the default written in the `.trail` file. Nothing else — in particular a
//! chosen value is never written back into the file, so a report under version
//! control keeps meaning the same thing to everyone who opens it.
//!
//! The checks here are the ones that can only be made once a value exists, so
//! they deliberately overlap with [`super::validate`]: that pass judges the
//! *declaration* (is this default one of the choices?), this one judges the
//! *value* (is what you picked one of the choices?). A report can pass
//! validation and still be run with nonsense.

use std::collections::HashMap;

use super::flow::{ParamDecl, ParamKind};
use crate::i18n::{Strings, fill};

/// The values chosen for a run, keyed by the parameter's raw name.
///
/// Keyed by name rather than by prompt on purpose: the name is the parameter's
/// identity everywhere (`{{NAME}}`, `--param NAME=…`, the remembered values), so
/// relabelling a parameter never orphans a value.
pub type ParamValues = HashMap<String, String>;

/// The value a parameter takes for this run, or why it can't take one.
///
/// A failure is reported rather than guessed at: substituting an empty string
/// for a required parameter produces a report full of plausible-looking rows
/// built from a URL with a hole in it, which is worse than not running.
pub fn value_for(decl: &ParamDecl, chosen: &ParamValues, s: &Strings) -> Result<String, String> {
    let value = match chosen.get(&decl.name) {
        Some(v) => v.clone(),
        None => match &decl.default {
            Some(d) => d.clone(),
            // No default and nothing supplied: the parameter is required.
            None => return Err(fill(s.param_required, &[&decl.prompt(), &decl.name])),
        },
    };
    check(decl, &value, s)?;
    Ok(value)
}

/// The value every declared parameter takes for this run — what was chosen, or
/// the declaration's own default — as a substitution map.
///
/// Used wherever a *name in the script* may be written as `{{PARAM}}` rather
/// than spelled out (an `ENVS` clause's environments, for one). Only parameters
/// are in it: an `ENVS` clause is read before anything has run, so resolving it
/// against captures or assignments would mean a name whose meaning depends on
/// where the run had got to.
pub fn effective(decls: &[&ParamDecl], chosen: &ParamValues) -> ParamValues {
    decls
        .iter()
        .filter_map(|d| {
            let v = chosen.get(&d.name).or(d.default.as_ref())?;
            Some((d.name.clone(), v.clone()))
        })
        .collect()
}

/// One row of a run settings form: a declared parameter, the value this run
/// will use for it, and what (if anything) is wrong with that value.
///
/// Lives here rather than in either front-end because both build the same
/// form from the same declarations, and a question that is worded one way in
/// the terminal and another in the window is two different reports.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParamRow {
    /// The raw name — what `{{NAME}}` reads, what `--param NAME=…` sets and
    /// what the value is remembered under.
    pub name: String,
    /// What the row asks for, in words (the `LABEL`, or derived from the name).
    pub prompt: String,
    pub kind: ParamKind,
    pub value: String,
    /// Why this value wouldn't be accepted, if it wouldn't.
    pub problem: Option<String>,
}

/// Build the run settings form for `decls` against the values chosen so far.
///
/// A row's value falls back to the declaration's default, so a form opened
/// before anything is chosen already shows what the report would run with
/// today. The complaint is the same check that would stop the run, made while
/// there is still someone here to fix it.
pub fn rows(decls: &[&ParamDecl], chosen: &ParamValues, s: &Strings) -> Vec<ParamRow> {
    decls
        .iter()
        .map(|p| {
            let value = chosen
                .get(&p.name)
                .cloned()
                .or_else(|| p.default.clone())
                .unwrap_or_default();
            ParamRow {
                name: p.name.clone(),
                prompt: p.prompt(),
                kind: p.kind.clone(),
                problem: check(p, &value, s).err().or_else(|| {
                    (value.trim().is_empty() && p.default.is_none())
                        .then(|| s.param_row_required.to_string())
                }),
                value,
            }
        })
        .collect()
}

/// Whether `value` is acceptable for `decl`.
///
/// Shared with the front-ends so a run settings form can say "not a number"
/// while it is being typed, using the same rule that would stop the run.
pub fn check(decl: &ParamDecl, value: &str, s: &Strings) -> Result<(), String> {
    let trimmed = value.trim();
    // A value that still has to be interpolated can't be judged yet; the run
    // substitutes it and whatever it becomes stands.
    if trimmed.contains("{{") {
        return Ok(());
    }
    match &decl.kind {
        ParamKind::Choice(options) if !options.is_empty() => {
            if !options.iter().any(|o| o == trimmed) {
                return Err(fill(
                    s.param_not_a_choice,
                    &[&decl.prompt(), trimmed, &options.join(", ")],
                ));
            }
        }
        ParamKind::Number => {
            if trimmed.is_empty() || trimmed.parse::<f64>().is_err() {
                return Err(fill(s.param_not_a_number, &[&decl.prompt(), trimmed]));
            }
        }
        _ => {}
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::parser::parse_flow;

    fn decl(src: &str) -> ParamDecl {
        let flow = parse_flow(&format!("{src}\n")).expect("parses");
        match &flow.nodes[0] {
            super::super::flow::FlowNode::Param(p) => p.clone(),
            other => panic!("expected Param, got {other:?}"),
        }
    }

    /// A parameter with no default is the report saying "I can't run without
    /// being told this". Filling in an empty string would produce a report of
    /// plausible-looking rows built from a hole.
    #[test]
    fn a_required_parameter_stops_the_run_rather_than_running_empty() {
        let s = Strings::english();
        let d = decl("PARAM TEXT TICKET_REF");
        let err = value_for(&d, &ParamValues::new(), s).unwrap_err();
        assert!(err.contains("Ticket ref"), "{err}");

        let mut chosen = ParamValues::new();
        chosen.insert("TICKET_REF".into(), "T-1".into());
        assert_eq!(value_for(&d, &chosen, s).unwrap(), "T-1");
    }

    #[test]
    fn a_chosen_value_is_held_to_the_same_rules_as_a_default() {
        let s = Strings::english();
        let d = decl("PARAM CHOICE(\"v4.2\", \"v4.3\") VERSION = \"v4.3\"");
        let mut chosen = ParamValues::new();
        chosen.insert("VERSION".into(), "v9".into());
        let err = value_for(&d, &chosen, s).unwrap_err();
        assert!(err.contains("v9") && err.contains("v4.2, v4.3"), "{err}");

        let n = decl("PARAM NUMBER TRIES = \"3\"");
        chosen.insert("TRIES".into(), "lots".into());
        assert!(value_for(&n, &chosen, s).is_err());
        chosen.insert("TRIES".into(), "5".into());
        assert_eq!(value_for(&n, &chosen, s).unwrap(), "5");
    }

    /// The chosen value wins over the file's default; the file is never
    /// rewritten to match.
    #[test]
    fn a_supplied_value_beats_the_declared_default() {
        let s = Strings::english();
        let d = decl("PARAM ENV TARGET = \"staging\"");
        assert_eq!(value_for(&d, &ParamValues::new(), s).unwrap(), "staging");
        let mut chosen = ParamValues::new();
        chosen.insert("TARGET".into(), "prod".into());
        assert_eq!(value_for(&d, &chosen, s).unwrap(), "prod");
    }
}