Skip to main content

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