use newt_interaction::{
ChoiceOption, Control, ControlId, ControlKind, InteractionDefinition, InteractionKind,
OptionId, Requirement, SemanticRole,
};
pub const FIELD_CONTROL: &str = "field";
pub const YES: &str = "yes";
pub const NO: &str = "no";
fn field(kind: ControlKind) -> Control {
Control {
id: ControlId::new(FIELD_CONTROL)
.expect("`field` is a valid control id (non-empty, [A-Za-z0-9_-]); it is a const"),
kind,
label: String::new(),
requirement: Requirement::Required,
}
}
#[must_use]
pub fn text_field(body: impl Into<String>, hint: impl Into<String>) -> InteractionDefinition {
let hint = hint.into();
InteractionDefinition {
note: (!hint.is_empty()).then_some(hint),
..InteractionDefinition::new(
InteractionKind::Prompt,
body,
vec![field(ControlKind::Text)],
)
}
}
#[must_use]
pub fn secret_field(label: impl Into<String>) -> InteractionDefinition {
InteractionDefinition::new(
InteractionKind::Prompt,
String::new(),
vec![Control {
label: label.into(),
..field(ControlKind::Secret)
}],
)
}
#[must_use]
pub fn confirm(
body: impl Into<String>,
hint: impl Into<String>,
yes_label: &str,
no_label: &str,
) -> InteractionDefinition {
let option = |wire: &str, role, key: &str, text: &str, alias: &str| ChoiceOption {
id: OptionId::new(wire).expect("`yes`/`no` are valid option ids; they are consts"),
role,
label: text.to_string(),
key: key.to_string(),
aliases: vec![alias.to_string()],
};
let hint = hint.into();
InteractionDefinition {
note: (!hint.is_empty()).then_some(hint),
..InteractionDefinition::new(
InteractionKind::Confirm,
body,
vec![field(ControlKind::Choice {
options: vec![
option(YES, SemanticRole::Allow, "y", yes_label, "Y"),
option(NO, SemanticRole::Cancel, "n", no_label, "N"),
],
})],
)
}
}
#[must_use]
pub fn menu(
body: impl Into<String>,
hint: impl Into<String>,
entries: &[(&str, &str)],
) -> InteractionDefinition {
let options = entries
.iter()
.filter_map(|(key, text)| {
Some(ChoiceOption {
id: OptionId::new(*key).ok()?,
role: SemanticRole::Value,
label: format!("{key} {text}"),
key: (*key).to_string(),
aliases: Vec::new(),
})
})
.collect();
let hint = hint.into();
InteractionDefinition {
note: (!hint.is_empty()).then_some(hint),
..InteractionDefinition::new(
InteractionKind::Choice,
body,
vec![field(ControlKind::Choice { options })],
)
}
}
#[must_use]
pub fn resolve(definition: &InteractionDefinition, answer: &str) -> Option<OptionId> {
definition
.controls
.iter()
.find_map(|control| match &control.kind {
ControlKind::Choice { options } => {
newt_interaction::binding::resolve_typed(options, answer)
}
_ => None,
})
}
#[cfg(test)]
mod d1b2 {
use super::{confirm, menu, resolve, secret_field, text_field, FIELD_CONTROL, NO, YES};
use crate::markup::plain;
use newt_interaction::{ControlKind, InteractionKind, Requirement};
#[test]
fn a_text_field_shows_its_question_and_its_hint() {
let d = text_field("Ollama host", "[http://127.0.0.1:11434] — Enter keeps it");
assert_eq!(
plain::render(&d),
"Ollama host\n[http://127.0.0.1:11434] — Enter keeps it"
);
assert_eq!(d.kind, InteractionKind::Prompt);
assert!(matches!(d.controls[0].kind, ControlKind::Text));
assert_eq!(d.controls[0].id.as_str(), FIELD_CONTROL);
}
#[test]
fn an_empty_hint_adds_no_line() {
assert_eq!(
plain::render(&text_field("Endpoint URL", "")),
"Endpoint URL"
);
assert!(text_field("Endpoint URL", "").note.is_none());
}
#[test]
fn a_confirm_advertises_no_default() {
let shown = plain::render(&confirm("Use it?", "", "yes, use it", "no, paste a key"));
assert!(shown.contains("[y]es, use it"), "{shown}");
assert!(shown.contains("[n]o, paste a key"), "{shown}");
for advertised in ["[Y/n]", "[y/N]", "[Y]", "[N]"] {
assert!(!shown.contains(advertised), "{advertised} in {shown}");
}
}
#[test]
fn a_confirm_resolves_yes_and_no_and_refuses_the_rest() {
let d = confirm("Use it?", "", "yes, use it", "no, thanks");
for (input, want) in [("y", YES), ("Y", YES), ("yes", YES), ("n", NO), ("no", NO)] {
assert_eq!(
resolve(&d, input)
.as_ref()
.map(newt_interaction::OptionId::as_str),
Some(want),
"{input:?}"
);
}
for input in ["", " ", "maybe", "sure", "1"] {
assert!(resolve(&d, input).is_none(), "{input:?} must not resolve");
}
}
#[test]
fn resolve_finds_nothing_in_a_definition_with_no_choice() {
assert!(resolve(&text_field("Endpoint URL", ""), "y").is_none());
assert!(resolve(&secret_field("API key"), "y").is_none());
assert!(resolve(&confirm("Use it?", "", "yes", "no"), "y").is_some());
}
#[test]
fn a_menu_shows_every_entry_with_its_key_bracketed() {
let d = menu(
"Where does your model run?",
"",
&[
("1", "Ollama on this machine (http://127.0.0.1:11434)"),
("2", "Custom host or URL"),
("3", "A hosted provider"),
],
);
assert_eq!(
plain::render(&d),
"Where does your model run?\n\
[1] Ollama on this machine (http://127.0.0.1:11434)\n\
[2] Custom host or URL\n\
[3] A hosted provider"
);
assert_eq!(d.kind, InteractionKind::Choice);
}
#[test]
fn menu_keys_are_the_callers_and_resolve_exactly() {
let entries: Vec<(String, String)> = (0..12)
.map(|i| (i.to_string(), format!("entry {i}")))
.collect();
let refs: Vec<(&str, &str)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let d = menu("pick", "", &refs);
assert_eq!(plain::render(&d).lines().count(), 13, "body plus 12");
for key in ["0", "1", "9", "10", "11"] {
assert_eq!(
resolve(&d, key)
.as_ref()
.map(newt_interaction::OptionId::as_str),
Some(key),
"{key} must resolve to exactly its own entry"
);
}
for miss in ["", "12", "x", " "] {
assert!(resolve(&d, miss).is_none(), "{miss:?}");
}
}
#[test]
fn every_menu_entry_reaches_the_operator() {
let entries = [("1", "alpha"), ("2", "beta"), ("3", "gamma")];
let rendered = plain::render(&menu("pick", "", &entries));
for (key, text) in entries {
assert!(
rendered.contains(&format!("[{key}] {text}")),
"{key}/{text} missing from {rendered}"
);
}
assert_eq!(rendered.lines().count(), entries.len() + 1);
assert_eq!(plain::render(&menu("pick", "", &[])), "pick");
}
#[test]
fn a_secret_field_shows_its_label_and_no_value() {
let d = secret_field("API key");
assert_eq!(plain::render(&d), "API key: (secret, not echoed)");
assert!(matches!(d.controls[0].kind, ControlKind::Secret));
assert_eq!(d.controls[0].requirement, Requirement::Required);
assert!(d.features.is_empty());
}
}