Skip to main content

leviath_cli/commands/setup/
state.rs

1//! The wizard's state: what step we're on, what's been chosen, and how a
2//! choice turns back into a [`SetupPlan`].
3//!
4//! Deliberately free of drawing and of key handling - those are `render` and
5//! `input`. Everything here is ordinary data and pure transitions, so the whole
6//! flow is testable without a terminal.
7
8use std::collections::HashMap;
9
10use leviath_mcp::MCPServerConfig;
11use tokio::sync::mpsc;
12
13use super::catalog::{self, Credential, Provider};
14use super::import::{self, Candidate};
15use super::plan::SetupPlan;
16use super::verify::Outcome;
17use crate::bundled::{AgentAction, BundledAgent};
18use crate::config::Config;
19
20/// The wizard's screens, in order.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum Step {
23    Welcome,
24    Providers,
25    ProviderDetail,
26    Defaults,
27    Limits,
28    Agents,
29    Mcp,
30    Review,
31}
32
33impl Step {
34    /// Every step, in order.
35    pub const ALL: [Step; 8] = [
36        Step::Welcome,
37        Step::Providers,
38        Step::ProviderDetail,
39        Step::Defaults,
40        Step::Limits,
41        Step::Agents,
42        Step::Mcp,
43        Step::Review,
44    ];
45
46    /// Title shown in the header.
47    pub fn title(self) -> &'static str {
48        match self {
49            Step::Welcome => "Welcome",
50            Step::Providers => "Providers",
51            Step::ProviderDetail => "Credentials",
52            Step::Defaults => "Defaults",
53            Step::Limits => "Limits",
54            Step::Agents => "Agents",
55            Step::Mcp => "MCP servers",
56            Step::Review => "Review",
57        }
58    }
59
60    /// Position in [`Self::ALL`].
61    pub fn index(self) -> usize {
62        Self::ALL
63            .iter()
64            .position(|s| *s == self)
65            .expect("every step is in ALL")
66    }
67}
68
69/// One row of the provider pick-list.
70#[derive(Debug, Clone)]
71pub struct ProviderRow {
72    pub provider: Provider,
73    pub selected: bool,
74    /// The credential as typed. Empty means "no value".
75    pub value: String,
76    /// The credential is already in the environment, under this variable, and
77    /// is not being written to the config.
78    pub from_env: Option<&'static str>,
79    /// Reasoning effort, for the Claude Code transport only.
80    pub effort: usize,
81    pub outcome: Outcome,
82    /// A verification is in flight.
83    pub checking: bool,
84}
85
86impl ProviderRow {
87    /// Whether this provider has something to verify.
88    pub fn has_credential(&self) -> bool {
89        match self.provider.credential {
90            Credential::ApiKey => !self.value.is_empty() || self.from_env.is_some(),
91            // Ollama and the Claude Code transport need no key; selecting them
92            // is the whole configuration, so they are always checkable.
93            Credential::BaseUrl | Credential::None => true,
94        }
95    }
96}
97
98/// One row of the blueprint list.
99#[derive(Debug, Clone)]
100pub struct AgentRow {
101    pub agent: &'static BundledAgent,
102    pub action: AgentAction,
103    pub selected: bool,
104}
105
106/// One importable MCP server.
107#[derive(Debug, Clone)]
108pub struct McpRow {
109    pub candidate: Candidate,
110    /// Which harness it came from.
111    pub source: String,
112    pub selected: bool,
113    /// A server of this name is already in the Leviath config.
114    pub collides: bool,
115    /// The name it will actually be stored under, after collision handling.
116    pub name: String,
117}
118
119/// A single editable setting on the Defaults / Limits screens.
120#[derive(Debug, Clone)]
121pub struct Field {
122    pub label: &'static str,
123    pub help: &'static str,
124    pub value: FieldValue,
125}
126
127/// The kinds of setting the wizard edits, and therefore the ways a key press
128/// can mean something.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum FieldValue {
131    /// A whole number. `None` means unset.
132    Number(Option<u64>),
133    /// A toggle.
134    Bool(bool),
135    /// One of a fixed list.
136    Choice { options: Vec<String>, index: usize },
137}
138
139impl FieldValue {
140    /// How the value reads on screen.
141    pub fn display(&self) -> String {
142        match self {
143            Self::Number(None) => "(unset)".to_string(),
144            Self::Number(Some(n)) => n.to_string(),
145            Self::Bool(true) => "yes".to_string(),
146            Self::Bool(false) => "no".to_string(),
147            Self::Choice { options, index } => match options.get(*index) {
148                Some(chosen) => chosen.clone(),
149                None => "(none)".to_string(),
150            },
151        }
152    }
153
154    /// The options of a choice field; empty for any other kind.
155    pub fn options(&self) -> &[String] {
156        match self {
157            Self::Choice { options, .. } => options,
158            Self::Number(_) | Self::Bool(_) => &[],
159        }
160    }
161}
162
163/// Asked of the background verifier.
164#[derive(Debug, Clone)]
165pub struct VerifyRequest {
166    pub provider_id: String,
167    pub creds: leviath_runtime::provider_creds::ProviderCreds,
168}
169
170/// Answered by the background verifier.
171#[derive(Debug, Clone)]
172pub struct VerifyReply {
173    pub provider_id: String,
174    pub outcome: Outcome,
175}
176
177/// Where the text being typed goes when it is committed.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum EditTarget {
180    /// The credential of the provider at this index in `providers`.
181    Credential(usize),
182    /// The field at this index of the current step's fields.
183    Field(usize),
184}
185
186/// An in-progress text edit.
187#[derive(Debug, Clone)]
188pub struct Edit {
189    pub target: EditTarget,
190    /// The shared single-line editor (cursor movement, masking).
191    pub(crate) line: crate::tui::widgets::line_edit::LineEdit,
192}
193
194/// Why a confirmation dialog is on screen, so its Yes can be routed.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum ConfirmPurpose {
197    /// `q`/Ctrl-C with unsaved choices: quit and discard?
198    QuitDiscard,
199    /// Saving with the Claude Code transport selected: accept the terms risk?
200    SaveTos,
201    /// Leaving the Providers screen with nothing selected: continue anyway?
202    NoProviders,
203}
204
205/// A pending confirmation: the dialog plus what its Yes means.
206#[derive(Debug, Clone, PartialEq)]
207pub struct PendingConfirm {
208    pub purpose: ConfirmPurpose,
209    pub(crate) dialog: crate::tui::widgets::confirm::Confirm,
210}
211
212/// The whole wizard.
213pub struct Wizard {
214    pub step: Step,
215    /// Selected row within the current step.
216    pub cursor: usize,
217    pub providers: Vec<ProviderRow>,
218    /// Which selected provider the credential screen is showing.
219    pub detail: usize,
220    pub defaults: Vec<Field>,
221    pub limits: Vec<Field>,
222    pub agents: Vec<AgentRow>,
223    pub mcp: Vec<McpRow>,
224    pub mcp_scan_errors: Vec<String>,
225    pub edit: Option<Edit>,
226    /// Show credentials in clear text.
227    pub reveal: bool,
228    pub show_help: bool,
229    /// A confirmation dialog is on screen, and (after Ctrl-C) it is the only
230    /// thing keys mean until it is answered.
231    pub confirm: Option<PendingConfirm>,
232    /// The user has changed something since the wizard opened, so quitting
233    /// silently would discard real choices.
234    pub dirty: bool,
235    /// The user has acknowledged the Claude Code transport's terms risk. A
236    /// hard gate on saving: the transport cannot be written to the config
237    /// without it, and deselecting the transport withdraws it.
238    pub claude_code_tos_accepted: bool,
239    pub should_quit: bool,
240    /// Set once the plan has been applied, so the loop knows to stop.
241    pub finished: bool,
242    /// A one-line status message.
243    pub message: Option<String>,
244    /// The config as loaded *from the file*, which is what the plan is
245    /// diffed against and built on.
246    pub base: Config,
247    /// Credentials present only in the environment. Shown, never written.
248    pub env_only: HashMap<&'static str, String>,
249    /// Opens a provider's signup page. Injected rather than called directly:
250    /// `lev dash` once had a unit test launch a real browser, and this is the
251    /// same shape of hazard.
252    pub opener: leviath_mcp::BrowserOpener,
253    pub verify_tx: mpsc::UnboundedSender<VerifyRequest>,
254    verify_rx: Option<mpsc::UnboundedReceiver<VerifyRequest>>,
255    reply_tx: mpsc::UnboundedSender<VerifyReply>,
256    pub reply_rx: mpsc::UnboundedReceiver<VerifyReply>,
257    /// Tick counter, for the spinner.
258    pub ticks: u64,
259}
260
261/// The environment variables the wizard reports as already-supplying a
262/// credential, paired with their provider.
263fn env_credentials(lookup: &dyn Fn(&str) -> Option<String>) -> HashMap<&'static str, String> {
264    catalog::providers()
265        .iter()
266        .filter_map(|p| {
267            let var = p.env_var?;
268            let value = lookup(var)?;
269            (!value.is_empty()).then_some((var, value))
270        })
271        .collect()
272}
273
274impl Wizard {
275    /// Build the wizard from the config *file* and the surrounding environment.
276    ///
277    /// `base` must come from reading the file, not from `Config::load()`:
278    /// `load` folds `$ANTHROPIC_API_KEY` and friends into the struct, and the
279    /// old wizard then re-serialized the whole thing - silently writing a key
280    /// the user had deliberately kept in their environment into
281    /// `~/.leviath/config.toml`. Environment-supplied credentials are tracked
282    /// separately in `env_only` and shown as such.
283    pub fn new(
284        base: Config,
285        env_lookup: &dyn Fn(&str) -> Option<String>,
286        candidates: Vec<(String, Candidate)>,
287        scan_errors: Vec<String>,
288        agents_dir: &std::path::Path,
289        opener: leviath_mcp::BrowserOpener,
290    ) -> Self {
291        let env_only = env_credentials(env_lookup);
292
293        let providers = catalog::providers()
294            .into_iter()
295            .map(|provider| {
296                let stored = catalog::stored_credential(&base, provider.id);
297                let from_env = provider
298                    .env_var
299                    .filter(|v| stored.is_none() && env_only.contains_key(v));
300                ProviderRow {
301                    selected: catalog::is_configured(&base, provider.id) || from_env.is_some(),
302                    value: stored.unwrap_or_default(),
303                    from_env,
304                    effort: effort_index(base.providers.claude_code_effort.as_deref()),
305                    outcome: Outcome::Skipped,
306                    checking: false,
307                    provider,
308                }
309            })
310            .collect();
311
312        let agents = crate::bundled::plan_agent_actions(agents_dir)
313            .into_iter()
314            .map(|(agent, action)| AgentRow {
315                selected: action.is_change(),
316                agent,
317                action,
318            })
319            .collect();
320
321        let mcp = candidates
322            .into_iter()
323            .map(|(source, candidate)| {
324                let collides =
325                    import::already_configured(&base.mcp_servers, &candidate.config.name);
326                let name = import::dedup_name(&base.mcp_servers, &candidate.config.name);
327                McpRow {
328                    // A server already configured under this name is offered
329                    // unchecked: the user has it, and silently adding a second
330                    // copy under a suffixed name is not what "import" means.
331                    selected: !collides,
332                    source,
333                    collides,
334                    name,
335                    candidate,
336                }
337            })
338            .collect();
339
340        let (verify_tx, verify_rx) = mpsc::unbounded_channel();
341        let (reply_tx, reply_rx) = mpsc::unbounded_channel();
342
343        let mut wizard = Self {
344            step: Step::Welcome,
345            cursor: 0,
346            providers,
347            detail: 0,
348            defaults: Vec::new(),
349            limits: limits_fields(&base),
350            agents,
351            mcp,
352            mcp_scan_errors: scan_errors,
353            edit: None,
354            reveal: false,
355            show_help: false,
356            confirm: None,
357            dirty: false,
358            claude_code_tos_accepted: false,
359            should_quit: false,
360            finished: false,
361            message: None,
362            base,
363            env_only,
364            opener,
365            verify_tx,
366            verify_rx: Some(verify_rx),
367            reply_tx,
368            reply_rx,
369            ticks: 0,
370        };
371        wizard.rebuild_defaults();
372        wizard
373    }
374
375    /// Hand the background verifier loop its channel ends. Returns `None` if
376    /// already taken.
377    pub fn take_verify_ends(
378        &mut self,
379    ) -> Option<(
380        mpsc::UnboundedReceiver<VerifyRequest>,
381        mpsc::UnboundedSender<VerifyReply>,
382    )> {
383        self.verify_rx.take().map(|rx| (rx, self.reply_tx.clone()))
384    }
385
386    // ── Rows and navigation ─────────────────────────────────────────────────
387
388    /// Providers the user picked, in table order.
389    pub fn selected_providers(&self) -> Vec<usize> {
390        self.providers
391            .iter()
392            .enumerate()
393            .filter(|(_, r)| r.selected)
394            .map(|(i, _)| i)
395            .collect()
396    }
397
398    /// The provider row the credential screen is currently showing.
399    pub fn detail_row(&self) -> Option<usize> {
400        self.selected_providers().get(self.detail).copied()
401    }
402
403    /// Whether the Claude Code transport is one of the picked providers.
404    pub fn claude_code_selected(&self) -> bool {
405        self.providers
406            .iter()
407            .any(|r| r.selected && r.provider.id == "claude-code")
408    }
409
410    /// Whether saving must first ask the user to acknowledge Anthropic's
411    /// terms. Enabling the transport routes inference through a subscription
412    /// session, so the risk is confirmed once, explicitly, rather than being
413    /// buried in a paragraph nobody reads.
414    pub fn needs_tos_confirmation(&self) -> bool {
415        self.claude_code_selected() && !self.claude_code_tos_accepted
416    }
417
418    /// The fields the current step edits, if it edits fields.
419    pub fn fields(&self) -> &[Field] {
420        match self.step {
421            Step::Defaults => &self.defaults,
422            Step::Limits => &self.limits,
423            _ => &[],
424        }
425    }
426
427    pub(super) fn fields_mut(&mut self) -> Option<&mut Vec<Field>> {
428        match self.step {
429            Step::Defaults => Some(&mut self.defaults),
430            Step::Limits => Some(&mut self.limits),
431            _ => None,
432        }
433    }
434
435    /// How many selectable rows the current step has.
436    pub fn row_count(&self) -> usize {
437        match self.step {
438            Step::Welcome | Step::Review => 0,
439            Step::Providers => self.providers.len(),
440            Step::ProviderDetail => usize::from(self.detail_row().is_some()),
441            Step::Defaults => self.defaults.len(),
442            Step::Limits => self.limits.len(),
443            Step::Agents => self.agents.len(),
444            Step::Mcp => self.mcp.len(),
445        }
446    }
447
448    /// How many cursor positions the current step has: its rows plus the
449    /// Continue/action button that every step ends with.
450    pub fn nav_rows(&self) -> usize {
451        self.row_count() + 1
452    }
453
454    /// Whether the cursor sits on the step's Continue/action button (the
455    /// virtual row after the last real one).
456    pub fn on_continue(&self) -> bool {
457        self.cursor == self.row_count()
458    }
459
460    /// The label of the current step's Continue/action button. It carries
461    /// state (selection counts, what screen is next) so advancing is never a
462    /// surprise.
463    pub fn continue_label(&self) -> String {
464        match self.step {
465            Step::Welcome => "Get started".to_string(),
466            Step::Review => "Apply and finish".to_string(),
467            Step::Providers => {
468                let count = self.selected_providers().len();
469                if count == 0 {
470                    "Continue (no providers selected)".to_string()
471                } else {
472                    format!("Continue: {} ({count} selected)", self.next_step_title())
473                }
474            }
475            Step::ProviderDetail => {
476                let selected = self.selected_providers();
477                match selected.get(self.detail + 1) {
478                    Some(&next) => format!("Next: {}", self.providers[next].provider.display),
479                    None => format!("Continue: {}", self.next_step_title()),
480                }
481            }
482            Step::Defaults | Step::Limits | Step::Agents | Step::Mcp => {
483                format!("Continue: {}", self.next_step_title())
484            }
485        }
486    }
487
488    /// The title of the step `next_step` would land on.
489    fn next_step_title(&self) -> &'static str {
490        let mut index = self.step.index();
491        while index + 1 < Step::ALL.len() {
492            index += 1;
493            let step = Step::ALL[index];
494            if !self.is_empty_step(step) {
495                return step.title();
496            }
497        }
498        Step::Review.title()
499    }
500
501    /// Move the selection, clamped to the step's rows plus its button.
502    pub fn move_cursor(&mut self, delta: isize) {
503        let count = self.nav_rows();
504        let next = self.cursor as isize + delta;
505        self.cursor = next.clamp(0, count as isize - 1) as usize;
506    }
507
508    /// Advance to the next step, skipping ones with nothing to show. Skipping
509    /// the credential screen is announced rather than silent: it looks exactly
510    /// like a bug when a screen the breadcrumb promises never appears.
511    pub fn next_step(&mut self) {
512        let mut index = self.step.index();
513        while index + 1 < Step::ALL.len() {
514            index += 1;
515            let step = Step::ALL[index];
516            if !self.is_empty_step(step) {
517                self.enter(step);
518                return;
519            }
520            if step == Step::ProviderDetail {
521                self.message =
522                    Some("Skipped Credentials: no selected provider needs setup.".to_string());
523            }
524        }
525        // Past the last step: the Review screen's action is to save.
526        self.enter(Step::Review);
527    }
528
529    /// Go back a step, skipping empty ones. No-op on the first.
530    pub fn prev_step(&mut self) {
531        let mut index = self.step.index();
532        while index > 0 {
533            index -= 1;
534            let step = Step::ALL[index];
535            if !self.is_empty_step(step) {
536                self.enter(step);
537                return;
538            }
539        }
540    }
541
542    /// Whether a step has nothing worth showing, and should be skipped.
543    ///
544    /// Only the two discovery-driven screens can be empty: nobody should have
545    /// to press Enter through "no MCP servers found" on a clean machine.
546    fn is_empty_step(&self, step: Step) -> bool {
547        match step {
548            Step::Mcp => self.mcp.is_empty() && self.mcp_scan_errors.is_empty(),
549            Step::ProviderDetail => self.detail_row().is_none(),
550            _ => false,
551        }
552    }
553
554    /// Switch to `step`, resetting per-step state.
555    pub fn enter(&mut self, step: Step) {
556        self.step = step;
557        self.cursor = 0;
558        self.edit = None;
559        if step == Step::Defaults {
560            // The model picker is populated by verification, which may have
561            // finished since the last visit.
562            self.rebuild_defaults();
563        }
564    }
565
566    /// Within the credential screen, move to the next selected provider;
567    /// returns false when there is no next one.
568    pub fn next_detail(&mut self) -> bool {
569        if self.detail + 1 < self.selected_providers().len() {
570            self.detail += 1;
571            self.cursor = 0;
572            self.edit = None;
573            return true;
574        }
575        false
576    }
577
578    /// The reverse of [`Self::next_detail`].
579    pub fn prev_detail(&mut self) -> bool {
580        if self.detail > 0 {
581            self.detail -= 1;
582            self.cursor = 0;
583            self.edit = None;
584            return true;
585        }
586        false
587    }
588
589    // ── Verification ────────────────────────────────────────────────────────
590
591    /// Ask the background verifier about the provider at `index`.
592    ///
593    /// A provider with nothing to check is left alone rather than queued: a
594    /// blank API key would fail with a message about the key rather than saying
595    /// the obvious, that none was given.
596    pub fn request_verification(&mut self, index: usize) {
597        let Some(row) = self.providers.get_mut(index) else {
598            return;
599        };
600        if !row.has_credential() {
601            row.outcome = Outcome::Skipped;
602            return;
603        }
604        let id = row.provider.id.to_string();
605        let key = if row.value.is_empty() {
606            self.env_only
607                .get(row.provider.env_var.unwrap_or_default())
608                .cloned()
609        } else {
610            Some(row.value.clone())
611        };
612        let base_url = (row.provider.credential == Credential::BaseUrl).then(|| {
613            if row.value.is_empty() {
614                catalog::DEFAULT_OLLAMA_URL.to_string()
615            } else {
616                row.value.clone()
617            }
618        });
619        row.checking = true;
620
621        let creds = leviath_runtime::provider_creds::ProviderCreds {
622            name: id.clone(),
623            api_key: base_url.is_none().then_some(key).flatten(),
624            base_url,
625            model_capabilities: HashMap::new(),
626            request_timeout_secs: Some(20),
627            rate_limit: None,
628            options: HashMap::new(),
629        };
630        // A closed receiver means the background task is gone; the row simply
631        // stays "checking" and nothing else breaks.
632        let _ = self.verify_tx.send(VerifyRequest {
633            provider_id: id,
634            creds,
635        });
636    }
637
638    /// Ask about every selected provider at once.
639    pub fn verify_all(&mut self) {
640        for index in self.selected_providers() {
641            self.request_verification(index);
642        }
643    }
644
645    /// Take whatever the background verifier has answered.
646    pub fn drain_verifications(&mut self) {
647        let mut landed = false;
648        while let Ok(reply) = self.reply_rx.try_recv() {
649            if let Some(row) = self
650                .providers
651                .iter_mut()
652                .find(|r| r.provider.id == reply.provider_id)
653            {
654                row.checking = false;
655                row.outcome = reply.outcome;
656                landed = true;
657            }
658        }
659        // A reply carries the model list, and the picker was built from
660        // whatever had arrived when the screen opened. Without this, moving on
661        // from a credential and straight into Defaults shows an empty picker
662        // for the provider that was verified half a second ago. Rebuilding
663        // keeps the current selection, so nothing the user chose is lost.
664        if landed && self.step == Step::Defaults {
665            self.rebuild_defaults();
666        }
667    }
668
669    /// Every model id any provider reported, deduplicated, for the picker.
670    pub fn discovered_models(&self) -> Vec<String> {
671        let mut models: Vec<String> = self
672            .providers
673            .iter()
674            .filter(|r| r.selected)
675            .flat_map(|r| r.outcome.models().iter().cloned())
676            .collect();
677        models.sort();
678        models.dedup();
679        models
680    }
681
682    // ── Forms ───────────────────────────────────────────────────────────────
683
684    /// Rebuild the Defaults screen. Called on entry, since both the provider
685    /// list and the discovered models can change between visits.
686    pub fn rebuild_defaults(&mut self) {
687        let chosen = self.current_default_provider();
688        let providers: Vec<String> = self
689            .selected_providers()
690            .iter()
691            .map(|i| self.providers[*i].provider.id.to_string())
692            .collect();
693        // Fall back to whatever is configured when nothing is selected, so the
694        // field is never empty.
695        let providers = if providers.is_empty() {
696            vec![self.base.default_provider.clone()]
697        } else {
698            providers
699        };
700        let index = providers.iter().position(|p| *p == chosen).unwrap_or(0);
701
702        let mut models = vec!["(provider default)".to_string()];
703        models.extend(self.discovered_models());
704        let current_model = self
705            .current_default_model()
706            .unwrap_or_else(|| "(provider default)".to_string());
707        if !models.contains(&current_model) {
708            models.push(current_model.clone());
709        }
710        let model_index = models
711            .iter()
712            .position(|m| *m == current_model)
713            .unwrap_or_default();
714
715        let timeout = self.current_request_timeout();
716        self.defaults = vec![
717            Field {
718                label: "Default provider",
719                help: "Used by any blueprint that allows a user default.",
720                value: FieldValue::Choice {
721                    options: providers,
722                    index,
723                },
724            },
725            Field {
726                label: "Default model",
727                help: "Filled in from the models your providers reported.",
728                value: FieldValue::Choice {
729                    options: models,
730                    index: model_index,
731                },
732            },
733            Field {
734                label: "Request timeout (seconds)",
735                help: "How long to wait on one inference. Unset uses the provider default.",
736                value: FieldValue::Number(timeout),
737            },
738        ];
739        // Re-pick the concurrency default now that the provider choice is
740        // settled. Doing this only on an arrow press missed the commonest
741        // Ollama case entirely: when it is the *only* provider selected it is
742        // already at index 0, nobody ever presses an arrow, and the limit
743        // stayed at the hosted-API default of 8.
744        self.apply_provider_concurrency_default();
745    }
746
747    /// The default provider as it currently stands in the form, or the base
748    /// config's value before the form exists.
749    fn current_default_provider(&self) -> String {
750        match self.defaults.first().map(|f| &f.value) {
751            Some(FieldValue::Choice { options, index }) => match options.get(*index) {
752                Some(chosen) => chosen.clone(),
753                None => self.base.default_provider.clone(),
754            },
755            _ => self.base.default_provider.clone(),
756        }
757    }
758
759    fn current_default_model(&self) -> Option<String> {
760        match self.defaults.get(1).map(|f| &f.value) {
761            Some(FieldValue::Choice { options, index }) => options.get(*index).cloned(),
762            _ => self.base.default_model.clone(),
763        }
764    }
765
766    fn current_request_timeout(&self) -> Option<u64> {
767        match self.defaults.get(2).map(|f| &f.value) {
768            Some(FieldValue::Number(n)) => *n,
769            _ => self.base.request_timeout_secs,
770        }
771    }
772
773    /// Set the concurrency default that suits the chosen provider.
774    ///
775    /// A local Ollama serves one model at a time, so eight concurrent
776    /// inferences against it queue and thrash rather than going faster. Only
777    /// applied while the field still holds the general default, so a number the
778    /// user typed is never overwritten.
779    pub fn apply_provider_concurrency_default(&mut self) {
780        let ollama = self.current_default_provider() == "ollama";
781        let general = Config::default().limits.max_concurrent_inferences;
782        let local = Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64);
783        let general = general.map(|n| n as u64);
784
785        // `limits[0]` is the concurrency field, built as a `Number` by
786        // `limits_fields`, so this is a total match rather than a fallible
787        // lookup with an arm nothing can reach.
788        let Some(FieldValue::Number(current)) = self.limits.first_mut().map(|f| &mut f.value)
789        else {
790            return;
791        };
792        if ollama && *current == general {
793            *current = local;
794        } else if !ollama && *current == local {
795            *current = general;
796        }
797    }
798
799    // ── Confirmations ───────────────────────────────────────────────────────
800
801    /// `q`/Ctrl-C with unsaved choices: ask before discarding them.
802    pub fn open_quit_confirm(&mut self) {
803        use ratatui::text::Line;
804        self.confirm = Some(PendingConfirm {
805            purpose: ConfirmPurpose::QuitDiscard,
806            dialog: crate::tui::widgets::confirm::Confirm::new(
807                "Quit setup?",
808                vec![Line::from(
809                    "Nothing has been written yet. Your choices so far will be discarded.",
810                )],
811                "Quit",
812                "Stay",
813            ),
814        });
815    }
816
817    /// Saving with the Claude Code transport selected: the terms risk is
818    /// confirmed once, explicitly, on a dialog with real buttons.
819    pub fn open_tos_confirm(&mut self) {
820        use ratatui::text::Line;
821        self.confirm = Some(PendingConfirm {
822            purpose: ConfirmPurpose::SaveTos,
823            dialog: crate::tui::widgets::confirm::Confirm::new(
824                "Claude Code terms of service",
825                vec![
826                    Line::from("Anthropic's terms prohibit third-party developers from offering"),
827                    Line::from("claude.ai subscription auth for their products without prior"),
828                    Line::from("approval. The Claude Code transport routes inference through"),
829                    Line::from("your subscription via the CLI's OAuth session."),
830                    Line::from(""),
831                    Line::from("For unambiguous compliance, use a direct Anthropic API key."),
832                    Line::from(""),
833                    Line::from("Accepting means you take responsibility for compliance."),
834                ],
835                "Accept and save",
836                "Cancel",
837            )
838            .danger(),
839        });
840    }
841
842    /// Leaving the Providers screen with nothing selected: Leviath cannot run
843    /// an agent without a provider, so this is almost always a slip.
844    pub fn open_no_providers_confirm(&mut self) {
845        use ratatui::text::Line;
846        self.confirm = Some(PendingConfirm {
847            purpose: ConfirmPurpose::NoProviders,
848            dialog: crate::tui::widgets::confirm::Confirm::new(
849                "No providers selected",
850                vec![
851                    Line::from("Without a provider, Leviath cannot run any agent."),
852                    Line::from("Select one with Space or Enter, or continue anyway to"),
853                    Line::from("configure providers later."),
854                ],
855                "Continue anyway",
856                "Go back",
857            ),
858        });
859    }
860
861    /// Commit an edited text buffer into wherever it belongs.
862    pub fn commit_edit(&mut self) {
863        let Some(edit) = self.edit.take() else {
864            return;
865        };
866        match edit.target {
867            EditTarget::Credential(index) => {
868                if let Some(row) = self.providers.get_mut(index) {
869                    row.value = edit.line.value().trim().to_string();
870                    // A typed credential replaces the environment's, and the
871                    // row stops claiming the environment supplies it.
872                    if !row.value.is_empty() {
873                        row.from_env = None;
874                    }
875                    row.outcome = Outcome::Skipped;
876                }
877            }
878            EditTarget::Field(index) => {
879                let Some(fields) = self.fields_mut() else {
880                    return;
881                };
882                if let Some(field) = fields.get_mut(index) {
883                    match &mut field.value {
884                        FieldValue::Number(n) => {
885                            let trimmed = edit.line.value().trim();
886                            *n = if trimmed.is_empty() {
887                                None
888                            } else {
889                                trimmed.parse().ok().or(*n)
890                            };
891                        }
892                        // Booleans and choices are never text-edited.
893                        FieldValue::Bool(_) | FieldValue::Choice { .. } => {}
894                    }
895                }
896            }
897        }
898    }
899
900    // ── Producing the plan ──────────────────────────────────────────────────
901
902    /// Fold every choice into the config that will be written.
903    pub fn build_config(&self) -> Config {
904        let mut config = self.base.clone();
905
906        for row in &self.providers {
907            match row.provider.credential {
908                Credential::None => {}
909                _ if !row.selected => catalog::set_credential(&mut config, row.provider.id, None),
910                // An environment-supplied credential is left out of the file:
911                // the user put it in their environment on purpose, and
912                // `Config::load` already reads it back from there.
913                _ if row.value.is_empty() => {
914                    catalog::set_credential(&mut config, row.provider.id, None)
915                }
916                Credential::BaseUrl if row.value == catalog::DEFAULT_OLLAMA_URL => {
917                    // Storing the default would pin it; leaving it unset lets
918                    // the built-in default (and `$OLLAMA_HOST`) apply.
919                    catalog::set_credential(&mut config, row.provider.id, None)
920                }
921                _ => catalog::set_credential(&mut config, row.provider.id, Some(row.value.clone())),
922            }
923        }
924
925        // The transport is always in the table, so this reads its row when
926        // selected rather than guarding a lookup that cannot miss.
927        let transport = self
928            .providers
929            .iter()
930            .find(|r| r.provider.id == "claude-code")
931            .filter(|r| r.selected);
932        config.providers.claude_code_enabled = transport.is_some();
933        if let Some(row) = transport {
934            config.providers.claude_code_effort =
935                Some(effort_options()[row.effort.min(effort_options().len() - 1)].to_string());
936        }
937
938        config.default_provider = self.current_default_provider();
939        config.default_model = self
940            .current_default_model()
941            .filter(|m| m != "(provider default)");
942        config.request_timeout_secs = self.current_request_timeout();
943
944        apply_limits_fields(&mut config, &self.limits);
945
946        for row in self.mcp.iter().filter(|r| r.selected) {
947            let mut server = row.candidate.config.clone();
948            server.name = row.name.clone();
949            config.mcp_servers.push(server);
950        }
951
952        config
953    }
954
955    /// The plan this wizard describes.
956    pub fn build_plan(&self) -> SetupPlan {
957        SetupPlan {
958            config: self.build_config(),
959            agents: self
960                .agents
961                .iter()
962                .filter(|r| r.selected)
963                .map(|r| r.agent)
964                .collect(),
965        }
966    }
967
968    /// Lines for the review screen.
969    pub fn review_lines(&self) -> Vec<String> {
970        let plan = self.build_plan();
971        let changes = super::plan::changes(&self.base, &plan);
972        if changes.is_empty() {
973            vec!["Nothing would change.".to_string()]
974        } else {
975            changes
976        }
977    }
978
979    /// MCP rows carrying a credential copied verbatim out of another tool's
980    /// config, which importing would duplicate into `~/.leviath/config.toml`.
981    pub fn selected_inline_secrets(&self) -> Vec<String> {
982        self.mcp
983            .iter()
984            .filter(|r| r.selected && !r.candidate.inline_secrets.is_empty())
985            .map(|r| format!("{}: {}", r.name, r.candidate.inline_secrets.join(", ")))
986            .collect()
987    }
988}
989
990/// Reasoning-effort levels for the Claude Code transport, from the provider
991/// rather than re-typed here.
992pub fn effort_options() -> &'static [&'static str] {
993    &leviath_providers::claude_code::EFFORT_LEVELS
994}
995
996/// Index of `effort` in [`effort_options`], defaulting to the provider default.
997fn effort_index(effort: Option<&str>) -> usize {
998    let wanted = effort.unwrap_or(leviath_providers::claude_code::DEFAULT_EFFORT);
999    effort_options()
1000        .iter()
1001        .position(|e| *e == wanted)
1002        .unwrap_or_default()
1003}
1004
1005/// The Limits screen's fields, seeded from a config.
1006fn limits_fields(config: &Config) -> Vec<Field> {
1007    vec![
1008        Field {
1009            label: "Max concurrent inferences",
1010            help: "How many model calls run at once across all agents.",
1011            value: FieldValue::Number(config.limits.max_concurrent_inferences.map(|n| n as u64)),
1012        },
1013        Field {
1014            label: "Max concurrent tools",
1015            help: "How many tool calls run at once within one batch.",
1016            value: FieldValue::Number(Some(config.limits.max_concurrent_tools as u64)),
1017        },
1018        Field {
1019            label: "Default max iterations",
1020            help: "Per-stage ceiling when a blueprint sets none.",
1021            value: FieldValue::Number(config.limits.default_max_iterations.map(|n| n as u64)),
1022        },
1023        Field {
1024            label: "Exact token counting",
1025            help: "Ask the provider for real token counts instead of estimating. Slower.",
1026            value: FieldValue::Bool(config.limits.exact_token_counting),
1027        },
1028        Field {
1029            label: "Batch tool-call hint",
1030            help: "Nudge models to request several tools in one turn.",
1031            value: FieldValue::Bool(config.batch_tool_hint),
1032        },
1033        Field {
1034            label: "Platform shell hint",
1035            help: "Tell models what shell they get. Only says anything on Windows (cmd.exe).",
1036            value: FieldValue::Bool(config.shell_hint),
1037        },
1038        Field {
1039            label: "Stall timeout (seconds)",
1040            help: "Fail a run that can never dispatch (unconfigured provider). 0 waits forever.",
1041            value: FieldValue::Number(Some(config.limits.stall_timeout_secs)),
1042        },
1043        Field {
1044            label: "Dead cycles before relief",
1045            help: "Widen the tool lane after this many 30s cycles with work queued and nothing moving. 0 never does.",
1046            value: FieldValue::Number(Some(config.limits.dead_cycles_before_relief as u64)),
1047        },
1048        Field {
1049            label: "Finished run retention (seconds)",
1050            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.",
1051            value: FieldValue::Number(Some(config.limits.finished_retention_secs)),
1052        },
1053        Field {
1054            label: "Wedge timeout (seconds)",
1055            help: "Fail a run nothing in the engine can reach any more. 0 is off; 300 is a sensible value.",
1056            value: FieldValue::Number(Some(config.limits.wedge_timeout_secs)),
1057        },
1058        Field {
1059            label: "Interaction timeout (seconds)",
1060            help: "Resolve a prompt nobody answered after this long, so the run carries on. 0 waits for ever.",
1061            value: FieldValue::Number(Some(config.limits.interaction_timeout_secs)),
1062        },
1063    ]
1064}
1065
1066/// Write the Limits screen's fields back into a config.
1067fn apply_limits_fields(config: &mut Config, fields: &[Field]) {
1068    for (index, field) in fields.iter().enumerate() {
1069        match (index, &field.value) {
1070            (0, FieldValue::Number(n)) => {
1071                config.limits.max_concurrent_inferences = n.map(|n| n as usize)
1072            }
1073            // A zero here would deadlock every tool batch, so an explicit unset
1074            // or 0 falls back to the default rather than being stored.
1075            (1, FieldValue::Number(n)) => {
1076                config.limits.max_concurrent_tools = n
1077                    .filter(|n| *n > 0)
1078                    .map(|n| n as usize)
1079                    .unwrap_or(Config::default().limits.max_concurrent_tools)
1080            }
1081            (2, FieldValue::Number(n)) => {
1082                config.limits.default_max_iterations = n.map(|n| n as usize)
1083            }
1084            (3, FieldValue::Bool(b)) => config.limits.exact_token_counting = *b,
1085            (4, FieldValue::Bool(b)) => config.batch_tool_hint = *b,
1086            (5, FieldValue::Bool(b)) => config.shell_hint = *b,
1087            // Unset means "leave the watchdog at its default", not "disable it";
1088            // disabling is an explicit 0.
1089            (6, FieldValue::Number(n)) => {
1090                config.limits.stall_timeout_secs =
1091                    n.unwrap_or(Config::default().limits.stall_timeout_secs)
1092            }
1093            // Same rule: unset keeps the default, 0 is an explicit "never".
1094            (7, FieldValue::Number(n)) => {
1095                config.limits.dead_cycles_before_relief = n
1096                    .map(|n| n as u32)
1097                    .unwrap_or(Config::default().limits.dead_cycles_before_relief)
1098            }
1099            // And again: unset keeps the default, 0 means keep nothing.
1100            (8, FieldValue::Number(n)) => {
1101                config.limits.finished_retention_secs =
1102                    n.unwrap_or(Config::default().limits.finished_retention_secs)
1103            }
1104            // Same rule once more, and here the default is itself 0 (off).
1105            (9, FieldValue::Number(n)) => {
1106                config.limits.wedge_timeout_secs =
1107                    n.unwrap_or(Config::default().limits.wedge_timeout_secs)
1108            }
1109            // Same rule again: unset keeps the default hour, 0 is an explicit
1110            // "wait for a person however long it takes".
1111            (10, FieldValue::Number(n)) => {
1112                config.limits.interaction_timeout_secs =
1113                    n.unwrap_or(Config::default().limits.interaction_timeout_secs)
1114            }
1115            _ => {}
1116        }
1117    }
1118}
1119
1120/// Merge every scan into the flat `(source, candidate)` list the wizard takes,
1121/// alongside the human-readable errors.
1122pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
1123    let mut candidates = Vec::new();
1124    let mut errors = Vec::new();
1125    for scan in scans {
1126        match scan.result {
1127            Ok(found) => candidates.extend(
1128                found
1129                    .into_iter()
1130                    .map(|c| (scan.source.display.to_string(), c)),
1131            ),
1132            Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
1133        }
1134    }
1135    (candidates, errors)
1136}
1137
1138/// Build an [`MCPServerConfig`] list from selected rows. Exposed for tests and
1139/// for any future non-terminal front-end.
1140pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
1141    rows.iter()
1142        .filter(|r| r.selected)
1143        .map(|r| {
1144            let mut server = r.candidate.config.clone();
1145            server.name = r.name.clone();
1146            server
1147        })
1148        .collect()
1149}
1150
1151#[cfg(test)]
1152pub(super) mod tests {
1153    use super::*;
1154    use crate::bundled::BUNDLED_AGENTS;
1155
1156    /// A wizard over tempdirs and a fixed environment, with a browser opener
1157    /// that records rather than launches.
1158    pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
1159        Wizard::new(
1160            Config::default(),
1161            &|_| None,
1162            Vec::new(),
1163            Vec::new(),
1164            agents_dir,
1165            std::sync::Arc::new(|_| true),
1166        )
1167    }
1168
1169    fn candidate(name: &str) -> Candidate {
1170        Candidate {
1171            config: MCPServerConfig::stdio(name, "npx", vec![]),
1172            scope: String::new(),
1173            inline_secrets: Vec::new(),
1174        }
1175    }
1176
1177    // ─── Step ───────────────────────────────────────────────────────────────
1178
1179    #[test]
1180    fn every_step_is_titled_and_ordered() {
1181        for (index, step) in Step::ALL.iter().enumerate() {
1182            assert!(!step.title().is_empty(), "{step:?} has no title");
1183            assert_eq!(step.index(), index);
1184        }
1185    }
1186
1187    // ─── construction ───────────────────────────────────────────────────────
1188
1189    #[test]
1190    fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
1191        let dir = tempfile::tempdir().unwrap();
1192
1193        let wizard = test_wizard(dir.path());
1194
1195        assert_eq!(wizard.step, Step::Welcome);
1196        assert!(wizard.selected_providers().is_empty());
1197        assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
1198        assert!(
1199            wizard.agents.iter().all(|r| r.selected),
1200            "a fresh install should offer to install everything"
1201        );
1202        assert!(
1203            wizard
1204                .agents
1205                .iter()
1206                .all(|r| r.action == AgentAction::Install)
1207        );
1208    }
1209
1210    #[test]
1211    fn already_installed_agents_are_listed_but_not_reselected() {
1212        let dir = tempfile::tempdir().unwrap();
1213        for agent in BUNDLED_AGENTS {
1214            crate::bundled::install_bundled(agent, dir.path()).unwrap();
1215        }
1216
1217        let wizard = test_wizard(dir.path());
1218
1219        assert!(
1220            wizard.agents.iter().all(|r| !r.selected),
1221            "nothing needs doing, so nothing should be pre-checked"
1222        );
1223    }
1224
1225    #[test]
1226    fn a_configured_provider_starts_selected_with_its_credential() {
1227        let dir = tempfile::tempdir().unwrap();
1228        let base = Config {
1229            providers: crate::config::ProviderConfig {
1230                anthropic_api_key: Some("sk-ant-stored".to_string()),
1231                ..Config::default().providers
1232            },
1233            ..Config::default()
1234        };
1235
1236        let wizard = Wizard::new(
1237            base,
1238            &|_| None,
1239            Vec::new(),
1240            Vec::new(),
1241            dir.path(),
1242            std::sync::Arc::new(|_| true),
1243        );
1244
1245        let row = wizard
1246            .providers
1247            .iter()
1248            .find(|r| r.provider.id == "anthropic")
1249            .expect("anthropic is in the table");
1250        assert!(row.selected);
1251        assert_eq!(row.value, "sk-ant-stored");
1252        assert!(row.from_env.is_none());
1253    }
1254
1255    #[test]
1256    fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
1257        // The bug this closes: `Config::load` folds env keys into the struct,
1258        // so a wizard that re-serializes the whole thing silently writes a key
1259        // the user deliberately kept in their environment into
1260        // ~/.leviath/config.toml.
1261        let dir = tempfile::tempdir().unwrap();
1262
1263        let wizard = Wizard::new(
1264            Config::default(),
1265            &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
1266            Vec::new(),
1267            Vec::new(),
1268            dir.path(),
1269            std::sync::Arc::new(|_| true),
1270        );
1271
1272        let row = wizard
1273            .providers
1274            .iter()
1275            .find(|r| r.provider.id == "anthropic")
1276            .expect("anthropic is in the table");
1277        assert!(row.selected, "the provider is usable, so it is selected");
1278        assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
1279        assert!(row.value.is_empty());
1280
1281        let written = wizard.build_config();
1282        assert!(
1283            written.providers.anthropic_api_key.is_none(),
1284            "an environment-supplied key must not be copied into the config"
1285        );
1286    }
1287
1288    #[test]
1289    fn a_stored_key_wins_over_the_environment() {
1290        // Both present: the file is what setup is editing, so that is what is
1291        // shown and kept.
1292        let dir = tempfile::tempdir().unwrap();
1293        let base = Config {
1294            providers: crate::config::ProviderConfig {
1295                anthropic_api_key: Some("sk-ant-stored".to_string()),
1296                ..Config::default().providers
1297            },
1298            ..Config::default()
1299        };
1300
1301        let wizard = Wizard::new(
1302            base,
1303            &|_| Some("sk-ant-from-env".to_string()),
1304            Vec::new(),
1305            Vec::new(),
1306            dir.path(),
1307            std::sync::Arc::new(|_| true),
1308        );
1309
1310        let row = &wizard.providers[0];
1311        assert!(row.from_env.is_none());
1312        assert_eq!(row.value, "sk-ant-stored");
1313    }
1314
1315    #[test]
1316    fn an_empty_environment_variable_does_not_count_as_a_credential() {
1317        let dir = tempfile::tempdir().unwrap();
1318
1319        let wizard = Wizard::new(
1320            Config::default(),
1321            &|_| Some(String::new()),
1322            Vec::new(),
1323            Vec::new(),
1324            dir.path(),
1325            std::sync::Arc::new(|_| true),
1326        );
1327
1328        assert!(wizard.env_only.is_empty());
1329        assert!(wizard.selected_providers().is_empty());
1330    }
1331
1332    // ─── MCP rows ───────────────────────────────────────────────────────────
1333
1334    #[test]
1335    fn an_importable_server_is_preselected_and_named_as_found() {
1336        let dir = tempfile::tempdir().unwrap();
1337
1338        let wizard = Wizard::new(
1339            Config::default(),
1340            &|_| None,
1341            vec![("Claude Code".to_string(), candidate("fs"))],
1342            Vec::new(),
1343            dir.path(),
1344            std::sync::Arc::new(|_| true),
1345        );
1346
1347        assert_eq!(wizard.mcp.len(), 1);
1348        assert!(wizard.mcp[0].selected);
1349        assert!(!wizard.mcp[0].collides);
1350        assert_eq!(wizard.mcp[0].name, "fs");
1351        assert_eq!(wizard.mcp[0].source, "Claude Code");
1352    }
1353
1354    #[test]
1355    fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
1356        // The user already has it. Silently adding a second copy under a
1357        // suffixed name is not what "import" means.
1358        let dir = tempfile::tempdir().unwrap();
1359        let base = Config {
1360            mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
1361            ..Config::default()
1362        };
1363
1364        let wizard = Wizard::new(
1365            base,
1366            &|_| None,
1367            vec![("Cursor".to_string(), candidate("fs"))],
1368            Vec::new(),
1369            dir.path(),
1370            std::sync::Arc::new(|_| true),
1371        );
1372
1373        assert!(!wizard.mcp[0].selected);
1374        assert!(wizard.mcp[0].collides);
1375        assert_eq!(wizard.mcp[0].name, "fs-2");
1376
1377        // Selecting it anyway stores it under the free name, leaving the
1378        // original alone.
1379        let mut wizard = wizard;
1380        wizard.mcp[0].selected = true;
1381        let config = wizard.build_config();
1382        let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
1383        assert_eq!(names, vec!["fs", "fs-2"]);
1384    }
1385
1386    #[test]
1387    fn selected_servers_renames_and_filters() {
1388        let dir = tempfile::tempdir().unwrap();
1389        let mut wizard = Wizard::new(
1390            Config::default(),
1391            &|_| None,
1392            vec![
1393                ("A".to_string(), candidate("keep")),
1394                ("B".to_string(), candidate("drop")),
1395            ],
1396            Vec::new(),
1397            dir.path(),
1398            std::sync::Arc::new(|_| true),
1399        );
1400        wizard.mcp[1].selected = false;
1401        wizard.mcp[0].name = "renamed".to_string();
1402
1403        let servers = selected_servers(&wizard.mcp);
1404
1405        assert_eq!(servers.len(), 1);
1406        assert_eq!(servers[0].name, "renamed");
1407    }
1408
1409    #[test]
1410    fn inline_secrets_are_reported_only_for_selected_rows() {
1411        let dir = tempfile::tempdir().unwrap();
1412        let mut secretive = candidate("leaky");
1413        secretive.inline_secrets = vec!["API_TOKEN".to_string()];
1414
1415        let mut wizard = Wizard::new(
1416            Config::default(),
1417            &|_| None,
1418            vec![
1419                ("A".to_string(), secretive),
1420                ("B".to_string(), candidate("clean")),
1421            ],
1422            Vec::new(),
1423            dir.path(),
1424            std::sync::Arc::new(|_| true),
1425        );
1426
1427        assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
1428        wizard.mcp[0].selected = false;
1429        assert!(wizard.selected_inline_secrets().is_empty());
1430    }
1431
1432    // ─── navigation ─────────────────────────────────────────────────────────
1433
1434    #[test]
1435    fn the_cursor_stays_inside_the_current_step() {
1436        let dir = tempfile::tempdir().unwrap();
1437        let mut wizard = test_wizard(dir.path());
1438        wizard.enter(Step::Providers);
1439
1440        wizard.move_cursor(-5);
1441        assert_eq!(wizard.cursor, 0);
1442        wizard.move_cursor(100);
1443        assert_eq!(
1444            wizard.cursor,
1445            wizard.providers.len(),
1446            "clamped to the Continue button after the last row"
1447        );
1448        assert!(wizard.on_continue());
1449    }
1450
1451    #[test]
1452    fn a_step_with_no_rows_pins_the_cursor_at_zero() {
1453        let dir = tempfile::tempdir().unwrap();
1454        let mut wizard = test_wizard(dir.path());
1455        wizard.enter(Step::Welcome);
1456        wizard.cursor = 4;
1457
1458        wizard.move_cursor(1);
1459
1460        assert_eq!(wizard.cursor, 0);
1461        assert_eq!(wizard.row_count(), 0);
1462    }
1463
1464    #[test]
1465    fn the_continue_label_names_where_it_goes() {
1466        let dir = tempfile::tempdir().unwrap();
1467        let mut wizard = test_wizard(dir.path());
1468
1469        wizard.enter(Step::Welcome);
1470        assert_eq!(wizard.continue_label(), "Get started");
1471
1472        wizard.enter(Step::Providers);
1473        assert_eq!(wizard.continue_label(), "Continue (no providers selected)");
1474        wizard.providers[0].selected = true;
1475        wizard.providers[1].selected = true;
1476        assert_eq!(
1477            wizard.continue_label(),
1478            "Continue: Credentials (2 selected)"
1479        );
1480
1481        // On the credential walk: the next selected provider, then the next step.
1482        wizard.enter(Step::ProviderDetail);
1483        assert_eq!(
1484            wizard.continue_label(),
1485            format!("Next: {}", wizard.providers[1].provider.display)
1486        );
1487        wizard.detail = 1;
1488        assert_eq!(wizard.continue_label(), "Continue: Defaults");
1489
1490        wizard.enter(Step::Limits);
1491        assert_eq!(wizard.continue_label(), "Continue: Agents");
1492
1493        wizard.enter(Step::Review);
1494        assert_eq!(wizard.continue_label(), "Apply and finish");
1495    }
1496
1497    #[test]
1498    fn next_step_title_past_the_last_step_falls_back_to_review() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let mut wizard = test_wizard(dir.path());
1501        wizard.enter(Step::Review);
1502        assert_eq!(wizard.next_step_title(), "Review");
1503    }
1504
1505    #[test]
1506    fn empty_discovery_steps_are_skipped_in_both_directions() {
1507        // Nobody should have to press Enter through "no MCP servers found" on a
1508        // clean machine, or through a credentials screen with no providers
1509        // picked.
1510        let dir = tempfile::tempdir().unwrap();
1511        let mut wizard = test_wizard(dir.path());
1512        assert!(wizard.mcp.is_empty());
1513
1514        wizard.enter(Step::Providers);
1515        wizard.next_step();
1516        assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
1517
1518        wizard.enter(Step::Agents);
1519        wizard.next_step();
1520        assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
1521
1522        wizard.prev_step();
1523        assert_eq!(wizard.step, Step::Agents);
1524    }
1525
1526    #[test]
1527    fn a_nonempty_discovery_step_is_visited() {
1528        let dir = tempfile::tempdir().unwrap();
1529        let mut wizard = Wizard::new(
1530            Config::default(),
1531            &|_| None,
1532            vec![("A".to_string(), candidate("fs"))],
1533            Vec::new(),
1534            dir.path(),
1535            std::sync::Arc::new(|_| true),
1536        );
1537
1538        wizard.enter(Step::Agents);
1539        wizard.next_step();
1540
1541        assert_eq!(wizard.step, Step::Mcp);
1542    }
1543
1544    #[test]
1545    fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
1546        // "We couldn't read your Zed config" is worth a screen even with no
1547        // servers to import.
1548        let dir = tempfile::tempdir().unwrap();
1549        let mut wizard = Wizard::new(
1550            Config::default(),
1551            &|_| None,
1552            Vec::new(),
1553            vec!["Zed: unreadable".to_string()],
1554            dir.path(),
1555            std::sync::Arc::new(|_| true),
1556        );
1557
1558        wizard.enter(Step::Agents);
1559        wizard.next_step();
1560
1561        assert_eq!(wizard.step, Step::Mcp);
1562    }
1563
1564    #[test]
1565    fn the_first_step_has_nowhere_to_go_back_to() {
1566        let dir = tempfile::tempdir().unwrap();
1567        let mut wizard = test_wizard(dir.path());
1568
1569        wizard.prev_step();
1570
1571        assert_eq!(wizard.step, Step::Welcome);
1572    }
1573
1574    #[test]
1575    fn advancing_past_the_last_step_stays_on_review() {
1576        let dir = tempfile::tempdir().unwrap();
1577        let mut wizard = test_wizard(dir.path());
1578        wizard.enter(Step::Review);
1579
1580        wizard.next_step();
1581
1582        assert_eq!(wizard.step, Step::Review);
1583    }
1584
1585    #[test]
1586    fn the_credential_screen_walks_the_selected_providers() {
1587        let dir = tempfile::tempdir().unwrap();
1588        let mut wizard = test_wizard(dir.path());
1589        wizard.providers[0].selected = true;
1590        wizard.providers[1].selected = true;
1591
1592        assert_eq!(wizard.detail_row(), Some(0));
1593        assert!(wizard.next_detail());
1594        assert_eq!(wizard.detail_row(), Some(1));
1595        assert!(!wizard.next_detail(), "there is no third provider");
1596        assert!(wizard.prev_detail());
1597        assert_eq!(wizard.detail_row(), Some(0));
1598        assert!(!wizard.prev_detail());
1599    }
1600
1601    #[test]
1602    fn the_credential_screen_has_no_row_when_nothing_is_selected() {
1603        let dir = tempfile::tempdir().unwrap();
1604        let mut wizard = test_wizard(dir.path());
1605        wizard.enter(Step::ProviderDetail);
1606
1607        assert!(wizard.detail_row().is_none());
1608        assert_eq!(wizard.row_count(), 0);
1609    }
1610
1611    // ─── verification ───────────────────────────────────────────────────────
1612
1613    #[tokio::test]
1614    async fn verification_is_requested_for_a_provider_with_a_credential() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let mut wizard = test_wizard(dir.path());
1617        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1618        wizard.providers[0].selected = true;
1619        wizard.providers[0].value = "sk-ant-x".to_string();
1620
1621        wizard.request_verification(0);
1622
1623        assert!(wizard.providers[0].checking);
1624        let request = requests.try_recv().expect("a request was queued");
1625        assert_eq!(request.provider_id, "anthropic");
1626        assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
1627    }
1628
1629    #[tokio::test]
1630    async fn a_blank_api_key_is_not_queued_for_checking() {
1631        // Failing with "check the key" when none was given says nothing useful.
1632        let dir = tempfile::tempdir().unwrap();
1633        let mut wizard = test_wizard(dir.path());
1634        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1635        wizard.providers[0].selected = true;
1636
1637        wizard.request_verification(0);
1638
1639        assert!(!wizard.providers[0].checking);
1640        assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
1641        assert!(requests.try_recv().is_err());
1642    }
1643
1644    #[tokio::test]
1645    async fn an_environment_supplied_key_is_what_gets_checked() {
1646        let dir = tempfile::tempdir().unwrap();
1647        let mut wizard = Wizard::new(
1648            Config::default(),
1649            &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1650            Vec::new(),
1651            Vec::new(),
1652            dir.path(),
1653            std::sync::Arc::new(|_| true),
1654        );
1655        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1656
1657        wizard.request_verification(0);
1658
1659        let request = requests.try_recv().expect("a request was queued");
1660        assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
1661    }
1662
1663    #[tokio::test]
1664    async fn ollama_is_checked_by_url_with_no_key() {
1665        let dir = tempfile::tempdir().unwrap();
1666        let mut wizard = test_wizard(dir.path());
1667        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1668        let index = wizard
1669            .providers
1670            .iter()
1671            .position(|r| r.provider.id == "ollama")
1672            .expect("ollama is offered");
1673
1674        wizard.request_verification(index);
1675        let request = requests.try_recv().expect("a request was queued");
1676        assert!(request.creds.api_key.is_none());
1677        assert_eq!(
1678            request.creds.base_url.as_deref(),
1679            Some(catalog::DEFAULT_OLLAMA_URL),
1680            "an empty field means the default endpoint"
1681        );
1682
1683        wizard.providers[index].value = "http://box:11434".to_string();
1684        wizard.request_verification(index);
1685        let request = requests.try_recv().expect("a second request was queued");
1686        assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
1687    }
1688
1689    #[tokio::test]
1690    async fn verify_all_covers_every_selected_provider() {
1691        let dir = tempfile::tempdir().unwrap();
1692        let mut wizard = test_wizard(dir.path());
1693        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1694        wizard.providers[0].selected = true;
1695        wizard.providers[0].value = "sk-ant".to_string();
1696        let ollama = wizard
1697            .providers
1698            .iter()
1699            .position(|r| r.provider.id == "ollama")
1700            .expect("ollama is offered");
1701        wizard.providers[ollama].selected = true;
1702
1703        wizard.verify_all();
1704
1705        let mut seen = Vec::new();
1706        while let Ok(request) = requests.try_recv() {
1707            seen.push(request.provider_id);
1708        }
1709        assert_eq!(seen, vec!["anthropic", "ollama"]);
1710    }
1711
1712    #[tokio::test]
1713    async fn an_out_of_range_verification_request_is_a_no_op() {
1714        let dir = tempfile::tempdir().unwrap();
1715        let mut wizard = test_wizard(dir.path());
1716        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1717
1718        wizard.request_verification(999);
1719
1720        assert!(requests.try_recv().is_err());
1721    }
1722
1723    #[tokio::test]
1724    async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
1725        let dir = tempfile::tempdir().unwrap();
1726        let mut wizard = test_wizard(dir.path());
1727        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1728        wizard.providers[0].selected = true;
1729        wizard.providers[0].checking = true;
1730
1731        replies
1732            .send(VerifyReply {
1733                provider_id: "anthropic".to_string(),
1734                outcome: Outcome::Reachable {
1735                    models: vec!["claude-opus-5".to_string()],
1736                },
1737            })
1738            .unwrap();
1739        // A reply for something not in the table is ignored rather than panicking.
1740        replies
1741            .send(VerifyReply {
1742                provider_id: "not-a-provider".to_string(),
1743                outcome: Outcome::Skipped,
1744            })
1745            .unwrap();
1746        wizard.drain_verifications();
1747
1748        assert!(!wizard.providers[0].checking);
1749        assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
1750    }
1751
1752    #[tokio::test]
1753    async fn a_late_reply_refills_the_model_picker() {
1754        // Moving straight from a credential into Defaults gets there before the
1755        // check comes back, so the picker was built from an empty model list
1756        // and stayed that way - caught by driving the real TUI against a live
1757        // API key.
1758        let dir = tempfile::tempdir().unwrap();
1759        let mut wizard = test_wizard(dir.path());
1760        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1761        wizard.providers[0].selected = true;
1762        wizard.enter(Step::Defaults);
1763        assert_eq!(
1764            wizard.defaults[1].value.options(),
1765            ["(provider default)".to_string()],
1766            "nothing has been reported yet"
1767        );
1768
1769        replies
1770            .send(VerifyReply {
1771                provider_id: "anthropic".to_string(),
1772                outcome: Outcome::Reachable {
1773                    models: vec!["claude-opus-5".to_string()],
1774                },
1775            })
1776            .unwrap();
1777        wizard.drain_verifications();
1778
1779        assert!(
1780            wizard.defaults[1]
1781                .value
1782                .options()
1783                .contains(&"claude-opus-5".to_string()),
1784            "the picker should have refilled"
1785        );
1786    }
1787
1788    #[tokio::test]
1789    async fn a_late_reply_does_not_disturb_another_screen() {
1790        let dir = tempfile::tempdir().unwrap();
1791        let mut wizard = test_wizard(dir.path());
1792        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1793        wizard.providers[0].selected = true;
1794        wizard.enter(Step::Limits);
1795        wizard.limits[0].value = FieldValue::Number(Some(3));
1796
1797        replies
1798            .send(VerifyReply {
1799                provider_id: "anthropic".to_string(),
1800                outcome: Outcome::Reachable {
1801                    models: vec!["m".to_string()],
1802                },
1803            })
1804            .unwrap();
1805        wizard.drain_verifications();
1806
1807        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1808    }
1809
1810    #[test]
1811    fn the_verification_channel_ends_can_only_be_taken_once() {
1812        let dir = tempfile::tempdir().unwrap();
1813        let mut wizard = test_wizard(dir.path());
1814
1815        assert!(wizard.take_verify_ends().is_some());
1816        assert!(wizard.take_verify_ends().is_none());
1817    }
1818
1819    #[test]
1820    fn models_from_unselected_providers_are_not_offered() {
1821        let dir = tempfile::tempdir().unwrap();
1822        let mut wizard = test_wizard(dir.path());
1823        wizard.providers[0].outcome = Outcome::Reachable {
1824            models: vec!["hidden".to_string()],
1825        };
1826
1827        assert!(wizard.discovered_models().is_empty());
1828    }
1829
1830    // ─── forms ──────────────────────────────────────────────────────────────
1831
1832    #[test]
1833    fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
1834        // A free-text prompt lets a typo through and only fails at the
1835        // first agent run.
1836        let dir = tempfile::tempdir().unwrap();
1837        let mut wizard = test_wizard(dir.path());
1838        let ollama = wizard
1839            .providers
1840            .iter()
1841            .position(|r| r.provider.id == "ollama")
1842            .expect("ollama is offered");
1843        wizard.providers[ollama].selected = true;
1844        wizard.enter(Step::Defaults);
1845
1846        assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
1847    }
1848
1849    #[test]
1850    fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
1851        let dir = tempfile::tempdir().unwrap();
1852        let mut wizard = test_wizard(dir.path());
1853        wizard.enter(Step::Defaults);
1854
1855        assert_eq!(
1856            wizard.defaults[0].value.display(),
1857            Config::default().default_provider
1858        );
1859    }
1860
1861    #[test]
1862    fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
1863        let dir = tempfile::tempdir().unwrap();
1864        let base = Config {
1865            default_model: Some("hand-typed".to_string()),
1866            ..Config::default()
1867        };
1868        let mut wizard = Wizard::new(
1869            base,
1870            &|_| None,
1871            Vec::new(),
1872            Vec::new(),
1873            dir.path(),
1874            std::sync::Arc::new(|_| true),
1875        );
1876        wizard.providers[0].selected = true;
1877        wizard.providers[0].outcome = Outcome::Reachable {
1878            models: vec!["claude-opus-5".to_string()],
1879        };
1880
1881        wizard.enter(Step::Defaults);
1882
1883        let options = wizard.defaults[1].value.options();
1884        assert!(options.contains(&"(provider default)".to_string()));
1885        assert!(options.contains(&"claude-opus-5".to_string()));
1886        assert_eq!(
1887            wizard.defaults[1].value.display(),
1888            "hand-typed",
1889            "a model already in the config must survive"
1890        );
1891    }
1892
1893    #[test]
1894    fn only_a_choice_field_has_options() {
1895        assert_eq!(
1896            FieldValue::Choice {
1897                options: vec!["a".into()],
1898                index: 0
1899            }
1900            .options(),
1901            ["a".to_string()]
1902        );
1903        assert!(FieldValue::Number(Some(1)).options().is_empty());
1904        assert!(FieldValue::Bool(true).options().is_empty());
1905    }
1906
1907    #[test]
1908    fn field_values_read_naturally() {
1909        assert_eq!(FieldValue::Number(None).display(), "(unset)");
1910        assert_eq!(FieldValue::Number(Some(7)).display(), "7");
1911        assert_eq!(FieldValue::Bool(true).display(), "yes");
1912        assert_eq!(FieldValue::Bool(false).display(), "no");
1913        assert_eq!(
1914            FieldValue::Choice {
1915                options: vec!["a".into()],
1916                index: 0
1917            }
1918            .display(),
1919            "a"
1920        );
1921        assert_eq!(
1922            FieldValue::Choice {
1923                options: vec![],
1924                index: 0
1925            }
1926            .display(),
1927            "(none)"
1928        );
1929    }
1930
1931    #[test]
1932    fn picking_ollama_drops_the_concurrency_default_to_one() {
1933        // A local box serves one model at a time; eight concurrent inferences
1934        // against one Ollama instance queue and thrash rather than going faster.
1935        let dir = tempfile::tempdir().unwrap();
1936        let mut wizard = test_wizard(dir.path());
1937        let ollama = wizard
1938            .providers
1939            .iter()
1940            .position(|r| r.provider.id == "ollama")
1941            .expect("ollama is offered");
1942        wizard.providers[ollama].selected = true;
1943        wizard.enter(Step::Defaults);
1944
1945        wizard.apply_provider_concurrency_default();
1946
1947        assert_eq!(
1948            wizard.limits[0].value,
1949            FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
1950        );
1951    }
1952
1953    #[test]
1954    fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
1955        // Regression: re-picking the default only when an arrow key moves the
1956        // provider choice misses this case. With Ollama the sole selection it
1957        // is already at index 0, no arrow is ever pressed, and the limit stays
1958        // at the hosted-API default of 8 - caught by driving the real TUI.
1959        let dir = tempfile::tempdir().unwrap();
1960        let mut wizard = test_wizard(dir.path());
1961        let ollama = wizard
1962            .providers
1963            .iter()
1964            .position(|r| r.provider.id == "ollama")
1965            .expect("ollama is offered");
1966        wizard.providers[ollama].selected = true;
1967
1968        wizard.enter(Step::Defaults);
1969
1970        assert_eq!(wizard.defaults[0].value.display(), "ollama");
1971        assert_eq!(
1972            wizard.build_config().limits.max_concurrent_inferences,
1973            Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
1974        );
1975    }
1976
1977    #[test]
1978    fn switching_back_off_ollama_restores_the_general_default() {
1979        let dir = tempfile::tempdir().unwrap();
1980        let mut wizard = test_wizard(dir.path());
1981        let ollama = wizard
1982            .providers
1983            .iter()
1984            .position(|r| r.provider.id == "ollama")
1985            .expect("ollama is offered");
1986        wizard.providers[ollama].selected = true;
1987        wizard.enter(Step::Defaults);
1988        wizard.apply_provider_concurrency_default();
1989
1990        wizard.providers[ollama].selected = false;
1991        wizard.providers[0].selected = true;
1992        wizard.rebuild_defaults();
1993        wizard.apply_provider_concurrency_default();
1994
1995        assert_eq!(
1996            wizard.limits[0].value,
1997            FieldValue::Number(
1998                Config::default()
1999                    .limits
2000                    .max_concurrent_inferences
2001                    .map(|n| n as u64)
2002            )
2003        );
2004    }
2005
2006    #[test]
2007    fn a_hand_typed_concurrency_is_never_overwritten() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let mut wizard = test_wizard(dir.path());
2010        let ollama = wizard
2011            .providers
2012            .iter()
2013            .position(|r| r.provider.id == "ollama")
2014            .expect("ollama is offered");
2015        wizard.providers[ollama].selected = true;
2016        wizard.enter(Step::Defaults);
2017        wizard.limits[0].value = FieldValue::Number(Some(3));
2018
2019        wizard.apply_provider_concurrency_default();
2020
2021        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
2022    }
2023
2024    // ─── editing ────────────────────────────────────────────────────────────
2025
2026    #[test]
2027    fn committing_a_credential_clears_its_stale_verification() {
2028        let dir = tempfile::tempdir().unwrap();
2029        let mut wizard = test_wizard(dir.path());
2030        wizard.providers[0].outcome = Outcome::Reachable {
2031            models: vec!["m".into()],
2032        };
2033        wizard.edit = Some(Edit {
2034            target: EditTarget::Credential(0),
2035            line: crate::tui::widgets::line_edit::LineEdit::new("  sk-ant-new  ".to_string(), true),
2036        });
2037
2038        wizard.commit_edit();
2039
2040        assert_eq!(wizard.providers[0].value, "sk-ant-new");
2041        assert_eq!(
2042            wizard.providers[0].outcome,
2043            Outcome::Skipped,
2044            "the old result was for a different key"
2045        );
2046        assert!(wizard.edit.is_none());
2047    }
2048
2049    #[test]
2050    fn typing_a_credential_supersedes_the_environments() {
2051        let dir = tempfile::tempdir().unwrap();
2052        let mut wizard = Wizard::new(
2053            Config::default(),
2054            &|_| Some("sk-ant-env".to_string()),
2055            Vec::new(),
2056            Vec::new(),
2057            dir.path(),
2058            std::sync::Arc::new(|_| true),
2059        );
2060        assert!(wizard.providers[0].from_env.is_some());
2061        wizard.edit = Some(Edit {
2062            target: EditTarget::Credential(0),
2063            line: crate::tui::widgets::line_edit::LineEdit::new("sk-ant-typed".to_string(), true),
2064        });
2065
2066        wizard.commit_edit();
2067
2068        assert!(wizard.providers[0].from_env.is_none());
2069        assert_eq!(
2070            wizard.build_config().providers.anthropic_api_key.as_deref(),
2071            Some("sk-ant-typed")
2072        );
2073    }
2074
2075    #[test]
2076    fn committing_numbers_handles_blank_and_unparseable_input() {
2077        let dir = tempfile::tempdir().unwrap();
2078        let mut wizard = test_wizard(dir.path());
2079        wizard.enter(Step::Limits);
2080
2081        wizard.edit = Some(Edit {
2082            target: EditTarget::Field(0),
2083            line: crate::tui::widgets::line_edit::LineEdit::new("16".to_string(), false),
2084        });
2085        wizard.commit_edit();
2086        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2087
2088        // Garbage keeps the previous value rather than silently unsetting it.
2089        wizard.edit = Some(Edit {
2090            target: EditTarget::Field(0),
2091            line: crate::tui::widgets::line_edit::LineEdit::new("not a number".to_string(), false),
2092        });
2093        wizard.commit_edit();
2094        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2095
2096        // Blank means unset, which is a real and different choice.
2097        wizard.edit = Some(Edit {
2098            target: EditTarget::Field(0),
2099            line: crate::tui::widgets::line_edit::LineEdit::new("   ".to_string(), false),
2100        });
2101        wizard.commit_edit();
2102        assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
2103    }
2104
2105    #[test]
2106    fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
2107        let dir = tempfile::tempdir().unwrap();
2108        let mut wizard = test_wizard(dir.path());
2109        wizard.commit_edit();
2110
2111        wizard.edit = Some(Edit {
2112            target: EditTarget::Credential(999),
2113            line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2114        });
2115        wizard.commit_edit();
2116
2117        wizard.enter(Step::Limits);
2118        wizard.edit = Some(Edit {
2119            target: EditTarget::Field(999),
2120            line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2121        });
2122        wizard.commit_edit();
2123
2124        // A step with no fields at all.
2125        wizard.enter(Step::Welcome);
2126        wizard.edit = Some(Edit {
2127            target: EditTarget::Field(0),
2128            line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2129        });
2130        wizard.commit_edit();
2131
2132        assert!(wizard.edit.is_none());
2133    }
2134
2135    #[test]
2136    fn every_step_reports_a_sensible_row_count() {
2137        let dir = tempfile::tempdir().unwrap();
2138        let mut wizard = Wizard::new(
2139            Config::default(),
2140            &|_| None,
2141            vec![("A".to_string(), candidate("fs"))],
2142            Vec::new(),
2143            dir.path(),
2144            std::sync::Arc::new(|_| true),
2145        );
2146        wizard.providers[0].selected = true;
2147
2148        for step in Step::ALL {
2149            wizard.enter(step);
2150            let rows = wizard.row_count();
2151            match step {
2152                Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
2153                _ => assert!(rows > 0, "{step:?} has no rows"),
2154            }
2155            // Fields exist for exactly the two form screens.
2156            let fields = wizard.fields().len();
2157            match step {
2158                Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
2159                _ => assert_eq!(fields, 0, "{step:?} should have no fields"),
2160            }
2161        }
2162    }
2163
2164    #[test]
2165    fn committing_onto_a_defaults_field_reaches_that_form_too() {
2166        let dir = tempfile::tempdir().unwrap();
2167        let mut wizard = test_wizard(dir.path());
2168        wizard.providers[0].selected = true;
2169        wizard.enter(Step::Defaults);
2170
2171        wizard.edit = Some(Edit {
2172            target: EditTarget::Field(2),
2173            line: crate::tui::widgets::line_edit::LineEdit::new("45".to_string(), false),
2174        });
2175        wizard.commit_edit();
2176
2177        assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
2178        assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
2179    }
2180
2181    #[test]
2182    fn clearing_a_credential_leaves_the_environment_marker_alone() {
2183        // Blanking the field is how a user says "use what's in my environment".
2184        let dir = tempfile::tempdir().unwrap();
2185        let mut wizard = Wizard::new(
2186            Config::default(),
2187            &|_| Some("sk-ant-env".to_string()),
2188            Vec::new(),
2189            Vec::new(),
2190            dir.path(),
2191            std::sync::Arc::new(|_| true),
2192        );
2193        wizard.edit = Some(Edit {
2194            target: EditTarget::Credential(0),
2195            line: crate::tui::widgets::line_edit::LineEdit::new("   ".to_string(), true),
2196        });
2197
2198        wizard.commit_edit();
2199
2200        assert!(wizard.providers[0].value.is_empty());
2201        assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
2202    }
2203
2204    #[test]
2205    fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
2206        // Defensive: a future reorder must not silently drop the setting.
2207        let dir = tempfile::tempdir().unwrap();
2208        let mut wizard = test_wizard(dir.path());
2209        wizard.defaults[0].value = FieldValue::Bool(true);
2210
2211        assert_eq!(
2212            wizard.build_config().default_provider,
2213            Config::default().default_provider
2214        );
2215    }
2216
2217    #[test]
2218    fn an_empty_choice_list_falls_back_to_the_base_config() {
2219        let dir = tempfile::tempdir().unwrap();
2220        let mut wizard = test_wizard(dir.path());
2221        wizard.defaults[0].value = FieldValue::Choice {
2222            options: Vec::new(),
2223            index: 0,
2224        };
2225
2226        assert_eq!(
2227            wizard.build_config().default_provider,
2228            Config::default().default_provider
2229        );
2230    }
2231
2232    #[test]
2233    fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
2234        let dir = tempfile::tempdir().unwrap();
2235        let mut wizard = test_wizard(dir.path());
2236        wizard.limits[0].value = FieldValue::Bool(true);
2237
2238        wizard.apply_provider_concurrency_default();
2239
2240        assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
2241    }
2242
2243    #[test]
2244    fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
2245        let dir = tempfile::tempdir().unwrap();
2246        let mut wizard = test_wizard(dir.path());
2247        wizard.enter(Step::Limits);
2248        let before = wizard.limits[3].value.clone();
2249
2250        wizard.edit = Some(Edit {
2251            target: EditTarget::Field(3),
2252            line: crate::tui::widgets::line_edit::LineEdit::new("yes".to_string(), false),
2253        });
2254        wizard.commit_edit();
2255
2256        assert_eq!(wizard.limits[3].value, before);
2257    }
2258
2259    // ─── building the config ────────────────────────────────────────────────
2260
2261    #[test]
2262    fn deselecting_a_provider_clears_its_credential() {
2263        let dir = tempfile::tempdir().unwrap();
2264        let base = Config {
2265            providers: crate::config::ProviderConfig {
2266                anthropic_api_key: Some("sk-ant-stored".to_string()),
2267                ..Config::default().providers
2268            },
2269            ..Config::default()
2270        };
2271        let mut wizard = Wizard::new(
2272            base,
2273            &|_| None,
2274            Vec::new(),
2275            Vec::new(),
2276            dir.path(),
2277            std::sync::Arc::new(|_| true),
2278        );
2279
2280        wizard.providers[0].selected = false;
2281
2282        assert!(wizard.build_config().providers.anthropic_api_key.is_none());
2283    }
2284
2285    #[test]
2286    fn ollamas_default_url_is_left_unset_rather_than_pinned() {
2287        // Storing the default would freeze it and shadow $OLLAMA_HOST.
2288        let dir = tempfile::tempdir().unwrap();
2289        let mut wizard = test_wizard(dir.path());
2290        let ollama = wizard
2291            .providers
2292            .iter()
2293            .position(|r| r.provider.id == "ollama")
2294            .expect("ollama is offered");
2295        wizard.providers[ollama].selected = true;
2296        wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
2297
2298        assert!(wizard.build_config().ollama_base_url.is_none());
2299
2300        wizard.providers[ollama].value = "http://box:11434".to_string();
2301        assert_eq!(
2302            wizard.build_config().ollama_base_url.as_deref(),
2303            Some("http://box:11434")
2304        );
2305    }
2306
2307    #[test]
2308    fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
2309        let dir = tempfile::tempdir().unwrap();
2310        let mut wizard = test_wizard(dir.path());
2311        let index = wizard
2312            .providers
2313            .iter()
2314            .position(|r| r.provider.id == "claude-code")
2315            .expect("the transport is offered");
2316
2317        assert!(!wizard.build_config().providers.claude_code_enabled);
2318
2319        wizard.providers[index].selected = true;
2320        wizard.providers[index].effort = effort_options().len() - 1;
2321        let config = wizard.build_config();
2322        assert!(config.providers.claude_code_enabled);
2323        assert_eq!(
2324            config.providers.claude_code_effort.as_deref(),
2325            Some(*effort_options().last().expect("levels exist"))
2326        );
2327    }
2328
2329    #[test]
2330    fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
2331        let dir = tempfile::tempdir().unwrap();
2332        let mut wizard = test_wizard(dir.path());
2333        let index = wizard
2334            .providers
2335            .iter()
2336            .position(|r| r.provider.id == "claude-code")
2337            .expect("the transport is offered");
2338        wizard.providers[index].selected = true;
2339        wizard.providers[index].effort = 99;
2340
2341        let config = wizard.build_config();
2342
2343        assert_eq!(
2344            config.providers.claude_code_effort.as_deref(),
2345            Some(*effort_options().last().expect("levels exist"))
2346        );
2347    }
2348
2349    #[test]
2350    fn the_stored_effort_selects_the_matching_option() {
2351        let dir = tempfile::tempdir().unwrap();
2352        let base = Config {
2353            providers: crate::config::ProviderConfig {
2354                claude_code_effort: Some("max".to_string()),
2355                ..Config::default().providers
2356            },
2357            ..Config::default()
2358        };
2359        let wizard = Wizard::new(
2360            base,
2361            &|_| None,
2362            Vec::new(),
2363            Vec::new(),
2364            dir.path(),
2365            std::sync::Arc::new(|_| true),
2366        );
2367
2368        let index = wizard
2369            .providers
2370            .iter()
2371            .position(|r| r.provider.id == "claude-code")
2372            .expect("the transport is offered");
2373        assert_eq!(effort_options()[wizard.providers[index].effort], "max");
2374    }
2375
2376    #[test]
2377    fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
2378        assert_eq!(effort_index(Some("not-a-level")), 0);
2379        assert_eq!(
2380            effort_options()[effort_index(None)],
2381            leviath_providers::claude_code::DEFAULT_EFFORT
2382        );
2383    }
2384
2385    #[test]
2386    fn the_provider_default_model_is_stored_as_unset() {
2387        let dir = tempfile::tempdir().unwrap();
2388        let mut wizard = test_wizard(dir.path());
2389        wizard.providers[0].selected = true;
2390        wizard.enter(Step::Defaults);
2391
2392        // Index 0 of the model picker is always "(provider default)".
2393        assert!(wizard.build_config().default_model.is_none());
2394    }
2395
2396    #[test]
2397    fn limits_are_written_back_including_the_zero_guard() {
2398        // A zero here would deadlock every tool batch, so it falls back to the
2399        // default rather than being stored.
2400        let dir = tempfile::tempdir().unwrap();
2401        let mut wizard = test_wizard(dir.path());
2402        wizard.enter(Step::Limits);
2403        wizard.limits[0].value = FieldValue::Number(Some(2));
2404        wizard.limits[1].value = FieldValue::Number(Some(0));
2405        wizard.limits[2].value = FieldValue::Number(None);
2406        wizard.limits[3].value = FieldValue::Bool(true);
2407        wizard.limits[4].value = FieldValue::Bool(false);
2408        wizard.limits[5].value = FieldValue::Bool(false);
2409        wizard.limits[6].value = FieldValue::Number(Some(11));
2410        wizard.limits[7].value = FieldValue::Number(Some(22));
2411        wizard.limits[8].value = FieldValue::Number(Some(33));
2412        wizard.limits[9].value = FieldValue::Number(Some(44));
2413
2414        let config = wizard.build_config();
2415
2416        assert_eq!(config.limits.max_concurrent_inferences, Some(2));
2417        assert_eq!(
2418            config.limits.max_concurrent_tools,
2419            Config::default().limits.max_concurrent_tools
2420        );
2421        assert!(config.limits.default_max_iterations.is_none());
2422        assert!(config.limits.exact_token_counting);
2423        assert!(!config.batch_tool_hint);
2424        assert!(!config.shell_hint);
2425        // Every remaining field gets a distinct value, so a form that grows a
2426        // row without renumbering `apply_limits_fields` fails here rather than
2427        // silently dropping whichever field the duplicated index shadowed.
2428        assert_eq!(config.limits.stall_timeout_secs, 11);
2429        assert_eq!(config.limits.dead_cycles_before_relief, 22);
2430        assert_eq!(config.limits.finished_retention_secs, 33);
2431        assert_eq!(config.limits.wedge_timeout_secs, 44);
2432    }
2433
2434    #[test]
2435    fn every_limits_field_is_written_back() {
2436        // The index in `apply_limits_fields` is positional and hand-written, so
2437        // an inserted row shifts every arm below it. Round-tripping the form
2438        // through itself catches a gap or a duplicate without naming indices.
2439        let dir = tempfile::tempdir().unwrap();
2440        let mut wizard = test_wizard(dir.path());
2441        wizard.enter(Step::Limits);
2442        let count = wizard.limits.len();
2443
2444        let before = wizard.build_config();
2445        let seeded = limits_fields(&before);
2446        assert_eq!(seeded.len(), count, "the form is built from the config");
2447
2448        // Flip every toggle and give every number a distinct non-default
2449        // value, then read the form back out of the config it produced: a
2450        // field that no arm writes comes back with its original value. The
2451        // limits form is toggles and numbers only, so the second arm is the
2452        // number case rather than an unexercised catch-all.
2453        for (i, field) in wizard.limits.iter_mut().enumerate() {
2454            field.value = match &field.value {
2455                FieldValue::Bool(b) => FieldValue::Bool(!b),
2456                _ => FieldValue::Number(Some(i as u64 + 11)),
2457            };
2458        }
2459        let expected: Vec<FieldValue> = wizard.limits.iter().map(|f| f.value.clone()).collect();
2460        let after = limits_fields(&wizard.build_config());
2461
2462        for (i, (got, want)) in after.iter().zip(&expected).enumerate() {
2463            assert_eq!(
2464                &got.value, want,
2465                "field {i} ({}) did not survive the round trip",
2466                got.label
2467            );
2468        }
2469    }
2470
2471    /// The four timing limits share one rule: an explicit number is stored,
2472    /// including `0` (which means "never" for each of them), while leaving a
2473    /// field blank keeps the shipped default rather than disabling anything.
2474    #[test]
2475    fn the_watchdog_limits_store_zero_and_keep_the_default_when_blank() {
2476        let dir = tempfile::tempdir().unwrap();
2477        let mut wizard = test_wizard(dir.path());
2478        wizard.enter(Step::Limits);
2479        wizard.limits[6].value = FieldValue::Number(Some(0));
2480        wizard.limits[7].value = FieldValue::Number(Some(0));
2481        wizard.limits[8].value = FieldValue::Number(Some(0));
2482        wizard.limits[9].value = FieldValue::Number(Some(300));
2483
2484        let config = wizard.build_config();
2485
2486        assert_eq!(config.limits.stall_timeout_secs, 0);
2487        assert_eq!(config.limits.dead_cycles_before_relief, 0);
2488        assert_eq!(config.limits.finished_retention_secs, 0);
2489        assert_eq!(config.limits.wedge_timeout_secs, 300);
2490
2491        let mut wizard = test_wizard(dir.path());
2492        wizard.enter(Step::Limits);
2493        wizard.limits[6].value = FieldValue::Number(None);
2494        wizard.limits[7].value = FieldValue::Number(None);
2495        wizard.limits[8].value = FieldValue::Number(None);
2496        wizard.limits[9].value = FieldValue::Number(None);
2497
2498        let config = wizard.build_config();
2499
2500        let default = Config::default();
2501        assert_eq!(
2502            config.limits.stall_timeout_secs,
2503            default.limits.stall_timeout_secs
2504        );
2505        assert_eq!(
2506            config.limits.dead_cycles_before_relief,
2507            default.limits.dead_cycles_before_relief
2508        );
2509        assert_eq!(
2510            config.limits.finished_retention_secs,
2511            default.limits.finished_retention_secs
2512        );
2513        assert_eq!(
2514            config.limits.wedge_timeout_secs,
2515            default.limits.wedge_timeout_secs
2516        );
2517    }
2518
2519    #[test]
2520    fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
2521        // Defensive: nothing builds this shape today, but a future edit that
2522        // reorders the form must not silently write a boolean into a count.
2523        let mut config = Config::default();
2524        apply_limits_fields(
2525            &mut config,
2526            &[Field {
2527                label: "Max concurrent inferences",
2528                help: "",
2529                value: FieldValue::Bool(true),
2530            }],
2531        );
2532
2533        assert_eq!(
2534            config.limits.max_concurrent_inferences,
2535            Config::default().limits.max_concurrent_inferences
2536        );
2537    }
2538
2539    #[test]
2540    fn the_plan_carries_only_the_selected_agents() {
2541        let dir = tempfile::tempdir().unwrap();
2542        let mut wizard = test_wizard(dir.path());
2543        for row in wizard.agents.iter_mut().skip(1) {
2544            row.selected = false;
2545        }
2546
2547        let plan = wizard.build_plan();
2548
2549        assert_eq!(plan.agents.len(), 1);
2550        assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
2551    }
2552
2553    #[test]
2554    fn the_review_says_so_when_nothing_would_change() {
2555        let dir = tempfile::tempdir().unwrap();
2556        let mut wizard = test_wizard(dir.path());
2557        for row in wizard.agents.iter_mut() {
2558            row.selected = false;
2559        }
2560
2561        assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
2562    }
2563
2564    #[test]
2565    fn the_review_lists_real_changes() {
2566        let dir = tempfile::tempdir().unwrap();
2567        let mut wizard = test_wizard(dir.path());
2568        wizard.providers[0].selected = true;
2569        wizard.providers[0].value = "sk-ant-x".to_string();
2570
2571        let lines = wizard.review_lines();
2572
2573        assert!(lines.iter().any(|l| l.contains("credential set")));
2574        assert!(lines.iter().any(|l| l.contains("to install")));
2575    }
2576
2577    // ─── scan merging ───────────────────────────────────────────────────────
2578
2579    #[test]
2580    fn scans_flatten_into_candidates_and_labelled_errors() {
2581        let scans = vec![
2582            import::Scan {
2583                source: import::Source {
2584                    id: "a",
2585                    display: "Harness A",
2586                    path: std::path::PathBuf::from("/a"),
2587                    layout: import::Layout::ClaudeCode,
2588                    allows_comments: false,
2589                },
2590                result: Ok(vec![candidate("fs")]),
2591            },
2592            import::Scan {
2593                source: import::Source {
2594                    id: "b",
2595                    display: "Harness B",
2596                    path: std::path::PathBuf::from("/b"),
2597                    layout: import::Layout::CodexToml,
2598                    allows_comments: false,
2599                },
2600                result: Err("unreadable".to_string()),
2601            },
2602        ];
2603
2604        let (candidates, errors) = candidates_from_scans(scans);
2605
2606        assert_eq!(candidates.len(), 1);
2607        assert_eq!(candidates[0].0, "Harness A");
2608        assert_eq!(errors, vec!["Harness B: unreadable"]);
2609    }
2610}