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