Skip to main content

leviath_cli/commands/setup/
state.rs

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