use std::collections::HashMap;
use leviath_mcp::MCPServerConfig;
use tokio::sync::mpsc;
use super::catalog::{self, Credential, Provider};
use super::import::{self, Candidate};
use super::plan::SetupPlan;
use super::verify::Outcome;
use crate::bundled::{AgentAction, BundledAgent};
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Step {
Welcome,
Providers,
ProviderDetail,
Defaults,
Limits,
Agents,
Mcp,
Review,
}
impl Step {
pub const ALL: [Step; 8] = [
Step::Welcome,
Step::Providers,
Step::ProviderDetail,
Step::Defaults,
Step::Limits,
Step::Agents,
Step::Mcp,
Step::Review,
];
pub fn title(self) -> &'static str {
match self {
Step::Welcome => "Welcome",
Step::Providers => "Providers",
Step::ProviderDetail => "Credentials",
Step::Defaults => "Defaults",
Step::Limits => "Limits",
Step::Agents => "Agents",
Step::Mcp => "MCP servers",
Step::Review => "Review",
}
}
pub fn index(self) -> usize {
Self::ALL
.iter()
.position(|s| *s == self)
.expect("every step is in ALL")
}
}
#[derive(Debug, Clone)]
pub struct ProviderRow {
pub provider: Provider,
pub selected: bool,
pub value: String,
pub from_env: Option<&'static str>,
pub effort: usize,
pub outcome: Outcome,
pub checking: bool,
}
impl ProviderRow {
pub fn has_credential(&self) -> bool {
match self.provider.credential {
Credential::ApiKey => !self.value.is_empty() || self.from_env.is_some(),
Credential::BaseUrl | Credential::None => true,
}
}
}
#[derive(Debug, Clone)]
pub struct AgentRow {
pub agent: &'static BundledAgent,
pub action: AgentAction,
pub selected: bool,
}
#[derive(Debug, Clone)]
pub struct McpRow {
pub candidate: Candidate,
pub source: String,
pub selected: bool,
pub collides: bool,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct Field {
pub label: &'static str,
pub help: &'static str,
pub value: FieldValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldValue {
Number(Option<u64>),
Bool(bool),
Choice { options: Vec<String>, index: usize },
}
impl FieldValue {
pub fn display(&self) -> String {
match self {
Self::Number(None) => "(unset)".to_string(),
Self::Number(Some(n)) => n.to_string(),
Self::Bool(true) => "yes".to_string(),
Self::Bool(false) => "no".to_string(),
Self::Choice { options, index } => match options.get(*index) {
Some(chosen) => chosen.clone(),
None => "(none)".to_string(),
},
}
}
pub fn options(&self) -> &[String] {
match self {
Self::Choice { options, .. } => options,
Self::Number(_) | Self::Bool(_) => &[],
}
}
}
#[derive(Debug, Clone)]
pub struct VerifyRequest {
pub provider_id: String,
pub creds: leviath_runtime::provider_creds::ProviderCreds,
}
#[derive(Debug, Clone)]
pub struct VerifyReply {
pub provider_id: String,
pub outcome: Outcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditTarget {
Credential(usize),
Field(usize),
}
#[derive(Debug, Clone)]
pub struct Edit {
pub target: EditTarget,
pub(crate) line: crate::tui::widgets::line_edit::LineEdit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmPurpose {
QuitDiscard,
SaveTos,
NoProviders,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PendingConfirm {
pub purpose: ConfirmPurpose,
pub(crate) dialog: crate::tui::widgets::confirm::Confirm,
}
pub struct Wizard {
pub step: Step,
pub cursor: usize,
pub providers: Vec<ProviderRow>,
pub detail: usize,
pub defaults: Vec<Field>,
pub limits: Vec<Field>,
pub agents: Vec<AgentRow>,
pub mcp: Vec<McpRow>,
pub mcp_scan_errors: Vec<String>,
pub edit: Option<Edit>,
pub reveal: bool,
pub show_help: bool,
pub confirm: Option<PendingConfirm>,
pub dirty: bool,
pub claude_code_tos_accepted: bool,
pub should_quit: bool,
pub finished: bool,
pub message: Option<String>,
pub base: Config,
pub env_only: HashMap<&'static str, String>,
pub opener: leviath_mcp::BrowserOpener,
pub verify_tx: mpsc::UnboundedSender<VerifyRequest>,
verify_rx: Option<mpsc::UnboundedReceiver<VerifyRequest>>,
reply_tx: mpsc::UnboundedSender<VerifyReply>,
pub reply_rx: mpsc::UnboundedReceiver<VerifyReply>,
pub ticks: u64,
}
fn env_credentials(lookup: &dyn Fn(&str) -> Option<String>) -> HashMap<&'static str, String> {
catalog::providers()
.iter()
.filter_map(|p| {
let var = p.env_var?;
let value = lookup(var)?;
(!value.is_empty()).then_some((var, value))
})
.collect()
}
impl Wizard {
pub fn new(
base: Config,
env_lookup: &dyn Fn(&str) -> Option<String>,
candidates: Vec<(String, Candidate)>,
scan_errors: Vec<String>,
agents_dir: &std::path::Path,
opener: leviath_mcp::BrowserOpener,
) -> Self {
let env_only = env_credentials(env_lookup);
let providers = catalog::providers()
.into_iter()
.map(|provider| {
let stored = catalog::stored_credential(&base, provider.id);
let from_env = provider
.env_var
.filter(|v| stored.is_none() && env_only.contains_key(v));
ProviderRow {
selected: catalog::is_configured(&base, provider.id) || from_env.is_some(),
value: stored.unwrap_or_default(),
from_env,
effort: effort_index(base.providers.claude_code_effort.as_deref()),
outcome: Outcome::Skipped,
checking: false,
provider,
}
})
.collect();
let agents = crate::bundled::plan_agent_actions(agents_dir)
.into_iter()
.map(|(agent, action)| AgentRow {
selected: action.is_change(),
agent,
action,
})
.collect();
let mcp = candidates
.into_iter()
.map(|(source, candidate)| {
let collides =
import::already_configured(&base.mcp_servers, &candidate.config.name);
let name = import::dedup_name(&base.mcp_servers, &candidate.config.name);
McpRow {
selected: !collides,
source,
collides,
name,
candidate,
}
})
.collect();
let (verify_tx, verify_rx) = mpsc::unbounded_channel();
let (reply_tx, reply_rx) = mpsc::unbounded_channel();
let mut wizard = Self {
step: Step::Welcome,
cursor: 0,
providers,
detail: 0,
defaults: Vec::new(),
limits: limits_fields(&base),
agents,
mcp,
mcp_scan_errors: scan_errors,
edit: None,
reveal: false,
show_help: false,
confirm: None,
dirty: false,
claude_code_tos_accepted: false,
should_quit: false,
finished: false,
message: None,
base,
env_only,
opener,
verify_tx,
verify_rx: Some(verify_rx),
reply_tx,
reply_rx,
ticks: 0,
};
wizard.rebuild_defaults();
wizard
}
pub fn take_verify_ends(
&mut self,
) -> Option<(
mpsc::UnboundedReceiver<VerifyRequest>,
mpsc::UnboundedSender<VerifyReply>,
)> {
self.verify_rx.take().map(|rx| (rx, self.reply_tx.clone()))
}
pub fn selected_providers(&self) -> Vec<usize> {
self.providers
.iter()
.enumerate()
.filter(|(_, r)| r.selected)
.map(|(i, _)| i)
.collect()
}
pub fn detail_row(&self) -> Option<usize> {
self.selected_providers().get(self.detail).copied()
}
pub fn claude_code_selected(&self) -> bool {
self.providers
.iter()
.any(|r| r.selected && r.provider.id == "claude-code")
}
pub fn needs_tos_confirmation(&self) -> bool {
self.claude_code_selected() && !self.claude_code_tos_accepted
}
pub fn fields(&self) -> &[Field] {
match self.step {
Step::Defaults => &self.defaults,
Step::Limits => &self.limits,
_ => &[],
}
}
pub(super) fn fields_mut(&mut self) -> Option<&mut Vec<Field>> {
match self.step {
Step::Defaults => Some(&mut self.defaults),
Step::Limits => Some(&mut self.limits),
_ => None,
}
}
pub fn row_count(&self) -> usize {
match self.step {
Step::Welcome | Step::Review => 0,
Step::Providers => self.providers.len(),
Step::ProviderDetail => usize::from(self.detail_row().is_some()),
Step::Defaults => self.defaults.len(),
Step::Limits => self.limits.len(),
Step::Agents => self.agents.len(),
Step::Mcp => self.mcp.len(),
}
}
pub fn nav_rows(&self) -> usize {
self.row_count() + 1
}
pub fn on_continue(&self) -> bool {
self.cursor == self.row_count()
}
pub fn continue_label(&self) -> String {
match self.step {
Step::Welcome => "Get started".to_string(),
Step::Review => "Apply and finish".to_string(),
Step::Providers => {
let count = self.selected_providers().len();
if count == 0 {
"Continue (no providers selected)".to_string()
} else {
format!("Continue: {} ({count} selected)", self.next_step_title())
}
}
Step::ProviderDetail => {
let selected = self.selected_providers();
match selected.get(self.detail + 1) {
Some(&next) => format!("Next: {}", self.providers[next].provider.display),
None => format!("Continue: {}", self.next_step_title()),
}
}
Step::Defaults | Step::Limits | Step::Agents | Step::Mcp => {
format!("Continue: {}", self.next_step_title())
}
}
}
fn next_step_title(&self) -> &'static str {
let mut index = self.step.index();
while index + 1 < Step::ALL.len() {
index += 1;
let step = Step::ALL[index];
if !self.is_empty_step(step) {
return step.title();
}
}
Step::Review.title()
}
pub fn move_cursor(&mut self, delta: isize) {
let count = self.nav_rows();
let next = self.cursor as isize + delta;
self.cursor = next.clamp(0, count as isize - 1) as usize;
}
pub fn next_step(&mut self) {
let mut index = self.step.index();
while index + 1 < Step::ALL.len() {
index += 1;
let step = Step::ALL[index];
if !self.is_empty_step(step) {
self.enter(step);
return;
}
if step == Step::ProviderDetail {
self.message =
Some("Skipped Credentials: no selected provider needs setup.".to_string());
}
}
self.enter(Step::Review);
}
pub fn prev_step(&mut self) {
let mut index = self.step.index();
while index > 0 {
index -= 1;
let step = Step::ALL[index];
if !self.is_empty_step(step) {
self.enter(step);
return;
}
}
}
fn is_empty_step(&self, step: Step) -> bool {
match step {
Step::Mcp => self.mcp.is_empty() && self.mcp_scan_errors.is_empty(),
Step::ProviderDetail => self.detail_row().is_none(),
_ => false,
}
}
pub fn enter(&mut self, step: Step) {
self.step = step;
self.cursor = 0;
self.edit = None;
if step == Step::Defaults {
self.rebuild_defaults();
}
}
pub fn next_detail(&mut self) -> bool {
if self.detail + 1 < self.selected_providers().len() {
self.detail += 1;
self.cursor = 0;
self.edit = None;
return true;
}
false
}
pub fn prev_detail(&mut self) -> bool {
if self.detail > 0 {
self.detail -= 1;
self.cursor = 0;
self.edit = None;
return true;
}
false
}
pub fn request_verification(&mut self, index: usize) {
let Some(row) = self.providers.get_mut(index) else {
return;
};
if !row.has_credential() {
row.outcome = Outcome::Skipped;
return;
}
let id = row.provider.id.to_string();
let key = if row.value.is_empty() {
self.env_only
.get(row.provider.env_var.unwrap_or_default())
.cloned()
} else {
Some(row.value.clone())
};
let base_url = (row.provider.credential == Credential::BaseUrl).then(|| {
if row.value.is_empty() {
catalog::DEFAULT_OLLAMA_URL.to_string()
} else {
row.value.clone()
}
});
row.checking = true;
let creds = leviath_runtime::provider_creds::ProviderCreds {
name: id.clone(),
api_key: base_url.is_none().then_some(key).flatten(),
base_url,
model_capabilities: HashMap::new(),
request_timeout_secs: Some(20),
rate_limit: None,
options: HashMap::new(),
};
let _ = self.verify_tx.send(VerifyRequest {
provider_id: id,
creds,
});
}
pub fn verify_all(&mut self) {
for index in self.selected_providers() {
self.request_verification(index);
}
}
pub fn drain_verifications(&mut self) {
let mut landed = false;
while let Ok(reply) = self.reply_rx.try_recv() {
if let Some(row) = self
.providers
.iter_mut()
.find(|r| r.provider.id == reply.provider_id)
{
row.checking = false;
row.outcome = reply.outcome;
landed = true;
}
}
if landed && self.step == Step::Defaults {
self.rebuild_defaults();
}
}
pub fn discovered_models(&self) -> Vec<String> {
let mut models: Vec<String> = self
.providers
.iter()
.filter(|r| r.selected)
.flat_map(|r| r.outcome.models().iter().cloned())
.collect();
models.sort();
models.dedup();
models
}
pub fn rebuild_defaults(&mut self) {
let chosen = self.current_default_provider();
let providers: Vec<String> = self
.selected_providers()
.iter()
.map(|i| self.providers[*i].provider.id.to_string())
.collect();
let providers = if providers.is_empty() {
vec![self.base.default_provider.clone()]
} else {
providers
};
let index = providers.iter().position(|p| *p == chosen).unwrap_or(0);
let mut models = vec!["(provider default)".to_string()];
models.extend(self.discovered_models());
let current_model = self
.current_default_model()
.unwrap_or_else(|| "(provider default)".to_string());
if !models.contains(¤t_model) {
models.push(current_model.clone());
}
let model_index = models
.iter()
.position(|m| *m == current_model)
.unwrap_or_default();
let timeout = self.current_request_timeout();
self.defaults = vec![
Field {
label: "Default provider",
help: "Used by any blueprint that allows a user default.",
value: FieldValue::Choice {
options: providers,
index,
},
},
Field {
label: "Default model",
help: "Filled in from the models your providers reported.",
value: FieldValue::Choice {
options: models,
index: model_index,
},
},
Field {
label: "Request timeout (seconds)",
help: "How long to wait on one inference. Unset uses the provider default.",
value: FieldValue::Number(timeout),
},
];
self.apply_provider_concurrency_default();
}
fn current_default_provider(&self) -> String {
match self.defaults.first().map(|f| &f.value) {
Some(FieldValue::Choice { options, index }) => match options.get(*index) {
Some(chosen) => chosen.clone(),
None => self.base.default_provider.clone(),
},
_ => self.base.default_provider.clone(),
}
}
fn current_default_model(&self) -> Option<String> {
match self.defaults.get(1).map(|f| &f.value) {
Some(FieldValue::Choice { options, index }) => options.get(*index).cloned(),
_ => self.base.default_model.clone(),
}
}
fn current_request_timeout(&self) -> Option<u64> {
match self.defaults.get(2).map(|f| &f.value) {
Some(FieldValue::Number(n)) => *n,
_ => self.base.request_timeout_secs,
}
}
pub fn apply_provider_concurrency_default(&mut self) {
let ollama = self.current_default_provider() == "ollama";
let general = Config::default().limits.max_concurrent_inferences;
let local = Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64);
let general = general.map(|n| n as u64);
let Some(FieldValue::Number(current)) = self.limits.first_mut().map(|f| &mut f.value)
else {
return;
};
if ollama && *current == general {
*current = local;
} else if !ollama && *current == local {
*current = general;
}
}
pub fn open_quit_confirm(&mut self) {
use ratatui::text::Line;
self.confirm = Some(PendingConfirm {
purpose: ConfirmPurpose::QuitDiscard,
dialog: crate::tui::widgets::confirm::Confirm::new(
"Quit setup?",
vec![Line::from(
"Nothing has been written yet. Your choices so far will be discarded.",
)],
"Quit",
"Stay",
),
});
}
pub fn open_tos_confirm(&mut self) {
use ratatui::text::Line;
self.confirm = Some(PendingConfirm {
purpose: ConfirmPurpose::SaveTos,
dialog: crate::tui::widgets::confirm::Confirm::new(
"Claude Code terms of service",
vec![
Line::from("Anthropic's terms prohibit third-party developers from offering"),
Line::from("claude.ai subscription auth for their products without prior"),
Line::from("approval. The Claude Code transport routes inference through"),
Line::from("your subscription via the CLI's OAuth session."),
Line::from(""),
Line::from("For unambiguous compliance, use a direct Anthropic API key."),
Line::from(""),
Line::from("Accepting means you take responsibility for compliance."),
],
"Accept and save",
"Cancel",
)
.danger(),
});
}
pub fn open_no_providers_confirm(&mut self) {
use ratatui::text::Line;
self.confirm = Some(PendingConfirm {
purpose: ConfirmPurpose::NoProviders,
dialog: crate::tui::widgets::confirm::Confirm::new(
"No providers selected",
vec![
Line::from("Without a provider, Leviath cannot run any agent."),
Line::from("Select one with Space or Enter, or continue anyway to"),
Line::from("configure providers later."),
],
"Continue anyway",
"Go back",
),
});
}
pub fn commit_edit(&mut self) {
let Some(edit) = self.edit.take() else {
return;
};
match edit.target {
EditTarget::Credential(index) => {
if let Some(row) = self.providers.get_mut(index) {
row.value = edit.line.value().trim().to_string();
if !row.value.is_empty() {
row.from_env = None;
}
row.outcome = Outcome::Skipped;
}
}
EditTarget::Field(index) => {
let Some(fields) = self.fields_mut() else {
return;
};
if let Some(field) = fields.get_mut(index) {
match &mut field.value {
FieldValue::Number(n) => {
let trimmed = edit.line.value().trim();
*n = if trimmed.is_empty() {
None
} else {
trimmed.parse().ok().or(*n)
};
}
FieldValue::Bool(_) | FieldValue::Choice { .. } => {}
}
}
}
}
}
pub fn build_config(&self) -> Config {
let mut config = self.base.clone();
for row in &self.providers {
match row.provider.credential {
Credential::None => {}
_ if !row.selected => catalog::set_credential(&mut config, row.provider.id, None),
_ if row.value.is_empty() => {
catalog::set_credential(&mut config, row.provider.id, None)
}
Credential::BaseUrl if row.value == catalog::DEFAULT_OLLAMA_URL => {
catalog::set_credential(&mut config, row.provider.id, None)
}
_ => catalog::set_credential(&mut config, row.provider.id, Some(row.value.clone())),
}
}
let transport = self
.providers
.iter()
.find(|r| r.provider.id == "claude-code")
.filter(|r| r.selected);
config.providers.claude_code_enabled = transport.is_some();
if let Some(row) = transport {
config.providers.claude_code_effort =
Some(effort_options()[row.effort.min(effort_options().len() - 1)].to_string());
}
config.default_provider = self.current_default_provider();
config.default_model = self
.current_default_model()
.filter(|m| m != "(provider default)");
config.request_timeout_secs = self.current_request_timeout();
apply_limits_fields(&mut config, &self.limits);
for row in self.mcp.iter().filter(|r| r.selected) {
let mut server = row.candidate.config.clone();
server.name = row.name.clone();
config.mcp_servers.push(server);
}
config
}
pub fn build_plan(&self) -> SetupPlan {
SetupPlan {
config: self.build_config(),
agents: self
.agents
.iter()
.filter(|r| r.selected)
.map(|r| r.agent)
.collect(),
}
}
pub fn review_lines(&self) -> Vec<String> {
let plan = self.build_plan();
let changes = super::plan::changes(&self.base, &plan);
if changes.is_empty() {
vec!["Nothing would change.".to_string()]
} else {
changes
}
}
pub fn selected_inline_secrets(&self) -> Vec<String> {
self.mcp
.iter()
.filter(|r| r.selected && !r.candidate.inline_secrets.is_empty())
.map(|r| format!("{}: {}", r.name, r.candidate.inline_secrets.join(", ")))
.collect()
}
}
pub fn effort_options() -> &'static [&'static str] {
&leviath_providers::claude_code::EFFORT_LEVELS
}
fn effort_index(effort: Option<&str>) -> usize {
let wanted = effort.unwrap_or(leviath_providers::claude_code::DEFAULT_EFFORT);
effort_options()
.iter()
.position(|e| *e == wanted)
.unwrap_or_default()
}
fn limits_fields(config: &Config) -> Vec<Field> {
vec![
Field {
label: "Max concurrent inferences",
help: "How many model calls run at once across all agents.",
value: FieldValue::Number(config.limits.max_concurrent_inferences.map(|n| n as u64)),
},
Field {
label: "Max concurrent tools",
help: "How many tool calls run at once within one batch.",
value: FieldValue::Number(Some(config.limits.max_concurrent_tools as u64)),
},
Field {
label: "Default max iterations",
help: "Per-stage ceiling when a blueprint sets none.",
value: FieldValue::Number(config.limits.default_max_iterations.map(|n| n as u64)),
},
Field {
label: "Exact token counting",
help: "Ask the provider for real token counts instead of estimating. Slower.",
value: FieldValue::Bool(config.limits.exact_token_counting),
},
Field {
label: "Batch tool-call hint",
help: "Nudge models to request several tools in one turn.",
value: FieldValue::Bool(config.batch_tool_hint),
},
Field {
label: "Platform shell hint",
help: "Tell models what shell they get. Only says anything on Windows (cmd.exe).",
value: FieldValue::Bool(config.shell_hint),
},
Field {
label: "Stall timeout (seconds)",
help: "Fail a run that can never dispatch (unconfigured provider). 0 waits forever.",
value: FieldValue::Number(Some(config.limits.stall_timeout_secs)),
},
Field {
label: "Dead cycles before relief",
help: "Widen the tool lane after this many 30s cycles with work queued and nothing moving. 0 never does.",
value: FieldValue::Number(Some(config.limits.dead_cycles_before_relief as u64)),
},
Field {
label: "Finished run retention (seconds)",
help: "Keep a run in `lev ps` this long after it ends, so a script polling on an interval sees how it ended. 0 drops it at once.",
value: FieldValue::Number(Some(config.limits.finished_retention_secs)),
},
Field {
label: "Wedge timeout (seconds)",
help: "Fail a run nothing in the engine can reach any more. 0 is off; 300 is a sensible value.",
value: FieldValue::Number(Some(config.limits.wedge_timeout_secs)),
},
Field {
label: "Interaction timeout (seconds)",
help: "Resolve a prompt nobody answered after this long, so the run carries on. 0 waits for ever.",
value: FieldValue::Number(Some(config.limits.interaction_timeout_secs)),
},
]
}
fn apply_limits_fields(config: &mut Config, fields: &[Field]) {
for (index, field) in fields.iter().enumerate() {
match (index, &field.value) {
(0, FieldValue::Number(n)) => {
config.limits.max_concurrent_inferences = n.map(|n| n as usize)
}
(1, FieldValue::Number(n)) => {
config.limits.max_concurrent_tools = n
.filter(|n| *n > 0)
.map(|n| n as usize)
.unwrap_or(Config::default().limits.max_concurrent_tools)
}
(2, FieldValue::Number(n)) => {
config.limits.default_max_iterations = n.map(|n| n as usize)
}
(3, FieldValue::Bool(b)) => config.limits.exact_token_counting = *b,
(4, FieldValue::Bool(b)) => config.batch_tool_hint = *b,
(5, FieldValue::Bool(b)) => config.shell_hint = *b,
(6, FieldValue::Number(n)) => {
config.limits.stall_timeout_secs =
n.unwrap_or(Config::default().limits.stall_timeout_secs)
}
(7, FieldValue::Number(n)) => {
config.limits.dead_cycles_before_relief = n
.map(|n| n as u32)
.unwrap_or(Config::default().limits.dead_cycles_before_relief)
}
(8, FieldValue::Number(n)) => {
config.limits.finished_retention_secs =
n.unwrap_or(Config::default().limits.finished_retention_secs)
}
(9, FieldValue::Number(n)) => {
config.limits.wedge_timeout_secs =
n.unwrap_or(Config::default().limits.wedge_timeout_secs)
}
(10, FieldValue::Number(n)) => {
config.limits.interaction_timeout_secs =
n.unwrap_or(Config::default().limits.interaction_timeout_secs)
}
_ => {}
}
}
}
pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
let mut candidates = Vec::new();
let mut errors = Vec::new();
for scan in scans {
match scan.result {
Ok(found) => candidates.extend(
found
.into_iter()
.map(|c| (scan.source.display.to_string(), c)),
),
Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
}
}
(candidates, errors)
}
pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
rows.iter()
.filter(|r| r.selected)
.map(|r| {
let mut server = r.candidate.config.clone();
server.name = r.name.clone();
server
})
.collect()
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
use crate::bundled::BUNDLED_AGENTS;
pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
Wizard::new(
Config::default(),
&|_| None,
Vec::new(),
Vec::new(),
agents_dir,
std::sync::Arc::new(|_| true),
)
}
fn candidate(name: &str) -> Candidate {
Candidate {
config: MCPServerConfig::stdio(name, "npx", vec![]),
scope: String::new(),
inline_secrets: Vec::new(),
}
}
#[test]
fn every_step_is_titled_and_ordered() {
for (index, step) in Step::ALL.iter().enumerate() {
assert!(!step.title().is_empty(), "{step:?} has no title");
assert_eq!(step.index(), index);
}
}
#[test]
fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
let dir = tempfile::tempdir().unwrap();
let wizard = test_wizard(dir.path());
assert_eq!(wizard.step, Step::Welcome);
assert!(wizard.selected_providers().is_empty());
assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
assert!(
wizard.agents.iter().all(|r| r.selected),
"a fresh install should offer to install everything"
);
assert!(
wizard
.agents
.iter()
.all(|r| r.action == AgentAction::Install)
);
}
#[test]
fn already_installed_agents_are_listed_but_not_reselected() {
let dir = tempfile::tempdir().unwrap();
for agent in BUNDLED_AGENTS {
crate::bundled::install_bundled(agent, dir.path()).unwrap();
}
let wizard = test_wizard(dir.path());
assert!(
wizard.agents.iter().all(|r| !r.selected),
"nothing needs doing, so nothing should be pre-checked"
);
}
#[test]
fn a_configured_provider_starts_selected_with_its_credential() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-stored".to_string()),
..Config::default().providers
},
..Config::default()
};
let wizard = Wizard::new(
base,
&|_| None,
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
let row = wizard
.providers
.iter()
.find(|r| r.provider.id == "anthropic")
.expect("anthropic is in the table");
assert!(row.selected);
assert_eq!(row.value, "sk-ant-stored");
assert!(row.from_env.is_none());
}
#[test]
fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
let dir = tempfile::tempdir().unwrap();
let wizard = Wizard::new(
Config::default(),
&|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
let row = wizard
.providers
.iter()
.find(|r| r.provider.id == "anthropic")
.expect("anthropic is in the table");
assert!(row.selected, "the provider is usable, so it is selected");
assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
assert!(row.value.is_empty());
let written = wizard.build_config();
assert!(
written.providers.anthropic_api_key.is_none(),
"an environment-supplied key must not be copied into the config"
);
}
#[test]
fn a_stored_key_wins_over_the_environment() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-stored".to_string()),
..Config::default().providers
},
..Config::default()
};
let wizard = Wizard::new(
base,
&|_| Some("sk-ant-from-env".to_string()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
let row = &wizard.providers[0];
assert!(row.from_env.is_none());
assert_eq!(row.value, "sk-ant-stored");
}
#[test]
fn an_empty_environment_variable_does_not_count_as_a_credential() {
let dir = tempfile::tempdir().unwrap();
let wizard = Wizard::new(
Config::default(),
&|_| Some(String::new()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
assert!(wizard.env_only.is_empty());
assert!(wizard.selected_providers().is_empty());
}
#[test]
fn an_importable_server_is_preselected_and_named_as_found() {
let dir = tempfile::tempdir().unwrap();
let wizard = Wizard::new(
Config::default(),
&|_| None,
vec![("Claude Code".to_string(), candidate("fs"))],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
assert_eq!(wizard.mcp.len(), 1);
assert!(wizard.mcp[0].selected);
assert!(!wizard.mcp[0].collides);
assert_eq!(wizard.mcp[0].name, "fs");
assert_eq!(wizard.mcp[0].source, "Claude Code");
}
#[test]
fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
..Config::default()
};
let wizard = Wizard::new(
base,
&|_| None,
vec![("Cursor".to_string(), candidate("fs"))],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
assert!(!wizard.mcp[0].selected);
assert!(wizard.mcp[0].collides);
assert_eq!(wizard.mcp[0].name, "fs-2");
let mut wizard = wizard;
wizard.mcp[0].selected = true;
let config = wizard.build_config();
let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["fs", "fs-2"]);
}
#[test]
fn selected_servers_renames_and_filters() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| None,
vec![
("A".to_string(), candidate("keep")),
("B".to_string(), candidate("drop")),
],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.mcp[1].selected = false;
wizard.mcp[0].name = "renamed".to_string();
let servers = selected_servers(&wizard.mcp);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "renamed");
}
#[test]
fn inline_secrets_are_reported_only_for_selected_rows() {
let dir = tempfile::tempdir().unwrap();
let mut secretive = candidate("leaky");
secretive.inline_secrets = vec!["API_TOKEN".to_string()];
let mut wizard = Wizard::new(
Config::default(),
&|_| None,
vec![
("A".to_string(), secretive),
("B".to_string(), candidate("clean")),
],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
wizard.mcp[0].selected = false;
assert!(wizard.selected_inline_secrets().is_empty());
}
#[test]
fn the_cursor_stays_inside_the_current_step() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Providers);
wizard.move_cursor(-5);
assert_eq!(wizard.cursor, 0);
wizard.move_cursor(100);
assert_eq!(
wizard.cursor,
wizard.providers.len(),
"clamped to the Continue button after the last row"
);
assert!(wizard.on_continue());
}
#[test]
fn a_step_with_no_rows_pins_the_cursor_at_zero() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Welcome);
wizard.cursor = 4;
wizard.move_cursor(1);
assert_eq!(wizard.cursor, 0);
assert_eq!(wizard.row_count(), 0);
}
#[test]
fn the_continue_label_names_where_it_goes() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Welcome);
assert_eq!(wizard.continue_label(), "Get started");
wizard.enter(Step::Providers);
assert_eq!(wizard.continue_label(), "Continue (no providers selected)");
wizard.providers[0].selected = true;
wizard.providers[1].selected = true;
assert_eq!(
wizard.continue_label(),
"Continue: Credentials (2 selected)"
);
wizard.enter(Step::ProviderDetail);
assert_eq!(
wizard.continue_label(),
format!("Next: {}", wizard.providers[1].provider.display)
);
wizard.detail = 1;
assert_eq!(wizard.continue_label(), "Continue: Defaults");
wizard.enter(Step::Limits);
assert_eq!(wizard.continue_label(), "Continue: Agents");
wizard.enter(Step::Review);
assert_eq!(wizard.continue_label(), "Apply and finish");
}
#[test]
fn next_step_title_past_the_last_step_falls_back_to_review() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Review);
assert_eq!(wizard.next_step_title(), "Review");
}
#[test]
fn empty_discovery_steps_are_skipped_in_both_directions() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
assert!(wizard.mcp.is_empty());
wizard.enter(Step::Providers);
wizard.next_step();
assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
wizard.enter(Step::Agents);
wizard.next_step();
assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
wizard.prev_step();
assert_eq!(wizard.step, Step::Agents);
}
#[test]
fn a_nonempty_discovery_step_is_visited() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| None,
vec![("A".to_string(), candidate("fs"))],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.enter(Step::Agents);
wizard.next_step();
assert_eq!(wizard.step, Step::Mcp);
}
#[test]
fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| None,
Vec::new(),
vec!["Zed: unreadable".to_string()],
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.enter(Step::Agents);
wizard.next_step();
assert_eq!(wizard.step, Step::Mcp);
}
#[test]
fn the_first_step_has_nowhere_to_go_back_to() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.prev_step();
assert_eq!(wizard.step, Step::Welcome);
}
#[test]
fn advancing_past_the_last_step_stays_on_review() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Review);
wizard.next_step();
assert_eq!(wizard.step, Step::Review);
}
#[test]
fn the_credential_screen_walks_the_selected_providers() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].selected = true;
wizard.providers[1].selected = true;
assert_eq!(wizard.detail_row(), Some(0));
assert!(wizard.next_detail());
assert_eq!(wizard.detail_row(), Some(1));
assert!(!wizard.next_detail(), "there is no third provider");
assert!(wizard.prev_detail());
assert_eq!(wizard.detail_row(), Some(0));
assert!(!wizard.prev_detail());
}
#[test]
fn the_credential_screen_has_no_row_when_nothing_is_selected() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::ProviderDetail);
assert!(wizard.detail_row().is_none());
assert_eq!(wizard.row_count(), 0);
}
#[tokio::test]
async fn verification_is_requested_for_a_provider_with_a_credential() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.providers[0].value = "sk-ant-x".to_string();
wizard.request_verification(0);
assert!(wizard.providers[0].checking);
let request = requests.try_recv().expect("a request was queued");
assert_eq!(request.provider_id, "anthropic");
assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
}
#[tokio::test]
async fn a_blank_api_key_is_not_queued_for_checking() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.request_verification(0);
assert!(!wizard.providers[0].checking);
assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
assert!(requests.try_recv().is_err());
}
#[tokio::test]
async fn an_environment_supplied_key_is_what_gets_checked() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
wizard.request_verification(0);
let request = requests.try_recv().expect("a request was queued");
assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
}
#[tokio::test]
async fn ollama_is_checked_by_url_with_no_key() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
let index = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.request_verification(index);
let request = requests.try_recv().expect("a request was queued");
assert!(request.creds.api_key.is_none());
assert_eq!(
request.creds.base_url.as_deref(),
Some(catalog::DEFAULT_OLLAMA_URL),
"an empty field means the default endpoint"
);
wizard.providers[index].value = "http://box:11434".to_string();
wizard.request_verification(index);
let request = requests.try_recv().expect("a second request was queued");
assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
}
#[tokio::test]
async fn verify_all_covers_every_selected_provider() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.providers[0].value = "sk-ant".to_string();
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.verify_all();
let mut seen = Vec::new();
while let Ok(request) = requests.try_recv() {
seen.push(request.provider_id);
}
assert_eq!(seen, vec!["anthropic", "ollama"]);
}
#[tokio::test]
async fn an_out_of_range_verification_request_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
wizard.request_verification(999);
assert!(requests.try_recv().is_err());
}
#[tokio::test]
async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (_requests, replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.providers[0].checking = true;
replies
.send(VerifyReply {
provider_id: "anthropic".to_string(),
outcome: Outcome::Reachable {
models: vec!["claude-opus-5".to_string()],
},
})
.unwrap();
replies
.send(VerifyReply {
provider_id: "not-a-provider".to_string(),
outcome: Outcome::Skipped,
})
.unwrap();
wizard.drain_verifications();
assert!(!wizard.providers[0].checking);
assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
}
#[tokio::test]
async fn a_late_reply_refills_the_model_picker() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (_requests, replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.enter(Step::Defaults);
assert_eq!(
wizard.defaults[1].value.options(),
["(provider default)".to_string()],
"nothing has been reported yet"
);
replies
.send(VerifyReply {
provider_id: "anthropic".to_string(),
outcome: Outcome::Reachable {
models: vec!["claude-opus-5".to_string()],
},
})
.unwrap();
wizard.drain_verifications();
assert!(
wizard.defaults[1]
.value
.options()
.contains(&"claude-opus-5".to_string()),
"the picker should have refilled"
);
}
#[tokio::test]
async fn a_late_reply_does_not_disturb_another_screen() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let (_requests, replies) = wizard.take_verify_ends().expect("first take");
wizard.providers[0].selected = true;
wizard.enter(Step::Limits);
wizard.limits[0].value = FieldValue::Number(Some(3));
replies
.send(VerifyReply {
provider_id: "anthropic".to_string(),
outcome: Outcome::Reachable {
models: vec!["m".to_string()],
},
})
.unwrap();
wizard.drain_verifications();
assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
}
#[test]
fn the_verification_channel_ends_can_only_be_taken_once() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
assert!(wizard.take_verify_ends().is_some());
assert!(wizard.take_verify_ends().is_none());
}
#[test]
fn models_from_unselected_providers_are_not_offered() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].outcome = Outcome::Reachable {
models: vec!["hidden".to_string()],
};
assert!(wizard.discovered_models().is_empty());
}
#[test]
fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.enter(Step::Defaults);
assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
}
#[test]
fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Defaults);
assert_eq!(
wizard.defaults[0].value.display(),
Config::default().default_provider
);
}
#[test]
fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
default_model: Some("hand-typed".to_string()),
..Config::default()
};
let mut wizard = Wizard::new(
base,
&|_| None,
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.providers[0].selected = true;
wizard.providers[0].outcome = Outcome::Reachable {
models: vec!["claude-opus-5".to_string()],
};
wizard.enter(Step::Defaults);
let options = wizard.defaults[1].value.options();
assert!(options.contains(&"(provider default)".to_string()));
assert!(options.contains(&"claude-opus-5".to_string()));
assert_eq!(
wizard.defaults[1].value.display(),
"hand-typed",
"a model already in the config must survive"
);
}
#[test]
fn only_a_choice_field_has_options() {
assert_eq!(
FieldValue::Choice {
options: vec!["a".into()],
index: 0
}
.options(),
["a".to_string()]
);
assert!(FieldValue::Number(Some(1)).options().is_empty());
assert!(FieldValue::Bool(true).options().is_empty());
}
#[test]
fn field_values_read_naturally() {
assert_eq!(FieldValue::Number(None).display(), "(unset)");
assert_eq!(FieldValue::Number(Some(7)).display(), "7");
assert_eq!(FieldValue::Bool(true).display(), "yes");
assert_eq!(FieldValue::Bool(false).display(), "no");
assert_eq!(
FieldValue::Choice {
options: vec!["a".into()],
index: 0
}
.display(),
"a"
);
assert_eq!(
FieldValue::Choice {
options: vec![],
index: 0
}
.display(),
"(none)"
);
}
#[test]
fn picking_ollama_drops_the_concurrency_default_to_one() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.enter(Step::Defaults);
wizard.apply_provider_concurrency_default();
assert_eq!(
wizard.limits[0].value,
FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
);
}
#[test]
fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.enter(Step::Defaults);
assert_eq!(wizard.defaults[0].value.display(), "ollama");
assert_eq!(
wizard.build_config().limits.max_concurrent_inferences,
Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
);
}
#[test]
fn switching_back_off_ollama_restores_the_general_default() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.enter(Step::Defaults);
wizard.apply_provider_concurrency_default();
wizard.providers[ollama].selected = false;
wizard.providers[0].selected = true;
wizard.rebuild_defaults();
wizard.apply_provider_concurrency_default();
assert_eq!(
wizard.limits[0].value,
FieldValue::Number(
Config::default()
.limits
.max_concurrent_inferences
.map(|n| n as u64)
)
);
}
#[test]
fn a_hand_typed_concurrency_is_never_overwritten() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.enter(Step::Defaults);
wizard.limits[0].value = FieldValue::Number(Some(3));
wizard.apply_provider_concurrency_default();
assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
}
#[test]
fn committing_a_credential_clears_its_stale_verification() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].outcome = Outcome::Reachable {
models: vec!["m".into()],
};
wizard.edit = Some(Edit {
target: EditTarget::Credential(0),
line: crate::tui::widgets::line_edit::LineEdit::new(" sk-ant-new ".to_string(), true),
});
wizard.commit_edit();
assert_eq!(wizard.providers[0].value, "sk-ant-new");
assert_eq!(
wizard.providers[0].outcome,
Outcome::Skipped,
"the old result was for a different key"
);
assert!(wizard.edit.is_none());
}
#[test]
fn typing_a_credential_supersedes_the_environments() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| Some("sk-ant-env".to_string()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
assert!(wizard.providers[0].from_env.is_some());
wizard.edit = Some(Edit {
target: EditTarget::Credential(0),
line: crate::tui::widgets::line_edit::LineEdit::new("sk-ant-typed".to_string(), true),
});
wizard.commit_edit();
assert!(wizard.providers[0].from_env.is_none());
assert_eq!(
wizard.build_config().providers.anthropic_api_key.as_deref(),
Some("sk-ant-typed")
);
}
#[test]
fn committing_numbers_handles_blank_and_unparseable_input() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
wizard.edit = Some(Edit {
target: EditTarget::Field(0),
line: crate::tui::widgets::line_edit::LineEdit::new("16".to_string(), false),
});
wizard.commit_edit();
assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
wizard.edit = Some(Edit {
target: EditTarget::Field(0),
line: crate::tui::widgets::line_edit::LineEdit::new("not a number".to_string(), false),
});
wizard.commit_edit();
assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
wizard.edit = Some(Edit {
target: EditTarget::Field(0),
line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), false),
});
wizard.commit_edit();
assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
}
#[test]
fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.commit_edit();
wizard.edit = Some(Edit {
target: EditTarget::Credential(999),
line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
});
wizard.commit_edit();
wizard.enter(Step::Limits);
wizard.edit = Some(Edit {
target: EditTarget::Field(999),
line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
});
wizard.commit_edit();
wizard.enter(Step::Welcome);
wizard.edit = Some(Edit {
target: EditTarget::Field(0),
line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
});
wizard.commit_edit();
assert!(wizard.edit.is_none());
}
#[test]
fn every_step_reports_a_sensible_row_count() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| None,
vec![("A".to_string(), candidate("fs"))],
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.providers[0].selected = true;
for step in Step::ALL {
wizard.enter(step);
let rows = wizard.row_count();
match step {
Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
_ => assert!(rows > 0, "{step:?} has no rows"),
}
let fields = wizard.fields().len();
match step {
Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
_ => assert_eq!(fields, 0, "{step:?} should have no fields"),
}
}
}
#[test]
fn committing_onto_a_defaults_field_reaches_that_form_too() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].selected = true;
wizard.enter(Step::Defaults);
wizard.edit = Some(Edit {
target: EditTarget::Field(2),
line: crate::tui::widgets::line_edit::LineEdit::new("45".to_string(), false),
});
wizard.commit_edit();
assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
}
#[test]
fn clearing_a_credential_leaves_the_environment_marker_alone() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = Wizard::new(
Config::default(),
&|_| Some("sk-ant-env".to_string()),
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.edit = Some(Edit {
target: EditTarget::Credential(0),
line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), true),
});
wizard.commit_edit();
assert!(wizard.providers[0].value.is_empty());
assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
}
#[test]
fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.defaults[0].value = FieldValue::Bool(true);
assert_eq!(
wizard.build_config().default_provider,
Config::default().default_provider
);
}
#[test]
fn an_empty_choice_list_falls_back_to_the_base_config() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.defaults[0].value = FieldValue::Choice {
options: Vec::new(),
index: 0,
};
assert_eq!(
wizard.build_config().default_provider,
Config::default().default_provider
);
}
#[test]
fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.limits[0].value = FieldValue::Bool(true);
wizard.apply_provider_concurrency_default();
assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
}
#[test]
fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
let before = wizard.limits[3].value.clone();
wizard.edit = Some(Edit {
target: EditTarget::Field(3),
line: crate::tui::widgets::line_edit::LineEdit::new("yes".to_string(), false),
});
wizard.commit_edit();
assert_eq!(wizard.limits[3].value, before);
}
#[test]
fn deselecting_a_provider_clears_its_credential() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-stored".to_string()),
..Config::default().providers
},
..Config::default()
};
let mut wizard = Wizard::new(
base,
&|_| None,
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
wizard.providers[0].selected = false;
assert!(wizard.build_config().providers.anthropic_api_key.is_none());
}
#[test]
fn ollamas_default_url_is_left_unset_rather_than_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let ollama = wizard
.providers
.iter()
.position(|r| r.provider.id == "ollama")
.expect("ollama is offered");
wizard.providers[ollama].selected = true;
wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
assert!(wizard.build_config().ollama_base_url.is_none());
wizard.providers[ollama].value = "http://box:11434".to_string();
assert_eq!(
wizard.build_config().ollama_base_url.as_deref(),
Some("http://box:11434")
);
}
#[test]
fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let index = wizard
.providers
.iter()
.position(|r| r.provider.id == "claude-code")
.expect("the transport is offered");
assert!(!wizard.build_config().providers.claude_code_enabled);
wizard.providers[index].selected = true;
wizard.providers[index].effort = effort_options().len() - 1;
let config = wizard.build_config();
assert!(config.providers.claude_code_enabled);
assert_eq!(
config.providers.claude_code_effort.as_deref(),
Some(*effort_options().last().expect("levels exist"))
);
}
#[test]
fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
let index = wizard
.providers
.iter()
.position(|r| r.provider.id == "claude-code")
.expect("the transport is offered");
wizard.providers[index].selected = true;
wizard.providers[index].effort = 99;
let config = wizard.build_config();
assert_eq!(
config.providers.claude_code_effort.as_deref(),
Some(*effort_options().last().expect("levels exist"))
);
}
#[test]
fn the_stored_effort_selects_the_matching_option() {
let dir = tempfile::tempdir().unwrap();
let base = Config {
providers: crate::config::ProviderConfig {
claude_code_effort: Some("max".to_string()),
..Config::default().providers
},
..Config::default()
};
let wizard = Wizard::new(
base,
&|_| None,
Vec::new(),
Vec::new(),
dir.path(),
std::sync::Arc::new(|_| true),
);
let index = wizard
.providers
.iter()
.position(|r| r.provider.id == "claude-code")
.expect("the transport is offered");
assert_eq!(effort_options()[wizard.providers[index].effort], "max");
}
#[test]
fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
assert_eq!(effort_index(Some("not-a-level")), 0);
assert_eq!(
effort_options()[effort_index(None)],
leviath_providers::claude_code::DEFAULT_EFFORT
);
}
#[test]
fn the_provider_default_model_is_stored_as_unset() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].selected = true;
wizard.enter(Step::Defaults);
assert!(wizard.build_config().default_model.is_none());
}
#[test]
fn limits_are_written_back_including_the_zero_guard() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
wizard.limits[0].value = FieldValue::Number(Some(2));
wizard.limits[1].value = FieldValue::Number(Some(0));
wizard.limits[2].value = FieldValue::Number(None);
wizard.limits[3].value = FieldValue::Bool(true);
wizard.limits[4].value = FieldValue::Bool(false);
wizard.limits[5].value = FieldValue::Bool(false);
wizard.limits[6].value = FieldValue::Number(Some(11));
wizard.limits[7].value = FieldValue::Number(Some(22));
wizard.limits[8].value = FieldValue::Number(Some(33));
wizard.limits[9].value = FieldValue::Number(Some(44));
let config = wizard.build_config();
assert_eq!(config.limits.max_concurrent_inferences, Some(2));
assert_eq!(
config.limits.max_concurrent_tools,
Config::default().limits.max_concurrent_tools
);
assert!(config.limits.default_max_iterations.is_none());
assert!(config.limits.exact_token_counting);
assert!(!config.batch_tool_hint);
assert!(!config.shell_hint);
assert_eq!(config.limits.stall_timeout_secs, 11);
assert_eq!(config.limits.dead_cycles_before_relief, 22);
assert_eq!(config.limits.finished_retention_secs, 33);
assert_eq!(config.limits.wedge_timeout_secs, 44);
}
#[test]
fn every_limits_field_is_written_back() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
let count = wizard.limits.len();
let before = wizard.build_config();
let seeded = limits_fields(&before);
assert_eq!(seeded.len(), count, "the form is built from the config");
for (i, field) in wizard.limits.iter_mut().enumerate() {
field.value = match &field.value {
FieldValue::Bool(b) => FieldValue::Bool(!b),
_ => FieldValue::Number(Some(i as u64 + 11)),
};
}
let expected: Vec<FieldValue> = wizard.limits.iter().map(|f| f.value.clone()).collect();
let after = limits_fields(&wizard.build_config());
for (i, (got, want)) in after.iter().zip(&expected).enumerate() {
assert_eq!(
&got.value, want,
"field {i} ({}) did not survive the round trip",
got.label
);
}
}
#[test]
fn the_watchdog_limits_store_zero_and_keep_the_default_when_blank() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
wizard.limits[6].value = FieldValue::Number(Some(0));
wizard.limits[7].value = FieldValue::Number(Some(0));
wizard.limits[8].value = FieldValue::Number(Some(0));
wizard.limits[9].value = FieldValue::Number(Some(300));
let config = wizard.build_config();
assert_eq!(config.limits.stall_timeout_secs, 0);
assert_eq!(config.limits.dead_cycles_before_relief, 0);
assert_eq!(config.limits.finished_retention_secs, 0);
assert_eq!(config.limits.wedge_timeout_secs, 300);
let mut wizard = test_wizard(dir.path());
wizard.enter(Step::Limits);
wizard.limits[6].value = FieldValue::Number(None);
wizard.limits[7].value = FieldValue::Number(None);
wizard.limits[8].value = FieldValue::Number(None);
wizard.limits[9].value = FieldValue::Number(None);
let config = wizard.build_config();
let default = Config::default();
assert_eq!(
config.limits.stall_timeout_secs,
default.limits.stall_timeout_secs
);
assert_eq!(
config.limits.dead_cycles_before_relief,
default.limits.dead_cycles_before_relief
);
assert_eq!(
config.limits.finished_retention_secs,
default.limits.finished_retention_secs
);
assert_eq!(
config.limits.wedge_timeout_secs,
default.limits.wedge_timeout_secs
);
}
#[test]
fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
let mut config = Config::default();
apply_limits_fields(
&mut config,
&[Field {
label: "Max concurrent inferences",
help: "",
value: FieldValue::Bool(true),
}],
);
assert_eq!(
config.limits.max_concurrent_inferences,
Config::default().limits.max_concurrent_inferences
);
}
#[test]
fn the_plan_carries_only_the_selected_agents() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
for row in wizard.agents.iter_mut().skip(1) {
row.selected = false;
}
let plan = wizard.build_plan();
assert_eq!(plan.agents.len(), 1);
assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
}
#[test]
fn the_review_says_so_when_nothing_would_change() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
for row in wizard.agents.iter_mut() {
row.selected = false;
}
assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
}
#[test]
fn the_review_lists_real_changes() {
let dir = tempfile::tempdir().unwrap();
let mut wizard = test_wizard(dir.path());
wizard.providers[0].selected = true;
wizard.providers[0].value = "sk-ant-x".to_string();
let lines = wizard.review_lines();
assert!(lines.iter().any(|l| l.contains("credential set")));
assert!(lines.iter().any(|l| l.contains("to install")));
}
#[test]
fn scans_flatten_into_candidates_and_labelled_errors() {
let scans = vec![
import::Scan {
source: import::Source {
id: "a",
display: "Harness A",
path: std::path::PathBuf::from("/a"),
layout: import::Layout::ClaudeCode,
allows_comments: false,
},
result: Ok(vec![candidate("fs")]),
},
import::Scan {
source: import::Source {
id: "b",
display: "Harness B",
path: std::path::PathBuf::from("/b"),
layout: import::Layout::CodexToml,
allows_comments: false,
},
result: Err("unreadable".to_string()),
},
];
let (candidates, errors) = candidates_from_scans(scans);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].0, "Harness A");
assert_eq!(errors, vec!["Harness B: unreadable"]);
}
}