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 pub scroll: usize,
94 pub show_advanced: bool,
101 pub help_scroll: std::cell::Cell<usize>,
104 pub picker: Option<Picker>,
106}
107
108fn env_credentials(lookup: &dyn Fn(&str) -> Option<String>) -> HashMap<&'static str, String> {
111 catalog::providers()
112 .iter()
113 .filter_map(|p| {
114 let var = p.env_var?;
115 let value = lookup(var)?;
116 (!value.is_empty()).then_some((var, value))
117 })
118 .collect()
119}
120
121impl Wizard {
122 pub fn new(
131 base: Config,
132 env_lookup: &dyn Fn(&str) -> Option<String>,
133 candidates: Vec<(String, Candidate)>,
134 scan_errors: Vec<String>,
135 agents_dir: &std::path::Path,
136 opener: leviath_mcp::BrowserOpener,
137 ) -> Self {
138 let env_only = env_credentials(env_lookup);
139
140 let providers = catalog::providers()
141 .into_iter()
142 .map(|provider| {
143 let stored = catalog::stored_credential(&base, provider.id);
144 let from_env = provider
145 .env_var
146 .filter(|v| stored.is_none() && env_only.contains_key(v));
147 ProviderRow {
148 selected: catalog::is_configured(&base, provider.id) || from_env.is_some(),
149 value: stored.unwrap_or_default(),
150 from_env,
151 effort: effort_index(base.providers.claude_code_effort.as_deref()),
152 outcome: Outcome::Skipped,
153 checking: false,
154 provider,
155 }
156 })
157 .collect();
158
159 let agents = crate::bundled::plan_agent_actions(agents_dir)
160 .into_iter()
161 .map(|(agent, action)| AgentRow {
162 selected: action.preselect(),
163 agent,
164 action,
165 })
166 .collect();
167
168 let mcp = candidates
169 .into_iter()
170 .map(|(source, candidate)| {
171 let collides =
172 import::already_configured(&base.mcp_servers, &candidate.config.name);
173 let name = import::dedup_name(&base.mcp_servers, &candidate.config.name);
174 McpRow {
175 selected: !collides,
179 source,
180 collides,
181 name,
182 candidate,
183 }
184 })
185 .collect();
186
187 let (verify_tx, verify_rx) = mpsc::unbounded_channel();
188 let (reply_tx, reply_rx) = mpsc::unbounded_channel();
189
190 let mut wizard = Self {
191 step: Step::Welcome,
192 cursor: 0,
193 providers,
194 detail: 0,
195 defaults: Vec::new(),
196 limits: limits_fields(&base),
197 agents,
198 mcp,
199 mcp_scan_errors: scan_errors,
200 edit: None,
201 reveal: false,
202 show_help: false,
203 confirm: None,
204 dirty: false,
205 claude_code_tos_accepted: false,
206 should_quit: false,
207 finished: false,
208 message: None,
209 base,
210 env_only,
211 opener,
212 verify_tx,
213 verify_rx: Some(verify_rx),
214 reply_tx,
215 reply_rx,
216 ticks: 0,
217 scroll: 0,
218 show_advanced: false,
219 help_scroll: std::cell::Cell::new(0),
220 picker: None,
221 };
222 wizard.rebuild_defaults();
223 wizard
224 }
225
226 pub fn take_verify_ends(
229 &mut self,
230 ) -> Option<(
231 mpsc::UnboundedReceiver<VerifyRequest>,
232 mpsc::UnboundedSender<VerifyReply>,
233 )> {
234 self.verify_rx.take().map(|rx| (rx, self.reply_tx.clone()))
235 }
236
237 pub fn selected_providers(&self) -> Vec<usize> {
241 self.providers
242 .iter()
243 .enumerate()
244 .filter(|(_, r)| r.selected)
245 .map(|(i, _)| i)
246 .collect()
247 }
248
249 pub fn detail_row(&self) -> Option<usize> {
251 self.selected_providers().get(self.detail).copied()
252 }
253
254 pub fn claude_code_selected(&self) -> bool {
256 self.providers
257 .iter()
258 .any(|r| r.selected && r.provider.id == "claude-code")
259 }
260
261 pub fn needs_tos_confirmation(&self) -> bool {
266 self.claude_code_selected() && !self.claude_code_tos_accepted
267 }
268
269 pub fn fields(&self) -> &[Field] {
271 match self.step {
272 Step::Defaults => &self.defaults,
273 Step::Limits => &self.limits,
274 _ => &[],
275 }
276 }
277
278 pub(super) fn fields_mut(&mut self) -> Option<&mut Vec<Field>> {
279 match self.step {
280 Step::Defaults => Some(&mut self.defaults),
281 Step::Limits => Some(&mut self.limits),
282 _ => None,
283 }
284 }
285
286 pub fn detail_actions(&self) -> Vec<DetailAction> {
292 let Some(index) = self.detail_row() else {
293 return Vec::new();
294 };
295 let mut actions = Vec::new();
296 if self.providers[index].provider.signup_url.is_some() {
297 actions.push(DetailAction::OpenSignup);
298 }
299 actions.push(DetailAction::Verify);
300 actions
301 }
302
303 pub fn row_count(&self) -> usize {
305 match self.step {
306 Step::Welcome | Step::Review => 0,
307 Step::Providers => self.providers.len(),
308 Step::ProviderDetail => match self.detail_row() {
309 Some(_) => 1 + self.detail_actions().len(),
310 None => 0,
311 },
312 Step::Defaults => self.defaults.len(),
313 Step::Limits => self.limits.len(),
314 Step::Agents => self.agents.len(),
315 Step::Mcp => self.mcp.len(),
316 }
317 }
318
319 pub fn nav_rows(&self) -> usize {
322 self.row_count() + 1
323 }
324
325 pub fn on_continue(&self) -> bool {
328 self.cursor == self.row_count()
329 }
330
331 pub fn continue_label(&self) -> String {
335 match self.step {
336 Step::Welcome => "Get started".to_string(),
337 Step::Review => "Apply and finish".to_string(),
338 Step::Providers => {
339 let count = self.selected_providers().len();
340 if count == 0 {
341 "Continue (no providers selected)".to_string()
342 } else {
343 format!("Continue: {} ({count} selected)", self.next_step_title())
344 }
345 }
346 Step::ProviderDetail => {
347 let selected = self.selected_providers();
348 match selected.get(self.detail + 1) {
349 Some(&next) => format!("Next: {}", self.providers[next].provider.display),
350 None => format!("Continue: {}", self.next_step_title()),
351 }
352 }
353 Step::Defaults | Step::Limits | Step::Agents | Step::Mcp => {
354 format!("Continue: {}", self.next_step_title())
355 }
356 }
357 }
358
359 fn next_step_title(&self) -> &'static str {
361 let mut index = self.step.index();
362 while index + 1 < Step::ALL.len() {
363 index += 1;
364 let step = Step::ALL[index];
365 if !self.is_empty_step(step) {
366 return step.title();
367 }
368 }
369 Step::Review.title()
370 }
371
372 pub fn move_cursor(&mut self, delta: isize) {
374 let count = self.nav_rows();
375 let next = self.cursor as isize + delta;
376 self.cursor = next.clamp(0, count as isize - 1) as usize;
377 }
378
379 pub const PAGE: isize = 8;
382
383 pub fn scroll_by(&mut self, rows: isize) {
390 if self.row_count() > 0 {
391 self.move_cursor(rows);
392 return;
393 }
394 self.scroll = self.scroll.saturating_add_signed(rows);
395 }
396
397 pub fn scroll_home(&mut self) {
399 self.cursor = 0;
400 self.scroll = 0;
401 }
402
403 pub fn scroll_end(&mut self) {
409 self.cursor = self.nav_rows().saturating_sub(1);
410 self.scroll = usize::MAX;
411 }
412
413 pub fn next_step(&mut self) {
417 let mut index = self.step.index();
418 while index + 1 < Step::ALL.len() {
419 index += 1;
420 let step = Step::ALL[index];
421 if !self.is_empty_step(step) {
422 self.enter(step);
423 return;
424 }
425 if step == Step::ProviderDetail {
426 self.message =
427 Some("Skipped Credentials: no selected provider needs setup.".to_string());
428 }
429 }
430 self.enter(Step::Review);
432 }
433
434 pub fn prev_step(&mut self) {
436 let mut index = self.step.index();
437 while index > 0 {
438 index -= 1;
439 let step = Step::ALL[index];
440 if !self.is_empty_step(step) {
441 self.enter(step);
442 return;
443 }
444 }
445 }
446
447 fn is_empty_step(&self, step: Step) -> bool {
452 match step {
453 Step::Mcp => self.mcp.is_empty() && self.mcp_scan_errors.is_empty(),
454 Step::ProviderDetail => self.detail_row().is_none(),
455 Step::Limits => !self.show_advanced,
460 _ => false,
461 }
462 }
463
464 pub fn enter(&mut self, step: Step) {
466 self.step = step;
467 self.cursor = 0;
468 self.scroll = 0;
469 self.edit = None;
470 if step == Step::Defaults {
471 self.rebuild_defaults();
474 }
475 }
476
477 pub fn next_detail(&mut self) -> bool {
480 if self.detail + 1 < self.selected_providers().len() {
481 self.detail += 1;
482 self.cursor = 0;
483 self.edit = None;
484 return true;
485 }
486 false
487 }
488
489 pub fn prev_detail(&mut self) -> bool {
491 if self.detail > 0 {
492 self.detail -= 1;
493 self.cursor = 0;
494 self.edit = None;
495 return true;
496 }
497 false
498 }
499
500 pub fn request_verification(&mut self, index: usize) {
508 let Some(row) = self.providers.get_mut(index) else {
509 return;
510 };
511 if !row.has_credential() {
512 row.outcome = Outcome::Skipped;
513 return;
514 }
515 let id = row.provider.id.to_string();
516 let key = if row.value.is_empty() {
517 self.env_only
518 .get(row.provider.env_var.unwrap_or_default())
519 .cloned()
520 } else {
521 Some(row.value.clone())
522 };
523 let base_url = (row.provider.credential == Credential::BaseUrl).then(|| {
524 if row.value.is_empty() {
525 catalog::DEFAULT_OLLAMA_URL.to_string()
526 } else {
527 row.value.clone()
528 }
529 });
530 row.checking = true;
531
532 let creds = leviath_runtime::provider_creds::ProviderCreds {
533 name: id.clone(),
534 api_key: base_url.is_none().then_some(key).flatten(),
535 base_url,
536 model_capabilities: HashMap::new(),
537 request_timeout_secs: Some(20),
538 rate_limit: None,
539 options: HashMap::new(),
540 };
541 let _ = self.verify_tx.send(VerifyRequest {
544 provider_id: id,
545 creds,
546 });
547 }
548
549 pub fn verify_all(&mut self) {
551 for index in self.selected_providers() {
552 self.request_verification(index);
553 }
554 }
555
556 pub fn drain_verifications(&mut self) {
558 let mut landed = false;
559 while let Ok(reply) = self.reply_rx.try_recv() {
560 if let Some(row) = self
561 .providers
562 .iter_mut()
563 .find(|r| r.provider.id == reply.provider_id)
564 {
565 row.checking = false;
566 row.outcome = reply.outcome;
567 landed = true;
568 }
569 }
570 if landed && self.step == Step::Defaults {
576 self.rebuild_defaults();
577 }
578 }
579
580 fn precedence_explanation(provider: bool) -> Vec<&'static str> {
588 if provider {
589 vec![
590 "Where your runs go by default. A stage that lists this provider among",
591 "its models is served by it first, ahead of the blueprint's own order.",
592 "",
593 "This field does nothing on its own. Until you also set a default model",
594 "below, the only thing it can do is reorder a list that already names",
595 "this provider, so on a machine keyed for one provider that no blueprint",
596 "mentions, every stage still goes somewhere else.",
597 "",
598 "It never overrides a blueprint that pins its provider: a stage with",
599 "allow_user_default = false ignores this, and so does a provider you",
600 "have not given a credential.",
601 ]
602 } else {
603 vec![
604 "The model your default provider is asked for. The two travel together:",
605 "this name is never sent to a different provider, so an OpenAI model is",
606 "never asked of Anthropic.",
607 "",
608 "Setting it is what makes the default provider above take effect. The",
609 "pair is offered to every stage that allows a user default, and moves to",
610 "the front of the models that stage lists.",
611 "",
612 "Leaving it unset does not mean your provider picks a model. It means",
613 "each blueprint uses the models it names, and a stage that names none",
614 "falls back to a model built into Leviath, whoever you configured.",
615 ]
616 }
617 }
618
619 pub fn discovered_models(&self) -> Vec<String> {
621 let mut models: Vec<String> = self
622 .providers
623 .iter()
624 .filter(|r| r.selected)
625 .flat_map(|r| r.outcome.models().iter().cloned())
626 .collect();
627 models.sort();
628 models.dedup();
629 models
630 }
631
632 pub fn rebuild_defaults(&mut self) {
637 let chosen = self.current_default_provider();
638 let providers: Vec<String> = self
639 .selected_providers()
640 .iter()
641 .map(|i| self.providers[*i].provider.id.to_string())
642 .collect();
643 let providers = if providers.is_empty() {
646 vec![self.base.default_provider.clone()]
647 } else {
648 providers
649 };
650 let index = providers.iter().position(|p| *p == chosen).unwrap_or(0);
651
652 let mut models = vec![Self::NO_DEFAULT_MODEL.to_string()];
653 models.extend(self.discovered_models());
654 let current_model = self
655 .current_default_model()
656 .unwrap_or_else(|| Self::NO_DEFAULT_MODEL.to_string());
657 if !models.contains(¤t_model) {
658 models.push(current_model.clone());
659 }
660 let model_index = models
661 .iter()
662 .position(|m| *m == current_model)
663 .unwrap_or_default();
664
665 let timeout = self.current_request_timeout();
666 self.defaults = vec![
667 Field {
668 label: "Default provider",
669 help: "Preferred by any stage that allows a user default. Takes effect once a \
670 default model is set below.",
671 value: FieldValue::Choice {
672 options: providers,
673 index,
674 },
675 },
676 Field {
677 label: "Default model",
678 help: "Offered to every stage that allows a user default, paired with the \
679 provider above. Listed from what your providers reported.",
680 value: FieldValue::Choice {
681 options: models,
682 index: model_index,
683 },
684 },
685 Field {
686 label: "Request timeout (seconds)",
687 help: "How long to wait on one inference. Unset uses the provider default.",
688 value: FieldValue::Number(timeout),
689 },
690 Field {
691 label: "Show advanced tuning",
692 help: "Adds a screen of concurrency, retry and context limits. Every one of \
693 them already has a default that works.",
694 value: FieldValue::Bool(self.show_advanced),
695 },
696 ];
697 self.apply_provider_concurrency_default();
703 }
704
705 pub const PROVIDER_FIELD: usize = 0;
707
708 pub const ADVANCED_FIELD: usize = 3;
710
711 pub const NO_DEFAULT_MODEL: &'static str = "(each blueprint decides)";
719
720 pub(super) fn open_picker(&mut self, title: &'static str, options: Vec<String>, index: usize) {
725 let field = self.cursor;
726 let options = options
727 .into_iter()
728 .map(|value| {
729 let detail = if field == Self::PROVIDER_FIELD {
730 self.provider_detail(&value)
731 } else {
732 self.model_detail(&value)
733 };
734 PickerOption { value, detail }
735 })
736 .collect();
737 self.picker = Some(Picker {
738 field,
739 title,
740 explain: Self::precedence_explanation(field == Self::PROVIDER_FIELD),
741 query: crate::tui::widgets::line_edit::LineEdit::new(String::new(), false),
742 options,
743 cursor: index,
746 });
747 }
748
749 fn provider_detail(&self, id: &str) -> String {
751 match self.providers.iter().find(|r| r.provider.id == id) {
752 Some(row) => row.provider.display.to_string(),
753 None => "from your config".to_string(),
756 }
757 }
758
759 fn model_detail(&self, model: &str) -> String {
761 if model == Self::NO_DEFAULT_MODEL {
764 return "no default; every blueprint uses the models it names".to_string();
765 }
766 let reported: Vec<&str> = self
767 .providers
768 .iter()
769 .filter(|r| r.selected && r.outcome.models().iter().any(|m| m == model))
770 .map(|r| r.provider.display)
771 .collect();
772 if reported.is_empty() {
773 return "not reported by a provider you selected".to_string();
774 }
775 format!("reported by {}", reported.join(", "))
776 }
777
778 pub(super) fn commit_picker(&mut self, picker: Picker) {
780 let Some(chosen) = picker.selected() else {
781 return;
784 };
785 self.defaults[picker.field].value.set_index(chosen);
790 self.dirty = true;
791 if picker.field == Self::PROVIDER_FIELD {
794 self.apply_provider_concurrency_default();
795 }
796 }
797
798 fn current_default_provider(&self) -> String {
801 match self.defaults.first().map(|f| &f.value) {
802 Some(FieldValue::Choice { options, index }) => match options.get(*index) {
803 Some(chosen) => chosen.clone(),
804 None => self.base.default_provider.clone(),
805 },
806 _ => self.base.default_provider.clone(),
807 }
808 }
809
810 fn current_default_model(&self) -> Option<String> {
811 match self.defaults.get(1).map(|f| &f.value) {
812 Some(FieldValue::Choice { options, index }) => options.get(*index).cloned(),
813 _ => self.base.default_model.clone(),
814 }
815 }
816
817 fn current_request_timeout(&self) -> Option<u64> {
818 match self.defaults.get(2).map(|f| &f.value) {
819 Some(FieldValue::Number(n)) => *n,
820 _ => self.base.request_timeout_secs,
821 }
822 }
823
824 pub fn apply_provider_concurrency_default(&mut self) {
831 let ollama = self.current_default_provider() == "ollama";
832 let general = Config::default().limits.max_concurrent_inferences;
833 let local = Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64);
834 let general = general.map(|n| n as u64);
835
836 let Some(FieldValue::Number(current)) = self.limits.first_mut().map(|f| &mut f.value)
840 else {
841 return;
842 };
843 if ollama && *current == general {
844 *current = local;
845 } else if !ollama && *current == local {
846 *current = general;
847 }
848 }
849
850 pub fn open_quit_confirm(&mut self) {
854 use ratatui::text::Line;
855 self.confirm = Some(PendingConfirm {
856 purpose: ConfirmPurpose::QuitDiscard,
857 dialog: crate::tui::widgets::confirm::Confirm::new(
858 "Quit setup?",
859 vec![Line::from(
860 "Nothing has been written yet. Your choices so far will be discarded.",
861 )],
862 "Quit",
863 "Stay",
864 ),
865 });
866 }
867
868 pub fn open_tos_confirm(&mut self) {
871 use ratatui::text::Line;
872 self.confirm = Some(PendingConfirm {
873 purpose: ConfirmPurpose::SaveTos,
874 dialog: crate::tui::widgets::confirm::Confirm::new(
875 "Claude Code terms of service",
876 vec![
877 Line::from("Anthropic's terms prohibit third-party developers from offering"),
878 Line::from("claude.ai subscription auth for their products without prior"),
879 Line::from("approval. The Claude Code transport routes inference through"),
880 Line::from("your subscription via the CLI's OAuth session."),
881 Line::from(""),
882 Line::from("For unambiguous compliance, use a direct Anthropic API key."),
883 Line::from(""),
884 Line::from("Accepting means you take responsibility for compliance."),
885 ],
886 "Accept and save",
887 "Cancel",
888 )
889 .danger(),
890 });
891 }
892
893 pub fn open_no_providers_confirm(&mut self) {
896 use ratatui::text::Line;
897 self.confirm = Some(PendingConfirm {
898 purpose: ConfirmPurpose::NoProviders,
899 dialog: crate::tui::widgets::confirm::Confirm::new(
900 "No providers selected",
901 vec![
902 Line::from("Without a provider, Leviath cannot run any agent."),
903 Line::from("Select one with Space or Enter, or continue anyway to"),
904 Line::from("configure providers later."),
905 ],
906 "Continue anyway",
907 "Go back",
908 ),
909 });
910 }
911
912 pub fn commit_edit(&mut self) {
914 let Some(edit) = self.edit.take() else {
915 return;
916 };
917 match edit.target {
918 EditTarget::Credential(index) => {
919 if let Some(row) = self.providers.get_mut(index) {
920 row.value = edit.line.value().trim().to_string();
921 if !row.value.is_empty() {
924 row.from_env = None;
925 }
926 row.outcome = Outcome::Skipped;
927 }
928 }
929 EditTarget::Field(index) => {
930 let Some(fields) = self.fields_mut() else {
931 return;
932 };
933 if let Some(field) = fields.get_mut(index) {
934 match &mut field.value {
935 FieldValue::Number(n) => {
936 let trimmed = edit.line.value().trim();
937 *n = if trimmed.is_empty() {
938 None
939 } else {
940 trimmed.parse().ok().or(*n)
941 };
942 }
943 FieldValue::Bool(_) | FieldValue::Choice { .. } => {}
945 }
946 }
947 }
948 }
949 }
950
951 pub fn build_config(&self) -> Config {
955 let mut config = self.base.clone();
956
957 for row in &self.providers {
958 match row.provider.credential {
959 Credential::None => {}
960 _ if !row.selected => catalog::set_credential(&mut config, row.provider.id, None),
961 _ if row.value.is_empty() => {
965 catalog::set_credential(&mut config, row.provider.id, None)
966 }
967 Credential::BaseUrl if row.value == catalog::DEFAULT_OLLAMA_URL => {
968 catalog::set_credential(&mut config, row.provider.id, None)
971 }
972 _ => catalog::set_credential(&mut config, row.provider.id, Some(row.value.clone())),
973 }
974 }
975
976 let transport = self
979 .providers
980 .iter()
981 .find(|r| r.provider.id == "claude-code")
982 .filter(|r| r.selected);
983 config.providers.claude_code_enabled = transport.is_some();
984 if let Some(row) = transport {
985 config.providers.claude_code_effort =
986 Some(effort_options()[row.effort.min(effort_options().len() - 1)].to_string());
987 }
988
989 config.default_provider = self.current_default_provider();
990 config.default_model = self
991 .current_default_model()
992 .filter(|m| m != Self::NO_DEFAULT_MODEL);
993 config.request_timeout_secs = self.current_request_timeout();
994
995 apply_limits_fields(&mut config, &self.limits);
996
997 for row in self.mcp.iter().filter(|r| r.selected) {
998 let mut server = row.candidate.config.clone();
999 server.name = row.name.clone();
1000 config.mcp_servers.push(server);
1001 }
1002
1003 config
1004 }
1005
1006 pub fn build_plan(&self) -> SetupPlan {
1008 SetupPlan {
1009 config: self.build_config(),
1010 agents: self
1011 .agents
1012 .iter()
1013 .filter(|r| r.selected)
1014 .map(|r| r.agent)
1015 .collect(),
1016 }
1017 }
1018
1019 pub fn review_lines(&self) -> Vec<String> {
1021 let plan = self.build_plan();
1022 let changes = super::plan::changes(&self.base, &plan);
1023 if changes.is_empty() {
1024 vec!["Nothing would change.".to_string()]
1025 } else {
1026 changes
1027 }
1028 }
1029
1030 pub fn selected_inline_secrets(&self) -> Vec<String> {
1033 self.mcp
1034 .iter()
1035 .filter(|r| r.selected && !r.candidate.inline_secrets.is_empty())
1036 .map(|r| format!("{}: {}", r.name, r.candidate.inline_secrets.join(", ")))
1037 .collect()
1038 }
1039}
1040
1041pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
1044 let mut candidates = Vec::new();
1045 let mut errors = Vec::new();
1046 for scan in scans {
1047 match scan.result {
1048 Ok(found) => candidates.extend(
1049 found
1050 .into_iter()
1051 .map(|c| (scan.source.display.to_string(), c)),
1052 ),
1053 Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
1054 }
1055 }
1056 (candidates, errors)
1057}
1058
1059pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
1062 rows.iter()
1063 .filter(|r| r.selected)
1064 .map(|r| {
1065 let mut server = r.candidate.config.clone();
1066 server.name = r.name.clone();
1067 server
1068 })
1069 .collect()
1070}
1071
1072#[cfg(test)]
1073pub(super) mod tests {
1074 use super::*;
1075 use crate::bundled::BUNDLED_AGENTS;
1076
1077 pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
1080 Wizard::new(
1081 Config::default(),
1082 &|_| None,
1083 Vec::new(),
1084 Vec::new(),
1085 agents_dir,
1086 std::sync::Arc::new(|_| true),
1087 )
1088 }
1089
1090 fn candidate(name: &str) -> Candidate {
1091 Candidate {
1092 config: MCPServerConfig::stdio(name, "npx", vec![]),
1093 scope: String::new(),
1094 inline_secrets: Vec::new(),
1095 }
1096 }
1097
1098 #[test]
1101 fn every_step_is_titled_and_ordered() {
1102 for (index, step) in Step::ALL.iter().enumerate() {
1103 assert!(!step.title().is_empty(), "{step:?} has no title");
1104 assert_eq!(step.index(), index);
1105 }
1106 }
1107
1108 #[test]
1111 fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
1112 let dir = tempfile::tempdir().unwrap();
1113
1114 let wizard = test_wizard(dir.path());
1115
1116 assert_eq!(wizard.step, Step::Welcome);
1117 assert!(wizard.selected_providers().is_empty());
1118 assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
1119 assert!(
1120 wizard.agents.iter().all(|r| r.selected),
1121 "a fresh install should offer to install everything"
1122 );
1123 assert!(
1124 wizard
1125 .agents
1126 .iter()
1127 .all(|r| r.action == AgentAction::Install)
1128 );
1129 }
1130
1131 #[test]
1132 fn already_installed_agents_are_listed_but_not_reselected() {
1133 let dir = tempfile::tempdir().unwrap();
1134 for agent in BUNDLED_AGENTS {
1135 crate::bundled::install_bundled(agent, dir.path()).unwrap();
1136 }
1137
1138 let wizard = test_wizard(dir.path());
1139
1140 assert!(
1141 wizard.agents.iter().all(|r| !r.selected),
1142 "nothing needs doing, so nothing should be pre-checked"
1143 );
1144 }
1145
1146 #[test]
1147 fn a_configured_provider_starts_selected_with_its_credential() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let base = Config {
1150 providers: crate::config::ProviderConfig {
1151 anthropic_api_key: Some("sk-ant-stored".to_string()),
1152 ..Config::default().providers
1153 },
1154 ..Config::default()
1155 };
1156
1157 let wizard = Wizard::new(
1158 base,
1159 &|_| None,
1160 Vec::new(),
1161 Vec::new(),
1162 dir.path(),
1163 std::sync::Arc::new(|_| true),
1164 );
1165
1166 let row = wizard
1167 .providers
1168 .iter()
1169 .find(|r| r.provider.id == "anthropic")
1170 .expect("anthropic is in the table");
1171 assert!(row.selected);
1172 assert_eq!(row.value, "sk-ant-stored");
1173 assert!(row.from_env.is_none());
1174 }
1175
1176 #[test]
1177 fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
1178 let dir = tempfile::tempdir().unwrap();
1183
1184 let wizard = Wizard::new(
1185 Config::default(),
1186 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
1187 Vec::new(),
1188 Vec::new(),
1189 dir.path(),
1190 std::sync::Arc::new(|_| true),
1191 );
1192
1193 let row = wizard
1194 .providers
1195 .iter()
1196 .find(|r| r.provider.id == "anthropic")
1197 .expect("anthropic is in the table");
1198 assert!(row.selected, "the provider is usable, so it is selected");
1199 assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
1200 assert!(row.value.is_empty());
1201
1202 let written = wizard.build_config();
1203 assert!(
1204 written.providers.anthropic_api_key.is_none(),
1205 "an environment-supplied key must not be copied into the config"
1206 );
1207 }
1208
1209 #[test]
1210 fn a_stored_key_wins_over_the_environment() {
1211 let dir = tempfile::tempdir().unwrap();
1214 let base = Config {
1215 providers: crate::config::ProviderConfig {
1216 anthropic_api_key: Some("sk-ant-stored".to_string()),
1217 ..Config::default().providers
1218 },
1219 ..Config::default()
1220 };
1221
1222 let wizard = Wizard::new(
1223 base,
1224 &|_| Some("sk-ant-from-env".to_string()),
1225 Vec::new(),
1226 Vec::new(),
1227 dir.path(),
1228 std::sync::Arc::new(|_| true),
1229 );
1230
1231 let row = &wizard.providers[0];
1232 assert!(row.from_env.is_none());
1233 assert_eq!(row.value, "sk-ant-stored");
1234 }
1235
1236 #[test]
1237 fn an_empty_environment_variable_does_not_count_as_a_credential() {
1238 let dir = tempfile::tempdir().unwrap();
1239
1240 let wizard = Wizard::new(
1241 Config::default(),
1242 &|_| Some(String::new()),
1243 Vec::new(),
1244 Vec::new(),
1245 dir.path(),
1246 std::sync::Arc::new(|_| true),
1247 );
1248
1249 assert!(wizard.env_only.is_empty());
1250 assert!(wizard.selected_providers().is_empty());
1251 }
1252
1253 #[test]
1256 fn an_importable_server_is_preselected_and_named_as_found() {
1257 let dir = tempfile::tempdir().unwrap();
1258
1259 let wizard = Wizard::new(
1260 Config::default(),
1261 &|_| None,
1262 vec![("Claude Code".to_string(), candidate("fs"))],
1263 Vec::new(),
1264 dir.path(),
1265 std::sync::Arc::new(|_| true),
1266 );
1267
1268 assert_eq!(wizard.mcp.len(), 1);
1269 assert!(wizard.mcp[0].selected);
1270 assert!(!wizard.mcp[0].collides);
1271 assert_eq!(wizard.mcp[0].name, "fs");
1272 assert_eq!(wizard.mcp[0].source, "Claude Code");
1273 }
1274
1275 #[test]
1276 fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
1277 let dir = tempfile::tempdir().unwrap();
1280 let base = Config {
1281 mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
1282 ..Config::default()
1283 };
1284
1285 let wizard = Wizard::new(
1286 base,
1287 &|_| None,
1288 vec![("Cursor".to_string(), candidate("fs"))],
1289 Vec::new(),
1290 dir.path(),
1291 std::sync::Arc::new(|_| true),
1292 );
1293
1294 assert!(!wizard.mcp[0].selected);
1295 assert!(wizard.mcp[0].collides);
1296 assert_eq!(wizard.mcp[0].name, "fs-2");
1297
1298 let mut wizard = wizard;
1301 wizard.mcp[0].selected = true;
1302 let config = wizard.build_config();
1303 let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
1304 assert_eq!(names, vec!["fs", "fs-2"]);
1305 }
1306
1307 #[test]
1308 fn selected_servers_renames_and_filters() {
1309 let dir = tempfile::tempdir().unwrap();
1310 let mut wizard = Wizard::new(
1311 Config::default(),
1312 &|_| None,
1313 vec![
1314 ("A".to_string(), candidate("keep")),
1315 ("B".to_string(), candidate("drop")),
1316 ],
1317 Vec::new(),
1318 dir.path(),
1319 std::sync::Arc::new(|_| true),
1320 );
1321 wizard.mcp[1].selected = false;
1322 wizard.mcp[0].name = "renamed".to_string();
1323
1324 let servers = selected_servers(&wizard.mcp);
1325
1326 assert_eq!(servers.len(), 1);
1327 assert_eq!(servers[0].name, "renamed");
1328 }
1329
1330 #[test]
1331 fn inline_secrets_are_reported_only_for_selected_rows() {
1332 let dir = tempfile::tempdir().unwrap();
1333 let mut secretive = candidate("leaky");
1334 secretive.inline_secrets = vec!["API_TOKEN".to_string()];
1335
1336 let mut wizard = Wizard::new(
1337 Config::default(),
1338 &|_| None,
1339 vec![
1340 ("A".to_string(), secretive),
1341 ("B".to_string(), candidate("clean")),
1342 ],
1343 Vec::new(),
1344 dir.path(),
1345 std::sync::Arc::new(|_| true),
1346 );
1347
1348 assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
1349 wizard.mcp[0].selected = false;
1350 assert!(wizard.selected_inline_secrets().is_empty());
1351 }
1352
1353 #[test]
1356 fn the_cursor_stays_inside_the_current_step() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let mut wizard = test_wizard(dir.path());
1359 wizard.enter(Step::Providers);
1360
1361 wizard.move_cursor(-5);
1362 assert_eq!(wizard.cursor, 0);
1363 wizard.move_cursor(100);
1364 assert_eq!(
1365 wizard.cursor,
1366 wizard.providers.len(),
1367 "clamped to the Continue button after the last row"
1368 );
1369 assert!(wizard.on_continue());
1370 }
1371
1372 #[test]
1373 fn a_step_with_no_rows_pins_the_cursor_at_zero() {
1374 let dir = tempfile::tempdir().unwrap();
1375 let mut wizard = test_wizard(dir.path());
1376 wizard.enter(Step::Welcome);
1377 wizard.cursor = 4;
1378
1379 wizard.move_cursor(1);
1380
1381 assert_eq!(wizard.cursor, 0);
1382 assert_eq!(wizard.row_count(), 0);
1383 }
1384
1385 #[test]
1386 fn the_continue_label_names_where_it_goes() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let mut wizard = test_wizard(dir.path());
1389
1390 wizard.enter(Step::Welcome);
1391 assert_eq!(wizard.continue_label(), "Get started");
1392
1393 wizard.enter(Step::Providers);
1394 assert_eq!(wizard.continue_label(), "Continue (no providers selected)");
1395 wizard.providers[0].selected = true;
1396 wizard.providers[1].selected = true;
1397 assert_eq!(
1398 wizard.continue_label(),
1399 "Continue: Credentials (2 selected)"
1400 );
1401
1402 wizard.enter(Step::ProviderDetail);
1404 assert_eq!(
1405 wizard.continue_label(),
1406 format!("Next: {}", wizard.providers[1].provider.display)
1407 );
1408 wizard.detail = 1;
1409 assert_eq!(wizard.continue_label(), "Continue: Defaults");
1410
1411 wizard.enter(Step::Limits);
1412 assert_eq!(wizard.continue_label(), "Continue: Agents");
1413
1414 wizard.enter(Step::Review);
1415 assert_eq!(wizard.continue_label(), "Apply and finish");
1416 }
1417
1418 #[test]
1419 fn next_step_title_past_the_last_step_falls_back_to_review() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let mut wizard = test_wizard(dir.path());
1422 wizard.enter(Step::Review);
1423 assert_eq!(wizard.next_step_title(), "Review");
1424 }
1425
1426 #[test]
1427 fn empty_discovery_steps_are_skipped_in_both_directions() {
1428 let dir = tempfile::tempdir().unwrap();
1432 let mut wizard = test_wizard(dir.path());
1433 assert!(wizard.mcp.is_empty());
1434
1435 wizard.enter(Step::Providers);
1436 wizard.next_step();
1437 assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
1438
1439 wizard.enter(Step::Agents);
1440 wizard.next_step();
1441 assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
1442
1443 wizard.prev_step();
1444 assert_eq!(wizard.step, Step::Agents);
1445 }
1446
1447 #[test]
1448 fn a_nonempty_discovery_step_is_visited() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let mut wizard = Wizard::new(
1451 Config::default(),
1452 &|_| None,
1453 vec![("A".to_string(), candidate("fs"))],
1454 Vec::new(),
1455 dir.path(),
1456 std::sync::Arc::new(|_| true),
1457 );
1458
1459 wizard.enter(Step::Agents);
1460 wizard.next_step();
1461
1462 assert_eq!(wizard.step, Step::Mcp);
1463 }
1464
1465 #[test]
1466 fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
1467 let dir = tempfile::tempdir().unwrap();
1470 let mut wizard = Wizard::new(
1471 Config::default(),
1472 &|_| None,
1473 Vec::new(),
1474 vec!["Zed: unreadable".to_string()],
1475 dir.path(),
1476 std::sync::Arc::new(|_| true),
1477 );
1478
1479 wizard.enter(Step::Agents);
1480 wizard.next_step();
1481
1482 assert_eq!(wizard.step, Step::Mcp);
1483 }
1484
1485 #[test]
1486 fn the_first_step_has_nowhere_to_go_back_to() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let mut wizard = test_wizard(dir.path());
1489
1490 wizard.prev_step();
1491
1492 assert_eq!(wizard.step, Step::Welcome);
1493 }
1494
1495 #[test]
1496 fn advancing_past_the_last_step_stays_on_review() {
1497 let dir = tempfile::tempdir().unwrap();
1498 let mut wizard = test_wizard(dir.path());
1499 wizard.enter(Step::Review);
1500
1501 wizard.next_step();
1502
1503 assert_eq!(wizard.step, Step::Review);
1504 }
1505
1506 #[test]
1507 fn the_credential_screen_walks_the_selected_providers() {
1508 let dir = tempfile::tempdir().unwrap();
1509 let mut wizard = test_wizard(dir.path());
1510 wizard.providers[0].selected = true;
1511 wizard.providers[1].selected = true;
1512
1513 assert_eq!(wizard.detail_row(), Some(0));
1514 assert!(wizard.next_detail());
1515 assert_eq!(wizard.detail_row(), Some(1));
1516 assert!(!wizard.next_detail(), "there is no third provider");
1517 assert!(wizard.prev_detail());
1518 assert_eq!(wizard.detail_row(), Some(0));
1519 assert!(!wizard.prev_detail());
1520 }
1521
1522 #[test]
1523 fn the_credential_screen_has_no_row_when_nothing_is_selected() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let mut wizard = test_wizard(dir.path());
1526 wizard.enter(Step::ProviderDetail);
1527
1528 assert!(wizard.detail_row().is_none());
1529 assert_eq!(wizard.row_count(), 0);
1530 }
1531
1532 #[tokio::test]
1535 async fn verification_is_requested_for_a_provider_with_a_credential() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let mut wizard = test_wizard(dir.path());
1538 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1539 wizard.providers[0].selected = true;
1540 wizard.providers[0].value = "sk-ant-x".to_string();
1541
1542 wizard.request_verification(0);
1543
1544 assert!(wizard.providers[0].checking);
1545 let request = requests.try_recv().expect("a request was queued");
1546 assert_eq!(request.provider_id, "anthropic");
1547 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
1548 }
1549
1550 #[tokio::test]
1551 async fn a_blank_api_key_is_not_queued_for_checking() {
1552 let dir = tempfile::tempdir().unwrap();
1554 let mut wizard = test_wizard(dir.path());
1555 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1556 wizard.providers[0].selected = true;
1557
1558 wizard.request_verification(0);
1559
1560 assert!(!wizard.providers[0].checking);
1561 assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
1562 assert!(requests.try_recv().is_err());
1563 }
1564
1565 #[tokio::test]
1566 async fn an_environment_supplied_key_is_what_gets_checked() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let mut wizard = Wizard::new(
1569 Config::default(),
1570 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1571 Vec::new(),
1572 Vec::new(),
1573 dir.path(),
1574 std::sync::Arc::new(|_| true),
1575 );
1576 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1577
1578 wizard.request_verification(0);
1579
1580 let request = requests.try_recv().expect("a request was queued");
1581 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
1582 }
1583
1584 #[tokio::test]
1585 async fn ollama_is_checked_by_url_with_no_key() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let mut wizard = test_wizard(dir.path());
1588 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1589 let index = wizard
1590 .providers
1591 .iter()
1592 .position(|r| r.provider.id == "ollama")
1593 .expect("ollama is offered");
1594
1595 wizard.request_verification(index);
1596 let request = requests.try_recv().expect("a request was queued");
1597 assert!(request.creds.api_key.is_none());
1598 assert_eq!(
1599 request.creds.base_url.as_deref(),
1600 Some(catalog::DEFAULT_OLLAMA_URL),
1601 "an empty field means the default endpoint"
1602 );
1603
1604 wizard.providers[index].value = "http://box:11434".to_string();
1605 wizard.request_verification(index);
1606 let request = requests.try_recv().expect("a second request was queued");
1607 assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
1608 }
1609
1610 #[tokio::test]
1611 async fn verify_all_covers_every_selected_provider() {
1612 let dir = tempfile::tempdir().unwrap();
1613 let mut wizard = test_wizard(dir.path());
1614 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1615 wizard.providers[0].selected = true;
1616 wizard.providers[0].value = "sk-ant".to_string();
1617 let ollama = wizard
1618 .providers
1619 .iter()
1620 .position(|r| r.provider.id == "ollama")
1621 .expect("ollama is offered");
1622 wizard.providers[ollama].selected = true;
1623
1624 wizard.verify_all();
1625
1626 let mut seen = Vec::new();
1627 while let Ok(request) = requests.try_recv() {
1628 seen.push(request.provider_id);
1629 }
1630 assert_eq!(seen, vec!["anthropic", "ollama"]);
1631 }
1632
1633 #[tokio::test]
1634 async fn an_out_of_range_verification_request_is_a_no_op() {
1635 let dir = tempfile::tempdir().unwrap();
1636 let mut wizard = test_wizard(dir.path());
1637 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1638
1639 wizard.request_verification(999);
1640
1641 assert!(requests.try_recv().is_err());
1642 }
1643
1644 #[tokio::test]
1645 async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
1646 let dir = tempfile::tempdir().unwrap();
1647 let mut wizard = test_wizard(dir.path());
1648 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1649 wizard.providers[0].selected = true;
1650 wizard.providers[0].checking = true;
1651
1652 replies
1653 .send(VerifyReply {
1654 provider_id: "anthropic".to_string(),
1655 outcome: Outcome::Reachable {
1656 models: vec!["claude-opus-5".to_string()],
1657 },
1658 })
1659 .unwrap();
1660 replies
1662 .send(VerifyReply {
1663 provider_id: "not-a-provider".to_string(),
1664 outcome: Outcome::Skipped,
1665 })
1666 .unwrap();
1667 wizard.drain_verifications();
1668
1669 assert!(!wizard.providers[0].checking);
1670 assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
1671 }
1672
1673 #[tokio::test]
1674 async fn a_late_reply_refills_the_model_picker() {
1675 let dir = tempfile::tempdir().unwrap();
1680 let mut wizard = test_wizard(dir.path());
1681 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1682 wizard.providers[0].selected = true;
1683 wizard.enter(Step::Defaults);
1684 assert_eq!(
1685 wizard.defaults[1].value.options(),
1686 [Wizard::NO_DEFAULT_MODEL.to_string()],
1687 "nothing has been reported yet"
1688 );
1689
1690 replies
1691 .send(VerifyReply {
1692 provider_id: "anthropic".to_string(),
1693 outcome: Outcome::Reachable {
1694 models: vec!["claude-opus-5".to_string()],
1695 },
1696 })
1697 .unwrap();
1698 wizard.drain_verifications();
1699
1700 assert!(
1701 wizard.defaults[1]
1702 .value
1703 .options()
1704 .contains(&"claude-opus-5".to_string()),
1705 "the picker should have refilled"
1706 );
1707 }
1708
1709 #[tokio::test]
1710 async fn a_late_reply_does_not_disturb_another_screen() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let mut wizard = test_wizard(dir.path());
1713 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1714 wizard.providers[0].selected = true;
1715 wizard.enter(Step::Limits);
1716 wizard.limits[0].value = FieldValue::Number(Some(3));
1717
1718 replies
1719 .send(VerifyReply {
1720 provider_id: "anthropic".to_string(),
1721 outcome: Outcome::Reachable {
1722 models: vec!["m".to_string()],
1723 },
1724 })
1725 .unwrap();
1726 wizard.drain_verifications();
1727
1728 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1729 }
1730
1731 #[test]
1732 fn the_verification_channel_ends_can_only_be_taken_once() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let mut wizard = test_wizard(dir.path());
1735
1736 assert!(wizard.take_verify_ends().is_some());
1737 assert!(wizard.take_verify_ends().is_none());
1738 }
1739
1740 #[test]
1741 fn models_from_unselected_providers_are_not_offered() {
1742 let dir = tempfile::tempdir().unwrap();
1743 let mut wizard = test_wizard(dir.path());
1744 wizard.providers[0].outcome = Outcome::Reachable {
1745 models: vec!["hidden".to_string()],
1746 };
1747
1748 assert!(wizard.discovered_models().is_empty());
1749 }
1750
1751 #[test]
1754 fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
1755 let dir = tempfile::tempdir().unwrap();
1758 let mut wizard = test_wizard(dir.path());
1759 let ollama = wizard
1760 .providers
1761 .iter()
1762 .position(|r| r.provider.id == "ollama")
1763 .expect("ollama is offered");
1764 wizard.providers[ollama].selected = true;
1765 wizard.enter(Step::Defaults);
1766
1767 assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
1768 }
1769
1770 #[test]
1771 fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let mut wizard = test_wizard(dir.path());
1774 wizard.enter(Step::Defaults);
1775
1776 assert_eq!(
1777 wizard.defaults[0].value.display(),
1778 Config::default().default_provider
1779 );
1780 }
1781
1782 #[test]
1783 fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let base = Config {
1786 default_model: Some("hand-typed".to_string()),
1787 ..Config::default()
1788 };
1789 let mut wizard = Wizard::new(
1790 base,
1791 &|_| None,
1792 Vec::new(),
1793 Vec::new(),
1794 dir.path(),
1795 std::sync::Arc::new(|_| true),
1796 );
1797 wizard.providers[0].selected = true;
1798 wizard.providers[0].outcome = Outcome::Reachable {
1799 models: vec!["claude-opus-5".to_string()],
1800 };
1801
1802 wizard.enter(Step::Defaults);
1803
1804 let options = wizard.defaults[1].value.options();
1805 assert!(options.contains(&Wizard::NO_DEFAULT_MODEL.to_string()));
1806 assert!(options.contains(&"claude-opus-5".to_string()));
1807 assert_eq!(
1808 wizard.defaults[1].value.display(),
1809 "hand-typed",
1810 "a model already in the config must survive"
1811 );
1812 }
1813
1814 #[test]
1815 fn only_a_choice_field_has_options() {
1816 assert_eq!(
1817 FieldValue::Choice {
1818 options: vec!["a".into()],
1819 index: 0
1820 }
1821 .options(),
1822 ["a".to_string()]
1823 );
1824 assert!(FieldValue::Number(Some(1)).options().is_empty());
1825 assert!(FieldValue::Bool(true).options().is_empty());
1826 }
1827
1828 #[test]
1829 fn field_values_read_naturally() {
1830 assert_eq!(FieldValue::Number(None).display(), "(unset)");
1831 assert_eq!(FieldValue::Number(Some(7)).display(), "7");
1832 assert_eq!(FieldValue::Bool(true).display(), "yes");
1833 assert_eq!(FieldValue::Bool(false).display(), "no");
1834 assert_eq!(
1835 FieldValue::Choice {
1836 options: vec!["a".into()],
1837 index: 0
1838 }
1839 .display(),
1840 "a"
1841 );
1842 assert_eq!(
1843 FieldValue::Choice {
1844 options: vec![],
1845 index: 0
1846 }
1847 .display(),
1848 "(none)"
1849 );
1850 }
1851
1852 #[test]
1856 fn setting_an_index_moves_a_choice_and_leaves_other_kinds_alone() {
1857 let mut choice = FieldValue::Choice {
1858 options: vec!["a".into(), "b".into()],
1859 index: 0,
1860 };
1861 choice.set_index(1);
1862 assert_eq!(choice.display(), "b");
1863
1864 let mut number = FieldValue::Number(Some(7));
1865 number.set_index(1);
1866 assert_eq!(number.display(), "7");
1867 }
1868
1869 #[test]
1870 fn picking_ollama_drops_the_concurrency_default_to_one() {
1871 let dir = tempfile::tempdir().unwrap();
1874 let mut wizard = test_wizard(dir.path());
1875 let ollama = wizard
1876 .providers
1877 .iter()
1878 .position(|r| r.provider.id == "ollama")
1879 .expect("ollama is offered");
1880 wizard.providers[ollama].selected = true;
1881 wizard.enter(Step::Defaults);
1882
1883 wizard.apply_provider_concurrency_default();
1884
1885 assert_eq!(
1886 wizard.limits[0].value,
1887 FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
1888 );
1889 }
1890
1891 #[test]
1892 fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
1893 let dir = tempfile::tempdir().unwrap();
1898 let mut wizard = test_wizard(dir.path());
1899 let ollama = wizard
1900 .providers
1901 .iter()
1902 .position(|r| r.provider.id == "ollama")
1903 .expect("ollama is offered");
1904 wizard.providers[ollama].selected = true;
1905
1906 wizard.enter(Step::Defaults);
1907
1908 assert_eq!(wizard.defaults[0].value.display(), "ollama");
1909 assert_eq!(
1910 wizard.build_config().limits.max_concurrent_inferences,
1911 Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
1912 );
1913 }
1914
1915 #[test]
1916 fn switching_back_off_ollama_restores_the_general_default() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let mut wizard = test_wizard(dir.path());
1919 let ollama = wizard
1920 .providers
1921 .iter()
1922 .position(|r| r.provider.id == "ollama")
1923 .expect("ollama is offered");
1924 wizard.providers[ollama].selected = true;
1925 wizard.enter(Step::Defaults);
1926 wizard.apply_provider_concurrency_default();
1927
1928 wizard.providers[ollama].selected = false;
1929 wizard.providers[0].selected = true;
1930 wizard.rebuild_defaults();
1931 wizard.apply_provider_concurrency_default();
1932
1933 assert_eq!(
1934 wizard.limits[0].value,
1935 FieldValue::Number(
1936 Config::default()
1937 .limits
1938 .max_concurrent_inferences
1939 .map(|n| n as u64)
1940 )
1941 );
1942 }
1943
1944 #[test]
1945 fn a_hand_typed_concurrency_is_never_overwritten() {
1946 let dir = tempfile::tempdir().unwrap();
1947 let mut wizard = test_wizard(dir.path());
1948 let ollama = wizard
1949 .providers
1950 .iter()
1951 .position(|r| r.provider.id == "ollama")
1952 .expect("ollama is offered");
1953 wizard.providers[ollama].selected = true;
1954 wizard.enter(Step::Defaults);
1955 wizard.limits[0].value = FieldValue::Number(Some(3));
1956
1957 wizard.apply_provider_concurrency_default();
1958
1959 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1960 }
1961
1962 #[test]
1965 fn committing_a_credential_clears_its_stale_verification() {
1966 let dir = tempfile::tempdir().unwrap();
1967 let mut wizard = test_wizard(dir.path());
1968 wizard.providers[0].outcome = Outcome::Reachable {
1969 models: vec!["m".into()],
1970 };
1971 wizard.edit = Some(Edit {
1972 target: EditTarget::Credential(0),
1973 line: crate::tui::widgets::line_edit::LineEdit::new(" sk-ant-new ".to_string(), true),
1974 });
1975
1976 wizard.commit_edit();
1977
1978 assert_eq!(wizard.providers[0].value, "sk-ant-new");
1979 assert_eq!(
1980 wizard.providers[0].outcome,
1981 Outcome::Skipped,
1982 "the old result was for a different key"
1983 );
1984 assert!(wizard.edit.is_none());
1985 }
1986
1987 #[test]
1988 fn typing_a_credential_supersedes_the_environments() {
1989 let dir = tempfile::tempdir().unwrap();
1990 let mut wizard = Wizard::new(
1991 Config::default(),
1992 &|_| Some("sk-ant-env".to_string()),
1993 Vec::new(),
1994 Vec::new(),
1995 dir.path(),
1996 std::sync::Arc::new(|_| true),
1997 );
1998 assert!(wizard.providers[0].from_env.is_some());
1999 wizard.edit = Some(Edit {
2000 target: EditTarget::Credential(0),
2001 line: crate::tui::widgets::line_edit::LineEdit::new("sk-ant-typed".to_string(), true),
2002 });
2003
2004 wizard.commit_edit();
2005
2006 assert!(wizard.providers[0].from_env.is_none());
2007 assert_eq!(
2008 wizard.build_config().providers.anthropic_api_key.as_deref(),
2009 Some("sk-ant-typed")
2010 );
2011 }
2012
2013 #[test]
2014 fn committing_numbers_handles_blank_and_unparseable_input() {
2015 let dir = tempfile::tempdir().unwrap();
2016 let mut wizard = test_wizard(dir.path());
2017 wizard.enter(Step::Limits);
2018
2019 wizard.edit = Some(Edit {
2020 target: EditTarget::Field(0),
2021 line: crate::tui::widgets::line_edit::LineEdit::new("16".to_string(), false),
2022 });
2023 wizard.commit_edit();
2024 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2025
2026 wizard.edit = Some(Edit {
2028 target: EditTarget::Field(0),
2029 line: crate::tui::widgets::line_edit::LineEdit::new("not a number".to_string(), false),
2030 });
2031 wizard.commit_edit();
2032 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2033
2034 wizard.edit = Some(Edit {
2036 target: EditTarget::Field(0),
2037 line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), false),
2038 });
2039 wizard.commit_edit();
2040 assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
2041 }
2042
2043 #[test]
2044 fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
2045 let dir = tempfile::tempdir().unwrap();
2046 let mut wizard = test_wizard(dir.path());
2047 wizard.commit_edit();
2048
2049 wizard.edit = Some(Edit {
2050 target: EditTarget::Credential(999),
2051 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2052 });
2053 wizard.commit_edit();
2054
2055 wizard.enter(Step::Limits);
2056 wizard.edit = Some(Edit {
2057 target: EditTarget::Field(999),
2058 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2059 });
2060 wizard.commit_edit();
2061
2062 wizard.enter(Step::Welcome);
2064 wizard.edit = Some(Edit {
2065 target: EditTarget::Field(0),
2066 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2067 });
2068 wizard.commit_edit();
2069
2070 assert!(wizard.edit.is_none());
2071 }
2072
2073 #[test]
2074 fn every_step_reports_a_sensible_row_count() {
2075 let dir = tempfile::tempdir().unwrap();
2076 let mut wizard = Wizard::new(
2077 Config::default(),
2078 &|_| None,
2079 vec![("A".to_string(), candidate("fs"))],
2080 Vec::new(),
2081 dir.path(),
2082 std::sync::Arc::new(|_| true),
2083 );
2084 wizard.providers[0].selected = true;
2085
2086 for step in Step::ALL {
2087 wizard.enter(step);
2088 let rows = wizard.row_count();
2089 match step {
2090 Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
2091 _ => assert!(rows > 0, "{step:?} has no rows"),
2092 }
2093 let fields = wizard.fields().len();
2095 match step {
2096 Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
2097 _ => assert_eq!(fields, 0, "{step:?} should have no fields"),
2098 }
2099 }
2100 }
2101
2102 #[test]
2103 fn committing_onto_a_defaults_field_reaches_that_form_too() {
2104 let dir = tempfile::tempdir().unwrap();
2105 let mut wizard = test_wizard(dir.path());
2106 wizard.providers[0].selected = true;
2107 wizard.enter(Step::Defaults);
2108
2109 wizard.edit = Some(Edit {
2110 target: EditTarget::Field(2),
2111 line: crate::tui::widgets::line_edit::LineEdit::new("45".to_string(), false),
2112 });
2113 wizard.commit_edit();
2114
2115 assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
2116 assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
2117 }
2118
2119 #[test]
2120 fn clearing_a_credential_leaves_the_environment_marker_alone() {
2121 let dir = tempfile::tempdir().unwrap();
2123 let mut wizard = Wizard::new(
2124 Config::default(),
2125 &|_| Some("sk-ant-env".to_string()),
2126 Vec::new(),
2127 Vec::new(),
2128 dir.path(),
2129 std::sync::Arc::new(|_| true),
2130 );
2131 wizard.edit = Some(Edit {
2132 target: EditTarget::Credential(0),
2133 line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), true),
2134 });
2135
2136 wizard.commit_edit();
2137
2138 assert!(wizard.providers[0].value.is_empty());
2139 assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
2140 }
2141
2142 #[test]
2143 fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
2144 let dir = tempfile::tempdir().unwrap();
2146 let mut wizard = test_wizard(dir.path());
2147 wizard.defaults[0].value = FieldValue::Bool(true);
2148
2149 assert_eq!(
2150 wizard.build_config().default_provider,
2151 Config::default().default_provider
2152 );
2153 }
2154
2155 #[test]
2156 fn an_empty_choice_list_falls_back_to_the_base_config() {
2157 let dir = tempfile::tempdir().unwrap();
2158 let mut wizard = test_wizard(dir.path());
2159 wizard.defaults[0].value = FieldValue::Choice {
2160 options: Vec::new(),
2161 index: 0,
2162 };
2163
2164 assert_eq!(
2165 wizard.build_config().default_provider,
2166 Config::default().default_provider
2167 );
2168 }
2169
2170 #[test]
2171 fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
2172 let dir = tempfile::tempdir().unwrap();
2173 let mut wizard = test_wizard(dir.path());
2174 wizard.limits[0].value = FieldValue::Bool(true);
2175
2176 wizard.apply_provider_concurrency_default();
2177
2178 assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
2179 }
2180
2181 #[test]
2182 fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
2183 let dir = tempfile::tempdir().unwrap();
2184 let mut wizard = test_wizard(dir.path());
2185 wizard.enter(Step::Limits);
2186 let before = wizard.limits[3].value.clone();
2187
2188 wizard.edit = Some(Edit {
2189 target: EditTarget::Field(3),
2190 line: crate::tui::widgets::line_edit::LineEdit::new("yes".to_string(), false),
2191 });
2192 wizard.commit_edit();
2193
2194 assert_eq!(wizard.limits[3].value, before);
2195 }
2196
2197 #[test]
2200 fn deselecting_a_provider_clears_its_credential() {
2201 let dir = tempfile::tempdir().unwrap();
2202 let base = Config {
2203 providers: crate::config::ProviderConfig {
2204 anthropic_api_key: Some("sk-ant-stored".to_string()),
2205 ..Config::default().providers
2206 },
2207 ..Config::default()
2208 };
2209 let mut wizard = Wizard::new(
2210 base,
2211 &|_| None,
2212 Vec::new(),
2213 Vec::new(),
2214 dir.path(),
2215 std::sync::Arc::new(|_| true),
2216 );
2217
2218 wizard.providers[0].selected = false;
2219
2220 assert!(wizard.build_config().providers.anthropic_api_key.is_none());
2221 }
2222
2223 #[test]
2224 fn ollamas_default_url_is_left_unset_rather_than_pinned() {
2225 let dir = tempfile::tempdir().unwrap();
2227 let mut wizard = test_wizard(dir.path());
2228 let ollama = wizard
2229 .providers
2230 .iter()
2231 .position(|r| r.provider.id == "ollama")
2232 .expect("ollama is offered");
2233 wizard.providers[ollama].selected = true;
2234 wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
2235
2236 assert!(wizard.build_config().ollama_base_url.is_none());
2237
2238 wizard.providers[ollama].value = "http://box:11434".to_string();
2239 assert_eq!(
2240 wizard.build_config().ollama_base_url.as_deref(),
2241 Some("http://box:11434")
2242 );
2243 }
2244
2245 #[test]
2246 fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
2247 let dir = tempfile::tempdir().unwrap();
2248 let mut wizard = test_wizard(dir.path());
2249 let index = wizard
2250 .providers
2251 .iter()
2252 .position(|r| r.provider.id == "claude-code")
2253 .expect("the transport is offered");
2254
2255 assert!(!wizard.build_config().providers.claude_code_enabled);
2256
2257 wizard.providers[index].selected = true;
2258 wizard.providers[index].effort = effort_options().len() - 1;
2259 let config = wizard.build_config();
2260 assert!(config.providers.claude_code_enabled);
2261 assert_eq!(
2262 config.providers.claude_code_effort.as_deref(),
2263 Some(*effort_options().last().expect("levels exist"))
2264 );
2265 }
2266
2267 #[test]
2268 fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
2269 let dir = tempfile::tempdir().unwrap();
2270 let mut wizard = test_wizard(dir.path());
2271 let index = wizard
2272 .providers
2273 .iter()
2274 .position(|r| r.provider.id == "claude-code")
2275 .expect("the transport is offered");
2276 wizard.providers[index].selected = true;
2277 wizard.providers[index].effort = 99;
2278
2279 let config = wizard.build_config();
2280
2281 assert_eq!(
2282 config.providers.claude_code_effort.as_deref(),
2283 Some(*effort_options().last().expect("levels exist"))
2284 );
2285 }
2286
2287 #[test]
2288 fn the_stored_effort_selects_the_matching_option() {
2289 let dir = tempfile::tempdir().unwrap();
2290 let base = Config {
2291 providers: crate::config::ProviderConfig {
2292 claude_code_effort: Some("max".to_string()),
2293 ..Config::default().providers
2294 },
2295 ..Config::default()
2296 };
2297 let wizard = Wizard::new(
2298 base,
2299 &|_| None,
2300 Vec::new(),
2301 Vec::new(),
2302 dir.path(),
2303 std::sync::Arc::new(|_| true),
2304 );
2305
2306 let index = wizard
2307 .providers
2308 .iter()
2309 .position(|r| r.provider.id == "claude-code")
2310 .expect("the transport is offered");
2311 assert_eq!(effort_options()[wizard.providers[index].effort], "max");
2312 }
2313
2314 #[test]
2315 fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
2316 assert_eq!(effort_index(Some("not-a-level")), 0);
2317 assert_eq!(
2318 effort_options()[effort_index(None)],
2319 leviath_providers::claude_code::DEFAULT_EFFORT
2320 );
2321 }
2322
2323 #[test]
2324 fn the_provider_default_model_is_stored_as_unset() {
2325 let dir = tempfile::tempdir().unwrap();
2326 let mut wizard = test_wizard(dir.path());
2327 wizard.providers[0].selected = true;
2328 wizard.enter(Step::Defaults);
2329
2330 assert!(wizard.build_config().default_model.is_none());
2332 }
2333
2334 #[test]
2335 fn limits_are_written_back_including_the_zero_guard() {
2336 let dir = tempfile::tempdir().unwrap();
2339 let mut wizard = test_wizard(dir.path());
2340 wizard.enter(Step::Limits);
2341 wizard.limits[0].value = FieldValue::Number(Some(2));
2342 wizard.limits[1].value = FieldValue::Number(Some(0));
2343 wizard.limits[2].value = FieldValue::Number(None);
2344 wizard.limits[3].value = FieldValue::Bool(true);
2345 wizard.limits[4].value = FieldValue::Bool(false);
2346 wizard.limits[5].value = FieldValue::Bool(false);
2347 wizard.limits[6].value = FieldValue::Number(Some(11));
2348 wizard.limits[7].value = FieldValue::Number(Some(22));
2349 wizard.limits[8].value = FieldValue::Number(Some(33));
2350 wizard.limits[9].value = FieldValue::Number(Some(44));
2351
2352 let config = wizard.build_config();
2353
2354 assert_eq!(config.limits.max_concurrent_inferences, Some(2));
2355 assert_eq!(
2356 config.limits.max_concurrent_tools,
2357 Config::default().limits.max_concurrent_tools
2358 );
2359 assert!(config.limits.default_max_iterations.is_none());
2360 assert!(config.limits.exact_token_counting);
2361 assert!(!config.batch_tool_hint);
2362 assert!(!config.shell_hint);
2363 assert_eq!(config.limits.stall_timeout_secs, 11);
2367 assert_eq!(config.limits.dead_cycles_before_relief, 22);
2368 assert_eq!(config.limits.finished_retention_secs, 33);
2369 assert_eq!(config.limits.wedge_timeout_secs, 44);
2370 }
2371
2372 #[test]
2373 fn every_limits_field_is_written_back() {
2374 let dir = tempfile::tempdir().unwrap();
2378 let mut wizard = test_wizard(dir.path());
2379 wizard.enter(Step::Limits);
2380 let count = wizard.limits.len();
2381
2382 let before = wizard.build_config();
2383 let seeded = limits_fields(&before);
2384 assert_eq!(seeded.len(), count, "the form is built from the config");
2385
2386 for (i, field) in wizard.limits.iter_mut().enumerate() {
2392 field.value = match &field.value {
2393 FieldValue::Bool(b) => FieldValue::Bool(!b),
2394 _ => FieldValue::Number(Some(i as u64 + 11)),
2395 };
2396 }
2397 let expected: Vec<FieldValue> = wizard.limits.iter().map(|f| f.value.clone()).collect();
2398 let after = limits_fields(&wizard.build_config());
2399
2400 for (i, (got, want)) in after.iter().zip(&expected).enumerate() {
2401 assert_eq!(
2402 &got.value, want,
2403 "field {i} ({}) did not survive the round trip",
2404 got.label
2405 );
2406 }
2407 }
2408
2409 #[test]
2413 fn the_watchdog_limits_store_zero_and_keep_the_default_when_blank() {
2414 let dir = tempfile::tempdir().unwrap();
2415 let mut wizard = test_wizard(dir.path());
2416 wizard.enter(Step::Limits);
2417 wizard.limits[6].value = FieldValue::Number(Some(0));
2418 wizard.limits[7].value = FieldValue::Number(Some(0));
2419 wizard.limits[8].value = FieldValue::Number(Some(0));
2420 wizard.limits[9].value = FieldValue::Number(Some(300));
2421
2422 let config = wizard.build_config();
2423
2424 assert_eq!(config.limits.stall_timeout_secs, 0);
2425 assert_eq!(config.limits.dead_cycles_before_relief, 0);
2426 assert_eq!(config.limits.finished_retention_secs, 0);
2427 assert_eq!(config.limits.wedge_timeout_secs, 300);
2428
2429 let mut wizard = test_wizard(dir.path());
2430 wizard.enter(Step::Limits);
2431 wizard.limits[6].value = FieldValue::Number(None);
2432 wizard.limits[7].value = FieldValue::Number(None);
2433 wizard.limits[8].value = FieldValue::Number(None);
2434 wizard.limits[9].value = FieldValue::Number(None);
2435
2436 let config = wizard.build_config();
2437
2438 let default = Config::default();
2439 assert_eq!(
2440 config.limits.stall_timeout_secs,
2441 default.limits.stall_timeout_secs
2442 );
2443 assert_eq!(
2444 config.limits.dead_cycles_before_relief,
2445 default.limits.dead_cycles_before_relief
2446 );
2447 assert_eq!(
2448 config.limits.finished_retention_secs,
2449 default.limits.finished_retention_secs
2450 );
2451 assert_eq!(
2452 config.limits.wedge_timeout_secs,
2453 default.limits.wedge_timeout_secs
2454 );
2455 }
2456
2457 #[test]
2458 fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
2459 let mut config = Config::default();
2462 apply_limits_fields(
2463 &mut config,
2464 &[Field {
2465 label: "Max concurrent inferences",
2466 help: "",
2467 value: FieldValue::Bool(true),
2468 }],
2469 );
2470
2471 assert_eq!(
2472 config.limits.max_concurrent_inferences,
2473 Config::default().limits.max_concurrent_inferences
2474 );
2475 }
2476
2477 #[test]
2478 fn the_plan_carries_only_the_selected_agents() {
2479 let dir = tempfile::tempdir().unwrap();
2480 let mut wizard = test_wizard(dir.path());
2481 for row in wizard.agents.iter_mut().skip(1) {
2482 row.selected = false;
2483 }
2484
2485 let plan = wizard.build_plan();
2486
2487 assert_eq!(plan.agents.len(), 1);
2488 assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
2489 }
2490
2491 #[test]
2492 fn the_review_says_so_when_nothing_would_change() {
2493 let dir = tempfile::tempdir().unwrap();
2494 let mut wizard = test_wizard(dir.path());
2495 for row in wizard.agents.iter_mut() {
2496 row.selected = false;
2497 }
2498
2499 assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
2500 }
2501
2502 #[test]
2503 fn the_review_lists_real_changes() {
2504 let dir = tempfile::tempdir().unwrap();
2505 let mut wizard = test_wizard(dir.path());
2506 wizard.providers[0].selected = true;
2507 wizard.providers[0].value = "sk-ant-x".to_string();
2508
2509 let lines = wizard.review_lines();
2510
2511 assert!(lines.iter().any(|l| l.contains("credential set")));
2512 assert!(lines.iter().any(|l| l.contains("to install")));
2513 }
2514
2515 #[test]
2518 fn scans_flatten_into_candidates_and_labelled_errors() {
2519 let scans = vec![
2520 import::Scan {
2521 source: import::Source {
2522 id: "a",
2523 display: "Harness A",
2524 path: std::path::PathBuf::from("/a"),
2525 layout: import::Layout::ClaudeCode,
2526 allows_comments: false,
2527 },
2528 result: Ok(vec![candidate("fs")]),
2529 },
2530 import::Scan {
2531 source: import::Source {
2532 id: "b",
2533 display: "Harness B",
2534 path: std::path::PathBuf::from("/b"),
2535 layout: import::Layout::CodexToml,
2536 allows_comments: false,
2537 },
2538 result: Err("unreadable".to_string()),
2539 },
2540 ];
2541
2542 let (candidates, errors) = candidates_from_scans(scans);
2543
2544 assert_eq!(candidates.len(), 1);
2545 assert_eq!(candidates[0].0, "Harness A");
2546 assert_eq!(errors, vec!["Harness B: unreadable"]);
2547 }
2548}