1use 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#[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 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 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 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#[derive(Debug, Clone)]
71pub struct ProviderRow {
72 pub provider: Provider,
73 pub selected: bool,
74 pub value: String,
76 pub from_env: Option<&'static str>,
79 pub effort: usize,
81 pub outcome: Outcome,
82 pub checking: bool,
84}
85
86impl ProviderRow {
87 pub fn has_credential(&self) -> bool {
89 match self.provider.credential {
90 Credential::ApiKey => !self.value.is_empty() || self.from_env.is_some(),
91 Credential::BaseUrl | Credential::None => true,
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct AgentRow {
101 pub agent: &'static BundledAgent,
102 pub action: AgentAction,
103 pub selected: bool,
104}
105
106#[derive(Debug, Clone)]
108pub struct McpRow {
109 pub candidate: Candidate,
110 pub source: String,
112 pub selected: bool,
113 pub collides: bool,
115 pub name: String,
117}
118
119#[derive(Debug, Clone)]
121pub struct Field {
122 pub label: &'static str,
123 pub help: &'static str,
124 pub value: FieldValue,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum FieldValue {
131 Number(Option<u64>),
133 Bool(bool),
135 Choice { options: Vec<String>, index: usize },
137}
138
139impl FieldValue {
140 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 pub fn options(&self) -> &[String] {
156 match self {
157 Self::Choice { options, .. } => options,
158 Self::Number(_) | Self::Bool(_) => &[],
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct VerifyRequest {
166 pub provider_id: String,
167 pub creds: leviath_runtime::provider_creds::ProviderCreds,
168}
169
170#[derive(Debug, Clone)]
172pub struct VerifyReply {
173 pub provider_id: String,
174 pub outcome: Outcome,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum EditTarget {
180 Credential(usize),
182 Field(usize),
184}
185
186#[derive(Debug, Clone)]
188pub struct Edit {
189 pub target: EditTarget,
190 pub buffer: String,
191 pub masked: bool,
193}
194
195pub struct Wizard {
197 pub step: Step,
198 pub cursor: usize,
200 pub providers: Vec<ProviderRow>,
201 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 pub reveal: bool,
211 pub show_help: bool,
212 pub show_tos_confirm: bool,
215 pub claude_code_tos_accepted: bool,
219 pub should_quit: bool,
220 pub finished: bool,
222 pub message: Option<String>,
224 pub base: Config,
227 pub env_only: HashMap<&'static str, String>,
229 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 pub ticks: u64,
239}
240
241fn 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 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 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 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 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 pub fn detail_row(&self) -> Option<usize> {
379 self.selected_providers().get(self.detail).copied()
380 }
381
382 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 pub fn needs_tos_confirmation(&self) -> bool {
394 self.claude_code_selected() && !self.claude_code_tos_accepted
395 }
396
397 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 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 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 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 self.enter(Step::Review);
451 }
452
453 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 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 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 self.rebuild_defaults();
487 }
488 }
489
490 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 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 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 let _ = self.verify_tx.send(VerifyRequest {
557 provider_id: id,
558 creds,
559 });
560 }
561
562 pub fn verify_all(&mut self) {
564 for index in self.selected_providers() {
565 self.request_verification(index);
566 }
567 }
568
569 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 if landed && self.step == Step::Defaults {
589 self.rebuild_defaults();
590 }
591 }
592
593 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 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 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(¤t_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 self.apply_provider_concurrency_default();
669 }
670
671 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 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 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 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 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 FieldValue::Bool(_) | FieldValue::Choice { .. } => {}
756 }
757 }
758 }
759 }
760 }
761
762 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 _ 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 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 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 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 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 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
852pub fn effort_options() -> &'static [&'static str] {
855 &leviath_providers::claude_code::EFFORT_LEVELS
856}
857
858fn 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
867fn limits_fields(config: &Config) -> Vec<Field> {
869 vec![
870 Field {
871 label: "Max concurrent inferences",
872 help: "How many model calls run at once across all agents.",
873 value: FieldValue::Number(config.limits.max_concurrent_inferences.map(|n| n as u64)),
874 },
875 Field {
876 label: "Max concurrent tools",
877 help: "How many tool calls run at once within one batch.",
878 value: FieldValue::Number(Some(config.limits.max_concurrent_tools as u64)),
879 },
880 Field {
881 label: "Default max iterations",
882 help: "Per-stage ceiling when a blueprint sets none.",
883 value: FieldValue::Number(config.limits.default_max_iterations.map(|n| n as u64)),
884 },
885 Field {
886 label: "Exact token counting",
887 help: "Ask the provider for real token counts instead of estimating. Slower.",
888 value: FieldValue::Bool(config.limits.exact_token_counting),
889 },
890 Field {
891 label: "Batch tool-call hint",
892 help: "Nudge models to request several tools in one turn.",
893 value: FieldValue::Bool(config.batch_tool_hint),
894 },
895 ]
896}
897
898fn apply_limits_fields(config: &mut Config, fields: &[Field]) {
900 for (index, field) in fields.iter().enumerate() {
901 match (index, &field.value) {
902 (0, FieldValue::Number(n)) => {
903 config.limits.max_concurrent_inferences = n.map(|n| n as usize)
904 }
905 (1, FieldValue::Number(n)) => {
908 config.limits.max_concurrent_tools = n
909 .filter(|n| *n > 0)
910 .map(|n| n as usize)
911 .unwrap_or(Config::default().limits.max_concurrent_tools)
912 }
913 (2, FieldValue::Number(n)) => {
914 config.limits.default_max_iterations = n.map(|n| n as usize)
915 }
916 (3, FieldValue::Bool(b)) => config.limits.exact_token_counting = *b,
917 (4, FieldValue::Bool(b)) => config.batch_tool_hint = *b,
918 _ => {}
919 }
920 }
921}
922
923pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
926 let mut candidates = Vec::new();
927 let mut errors = Vec::new();
928 for scan in scans {
929 match scan.result {
930 Ok(found) => candidates.extend(
931 found
932 .into_iter()
933 .map(|c| (scan.source.display.to_string(), c)),
934 ),
935 Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
936 }
937 }
938 (candidates, errors)
939}
940
941pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
944 rows.iter()
945 .filter(|r| r.selected)
946 .map(|r| {
947 let mut server = r.candidate.config.clone();
948 server.name = r.name.clone();
949 server
950 })
951 .collect()
952}
953
954#[cfg(test)]
955pub(super) mod tests {
956 use super::*;
957 use crate::bundled::BUNDLED_AGENTS;
958
959 pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
962 Wizard::new(
963 Config::default(),
964 &|_| None,
965 Vec::new(),
966 Vec::new(),
967 agents_dir,
968 std::sync::Arc::new(|_| true),
969 )
970 }
971
972 fn candidate(name: &str) -> Candidate {
973 Candidate {
974 config: MCPServerConfig::stdio(name, "npx", vec![]),
975 scope: String::new(),
976 inline_secrets: Vec::new(),
977 }
978 }
979
980 #[test]
983 fn every_step_is_titled_and_ordered() {
984 for (index, step) in Step::ALL.iter().enumerate() {
985 assert!(!step.title().is_empty(), "{step:?} has no title");
986 assert_eq!(step.index(), index);
987 }
988 }
989
990 #[test]
993 fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
994 let dir = tempfile::tempdir().unwrap();
995
996 let wizard = test_wizard(dir.path());
997
998 assert_eq!(wizard.step, Step::Welcome);
999 assert!(wizard.selected_providers().is_empty());
1000 assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
1001 assert!(
1002 wizard.agents.iter().all(|r| r.selected),
1003 "a fresh install should offer to install everything"
1004 );
1005 assert!(
1006 wizard
1007 .agents
1008 .iter()
1009 .all(|r| r.action == AgentAction::Install)
1010 );
1011 }
1012
1013 #[test]
1014 fn already_installed_agents_are_listed_but_not_reselected() {
1015 let dir = tempfile::tempdir().unwrap();
1016 for agent in BUNDLED_AGENTS {
1017 crate::bundled::install_bundled(agent, dir.path()).unwrap();
1018 }
1019
1020 let wizard = test_wizard(dir.path());
1021
1022 assert!(
1023 wizard.agents.iter().all(|r| !r.selected),
1024 "nothing needs doing, so nothing should be pre-checked"
1025 );
1026 }
1027
1028 #[test]
1029 fn a_configured_provider_starts_selected_with_its_credential() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let base = Config {
1032 providers: crate::config::ProviderConfig {
1033 anthropic_api_key: Some("sk-ant-stored".to_string()),
1034 ..Config::default().providers
1035 },
1036 ..Config::default()
1037 };
1038
1039 let wizard = Wizard::new(
1040 base,
1041 &|_| None,
1042 Vec::new(),
1043 Vec::new(),
1044 dir.path(),
1045 std::sync::Arc::new(|_| true),
1046 );
1047
1048 let row = wizard
1049 .providers
1050 .iter()
1051 .find(|r| r.provider.id == "anthropic")
1052 .expect("anthropic is in the table");
1053 assert!(row.selected);
1054 assert_eq!(row.value, "sk-ant-stored");
1055 assert!(row.from_env.is_none());
1056 }
1057
1058 #[test]
1059 fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
1060 let dir = tempfile::tempdir().unwrap();
1065
1066 let wizard = Wizard::new(
1067 Config::default(),
1068 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
1069 Vec::new(),
1070 Vec::new(),
1071 dir.path(),
1072 std::sync::Arc::new(|_| true),
1073 );
1074
1075 let row = wizard
1076 .providers
1077 .iter()
1078 .find(|r| r.provider.id == "anthropic")
1079 .expect("anthropic is in the table");
1080 assert!(row.selected, "the provider is usable, so it is selected");
1081 assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
1082 assert!(row.value.is_empty());
1083
1084 let written = wizard.build_config();
1085 assert!(
1086 written.providers.anthropic_api_key.is_none(),
1087 "an environment-supplied key must not be copied into the config"
1088 );
1089 }
1090
1091 #[test]
1092 fn a_stored_key_wins_over_the_environment() {
1093 let dir = tempfile::tempdir().unwrap();
1096 let base = Config {
1097 providers: crate::config::ProviderConfig {
1098 anthropic_api_key: Some("sk-ant-stored".to_string()),
1099 ..Config::default().providers
1100 },
1101 ..Config::default()
1102 };
1103
1104 let wizard = Wizard::new(
1105 base,
1106 &|_| Some("sk-ant-from-env".to_string()),
1107 Vec::new(),
1108 Vec::new(),
1109 dir.path(),
1110 std::sync::Arc::new(|_| true),
1111 );
1112
1113 let row = &wizard.providers[0];
1114 assert!(row.from_env.is_none());
1115 assert_eq!(row.value, "sk-ant-stored");
1116 }
1117
1118 #[test]
1119 fn an_empty_environment_variable_does_not_count_as_a_credential() {
1120 let dir = tempfile::tempdir().unwrap();
1121
1122 let wizard = Wizard::new(
1123 Config::default(),
1124 &|_| Some(String::new()),
1125 Vec::new(),
1126 Vec::new(),
1127 dir.path(),
1128 std::sync::Arc::new(|_| true),
1129 );
1130
1131 assert!(wizard.env_only.is_empty());
1132 assert!(wizard.selected_providers().is_empty());
1133 }
1134
1135 #[test]
1138 fn an_importable_server_is_preselected_and_named_as_found() {
1139 let dir = tempfile::tempdir().unwrap();
1140
1141 let wizard = Wizard::new(
1142 Config::default(),
1143 &|_| None,
1144 vec![("Claude Code".to_string(), candidate("fs"))],
1145 Vec::new(),
1146 dir.path(),
1147 std::sync::Arc::new(|_| true),
1148 );
1149
1150 assert_eq!(wizard.mcp.len(), 1);
1151 assert!(wizard.mcp[0].selected);
1152 assert!(!wizard.mcp[0].collides);
1153 assert_eq!(wizard.mcp[0].name, "fs");
1154 assert_eq!(wizard.mcp[0].source, "Claude Code");
1155 }
1156
1157 #[test]
1158 fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
1159 let dir = tempfile::tempdir().unwrap();
1162 let base = Config {
1163 mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
1164 ..Config::default()
1165 };
1166
1167 let wizard = Wizard::new(
1168 base,
1169 &|_| None,
1170 vec![("Cursor".to_string(), candidate("fs"))],
1171 Vec::new(),
1172 dir.path(),
1173 std::sync::Arc::new(|_| true),
1174 );
1175
1176 assert!(!wizard.mcp[0].selected);
1177 assert!(wizard.mcp[0].collides);
1178 assert_eq!(wizard.mcp[0].name, "fs-2");
1179
1180 let mut wizard = wizard;
1183 wizard.mcp[0].selected = true;
1184 let config = wizard.build_config();
1185 let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
1186 assert_eq!(names, vec!["fs", "fs-2"]);
1187 }
1188
1189 #[test]
1190 fn selected_servers_renames_and_filters() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let mut wizard = Wizard::new(
1193 Config::default(),
1194 &|_| None,
1195 vec![
1196 ("A".to_string(), candidate("keep")),
1197 ("B".to_string(), candidate("drop")),
1198 ],
1199 Vec::new(),
1200 dir.path(),
1201 std::sync::Arc::new(|_| true),
1202 );
1203 wizard.mcp[1].selected = false;
1204 wizard.mcp[0].name = "renamed".to_string();
1205
1206 let servers = selected_servers(&wizard.mcp);
1207
1208 assert_eq!(servers.len(), 1);
1209 assert_eq!(servers[0].name, "renamed");
1210 }
1211
1212 #[test]
1213 fn inline_secrets_are_reported_only_for_selected_rows() {
1214 let dir = tempfile::tempdir().unwrap();
1215 let mut secretive = candidate("leaky");
1216 secretive.inline_secrets = vec!["API_TOKEN".to_string()];
1217
1218 let mut wizard = Wizard::new(
1219 Config::default(),
1220 &|_| None,
1221 vec![
1222 ("A".to_string(), secretive),
1223 ("B".to_string(), candidate("clean")),
1224 ],
1225 Vec::new(),
1226 dir.path(),
1227 std::sync::Arc::new(|_| true),
1228 );
1229
1230 assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
1231 wizard.mcp[0].selected = false;
1232 assert!(wizard.selected_inline_secrets().is_empty());
1233 }
1234
1235 #[test]
1238 fn the_cursor_stays_inside_the_current_step() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let mut wizard = test_wizard(dir.path());
1241 wizard.enter(Step::Providers);
1242
1243 wizard.move_cursor(-5);
1244 assert_eq!(wizard.cursor, 0);
1245 wizard.move_cursor(100);
1246 assert_eq!(wizard.cursor, wizard.providers.len() - 1);
1247 }
1248
1249 #[test]
1250 fn a_step_with_no_rows_pins_the_cursor_at_zero() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let mut wizard = test_wizard(dir.path());
1253 wizard.enter(Step::Welcome);
1254 wizard.cursor = 4;
1255
1256 wizard.move_cursor(1);
1257
1258 assert_eq!(wizard.cursor, 0);
1259 assert_eq!(wizard.row_count(), 0);
1260 }
1261
1262 #[test]
1263 fn empty_discovery_steps_are_skipped_in_both_directions() {
1264 let dir = tempfile::tempdir().unwrap();
1268 let mut wizard = test_wizard(dir.path());
1269 assert!(wizard.mcp.is_empty());
1270
1271 wizard.enter(Step::Providers);
1272 wizard.next_step();
1273 assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
1274
1275 wizard.enter(Step::Agents);
1276 wizard.next_step();
1277 assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
1278
1279 wizard.prev_step();
1280 assert_eq!(wizard.step, Step::Agents);
1281 }
1282
1283 #[test]
1284 fn a_nonempty_discovery_step_is_visited() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let mut wizard = Wizard::new(
1287 Config::default(),
1288 &|_| None,
1289 vec![("A".to_string(), candidate("fs"))],
1290 Vec::new(),
1291 dir.path(),
1292 std::sync::Arc::new(|_| true),
1293 );
1294
1295 wizard.enter(Step::Agents);
1296 wizard.next_step();
1297
1298 assert_eq!(wizard.step, Step::Mcp);
1299 }
1300
1301 #[test]
1302 fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
1303 let dir = tempfile::tempdir().unwrap();
1306 let mut wizard = Wizard::new(
1307 Config::default(),
1308 &|_| None,
1309 Vec::new(),
1310 vec!["Zed: unreadable".to_string()],
1311 dir.path(),
1312 std::sync::Arc::new(|_| true),
1313 );
1314
1315 wizard.enter(Step::Agents);
1316 wizard.next_step();
1317
1318 assert_eq!(wizard.step, Step::Mcp);
1319 }
1320
1321 #[test]
1322 fn the_first_step_has_nowhere_to_go_back_to() {
1323 let dir = tempfile::tempdir().unwrap();
1324 let mut wizard = test_wizard(dir.path());
1325
1326 wizard.prev_step();
1327
1328 assert_eq!(wizard.step, Step::Welcome);
1329 }
1330
1331 #[test]
1332 fn advancing_past_the_last_step_stays_on_review() {
1333 let dir = tempfile::tempdir().unwrap();
1334 let mut wizard = test_wizard(dir.path());
1335 wizard.enter(Step::Review);
1336
1337 wizard.next_step();
1338
1339 assert_eq!(wizard.step, Step::Review);
1340 }
1341
1342 #[test]
1343 fn the_credential_screen_walks_the_selected_providers() {
1344 let dir = tempfile::tempdir().unwrap();
1345 let mut wizard = test_wizard(dir.path());
1346 wizard.providers[0].selected = true;
1347 wizard.providers[1].selected = true;
1348
1349 assert_eq!(wizard.detail_row(), Some(0));
1350 assert!(wizard.next_detail());
1351 assert_eq!(wizard.detail_row(), Some(1));
1352 assert!(!wizard.next_detail(), "there is no third provider");
1353 assert!(wizard.prev_detail());
1354 assert_eq!(wizard.detail_row(), Some(0));
1355 assert!(!wizard.prev_detail());
1356 }
1357
1358 #[test]
1359 fn the_credential_screen_has_no_row_when_nothing_is_selected() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let mut wizard = test_wizard(dir.path());
1362 wizard.enter(Step::ProviderDetail);
1363
1364 assert!(wizard.detail_row().is_none());
1365 assert_eq!(wizard.row_count(), 0);
1366 }
1367
1368 #[tokio::test]
1371 async fn verification_is_requested_for_a_provider_with_a_credential() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let mut wizard = test_wizard(dir.path());
1374 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1375 wizard.providers[0].selected = true;
1376 wizard.providers[0].value = "sk-ant-x".to_string();
1377
1378 wizard.request_verification(0);
1379
1380 assert!(wizard.providers[0].checking);
1381 let request = requests.try_recv().expect("a request was queued");
1382 assert_eq!(request.provider_id, "anthropic");
1383 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
1384 }
1385
1386 #[tokio::test]
1387 async fn a_blank_api_key_is_not_queued_for_checking() {
1388 let dir = tempfile::tempdir().unwrap();
1390 let mut wizard = test_wizard(dir.path());
1391 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1392 wizard.providers[0].selected = true;
1393
1394 wizard.request_verification(0);
1395
1396 assert!(!wizard.providers[0].checking);
1397 assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
1398 assert!(requests.try_recv().is_err());
1399 }
1400
1401 #[tokio::test]
1402 async fn an_environment_supplied_key_is_what_gets_checked() {
1403 let dir = tempfile::tempdir().unwrap();
1404 let mut wizard = Wizard::new(
1405 Config::default(),
1406 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1407 Vec::new(),
1408 Vec::new(),
1409 dir.path(),
1410 std::sync::Arc::new(|_| true),
1411 );
1412 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1413
1414 wizard.request_verification(0);
1415
1416 let request = requests.try_recv().expect("a request was queued");
1417 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
1418 }
1419
1420 #[tokio::test]
1421 async fn ollama_is_checked_by_url_with_no_key() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let mut wizard = test_wizard(dir.path());
1424 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1425 let index = wizard
1426 .providers
1427 .iter()
1428 .position(|r| r.provider.id == "ollama")
1429 .expect("ollama is offered");
1430
1431 wizard.request_verification(index);
1432 let request = requests.try_recv().expect("a request was queued");
1433 assert!(request.creds.api_key.is_none());
1434 assert_eq!(
1435 request.creds.base_url.as_deref(),
1436 Some(catalog::DEFAULT_OLLAMA_URL),
1437 "an empty field means the default endpoint"
1438 );
1439
1440 wizard.providers[index].value = "http://box:11434".to_string();
1441 wizard.request_verification(index);
1442 let request = requests.try_recv().expect("a second request was queued");
1443 assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
1444 }
1445
1446 #[tokio::test]
1447 async fn verify_all_covers_every_selected_provider() {
1448 let dir = tempfile::tempdir().unwrap();
1449 let mut wizard = test_wizard(dir.path());
1450 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1451 wizard.providers[0].selected = true;
1452 wizard.providers[0].value = "sk-ant".to_string();
1453 let ollama = wizard
1454 .providers
1455 .iter()
1456 .position(|r| r.provider.id == "ollama")
1457 .expect("ollama is offered");
1458 wizard.providers[ollama].selected = true;
1459
1460 wizard.verify_all();
1461
1462 let mut seen = Vec::new();
1463 while let Ok(request) = requests.try_recv() {
1464 seen.push(request.provider_id);
1465 }
1466 assert_eq!(seen, vec!["anthropic", "ollama"]);
1467 }
1468
1469 #[tokio::test]
1470 async fn an_out_of_range_verification_request_is_a_no_op() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let mut wizard = test_wizard(dir.path());
1473 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1474
1475 wizard.request_verification(999);
1476
1477 assert!(requests.try_recv().is_err());
1478 }
1479
1480 #[tokio::test]
1481 async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let mut wizard = test_wizard(dir.path());
1484 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1485 wizard.providers[0].selected = true;
1486 wizard.providers[0].checking = true;
1487
1488 replies
1489 .send(VerifyReply {
1490 provider_id: "anthropic".to_string(),
1491 outcome: Outcome::Reachable {
1492 models: vec!["claude-opus-5".to_string()],
1493 },
1494 })
1495 .unwrap();
1496 replies
1498 .send(VerifyReply {
1499 provider_id: "not-a-provider".to_string(),
1500 outcome: Outcome::Skipped,
1501 })
1502 .unwrap();
1503 wizard.drain_verifications();
1504
1505 assert!(!wizard.providers[0].checking);
1506 assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
1507 }
1508
1509 #[tokio::test]
1510 async fn a_late_reply_refills_the_model_picker() {
1511 let dir = tempfile::tempdir().unwrap();
1516 let mut wizard = test_wizard(dir.path());
1517 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1518 wizard.providers[0].selected = true;
1519 wizard.enter(Step::Defaults);
1520 assert_eq!(
1521 wizard.defaults[1].value.options(),
1522 ["(provider default)".to_string()],
1523 "nothing has been reported yet"
1524 );
1525
1526 replies
1527 .send(VerifyReply {
1528 provider_id: "anthropic".to_string(),
1529 outcome: Outcome::Reachable {
1530 models: vec!["claude-opus-5".to_string()],
1531 },
1532 })
1533 .unwrap();
1534 wizard.drain_verifications();
1535
1536 assert!(
1537 wizard.defaults[1]
1538 .value
1539 .options()
1540 .contains(&"claude-opus-5".to_string()),
1541 "the picker should have refilled"
1542 );
1543 }
1544
1545 #[tokio::test]
1546 async fn a_late_reply_does_not_disturb_another_screen() {
1547 let dir = tempfile::tempdir().unwrap();
1548 let mut wizard = test_wizard(dir.path());
1549 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1550 wizard.providers[0].selected = true;
1551 wizard.enter(Step::Limits);
1552 wizard.limits[0].value = FieldValue::Number(Some(3));
1553
1554 replies
1555 .send(VerifyReply {
1556 provider_id: "anthropic".to_string(),
1557 outcome: Outcome::Reachable {
1558 models: vec!["m".to_string()],
1559 },
1560 })
1561 .unwrap();
1562 wizard.drain_verifications();
1563
1564 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1565 }
1566
1567 #[test]
1568 fn the_verification_channel_ends_can_only_be_taken_once() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let mut wizard = test_wizard(dir.path());
1571
1572 assert!(wizard.take_verify_ends().is_some());
1573 assert!(wizard.take_verify_ends().is_none());
1574 }
1575
1576 #[test]
1577 fn models_from_unselected_providers_are_not_offered() {
1578 let dir = tempfile::tempdir().unwrap();
1579 let mut wizard = test_wizard(dir.path());
1580 wizard.providers[0].outcome = Outcome::Reachable {
1581 models: vec!["hidden".to_string()],
1582 };
1583
1584 assert!(wizard.discovered_models().is_empty());
1585 }
1586
1587 #[test]
1590 fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
1591 let dir = tempfile::tempdir().unwrap();
1594 let mut wizard = test_wizard(dir.path());
1595 let ollama = wizard
1596 .providers
1597 .iter()
1598 .position(|r| r.provider.id == "ollama")
1599 .expect("ollama is offered");
1600 wizard.providers[ollama].selected = true;
1601 wizard.enter(Step::Defaults);
1602
1603 assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
1604 }
1605
1606 #[test]
1607 fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let mut wizard = test_wizard(dir.path());
1610 wizard.enter(Step::Defaults);
1611
1612 assert_eq!(
1613 wizard.defaults[0].value.display(),
1614 Config::default().default_provider
1615 );
1616 }
1617
1618 #[test]
1619 fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
1620 let dir = tempfile::tempdir().unwrap();
1621 let base = Config {
1622 default_model: Some("hand-typed".to_string()),
1623 ..Config::default()
1624 };
1625 let mut wizard = Wizard::new(
1626 base,
1627 &|_| None,
1628 Vec::new(),
1629 Vec::new(),
1630 dir.path(),
1631 std::sync::Arc::new(|_| true),
1632 );
1633 wizard.providers[0].selected = true;
1634 wizard.providers[0].outcome = Outcome::Reachable {
1635 models: vec!["claude-opus-5".to_string()],
1636 };
1637
1638 wizard.enter(Step::Defaults);
1639
1640 let options = wizard.defaults[1].value.options();
1641 assert!(options.contains(&"(provider default)".to_string()));
1642 assert!(options.contains(&"claude-opus-5".to_string()));
1643 assert_eq!(
1644 wizard.defaults[1].value.display(),
1645 "hand-typed",
1646 "a model already in the config must survive"
1647 );
1648 }
1649
1650 #[test]
1651 fn only_a_choice_field_has_options() {
1652 assert_eq!(
1653 FieldValue::Choice {
1654 options: vec!["a".into()],
1655 index: 0
1656 }
1657 .options(),
1658 ["a".to_string()]
1659 );
1660 assert!(FieldValue::Number(Some(1)).options().is_empty());
1661 assert!(FieldValue::Bool(true).options().is_empty());
1662 }
1663
1664 #[test]
1665 fn field_values_read_naturally() {
1666 assert_eq!(FieldValue::Number(None).display(), "(unset)");
1667 assert_eq!(FieldValue::Number(Some(7)).display(), "7");
1668 assert_eq!(FieldValue::Bool(true).display(), "yes");
1669 assert_eq!(FieldValue::Bool(false).display(), "no");
1670 assert_eq!(
1671 FieldValue::Choice {
1672 options: vec!["a".into()],
1673 index: 0
1674 }
1675 .display(),
1676 "a"
1677 );
1678 assert_eq!(
1679 FieldValue::Choice {
1680 options: vec![],
1681 index: 0
1682 }
1683 .display(),
1684 "(none)"
1685 );
1686 }
1687
1688 #[test]
1689 fn picking_ollama_drops_the_concurrency_default_to_one() {
1690 let dir = tempfile::tempdir().unwrap();
1693 let mut wizard = test_wizard(dir.path());
1694 let ollama = wizard
1695 .providers
1696 .iter()
1697 .position(|r| r.provider.id == "ollama")
1698 .expect("ollama is offered");
1699 wizard.providers[ollama].selected = true;
1700 wizard.enter(Step::Defaults);
1701
1702 wizard.apply_provider_concurrency_default();
1703
1704 assert_eq!(
1705 wizard.limits[0].value,
1706 FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
1707 );
1708 }
1709
1710 #[test]
1711 fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
1712 let dir = tempfile::tempdir().unwrap();
1717 let mut wizard = test_wizard(dir.path());
1718 let ollama = wizard
1719 .providers
1720 .iter()
1721 .position(|r| r.provider.id == "ollama")
1722 .expect("ollama is offered");
1723 wizard.providers[ollama].selected = true;
1724
1725 wizard.enter(Step::Defaults);
1726
1727 assert_eq!(wizard.defaults[0].value.display(), "ollama");
1728 assert_eq!(
1729 wizard.build_config().limits.max_concurrent_inferences,
1730 Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
1731 );
1732 }
1733
1734 #[test]
1735 fn switching_back_off_ollama_restores_the_general_default() {
1736 let dir = tempfile::tempdir().unwrap();
1737 let mut wizard = test_wizard(dir.path());
1738 let ollama = wizard
1739 .providers
1740 .iter()
1741 .position(|r| r.provider.id == "ollama")
1742 .expect("ollama is offered");
1743 wizard.providers[ollama].selected = true;
1744 wizard.enter(Step::Defaults);
1745 wizard.apply_provider_concurrency_default();
1746
1747 wizard.providers[ollama].selected = false;
1748 wizard.providers[0].selected = true;
1749 wizard.rebuild_defaults();
1750 wizard.apply_provider_concurrency_default();
1751
1752 assert_eq!(
1753 wizard.limits[0].value,
1754 FieldValue::Number(
1755 Config::default()
1756 .limits
1757 .max_concurrent_inferences
1758 .map(|n| n as u64)
1759 )
1760 );
1761 }
1762
1763 #[test]
1764 fn a_hand_typed_concurrency_is_never_overwritten() {
1765 let dir = tempfile::tempdir().unwrap();
1766 let mut wizard = test_wizard(dir.path());
1767 let ollama = wizard
1768 .providers
1769 .iter()
1770 .position(|r| r.provider.id == "ollama")
1771 .expect("ollama is offered");
1772 wizard.providers[ollama].selected = true;
1773 wizard.enter(Step::Defaults);
1774 wizard.limits[0].value = FieldValue::Number(Some(3));
1775
1776 wizard.apply_provider_concurrency_default();
1777
1778 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1779 }
1780
1781 #[test]
1784 fn committing_a_credential_clears_its_stale_verification() {
1785 let dir = tempfile::tempdir().unwrap();
1786 let mut wizard = test_wizard(dir.path());
1787 wizard.providers[0].outcome = Outcome::Reachable {
1788 models: vec!["m".into()],
1789 };
1790 wizard.edit = Some(Edit {
1791 target: EditTarget::Credential(0),
1792 buffer: " sk-ant-new ".to_string(),
1793 masked: true,
1794 });
1795
1796 wizard.commit_edit();
1797
1798 assert_eq!(wizard.providers[0].value, "sk-ant-new");
1799 assert_eq!(
1800 wizard.providers[0].outcome,
1801 Outcome::Skipped,
1802 "the old result was for a different key"
1803 );
1804 assert!(wizard.edit.is_none());
1805 }
1806
1807 #[test]
1808 fn typing_a_credential_supersedes_the_environments() {
1809 let dir = tempfile::tempdir().unwrap();
1810 let mut wizard = Wizard::new(
1811 Config::default(),
1812 &|_| Some("sk-ant-env".to_string()),
1813 Vec::new(),
1814 Vec::new(),
1815 dir.path(),
1816 std::sync::Arc::new(|_| true),
1817 );
1818 assert!(wizard.providers[0].from_env.is_some());
1819 wizard.edit = Some(Edit {
1820 target: EditTarget::Credential(0),
1821 buffer: "sk-ant-typed".to_string(),
1822 masked: true,
1823 });
1824
1825 wizard.commit_edit();
1826
1827 assert!(wizard.providers[0].from_env.is_none());
1828 assert_eq!(
1829 wizard.build_config().providers.anthropic_api_key.as_deref(),
1830 Some("sk-ant-typed")
1831 );
1832 }
1833
1834 #[test]
1835 fn committing_numbers_handles_blank_and_unparseable_input() {
1836 let dir = tempfile::tempdir().unwrap();
1837 let mut wizard = test_wizard(dir.path());
1838 wizard.enter(Step::Limits);
1839
1840 wizard.edit = Some(Edit {
1841 target: EditTarget::Field(0),
1842 buffer: "16".to_string(),
1843 masked: false,
1844 });
1845 wizard.commit_edit();
1846 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
1847
1848 wizard.edit = Some(Edit {
1850 target: EditTarget::Field(0),
1851 buffer: "not a number".to_string(),
1852 masked: false,
1853 });
1854 wizard.commit_edit();
1855 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
1856
1857 wizard.edit = Some(Edit {
1859 target: EditTarget::Field(0),
1860 buffer: " ".to_string(),
1861 masked: false,
1862 });
1863 wizard.commit_edit();
1864 assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
1865 }
1866
1867 #[test]
1868 fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
1869 let dir = tempfile::tempdir().unwrap();
1870 let mut wizard = test_wizard(dir.path());
1871 wizard.commit_edit();
1872
1873 wizard.edit = Some(Edit {
1874 target: EditTarget::Credential(999),
1875 buffer: "x".to_string(),
1876 masked: false,
1877 });
1878 wizard.commit_edit();
1879
1880 wizard.enter(Step::Limits);
1881 wizard.edit = Some(Edit {
1882 target: EditTarget::Field(999),
1883 buffer: "x".to_string(),
1884 masked: false,
1885 });
1886 wizard.commit_edit();
1887
1888 wizard.enter(Step::Welcome);
1890 wizard.edit = Some(Edit {
1891 target: EditTarget::Field(0),
1892 buffer: "x".to_string(),
1893 masked: false,
1894 });
1895 wizard.commit_edit();
1896
1897 assert!(wizard.edit.is_none());
1898 }
1899
1900 #[test]
1901 fn every_step_reports_a_sensible_row_count() {
1902 let dir = tempfile::tempdir().unwrap();
1903 let mut wizard = Wizard::new(
1904 Config::default(),
1905 &|_| None,
1906 vec![("A".to_string(), candidate("fs"))],
1907 Vec::new(),
1908 dir.path(),
1909 std::sync::Arc::new(|_| true),
1910 );
1911 wizard.providers[0].selected = true;
1912
1913 for step in Step::ALL {
1914 wizard.enter(step);
1915 let rows = wizard.row_count();
1916 match step {
1917 Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
1918 _ => assert!(rows > 0, "{step:?} has no rows"),
1919 }
1920 let fields = wizard.fields().len();
1922 match step {
1923 Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
1924 _ => assert_eq!(fields, 0, "{step:?} should have no fields"),
1925 }
1926 }
1927 }
1928
1929 #[test]
1930 fn committing_onto_a_defaults_field_reaches_that_form_too() {
1931 let dir = tempfile::tempdir().unwrap();
1932 let mut wizard = test_wizard(dir.path());
1933 wizard.providers[0].selected = true;
1934 wizard.enter(Step::Defaults);
1935
1936 wizard.edit = Some(Edit {
1937 target: EditTarget::Field(2),
1938 buffer: "45".to_string(),
1939 masked: false,
1940 });
1941 wizard.commit_edit();
1942
1943 assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
1944 assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
1945 }
1946
1947 #[test]
1948 fn clearing_a_credential_leaves_the_environment_marker_alone() {
1949 let dir = tempfile::tempdir().unwrap();
1951 let mut wizard = Wizard::new(
1952 Config::default(),
1953 &|_| Some("sk-ant-env".to_string()),
1954 Vec::new(),
1955 Vec::new(),
1956 dir.path(),
1957 std::sync::Arc::new(|_| true),
1958 );
1959 wizard.edit = Some(Edit {
1960 target: EditTarget::Credential(0),
1961 buffer: " ".to_string(),
1962 masked: true,
1963 });
1964
1965 wizard.commit_edit();
1966
1967 assert!(wizard.providers[0].value.is_empty());
1968 assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
1969 }
1970
1971 #[test]
1972 fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
1973 let dir = tempfile::tempdir().unwrap();
1975 let mut wizard = test_wizard(dir.path());
1976 wizard.defaults[0].value = FieldValue::Bool(true);
1977
1978 assert_eq!(
1979 wizard.build_config().default_provider,
1980 Config::default().default_provider
1981 );
1982 }
1983
1984 #[test]
1985 fn an_empty_choice_list_falls_back_to_the_base_config() {
1986 let dir = tempfile::tempdir().unwrap();
1987 let mut wizard = test_wizard(dir.path());
1988 wizard.defaults[0].value = FieldValue::Choice {
1989 options: Vec::new(),
1990 index: 0,
1991 };
1992
1993 assert_eq!(
1994 wizard.build_config().default_provider,
1995 Config::default().default_provider
1996 );
1997 }
1998
1999 #[test]
2000 fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let mut wizard = test_wizard(dir.path());
2003 wizard.limits[0].value = FieldValue::Bool(true);
2004
2005 wizard.apply_provider_concurrency_default();
2006
2007 assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
2008 }
2009
2010 #[test]
2011 fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
2012 let dir = tempfile::tempdir().unwrap();
2013 let mut wizard = test_wizard(dir.path());
2014 wizard.enter(Step::Limits);
2015 let before = wizard.limits[3].value.clone();
2016
2017 wizard.edit = Some(Edit {
2018 target: EditTarget::Field(3),
2019 buffer: "yes".to_string(),
2020 masked: false,
2021 });
2022 wizard.commit_edit();
2023
2024 assert_eq!(wizard.limits[3].value, before);
2025 }
2026
2027 #[test]
2030 fn deselecting_a_provider_clears_its_credential() {
2031 let dir = tempfile::tempdir().unwrap();
2032 let base = Config {
2033 providers: crate::config::ProviderConfig {
2034 anthropic_api_key: Some("sk-ant-stored".to_string()),
2035 ..Config::default().providers
2036 },
2037 ..Config::default()
2038 };
2039 let mut wizard = Wizard::new(
2040 base,
2041 &|_| None,
2042 Vec::new(),
2043 Vec::new(),
2044 dir.path(),
2045 std::sync::Arc::new(|_| true),
2046 );
2047
2048 wizard.providers[0].selected = false;
2049
2050 assert!(wizard.build_config().providers.anthropic_api_key.is_none());
2051 }
2052
2053 #[test]
2054 fn ollamas_default_url_is_left_unset_rather_than_pinned() {
2055 let dir = tempfile::tempdir().unwrap();
2057 let mut wizard = test_wizard(dir.path());
2058 let ollama = wizard
2059 .providers
2060 .iter()
2061 .position(|r| r.provider.id == "ollama")
2062 .expect("ollama is offered");
2063 wizard.providers[ollama].selected = true;
2064 wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
2065
2066 assert!(wizard.build_config().ollama_base_url.is_none());
2067
2068 wizard.providers[ollama].value = "http://box:11434".to_string();
2069 assert_eq!(
2070 wizard.build_config().ollama_base_url.as_deref(),
2071 Some("http://box:11434")
2072 );
2073 }
2074
2075 #[test]
2076 fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
2077 let dir = tempfile::tempdir().unwrap();
2078 let mut wizard = test_wizard(dir.path());
2079 let index = wizard
2080 .providers
2081 .iter()
2082 .position(|r| r.provider.id == "claude-code")
2083 .expect("the transport is offered");
2084
2085 assert!(!wizard.build_config().providers.claude_code_enabled);
2086
2087 wizard.providers[index].selected = true;
2088 wizard.providers[index].effort = effort_options().len() - 1;
2089 let config = wizard.build_config();
2090 assert!(config.providers.claude_code_enabled);
2091 assert_eq!(
2092 config.providers.claude_code_effort.as_deref(),
2093 Some(*effort_options().last().expect("levels exist"))
2094 );
2095 }
2096
2097 #[test]
2098 fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
2099 let dir = tempfile::tempdir().unwrap();
2100 let mut wizard = test_wizard(dir.path());
2101 let index = wizard
2102 .providers
2103 .iter()
2104 .position(|r| r.provider.id == "claude-code")
2105 .expect("the transport is offered");
2106 wizard.providers[index].selected = true;
2107 wizard.providers[index].effort = 99;
2108
2109 let config = wizard.build_config();
2110
2111 assert_eq!(
2112 config.providers.claude_code_effort.as_deref(),
2113 Some(*effort_options().last().expect("levels exist"))
2114 );
2115 }
2116
2117 #[test]
2118 fn the_stored_effort_selects_the_matching_option() {
2119 let dir = tempfile::tempdir().unwrap();
2120 let base = Config {
2121 providers: crate::config::ProviderConfig {
2122 claude_code_effort: Some("max".to_string()),
2123 ..Config::default().providers
2124 },
2125 ..Config::default()
2126 };
2127 let wizard = Wizard::new(
2128 base,
2129 &|_| None,
2130 Vec::new(),
2131 Vec::new(),
2132 dir.path(),
2133 std::sync::Arc::new(|_| true),
2134 );
2135
2136 let index = wizard
2137 .providers
2138 .iter()
2139 .position(|r| r.provider.id == "claude-code")
2140 .expect("the transport is offered");
2141 assert_eq!(effort_options()[wizard.providers[index].effort], "max");
2142 }
2143
2144 #[test]
2145 fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
2146 assert_eq!(effort_index(Some("not-a-level")), 0);
2147 assert_eq!(
2148 effort_options()[effort_index(None)],
2149 leviath_providers::claude_code::DEFAULT_EFFORT
2150 );
2151 }
2152
2153 #[test]
2154 fn the_provider_default_model_is_stored_as_unset() {
2155 let dir = tempfile::tempdir().unwrap();
2156 let mut wizard = test_wizard(dir.path());
2157 wizard.providers[0].selected = true;
2158 wizard.enter(Step::Defaults);
2159
2160 assert!(wizard.build_config().default_model.is_none());
2162 }
2163
2164 #[test]
2165 fn limits_are_written_back_including_the_zero_guard() {
2166 let dir = tempfile::tempdir().unwrap();
2169 let mut wizard = test_wizard(dir.path());
2170 wizard.enter(Step::Limits);
2171 wizard.limits[0].value = FieldValue::Number(Some(2));
2172 wizard.limits[1].value = FieldValue::Number(Some(0));
2173 wizard.limits[2].value = FieldValue::Number(None);
2174 wizard.limits[3].value = FieldValue::Bool(true);
2175 wizard.limits[4].value = FieldValue::Bool(false);
2176
2177 let config = wizard.build_config();
2178
2179 assert_eq!(config.limits.max_concurrent_inferences, Some(2));
2180 assert_eq!(
2181 config.limits.max_concurrent_tools,
2182 Config::default().limits.max_concurrent_tools
2183 );
2184 assert!(config.limits.default_max_iterations.is_none());
2185 assert!(config.limits.exact_token_counting);
2186 assert!(!config.batch_tool_hint);
2187 }
2188
2189 #[test]
2190 fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
2191 let mut config = Config::default();
2194 apply_limits_fields(
2195 &mut config,
2196 &[Field {
2197 label: "Max concurrent inferences",
2198 help: "",
2199 value: FieldValue::Bool(true),
2200 }],
2201 );
2202
2203 assert_eq!(
2204 config.limits.max_concurrent_inferences,
2205 Config::default().limits.max_concurrent_inferences
2206 );
2207 }
2208
2209 #[test]
2210 fn the_plan_carries_only_the_selected_agents() {
2211 let dir = tempfile::tempdir().unwrap();
2212 let mut wizard = test_wizard(dir.path());
2213 for row in wizard.agents.iter_mut().skip(1) {
2214 row.selected = false;
2215 }
2216
2217 let plan = wizard.build_plan();
2218
2219 assert_eq!(plan.agents.len(), 1);
2220 assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
2221 }
2222
2223 #[test]
2224 fn the_review_says_so_when_nothing_would_change() {
2225 let dir = tempfile::tempdir().unwrap();
2226 let mut wizard = test_wizard(dir.path());
2227 for row in wizard.agents.iter_mut() {
2228 row.selected = false;
2229 }
2230
2231 assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
2232 }
2233
2234 #[test]
2235 fn the_review_lists_real_changes() {
2236 let dir = tempfile::tempdir().unwrap();
2237 let mut wizard = test_wizard(dir.path());
2238 wizard.providers[0].selected = true;
2239 wizard.providers[0].value = "sk-ant-x".to_string();
2240
2241 let lines = wizard.review_lines();
2242
2243 assert!(lines.iter().any(|l| l.contains("credential set")));
2244 assert!(lines.iter().any(|l| l.contains("to install")));
2245 }
2246
2247 #[test]
2250 fn scans_flatten_into_candidates_and_labelled_errors() {
2251 let scans = vec![
2252 import::Scan {
2253 source: import::Source {
2254 id: "a",
2255 display: "Harness A",
2256 path: std::path::PathBuf::from("/a"),
2257 layout: import::Layout::ClaudeCode,
2258 allows_comments: false,
2259 },
2260 result: Ok(vec![candidate("fs")]),
2261 },
2262 import::Scan {
2263 source: import::Source {
2264 id: "b",
2265 display: "Harness B",
2266 path: std::path::PathBuf::from("/b"),
2267 layout: import::Layout::CodexToml,
2268 allows_comments: false,
2269 },
2270 result: Err("unreadable".to_string()),
2271 },
2272 ];
2273
2274 let (candidates, errors) = candidates_from_scans(scans);
2275
2276 assert_eq!(candidates.len(), 1);
2277 assert_eq!(candidates[0].0, "Harness A");
2278 assert_eq!(errors, vec!["Harness B: unreadable"]);
2279 }
2280}