1use std::collections::HashMap;
9
10use leviath_mcp::MCPServerConfig;
11use tokio::sync::mpsc;
12
13use super::catalog::{self, Credential, Provider};
14use super::import::{self, Candidate};
15use super::plan::SetupPlan;
16use super::verify::Outcome;
17use crate::bundled::{AgentAction, BundledAgent};
18use crate::config::Config;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum Step {
23 Welcome,
24 Providers,
25 ProviderDetail,
26 Defaults,
27 Limits,
28 Agents,
29 Mcp,
30 Review,
31}
32
33impl Step {
34 pub const ALL: [Step; 8] = [
36 Step::Welcome,
37 Step::Providers,
38 Step::ProviderDetail,
39 Step::Defaults,
40 Step::Limits,
41 Step::Agents,
42 Step::Mcp,
43 Step::Review,
44 ];
45
46 pub fn title(self) -> &'static str {
48 match self {
49 Step::Welcome => "Welcome",
50 Step::Providers => "Providers",
51 Step::ProviderDetail => "Credentials",
52 Step::Defaults => "Defaults",
53 Step::Limits => "Limits",
54 Step::Agents => "Agents",
55 Step::Mcp => "MCP servers",
56 Step::Review => "Review",
57 }
58 }
59
60 pub fn index(self) -> usize {
62 Self::ALL
63 .iter()
64 .position(|s| *s == self)
65 .expect("every step is in ALL")
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct ProviderRow {
72 pub provider: Provider,
73 pub selected: bool,
74 pub value: String,
76 pub from_env: Option<&'static str>,
79 pub effort: usize,
81 pub outcome: Outcome,
82 pub checking: bool,
84}
85
86impl ProviderRow {
87 pub fn has_credential(&self) -> bool {
89 match self.provider.credential {
90 Credential::ApiKey => !self.value.is_empty() || self.from_env.is_some(),
91 Credential::BaseUrl | Credential::None => true,
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct AgentRow {
101 pub agent: &'static BundledAgent,
102 pub action: AgentAction,
103 pub selected: bool,
104}
105
106#[derive(Debug, Clone)]
108pub struct McpRow {
109 pub candidate: Candidate,
110 pub source: String,
112 pub selected: bool,
113 pub collides: bool,
115 pub name: String,
117}
118
119#[derive(Debug, Clone)]
121pub struct Field {
122 pub label: &'static str,
123 pub help: &'static str,
124 pub value: FieldValue,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum FieldValue {
131 Number(Option<u64>),
133 Bool(bool),
135 Choice { options: Vec<String>, index: usize },
137}
138
139impl FieldValue {
140 pub fn display(&self) -> String {
142 match self {
143 Self::Number(None) => "(unset)".to_string(),
144 Self::Number(Some(n)) => n.to_string(),
145 Self::Bool(true) => "yes".to_string(),
146 Self::Bool(false) => "no".to_string(),
147 Self::Choice { options, index } => match options.get(*index) {
148 Some(chosen) => chosen.clone(),
149 None => "(none)".to_string(),
150 },
151 }
152 }
153
154 pub fn options(&self) -> &[String] {
156 match self {
157 Self::Choice { options, .. } => options,
158 Self::Number(_) | Self::Bool(_) => &[],
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct VerifyRequest {
166 pub provider_id: String,
167 pub creds: leviath_runtime::provider_creds::ProviderCreds,
168}
169
170#[derive(Debug, Clone)]
172pub struct VerifyReply {
173 pub provider_id: String,
174 pub outcome: Outcome,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum EditTarget {
180 Credential(usize),
182 Field(usize),
184}
185
186#[derive(Debug, Clone)]
188pub struct Edit {
189 pub target: EditTarget,
190 pub(crate) line: crate::tui::widgets::line_edit::LineEdit,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum ConfirmPurpose {
197 QuitDiscard,
199 SaveTos,
201 NoProviders,
203}
204
205#[derive(Debug, Clone, PartialEq)]
207pub struct PendingConfirm {
208 pub purpose: ConfirmPurpose,
209 pub(crate) dialog: crate::tui::widgets::confirm::Confirm,
210}
211
212pub struct Wizard {
214 pub step: Step,
215 pub cursor: usize,
217 pub providers: Vec<ProviderRow>,
218 pub detail: usize,
220 pub defaults: Vec<Field>,
221 pub limits: Vec<Field>,
222 pub agents: Vec<AgentRow>,
223 pub mcp: Vec<McpRow>,
224 pub mcp_scan_errors: Vec<String>,
225 pub edit: Option<Edit>,
226 pub reveal: bool,
228 pub show_help: bool,
229 pub confirm: Option<PendingConfirm>,
232 pub dirty: bool,
235 pub claude_code_tos_accepted: bool,
239 pub should_quit: bool,
240 pub finished: bool,
242 pub message: Option<String>,
244 pub base: Config,
247 pub env_only: HashMap<&'static str, String>,
249 pub opener: leviath_mcp::BrowserOpener,
253 pub verify_tx: mpsc::UnboundedSender<VerifyRequest>,
254 verify_rx: Option<mpsc::UnboundedReceiver<VerifyRequest>>,
255 reply_tx: mpsc::UnboundedSender<VerifyReply>,
256 pub reply_rx: mpsc::UnboundedReceiver<VerifyReply>,
257 pub ticks: u64,
259}
260
261fn env_credentials(lookup: &dyn Fn(&str) -> Option<String>) -> HashMap<&'static str, String> {
264 catalog::providers()
265 .iter()
266 .filter_map(|p| {
267 let var = p.env_var?;
268 let value = lookup(var)?;
269 (!value.is_empty()).then_some((var, value))
270 })
271 .collect()
272}
273
274impl Wizard {
275 pub fn new(
284 base: Config,
285 env_lookup: &dyn Fn(&str) -> Option<String>,
286 candidates: Vec<(String, Candidate)>,
287 scan_errors: Vec<String>,
288 agents_dir: &std::path::Path,
289 opener: leviath_mcp::BrowserOpener,
290 ) -> Self {
291 let env_only = env_credentials(env_lookup);
292
293 let providers = catalog::providers()
294 .into_iter()
295 .map(|provider| {
296 let stored = catalog::stored_credential(&base, provider.id);
297 let from_env = provider
298 .env_var
299 .filter(|v| stored.is_none() && env_only.contains_key(v));
300 ProviderRow {
301 selected: catalog::is_configured(&base, provider.id) || from_env.is_some(),
302 value: stored.unwrap_or_default(),
303 from_env,
304 effort: effort_index(base.providers.claude_code_effort.as_deref()),
305 outcome: Outcome::Skipped,
306 checking: false,
307 provider,
308 }
309 })
310 .collect();
311
312 let agents = crate::bundled::plan_agent_actions(agents_dir)
313 .into_iter()
314 .map(|(agent, action)| AgentRow {
315 selected: action.is_change(),
316 agent,
317 action,
318 })
319 .collect();
320
321 let mcp = candidates
322 .into_iter()
323 .map(|(source, candidate)| {
324 let collides =
325 import::already_configured(&base.mcp_servers, &candidate.config.name);
326 let name = import::dedup_name(&base.mcp_servers, &candidate.config.name);
327 McpRow {
328 selected: !collides,
332 source,
333 collides,
334 name,
335 candidate,
336 }
337 })
338 .collect();
339
340 let (verify_tx, verify_rx) = mpsc::unbounded_channel();
341 let (reply_tx, reply_rx) = mpsc::unbounded_channel();
342
343 let mut wizard = Self {
344 step: Step::Welcome,
345 cursor: 0,
346 providers,
347 detail: 0,
348 defaults: Vec::new(),
349 limits: limits_fields(&base),
350 agents,
351 mcp,
352 mcp_scan_errors: scan_errors,
353 edit: None,
354 reveal: false,
355 show_help: false,
356 confirm: None,
357 dirty: false,
358 claude_code_tos_accepted: false,
359 should_quit: false,
360 finished: false,
361 message: None,
362 base,
363 env_only,
364 opener,
365 verify_tx,
366 verify_rx: Some(verify_rx),
367 reply_tx,
368 reply_rx,
369 ticks: 0,
370 };
371 wizard.rebuild_defaults();
372 wizard
373 }
374
375 pub fn take_verify_ends(
378 &mut self,
379 ) -> Option<(
380 mpsc::UnboundedReceiver<VerifyRequest>,
381 mpsc::UnboundedSender<VerifyReply>,
382 )> {
383 self.verify_rx.take().map(|rx| (rx, self.reply_tx.clone()))
384 }
385
386 pub fn selected_providers(&self) -> Vec<usize> {
390 self.providers
391 .iter()
392 .enumerate()
393 .filter(|(_, r)| r.selected)
394 .map(|(i, _)| i)
395 .collect()
396 }
397
398 pub fn detail_row(&self) -> Option<usize> {
400 self.selected_providers().get(self.detail).copied()
401 }
402
403 pub fn claude_code_selected(&self) -> bool {
405 self.providers
406 .iter()
407 .any(|r| r.selected && r.provider.id == "claude-code")
408 }
409
410 pub fn needs_tos_confirmation(&self) -> bool {
415 self.claude_code_selected() && !self.claude_code_tos_accepted
416 }
417
418 pub fn fields(&self) -> &[Field] {
420 match self.step {
421 Step::Defaults => &self.defaults,
422 Step::Limits => &self.limits,
423 _ => &[],
424 }
425 }
426
427 pub(super) fn fields_mut(&mut self) -> Option<&mut Vec<Field>> {
428 match self.step {
429 Step::Defaults => Some(&mut self.defaults),
430 Step::Limits => Some(&mut self.limits),
431 _ => None,
432 }
433 }
434
435 pub fn row_count(&self) -> usize {
437 match self.step {
438 Step::Welcome | Step::Review => 0,
439 Step::Providers => self.providers.len(),
440 Step::ProviderDetail => usize::from(self.detail_row().is_some()),
441 Step::Defaults => self.defaults.len(),
442 Step::Limits => self.limits.len(),
443 Step::Agents => self.agents.len(),
444 Step::Mcp => self.mcp.len(),
445 }
446 }
447
448 pub fn nav_rows(&self) -> usize {
451 self.row_count() + 1
452 }
453
454 pub fn on_continue(&self) -> bool {
457 self.cursor == self.row_count()
458 }
459
460 pub fn continue_label(&self) -> String {
464 match self.step {
465 Step::Welcome => "Get started".to_string(),
466 Step::Review => "Apply and finish".to_string(),
467 Step::Providers => {
468 let count = self.selected_providers().len();
469 if count == 0 {
470 "Continue (no providers selected)".to_string()
471 } else {
472 format!("Continue: {} ({count} selected)", self.next_step_title())
473 }
474 }
475 Step::ProviderDetail => {
476 let selected = self.selected_providers();
477 match selected.get(self.detail + 1) {
478 Some(&next) => format!("Next: {}", self.providers[next].provider.display),
479 None => format!("Continue: {}", self.next_step_title()),
480 }
481 }
482 Step::Defaults | Step::Limits | Step::Agents | Step::Mcp => {
483 format!("Continue: {}", self.next_step_title())
484 }
485 }
486 }
487
488 fn next_step_title(&self) -> &'static str {
490 let mut index = self.step.index();
491 while index + 1 < Step::ALL.len() {
492 index += 1;
493 let step = Step::ALL[index];
494 if !self.is_empty_step(step) {
495 return step.title();
496 }
497 }
498 Step::Review.title()
499 }
500
501 pub fn move_cursor(&mut self, delta: isize) {
503 let count = self.nav_rows();
504 let next = self.cursor as isize + delta;
505 self.cursor = next.clamp(0, count as isize - 1) as usize;
506 }
507
508 pub fn next_step(&mut self) {
512 let mut index = self.step.index();
513 while index + 1 < Step::ALL.len() {
514 index += 1;
515 let step = Step::ALL[index];
516 if !self.is_empty_step(step) {
517 self.enter(step);
518 return;
519 }
520 if step == Step::ProviderDetail {
521 self.message =
522 Some("Skipped Credentials: no selected provider needs setup.".to_string());
523 }
524 }
525 self.enter(Step::Review);
527 }
528
529 pub fn prev_step(&mut self) {
531 let mut index = self.step.index();
532 while index > 0 {
533 index -= 1;
534 let step = Step::ALL[index];
535 if !self.is_empty_step(step) {
536 self.enter(step);
537 return;
538 }
539 }
540 }
541
542 fn is_empty_step(&self, step: Step) -> bool {
547 match step {
548 Step::Mcp => self.mcp.is_empty() && self.mcp_scan_errors.is_empty(),
549 Step::ProviderDetail => self.detail_row().is_none(),
550 _ => false,
551 }
552 }
553
554 pub fn enter(&mut self, step: Step) {
556 self.step = step;
557 self.cursor = 0;
558 self.edit = None;
559 if step == Step::Defaults {
560 self.rebuild_defaults();
563 }
564 }
565
566 pub fn next_detail(&mut self) -> bool {
569 if self.detail + 1 < self.selected_providers().len() {
570 self.detail += 1;
571 self.cursor = 0;
572 self.edit = None;
573 return true;
574 }
575 false
576 }
577
578 pub fn prev_detail(&mut self) -> bool {
580 if self.detail > 0 {
581 self.detail -= 1;
582 self.cursor = 0;
583 self.edit = None;
584 return true;
585 }
586 false
587 }
588
589 pub fn request_verification(&mut self, index: usize) {
597 let Some(row) = self.providers.get_mut(index) else {
598 return;
599 };
600 if !row.has_credential() {
601 row.outcome = Outcome::Skipped;
602 return;
603 }
604 let id = row.provider.id.to_string();
605 let key = if row.value.is_empty() {
606 self.env_only
607 .get(row.provider.env_var.unwrap_or_default())
608 .cloned()
609 } else {
610 Some(row.value.clone())
611 };
612 let base_url = (row.provider.credential == Credential::BaseUrl).then(|| {
613 if row.value.is_empty() {
614 catalog::DEFAULT_OLLAMA_URL.to_string()
615 } else {
616 row.value.clone()
617 }
618 });
619 row.checking = true;
620
621 let creds = leviath_runtime::provider_creds::ProviderCreds {
622 name: id.clone(),
623 api_key: base_url.is_none().then_some(key).flatten(),
624 base_url,
625 model_capabilities: HashMap::new(),
626 request_timeout_secs: Some(20),
627 rate_limit: None,
628 options: HashMap::new(),
629 };
630 let _ = self.verify_tx.send(VerifyRequest {
633 provider_id: id,
634 creds,
635 });
636 }
637
638 pub fn verify_all(&mut self) {
640 for index in self.selected_providers() {
641 self.request_verification(index);
642 }
643 }
644
645 pub fn drain_verifications(&mut self) {
647 let mut landed = false;
648 while let Ok(reply) = self.reply_rx.try_recv() {
649 if let Some(row) = self
650 .providers
651 .iter_mut()
652 .find(|r| r.provider.id == reply.provider_id)
653 {
654 row.checking = false;
655 row.outcome = reply.outcome;
656 landed = true;
657 }
658 }
659 if landed && self.step == Step::Defaults {
665 self.rebuild_defaults();
666 }
667 }
668
669 pub fn discovered_models(&self) -> Vec<String> {
671 let mut models: Vec<String> = self
672 .providers
673 .iter()
674 .filter(|r| r.selected)
675 .flat_map(|r| r.outcome.models().iter().cloned())
676 .collect();
677 models.sort();
678 models.dedup();
679 models
680 }
681
682 pub fn rebuild_defaults(&mut self) {
687 let chosen = self.current_default_provider();
688 let providers: Vec<String> = self
689 .selected_providers()
690 .iter()
691 .map(|i| self.providers[*i].provider.id.to_string())
692 .collect();
693 let providers = if providers.is_empty() {
696 vec![self.base.default_provider.clone()]
697 } else {
698 providers
699 };
700 let index = providers.iter().position(|p| *p == chosen).unwrap_or(0);
701
702 let mut models = vec!["(provider default)".to_string()];
703 models.extend(self.discovered_models());
704 let current_model = self
705 .current_default_model()
706 .unwrap_or_else(|| "(provider default)".to_string());
707 if !models.contains(¤t_model) {
708 models.push(current_model.clone());
709 }
710 let model_index = models
711 .iter()
712 .position(|m| *m == current_model)
713 .unwrap_or_default();
714
715 let timeout = self.current_request_timeout();
716 self.defaults = vec![
717 Field {
718 label: "Default provider",
719 help: "Used by any blueprint that allows a user default.",
720 value: FieldValue::Choice {
721 options: providers,
722 index,
723 },
724 },
725 Field {
726 label: "Default model",
727 help: "Filled in from the models your providers reported.",
728 value: FieldValue::Choice {
729 options: models,
730 index: model_index,
731 },
732 },
733 Field {
734 label: "Request timeout (seconds)",
735 help: "How long to wait on one inference. Unset uses the provider default.",
736 value: FieldValue::Number(timeout),
737 },
738 ];
739 self.apply_provider_concurrency_default();
745 }
746
747 fn current_default_provider(&self) -> String {
750 match self.defaults.first().map(|f| &f.value) {
751 Some(FieldValue::Choice { options, index }) => match options.get(*index) {
752 Some(chosen) => chosen.clone(),
753 None => self.base.default_provider.clone(),
754 },
755 _ => self.base.default_provider.clone(),
756 }
757 }
758
759 fn current_default_model(&self) -> Option<String> {
760 match self.defaults.get(1).map(|f| &f.value) {
761 Some(FieldValue::Choice { options, index }) => options.get(*index).cloned(),
762 _ => self.base.default_model.clone(),
763 }
764 }
765
766 fn current_request_timeout(&self) -> Option<u64> {
767 match self.defaults.get(2).map(|f| &f.value) {
768 Some(FieldValue::Number(n)) => *n,
769 _ => self.base.request_timeout_secs,
770 }
771 }
772
773 pub fn apply_provider_concurrency_default(&mut self) {
780 let ollama = self.current_default_provider() == "ollama";
781 let general = Config::default().limits.max_concurrent_inferences;
782 let local = Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64);
783 let general = general.map(|n| n as u64);
784
785 let Some(FieldValue::Number(current)) = self.limits.first_mut().map(|f| &mut f.value)
789 else {
790 return;
791 };
792 if ollama && *current == general {
793 *current = local;
794 } else if !ollama && *current == local {
795 *current = general;
796 }
797 }
798
799 pub fn open_quit_confirm(&mut self) {
803 use ratatui::text::Line;
804 self.confirm = Some(PendingConfirm {
805 purpose: ConfirmPurpose::QuitDiscard,
806 dialog: crate::tui::widgets::confirm::Confirm::new(
807 "Quit setup?",
808 vec![Line::from(
809 "Nothing has been written yet. Your choices so far will be discarded.",
810 )],
811 "Quit",
812 "Stay",
813 ),
814 });
815 }
816
817 pub fn open_tos_confirm(&mut self) {
820 use ratatui::text::Line;
821 self.confirm = Some(PendingConfirm {
822 purpose: ConfirmPurpose::SaveTos,
823 dialog: crate::tui::widgets::confirm::Confirm::new(
824 "Claude Code terms of service",
825 vec![
826 Line::from("Anthropic's terms prohibit third-party developers from offering"),
827 Line::from("claude.ai subscription auth for their products without prior"),
828 Line::from("approval. The Claude Code transport routes inference through"),
829 Line::from("your subscription via the CLI's OAuth session."),
830 Line::from(""),
831 Line::from("For unambiguous compliance, use a direct Anthropic API key."),
832 Line::from(""),
833 Line::from("Accepting means you take responsibility for compliance."),
834 ],
835 "Accept and save",
836 "Cancel",
837 )
838 .danger(),
839 });
840 }
841
842 pub fn open_no_providers_confirm(&mut self) {
845 use ratatui::text::Line;
846 self.confirm = Some(PendingConfirm {
847 purpose: ConfirmPurpose::NoProviders,
848 dialog: crate::tui::widgets::confirm::Confirm::new(
849 "No providers selected",
850 vec![
851 Line::from("Without a provider, Leviath cannot run any agent."),
852 Line::from("Select one with Space or Enter, or continue anyway to"),
853 Line::from("configure providers later."),
854 ],
855 "Continue anyway",
856 "Go back",
857 ),
858 });
859 }
860
861 pub fn commit_edit(&mut self) {
863 let Some(edit) = self.edit.take() else {
864 return;
865 };
866 match edit.target {
867 EditTarget::Credential(index) => {
868 if let Some(row) = self.providers.get_mut(index) {
869 row.value = edit.line.value().trim().to_string();
870 if !row.value.is_empty() {
873 row.from_env = None;
874 }
875 row.outcome = Outcome::Skipped;
876 }
877 }
878 EditTarget::Field(index) => {
879 let Some(fields) = self.fields_mut() else {
880 return;
881 };
882 if let Some(field) = fields.get_mut(index) {
883 match &mut field.value {
884 FieldValue::Number(n) => {
885 let trimmed = edit.line.value().trim();
886 *n = if trimmed.is_empty() {
887 None
888 } else {
889 trimmed.parse().ok().or(*n)
890 };
891 }
892 FieldValue::Bool(_) | FieldValue::Choice { .. } => {}
894 }
895 }
896 }
897 }
898 }
899
900 pub fn build_config(&self) -> Config {
904 let mut config = self.base.clone();
905
906 for row in &self.providers {
907 match row.provider.credential {
908 Credential::None => {}
909 _ if !row.selected => catalog::set_credential(&mut config, row.provider.id, None),
910 _ if row.value.is_empty() => {
914 catalog::set_credential(&mut config, row.provider.id, None)
915 }
916 Credential::BaseUrl if row.value == catalog::DEFAULT_OLLAMA_URL => {
917 catalog::set_credential(&mut config, row.provider.id, None)
920 }
921 _ => catalog::set_credential(&mut config, row.provider.id, Some(row.value.clone())),
922 }
923 }
924
925 let transport = self
928 .providers
929 .iter()
930 .find(|r| r.provider.id == "claude-code")
931 .filter(|r| r.selected);
932 config.providers.claude_code_enabled = transport.is_some();
933 if let Some(row) = transport {
934 config.providers.claude_code_effort =
935 Some(effort_options()[row.effort.min(effort_options().len() - 1)].to_string());
936 }
937
938 config.default_provider = self.current_default_provider();
939 config.default_model = self
940 .current_default_model()
941 .filter(|m| m != "(provider default)");
942 config.request_timeout_secs = self.current_request_timeout();
943
944 apply_limits_fields(&mut config, &self.limits);
945
946 for row in self.mcp.iter().filter(|r| r.selected) {
947 let mut server = row.candidate.config.clone();
948 server.name = row.name.clone();
949 config.mcp_servers.push(server);
950 }
951
952 config
953 }
954
955 pub fn build_plan(&self) -> SetupPlan {
957 SetupPlan {
958 config: self.build_config(),
959 agents: self
960 .agents
961 .iter()
962 .filter(|r| r.selected)
963 .map(|r| r.agent)
964 .collect(),
965 }
966 }
967
968 pub fn review_lines(&self) -> Vec<String> {
970 let plan = self.build_plan();
971 let changes = super::plan::changes(&self.base, &plan);
972 if changes.is_empty() {
973 vec!["Nothing would change.".to_string()]
974 } else {
975 changes
976 }
977 }
978
979 pub fn selected_inline_secrets(&self) -> Vec<String> {
982 self.mcp
983 .iter()
984 .filter(|r| r.selected && !r.candidate.inline_secrets.is_empty())
985 .map(|r| format!("{}: {}", r.name, r.candidate.inline_secrets.join(", ")))
986 .collect()
987 }
988}
989
990pub fn effort_options() -> &'static [&'static str] {
993 &leviath_providers::claude_code::EFFORT_LEVELS
994}
995
996fn effort_index(effort: Option<&str>) -> usize {
998 let wanted = effort.unwrap_or(leviath_providers::claude_code::DEFAULT_EFFORT);
999 effort_options()
1000 .iter()
1001 .position(|e| *e == wanted)
1002 .unwrap_or_default()
1003}
1004
1005fn limits_fields(config: &Config) -> Vec<Field> {
1007 vec![
1008 Field {
1009 label: "Max concurrent inferences",
1010 help: "How many model calls run at once across all agents.",
1011 value: FieldValue::Number(config.limits.max_concurrent_inferences.map(|n| n as u64)),
1012 },
1013 Field {
1014 label: "Max concurrent tools",
1015 help: "How many tool calls run at once within one batch.",
1016 value: FieldValue::Number(Some(config.limits.max_concurrent_tools as u64)),
1017 },
1018 Field {
1019 label: "Default max iterations",
1020 help: "Per-stage ceiling when a blueprint sets none.",
1021 value: FieldValue::Number(config.limits.default_max_iterations.map(|n| n as u64)),
1022 },
1023 Field {
1024 label: "Exact token counting",
1025 help: "Ask the provider for real token counts instead of estimating. Slower.",
1026 value: FieldValue::Bool(config.limits.exact_token_counting),
1027 },
1028 Field {
1029 label: "Batch tool-call hint",
1030 help: "Nudge models to request several tools in one turn.",
1031 value: FieldValue::Bool(config.batch_tool_hint),
1032 },
1033 Field {
1034 label: "Platform shell hint",
1035 help: "Tell models what shell they get. Only says anything on Windows (cmd.exe).",
1036 value: FieldValue::Bool(config.shell_hint),
1037 },
1038 Field {
1039 label: "Stall timeout (seconds)",
1040 help: "Fail a run that can never dispatch (unconfigured provider). 0 waits forever.",
1041 value: FieldValue::Number(Some(config.limits.stall_timeout_secs)),
1042 },
1043 Field {
1044 label: "Dead cycles before relief",
1045 help: "Widen the tool lane after this many 30s cycles with work queued and nothing moving. 0 never does.",
1046 value: FieldValue::Number(Some(config.limits.dead_cycles_before_relief as u64)),
1047 },
1048 Field {
1049 label: "Finished run retention (seconds)",
1050 help: "Keep a run in `lev ps` this long after it ends, so a script polling on an interval sees how it ended. 0 drops it at once.",
1051 value: FieldValue::Number(Some(config.limits.finished_retention_secs)),
1052 },
1053 Field {
1054 label: "Wedge timeout (seconds)",
1055 help: "Fail a run nothing in the engine can reach any more. 0 is off; 300 is a sensible value.",
1056 value: FieldValue::Number(Some(config.limits.wedge_timeout_secs)),
1057 },
1058 Field {
1059 label: "Interaction timeout (seconds)",
1060 help: "Resolve a prompt nobody answered after this long, so the run carries on. 0 waits for ever.",
1061 value: FieldValue::Number(Some(config.limits.interaction_timeout_secs)),
1062 },
1063 ]
1064}
1065
1066fn apply_limits_fields(config: &mut Config, fields: &[Field]) {
1068 for (index, field) in fields.iter().enumerate() {
1069 match (index, &field.value) {
1070 (0, FieldValue::Number(n)) => {
1071 config.limits.max_concurrent_inferences = n.map(|n| n as usize)
1072 }
1073 (1, FieldValue::Number(n)) => {
1076 config.limits.max_concurrent_tools = n
1077 .filter(|n| *n > 0)
1078 .map(|n| n as usize)
1079 .unwrap_or(Config::default().limits.max_concurrent_tools)
1080 }
1081 (2, FieldValue::Number(n)) => {
1082 config.limits.default_max_iterations = n.map(|n| n as usize)
1083 }
1084 (3, FieldValue::Bool(b)) => config.limits.exact_token_counting = *b,
1085 (4, FieldValue::Bool(b)) => config.batch_tool_hint = *b,
1086 (5, FieldValue::Bool(b)) => config.shell_hint = *b,
1087 (6, FieldValue::Number(n)) => {
1090 config.limits.stall_timeout_secs =
1091 n.unwrap_or(Config::default().limits.stall_timeout_secs)
1092 }
1093 (7, FieldValue::Number(n)) => {
1095 config.limits.dead_cycles_before_relief = n
1096 .map(|n| n as u32)
1097 .unwrap_or(Config::default().limits.dead_cycles_before_relief)
1098 }
1099 (8, FieldValue::Number(n)) => {
1101 config.limits.finished_retention_secs =
1102 n.unwrap_or(Config::default().limits.finished_retention_secs)
1103 }
1104 (9, FieldValue::Number(n)) => {
1106 config.limits.wedge_timeout_secs =
1107 n.unwrap_or(Config::default().limits.wedge_timeout_secs)
1108 }
1109 (10, FieldValue::Number(n)) => {
1112 config.limits.interaction_timeout_secs =
1113 n.unwrap_or(Config::default().limits.interaction_timeout_secs)
1114 }
1115 _ => {}
1116 }
1117 }
1118}
1119
1120pub fn candidates_from_scans(scans: Vec<import::Scan>) -> (Vec<(String, Candidate)>, Vec<String>) {
1123 let mut candidates = Vec::new();
1124 let mut errors = Vec::new();
1125 for scan in scans {
1126 match scan.result {
1127 Ok(found) => candidates.extend(
1128 found
1129 .into_iter()
1130 .map(|c| (scan.source.display.to_string(), c)),
1131 ),
1132 Err(message) => errors.push(format!("{}: {message}", scan.source.display)),
1133 }
1134 }
1135 (candidates, errors)
1136}
1137
1138pub fn selected_servers(rows: &[McpRow]) -> Vec<MCPServerConfig> {
1141 rows.iter()
1142 .filter(|r| r.selected)
1143 .map(|r| {
1144 let mut server = r.candidate.config.clone();
1145 server.name = r.name.clone();
1146 server
1147 })
1148 .collect()
1149}
1150
1151#[cfg(test)]
1152pub(super) mod tests {
1153 use super::*;
1154 use crate::bundled::BUNDLED_AGENTS;
1155
1156 pub(in crate::commands::setup) fn test_wizard(agents_dir: &std::path::Path) -> Wizard {
1159 Wizard::new(
1160 Config::default(),
1161 &|_| None,
1162 Vec::new(),
1163 Vec::new(),
1164 agents_dir,
1165 std::sync::Arc::new(|_| true),
1166 )
1167 }
1168
1169 fn candidate(name: &str) -> Candidate {
1170 Candidate {
1171 config: MCPServerConfig::stdio(name, "npx", vec![]),
1172 scope: String::new(),
1173 inline_secrets: Vec::new(),
1174 }
1175 }
1176
1177 #[test]
1180 fn every_step_is_titled_and_ordered() {
1181 for (index, step) in Step::ALL.iter().enumerate() {
1182 assert!(!step.title().is_empty(), "{step:?} has no title");
1183 assert_eq!(step.index(), index);
1184 }
1185 }
1186
1187 #[test]
1190 fn a_fresh_install_starts_with_nothing_selected_and_every_agent_queued() {
1191 let dir = tempfile::tempdir().unwrap();
1192
1193 let wizard = test_wizard(dir.path());
1194
1195 assert_eq!(wizard.step, Step::Welcome);
1196 assert!(wizard.selected_providers().is_empty());
1197 assert_eq!(wizard.agents.len(), BUNDLED_AGENTS.len());
1198 assert!(
1199 wizard.agents.iter().all(|r| r.selected),
1200 "a fresh install should offer to install everything"
1201 );
1202 assert!(
1203 wizard
1204 .agents
1205 .iter()
1206 .all(|r| r.action == AgentAction::Install)
1207 );
1208 }
1209
1210 #[test]
1211 fn already_installed_agents_are_listed_but_not_reselected() {
1212 let dir = tempfile::tempdir().unwrap();
1213 for agent in BUNDLED_AGENTS {
1214 crate::bundled::install_bundled(agent, dir.path()).unwrap();
1215 }
1216
1217 let wizard = test_wizard(dir.path());
1218
1219 assert!(
1220 wizard.agents.iter().all(|r| !r.selected),
1221 "nothing needs doing, so nothing should be pre-checked"
1222 );
1223 }
1224
1225 #[test]
1226 fn a_configured_provider_starts_selected_with_its_credential() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let base = Config {
1229 providers: crate::config::ProviderConfig {
1230 anthropic_api_key: Some("sk-ant-stored".to_string()),
1231 ..Config::default().providers
1232 },
1233 ..Config::default()
1234 };
1235
1236 let wizard = Wizard::new(
1237 base,
1238 &|_| None,
1239 Vec::new(),
1240 Vec::new(),
1241 dir.path(),
1242 std::sync::Arc::new(|_| true),
1243 );
1244
1245 let row = wizard
1246 .providers
1247 .iter()
1248 .find(|r| r.provider.id == "anthropic")
1249 .expect("anthropic is in the table");
1250 assert!(row.selected);
1251 assert_eq!(row.value, "sk-ant-stored");
1252 assert!(row.from_env.is_none());
1253 }
1254
1255 #[test]
1256 fn a_key_that_lives_only_in_the_environment_is_shown_and_never_written() {
1257 let dir = tempfile::tempdir().unwrap();
1262
1263 let wizard = Wizard::new(
1264 Config::default(),
1265 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-from-env".to_string()),
1266 Vec::new(),
1267 Vec::new(),
1268 dir.path(),
1269 std::sync::Arc::new(|_| true),
1270 );
1271
1272 let row = wizard
1273 .providers
1274 .iter()
1275 .find(|r| r.provider.id == "anthropic")
1276 .expect("anthropic is in the table");
1277 assert!(row.selected, "the provider is usable, so it is selected");
1278 assert_eq!(row.from_env, Some("ANTHROPIC_API_KEY"));
1279 assert!(row.value.is_empty());
1280
1281 let written = wizard.build_config();
1282 assert!(
1283 written.providers.anthropic_api_key.is_none(),
1284 "an environment-supplied key must not be copied into the config"
1285 );
1286 }
1287
1288 #[test]
1289 fn a_stored_key_wins_over_the_environment() {
1290 let dir = tempfile::tempdir().unwrap();
1293 let base = Config {
1294 providers: crate::config::ProviderConfig {
1295 anthropic_api_key: Some("sk-ant-stored".to_string()),
1296 ..Config::default().providers
1297 },
1298 ..Config::default()
1299 };
1300
1301 let wizard = Wizard::new(
1302 base,
1303 &|_| Some("sk-ant-from-env".to_string()),
1304 Vec::new(),
1305 Vec::new(),
1306 dir.path(),
1307 std::sync::Arc::new(|_| true),
1308 );
1309
1310 let row = &wizard.providers[0];
1311 assert!(row.from_env.is_none());
1312 assert_eq!(row.value, "sk-ant-stored");
1313 }
1314
1315 #[test]
1316 fn an_empty_environment_variable_does_not_count_as_a_credential() {
1317 let dir = tempfile::tempdir().unwrap();
1318
1319 let wizard = Wizard::new(
1320 Config::default(),
1321 &|_| Some(String::new()),
1322 Vec::new(),
1323 Vec::new(),
1324 dir.path(),
1325 std::sync::Arc::new(|_| true),
1326 );
1327
1328 assert!(wizard.env_only.is_empty());
1329 assert!(wizard.selected_providers().is_empty());
1330 }
1331
1332 #[test]
1335 fn an_importable_server_is_preselected_and_named_as_found() {
1336 let dir = tempfile::tempdir().unwrap();
1337
1338 let wizard = Wizard::new(
1339 Config::default(),
1340 &|_| None,
1341 vec![("Claude Code".to_string(), candidate("fs"))],
1342 Vec::new(),
1343 dir.path(),
1344 std::sync::Arc::new(|_| true),
1345 );
1346
1347 assert_eq!(wizard.mcp.len(), 1);
1348 assert!(wizard.mcp[0].selected);
1349 assert!(!wizard.mcp[0].collides);
1350 assert_eq!(wizard.mcp[0].name, "fs");
1351 assert_eq!(wizard.mcp[0].source, "Claude Code");
1352 }
1353
1354 #[test]
1355 fn a_server_already_configured_is_offered_unchecked_under_a_free_name() {
1356 let dir = tempfile::tempdir().unwrap();
1359 let base = Config {
1360 mcp_servers: vec![MCPServerConfig::stdio("fs", "npx", vec![])],
1361 ..Config::default()
1362 };
1363
1364 let wizard = Wizard::new(
1365 base,
1366 &|_| None,
1367 vec![("Cursor".to_string(), candidate("fs"))],
1368 Vec::new(),
1369 dir.path(),
1370 std::sync::Arc::new(|_| true),
1371 );
1372
1373 assert!(!wizard.mcp[0].selected);
1374 assert!(wizard.mcp[0].collides);
1375 assert_eq!(wizard.mcp[0].name, "fs-2");
1376
1377 let mut wizard = wizard;
1380 wizard.mcp[0].selected = true;
1381 let config = wizard.build_config();
1382 let names: Vec<&str> = config.mcp_servers.iter().map(|s| s.name.as_str()).collect();
1383 assert_eq!(names, vec!["fs", "fs-2"]);
1384 }
1385
1386 #[test]
1387 fn selected_servers_renames_and_filters() {
1388 let dir = tempfile::tempdir().unwrap();
1389 let mut wizard = Wizard::new(
1390 Config::default(),
1391 &|_| None,
1392 vec![
1393 ("A".to_string(), candidate("keep")),
1394 ("B".to_string(), candidate("drop")),
1395 ],
1396 Vec::new(),
1397 dir.path(),
1398 std::sync::Arc::new(|_| true),
1399 );
1400 wizard.mcp[1].selected = false;
1401 wizard.mcp[0].name = "renamed".to_string();
1402
1403 let servers = selected_servers(&wizard.mcp);
1404
1405 assert_eq!(servers.len(), 1);
1406 assert_eq!(servers[0].name, "renamed");
1407 }
1408
1409 #[test]
1410 fn inline_secrets_are_reported_only_for_selected_rows() {
1411 let dir = tempfile::tempdir().unwrap();
1412 let mut secretive = candidate("leaky");
1413 secretive.inline_secrets = vec!["API_TOKEN".to_string()];
1414
1415 let mut wizard = Wizard::new(
1416 Config::default(),
1417 &|_| None,
1418 vec![
1419 ("A".to_string(), secretive),
1420 ("B".to_string(), candidate("clean")),
1421 ],
1422 Vec::new(),
1423 dir.path(),
1424 std::sync::Arc::new(|_| true),
1425 );
1426
1427 assert_eq!(wizard.selected_inline_secrets(), vec!["leaky: API_TOKEN"]);
1428 wizard.mcp[0].selected = false;
1429 assert!(wizard.selected_inline_secrets().is_empty());
1430 }
1431
1432 #[test]
1435 fn the_cursor_stays_inside_the_current_step() {
1436 let dir = tempfile::tempdir().unwrap();
1437 let mut wizard = test_wizard(dir.path());
1438 wizard.enter(Step::Providers);
1439
1440 wizard.move_cursor(-5);
1441 assert_eq!(wizard.cursor, 0);
1442 wizard.move_cursor(100);
1443 assert_eq!(
1444 wizard.cursor,
1445 wizard.providers.len(),
1446 "clamped to the Continue button after the last row"
1447 );
1448 assert!(wizard.on_continue());
1449 }
1450
1451 #[test]
1452 fn a_step_with_no_rows_pins_the_cursor_at_zero() {
1453 let dir = tempfile::tempdir().unwrap();
1454 let mut wizard = test_wizard(dir.path());
1455 wizard.enter(Step::Welcome);
1456 wizard.cursor = 4;
1457
1458 wizard.move_cursor(1);
1459
1460 assert_eq!(wizard.cursor, 0);
1461 assert_eq!(wizard.row_count(), 0);
1462 }
1463
1464 #[test]
1465 fn the_continue_label_names_where_it_goes() {
1466 let dir = tempfile::tempdir().unwrap();
1467 let mut wizard = test_wizard(dir.path());
1468
1469 wizard.enter(Step::Welcome);
1470 assert_eq!(wizard.continue_label(), "Get started");
1471
1472 wizard.enter(Step::Providers);
1473 assert_eq!(wizard.continue_label(), "Continue (no providers selected)");
1474 wizard.providers[0].selected = true;
1475 wizard.providers[1].selected = true;
1476 assert_eq!(
1477 wizard.continue_label(),
1478 "Continue: Credentials (2 selected)"
1479 );
1480
1481 wizard.enter(Step::ProviderDetail);
1483 assert_eq!(
1484 wizard.continue_label(),
1485 format!("Next: {}", wizard.providers[1].provider.display)
1486 );
1487 wizard.detail = 1;
1488 assert_eq!(wizard.continue_label(), "Continue: Defaults");
1489
1490 wizard.enter(Step::Limits);
1491 assert_eq!(wizard.continue_label(), "Continue: Agents");
1492
1493 wizard.enter(Step::Review);
1494 assert_eq!(wizard.continue_label(), "Apply and finish");
1495 }
1496
1497 #[test]
1498 fn next_step_title_past_the_last_step_falls_back_to_review() {
1499 let dir = tempfile::tempdir().unwrap();
1500 let mut wizard = test_wizard(dir.path());
1501 wizard.enter(Step::Review);
1502 assert_eq!(wizard.next_step_title(), "Review");
1503 }
1504
1505 #[test]
1506 fn empty_discovery_steps_are_skipped_in_both_directions() {
1507 let dir = tempfile::tempdir().unwrap();
1511 let mut wizard = test_wizard(dir.path());
1512 assert!(wizard.mcp.is_empty());
1513
1514 wizard.enter(Step::Providers);
1515 wizard.next_step();
1516 assert_eq!(wizard.step, Step::Defaults, "credentials screen was empty");
1517
1518 wizard.enter(Step::Agents);
1519 wizard.next_step();
1520 assert_eq!(wizard.step, Step::Review, "MCP screen was empty");
1521
1522 wizard.prev_step();
1523 assert_eq!(wizard.step, Step::Agents);
1524 }
1525
1526 #[test]
1527 fn a_nonempty_discovery_step_is_visited() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let mut wizard = Wizard::new(
1530 Config::default(),
1531 &|_| None,
1532 vec![("A".to_string(), candidate("fs"))],
1533 Vec::new(),
1534 dir.path(),
1535 std::sync::Arc::new(|_| true),
1536 );
1537
1538 wizard.enter(Step::Agents);
1539 wizard.next_step();
1540
1541 assert_eq!(wizard.step, Step::Mcp);
1542 }
1543
1544 #[test]
1545 fn a_scan_error_alone_is_enough_to_show_the_mcp_step() {
1546 let dir = tempfile::tempdir().unwrap();
1549 let mut wizard = Wizard::new(
1550 Config::default(),
1551 &|_| None,
1552 Vec::new(),
1553 vec!["Zed: unreadable".to_string()],
1554 dir.path(),
1555 std::sync::Arc::new(|_| true),
1556 );
1557
1558 wizard.enter(Step::Agents);
1559 wizard.next_step();
1560
1561 assert_eq!(wizard.step, Step::Mcp);
1562 }
1563
1564 #[test]
1565 fn the_first_step_has_nowhere_to_go_back_to() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let mut wizard = test_wizard(dir.path());
1568
1569 wizard.prev_step();
1570
1571 assert_eq!(wizard.step, Step::Welcome);
1572 }
1573
1574 #[test]
1575 fn advancing_past_the_last_step_stays_on_review() {
1576 let dir = tempfile::tempdir().unwrap();
1577 let mut wizard = test_wizard(dir.path());
1578 wizard.enter(Step::Review);
1579
1580 wizard.next_step();
1581
1582 assert_eq!(wizard.step, Step::Review);
1583 }
1584
1585 #[test]
1586 fn the_credential_screen_walks_the_selected_providers() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let mut wizard = test_wizard(dir.path());
1589 wizard.providers[0].selected = true;
1590 wizard.providers[1].selected = true;
1591
1592 assert_eq!(wizard.detail_row(), Some(0));
1593 assert!(wizard.next_detail());
1594 assert_eq!(wizard.detail_row(), Some(1));
1595 assert!(!wizard.next_detail(), "there is no third provider");
1596 assert!(wizard.prev_detail());
1597 assert_eq!(wizard.detail_row(), Some(0));
1598 assert!(!wizard.prev_detail());
1599 }
1600
1601 #[test]
1602 fn the_credential_screen_has_no_row_when_nothing_is_selected() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let mut wizard = test_wizard(dir.path());
1605 wizard.enter(Step::ProviderDetail);
1606
1607 assert!(wizard.detail_row().is_none());
1608 assert_eq!(wizard.row_count(), 0);
1609 }
1610
1611 #[tokio::test]
1614 async fn verification_is_requested_for_a_provider_with_a_credential() {
1615 let dir = tempfile::tempdir().unwrap();
1616 let mut wizard = test_wizard(dir.path());
1617 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1618 wizard.providers[0].selected = true;
1619 wizard.providers[0].value = "sk-ant-x".to_string();
1620
1621 wizard.request_verification(0);
1622
1623 assert!(wizard.providers[0].checking);
1624 let request = requests.try_recv().expect("a request was queued");
1625 assert_eq!(request.provider_id, "anthropic");
1626 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-x"));
1627 }
1628
1629 #[tokio::test]
1630 async fn a_blank_api_key_is_not_queued_for_checking() {
1631 let dir = tempfile::tempdir().unwrap();
1633 let mut wizard = test_wizard(dir.path());
1634 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1635 wizard.providers[0].selected = true;
1636
1637 wizard.request_verification(0);
1638
1639 assert!(!wizard.providers[0].checking);
1640 assert_eq!(wizard.providers[0].outcome, Outcome::Skipped);
1641 assert!(requests.try_recv().is_err());
1642 }
1643
1644 #[tokio::test]
1645 async fn an_environment_supplied_key_is_what_gets_checked() {
1646 let dir = tempfile::tempdir().unwrap();
1647 let mut wizard = Wizard::new(
1648 Config::default(),
1649 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1650 Vec::new(),
1651 Vec::new(),
1652 dir.path(),
1653 std::sync::Arc::new(|_| true),
1654 );
1655 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1656
1657 wizard.request_verification(0);
1658
1659 let request = requests.try_recv().expect("a request was queued");
1660 assert_eq!(request.creds.api_key.as_deref(), Some("sk-ant-env"));
1661 }
1662
1663 #[tokio::test]
1664 async fn ollama_is_checked_by_url_with_no_key() {
1665 let dir = tempfile::tempdir().unwrap();
1666 let mut wizard = test_wizard(dir.path());
1667 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1668 let index = wizard
1669 .providers
1670 .iter()
1671 .position(|r| r.provider.id == "ollama")
1672 .expect("ollama is offered");
1673
1674 wizard.request_verification(index);
1675 let request = requests.try_recv().expect("a request was queued");
1676 assert!(request.creds.api_key.is_none());
1677 assert_eq!(
1678 request.creds.base_url.as_deref(),
1679 Some(catalog::DEFAULT_OLLAMA_URL),
1680 "an empty field means the default endpoint"
1681 );
1682
1683 wizard.providers[index].value = "http://box:11434".to_string();
1684 wizard.request_verification(index);
1685 let request = requests.try_recv().expect("a second request was queued");
1686 assert_eq!(request.creds.base_url.as_deref(), Some("http://box:11434"));
1687 }
1688
1689 #[tokio::test]
1690 async fn verify_all_covers_every_selected_provider() {
1691 let dir = tempfile::tempdir().unwrap();
1692 let mut wizard = test_wizard(dir.path());
1693 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1694 wizard.providers[0].selected = true;
1695 wizard.providers[0].value = "sk-ant".to_string();
1696 let ollama = wizard
1697 .providers
1698 .iter()
1699 .position(|r| r.provider.id == "ollama")
1700 .expect("ollama is offered");
1701 wizard.providers[ollama].selected = true;
1702
1703 wizard.verify_all();
1704
1705 let mut seen = Vec::new();
1706 while let Ok(request) = requests.try_recv() {
1707 seen.push(request.provider_id);
1708 }
1709 assert_eq!(seen, vec!["anthropic", "ollama"]);
1710 }
1711
1712 #[tokio::test]
1713 async fn an_out_of_range_verification_request_is_a_no_op() {
1714 let dir = tempfile::tempdir().unwrap();
1715 let mut wizard = test_wizard(dir.path());
1716 let (mut requests, _replies) = wizard.take_verify_ends().expect("first take");
1717
1718 wizard.request_verification(999);
1719
1720 assert!(requests.try_recv().is_err());
1721 }
1722
1723 #[tokio::test]
1724 async fn replies_land_on_the_right_provider_and_feed_the_model_picker() {
1725 let dir = tempfile::tempdir().unwrap();
1726 let mut wizard = test_wizard(dir.path());
1727 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1728 wizard.providers[0].selected = true;
1729 wizard.providers[0].checking = true;
1730
1731 replies
1732 .send(VerifyReply {
1733 provider_id: "anthropic".to_string(),
1734 outcome: Outcome::Reachable {
1735 models: vec!["claude-opus-5".to_string()],
1736 },
1737 })
1738 .unwrap();
1739 replies
1741 .send(VerifyReply {
1742 provider_id: "not-a-provider".to_string(),
1743 outcome: Outcome::Skipped,
1744 })
1745 .unwrap();
1746 wizard.drain_verifications();
1747
1748 assert!(!wizard.providers[0].checking);
1749 assert_eq!(wizard.discovered_models(), vec!["claude-opus-5"]);
1750 }
1751
1752 #[tokio::test]
1753 async fn a_late_reply_refills_the_model_picker() {
1754 let dir = tempfile::tempdir().unwrap();
1759 let mut wizard = test_wizard(dir.path());
1760 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1761 wizard.providers[0].selected = true;
1762 wizard.enter(Step::Defaults);
1763 assert_eq!(
1764 wizard.defaults[1].value.options(),
1765 ["(provider default)".to_string()],
1766 "nothing has been reported yet"
1767 );
1768
1769 replies
1770 .send(VerifyReply {
1771 provider_id: "anthropic".to_string(),
1772 outcome: Outcome::Reachable {
1773 models: vec!["claude-opus-5".to_string()],
1774 },
1775 })
1776 .unwrap();
1777 wizard.drain_verifications();
1778
1779 assert!(
1780 wizard.defaults[1]
1781 .value
1782 .options()
1783 .contains(&"claude-opus-5".to_string()),
1784 "the picker should have refilled"
1785 );
1786 }
1787
1788 #[tokio::test]
1789 async fn a_late_reply_does_not_disturb_another_screen() {
1790 let dir = tempfile::tempdir().unwrap();
1791 let mut wizard = test_wizard(dir.path());
1792 let (_requests, replies) = wizard.take_verify_ends().expect("first take");
1793 wizard.providers[0].selected = true;
1794 wizard.enter(Step::Limits);
1795 wizard.limits[0].value = FieldValue::Number(Some(3));
1796
1797 replies
1798 .send(VerifyReply {
1799 provider_id: "anthropic".to_string(),
1800 outcome: Outcome::Reachable {
1801 models: vec!["m".to_string()],
1802 },
1803 })
1804 .unwrap();
1805 wizard.drain_verifications();
1806
1807 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
1808 }
1809
1810 #[test]
1811 fn the_verification_channel_ends_can_only_be_taken_once() {
1812 let dir = tempfile::tempdir().unwrap();
1813 let mut wizard = test_wizard(dir.path());
1814
1815 assert!(wizard.take_verify_ends().is_some());
1816 assert!(wizard.take_verify_ends().is_none());
1817 }
1818
1819 #[test]
1820 fn models_from_unselected_providers_are_not_offered() {
1821 let dir = tempfile::tempdir().unwrap();
1822 let mut wizard = test_wizard(dir.path());
1823 wizard.providers[0].outcome = Outcome::Reachable {
1824 models: vec!["hidden".to_string()],
1825 };
1826
1827 assert!(wizard.discovered_models().is_empty());
1828 }
1829
1830 #[test]
1833 fn the_provider_choice_is_a_radio_over_what_was_actually_selected() {
1834 let dir = tempfile::tempdir().unwrap();
1837 let mut wizard = test_wizard(dir.path());
1838 let ollama = wizard
1839 .providers
1840 .iter()
1841 .position(|r| r.provider.id == "ollama")
1842 .expect("ollama is offered");
1843 wizard.providers[ollama].selected = true;
1844 wizard.enter(Step::Defaults);
1845
1846 assert_eq!(wizard.defaults[0].value.options(), ["ollama".to_string()]);
1847 }
1848
1849 #[test]
1850 fn the_provider_choice_falls_back_to_the_configured_one_when_nothing_is_picked() {
1851 let dir = tempfile::tempdir().unwrap();
1852 let mut wizard = test_wizard(dir.path());
1853 wizard.enter(Step::Defaults);
1854
1855 assert_eq!(
1856 wizard.defaults[0].value.display(),
1857 Config::default().default_provider
1858 );
1859 }
1860
1861 #[test]
1862 fn the_model_picker_is_filled_from_verification_and_keeps_a_stored_value() {
1863 let dir = tempfile::tempdir().unwrap();
1864 let base = Config {
1865 default_model: Some("hand-typed".to_string()),
1866 ..Config::default()
1867 };
1868 let mut wizard = Wizard::new(
1869 base,
1870 &|_| None,
1871 Vec::new(),
1872 Vec::new(),
1873 dir.path(),
1874 std::sync::Arc::new(|_| true),
1875 );
1876 wizard.providers[0].selected = true;
1877 wizard.providers[0].outcome = Outcome::Reachable {
1878 models: vec!["claude-opus-5".to_string()],
1879 };
1880
1881 wizard.enter(Step::Defaults);
1882
1883 let options = wizard.defaults[1].value.options();
1884 assert!(options.contains(&"(provider default)".to_string()));
1885 assert!(options.contains(&"claude-opus-5".to_string()));
1886 assert_eq!(
1887 wizard.defaults[1].value.display(),
1888 "hand-typed",
1889 "a model already in the config must survive"
1890 );
1891 }
1892
1893 #[test]
1894 fn only_a_choice_field_has_options() {
1895 assert_eq!(
1896 FieldValue::Choice {
1897 options: vec!["a".into()],
1898 index: 0
1899 }
1900 .options(),
1901 ["a".to_string()]
1902 );
1903 assert!(FieldValue::Number(Some(1)).options().is_empty());
1904 assert!(FieldValue::Bool(true).options().is_empty());
1905 }
1906
1907 #[test]
1908 fn field_values_read_naturally() {
1909 assert_eq!(FieldValue::Number(None).display(), "(unset)");
1910 assert_eq!(FieldValue::Number(Some(7)).display(), "7");
1911 assert_eq!(FieldValue::Bool(true).display(), "yes");
1912 assert_eq!(FieldValue::Bool(false).display(), "no");
1913 assert_eq!(
1914 FieldValue::Choice {
1915 options: vec!["a".into()],
1916 index: 0
1917 }
1918 .display(),
1919 "a"
1920 );
1921 assert_eq!(
1922 FieldValue::Choice {
1923 options: vec![],
1924 index: 0
1925 }
1926 .display(),
1927 "(none)"
1928 );
1929 }
1930
1931 #[test]
1932 fn picking_ollama_drops_the_concurrency_default_to_one() {
1933 let dir = tempfile::tempdir().unwrap();
1936 let mut wizard = test_wizard(dir.path());
1937 let ollama = wizard
1938 .providers
1939 .iter()
1940 .position(|r| r.provider.id == "ollama")
1941 .expect("ollama is offered");
1942 wizard.providers[ollama].selected = true;
1943 wizard.enter(Step::Defaults);
1944
1945 wizard.apply_provider_concurrency_default();
1946
1947 assert_eq!(
1948 wizard.limits[0].value,
1949 FieldValue::Number(Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64))
1950 );
1951 }
1952
1953 #[test]
1954 fn ollama_as_the_only_provider_still_lowers_the_concurrency_limit() {
1955 let dir = tempfile::tempdir().unwrap();
1960 let mut wizard = test_wizard(dir.path());
1961 let ollama = wizard
1962 .providers
1963 .iter()
1964 .position(|r| r.provider.id == "ollama")
1965 .expect("ollama is offered");
1966 wizard.providers[ollama].selected = true;
1967
1968 wizard.enter(Step::Defaults);
1969
1970 assert_eq!(wizard.defaults[0].value.display(), "ollama");
1971 assert_eq!(
1972 wizard.build_config().limits.max_concurrent_inferences,
1973 Some(catalog::OLLAMA_MAX_CONCURRENT_INFERENCES)
1974 );
1975 }
1976
1977 #[test]
1978 fn switching_back_off_ollama_restores_the_general_default() {
1979 let dir = tempfile::tempdir().unwrap();
1980 let mut wizard = test_wizard(dir.path());
1981 let ollama = wizard
1982 .providers
1983 .iter()
1984 .position(|r| r.provider.id == "ollama")
1985 .expect("ollama is offered");
1986 wizard.providers[ollama].selected = true;
1987 wizard.enter(Step::Defaults);
1988 wizard.apply_provider_concurrency_default();
1989
1990 wizard.providers[ollama].selected = false;
1991 wizard.providers[0].selected = true;
1992 wizard.rebuild_defaults();
1993 wizard.apply_provider_concurrency_default();
1994
1995 assert_eq!(
1996 wizard.limits[0].value,
1997 FieldValue::Number(
1998 Config::default()
1999 .limits
2000 .max_concurrent_inferences
2001 .map(|n| n as u64)
2002 )
2003 );
2004 }
2005
2006 #[test]
2007 fn a_hand_typed_concurrency_is_never_overwritten() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let mut wizard = test_wizard(dir.path());
2010 let ollama = wizard
2011 .providers
2012 .iter()
2013 .position(|r| r.provider.id == "ollama")
2014 .expect("ollama is offered");
2015 wizard.providers[ollama].selected = true;
2016 wizard.enter(Step::Defaults);
2017 wizard.limits[0].value = FieldValue::Number(Some(3));
2018
2019 wizard.apply_provider_concurrency_default();
2020
2021 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(3)));
2022 }
2023
2024 #[test]
2027 fn committing_a_credential_clears_its_stale_verification() {
2028 let dir = tempfile::tempdir().unwrap();
2029 let mut wizard = test_wizard(dir.path());
2030 wizard.providers[0].outcome = Outcome::Reachable {
2031 models: vec!["m".into()],
2032 };
2033 wizard.edit = Some(Edit {
2034 target: EditTarget::Credential(0),
2035 line: crate::tui::widgets::line_edit::LineEdit::new(" sk-ant-new ".to_string(), true),
2036 });
2037
2038 wizard.commit_edit();
2039
2040 assert_eq!(wizard.providers[0].value, "sk-ant-new");
2041 assert_eq!(
2042 wizard.providers[0].outcome,
2043 Outcome::Skipped,
2044 "the old result was for a different key"
2045 );
2046 assert!(wizard.edit.is_none());
2047 }
2048
2049 #[test]
2050 fn typing_a_credential_supersedes_the_environments() {
2051 let dir = tempfile::tempdir().unwrap();
2052 let mut wizard = Wizard::new(
2053 Config::default(),
2054 &|_| Some("sk-ant-env".to_string()),
2055 Vec::new(),
2056 Vec::new(),
2057 dir.path(),
2058 std::sync::Arc::new(|_| true),
2059 );
2060 assert!(wizard.providers[0].from_env.is_some());
2061 wizard.edit = Some(Edit {
2062 target: EditTarget::Credential(0),
2063 line: crate::tui::widgets::line_edit::LineEdit::new("sk-ant-typed".to_string(), true),
2064 });
2065
2066 wizard.commit_edit();
2067
2068 assert!(wizard.providers[0].from_env.is_none());
2069 assert_eq!(
2070 wizard.build_config().providers.anthropic_api_key.as_deref(),
2071 Some("sk-ant-typed")
2072 );
2073 }
2074
2075 #[test]
2076 fn committing_numbers_handles_blank_and_unparseable_input() {
2077 let dir = tempfile::tempdir().unwrap();
2078 let mut wizard = test_wizard(dir.path());
2079 wizard.enter(Step::Limits);
2080
2081 wizard.edit = Some(Edit {
2082 target: EditTarget::Field(0),
2083 line: crate::tui::widgets::line_edit::LineEdit::new("16".to_string(), false),
2084 });
2085 wizard.commit_edit();
2086 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2087
2088 wizard.edit = Some(Edit {
2090 target: EditTarget::Field(0),
2091 line: crate::tui::widgets::line_edit::LineEdit::new("not a number".to_string(), false),
2092 });
2093 wizard.commit_edit();
2094 assert_eq!(wizard.limits[0].value, FieldValue::Number(Some(16)));
2095
2096 wizard.edit = Some(Edit {
2098 target: EditTarget::Field(0),
2099 line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), false),
2100 });
2101 wizard.commit_edit();
2102 assert_eq!(wizard.limits[0].value, FieldValue::Number(None));
2103 }
2104
2105 #[test]
2106 fn committing_with_nothing_open_or_out_of_range_is_a_no_op() {
2107 let dir = tempfile::tempdir().unwrap();
2108 let mut wizard = test_wizard(dir.path());
2109 wizard.commit_edit();
2110
2111 wizard.edit = Some(Edit {
2112 target: EditTarget::Credential(999),
2113 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2114 });
2115 wizard.commit_edit();
2116
2117 wizard.enter(Step::Limits);
2118 wizard.edit = Some(Edit {
2119 target: EditTarget::Field(999),
2120 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2121 });
2122 wizard.commit_edit();
2123
2124 wizard.enter(Step::Welcome);
2126 wizard.edit = Some(Edit {
2127 target: EditTarget::Field(0),
2128 line: crate::tui::widgets::line_edit::LineEdit::new("x".to_string(), false),
2129 });
2130 wizard.commit_edit();
2131
2132 assert!(wizard.edit.is_none());
2133 }
2134
2135 #[test]
2136 fn every_step_reports_a_sensible_row_count() {
2137 let dir = tempfile::tempdir().unwrap();
2138 let mut wizard = Wizard::new(
2139 Config::default(),
2140 &|_| None,
2141 vec![("A".to_string(), candidate("fs"))],
2142 Vec::new(),
2143 dir.path(),
2144 std::sync::Arc::new(|_| true),
2145 );
2146 wizard.providers[0].selected = true;
2147
2148 for step in Step::ALL {
2149 wizard.enter(step);
2150 let rows = wizard.row_count();
2151 match step {
2152 Step::Welcome | Step::Review => assert_eq!(rows, 0, "{step:?}"),
2153 _ => assert!(rows > 0, "{step:?} has no rows"),
2154 }
2155 let fields = wizard.fields().len();
2157 match step {
2158 Step::Defaults | Step::Limits => assert_eq!(fields, rows, "{step:?}"),
2159 _ => assert_eq!(fields, 0, "{step:?} should have no fields"),
2160 }
2161 }
2162 }
2163
2164 #[test]
2165 fn committing_onto_a_defaults_field_reaches_that_form_too() {
2166 let dir = tempfile::tempdir().unwrap();
2167 let mut wizard = test_wizard(dir.path());
2168 wizard.providers[0].selected = true;
2169 wizard.enter(Step::Defaults);
2170
2171 wizard.edit = Some(Edit {
2172 target: EditTarget::Field(2),
2173 line: crate::tui::widgets::line_edit::LineEdit::new("45".to_string(), false),
2174 });
2175 wizard.commit_edit();
2176
2177 assert_eq!(wizard.defaults[2].value, FieldValue::Number(Some(45)));
2178 assert_eq!(wizard.build_config().request_timeout_secs, Some(45));
2179 }
2180
2181 #[test]
2182 fn clearing_a_credential_leaves_the_environment_marker_alone() {
2183 let dir = tempfile::tempdir().unwrap();
2185 let mut wizard = Wizard::new(
2186 Config::default(),
2187 &|_| Some("sk-ant-env".to_string()),
2188 Vec::new(),
2189 Vec::new(),
2190 dir.path(),
2191 std::sync::Arc::new(|_| true),
2192 );
2193 wizard.edit = Some(Edit {
2194 target: EditTarget::Credential(0),
2195 line: crate::tui::widgets::line_edit::LineEdit::new(" ".to_string(), true),
2196 });
2197
2198 wizard.commit_edit();
2199
2200 assert!(wizard.providers[0].value.is_empty());
2201 assert_eq!(wizard.providers[0].from_env, Some("ANTHROPIC_API_KEY"));
2202 }
2203
2204 #[test]
2205 fn a_defaults_form_with_no_choice_field_falls_back_to_the_base_config() {
2206 let dir = tempfile::tempdir().unwrap();
2208 let mut wizard = test_wizard(dir.path());
2209 wizard.defaults[0].value = FieldValue::Bool(true);
2210
2211 assert_eq!(
2212 wizard.build_config().default_provider,
2213 Config::default().default_provider
2214 );
2215 }
2216
2217 #[test]
2218 fn an_empty_choice_list_falls_back_to_the_base_config() {
2219 let dir = tempfile::tempdir().unwrap();
2220 let mut wizard = test_wizard(dir.path());
2221 wizard.defaults[0].value = FieldValue::Choice {
2222 options: Vec::new(),
2223 index: 0,
2224 };
2225
2226 assert_eq!(
2227 wizard.build_config().default_provider,
2228 Config::default().default_provider
2229 );
2230 }
2231
2232 #[test]
2233 fn the_concurrency_default_is_left_alone_when_the_form_is_not_a_number() {
2234 let dir = tempfile::tempdir().unwrap();
2235 let mut wizard = test_wizard(dir.path());
2236 wizard.limits[0].value = FieldValue::Bool(true);
2237
2238 wizard.apply_provider_concurrency_default();
2239
2240 assert_eq!(wizard.limits[0].value, FieldValue::Bool(true));
2241 }
2242
2243 #[test]
2244 fn a_text_buffer_committed_onto_a_toggle_leaves_it_alone() {
2245 let dir = tempfile::tempdir().unwrap();
2246 let mut wizard = test_wizard(dir.path());
2247 wizard.enter(Step::Limits);
2248 let before = wizard.limits[3].value.clone();
2249
2250 wizard.edit = Some(Edit {
2251 target: EditTarget::Field(3),
2252 line: crate::tui::widgets::line_edit::LineEdit::new("yes".to_string(), false),
2253 });
2254 wizard.commit_edit();
2255
2256 assert_eq!(wizard.limits[3].value, before);
2257 }
2258
2259 #[test]
2262 fn deselecting_a_provider_clears_its_credential() {
2263 let dir = tempfile::tempdir().unwrap();
2264 let base = Config {
2265 providers: crate::config::ProviderConfig {
2266 anthropic_api_key: Some("sk-ant-stored".to_string()),
2267 ..Config::default().providers
2268 },
2269 ..Config::default()
2270 };
2271 let mut wizard = Wizard::new(
2272 base,
2273 &|_| None,
2274 Vec::new(),
2275 Vec::new(),
2276 dir.path(),
2277 std::sync::Arc::new(|_| true),
2278 );
2279
2280 wizard.providers[0].selected = false;
2281
2282 assert!(wizard.build_config().providers.anthropic_api_key.is_none());
2283 }
2284
2285 #[test]
2286 fn ollamas_default_url_is_left_unset_rather_than_pinned() {
2287 let dir = tempfile::tempdir().unwrap();
2289 let mut wizard = test_wizard(dir.path());
2290 let ollama = wizard
2291 .providers
2292 .iter()
2293 .position(|r| r.provider.id == "ollama")
2294 .expect("ollama is offered");
2295 wizard.providers[ollama].selected = true;
2296 wizard.providers[ollama].value = catalog::DEFAULT_OLLAMA_URL.to_string();
2297
2298 assert!(wizard.build_config().ollama_base_url.is_none());
2299
2300 wizard.providers[ollama].value = "http://box:11434".to_string();
2301 assert_eq!(
2302 wizard.build_config().ollama_base_url.as_deref(),
2303 Some("http://box:11434")
2304 );
2305 }
2306
2307 #[test]
2308 fn the_claude_code_transport_carries_its_effort_only_when_enabled() {
2309 let dir = tempfile::tempdir().unwrap();
2310 let mut wizard = test_wizard(dir.path());
2311 let index = wizard
2312 .providers
2313 .iter()
2314 .position(|r| r.provider.id == "claude-code")
2315 .expect("the transport is offered");
2316
2317 assert!(!wizard.build_config().providers.claude_code_enabled);
2318
2319 wizard.providers[index].selected = true;
2320 wizard.providers[index].effort = effort_options().len() - 1;
2321 let config = wizard.build_config();
2322 assert!(config.providers.claude_code_enabled);
2323 assert_eq!(
2324 config.providers.claude_code_effort.as_deref(),
2325 Some(*effort_options().last().expect("levels exist"))
2326 );
2327 }
2328
2329 #[test]
2330 fn an_out_of_range_effort_index_clamps_rather_than_panicking() {
2331 let dir = tempfile::tempdir().unwrap();
2332 let mut wizard = test_wizard(dir.path());
2333 let index = wizard
2334 .providers
2335 .iter()
2336 .position(|r| r.provider.id == "claude-code")
2337 .expect("the transport is offered");
2338 wizard.providers[index].selected = true;
2339 wizard.providers[index].effort = 99;
2340
2341 let config = wizard.build_config();
2342
2343 assert_eq!(
2344 config.providers.claude_code_effort.as_deref(),
2345 Some(*effort_options().last().expect("levels exist"))
2346 );
2347 }
2348
2349 #[test]
2350 fn the_stored_effort_selects_the_matching_option() {
2351 let dir = tempfile::tempdir().unwrap();
2352 let base = Config {
2353 providers: crate::config::ProviderConfig {
2354 claude_code_effort: Some("max".to_string()),
2355 ..Config::default().providers
2356 },
2357 ..Config::default()
2358 };
2359 let wizard = Wizard::new(
2360 base,
2361 &|_| None,
2362 Vec::new(),
2363 Vec::new(),
2364 dir.path(),
2365 std::sync::Arc::new(|_| true),
2366 );
2367
2368 let index = wizard
2369 .providers
2370 .iter()
2371 .position(|r| r.provider.id == "claude-code")
2372 .expect("the transport is offered");
2373 assert_eq!(effort_options()[wizard.providers[index].effort], "max");
2374 }
2375
2376 #[test]
2377 fn an_unrecognised_stored_effort_falls_back_to_the_first_level() {
2378 assert_eq!(effort_index(Some("not-a-level")), 0);
2379 assert_eq!(
2380 effort_options()[effort_index(None)],
2381 leviath_providers::claude_code::DEFAULT_EFFORT
2382 );
2383 }
2384
2385 #[test]
2386 fn the_provider_default_model_is_stored_as_unset() {
2387 let dir = tempfile::tempdir().unwrap();
2388 let mut wizard = test_wizard(dir.path());
2389 wizard.providers[0].selected = true;
2390 wizard.enter(Step::Defaults);
2391
2392 assert!(wizard.build_config().default_model.is_none());
2394 }
2395
2396 #[test]
2397 fn limits_are_written_back_including_the_zero_guard() {
2398 let dir = tempfile::tempdir().unwrap();
2401 let mut wizard = test_wizard(dir.path());
2402 wizard.enter(Step::Limits);
2403 wizard.limits[0].value = FieldValue::Number(Some(2));
2404 wizard.limits[1].value = FieldValue::Number(Some(0));
2405 wizard.limits[2].value = FieldValue::Number(None);
2406 wizard.limits[3].value = FieldValue::Bool(true);
2407 wizard.limits[4].value = FieldValue::Bool(false);
2408 wizard.limits[5].value = FieldValue::Bool(false);
2409 wizard.limits[6].value = FieldValue::Number(Some(11));
2410 wizard.limits[7].value = FieldValue::Number(Some(22));
2411 wizard.limits[8].value = FieldValue::Number(Some(33));
2412 wizard.limits[9].value = FieldValue::Number(Some(44));
2413
2414 let config = wizard.build_config();
2415
2416 assert_eq!(config.limits.max_concurrent_inferences, Some(2));
2417 assert_eq!(
2418 config.limits.max_concurrent_tools,
2419 Config::default().limits.max_concurrent_tools
2420 );
2421 assert!(config.limits.default_max_iterations.is_none());
2422 assert!(config.limits.exact_token_counting);
2423 assert!(!config.batch_tool_hint);
2424 assert!(!config.shell_hint);
2425 assert_eq!(config.limits.stall_timeout_secs, 11);
2429 assert_eq!(config.limits.dead_cycles_before_relief, 22);
2430 assert_eq!(config.limits.finished_retention_secs, 33);
2431 assert_eq!(config.limits.wedge_timeout_secs, 44);
2432 }
2433
2434 #[test]
2435 fn every_limits_field_is_written_back() {
2436 let dir = tempfile::tempdir().unwrap();
2440 let mut wizard = test_wizard(dir.path());
2441 wizard.enter(Step::Limits);
2442 let count = wizard.limits.len();
2443
2444 let before = wizard.build_config();
2445 let seeded = limits_fields(&before);
2446 assert_eq!(seeded.len(), count, "the form is built from the config");
2447
2448 for (i, field) in wizard.limits.iter_mut().enumerate() {
2454 field.value = match &field.value {
2455 FieldValue::Bool(b) => FieldValue::Bool(!b),
2456 _ => FieldValue::Number(Some(i as u64 + 11)),
2457 };
2458 }
2459 let expected: Vec<FieldValue> = wizard.limits.iter().map(|f| f.value.clone()).collect();
2460 let after = limits_fields(&wizard.build_config());
2461
2462 for (i, (got, want)) in after.iter().zip(&expected).enumerate() {
2463 assert_eq!(
2464 &got.value, want,
2465 "field {i} ({}) did not survive the round trip",
2466 got.label
2467 );
2468 }
2469 }
2470
2471 #[test]
2475 fn the_watchdog_limits_store_zero_and_keep_the_default_when_blank() {
2476 let dir = tempfile::tempdir().unwrap();
2477 let mut wizard = test_wizard(dir.path());
2478 wizard.enter(Step::Limits);
2479 wizard.limits[6].value = FieldValue::Number(Some(0));
2480 wizard.limits[7].value = FieldValue::Number(Some(0));
2481 wizard.limits[8].value = FieldValue::Number(Some(0));
2482 wizard.limits[9].value = FieldValue::Number(Some(300));
2483
2484 let config = wizard.build_config();
2485
2486 assert_eq!(config.limits.stall_timeout_secs, 0);
2487 assert_eq!(config.limits.dead_cycles_before_relief, 0);
2488 assert_eq!(config.limits.finished_retention_secs, 0);
2489 assert_eq!(config.limits.wedge_timeout_secs, 300);
2490
2491 let mut wizard = test_wizard(dir.path());
2492 wizard.enter(Step::Limits);
2493 wizard.limits[6].value = FieldValue::Number(None);
2494 wizard.limits[7].value = FieldValue::Number(None);
2495 wizard.limits[8].value = FieldValue::Number(None);
2496 wizard.limits[9].value = FieldValue::Number(None);
2497
2498 let config = wizard.build_config();
2499
2500 let default = Config::default();
2501 assert_eq!(
2502 config.limits.stall_timeout_secs,
2503 default.limits.stall_timeout_secs
2504 );
2505 assert_eq!(
2506 config.limits.dead_cycles_before_relief,
2507 default.limits.dead_cycles_before_relief
2508 );
2509 assert_eq!(
2510 config.limits.finished_retention_secs,
2511 default.limits.finished_retention_secs
2512 );
2513 assert_eq!(
2514 config.limits.wedge_timeout_secs,
2515 default.limits.wedge_timeout_secs
2516 );
2517 }
2518
2519 #[test]
2520 fn a_field_of_the_wrong_kind_is_ignored_when_writing_limits() {
2521 let mut config = Config::default();
2524 apply_limits_fields(
2525 &mut config,
2526 &[Field {
2527 label: "Max concurrent inferences",
2528 help: "",
2529 value: FieldValue::Bool(true),
2530 }],
2531 );
2532
2533 assert_eq!(
2534 config.limits.max_concurrent_inferences,
2535 Config::default().limits.max_concurrent_inferences
2536 );
2537 }
2538
2539 #[test]
2540 fn the_plan_carries_only_the_selected_agents() {
2541 let dir = tempfile::tempdir().unwrap();
2542 let mut wizard = test_wizard(dir.path());
2543 for row in wizard.agents.iter_mut().skip(1) {
2544 row.selected = false;
2545 }
2546
2547 let plan = wizard.build_plan();
2548
2549 assert_eq!(plan.agents.len(), 1);
2550 assert_eq!(plan.agents[0].name, BUNDLED_AGENTS[0].name);
2551 }
2552
2553 #[test]
2554 fn the_review_says_so_when_nothing_would_change() {
2555 let dir = tempfile::tempdir().unwrap();
2556 let mut wizard = test_wizard(dir.path());
2557 for row in wizard.agents.iter_mut() {
2558 row.selected = false;
2559 }
2560
2561 assert_eq!(wizard.review_lines(), vec!["Nothing would change."]);
2562 }
2563
2564 #[test]
2565 fn the_review_lists_real_changes() {
2566 let dir = tempfile::tempdir().unwrap();
2567 let mut wizard = test_wizard(dir.path());
2568 wizard.providers[0].selected = true;
2569 wizard.providers[0].value = "sk-ant-x".to_string();
2570
2571 let lines = wizard.review_lines();
2572
2573 assert!(lines.iter().any(|l| l.contains("credential set")));
2574 assert!(lines.iter().any(|l| l.contains("to install")));
2575 }
2576
2577 #[test]
2580 fn scans_flatten_into_candidates_and_labelled_errors() {
2581 let scans = vec![
2582 import::Scan {
2583 source: import::Source {
2584 id: "a",
2585 display: "Harness A",
2586 path: std::path::PathBuf::from("/a"),
2587 layout: import::Layout::ClaudeCode,
2588 allows_comments: false,
2589 },
2590 result: Ok(vec![candidate("fs")]),
2591 },
2592 import::Scan {
2593 source: import::Source {
2594 id: "b",
2595 display: "Harness B",
2596 path: std::path::PathBuf::from("/b"),
2597 layout: import::Layout::CodexToml,
2598 allows_comments: false,
2599 },
2600 result: Err("unreadable".to_string()),
2601 },
2602 ];
2603
2604 let (candidates, errors) = candidates_from_scans(scans);
2605
2606 assert_eq!(candidates.len(), 1);
2607 assert_eq!(candidates[0].0, "Harness A");
2608 assert_eq!(errors, vec!["Harness B: unreadable"]);
2609 }
2610}