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        Field {
896            label: "Platform shell hint",
897            help: "Tell models what shell they get. Only says anything on Windows (cmd.exe).",
898            value: FieldValue::Bool(config.shell_hint),
899        },
900        Field {
901            label: "Stall timeout (seconds)",
902            help: "Fail a run that can never dispatch (unconfigured provider). 0 waits forever.",
903            value: FieldValue::Number(Some(config.limits.stall_timeout_secs)),
904        },
905        Field {
906            label: "Dead cycles before relief",
907            help: "Widen the tool lane after this many 30s cycles with work queued and nothing moving. 0 never does.",
908            value: FieldValue::Number(Some(config.limits.dead_cycles_before_relief as u64)),
909        },
910        Field {
911            label: "Finished run retention (seconds)",
912            help: "Keep a run in `lev ps` this long after it ends, so a script polling on an interval sees how it ended. 0 drops it at once.",
913            value: FieldValue::Number(Some(config.limits.finished_retention_secs)),
914        },
915        Field {
916            label: "Wedge timeout (seconds)",
917            help: "Fail a run nothing in the engine can reach any more. 0 is off; 300 is a sensible value.",
918            value: FieldValue::Number(Some(config.limits.wedge_timeout_secs)),
919        },
920        Field {
921            label: "Interaction timeout (seconds)",
922            help: "Resolve a prompt nobody answered after this long, so the run carries on. 0 waits for ever.",
923            value: FieldValue::Number(Some(config.limits.interaction_timeout_secs)),
924        },
925    ]
926}
927
928/// Write the Limits screen's fields back into a config.
929fn apply_limits_fields(config: &mut Config, fields: &[Field]) {
930    for (index, field) in fields.iter().enumerate() {
931        match (index, &field.value) {
932            (0, FieldValue::Number(n)) => {
933                config.limits.max_concurrent_inferences = n.map(|n| n as usize)
934            }
935            // A zero here would deadlock every tool batch, so an explicit unset
936            // or 0 falls back to the default rather than being stored.
937            (1, FieldValue::Number(n)) => {
938                config.limits.max_concurrent_tools = n
939                    .filter(|n| *n > 0)
940                    .map(|n| n as usize)
941                    .unwrap_or(Config::default().limits.max_concurrent_tools)
942            }
943            (2, FieldValue::Number(n)) => {
944                config.limits.default_max_iterations = n.map(|n| n as usize)
945            }
946            (3, FieldValue::Bool(b)) => config.limits.exact_token_counting = *b,
947            (4, FieldValue::Bool(b)) => config.batch_tool_hint = *b,
948            (5, FieldValue::Bool(b)) => config.shell_hint = *b,
949            // Unset means "leave the watchdog at its default", not "disable it";
950            // disabling is an explicit 0.
951            (6, FieldValue::Number(n)) => {
952                config.limits.stall_timeout_secs =
953                    n.unwrap_or(Config::default().limits.stall_timeout_secs)
954            }
955            // Same rule: unset keeps the default, 0 is an explicit "never".
956            (7, FieldValue::Number(n)) => {
957                config.limits.dead_cycles_before_relief = n
958                    .map(|n| n as u32)
959                    .unwrap_or(Config::default().limits.dead_cycles_before_relief)
960            }
961            // And again: unset keeps the default, 0 means keep nothing.
962            (8, FieldValue::Number(n)) => {
963                config.limits.finished_retention_secs =
964                    n.unwrap_or(Config::default().limits.finished_retention_secs)
965            }
966            // Same rule once more, and here the default is itself 0 (off).
967            (9, FieldValue::Number(n)) => {
968                config.limits.wedge_timeout_secs =
969                    n.unwrap_or(Config::default().limits.wedge_timeout_secs)
970            }
971            // Same rule again: unset keeps the default hour, 0 is an explicit
972            // "wait for a person however long it takes".
973            (10, FieldValue::Number(n)) => {
974                config.limits.interaction_timeout_secs =
975                    n.unwrap_or(Config::default().limits.interaction_timeout_secs)
976            }
977            _ => {}
978        }
979    }
980}
981
982/// Merge every scan into the flat `(source, candidate)` list the wizard takes,
983/// alongside the human-readable errors.
984pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
985    let mut candidates = Vec::new();
986    let mut errors = Vec::new();
987    for scan in scans {
988        match scan.result {
989            Ok(found) => candidates.extend(
990                found
991                    .into_iter()
992                    .map(|c| (scan.source.display.to_string(), c)),
993            ),
994            Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
995        }
996    }
997    (candidates, errors)
998}
999
1000/// Build an [`MCPServerConfig`] list from selected rows. Exposed for tests and
1001/// for any future non-terminal front-end.
1002pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
1003    rows.iter()
1004        .filter(|r| r.selected)
1005        .map(|r| {
1006            let mut server = r.candidate.config.clone();
1007            server.name = r.name.clone();
1008            server
1009        })
1010        .collect()
1011}
1012
1013#[cfg(test)]
1014pub(super) mod tests {
1015    use super::*;
1016    use crate::bundled::BUNDLED_AGENTS;
1017
1018    /// A wizard over tempdirs and a fixed environment, with a browser opener
1019    /// that records rather than launches.
1020    pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
1021        Wizard::new(
1022            Config::default(),
1023            &|_| None,
1024            Vec::new(),
1025            Vec::new(),
1026            agents_dir,
1027            std::sync::Arc::new(|_| true),
1028        )
1029    }
1030
1031    fn candidate(name: &str) -> Candidate {
1032        Candidate {
1033            config: MCPServerConfig::stdio(name, "npx", vec![]),
1034            scope: String::new(),
1035            inline_secrets: Vec::new(),
1036        }
1037    }
1038
1039    // ─── Step ───────────────────────────────────────────────────────────────
1040
1041    #[test]
1042    fn every_step_is_titled_and_ordered() {
1043        for (index, step) in Step::ALL.iter().enumerate() {
1044            assert!(!step.title().is_empty(), "{step:?} has no title");
1045            assert_eq!(step.index(), index);
1046        }
1047    }
1048
1049    // ─── construction ───────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
1053        let dir = tempfile::tempdir().unwrap();
1054
1055        let wizard = test_wizard(dir.path());
1056
1057        assert_eq!(wizard.step, Step::Welcome);
1058        assert!(wizard.selected_providers().is_empty());
1059        assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
1060        assert!(
1061            wizard.agents.iter().all(|r| r.selected),
1062            "a fresh install should offer to install everything"
1063        );
1064        assert!(
1065            wizard
1066                .agents
1067                .iter()
1068                .all(|r| r.action == AgentAction::Install)
1069        );
1070    }
1071
1072    #[test]
1073    fn already_installed_agents_are_listed_but_not_reselected() {
1074        let dir = tempfile::tempdir().unwrap();
1075        for agent in BUNDLED_AGENTS {
1076            crate::bundled::install_bundled(agent, dir.path()).unwrap();
1077        }
1078
1079        let wizard = test_wizard(dir.path());
1080
1081        assert!(
1082            wizard.agents.iter().all(|r| !r.selected),
1083            "nothing needs doing, so nothing should be pre-checked"
1084        );
1085    }
1086
1087    #[test]
1088    fn a_configured_provider_starts_selected_with_its_credential() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let base = Config {
1091            providers: crate::config::ProviderConfig {
1092                anthropic_api_key: Some("sk-ant-stored".to_string()),
1093                ..Config::default().providers
1094            },
1095            ..Config::default()
1096        };
1097
1098        let wizard = Wizard::new(
1099            base,
1100            &|_| None,
1101            Vec::new(),
1102            Vec::new(),
1103            dir.path(),
1104            std::sync::Arc::new(|_| true),
1105        );
1106
1107        let row = wizard
1108            .providers
1109            .iter()
1110            .find(|r| r.provider.id == "anthropic")
1111            .expect("anthropic is in the table");
1112        assert!(row.selected);
1113        assert_eq!(row.value, "sk-ant-stored");
1114        assert!(row.from_env.is_none());
1115    }
1116
1117    #[test]
1118    fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
1119        // The bug this closes: `Config::load` folds env keys into the struct,
1120        // so a wizard that re-serializes the whole thing silently writes a key
1121        // the user deliberately kept in their environment into
1122        // ~/.leviath/config.toml.
1123        let dir = tempfile::tempdir().unwrap();
1124
1125        let wizard = Wizard::new(
1126            Config::default(),
1127            &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
1128            Vec::new(),
1129            Vec::new(),
1130            dir.path(),
1131            std::sync::Arc::new(|_| true),
1132        );
1133
1134        let row = wizard
1135            .providers
1136            .iter()
1137            .find(|r| r.provider.id == "anthropic")
1138            .expect("anthropic is in the table");
1139        assert!(row.selected, "the provider is usable, so it is selected");
1140        assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
1141        assert!(row.value.is_empty());
1142
1143        let written = wizard.build_config();
1144        assert!(
1145            written.providers.anthropic_api_key.is_none(),
1146            "an environment-supplied key must not be copied into the config"
1147        );
1148    }
1149
1150    #[test]
1151    fn a_stored_key_wins_over_the_environment() {
1152        // Both present: the file is what setup is editing, so that is what is
1153        // shown and kept.
1154        let dir = tempfile::tempdir().unwrap();
1155        let base = Config {
1156            providers: crate::config::ProviderConfig {
1157                anthropic_api_key: Some("sk-ant-stored".to_string()),
1158                ..Config::default().providers
1159            },
1160            ..Config::default()
1161        };
1162
1163        let wizard = Wizard::new(
1164            base,
1165            &|_| Some("sk-ant-from-env".to_string()),
1166            Vec::new(),
1167            Vec::new(),
1168            dir.path(),
1169            std::sync::Arc::new(|_| true),
1170        );
1171
1172        let row = &wizard.providers[0];
1173        assert!(row.from_env.is_none());
1174        assert_eq!(row.value, "sk-ant-stored");
1175    }
1176
1177    #[test]
1178    fn an_empty_environment_variable_does_not_count_as_a_credential() {
1179        let dir = tempfile::tempdir().unwrap();
1180
1181        let wizard = Wizard::new(
1182            Config::default(),
1183            &|_| Some(String::new()),
1184            Vec::new(),
1185            Vec::new(),
1186            dir.path(),
1187            std::sync::Arc::new(|_| true),
1188        );
1189
1190        assert!(wizard.env_only.is_empty());
1191        assert!(wizard.selected_providers().is_empty());
1192    }
1193
1194    // ─── MCP rows ───────────────────────────────────────────────────────────
1195
1196    #[test]
1197    fn an_importable_server_is_preselected_and_named_as_found() {
1198        let dir = tempfile::tempdir().unwrap();
1199
1200        let wizard = Wizard::new(
1201            Config::default(),
1202            &|_| None,
1203            vec![("Claude Code".to_string(), candidate("fs"))],
1204            Vec::new(),
1205            dir.path(),
1206            std::sync::Arc::new(|_| true),
1207        );
1208
1209        assert_eq!(wizard.mcp.len(), 1);
1210        assert!(wizard.mcp[0].selected);
1211        assert!(!wizard.mcp[0].collides);
1212        assert_eq!(wizard.mcp[0].name, "fs");
1213        assert_eq!(wizard.mcp[0].source, "Claude Code");
1214    }
1215
1216    #[test]
1217    fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
1218        // The user already has it. Silently adding a second copy under a
1219        // suffixed name is not what "import" means.
1220        let dir = tempfile::tempdir().unwrap();
1221        let base = Config {
1222            mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
1223            ..Config::default()
1224        };
1225
1226        let wizard = Wizard::new(
1227            base,
1228            &|_| None,
1229            vec![("Cursor".to_string(), candidate("fs"))],
1230            Vec::new(),
1231            dir.path(),
1232            std::sync::Arc::new(|_| true),
1233        );
1234
1235        assert!(!wizard.mcp[0].selected);
1236        assert!(wizard.mcp[0].collides);
1237        assert_eq!(wizard.mcp[0].name, "fs-2");
1238
1239        // Selecting it anyway stores it under the free name, leaving the
1240        // original alone.
1241        let mut wizard = wizard;
1242        wizard.mcp[0].selected = true;
1243        let config = wizard.build_config();
1244        let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
1245        assert_eq!(names, vec!["fs", "fs-2"]);
1246    }
1247
1248    #[test]
1249    fn selected_servers_renames_and_filters() {
1250        let dir = tempfile::tempdir().unwrap();
1251        let mut wizard = Wizard::new(
1252            Config::default(),
1253            &|_| None,
1254            vec![
1255                ("A".to_string(), candidate("keep")),
1256                ("B".to_string(), candidate("drop")),
1257            ],
1258            Vec::new(),
1259            dir.path(),
1260            std::sync::Arc::new(|_| true),
1261        );
1262        wizard.mcp[1].selected = false;
1263        wizard.mcp[0].name = "renamed".to_string();
1264
1265        let servers = selected_servers(&wizard.mcp);
1266
1267        assert_eq!(servers.len(), 1);
1268        assert_eq!(servers[0].name, "renamed");
1269    }
1270
1271    #[test]
1272    fn inline_secrets_are_reported_only_for_selected_rows() {
1273        let dir = tempfile::tempdir().unwrap();
1274        let mut secretive = candidate("leaky");
1275        secretive.inline_secrets = vec!["API_TOKEN".to_string()];
1276
1277        let mut wizard = Wizard::new(
1278            Config::default(),
1279            &|_| None,
1280            vec![
1281                ("A".to_string(), secretive),
1282                ("B".to_string(), candidate("clean")),
1283            ],
1284            Vec::new(),
1285            dir.path(),
1286            std::sync::Arc::new(|_| true),
1287        );
1288
1289        assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
1290        wizard.mcp[0].selected = false;
1291        assert!(wizard.selected_inline_secrets().is_empty());
1292    }
1293
1294    // ─── navigation ─────────────────────────────────────────────────────────
1295
1296    #[test]
1297    fn the_cursor_stays_inside_the_current_step() {
1298        let dir = tempfile::tempdir().unwrap();
1299        let mut wizard = test_wizard(dir.path());
1300        wizard.enter(Step::Providers);
1301
1302        wizard.move_cursor(-5);
1303        assert_eq!(wizard.cursor, 0);
1304        wizard.move_cursor(100);
1305        assert_eq!(wizard.cursor, wizard.providers.len() - 1);
1306    }
1307
1308    #[test]
1309    fn a_step_with_no_rows_pins_the_cursor_at_zero() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let mut wizard = test_wizard(dir.path());
1312        wizard.enter(Step::Welcome);
1313        wizard.cursor = 4;
1314
1315        wizard.move_cursor(1);
1316
1317        assert_eq!(wizard.cursor, 0);
1318        assert_eq!(wizard.row_count(), 0);
1319    }
1320
1321    #[test]
1322    fn empty_discovery_steps_are_skipped_in_both_directions() {
1323        // Nobody should have to press Enter through "no MCP servers found" on a
1324        // clean machine, or through a credentials screen with no providers
1325        // picked.
1326        let dir = tempfile::tempdir().unwrap();
1327        let mut wizard = test_wizard(dir.path());
1328        assert!(wizard.mcp.is_empty());
1329
1330        wizard.enter(Step::Providers);
1331        wizard.next_step();
1332        assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
1333
1334        wizard.enter(Step::Agents);
1335        wizard.next_step();
1336        assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
1337
1338        wizard.prev_step();
1339        assert_eq!(wizard.step, Step::Agents);
1340    }
1341
1342    #[test]
1343    fn a_nonempty_discovery_step_is_visited() {
1344        let dir = tempfile::tempdir().unwrap();
1345        let mut wizard = Wizard::new(
1346            Config::default(),
1347            &|_| None,
1348            vec![("A".to_string(), candidate("fs"))],
1349            Vec::new(),
1350            dir.path(),
1351            std::sync::Arc::new(|_| true),
1352        );
1353
1354        wizard.enter(Step::Agents);
1355        wizard.next_step();
1356
1357        assert_eq!(wizard.step, Step::Mcp);
1358    }
1359
1360    #[test]
1361    fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
1362        // "We couldn't read your Zed config" is worth a screen even with no
1363        // servers to import.
1364        let dir = tempfile::tempdir().unwrap();
1365        let mut wizard = Wizard::new(
1366            Config::default(),
1367            &|_| None,
1368            Vec::new(),
1369            vec!["Zed: unreadable".to_string()],
1370            dir.path(),
1371            std::sync::Arc::new(|_| true),
1372        );
1373
1374        wizard.enter(Step::Agents);
1375        wizard.next_step();
1376
1377        assert_eq!(wizard.step, Step::Mcp);
1378    }
1379
1380    #[test]
1381    fn the_first_step_has_nowhere_to_go_back_to() {
1382        let dir = tempfile::tempdir().unwrap();
1383        let mut wizard = test_wizard(dir.path());
1384
1385        wizard.prev_step();
1386
1387        assert_eq!(wizard.step, Step::Welcome);
1388    }
1389
1390    #[test]
1391    fn advancing_past_the_last_step_stays_on_review() {
1392        let dir = tempfile::tempdir().unwrap();
1393        let mut wizard = test_wizard(dir.path());
1394        wizard.enter(Step::Review);
1395
1396        wizard.next_step();
1397
1398        assert_eq!(wizard.step, Step::Review);
1399    }
1400
1401    #[test]
1402    fn the_credential_screen_walks_the_selected_providers() {
1403        let dir = tempfile::tempdir().unwrap();
1404        let mut wizard = test_wizard(dir.path());
1405        wizard.providers[0].selected = true;
1406        wizard.providers[1].selected = true;
1407
1408        assert_eq!(wizard.detail_row(), Some(0));
1409        assert!(wizard.next_detail());
1410        assert_eq!(wizard.detail_row(), Some(1));
1411        assert!(!wizard.next_detail(), "there is no third provider");
1412        assert!(wizard.prev_detail());
1413        assert_eq!(wizard.detail_row(), Some(0));
1414        assert!(!wizard.prev_detail());
1415    }
1416
1417    #[test]
1418    fn the_credential_screen_has_no_row_when_nothing_is_selected() {
1419        let dir = tempfile::tempdir().unwrap();
1420        let mut wizard = test_wizard(dir.path());
1421        wizard.enter(Step::ProviderDetail);
1422
1423        assert!(wizard.detail_row().is_none());
1424        assert_eq!(wizard.row_count(), 0);
1425    }
1426
1427    // ─── verification ───────────────────────────────────────────────────────
1428
1429    #[tokio::test]
1430    async fn verification_is_requested_for_a_provider_with_a_credential() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let mut wizard = test_wizard(dir.path());
1433        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1434        wizard.providers[0].selected = true;
1435        wizard.providers[0].value = "sk-ant-x".to_string();
1436
1437        wizard.request_verification(0);
1438
1439        assert!(wizard.providers[0].checking);
1440        let request = requests.try_recv().expect("a request was queued");
1441        assert_eq!(request.provider_id, "anthropic");
1442        assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
1443    }
1444
1445    #[tokio::test]
1446    async fn a_blank_api_key_is_not_queued_for_checking() {
1447        // Failing with "check the key" when none was given says nothing useful.
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
1453        wizard.request_verification(0);
1454
1455        assert!(!wizard.providers[0].checking);
1456        assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
1457        assert!(requests.try_recv().is_err());
1458    }
1459
1460    #[tokio::test]
1461    async fn an_environment_supplied_key_is_what_gets_checked() {
1462        let dir = tempfile::tempdir().unwrap();
1463        let mut wizard = Wizard::new(
1464            Config::default(),
1465            &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1466            Vec::new(),
1467            Vec::new(),
1468            dir.path(),
1469            std::sync::Arc::new(|_| true),
1470        );
1471        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1472
1473        wizard.request_verification(0);
1474
1475        let request = requests.try_recv().expect("a request was queued");
1476        assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
1477    }
1478
1479    #[tokio::test]
1480    async fn ollama_is_checked_by_url_with_no_key() {
1481        let dir = tempfile::tempdir().unwrap();
1482        let mut wizard = test_wizard(dir.path());
1483        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1484        let index = wizard
1485            .providers
1486            .iter()
1487            .position(|r| r.provider.id == "ollama")
1488            .expect("ollama is offered");
1489
1490        wizard.request_verification(index);
1491        let request = requests.try_recv().expect("a request was queued");
1492        assert!(request.creds.api_key.is_none());
1493        assert_eq!(
1494            request.creds.base_url.as_deref(),
1495            Some(catalog::DEFAULT_OLLAMA_URL),
1496            "an empty field means the default endpoint"
1497        );
1498
1499        wizard.providers[index].value = "http://box:11434".to_string();
1500        wizard.request_verification(index);
1501        let request = requests.try_recv().expect("a second request was queued");
1502        assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
1503    }
1504
1505    #[tokio::test]
1506    async fn verify_all_covers_every_selected_provider() {
1507        let dir = tempfile::tempdir().unwrap();
1508        let mut wizard = test_wizard(dir.path());
1509        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1510        wizard.providers[0].selected = true;
1511        wizard.providers[0].value = "sk-ant".to_string();
1512        let ollama = wizard
1513            .providers
1514            .iter()
1515            .position(|r| r.provider.id == "ollama")
1516            .expect("ollama is offered");
1517        wizard.providers[ollama].selected = true;
1518
1519        wizard.verify_all();
1520
1521        let mut seen = Vec::new();
1522        while let Ok(request) = requests.try_recv() {
1523            seen.push(request.provider_id);
1524        }
1525        assert_eq!(seen, vec!["anthropic", "ollama"]);
1526    }
1527
1528    #[tokio::test]
1529    async fn an_out_of_range_verification_request_is_a_no_op() {
1530        let dir = tempfile::tempdir().unwrap();
1531        let mut wizard = test_wizard(dir.path());
1532        let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1533
1534        wizard.request_verification(999);
1535
1536        assert!(requests.try_recv().is_err());
1537    }
1538
1539    #[tokio::test]
1540    async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
1541        let dir = tempfile::tempdir().unwrap();
1542        let mut wizard = test_wizard(dir.path());
1543        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1544        wizard.providers[0].selected = true;
1545        wizard.providers[0].checking = true;
1546
1547        replies
1548            .send(VerifyReply {
1549                provider_id: "anthropic".to_string(),
1550                outcome: Outcome::Reachable {
1551                    models: vec!["claude-opus-5".to_string()],
1552                },
1553            })
1554            .unwrap();
1555        // A reply for something not in the table is ignored rather than panicking.
1556        replies
1557            .send(VerifyReply {
1558                provider_id: "not-a-provider".to_string(),
1559                outcome: Outcome::Skipped,
1560            })
1561            .unwrap();
1562        wizard.drain_verifications();
1563
1564        assert!(!wizard.providers[0].checking);
1565        assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
1566    }
1567
1568    #[tokio::test]
1569    async fn a_late_reply_refills_the_model_picker() {
1570        // Moving straight from a credential into Defaults gets there before the
1571        // check comes back, so the picker was built from an empty model list
1572        // and stayed that way - caught by driving the real TUI against a live
1573        // API key.
1574        let dir = tempfile::tempdir().unwrap();
1575        let mut wizard = test_wizard(dir.path());
1576        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1577        wizard.providers[0].selected = true;
1578        wizard.enter(Step::Defaults);
1579        assert_eq!(
1580            wizard.defaults[1].value.options(),
1581            ["(provider default)".to_string()],
1582            "nothing has been reported yet"
1583        );
1584
1585        replies
1586            .send(VerifyReply {
1587                provider_id: "anthropic".to_string(),
1588                outcome: Outcome::Reachable {
1589                    models: vec!["claude-opus-5".to_string()],
1590                },
1591            })
1592            .unwrap();
1593        wizard.drain_verifications();
1594
1595        assert!(
1596            wizard.defaults[1]
1597                .value
1598                .options()
1599                .contains(&"claude-opus-5".to_string()),
1600            "the picker should have refilled"
1601        );
1602    }
1603
1604    #[tokio::test]
1605    async fn a_late_reply_does_not_disturb_another_screen() {
1606        let dir = tempfile::tempdir().unwrap();
1607        let mut wizard = test_wizard(dir.path());
1608        let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1609        wizard.providers[0].selected = true;
1610        wizard.enter(Step::Limits);
1611        wizard.limits[0].value = FieldValue::Number(Some(3));
1612
1613        replies
1614            .send(VerifyReply {
1615                provider_id: "anthropic".to_string(),
1616                outcome: Outcome::Reachable {
1617                    models: vec!["m".to_string()],
1618                },
1619            })
1620            .unwrap();
1621        wizard.drain_verifications();
1622
1623        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1624    }
1625
1626    #[test]
1627    fn the_verification_channel_ends_can_only_be_taken_once() {
1628        let dir = tempfile::tempdir().unwrap();
1629        let mut wizard = test_wizard(dir.path());
1630
1631        assert!(wizard.take_verify_ends().is_some());
1632        assert!(wizard.take_verify_ends().is_none());
1633    }
1634
1635    #[test]
1636    fn models_from_unselected_providers_are_not_offered() {
1637        let dir = tempfile::tempdir().unwrap();
1638        let mut wizard = test_wizard(dir.path());
1639        wizard.providers[0].outcome = Outcome::Reachable {
1640            models: vec!["hidden".to_string()],
1641        };
1642
1643        assert!(wizard.discovered_models().is_empty());
1644    }
1645
1646    // ─── forms ──────────────────────────────────────────────────────────────
1647
1648    #[test]
1649    fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
1650        // A free-text prompt lets a typo through and only fails at the
1651        // first agent run.
1652        let dir = tempfile::tempdir().unwrap();
1653        let mut wizard = test_wizard(dir.path());
1654        let ollama = wizard
1655            .providers
1656            .iter()
1657            .position(|r| r.provider.id == "ollama")
1658            .expect("ollama is offered");
1659        wizard.providers[ollama].selected = true;
1660        wizard.enter(Step::Defaults);
1661
1662        assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
1663    }
1664
1665    #[test]
1666    fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
1667        let dir = tempfile::tempdir().unwrap();
1668        let mut wizard = test_wizard(dir.path());
1669        wizard.enter(Step::Defaults);
1670
1671        assert_eq!(
1672            wizard.defaults[0].value.display(),
1673            Config::default().default_provider
1674        );
1675    }
1676
1677    #[test]
1678    fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
1679        let dir = tempfile::tempdir().unwrap();
1680        let base = Config {
1681            default_model: Some("hand-typed".to_string()),
1682            ..Config::default()
1683        };
1684        let mut wizard = Wizard::new(
1685            base,
1686            &|_| None,
1687            Vec::new(),
1688            Vec::new(),
1689            dir.path(),
1690            std::sync::Arc::new(|_| true),
1691        );
1692        wizard.providers[0].selected = true;
1693        wizard.providers[0].outcome = Outcome::Reachable {
1694            models: vec!["claude-opus-5".to_string()],
1695        };
1696
1697        wizard.enter(Step::Defaults);
1698
1699        let options = wizard.defaults[1].value.options();
1700        assert!(options.contains(&"(provider default)".to_string()));
1701        assert!(options.contains(&"claude-opus-5".to_string()));
1702        assert_eq!(
1703            wizard.defaults[1].value.display(),
1704            "hand-typed",
1705            "a model already in the config must survive"
1706        );
1707    }
1708
1709    #[test]
1710    fn only_a_choice_field_has_options() {
1711        assert_eq!(
1712            FieldValue::Choice {
1713                options: vec!["a".into()],
1714                index: 0
1715            }
1716            .options(),
1717            ["a".to_string()]
1718        );
1719        assert!(FieldValue::Number(Some(1)).options().is_empty());
1720        assert!(FieldValue::Bool(true).options().is_empty());
1721    }
1722
1723    #[test]
1724    fn field_values_read_naturally() {
1725        assert_eq!(FieldValue::Number(None).display(), "(unset)");
1726        assert_eq!(FieldValue::Number(Some(7)).display(), "7");
1727        assert_eq!(FieldValue::Bool(true).display(), "yes");
1728        assert_eq!(FieldValue::Bool(false).display(), "no");
1729        assert_eq!(
1730            FieldValue::Choice {
1731                options: vec!["a".into()],
1732                index: 0
1733            }
1734            .display(),
1735            "a"
1736        );
1737        assert_eq!(
1738            FieldValue::Choice {
1739                options: vec![],
1740                index: 0
1741            }
1742            .display(),
1743            "(none)"
1744        );
1745    }
1746
1747    #[test]
1748    fn picking_ollama_drops_the_concurrency_default_to_one() {
1749        // A local box serves one model at a time; eight concurrent inferences
1750        // against one Ollama instance queue and thrash rather than going faster.
1751        let dir = tempfile::tempdir().unwrap();
1752        let mut wizard = test_wizard(dir.path());
1753        let ollama = wizard
1754            .providers
1755            .iter()
1756            .position(|r| r.provider.id == "ollama")
1757            .expect("ollama is offered");
1758        wizard.providers[ollama].selected = true;
1759        wizard.enter(Step::Defaults);
1760
1761        wizard.apply_provider_concurrency_default();
1762
1763        assert_eq!(
1764            wizard.limits[0].value,
1765            FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
1766        );
1767    }
1768
1769    #[test]
1770    fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
1771        // Regression: re-picking the default only when an arrow key moves the
1772        // provider choice misses this case. With Ollama the sole selection it
1773        // is already at index 0, no arrow is ever pressed, and the limit stays
1774        // at the hosted-API default of 8 - caught by driving the real TUI.
1775        let dir = tempfile::tempdir().unwrap();
1776        let mut wizard = test_wizard(dir.path());
1777        let ollama = wizard
1778            .providers
1779            .iter()
1780            .position(|r| r.provider.id == "ollama")
1781            .expect("ollama is offered");
1782        wizard.providers[ollama].selected = true;
1783
1784        wizard.enter(Step::Defaults);
1785
1786        assert_eq!(wizard.defaults[0].value.display(), "ollama");
1787        assert_eq!(
1788            wizard.build_config().limits.max_concurrent_inferences,
1789            Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
1790        );
1791    }
1792
1793    #[test]
1794    fn switching_back_off_ollama_restores_the_general_default() {
1795        let dir = tempfile::tempdir().unwrap();
1796        let mut wizard = test_wizard(dir.path());
1797        let ollama = wizard
1798            .providers
1799            .iter()
1800            .position(|r| r.provider.id == "ollama")
1801            .expect("ollama is offered");
1802        wizard.providers[ollama].selected = true;
1803        wizard.enter(Step::Defaults);
1804        wizard.apply_provider_concurrency_default();
1805
1806        wizard.providers[ollama].selected = false;
1807        wizard.providers[0].selected = true;
1808        wizard.rebuild_defaults();
1809        wizard.apply_provider_concurrency_default();
1810
1811        assert_eq!(
1812            wizard.limits[0].value,
1813            FieldValue::Number(
1814                Config::default()
1815                    .limits
1816                    .max_concurrent_inferences
1817                    .map(|n| n as u64)
1818            )
1819        );
1820    }
1821
1822    #[test]
1823    fn a_hand_typed_concurrency_is_never_overwritten() {
1824        let dir = tempfile::tempdir().unwrap();
1825        let mut wizard = test_wizard(dir.path());
1826        let ollama = wizard
1827            .providers
1828            .iter()
1829            .position(|r| r.provider.id == "ollama")
1830            .expect("ollama is offered");
1831        wizard.providers[ollama].selected = true;
1832        wizard.enter(Step::Defaults);
1833        wizard.limits[0].value = FieldValue::Number(Some(3));
1834
1835        wizard.apply_provider_concurrency_default();
1836
1837        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1838    }
1839
1840    // ─── editing ────────────────────────────────────────────────────────────
1841
1842    #[test]
1843    fn committing_a_credential_clears_its_stale_verification() {
1844        let dir = tempfile::tempdir().unwrap();
1845        let mut wizard = test_wizard(dir.path());
1846        wizard.providers[0].outcome = Outcome::Reachable {
1847            models: vec!["m".into()],
1848        };
1849        wizard.edit = Some(Edit {
1850            target: EditTarget::Credential(0),
1851            buffer: "  sk-ant-new  ".to_string(),
1852            masked: true,
1853        });
1854
1855        wizard.commit_edit();
1856
1857        assert_eq!(wizard.providers[0].value, "sk-ant-new");
1858        assert_eq!(
1859            wizard.providers[0].outcome,
1860            Outcome::Skipped,
1861            "the old result was for a different key"
1862        );
1863        assert!(wizard.edit.is_none());
1864    }
1865
1866    #[test]
1867    fn typing_a_credential_supersedes_the_environments() {
1868        let dir = tempfile::tempdir().unwrap();
1869        let mut wizard = Wizard::new(
1870            Config::default(),
1871            &|_| Some("sk-ant-env".to_string()),
1872            Vec::new(),
1873            Vec::new(),
1874            dir.path(),
1875            std::sync::Arc::new(|_| true),
1876        );
1877        assert!(wizard.providers[0].from_env.is_some());
1878        wizard.edit = Some(Edit {
1879            target: EditTarget::Credential(0),
1880            buffer: "sk-ant-typed".to_string(),
1881            masked: true,
1882        });
1883
1884        wizard.commit_edit();
1885
1886        assert!(wizard.providers[0].from_env.is_none());
1887        assert_eq!(
1888            wizard.build_config().providers.anthropic_api_key.as_deref(),
1889            Some("sk-ant-typed")
1890        );
1891    }
1892
1893    #[test]
1894    fn committing_numbers_handles_blank_and_unparseable_input() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let mut wizard = test_wizard(dir.path());
1897        wizard.enter(Step::Limits);
1898
1899        wizard.edit = Some(Edit {
1900            target: EditTarget::Field(0),
1901            buffer: "16".to_string(),
1902            masked: false,
1903        });
1904        wizard.commit_edit();
1905        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
1906
1907        // Garbage keeps the previous value rather than silently unsetting it.
1908        wizard.edit = Some(Edit {
1909            target: EditTarget::Field(0),
1910            buffer: "not a number".to_string(),
1911            masked: false,
1912        });
1913        wizard.commit_edit();
1914        assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
1915
1916        // Blank means unset, which is a real and different choice.
1917        wizard.edit = Some(Edit {
1918            target: EditTarget::Field(0),
1919            buffer: "   ".to_string(),
1920            masked: false,
1921        });
1922        wizard.commit_edit();
1923        assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
1924    }
1925
1926    #[test]
1927    fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
1928        let dir = tempfile::tempdir().unwrap();
1929        let mut wizard = test_wizard(dir.path());
1930        wizard.commit_edit();
1931
1932        wizard.edit = Some(Edit {
1933            target: EditTarget::Credential(999),
1934            buffer: "x".to_string(),
1935            masked: false,
1936        });
1937        wizard.commit_edit();
1938
1939        wizard.enter(Step::Limits);
1940        wizard.edit = Some(Edit {
1941            target: EditTarget::Field(999),
1942            buffer: "x".to_string(),
1943            masked: false,
1944        });
1945        wizard.commit_edit();
1946
1947        // A step with no fields at all.
1948        wizard.enter(Step::Welcome);
1949        wizard.edit = Some(Edit {
1950            target: EditTarget::Field(0),
1951            buffer: "x".to_string(),
1952            masked: false,
1953        });
1954        wizard.commit_edit();
1955
1956        assert!(wizard.edit.is_none());
1957    }
1958
1959    #[test]
1960    fn every_step_reports_a_sensible_row_count() {
1961        let dir = tempfile::tempdir().unwrap();
1962        let mut wizard = Wizard::new(
1963            Config::default(),
1964            &|_| None,
1965            vec![("A".to_string(), candidate("fs"))],
1966            Vec::new(),
1967            dir.path(),
1968            std::sync::Arc::new(|_| true),
1969        );
1970        wizard.providers[0].selected = true;
1971
1972        for step in Step::ALL {
1973            wizard.enter(step);
1974            let rows = wizard.row_count();
1975            match step {
1976                Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
1977                _ => assert!(rows > 0, "{step:?} has no rows"),
1978            }
1979            // Fields exist for exactly the two form screens.
1980            let fields = wizard.fields().len();
1981            match step {
1982                Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
1983                _ => assert_eq!(fields, 0, "{step:?} should have no fields"),
1984            }
1985        }
1986    }
1987
1988    #[test]
1989    fn committing_onto_a_defaults_field_reaches_that_form_too() {
1990        let dir = tempfile::tempdir().unwrap();
1991        let mut wizard = test_wizard(dir.path());
1992        wizard.providers[0].selected = true;
1993        wizard.enter(Step::Defaults);
1994
1995        wizard.edit = Some(Edit {
1996            target: EditTarget::Field(2),
1997            buffer: "45".to_string(),
1998            masked: false,
1999        });
2000        wizard.commit_edit();
2001
2002        assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
2003        assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
2004    }
2005
2006    #[test]
2007    fn clearing_a_credential_leaves_the_environment_marker_alone() {
2008        // Blanking the field is how a user says "use what's in my environment".
2009        let dir = tempfile::tempdir().unwrap();
2010        let mut wizard = Wizard::new(
2011            Config::default(),
2012            &|_| Some("sk-ant-env".to_string()),
2013            Vec::new(),
2014            Vec::new(),
2015            dir.path(),
2016            std::sync::Arc::new(|_| true),
2017        );
2018        wizard.edit = Some(Edit {
2019            target: EditTarget::Credential(0),
2020            buffer: "   ".to_string(),
2021            masked: true,
2022        });
2023
2024        wizard.commit_edit();
2025
2026        assert!(wizard.providers[0].value.is_empty());
2027        assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
2028    }
2029
2030    #[test]
2031    fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
2032        // Defensive: a future reorder must not silently drop the setting.
2033        let dir = tempfile::tempdir().unwrap();
2034        let mut wizard = test_wizard(dir.path());
2035        wizard.defaults[0].value = FieldValue::Bool(true);
2036
2037        assert_eq!(
2038            wizard.build_config().default_provider,
2039            Config::default().default_provider
2040        );
2041    }
2042
2043    #[test]
2044    fn an_empty_choice_list_falls_back_to_the_base_config() {
2045        let dir = tempfile::tempdir().unwrap();
2046        let mut wizard = test_wizard(dir.path());
2047        wizard.defaults[0].value = FieldValue::Choice {
2048            options: Vec::new(),
2049            index: 0,
2050        };
2051
2052        assert_eq!(
2053            wizard.build_config().default_provider,
2054            Config::default().default_provider
2055        );
2056    }
2057
2058    #[test]
2059    fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
2060        let dir = tempfile::tempdir().unwrap();
2061        let mut wizard = test_wizard(dir.path());
2062        wizard.limits[0].value = FieldValue::Bool(true);
2063
2064        wizard.apply_provider_concurrency_default();
2065
2066        assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
2067    }
2068
2069    #[test]
2070    fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let mut wizard = test_wizard(dir.path());
2073        wizard.enter(Step::Limits);
2074        let before = wizard.limits[3].value.clone();
2075
2076        wizard.edit = Some(Edit {
2077            target: EditTarget::Field(3),
2078            buffer: "yes".to_string(),
2079            masked: false,
2080        });
2081        wizard.commit_edit();
2082
2083        assert_eq!(wizard.limits[3].value, before);
2084    }
2085
2086    // ─── building the config ────────────────────────────────────────────────
2087
2088    #[test]
2089    fn deselecting_a_provider_clears_its_credential() {
2090        let dir = tempfile::tempdir().unwrap();
2091        let base = Config {
2092            providers: crate::config::ProviderConfig {
2093                anthropic_api_key: Some("sk-ant-stored".to_string()),
2094                ..Config::default().providers
2095            },
2096            ..Config::default()
2097        };
2098        let mut wizard = Wizard::new(
2099            base,
2100            &|_| None,
2101            Vec::new(),
2102            Vec::new(),
2103            dir.path(),
2104            std::sync::Arc::new(|_| true),
2105        );
2106
2107        wizard.providers[0].selected = false;
2108
2109        assert!(wizard.build_config().providers.anthropic_api_key.is_none());
2110    }
2111
2112    #[test]
2113    fn ollamas_default_url_is_left_unset_rather_than_pinned() {
2114        // Storing the default would freeze it and shadow $OLLAMA_HOST.
2115        let dir = tempfile::tempdir().unwrap();
2116        let mut wizard = test_wizard(dir.path());
2117        let ollama = wizard
2118            .providers
2119            .iter()
2120            .position(|r| r.provider.id == "ollama")
2121            .expect("ollama is offered");
2122        wizard.providers[ollama].selected = true;
2123        wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
2124
2125        assert!(wizard.build_config().ollama_base_url.is_none());
2126
2127        wizard.providers[ollama].value = "http://box:11434".to_string();
2128        assert_eq!(
2129            wizard.build_config().ollama_base_url.as_deref(),
2130            Some("http://box:11434")
2131        );
2132    }
2133
2134    #[test]
2135    fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
2136        let dir = tempfile::tempdir().unwrap();
2137        let mut wizard = test_wizard(dir.path());
2138        let index = wizard
2139            .providers
2140            .iter()
2141            .position(|r| r.provider.id == "claude-code")
2142            .expect("the transport is offered");
2143
2144        assert!(!wizard.build_config().providers.claude_code_enabled);
2145
2146        wizard.providers[index].selected = true;
2147        wizard.providers[index].effort = effort_options().len() - 1;
2148        let config = wizard.build_config();
2149        assert!(config.providers.claude_code_enabled);
2150        assert_eq!(
2151            config.providers.claude_code_effort.as_deref(),
2152            Some(*effort_options().last().expect("levels exist"))
2153        );
2154    }
2155
2156    #[test]
2157    fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
2158        let dir = tempfile::tempdir().unwrap();
2159        let mut wizard = test_wizard(dir.path());
2160        let index = wizard
2161            .providers
2162            .iter()
2163            .position(|r| r.provider.id == "claude-code")
2164            .expect("the transport is offered");
2165        wizard.providers[index].selected = true;
2166        wizard.providers[index].effort = 99;
2167
2168        let config = wizard.build_config();
2169
2170        assert_eq!(
2171            config.providers.claude_code_effort.as_deref(),
2172            Some(*effort_options().last().expect("levels exist"))
2173        );
2174    }
2175
2176    #[test]
2177    fn the_stored_effort_selects_the_matching_option() {
2178        let dir = tempfile::tempdir().unwrap();
2179        let base = Config {
2180            providers: crate::config::ProviderConfig {
2181                claude_code_effort: Some("max".to_string()),
2182                ..Config::default().providers
2183            },
2184            ..Config::default()
2185        };
2186        let wizard = Wizard::new(
2187            base,
2188            &|_| None,
2189            Vec::new(),
2190            Vec::new(),
2191            dir.path(),
2192            std::sync::Arc::new(|_| true),
2193        );
2194
2195        let index = wizard
2196            .providers
2197            .iter()
2198            .position(|r| r.provider.id == "claude-code")
2199            .expect("the transport is offered");
2200        assert_eq!(effort_options()[wizard.providers[index].effort], "max");
2201    }
2202
2203    #[test]
2204    fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
2205        assert_eq!(effort_index(Some("not-a-level")), 0);
2206        assert_eq!(
2207            effort_options()[effort_index(None)],
2208            leviath_providers::claude_code::DEFAULT_EFFORT
2209        );
2210    }
2211
2212    #[test]
2213    fn the_provider_default_model_is_stored_as_unset() {
2214        let dir = tempfile::tempdir().unwrap();
2215        let mut wizard = test_wizard(dir.path());
2216        wizard.providers[0].selected = true;
2217        wizard.enter(Step::Defaults);
2218
2219        // Index 0 of the model picker is always "(provider default)".
2220        assert!(wizard.build_config().default_model.is_none());
2221    }
2222
2223    #[test]
2224    fn limits_are_written_back_including_the_zero_guard() {
2225        // A zero here would deadlock every tool batch, so it falls back to the
2226        // default rather than being stored.
2227        let dir = tempfile::tempdir().unwrap();
2228        let mut wizard = test_wizard(dir.path());
2229        wizard.enter(Step::Limits);
2230        wizard.limits[0].value = FieldValue::Number(Some(2));
2231        wizard.limits[1].value = FieldValue::Number(Some(0));
2232        wizard.limits[2].value = FieldValue::Number(None);
2233        wizard.limits[3].value = FieldValue::Bool(true);
2234        wizard.limits[4].value = FieldValue::Bool(false);
2235        wizard.limits[5].value = FieldValue::Bool(false);
2236        wizard.limits[6].value = FieldValue::Number(Some(11));
2237        wizard.limits[7].value = FieldValue::Number(Some(22));
2238        wizard.limits[8].value = FieldValue::Number(Some(33));
2239        wizard.limits[9].value = FieldValue::Number(Some(44));
2240
2241        let config = wizard.build_config();
2242
2243        assert_eq!(config.limits.max_concurrent_inferences, Some(2));
2244        assert_eq!(
2245            config.limits.max_concurrent_tools,
2246            Config::default().limits.max_concurrent_tools
2247        );
2248        assert!(config.limits.default_max_iterations.is_none());
2249        assert!(config.limits.exact_token_counting);
2250        assert!(!config.batch_tool_hint);
2251        assert!(!config.shell_hint);
2252        // Every remaining field gets a distinct value, so a form that grows a
2253        // row without renumbering `apply_limits_fields` fails here rather than
2254        // silently dropping whichever field the duplicated index shadowed.
2255        assert_eq!(config.limits.stall_timeout_secs, 11);
2256        assert_eq!(config.limits.dead_cycles_before_relief, 22);
2257        assert_eq!(config.limits.finished_retention_secs, 33);
2258        assert_eq!(config.limits.wedge_timeout_secs, 44);
2259    }
2260
2261    #[test]
2262    fn every_limits_field_is_written_back() {
2263        // The index in `apply_limits_fields` is positional and hand-written, so
2264        // an inserted row shifts every arm below it. Round-tripping the form
2265        // through itself catches a gap or a duplicate without naming indices.
2266        let dir = tempfile::tempdir().unwrap();
2267        let mut wizard = test_wizard(dir.path());
2268        wizard.enter(Step::Limits);
2269        let count = wizard.limits.len();
2270
2271        let before = wizard.build_config();
2272        let seeded = limits_fields(&before);
2273        assert_eq!(seeded.len(), count, "the form is built from the config");
2274
2275        // Flip every toggle and give every number a distinct non-default
2276        // value, then read the form back out of the config it produced: a
2277        // field that no arm writes comes back with its original value. The
2278        // limits form is toggles and numbers only, so the second arm is the
2279        // number case rather than an unexercised catch-all.
2280        for (i, field) in wizard.limits.iter_mut().enumerate() {
2281            field.value = match &field.value {
2282                FieldValue::Bool(b) => FieldValue::Bool(!b),
2283                _ => FieldValue::Number(Some(i as u64 + 11)),
2284            };
2285        }
2286        let expected: Vec<FieldValue> = wizard.limits.iter().map(|f| f.value.clone()).collect();
2287        let after = limits_fields(&wizard.build_config());
2288
2289        for (i, (got, want)) in after.iter().zip(&expected).enumerate() {
2290            assert_eq!(
2291                &got.value, want,
2292                "field {i} ({}) did not survive the round trip",
2293                got.label
2294            );
2295        }
2296    }
2297
2298    /// The four timing limits share one rule: an explicit number is stored,
2299    /// including `0` (which means "never" for each of them), while leaving a
2300    /// field blank keeps the shipped default rather than disabling anything.
2301    #[test]
2302    fn the_watchdog_limits_store_zero_and_keep_the_default_when_blank() {
2303        let dir = tempfile::tempdir().unwrap();
2304        let mut wizard = test_wizard(dir.path());
2305        wizard.enter(Step::Limits);
2306        wizard.limits[6].value = FieldValue::Number(Some(0));
2307        wizard.limits[7].value = FieldValue::Number(Some(0));
2308        wizard.limits[8].value = FieldValue::Number(Some(0));
2309        wizard.limits[9].value = FieldValue::Number(Some(300));
2310
2311        let config = wizard.build_config();
2312
2313        assert_eq!(config.limits.stall_timeout_secs, 0);
2314        assert_eq!(config.limits.dead_cycles_before_relief, 0);
2315        assert_eq!(config.limits.finished_retention_secs, 0);
2316        assert_eq!(config.limits.wedge_timeout_secs, 300);
2317
2318        let mut wizard = test_wizard(dir.path());
2319        wizard.enter(Step::Limits);
2320        wizard.limits[6].value = FieldValue::Number(None);
2321        wizard.limits[7].value = FieldValue::Number(None);
2322        wizard.limits[8].value = FieldValue::Number(None);
2323        wizard.limits[9].value = FieldValue::Number(None);
2324
2325        let config = wizard.build_config();
2326
2327        let default = Config::default();
2328        assert_eq!(
2329            config.limits.stall_timeout_secs,
2330            default.limits.stall_timeout_secs
2331        );
2332        assert_eq!(
2333            config.limits.dead_cycles_before_relief,
2334            default.limits.dead_cycles_before_relief
2335        );
2336        assert_eq!(
2337            config.limits.finished_retention_secs,
2338            default.limits.finished_retention_secs
2339        );
2340        assert_eq!(
2341            config.limits.wedge_timeout_secs,
2342            default.limits.wedge_timeout_secs
2343        );
2344    }
2345
2346    #[test]
2347    fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
2348        // Defensive: nothing builds this shape today, but a future edit that
2349        // reorders the form must not silently write a boolean into a count.
2350        let mut config = Config::default();
2351        apply_limits_fields(
2352            &mut config,
2353            &[Field {
2354                label: "Max concurrent inferences",
2355                help: "",
2356                value: FieldValue::Bool(true),
2357            }],
2358        );
2359
2360        assert_eq!(
2361            config.limits.max_concurrent_inferences,
2362            Config::default().limits.max_concurrent_inferences
2363        );
2364    }
2365
2366    #[test]
2367    fn the_plan_carries_only_the_selected_agents() {
2368        let dir = tempfile::tempdir().unwrap();
2369        let mut wizard = test_wizard(dir.path());
2370        for row in wizard.agents.iter_mut().skip(1) {
2371            row.selected = false;
2372        }
2373
2374        let plan = wizard.build_plan();
2375
2376        assert_eq!(plan.agents.len(), 1);
2377        assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
2378    }
2379
2380    #[test]
2381    fn the_review_says_so_when_nothing_would_change() {
2382        let dir = tempfile::tempdir().unwrap();
2383        let mut wizard = test_wizard(dir.path());
2384        for row in wizard.agents.iter_mut() {
2385            row.selected = false;
2386        }
2387
2388        assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
2389    }
2390
2391    #[test]
2392    fn the_review_lists_real_changes() {
2393        let dir = tempfile::tempdir().unwrap();
2394        let mut wizard = test_wizard(dir.path());
2395        wizard.providers[0].selected = true;
2396        wizard.providers[0].value = "sk-ant-x".to_string();
2397
2398        let lines = wizard.review_lines();
2399
2400        assert!(lines.iter().any(|l| l.contains("credential set")));
2401        assert!(lines.iter().any(|l| l.contains("to install")));
2402    }
2403
2404    // ─── scan merging ───────────────────────────────────────────────────────
2405
2406    #[test]
2407    fn scans_flatten_into_candidates_and_labelled_errors() {
2408        let scans = vec![
2409            import::Scan {
2410                source: import::Source {
2411                    id: "a",
2412                    display: "Harness A",
2413                    path: std::path::PathBuf::from("/a"),
2414                    layout: import::Layout::ClaudeCode,
2415                    allows_comments: false,
2416                },
2417                result: Ok(vec![candidate("fs")]),
2418            },
2419            import::Scan {
2420                source: import::Source {
2421                    id: "b",
2422                    display: "Harness B",
2423                    path: std::path::PathBuf::from("/b"),
2424                    layout: import::Layout::CodexToml,
2425                    allows_comments: false,
2426                },
2427                result: Err("unreadable".to_string()),
2428            },
2429        ];
2430
2431        let (candidates, errors) = candidates_from_scans(scans);
2432
2433        assert_eq!(candidates.len(), 1);
2434        assert_eq!(candidates[0].0, "Harness A");
2435        assert_eq!(errors, vec!["Harness B: unreadable"]);
2436    }
2437}