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