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