use crate::discover::{Found, NoAdapter};
use crate::workspace::Workspace;
use std::io::{BufRead, IsTerminal, Write};
pub enum Choice {
Wrote,
Declined,
NotAsked,
}
pub fn profiles(
workspace: &Workspace,
problem: &NoAdapter,
input: &mut impl BufRead,
output: &mut impl Write,
interactive: bool,
) -> std::io::Result<Choice> {
let ready: Vec<&Found> = problem.found.iter().filter(|f| f.ready()).collect();
if !interactive || ready.is_empty() {
return Ok(Choice::NotAsked);
}
writeln!(output, "\n{}", problem.said)?;
writeln!(
output,
"\nInstalled here, with no profile in this workspace:"
)?;
for (n, f) in ready.iter().enumerate() {
writeln!(output, " {} {}", n + 1, f.id)?;
}
write!(
output,
"\nWrite profiles and run? [a] all, or numbers like 1,3 — anything else stops: "
)?;
output.flush()?;
let mut answer = String::new();
if input.read_line(&mut answer)? == 0 {
return Ok(Choice::Declined);
}
let picked = parse(answer.trim(), ready.len());
if picked.is_empty() {
return Ok(Choice::Declined);
}
for i in picked {
let id = &ready[i].id;
crate::init::write_profile(workspace, id)?;
writeln!(output, "wrote adapters/{id}.toml")?;
}
Ok(Choice::Wrote)
}
fn parse(answer: &str, count: usize) -> Vec<usize> {
if answer.eq_ignore_ascii_case("a") || answer.eq_ignore_ascii_case("all") {
return (0..count).collect();
}
let mut picked: Vec<usize> = Vec::new();
for part in answer.split(',') {
match part.trim().parse::<usize>() {
Ok(n) if n >= 1 && n <= count => {
if !picked.contains(&(n - 1)) {
picked.push(n - 1);
}
}
_ => return Vec::new(),
}
}
picked
}
pub fn at_a_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
#[cfg(test)]
mod tests {
use super::*;
fn found(id: &str, ready: bool) -> Found {
Found {
id: id.to_string(),
availability: if ready {
ostraka_adapter::Availability::Ready {
version: Some("1".into()),
}
} else {
ostraka_adapter::Availability::NotFound {
command: id.to_string(),
}
},
}
}
#[test]
fn every_answer_that_is_not_a_choice_stops() {
for answer in ["", "n", "no", "y", "yes", "0", "9", "1,", "1,x", "-1", " "] {
assert!(
parse(answer, 3).is_empty(),
"{answer:?} was read as a choice"
);
}
}
#[test]
fn a_list_of_numbers_is_those_numbers_and_a_is_all_of_them() {
assert_eq!(parse("a", 3), vec![0, 1, 2]);
assert_eq!(parse("ALL", 2), vec![0, 1]);
assert_eq!(parse("1,3", 3), vec![0, 2]);
assert_eq!(parse(" 2 , 1 ", 3), vec![1, 0], "order is the answer's");
assert_eq!(parse("2,2", 3), vec![1], "a repeat is not two profiles");
}
#[test]
fn nobody_is_asked_without_a_terminal() {
let dir = std::env::temp_dir().join(format!("ostraka-offer-{}", std::process::id()));
let workspace = Workspace::at(&dir);
let problem = NoAdapter {
said: "no usable adapter profile".into(),
found: vec![found("claude-code", true)],
};
let mut input = std::io::Cursor::new(b"a\n".to_vec());
let mut output: Vec<u8> = Vec::new();
let choice = profiles(&workspace, &problem, &mut input, &mut output, false).expect("asks");
assert!(matches!(choice, Choice::NotAsked));
assert!(output.is_empty(), "a question was written to nobody");
}
#[test]
fn nothing_installed_is_nothing_to_offer() {
let dir = std::env::temp_dir().join(format!("ostraka-offer-none-{}", std::process::id()));
let workspace = Workspace::at(&dir);
let problem = NoAdapter {
said: "no usable adapter profile".into(),
found: vec![found("codex", false)],
};
let mut input = std::io::Cursor::new(b"a\n".to_vec());
let mut output: Vec<u8> = Vec::new();
let choice = profiles(&workspace, &problem, &mut input, &mut output, true).expect("asks");
assert!(matches!(choice, Choice::NotAsked));
assert!(output.is_empty());
}
#[test]
fn a_stream_that_ends_without_an_answer_is_not_a_yes() {
let dir = std::env::temp_dir().join(format!("ostraka-offer-eof-{}", std::process::id()));
let workspace = Workspace::at(&dir);
let problem = NoAdapter {
said: "no usable adapter profile".into(),
found: vec![found("claude-code", true)],
};
let mut input = std::io::Cursor::new(Vec::new());
let mut output: Vec<u8> = Vec::new();
let choice = profiles(&workspace, &problem, &mut input, &mut output, true).expect("asks");
assert!(matches!(choice, Choice::Declined));
}
#[test]
fn accepting_writes_exactly_what_was_chosen() {
let dir = std::env::temp_dir().join(format!("ostraka-offer-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let workspace = Workspace::at(&dir);
let problem = NoAdapter {
said: "no adapter profiles found".into(),
found: vec![
found("agy", true),
found("claude-code", true),
found("codex", true),
],
};
let mut input = std::io::Cursor::new(b"1,3\n".to_vec());
let mut output: Vec<u8> = Vec::new();
let choice = profiles(&workspace, &problem, &mut input, &mut output, true).expect("asks");
assert!(matches!(choice, Choice::Wrote));
let adapters = workspace.adapters();
assert!(
adapters.join("agy.toml").is_file(),
"the first was not written"
);
assert!(
adapters.join("codex.toml").is_file(),
"the third was not written"
);
assert!(
!adapters.join("claude-code.toml").exists(),
"a profile nobody chose was written"
);
let written = std::fs::read_to_string(adapters.join("agy.toml")).expect("readable");
let shipped = crate::init::TEMPLATES
.iter()
.find(|(name, _)| *name == "agy.toml")
.map(|(_, text)| *text)
.expect("shipped");
assert_eq!(written, shipped);
let _ = std::fs::remove_dir_all(&dir);
}
}