use newt_core::config::{Crew, CrewBudgets};
use newt_core::interaction_form::{self, NO, YES};
use newt_interaction::InteractionDefinition;
const MAX_RETRIES: u8 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Step {
Planner,
Navigator,
Triage,
LoopProgram,
Test,
MaxAttempts,
MaxFilesTouched,
MaxLinesChanged,
ReviewGate,
Confirm,
Done,
}
impl Step {
fn next(self) -> Self {
match self {
Self::Planner => Self::Navigator,
Self::Navigator => Self::Triage,
Self::Triage => Self::LoopProgram,
Self::LoopProgram => Self::Test,
Self::Test => Self::MaxAttempts,
Self::MaxAttempts => Self::MaxFilesTouched,
Self::MaxFilesTouched => Self::MaxLinesChanged,
Self::MaxLinesChanged => Self::ReviewGate,
Self::ReviewGate => Self::Confirm,
Self::Confirm | Self::Done => Self::Done,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Fold {
Advanced,
Retry,
Declined,
}
#[derive(Debug, Clone)]
pub(crate) struct CrewForm {
crew: Crew,
budgets: CrewBudgets,
step: Step,
header: String,
label: String,
retry: Option<&'static str>,
refusals: u8,
}
impl CrewForm {
pub(crate) fn new(
starting: &Crew,
header: impl Into<String>,
label: impl Into<String>,
) -> Self {
Self {
crew: starting.clone(),
budgets: starting.budgets.clone().unwrap_or_default(),
step: Step::Planner,
header: header.into(),
label: label.into(),
retry: None,
refusals: 0,
}
}
pub(crate) fn prompt(&self) -> Option<InteractionDefinition> {
let field = |body: &str, hint: String| {
let body = if self.step == Step::Planner {
format!("{}\n\n{body}", self.header)
} else {
body.to_string()
};
Some(interaction_form::text_field(body, self.note(hint)))
};
match self.step {
Step::Done => None,
Step::Planner => field("planner loadout", keep_hint(&self.crew.planner)),
Step::Navigator => field(
"navigator loadout",
clear_hint(self.crew.navigator.as_deref()),
),
Step::Triage => field("triage loadout", clear_hint(self.crew.triage.as_deref())),
Step::LoopProgram => field(
"control loop",
clear_hint(self.crew.loop_program.as_deref()),
),
Step::Test => field("test command", clear_hint(self.crew.test.as_deref())),
Step::MaxAttempts => field("max attempts", number_hint(self.budgets.max_attempts)),
Step::MaxFilesTouched => field(
"max files touched",
number_hint(self.budgets.max_files_touched),
),
Step::MaxLinesChanged => field(
"max lines changed",
number_hint(self.budgets.max_lines_changed),
),
Step::ReviewGate => field(
"review-gate topics",
csv_hint(&self.budgets.require_human_review_on),
),
Step::Confirm => Some(self.write_confirm()),
}
}
pub(crate) fn answer(&mut self, answer: &str) -> Fold {
let answer = answer.trim();
let fold = match self.step {
Step::Done => Fold::Advanced,
Step::Planner => {
if !answer.is_empty() {
self.crew.planner = answer.to_string();
}
self.advance()
}
Step::Navigator => {
self.crew.navigator = optional(answer, self.crew.navigator.take());
self.advance()
}
Step::Triage => {
self.crew.triage = optional(answer, self.crew.triage.take());
self.advance()
}
Step::LoopProgram => {
self.crew.loop_program = optional(answer, self.crew.loop_program.take());
self.advance()
}
Step::Test => {
self.crew.test = optional(answer, self.crew.test.take());
self.advance()
}
Step::MaxAttempts => self.number(answer, |b| &mut b.max_attempts),
Step::MaxFilesTouched => self.number(answer, |b| &mut b.max_files_touched),
Step::MaxLinesChanged => self.number(answer, |b| &mut b.max_lines_changed),
Step::ReviewGate => {
self.budgets.require_human_review_on =
csv(answer, &self.budgets.require_human_review_on);
self.advance()
}
Step::Confirm => match self.resolve_confirm(answer) {
Some(true) => {
self.step = Step::Done;
Fold::Advanced
}
Some(false) => Fold::Declined,
None => self.refuse("answer y or n"),
},
};
if fold == Fold::Retry && self.refusals >= MAX_RETRIES {
return Fold::Declined;
}
fold
}
pub(crate) fn finish(&self) -> Crew {
let mut crew = self.crew.clone();
crew.budgets = (self.budgets != CrewBudgets::default()).then(|| self.budgets.clone());
crew
}
fn advance(&mut self) -> Fold {
self.step = self.step.next();
self.retry = None;
self.refusals = 0;
Fold::Advanced
}
fn refuse(&mut self, why: &'static str) -> Fold {
self.retry = Some(why);
self.refusals += 1;
Fold::Retry
}
fn number(&mut self, answer: &str, field: fn(&mut CrewBudgets) -> &mut Option<u32>) -> Fold {
match answer {
"" => {}
"-" => *field(&mut self.budgets) = None,
v => match v.parse::<u32>() {
Ok(n) => *field(&mut self.budgets) = Some(n),
Err(_) => return self.refuse("not a number — try again"),
},
}
self.advance()
}
fn preview(&self) -> String {
toml::to_string_pretty(&self.finish())
.unwrap_or_else(|e| format!("# (could not render preview: {e})"))
}
fn note(&self, hint: String) -> String {
match self.retry {
Some(why) => format!("{why}\n{hint}"),
None => hint,
}
}
fn resolve_confirm(&self, answer: &str) -> Option<bool> {
match interaction_form::resolve(&self.write_confirm(), answer)?.as_str() {
YES => Some(true),
NO => Some(false),
_ => None,
}
}
fn write_confirm(&self) -> InteractionDefinition {
interaction_form::confirm(
format!("# {}\n{}\nWrite it?", self.label, self.preview()),
self.retry.unwrap_or_default(),
"yes, write it",
"no, leave it alone",
)
}
}
fn optional(answer: &str, cur: Option<String>) -> Option<String> {
match answer {
"" => cur,
"-" => None,
v => Some(v.to_string()),
}
}
fn csv(answer: &str, cur: &[String]) -> Vec<String> {
match answer {
"" => cur.to_vec(),
"-" => Vec::new(),
v => v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
}
}
fn keep_hint(cur: &str) -> String {
format!("[{cur}] — Enter keeps it")
}
fn clear_hint(cur: Option<&str>) -> String {
format!(
"[{}] — Enter keeps it, '-' clears it",
cur.unwrap_or("none")
)
}
fn number_hint(cur: Option<u32>) -> String {
clear_hint(cur.map(|n| n.to_string()).as_deref())
}
fn csv_hint(cur: &[String]) -> String {
let shown = (!cur.is_empty()).then(|| cur.join(","));
format!(
"[{}] — comma-separated; Enter keeps it, '-' clears it",
shown.as_deref().unwrap_or("none")
)
}
pub(crate) fn name_prompt(body: String) -> InteractionDefinition {
interaction_form::text_field(body, String::new())
}
#[cfg(test)]
mod d1a {
use super::*;
use newt_core::markup::plain;
use newt_interaction::ControlKind;
const LABEL: &str = "crews/home.toml (crew 'home')";
fn form_for(starting: &Crew) -> CrewForm {
CrewForm::new(starting, "Creating crew 'home'", LABEL)
}
fn form() -> CrewForm {
form_for(&Crew::default())
}
fn walk(form: &mut CrewForm, answers: &[&str]) -> Vec<Fold> {
answers
.iter()
.map(|a| {
assert!(form.prompt().is_some(), "form finished before {a:?}");
form.answer(a)
})
.collect()
}
const ALL_FIELDS: &[&str] = &[
"planner",
"navigator",
"triage",
"patch-revise",
"just check",
"4",
"5",
"600",
"auth, crypto",
];
fn rendered(form: &CrewForm) -> String {
plain::render(&form.prompt().expect("a prompt"))
}
#[test]
fn every_field_lands_where_the_console_form_put_it() {
let mut f = form();
walk(&mut f, ALL_FIELDS);
walk(&mut f, &["y"]);
assert!(f.prompt().is_none(), "the confirm ends the form");
let crew = f.finish();
assert_eq!(crew.planner, "planner");
assert_eq!(crew.navigator.as_deref(), Some("navigator"));
assert_eq!(crew.triage.as_deref(), Some("triage"));
assert_eq!(crew.loop_program.as_deref(), Some("patch-revise"));
assert_eq!(crew.test.as_deref(), Some("just check"));
let b = crew.budgets.expect("budgets were answered");
assert_eq!(b.max_attempts, Some(4));
assert_eq!(b.max_files_touched, Some(5));
assert_eq!(b.max_lines_changed, Some(600));
assert_eq!(b.require_human_review_on, vec!["auth", "crypto"]);
}
#[test]
fn blank_keeps_the_current_value_and_dash_clears_it() {
let existing = Crew {
planner: "planner".into(),
navigator: Some("navigator".into()),
triage: Some("triage".into()),
loop_program: Some("patch-revise".into()),
role_timeout_secs: None,
test: Some("just check".into()),
budgets: Some(CrewBudgets {
max_attempts: Some(3),
require_human_review_on: vec!["auth".into()],
..Default::default()
}),
};
let mut f = form_for(&existing);
walk(&mut f, &["", "-", "", "", "", "5", "", "", "-"]);
walk(&mut f, &["y"]);
let crew = f.finish();
assert_eq!(crew.planner, "planner", "blank kept the required field");
assert_eq!(crew.navigator, None, "'-' cleared the navigator");
assert_eq!(crew.triage.as_deref(), Some("triage"));
assert_eq!(crew.loop_program.as_deref(), Some("patch-revise"));
assert_eq!(crew.test.as_deref(), Some("just check"));
let b = crew.budgets.expect("attempts survived");
assert_eq!(b.max_attempts, Some(5));
assert!(b.require_human_review_on.is_empty(), "'-' cleared the list");
}
#[test]
fn an_all_empty_budget_block_collapses_to_none() {
let mut f = form();
walk(&mut f, &["planner", "", "", "", "", "", "", "", ""]);
walk(&mut f, &["y"]);
assert_eq!(
f.finish().budgets,
None,
"an untouched budget block stays out of the file"
);
}
#[test]
fn a_bad_number_reasks_the_same_field_and_says_why() {
let mut f = form();
walk(&mut f, &["planner", "", "", "", ""]);
let before = rendered(&f);
assert_eq!(f.answer("not-a-number"), Fold::Retry);
let after = rendered(&f);
assert!(
after.contains("max attempts"),
"the SAME field is re-asked, not the next one: {after}"
);
assert!(
after.contains("not a number"),
"the reason travels in the definition, not in a side channel: {after}"
);
assert!(
!before.contains("not a number"),
"the first ask carried no complaint"
);
assert_eq!(f.answer("7"), Fold::Advanced);
assert!(
rendered(&f).contains("max files touched"),
"advanced past the field it was stuck on"
);
}
#[test]
fn the_write_confirm_refuses_an_empty_answer() {
let mut f = form();
walk(&mut f, ALL_FIELDS);
assert_eq!(f.answer(""), Fold::Retry, "blank decides nothing");
assert!(
f.prompt().is_some(),
"the confirm is still pending — nothing was written"
);
assert!(
rendered(&f).contains("answer y or n"),
"and it says why: {}",
rendered(&f)
);
}
#[test]
fn the_write_confirm_accepts_y_and_its_aliases() {
for yes in ["y", "Y", "yes"] {
let mut f = form();
walk(&mut f, ALL_FIELDS);
assert_eq!(f.answer(yes), Fold::Advanced, "{yes:?} confirms");
assert!(f.prompt().is_none(), "{yes:?} finished the form");
}
for no in ["n", "N", "no"] {
let mut f = form();
walk(&mut f, ALL_FIELDS);
assert_eq!(f.answer(no), Fold::Declined, "{no:?} declines");
}
let mut f = form();
walk(&mut f, ALL_FIELDS);
assert_eq!(f.answer("maybe"), Fold::Retry);
}
#[test]
fn the_confirm_resolves_through_the_one_resolver() {
let definition = form_for(&Crew::default()).write_confirm();
let ControlKind::Choice { options } = &definition.controls[0].kind else {
panic!("the confirm is a choice control");
};
use newt_interaction::binding::resolve_typed;
assert_eq!(resolve_typed(&options[..], "y").unwrap().as_str(), "yes");
assert_eq!(resolve_typed(&options[..], "no").unwrap().as_str(), "no");
assert!(resolve_typed(&options[..], "").is_none());
assert!(resolve_typed(&options[..], "sure").is_none());
}
#[test]
fn repeated_unusable_answers_give_up_rather_than_loop() {
let mut f = form();
walk(&mut f, ALL_FIELDS);
let folds: Vec<Fold> = (0..MAX_RETRIES + 1).map(|_| f.answer("")).collect();
assert_eq!(
folds.last(),
Some(&Fold::Declined),
"gave up after {MAX_RETRIES} refusals: {folds:?}"
);
assert!(
folds[..usize::from(MAX_RETRIES) - 1]
.iter()
.all(|f| *f == Fold::Retry),
"and re-asked before giving up: {folds:?}"
);
}
#[test]
fn the_retry_cap_resets_when_a_field_is_answered() {
let mut f = form();
walk(&mut f, &["planner", "", "", "", ""]);
for _ in 0..3 {
assert_eq!(f.answer("nope"), Fold::Retry);
assert_eq!(f.answer("1"), Fold::Advanced);
}
assert!(f.prompt().is_some(), "still asking, not given up");
}
#[test]
fn the_confirm_previews_the_exact_bytes_a_write_would_emit() {
let mut f = form();
walk(&mut f, ALL_FIELDS);
let shown = rendered(&f);
let bytes = toml::to_string_pretty(&f.finish()).expect("a crew serializes");
assert!(
shown.contains(&bytes),
"the confirm shows the file verbatim.\nshown:\n{shown}\nbytes:\n{bytes}"
);
assert!(shown.contains(LABEL), "and names the destination: {shown}");
}
#[test]
fn the_confirm_renders_no_default() {
let shown = plain::render(&form_for(&Crew::default()).write_confirm());
assert!(
shown.contains("[y]es") && shown.contains("[n]o"),
"both options are offered: {shown}"
);
assert!(
!shown.contains("[Y/n]") && !shown.contains("[y/N]"),
"no default is rendered: {shown}"
);
}
#[test]
fn answers_are_trimmed() {
let mut f = form();
walk(&mut f, &[" planner ", "", "", "", "", " 4 ", "", "", ""]);
assert_eq!(f.answer(" y "), Fold::Advanced);
let crew = f.finish();
assert_eq!(crew.planner, "planner");
assert_eq!(crew.budgets.expect("attempts").max_attempts, Some(4));
}
#[test]
fn every_field_carries_its_own_prompt_and_current_value() {
let existing = Crew {
planner: "pl".into(),
navigator: Some("nav".into()),
..Default::default()
};
let mut f = form_for(&existing);
let mut seen = Vec::new();
for answer in ALL_FIELDS {
seen.push(rendered(&f));
f.answer(answer);
}
seen.push(rendered(&f));
assert_eq!(seen.len(), 10, "nine fields and a confirm");
assert!(seen[0].contains("Creating crew 'home'"), "{}", seen[0]);
assert!(seen[0].contains("planner loadout"), "{}", seen[0]);
assert!(
seen[0].contains("[pl]"),
"shows the current value: {}",
seen[0]
);
assert!(seen[1].contains("[nav]"), "{}", seen[1]);
assert!(
seen[1].contains("'-' clears it"),
"an optional field says so: {}",
seen[1]
);
assert!(
!seen[1].contains("Creating crew"),
"the header is shown once, not on every field: {}",
seen[1]
);
assert!(seen[8].contains("comma-separated"), "{}", seen[8]);
assert!(seen[9].contains("Write it?"), "{}", seen[9]);
}
}