use std::io::{self, Write};
use std::path::{Path, PathBuf};
use newt_core::config::{Crew, CrewBudgets};
use newt_core::Config;
pub trait Console {
fn ask(&mut self, prompt: &str) -> io::Result<String>;
fn say(&mut self, line: &str);
}
struct StdinConsole;
impl Console for StdinConsole {
fn ask(&mut self, prompt: &str) -> io::Result<String> {
print!("{prompt}");
io::stdout().flush()?;
let mut buf = String::new();
if io::stdin().read_line(&mut buf)? == 0 {
return Ok(String::new());
}
Ok(buf.trim().to_string())
}
fn say(&mut self, line: &str) {
println!("{line}");
}
}
fn crews_dir() -> PathBuf {
Config::user_config_path()
.map(|p| p.with_file_name("crews"))
.unwrap_or_else(|| PathBuf::from("crews"))
}
pub fn run_edit(name: Option<&str>, _color: bool) -> anyhow::Result<()> {
let cfg = Config::resolve().unwrap_or_default();
let mut console = StdinConsole;
let dir = crews_dir();
edit_and_save(&mut console, &cfg, name, &dir)
}
pub fn edit_and_save(
console: &mut dyn Console,
cfg: &Config,
name: Option<&str>,
dir: &Path,
) -> anyhow::Result<()> {
let Some(name) = resolve_target_name(console, cfg, name)? else {
console.say("No crew name given — nothing to edit.");
return Ok(());
};
let starting = cfg.crews.get(&name).cloned().unwrap_or_default();
let is_new = !cfg.crews.contains_key(&name);
let path = dir.join(format!("{name}.toml"));
console.say(&format!(
"{} crew '{}' → {}",
if is_new { "Creating" } else { "Editing" },
name,
path.display()
));
let loadouts: Vec<&str> = cfg.loadouts.keys().map(String::as_str).collect();
if loadouts.is_empty() {
console
.say(" (no [loadouts.*] defined yet — role names won't resolve until you add some)");
} else {
console.say(&format!(" known loadouts: {}", loadouts.join(", ")));
}
console.say(" Enter keeps the [current] value; '-' clears an optional field.");
let crew = edit_fields(console, &starting)?;
let preview = toml::to_string_pretty(&crew)
.unwrap_or_else(|e| format!("# (could not render preview: {e})"));
console.say(&format!(
"\n# {} (crew '{name}')\n{preview}",
path.display()
));
let ans = console.ask(&format!("Write {}? [Y/n] ", path.display()))?;
if !is_yes(&ans, true) {
console.say("Aborted. Nothing written.");
return Ok(());
}
let written = save_crew(dir, &name, &crew)?;
console.say(&format!("Wrote {}.", written.display()));
if let Err(e) = crew.validate(cfg) {
console.say(&format!("Note: {e}"));
console.say(" (saved anyway — add the missing loadout before `newt crew`.)");
}
Ok(())
}
fn resolve_target_name(
console: &mut dyn Console,
cfg: &Config,
explicit: Option<&str>,
) -> io::Result<Option<String>> {
if let Some(n) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
return Ok(Some(n.to_string()));
}
let names: Vec<&str> = cfg.crews.keys().map(String::as_str).collect();
match names.as_slice() {
[] => {
let ans = console.ask("New crew name: ")?;
Ok((!ans.is_empty()).then_some(ans))
}
[only] => Ok(Some((*only).to_string())),
many => {
console.say(&format!("Crews: {}", many.join(", ")));
let ans = console.ask("Edit which crew (or type a new name)? ")?;
Ok((!ans.is_empty()).then_some(ans))
}
}
}
fn edit_fields(console: &mut dyn Console, cur: &Crew) -> io::Result<Crew> {
let planner = ask_required(console, "planner loadout", &cur.planner)?;
let navigator = ask_optional(console, "navigator loadout", cur.navigator.as_deref())?;
let triage = ask_optional(console, "triage loadout", cur.triage.as_deref())?;
let loop_program = ask_optional(console, "control loop", cur.loop_program.as_deref())?;
let test = ask_optional(console, "test command", cur.test.as_deref())?;
let cb = cur.budgets.clone().unwrap_or_default();
let budgets = CrewBudgets {
max_attempts: ask_optional_u32(console, "max attempts", cb.max_attempts)?,
max_files_touched: ask_optional_u32(console, "max files touched", cb.max_files_touched)?,
max_lines_changed: ask_optional_u32(console, "max lines changed", cb.max_lines_changed)?,
require_human_review_on: ask_csv(
console,
"review-gate topics (comma-sep)",
&cb.require_human_review_on,
)?,
};
let budgets = (budgets != CrewBudgets::default()).then_some(budgets);
Ok(Crew {
planner,
navigator,
triage,
loop_program,
test,
budgets,
role_timeout_secs: None,
})
}
fn ask_required(console: &mut dyn Console, label: &str, cur: &str) -> io::Result<String> {
let ans = console.ask(&format!("{label} [{cur}]: "))?;
Ok(if ans.is_empty() { cur.to_string() } else { ans })
}
fn ask_optional(
console: &mut dyn Console,
label: &str,
cur: Option<&str>,
) -> io::Result<Option<String>> {
let shown = cur.unwrap_or("none");
let ans = console.ask(&format!("{label} (blank=keep, '-'=none) [{shown}]: "))?;
Ok(match ans.as_str() {
"" => cur.map(str::to_string),
"-" => None,
v => Some(v.to_string()),
})
}
fn ask_optional_u32(
console: &mut dyn Console,
label: &str,
cur: Option<u32>,
) -> io::Result<Option<u32>> {
let shown = cur.map_or_else(|| "none".to_string(), |n| n.to_string());
loop {
let ans = console.ask(&format!("{label} (blank=keep, '-'=none) [{shown}]: "))?;
match ans.as_str() {
"" => return Ok(cur),
"-" => return Ok(None),
v => match v.parse::<u32>() {
Ok(n) => return Ok(Some(n)),
Err(_) => console.say(" not a number — try again."),
},
}
}
}
fn ask_csv(console: &mut dyn Console, label: &str, cur: &[String]) -> io::Result<Vec<String>> {
let shown = if cur.is_empty() {
"none".to_string()
} else {
cur.join(",")
};
let ans = console.ask(&format!("{label} (blank=keep, '-'=none) [{shown}]: "))?;
Ok(match ans.as_str() {
"" => cur.to_vec(),
"-" => Vec::new(),
v => v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
})
}
fn save_crew(dir: &Path, name: &str, crew: &Crew) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{name}.toml"));
std::fs::write(&path, toml::to_string_pretty(crew)?)?;
Ok(path)
}
fn is_yes(input: &str, default: bool) -> bool {
match input.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
_ => default,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
struct ScriptedConsole {
answers: std::collections::VecDeque<String>,
transcript: Vec<String>,
}
impl ScriptedConsole {
fn new(answers: &[&str]) -> Self {
Self {
answers: answers.iter().map(|s| s.to_string()).collect(),
transcript: Vec::new(),
}
}
}
impl Console for ScriptedConsole {
fn ask(&mut self, prompt: &str) -> io::Result<String> {
self.transcript.push(prompt.to_string());
Ok(self.answers.pop_front().unwrap_or_default())
}
fn say(&mut self, line: &str) {
self.transcript.push(line.to_string());
}
}
fn cfg_with(loadouts: &[&str], crews: &[(&str, Crew)]) -> Config {
Config {
loadouts: loadouts
.iter()
.map(|n| ((*n).to_string(), newt_core::Loadout::default()))
.collect(),
crews: crews
.iter()
.map(|(n, cr)| ((*n).to_string(), cr.clone()))
.collect(),
..Default::default()
}
}
fn read_saved(dir: &Path, name: &str) -> Crew {
let body = std::fs::read_to_string(dir.join(format!("{name}.toml"))).unwrap();
toml::from_str::<Crew>(&body).unwrap()
}
#[test]
fn is_yes_handles_defaults_and_explicit() {
assert!(is_yes("", true));
assert!(!is_yes("", false));
assert!(is_yes("y", false));
assert!(is_yes("YES", false));
assert!(!is_yes("n", true));
assert!(!is_yes("garbage", false));
}
#[serial_test::serial(real_fs)]
#[test]
fn new_crew_is_built_field_by_field_and_saved() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with(&["planner", "navigator", "triage"], &[]);
let mut console = ScriptedConsole::new(&[
"planner", "navigator", "triage", "", "just check", "4", "", "", "auth,crypto", "y", ]);
edit_and_save(&mut console, &cfg, Some("home"), dir.path()).unwrap();
let saved = read_saved(dir.path(), "home");
assert_eq!(saved.planner, "planner");
assert_eq!(saved.navigator.as_deref(), Some("navigator"));
assert_eq!(saved.triage.as_deref(), Some("triage"));
assert_eq!(saved.loop_program, None);
assert_eq!(saved.test.as_deref(), Some("just check"));
let b = saved.budgets.unwrap();
assert_eq!(b.max_attempts, Some(4));
assert_eq!(b.max_files_touched, None);
assert_eq!(b.require_human_review_on, vec!["auth", "crypto"]);
}
#[serial_test::serial(real_fs)]
#[test]
fn editing_keeps_current_values_on_blank_and_clears_on_dash() {
let dir = tempfile::tempdir().unwrap();
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),
..Default::default()
}),
};
let cfg = cfg_with(&["planner", "navigator", "triage"], &[("home", existing)]);
let mut console = ScriptedConsole::new(&[
"", "-", "", "", "", "5", "", "", "", "", ]);
edit_and_save(&mut console, &cfg, Some("home"), dir.path()).unwrap();
let saved = read_saved(dir.path(), "home");
assert_eq!(saved.planner, "planner");
assert_eq!(saved.navigator, None, "'-' cleared the navigator");
assert_eq!(saved.triage.as_deref(), Some("triage"));
assert_eq!(saved.loop_program.as_deref(), Some("patch-revise"));
assert_eq!(saved.budgets.unwrap().max_attempts, Some(5));
}
#[serial_test::serial(real_fs)]
#[test]
fn abort_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with(&["planner"], &[]);
let mut console = ScriptedConsole::new(&[
"planner", "", "", "", "", "", "", "", "", "n", ]);
edit_and_save(&mut console, &cfg, Some("home"), dir.path()).unwrap();
assert!(!dir.path().join("home.toml").exists());
assert!(console
.transcript
.iter()
.any(|l| l.contains("Nothing written")));
}
#[serial_test::serial(real_fs)]
#[test]
fn unknown_loadout_is_flagged_but_still_saved() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with(&["planner"], &[]); let mut console = ScriptedConsole::new(&[
"ghost", "", "", "", "", "", "", "", "", "y",
]);
edit_and_save(&mut console, &cfg, Some("home"), dir.path()).unwrap();
assert!(dir.path().join("home.toml").exists(), "saved anyway");
assert!(
console.transcript.iter().any(|l| l.starts_with("Note:")),
"the unknown-loadout note is surfaced"
);
}
#[serial_test::serial(real_fs)]
#[test]
fn invalid_number_reprompts_then_accepts() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with(&["planner"], &[]);
let mut console = ScriptedConsole::new(&[
"planner",
"",
"",
"",
"", "not-a-number", "7", "",
"",
"",
"y",
]);
edit_and_save(&mut console, &cfg, Some("home"), dir.path()).unwrap();
let saved = read_saved(dir.path(), "home");
assert_eq!(saved.budgets.unwrap().max_attempts, Some(7));
assert!(console
.transcript
.iter()
.any(|l| l.contains("not a number")));
}
#[test]
fn no_name_with_sole_crew_edits_that_one() {
let mut console = ScriptedConsole::new(&[]);
let cfg = cfg_with(&[], &[("only", Crew::default())]);
assert_eq!(
resolve_target_name(&mut console, &cfg, None)
.unwrap()
.as_deref(),
Some("only")
);
}
#[test]
fn no_name_no_crews_prompts_for_a_name() {
let mut console = ScriptedConsole::new(&["fresh"]);
let cfg = Config {
crews: BTreeMap::new(),
..Default::default()
};
assert_eq!(
resolve_target_name(&mut console, &cfg, None)
.unwrap()
.as_deref(),
Some("fresh")
);
}
}