1use std::collections::BTreeMap;
24use std::io::BufRead;
25use std::path::{Path, PathBuf};
26
27use ratatui::Frame;
28use ratatui::layout::{Constraint, Direction, Layout, Rect};
29use ratatui::style::Modifier;
30use ratatui::text::{Line, Span};
31use ratatui::widgets::{Clear, Paragraph};
32use ratatui_bubbletea_theme::BubbleTheme;
33use serde::{Deserialize, Serialize};
34use toml_edit::{DocumentMut, value};
35
36use crate::config::{
37 Config, edit_config_document, is_valid_env_var_name, read_config_document, set_bool, set_value,
38};
39use crate::error::{AppError, Result};
40use crate::theme::Theme;
41use crate::tui::style::bubble_theme;
42use crate::vendor::VendorId;
43
44pub struct KeyVendor {
48 pub id: VendorId,
49 pub label: &'static str,
50 pub section: &'static str,
51 pub config_key: &'static str,
53 pub secret_label: &'static str,
55 pub note: &'static str,
57}
58
59pub const KEY_VENDORS: &[KeyVendor] = &[
60 KeyVendor {
61 id: VendorId::AnthropicApi,
62 label: "Anthropic API",
63 section: VendorId::AnthropicApi.config_section(),
64 config_key: "api_key",
65 secret_label: "API key",
66 note: "admin key — monthly spend",
67 },
68 KeyVendor {
69 id: VendorId::Zai,
70 label: "Z.AI",
71 section: VendorId::Zai.config_section(),
72 config_key: "api_key",
73 secret_label: "API key",
74 note: "",
75 },
76 KeyVendor {
77 id: VendorId::Openrouter,
78 label: "OpenRouter",
79 section: VendorId::Openrouter.config_section(),
80 config_key: "api_key",
81 secret_label: "API key",
82 note: "",
83 },
84 KeyVendor {
85 id: VendorId::Deepseek,
86 label: "DeepSeek",
87 section: VendorId::Deepseek.config_section(),
88 config_key: "api_key",
89 secret_label: "API key",
90 note: "",
91 },
92 KeyVendor {
93 id: VendorId::Deepinfra,
94 label: "DeepInfra",
95 section: VendorId::Deepinfra.config_section(),
96 config_key: "api_key",
97 secret_label: "API key",
98 note: "billing balance and monthly spend",
99 },
100 KeyVendor {
101 id: VendorId::Kimi,
102 label: "Kimi",
103 section: VendorId::Kimi.config_section(),
104 config_key: "api_key",
105 secret_label: "API key",
106 note: "coding-plan usage",
107 },
108 KeyVendor {
109 id: VendorId::Kilo,
110 label: "Kilo",
111 section: VendorId::Kilo.config_section(),
112 config_key: "api_key",
113 secret_label: "API key",
114 note: "",
115 },
116 KeyVendor {
117 id: VendorId::Novita,
118 label: "Novita",
119 section: VendorId::Novita.config_section(),
120 config_key: "api_key",
121 secret_label: "API key",
122 note: "",
123 },
124 KeyVendor {
125 id: VendorId::Synthetic,
126 label: "Synthetic",
127 section: VendorId::Synthetic.config_section(),
128 config_key: "api_key",
129 secret_label: "API key",
130 note: "weekly credit quota",
131 },
132 KeyVendor {
133 id: VendorId::DeepRelay,
134 label: "DeepRelay",
135 section: VendorId::DeepRelay.config_section(),
136 config_key: "api_key",
137 secret_label: "API key",
138 note: "key needs the billing:read scope",
139 },
140 KeyVendor {
141 id: VendorId::Mistral,
142 label: "Mistral",
143 section: VendorId::Mistral.config_section(),
144 config_key: "api_key",
145 secret_label: "API key",
146 note: "key needs the see_org_inference_usage permission",
147 },
148 KeyVendor {
149 id: VendorId::Moonshot,
150 label: "Moonshot",
151 section: VendorId::Moonshot.config_section(),
152 config_key: "api_key",
153 secret_label: "API key",
154 note: "account balance",
155 },
156 KeyVendor {
157 id: VendorId::Grok,
158 label: "Grok",
159 section: VendorId::Grok.config_section(),
160 config_key: "api_key",
161 secret_label: "API key",
162 note: "management key, not the inference key",
163 },
164 KeyVendor {
165 id: VendorId::Minimax,
166 label: "MiniMax",
167 section: VendorId::Minimax.config_section(),
168 config_key: "api_key",
169 secret_label: "API key",
170 note: "Token Plan subscription key",
171 },
172 KeyVendor {
173 id: VendorId::OpenCodeGo,
174 label: "OpenCode Go",
175 section: VendorId::OpenCodeGo.config_section(),
176 config_key: "api_key",
177 secret_label: "API key",
178 note: "usage quota",
179 },
180 KeyVendor {
181 id: VendorId::Ollama,
182 label: "Ollama Cloud",
183 section: VendorId::Ollama.config_section(),
184 config_key: "api_key",
185 secret_label: "API key",
186 note: "ollama.com/settings/keys",
187 },
188 KeyVendor {
189 id: VendorId::OrcaRouter,
190 label: "OrcaRouter",
191 section: VendorId::OrcaRouter.config_section(),
192 config_key: "api_key",
193 secret_label: "API key",
194 note: "credit balance",
195 },
196 KeyVendor {
197 id: VendorId::Lyceum,
198 label: "Lyceum",
199 section: VendorId::Lyceum.config_section(),
200 config_key: "api_key",
201 secret_label: "API key",
202 note: "billing credits",
203 },
204];
205
206pub const PROVIDER_SWITCH_COUNT: usize = VendorId::all().len();
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum Focus {
216 Primary,
217 Key(usize),
218 Vendor(usize),
219 NotifyEnabled,
220 NotifyThreshold,
221 Save,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum SettingsRow {
227 Focus(Focus),
229 Switch(Focus, KeyCode, KeyModifiers),
233 OpenPicker,
236 Pick(usize),
239 HintKey(KeyCode, KeyModifiers),
242}
243
244impl Focus {
245 pub fn next(self) -> Self {
246 match self {
247 Focus::Primary => Focus::Key(0),
248 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
249 Focus::Key(_) => Focus::Vendor(0),
250 Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
251 Focus::Vendor(_) => Focus::NotifyEnabled,
252 Focus::NotifyEnabled => Focus::NotifyThreshold,
253 Focus::NotifyThreshold => Focus::Save,
254 Focus::Save => Focus::Primary,
255 }
256 }
257 pub fn prev(self) -> Self {
258 match self {
259 Focus::Primary => Focus::Save,
260 Focus::Key(0) => Focus::Primary,
261 Focus::Key(i) => Focus::Key(i - 1),
262 Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
263 Focus::Vendor(i) => Focus::Vendor(i - 1),
264 Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
265 Focus::NotifyThreshold => Focus::NotifyEnabled,
266 Focus::Save => Focus::NotifyThreshold,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Copy, Default)]
274pub struct PrimaryPicker {
275 pub cursor: usize,
276 pub scroll: u16,
277}
278
279#[derive(Debug, Clone, Default)]
281pub struct KeyInput {
282 pub buf: String,
283 pub cursor: usize,
285 pub revealed: bool,
287 pub dirty: bool,
291}
292
293impl KeyInput {
294 pub fn from_config(initial: Option<&str>) -> Self {
295 let buf = initial.unwrap_or("").to_string();
296 let cursor = buf.chars().count();
297 Self {
298 buf,
299 cursor,
300 revealed: false,
301 dirty: false,
302 }
303 }
304
305 pub fn insert_char(&mut self, c: char) {
306 let byte_idx = self.char_to_byte(self.cursor);
307 self.buf.insert(byte_idx, c);
308 self.cursor += 1;
309 self.dirty = true;
310 }
311
312 pub fn backspace(&mut self) {
313 if self.cursor == 0 {
314 return;
315 }
316 let prev_byte = self.char_to_byte(self.cursor - 1);
317 let cur_byte = self.char_to_byte(self.cursor);
318 self.buf.replace_range(prev_byte..cur_byte, "");
319 self.cursor -= 1;
320 self.dirty = true;
321 }
322
323 pub fn delete(&mut self) {
324 let n = self.buf.chars().count();
325 if self.cursor >= n {
326 return;
327 }
328 let cur_byte = self.char_to_byte(self.cursor);
329 let next_byte = self.char_to_byte(self.cursor + 1);
330 self.buf.replace_range(cur_byte..next_byte, "");
331 self.dirty = true;
332 }
333
334 pub fn move_left(&mut self) {
335 if self.cursor > 0 {
336 self.cursor -= 1;
337 }
338 }
339 pub fn move_right(&mut self) {
340 if self.cursor < self.buf.chars().count() {
341 self.cursor += 1;
342 }
343 }
344 pub fn move_home(&mut self) {
345 self.cursor = 0;
346 }
347 pub fn move_end(&mut self) {
348 self.cursor = self.buf.chars().count();
349 }
350 pub fn toggle_reveal(&mut self) {
351 self.revealed = !self.revealed;
352 }
353
354 pub fn display(&self) -> String {
356 if self.revealed {
357 self.buf.clone()
358 } else {
359 "•".repeat(self.buf.chars().count())
360 }
361 }
362
363 fn char_to_byte(&self, char_idx: usize) -> usize {
364 self.buf
365 .char_indices()
366 .map(|(b, _)| b)
367 .chain(std::iter::once(self.buf.len()))
368 .nth(char_idx)
369 .unwrap_or(self.buf.len())
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub struct ProviderSwitch {
378 pub enabled: bool,
379 pub dirty: bool,
380}
381
382#[derive(Debug, Clone)]
384pub struct SettingsState {
385 pub focus: Focus,
386 pub primary_choices: Vec<VendorId>,
389 pub primary: VendorId,
390 pub keys: Vec<KeyInput>,
392 pub vendors: Vec<ProviderSwitch>,
395 pub notify_enabled: bool,
397 pub notify_enabled_dirty: bool,
398 pub notify_threshold: KeyInput,
400 pub status: String,
402 pub scroll: u16,
405 pub picker: Option<PrimaryPicker>,
407}
408
409impl SettingsState {
410 pub fn from_config(cfg: &Config) -> Self {
411 Self::from_config_with(cfg, |name| {
412 std::env::var_os(name).is_some_and(|v| !v.is_empty())
413 })
414 }
415
416 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
425 let keys = KEY_VENDORS
426 .iter()
427 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
428 .collect();
429 let mut primary_choices = cfg.enabled_vendors();
430 if !primary_choices.contains(&VendorId::Copilot) {
434 primary_choices.push(VendorId::Copilot);
435 }
436 for kv in KEY_VENDORS {
444 if primary_choices.contains(&kv.id) {
445 continue;
446 }
447 let env = cfg.api_key_env_for(kv.id);
448 let exported = is_valid_env_var_name(env) && env_set(env);
449 if cfg.inline_api_key(kv.id).is_some() || exported {
450 primary_choices.push(kv.id);
451 }
452 }
453 let primary = cfg
460 .ui
461 .primary
462 .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
463 .or_else(|| primary_choices.first().copied())
464 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
465 Self {
466 focus: Focus::Primary,
467 primary_choices,
468 primary,
469 keys,
470 vendors: VendorId::all()
471 .iter()
472 .map(|id| ProviderSwitch {
473 enabled: cfg.is_enabled(*id),
474 dirty: false,
475 })
476 .collect(),
477 notify_enabled: cfg.notifications.enabled,
478 notify_enabled_dirty: false,
479 notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
480 status: String::new(),
481 scroll: 0,
482 picker: None,
483 }
484 }
485
486 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
488 match self.focus {
489 Focus::Key(i) => self.keys.get_mut(i),
490 _ => None,
491 }
492 }
493
494 fn next_focus(&self) -> Focus {
497 self.focus.next()
498 }
499
500 fn prev_focus(&self) -> Focus {
501 self.focus.prev()
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum Action {
508 Continue,
510 Close,
512 SavedAndClose,
514 Quit,
517}
518
519#[cfg(unix)]
522const PERMS_NOTE: &str = " (chmod 600)";
523#[cfg(not(unix))]
524const PERMS_NOTE: &str = "";
525
526fn saved_status() -> String {
527 format!(
528 "saved to {}{}",
529 crate::config::config_path_hint(),
530 PERMS_NOTE
531 )
532}
533
534pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
536 if state.picker.is_some()
539 && !(matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL))
540 {
541 return handle_picker_key(state, code);
542 }
543 if matches!(code, KeyCode::Esc) {
544 return Action::Close;
545 }
546 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
547 return Action::Quit;
548 }
549 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
551 return try_save(state);
552 }
553 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
554 if let Some(input) = state.focused_key_mut() {
555 input.toggle_reveal();
556 }
557 return Action::Continue;
558 }
559 match code {
560 KeyCode::Tab | KeyCode::Down => {
561 state.focus = state.next_focus();
562 return Action::Continue;
563 }
564 KeyCode::BackTab | KeyCode::Up => {
565 state.focus = state.prev_focus();
566 return Action::Continue;
567 }
568 _ => {}
569 }
570
571 if matches!(code, KeyCode::Char(_))
576 && mods.intersects(
577 KeyModifiers::CONTROL
578 | KeyModifiers::ALT
579 | KeyModifiers::SUPER
580 | KeyModifiers::HYPER
581 | KeyModifiers::META,
582 )
583 {
584 return Action::Continue;
585 }
586
587 match state.focus {
589 Focus::Primary => handle_primary(state, code),
590 Focus::Key(i) => {
591 if let Some(input) = state.keys.get_mut(i) {
592 handle_input(input, code);
593 }
594 }
595 Focus::Vendor(i) => handle_provider_switch(state, i, code),
596 Focus::NotifyEnabled => handle_notify_enabled(state, code),
597 Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
598 Focus::Save => {
599 if matches!(code, KeyCode::Enter) {
600 return try_save(state);
601 }
602 }
603 }
604 Action::Continue
605}
606
607fn try_save(state: &mut SettingsState) -> Action {
608 match save_to_config_default(state) {
609 Ok(()) => {
610 state.status = saved_status();
611 Action::SavedAndClose
612 }
613 Err(e) => {
614 state.status = format!("save failed: {e}");
615 Action::Continue
616 }
617 }
618}
619
620fn handle_picker_key(state: &mut SettingsState, code: KeyCode) -> Action {
623 let count = state.primary_choices.len();
624 if count == 0 {
625 state.picker = None;
626 return Action::Continue;
627 }
628 let Some(picker) = state.picker.as_mut() else {
629 return Action::Continue;
630 };
631 match code {
632 KeyCode::Up => picker.cursor = (picker.cursor + count - 1) % count,
633 KeyCode::Down => picker.cursor = (picker.cursor + 1) % count,
634 KeyCode::Enter | KeyCode::Char(' ') => {
635 state.primary = state.primary_choices[picker.cursor];
636 state.picker = None;
637 }
638 KeyCode::Esc => state.picker = None,
639 _ => {}
640 }
641 Action::Continue
642}
643
644fn handle_primary(state: &mut SettingsState, code: KeyCode) {
645 let choices = &state.primary_choices;
647 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
648 return;
649 };
650 let step = match code {
651 KeyCode::Left => -1,
652 KeyCode::Right | KeyCode::Char(' ') => 1,
653 _ => return,
654 };
655 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
656}
657
658fn handle_input(input: &mut KeyInput, code: KeyCode) {
659 match code {
660 KeyCode::Char(c) => input.insert_char(c),
661 KeyCode::Backspace => input.backspace(),
662 KeyCode::Delete => input.delete(),
663 KeyCode::Left => input.move_left(),
664 KeyCode::Right => input.move_right(),
665 KeyCode::Home => input.move_home(),
666 KeyCode::End => input.move_end(),
667 _ => {}
668 }
669}
670
671fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
674 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
675 state.notify_enabled = !state.notify_enabled;
676 state.notify_enabled_dirty = true;
677 }
678}
679
680fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
684 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
685 && let Some(switch) = state.vendors.get_mut(index)
686 {
687 switch.enabled = !switch.enabled;
688 switch.dirty = true;
689 }
690}
691
692fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
696 match code {
697 KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
698 KeyCode::Backspace => input.backspace(),
699 KeyCode::Delete => input.delete(),
700 KeyCode::Left => input.move_left(),
701 KeyCode::Right => input.move_right(),
702 KeyCode::Home => input.move_home(),
703 KeyCode::End => input.move_end(),
704 _ => {}
705 }
706}
707
708fn save_to_config_default(state: &SettingsState) -> Result<()> {
711 let path = default_config_path()?;
712 if let Some(parent) = path.parent() {
713 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
714 }
715 save_to_path(state, &path)?;
716 crate::waybar::request_refresh();
717 Ok(())
718}
719
720pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
723 edit_config_document(path, |doc| {
726 if let Some(table) = doc
730 .get_mut("copilot")
731 .and_then(toml_edit::Item::as_table_like_mut)
732 {
733 table.remove("token");
734 table.remove("token_env");
735 }
736
737 if state.primary_choices.contains(&state.primary) {
746 set_string(doc, "ui", "primary", state.primary.slug())?;
747 if state.primary == VendorId::Copilot
748 || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
749 {
750 set_bool(doc, state.primary.config_section(), "enabled", true)?;
751 }
752 }
753
754 for (i, kv) in KEY_VENDORS.iter().enumerate() {
755 let Some(input) = state.keys.get(i) else {
756 continue;
757 };
758 update_key(doc, kv, input)?;
759 }
760
761 for (i, id) in VendorId::all().iter().enumerate() {
767 let Some(switch) = state.vendors.get(i) else {
768 continue;
769 };
770 if switch.dirty {
771 crate::config::set_vendor_enabled_in_doc(doc, id.slug(), switch.enabled)?;
772 }
773 }
774
775 if state.notify_enabled_dirty {
778 set_bool(doc, "notifications", "enabled", state.notify_enabled)?;
779 }
780 if state.notify_threshold.dirty {
781 let raw = state.notify_threshold.buf.trim();
782 let threshold = raw.parse::<u8>();
783 match threshold {
784 Ok(threshold) if (1..=100).contains(&threshold) => {
785 set_value(
786 doc,
787 "notifications",
788 "threshold",
789 Some(toml_edit::Value::from(i64::from(threshold))),
790 )?;
791 }
792 _ => {
793 return Err(AppError::Other(format!(
794 "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
795 )));
796 }
797 }
798 }
799
800 Ok(())
801 })
802}
803
804fn reject_invalid_key(secret_label: &str, vendor_slug: &str, value: &str) -> Result<()> {
808 if value.is_empty() {
809 return Err(AppError::Other(format!(
810 "{secret_label} for {vendor_slug:?} is empty; use the clear action to remove it"
811 )));
812 }
813 if value.len() > MAX_API_KEY_BYTES {
814 return Err(AppError::Other(format!(
815 "{secret_label} for {vendor_slug:?} exceeds {MAX_API_KEY_BYTES} bytes"
816 )));
817 }
818 if value.chars().any(char::is_control) {
819 return Err(AppError::Other(format!(
820 "{secret_label} for {vendor_slug:?} contains control characters"
821 )));
822 }
823 Ok(())
824}
825
826pub(crate) fn key_vendor_for_slug(slug: &str) -> Option<&'static KeyVendor> {
830 KEY_VENDORS.iter().find(|vendor| vendor.id.slug() == slug)
831}
832
833pub(crate) fn validate_key_value(vendor: &KeyVendor, value: &str) -> Result<()> {
836 reject_invalid_key(vendor.secret_label, vendor.id.slug(), value)
837}
838
839pub(crate) struct KeyItemSnapshot {
845 key_decor: toml_edit::Decor,
846 value: toml_edit::Value,
847}
848
849pub(crate) struct KeySnapshot {
855 #[cfg_attr(not(test), allow(dead_code))]
859 pub file_existed: bool,
860 pub section_existed: bool,
861 pub key: Option<KeyItemSnapshot>,
862 pub enabled: Option<KeyItemSnapshot>,
863}
864
865fn capture_key_item(table: &dyn toml_edit::TableLike, name: &str) -> Option<KeyItemSnapshot> {
869 let (key, item) = table.get_key_value(name)?;
870 let value = item.as_value()?.clone();
871 Some(KeyItemSnapshot {
872 key_decor: key.leaf_decor().clone(),
873 value,
874 })
875}
876
877pub(crate) fn snapshot_inline_key(path: &Path, vendor: &KeyVendor) -> Result<KeySnapshot> {
883 let file_existed = path.exists();
884 let doc = read_config_document(path)?;
885 Ok(key_snapshot_of_doc(&doc, file_existed, vendor))
886}
887
888fn key_snapshot_of_doc(
892 doc: &toml_edit::DocumentMut,
893 file_existed: bool,
894 vendor: &KeyVendor,
895) -> KeySnapshot {
896 match doc
897 .get(vendor.section)
898 .and_then(toml_edit::Item::as_table_like)
899 {
900 Some(table) => KeySnapshot {
901 file_existed,
902 section_existed: true,
903 key: capture_key_item(table, vendor.config_key),
904 enabled: capture_key_item(table, "enabled"),
905 },
906 None => KeySnapshot {
907 file_existed,
908 section_existed: false,
909 key: None,
910 enabled: None,
911 },
912 }
913}
914
915pub(crate) const KEY_ALREADY_SAVED_MESSAGE: &str =
920 "A key is already saved in config.toml; replace it in the TUI settings.";
921
922pub(crate) fn saved_inline_key(snapshot: &KeySnapshot) -> Option<String> {
927 snapshot
928 .key
929 .as_ref()
930 .and_then(|item| item.value.as_str())
931 .filter(|value| !value.is_empty())
932 .map(str::to_string)
933}
934
935#[cfg_attr(not(test), allow(dead_code))]
945pub(crate) fn write_inline_key(path: &Path, vendor: &KeyVendor, value: &str) -> Result<()> {
946 validate_key_value(vendor, value)?;
947 edit_config_document(path, |doc| {
948 set_string(doc, vendor.section, vendor.config_key, value)?;
949 set_bool(doc, vendor.section, "enabled", true)
950 })
951}
952
953pub(crate) fn snapshot_and_write_inline_key_if_absent(
966 path: &Path,
967 vendor: &KeyVendor,
968 value: &str,
969) -> Result<KeySnapshot> {
970 validate_key_value(vendor, value)?;
971 edit_config_document(path, |doc| {
972 let snapshot = key_snapshot_of_doc(doc, path.exists(), vendor);
973 if saved_inline_key(&snapshot).is_some() {
974 return Err(AppError::Other(KEY_ALREADY_SAVED_MESSAGE.to_string()));
975 }
976 set_string(doc, vendor.section, vendor.config_key, value)?;
977 set_bool(doc, vendor.section, "enabled", true)?;
978 Ok(snapshot)
979 })
980}
981
982pub(crate) fn restore_inline_key_snapshot(
998 path: &Path,
999 vendor: &KeyVendor,
1000 snapshot: &KeySnapshot,
1001 candidate: &str,
1002) -> Result<()> {
1003 edit_config_document(path, |doc| {
1004 let table = doc
1005 .entry(vendor.section)
1006 .or_insert_with(toml_edit::table)
1007 .as_table_like_mut()
1008 .ok_or_else(|| {
1009 AppError::Other(format!("config.toml: [{}] is not a table", vendor.section))
1010 })?;
1011 let current_key = table
1019 .get(vendor.config_key)
1020 .and_then(toml_edit::Item::as_str);
1021 match &snapshot.key {
1022 Some(captured) if current_key == Some(candidate) => {
1023 if let Some((mut key, item)) = table.get_key_value_mut(vendor.config_key) {
1024 *key.leaf_decor_mut() = captured.key_decor.clone();
1025 *item = toml_edit::Item::Value(captured.value.clone());
1026 }
1027 }
1028 None if current_key == Some(candidate) => {
1029 table.remove(vendor.config_key);
1030 }
1031 _ => {}
1033 }
1034 let current_enabled = table.get("enabled").and_then(toml_edit::Item::as_bool);
1035 match &snapshot.enabled {
1036 Some(captured) if current_enabled == Some(true) => {
1037 if let Some((mut key, item)) = table.get_key_value_mut("enabled") {
1038 *key.leaf_decor_mut() = captured.key_decor.clone();
1039 *item = toml_edit::Item::Value(captured.value.clone());
1040 }
1041 }
1042 None if current_enabled == Some(true) => {
1043 table.remove("enabled");
1044 }
1045 _ => {}
1047 }
1048 if !snapshot.section_existed
1052 && doc
1053 .get(vendor.section)
1054 .and_then(toml_edit::Item::as_table_like)
1055 .is_some_and(|table| table.is_empty())
1056 {
1057 doc.remove(vendor.section);
1058 }
1059 Ok(())
1060 })
1061}
1062
1063fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
1069 if !input.dirty {
1070 return Ok(());
1071 }
1072 if input.buf.is_empty() {
1073 if let Some(table) = doc
1074 .get_mut(vendor.section)
1075 .and_then(toml_edit::Item::as_table_like_mut)
1076 {
1077 table.remove(vendor.config_key);
1078 }
1079 return Ok(());
1080 }
1081 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
1082 set_bool(doc, vendor.section, "enabled", true)
1083}
1084
1085fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
1088 let table = doc
1089 .entry(section)
1090 .or_insert_with(toml_edit::table)
1091 .as_table_like_mut()
1092 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
1093
1094 if let Some(item) = table.get_mut(key)
1095 && let Some(v) = item.as_value_mut()
1096 {
1097 *v = toml_edit::Value::from(new_value);
1098 v.decor_mut().set_prefix(" ");
1099 return Ok(());
1100 }
1101 table.insert(key, value(new_value));
1102 Ok(())
1103}
1104
1105fn default_config_path() -> Result<PathBuf> {
1106 crate::config::resolved_path()
1111 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
1112}
1113
1114#[derive(Debug, Serialize)]
1121struct SettingsSnapshot {
1122 schema_version: u8,
1123 primary: String,
1124 primary_choices: Vec<PrimaryChoice>,
1125 keys: Vec<KeyStatus>,
1126 vendors: Vec<VendorStatus>,
1129}
1130
1131#[derive(Debug, Serialize)]
1132struct PrimaryChoice {
1133 id: String,
1134 label: String,
1135}
1136
1137#[derive(Debug, Serialize)]
1138struct KeyStatus {
1139 id: String,
1140 label: String,
1141 environment: String,
1142 secret_label: String,
1143 note: String,
1144 configured: bool,
1145 inline_configured: bool,
1146 environment_configured: bool,
1147}
1148
1149#[derive(Debug, Serialize)]
1152struct VendorStatus {
1153 id: String,
1154 label: String,
1155 enabled: bool,
1156}
1157
1158#[derive(Debug, Deserialize)]
1164#[serde(deny_unknown_fields)]
1165struct ApplyRequest {
1166 schema_version: u8,
1167 primary: Option<String>,
1168 #[serde(default)]
1169 keys: BTreeMap<String, KeyMutation>,
1170 #[serde(default)]
1171 vendors: BTreeMap<String, bool>,
1172}
1173
1174#[derive(Debug, Deserialize)]
1175#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
1176enum KeyMutation {
1177 Set { value: String },
1178 Clear,
1179}
1180
1181const SETTINGS_SCHEMA_VERSION: u8 = 1;
1182const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
1183pub(crate) const MAX_API_KEY_BYTES: usize = 16 * 1024;
1185
1186fn snapshot_from_config_with(
1187 cfg: &Config,
1188 environment_configured: impl Fn(&str) -> bool,
1189) -> SettingsSnapshot {
1190 let state = SettingsState::from_config(cfg);
1191 let primary_choices = state
1192 .primary_choices
1193 .iter()
1194 .map(|id| PrimaryChoice {
1195 id: id.slug().to_string(),
1196 label: id.display_name().to_string(),
1197 })
1198 .collect();
1199 let keys = KEY_VENDORS
1200 .iter()
1201 .map(|vendor| {
1202 let environment = cfg.api_key_env_for(vendor.id);
1203 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
1204 let environment_configured = environment_configured(environment);
1205 KeyStatus {
1206 id: vendor.id.slug().to_string(),
1207 label: vendor.label.to_string(),
1208 environment: environment.to_string(),
1209 secret_label: vendor.secret_label.to_string(),
1210 note: vendor.note.to_string(),
1211 configured: inline_configured || environment_configured,
1212 inline_configured,
1213 environment_configured,
1214 }
1215 })
1216 .collect();
1217 let vendors = VendorId::all()
1218 .iter()
1219 .map(|id| VendorStatus {
1220 id: id.slug().to_string(),
1221 label: id.display_name().to_string(),
1222 enabled: cfg.is_enabled(*id),
1223 })
1224 .collect();
1225 SettingsSnapshot {
1226 schema_version: SETTINGS_SCHEMA_VERSION,
1227 primary: state.primary.slug().to_string(),
1228 primary_choices,
1229 keys,
1230 vendors,
1231 }
1232}
1233
1234fn settings_snapshot_json(cfg: &Config) -> Result<String> {
1235 Ok(serde_json::to_string(&snapshot_from_config_with(
1236 cfg,
1237 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
1238 ))?)
1239}
1240
1241#[cfg(test)]
1242fn settings_snapshot_json_with(
1243 cfg: &Config,
1244 environment_configured: impl Fn(&str) -> bool,
1245) -> Result<String> {
1246 Ok(serde_json::to_string(&snapshot_from_config_with(
1247 cfg,
1248 environment_configured,
1249 ))?)
1250}
1251
1252fn vendor_from_slug(slug: &str) -> Option<VendorId> {
1253 VendorId::from_slug(slug)
1254}
1255
1256fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
1257 let request: ApplyRequest = serde_json::from_str(raw)?;
1258 if request.schema_version != SETTINGS_SCHEMA_VERSION {
1259 return Err(AppError::Other(format!(
1260 "unsupported settings schema version {}",
1261 request.schema_version
1262 )));
1263 }
1264
1265 let mut state = SettingsState::from_config(cfg);
1266 if let Some(primary) = request.primary {
1267 let id = vendor_from_slug(&primary)
1268 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
1269 if !state.primary_choices.contains(&id) {
1270 return Err(AppError::Other(format!(
1271 "primary vendor {primary:?} is not enabled"
1272 )));
1273 }
1274 state.primary = id;
1275 }
1276
1277 for (id, mutation) in request.keys {
1278 let index = KEY_VENDORS
1279 .iter()
1280 .position(|vendor| vendor.id.slug() == id)
1281 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
1282 let input = &mut state.keys[index];
1283 match mutation {
1284 KeyMutation::Set { value } => {
1285 reject_invalid_key(KEY_VENDORS[index].secret_label, &id, &value)?;
1286 input.buf = value;
1287 }
1288 KeyMutation::Clear => input.buf.clear(),
1289 }
1290 input.cursor = input.buf.chars().count();
1291 input.dirty = true;
1292 input.revealed = false;
1293 }
1294
1295 for (slug, enabled) in request.vendors {
1299 let index = VendorId::all()
1300 .iter()
1301 .position(|id| id.slug() == slug)
1302 .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
1303 state.vendors[index] = ProviderSwitch {
1304 enabled,
1305 dirty: true,
1306 };
1307 }
1308 Ok(state)
1309}
1310
1311#[cfg(test)]
1312fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
1313 let state = state_from_apply_request(cfg, raw)?;
1314 save_to_path(&state, path)
1315}
1316
1317fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
1318 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
1319 let mut bytes = Vec::new();
1320 limited.read_until(b'\n', &mut bytes)?;
1321 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
1322 return Err(AppError::Other(format!(
1323 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
1324 )));
1325 }
1326 if bytes.last() == Some(&b'\n') {
1327 bytes.pop();
1328 if bytes.last() == Some(&b'\r') {
1329 bytes.pop();
1330 }
1331 }
1332 String::from_utf8(bytes)
1333 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
1334}
1335
1336fn apply_settings_from_stdin() -> Result<()> {
1337 let raw = read_settings_request(std::io::stdin().lock())?;
1338 let cfg = Config::load()?;
1339 let state = state_from_apply_request(&cfg, &raw)?;
1340 save_to_config_default(&state)
1341}
1342
1343fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
1345 edit_config_document(path, |doc| {
1346 set_bool(doc, vendor.config_section(), "enabled", true)
1347 })
1348}
1349
1350pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1354 let result = match action {
1355 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1356 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1357 .map(|()| {
1358 crate::waybar::request_refresh();
1359 println!(r#"{{"ok":true}}"#);
1360 }),
1361 crate::widget::cli::SettingsAction::Show => Config::load()
1362 .and_then(|cfg| settings_snapshot_json(&cfg))
1363 .map(|json| println!("{json}")),
1364 crate::widget::cli::SettingsAction::Apply => {
1365 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1366 }
1367 };
1368 match result {
1369 Ok(()) => 0,
1370 Err(error) => {
1371 eprintln!("settings: {error}");
1372 1
1373 }
1374 }
1375}
1376
1377struct BodyLayout {
1383 primary: usize,
1384 first_key: usize,
1385 first_vendor: usize,
1386 notify_enabled: usize,
1387 notify_threshold: usize,
1388 save: usize,
1389 rows: usize,
1391}
1392
1393fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1394 let first_key = 4;
1395 let first_vendor = first_key + keys + 2;
1396 let notify_enabled = first_vendor + vendors + 2;
1397 BodyLayout {
1398 primary: 1,
1399 first_key,
1400 first_vendor,
1401 notify_enabled,
1402 notify_threshold: notify_enabled + 1,
1403 save: notify_enabled + 3,
1404 rows: notify_enabled + 4,
1405 }
1406}
1407
1408fn focus_line(state: &SettingsState) -> usize {
1410 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1411 match state.focus {
1412 Focus::Primary => layout.primary,
1413 Focus::Key(i) => layout.first_key + i,
1414 Focus::Vendor(i) => layout.first_vendor + i,
1415 Focus::NotifyEnabled => layout.notify_enabled,
1416 Focus::NotifyThreshold => layout.notify_threshold,
1417 Focus::Save => layout.save,
1418 }
1419}
1420
1421fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1425 if viewport == 0 || total == 0 {
1426 return 0;
1427 }
1428 let max_offset = (total as u16).saturating_sub(viewport);
1429 let focused = focused.min(total - 1) as u16;
1430 let wanted = if focused < current {
1431 focused
1432 } else if focused >= current.saturating_add(viewport) {
1433 (focused + 1).saturating_sub(viewport)
1436 } else {
1437 current
1438 };
1439 wanted.min(max_offset)
1440}
1441
1442pub fn render(
1444 f: &mut Frame,
1445 area: Rect,
1446 state: &mut SettingsState,
1447 theme: &Theme,
1448 hits: &mut Vec<(SettingsRow, Rect)>,
1449) {
1450 let modal = centered_rect(74, 88, area);
1451 f.render_widget(Clear, modal);
1452
1453 let bubble = bubble_theme(theme);
1454 let block = bubble.titled_modal_block(" Settings ");
1455 let inner = block.inner(modal);
1456 f.render_widget(block, modal);
1457
1458 let chunks = Layout::default()
1460 .direction(Direction::Vertical)
1461 .constraints([Constraint::Min(0), Constraint::Length(1)])
1462 .split(inner);
1463
1464 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1470 let total = layout.rows + usize::from(!state.status.is_empty());
1471 let scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1472 state.scroll = scroll;
1473
1474 let row_at = |line: usize| {
1479 let top = chunks[0].y;
1480 let bottom = top.saturating_add(chunks[0].height);
1481 let y = top.saturating_add(line as u16).checked_sub(scroll)?;
1482 (y >= top && y < bottom).then_some(Rect::new(inner.x, y, inner.width, 1))
1483 };
1484
1485 let mut lines: Vec<Line> = vec![
1487 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1488 primary_line(state, &bubble),
1489 Line::from(""),
1490 section_header("API keys", "all key providers", &bubble),
1491 ];
1492 if let Some(r) = row_at(1) {
1493 if state.focus == Focus::Primary {
1499 let pad = primary_name_pad(state);
1500 hits.push((
1501 SettingsRow::Switch(Focus::Primary, KeyCode::Left, KeyModifiers::NONE),
1502 Rect::new(r.x.saturating_add(5), r.y, 2, 1),
1503 ));
1504 hits.push((
1505 SettingsRow::Switch(Focus::Primary, KeyCode::Right, KeyModifiers::NONE),
1506 Rect::new(r.x.saturating_add(9 + pad), r.y, 2, 1),
1507 ));
1508 hits.push((
1509 SettingsRow::OpenPicker,
1510 Rect::new(r.x.saturating_add(7), r.y, pad + 2, 1),
1511 ));
1512 }
1513 hits.push((SettingsRow::Focus(Focus::Primary), r));
1514 }
1515
1516 for (i, kv) in KEY_VENDORS.iter().enumerate() {
1517 let focused = state.focus == Focus::Key(i);
1518 if let Some(r) = row_at(lines.len()) {
1519 hits.push((SettingsRow::Focus(Focus::Key(i)), r));
1520 }
1521 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1522 }
1523
1524 lines.push(Line::from(""));
1525
1526 lines.push(section_header(
1528 "Providers",
1529 "which vendors the report, bar and TUI fetch at all",
1530 &bubble,
1531 ));
1532 for (i, id) in VendorId::all().iter().enumerate() {
1533 let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1534 enabled: false,
1535 dirty: false,
1536 });
1537 let focused = state.focus == Focus::Vendor(i);
1538 if let Some(r) = row_at(lines.len()) {
1539 let (value_x, value_w) =
1545 switch_cell(id.display_name(), switch_value(switch.enabled), focused);
1546 hits.push((
1547 SettingsRow::Switch(Focus::Vendor(i), KeyCode::Char(' '), KeyModifiers::NONE),
1548 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1549 ));
1550 hits.push((SettingsRow::Focus(Focus::Vendor(i)), r));
1551 }
1552 lines.push(provider_row(id, switch, focused, &bubble));
1553 }
1554 lines.push(Line::from(""));
1555
1556 lines.push(section_header(
1558 "Notifications",
1559 "desktop alert when a quota window crosses the threshold",
1560 &bubble,
1561 ));
1562 if let Some(r) = row_at(lines.len()) {
1563 let (value_x, value_w) = switch_cell(
1564 "Quota alerts",
1565 switch_value(state.notify_enabled),
1566 state.focus == Focus::NotifyEnabled,
1567 );
1568 hits.push((
1569 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), KeyModifiers::NONE),
1570 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1571 ));
1572 hits.push((SettingsRow::Focus(Focus::NotifyEnabled), r));
1573 }
1574 lines.push(notify_enabled_line(state, &bubble));
1575 if let Some(r) = row_at(lines.len()) {
1576 hits.push((SettingsRow::Focus(Focus::NotifyThreshold), r));
1577 }
1578 lines.push(notify_threshold_line(state, &bubble));
1579 lines.push(Line::from(""));
1580
1581 if let Some(r) = row_at(lines.len()) {
1583 hits.push((SettingsRow::Focus(Focus::Save), r));
1584 }
1585 lines.push(save_line(state.focus == Focus::Save, &bubble));
1586 if !state.status.is_empty() {
1587 let ok = state.status.starts_with("saved");
1588 let mark = if ok { " ✓ " } else { " ✗ " };
1589 let style = if ok { bubble.accent } else { bubble.selected };
1590 lines.push(Line::from(vec![
1591 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1592 Span::styled(state.status.clone(), bubble.muted),
1593 ]));
1594 }
1595
1596 f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
1599
1600 type HintSegment<'a> = (&'a str, &'a str, Option<(KeyCode, KeyModifiers)>);
1604
1605 let segments: &[HintSegment] = match state.focus {
1610 Focus::Primary => &[
1611 ("↑↓/tab", "move", None),
1612 (
1613 "←→",
1614 "change vendor",
1615 Some((KeyCode::Right, KeyModifiers::NONE)),
1616 ),
1617 (
1618 "^S",
1619 "save",
1620 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1621 ),
1622 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1623 ],
1624 Focus::Key(_) => &[
1625 ("↑↓/tab", "move", None),
1626 ("type", "edit key", None),
1627 (
1628 "^V",
1629 "reveal",
1630 Some((KeyCode::Char('v'), KeyModifiers::CONTROL)),
1631 ),
1632 (
1633 "^S",
1634 "save",
1635 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1636 ),
1637 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1638 ],
1639 Focus::Vendor(_) | Focus::NotifyEnabled => &[
1640 ("↑↓/tab", "move", None),
1641 (
1642 "←→/space",
1643 "toggle",
1644 Some((KeyCode::Char(' '), KeyModifiers::NONE)),
1645 ),
1646 (
1647 "^S",
1648 "save",
1649 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1650 ),
1651 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1652 ],
1653 Focus::NotifyThreshold => &[
1654 ("↑↓/tab", "move", None),
1655 ("type", "digits 1-100", None),
1656 (
1657 "^S",
1658 "save",
1659 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1660 ),
1661 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1662 ],
1663 Focus::Save => &[
1664 ("↑↓/tab", "move", None),
1665 (
1666 "enter/^S",
1667 "save",
1668 Some((KeyCode::Enter, KeyModifiers::NONE)),
1669 ),
1670 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1671 ],
1672 };
1673 let hint = bubble.help_line(segments.iter().map(|(key, desc, _)| (*key, *desc)));
1674 f.render_widget(Paragraph::new(hint), chunks[1]);
1675
1676 let right = chunks[1].x.saturating_add(chunks[1].width);
1681 let mut x = chunks[1].x;
1682 for (key, desc, action) in segments {
1683 let width = (crate::display::text_width(key) as u16)
1684 .saturating_add(1)
1685 .saturating_add(crate::display::text_width(desc) as u16);
1686 if let Some((code, mods)) = action
1687 && x < right
1688 {
1689 let visible = width.min(right.saturating_sub(x));
1690 if visible > 0 {
1691 hits.push((
1692 SettingsRow::HintKey(*code, *mods),
1693 Rect::new(x, chunks[1].y, visible, 1),
1694 ));
1695 }
1696 }
1697 x = x.saturating_add(width).saturating_add(3);
1698 }
1699
1700 if state.picker.is_some() {
1703 render_primary_picker(f, state, &bubble, chunks[0], hits);
1704 }
1705}
1706
1707fn render_primary_picker(
1711 f: &mut Frame,
1712 state: &mut SettingsState,
1713 theme: &BubbleTheme,
1714 body: Rect,
1715 hits: &mut Vec<(SettingsRow, Rect)>,
1716) {
1717 let count = state.primary_choices.len();
1718 if count == 0 {
1719 return;
1720 }
1721 let pad = primary_name_pad(state) as usize;
1722 let width = u16::try_from(pad + 6).unwrap_or(u16::MAX).min(body.width);
1723 if width < 4 {
1724 return;
1725 }
1726 let height = (count as u16 + 2)
1728 .min(body.bottom().saturating_sub(body.y + 2))
1729 .max(3);
1730 let area = Rect::new(
1731 body.x.saturating_add(2),
1732 body.y.saturating_add(2),
1733 width,
1734 height,
1735 );
1736
1737 f.render_widget(Clear, area);
1738 let block = theme.titled_modal_block(" Primary vendor ");
1739 let inner = block.inner(area);
1740 f.render_widget(block, area);
1741
1742 let Some(picker) = state.picker.as_mut() else {
1743 return;
1744 };
1745 let viewport = inner.height;
1746 picker.scroll = follow_focus_scroll(picker.cursor, count, viewport, picker.scroll);
1747 for (index, vendor) in state
1748 .primary_choices
1749 .iter()
1750 .enumerate()
1751 .skip(picker.scroll as usize)
1752 .take(viewport as usize)
1753 {
1754 let y = inner.y + u16::try_from(index - picker.scroll as usize).unwrap_or(u16::MAX);
1755 let cursor = picker.cursor == index;
1756 let current = *vendor == state.primary;
1757 let style = if cursor {
1758 theme.accent.add_modifier(Modifier::BOLD)
1759 } else if current {
1760 theme.title
1761 } else {
1762 theme.text
1763 };
1764 let line = Line::from(vec![
1765 theme.span(if cursor { "▸ " } else { " " }),
1766 Span::styled(format!("{:<pad$}", vendor.display_name(), pad = pad), style),
1767 theme.span(if current { " ✓" } else { "" }),
1768 ]);
1769 f.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
1770 hits.push((
1771 SettingsRow::Pick(index),
1772 Rect::new(inner.x, y, inner.width, 1),
1773 ));
1774 }
1775}
1776
1777fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1778 Line::from(vec![
1779 theme.span(" "),
1780 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1781 theme.muted(format!(" — {sub}")),
1782 ])
1783}
1784
1785fn primary_name_pad(state: &SettingsState) -> u16 {
1788 state
1789 .primary_choices
1790 .iter()
1791 .map(|v| crate::display::text_width(v.display_name()) as u16)
1792 .max()
1793 .unwrap_or(1)
1794}
1795
1796fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1797 let focused = state.focus == Focus::Primary;
1798 let name = state.primary.display_name().to_string();
1799 if focused {
1800 let pad = primary_name_pad(state) as usize;
1801 Line::from(vec![
1802 theme.span(" "),
1803 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1804 Span::styled("◀ ", theme.accent),
1805 Span::styled(
1806 format!(" {:<pad$} ", name, pad = pad),
1807 theme
1808 .selected
1809 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1810 ),
1811 Span::styled(" ▶", theme.accent),
1812 theme.muted(" ← → or click to change"),
1813 ])
1814 } else {
1815 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
1816 }
1817}
1818
1819fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1820 let label = format!("{:<11}", kv.label);
1821 let value = value_text(input, focused);
1822
1823 let env_name = kv.id.api_key_env();
1825 let env_set = std::env::var(env_name)
1826 .map(|v| !v.is_empty())
1827 .unwrap_or(false);
1828 let mut suffix = format!(" {env_name}");
1829 if env_set {
1830 suffix.push_str(" · env set (overrides)");
1831 }
1832 if !kv.note.is_empty() {
1833 suffix.push_str(&format!(" · {}", kv.note));
1834 }
1835
1836 if focused {
1837 let val_style = if input.buf.is_empty() {
1838 theme.accent.add_modifier(Modifier::BOLD)
1839 } else {
1840 theme.selected.add_modifier(Modifier::REVERSED)
1841 };
1842 let mut spans = vec![
1843 theme.span(" "),
1844 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1845 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1846 Span::styled(format!(" {value} "), val_style),
1847 ];
1848 if input.revealed {
1849 spans.push(theme.muted(" [revealed]"));
1850 }
1851 spans.push(theme.muted(suffix));
1852 Line::from(spans)
1853 } else {
1854 let val_style = if input.buf.is_empty() {
1855 theme.muted
1856 } else {
1857 theme.text
1858 };
1859 Line::from(vec![
1860 theme.span(" "),
1861 Span::styled(label, theme.text),
1862 Span::styled(format!(" {value}"), val_style),
1863 theme.muted(suffix),
1864 ])
1865 }
1866}
1867
1868fn value_text(input: &KeyInput, focused: bool) -> String {
1871 if input.buf.is_empty() {
1872 return if focused {
1873 "‸".to_string()
1874 } else {
1875 "(empty)".to_string()
1876 };
1877 }
1878 let base = input.display();
1879 if !focused {
1880 return base;
1881 }
1882 let mut chars: Vec<char> = base.chars().collect();
1883 let pos = input.cursor.min(chars.len());
1884 chars.insert(pos, '‸');
1885 chars.into_iter().collect()
1886}
1887
1888fn padded_label(label: &str) -> String {
1892 format!("{:<11}", label)
1893}
1894
1895fn switch_value(enabled: bool) -> &'static str {
1897 if enabled { "on" } else { "off" }
1898}
1899
1900fn switch_cell(label: &str, value: &str, focused: bool) -> (u16, u16) {
1905 let value_x = 5 + crate::display::text_width(&padded_label(label)) as u16;
1906 let decoration_w = if focused { 6 } else { 2 };
1907 let value_w = decoration_w + crate::display::text_width(value) as u16;
1908 (value_x, value_w)
1909}
1910
1911fn provider_row(
1914 id: &VendorId,
1915 switch: ProviderSwitch,
1916 focused: bool,
1917 theme: &BubbleTheme,
1918) -> Line<'static> {
1919 let label = padded_label(id.display_name());
1920 let value = switch_value(switch.enabled);
1921 if focused {
1922 Line::from(vec![
1923 theme.span(" "),
1924 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1925 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1926 Span::styled(
1927 format!(" ◀ {value} ▶ "),
1928 theme
1929 .selected
1930 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1931 ),
1932 ])
1933 } else {
1934 Line::from(vec![
1935 theme.span(" "),
1936 Span::styled(label, theme.text),
1937 Span::styled(format!(" {value}"), theme.muted),
1938 ])
1939 }
1940}
1941
1942fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1945 let focused = state.focus == Focus::NotifyEnabled;
1946 let label = padded_label("Quota alerts");
1947 let value = switch_value(state.notify_enabled);
1948 if focused {
1949 Line::from(vec![
1950 theme.span(" "),
1951 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1952 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1953 Span::styled(
1954 format!(" ◀ {value} ▶ "),
1955 theme
1956 .selected
1957 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1958 ),
1959 ])
1960 } else {
1961 Line::from(vec![
1962 theme.span(" "),
1963 Span::styled(label, theme.text),
1964 Span::styled(format!(" {value}"), theme.muted),
1965 ])
1966 }
1967}
1968
1969fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1972 let focused = state.focus == Focus::NotifyThreshold;
1973 let label = format!("{:<11}", "Threshold %");
1974 let input = &state.notify_threshold;
1975 let value = if input.buf.is_empty() {
1976 if focused {
1977 "‸".to_string()
1978 } else {
1979 "(97)".to_string()
1980 }
1981 } else {
1982 let mut chars: Vec<char> = input.buf.chars().collect();
1983 if focused {
1984 let pos = input.cursor.min(chars.len());
1985 chars.insert(pos, '‸');
1986 }
1987 chars.into_iter().collect()
1988 };
1989 if focused {
1990 Line::from(vec![
1991 theme.span(" "),
1992 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1993 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1994 Span::styled(
1995 format!(" {value} "),
1996 theme
1997 .selected
1998 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1999 ),
2000 theme.muted(" 1-100"),
2001 ])
2002 } else {
2003 Line::from(vec![
2004 theme.span(" "),
2005 Span::styled(label, theme.text),
2006 Span::styled(format!(" {value}"), theme.text),
2007 theme.muted(" 1-100"),
2008 ])
2009 }
2010}
2011
2012fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
2013 let style = if focused {
2014 theme
2015 .selected
2016 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2017 } else {
2018 theme.accent.add_modifier(Modifier::BOLD)
2019 };
2020 let marker = if focused { "▸ " } else { " " };
2021 Line::from(vec![
2022 theme.span(" "),
2023 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
2024 Span::styled(" Save (Ctrl-S) ", style),
2025 ])
2026}
2027
2028fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2030 let popup_h = (r.height * percent_y) / 100;
2031 let popup_w = (r.width * percent_x) / 100;
2032 Rect {
2033 x: r.x + (r.width - popup_w) / 2,
2034 y: r.y + (r.height - popup_h) / 2,
2035 width: popup_w,
2036 height: popup_h,
2037 }
2038}
2039
2040pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2042
2043#[cfg(test)]
2044mod tests {
2045 use super::*;
2046 use tempfile::TempDir;
2047
2048 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
2049 crate::cache::closed_temp_file("config.toml", initial)
2050 }
2051
2052 fn key_index(id: VendorId) -> usize {
2053 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
2054 }
2055
2056 #[test]
2057 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
2058 let dir = tempfile::tempdir().unwrap();
2059 let path = dir.path().join("config.toml");
2060 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
2061 std::fs::write(&path, original).unwrap();
2062 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2063 let expected = original.replacen("enabled = false", "enabled = true", 1);
2064 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2065 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2066 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2067 }
2068
2069 #[test]
2070 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let path = dir.path().join("nested/config.toml");
2073 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2074 assert!(
2075 std::fs::read_to_string(&path)
2076 .unwrap()
2077 .contains("enabled = true")
2078 );
2079 let broken = "[anthropic\n";
2080 std::fs::write(&path, broken).unwrap();
2081 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
2082 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
2083 }
2084
2085 fn blank_state(primary: VendorId) -> SettingsState {
2086 SettingsState {
2087 focus: Focus::Primary,
2088 primary_choices: VendorId::all().to_vec(),
2089 primary,
2090 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
2091 vendors: VendorId::all()
2092 .iter()
2093 .map(|id| ProviderSwitch {
2094 enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
2095 dirty: false,
2096 })
2097 .collect(),
2098 notify_enabled: true,
2099 notify_enabled_dirty: false,
2100 notify_threshold: KeyInput::from_config(Some("97")),
2101 status: String::new(),
2102 scroll: 0,
2103 picker: None,
2104 }
2105 }
2106
2107 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
2109 let mut s = blank_state(primary);
2110 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
2111 s.keys[key_index(VendorId::Zai)].dirty = true;
2112 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
2113 s.keys[key_index(VendorId::Openrouter)].dirty = true;
2114 s
2115 }
2116
2117 #[test]
2118 fn focus_cycles_through_primary_all_keys_notifications_and_save() {
2119 let mut f = Focus::Primary;
2120 let mut seen = vec![f];
2121 for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
2124 f = f.next();
2125 seen.push(f);
2126 }
2127 assert_eq!(seen.first(), Some(&Focus::Primary));
2130 assert_eq!(seen.last(), Some(&Focus::Primary));
2131 assert!(seen.contains(&Focus::Key(0)));
2132 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
2133 assert!(seen.contains(&Focus::Vendor(0)));
2134 assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
2135 assert!(seen.contains(&Focus::NotifyEnabled));
2136 assert!(seen.contains(&Focus::NotifyThreshold));
2137 assert!(seen.contains(&Focus::Save));
2138 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
2141 assert_eq!(Focus::Save.prev().next(), Focus::Save);
2142 assert_eq!(
2143 Focus::Key(KEY_VENDORS.len() - 1).next(),
2144 Focus::Vendor(0),
2145 "the first provider row follows the last credential"
2146 );
2147 assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
2148 assert_eq!(
2149 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
2150 Focus::NotifyEnabled
2151 );
2152 assert_eq!(
2153 Focus::NotifyEnabled.prev(),
2154 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
2155 );
2156 assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
2157 assert_eq!(Focus::Primary.prev(), Focus::Save);
2158 }
2159
2160 #[test]
2164 fn provider_switch_count_matches_the_vendor_enumeration() {
2165 assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
2166 }
2167
2168 #[test]
2169 fn from_config_prefills_provider_switches_from_enabled_vendors() {
2170 let mut cfg = Config::default();
2171 cfg.grok.enabled = true;
2172 cfg.anthropic.enabled = false;
2173 let s = SettingsState::from_config_with(&cfg, |_| false);
2174 for (i, id) in VendorId::all().iter().enumerate() {
2175 assert_eq!(
2176 s.vendors[i],
2177 ProviderSwitch {
2178 enabled: cfg.is_enabled(*id),
2179 dirty: false
2180 },
2181 "{}",
2182 id.slug()
2183 );
2184 }
2185 assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
2186 assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
2187 }
2188
2189 fn vendor_index(id: VendorId) -> usize {
2190 VendorId::all().iter().position(|v| *v == id).unwrap()
2191 }
2192
2193 #[test]
2194 fn provider_switch_flips_on_left_right_and_space() {
2195 let mut s = blank_state(VendorId::Anthropic);
2196 let grok = vendor_index(VendorId::Grok);
2197 s.focus = Focus::Vendor(grok);
2198 assert!(!s.vendors[grok].enabled);
2199 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2200 assert_eq!(
2201 s.vendors[grok],
2202 ProviderSwitch {
2203 enabled: true,
2204 dirty: true
2205 }
2206 );
2207 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2208 assert!(!s.vendors[grok].enabled);
2209 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2210 assert!(s.vendors[grok].enabled);
2211 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2213 assert!(s.vendors[grok].enabled);
2214 assert!(s.vendors[grok].dirty);
2215 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
2217 assert!(s.vendors[grok].enabled);
2218 let before = s.vendors[grok];
2220 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
2221 assert_ne!(s.focus, Focus::Vendor(grok));
2222 assert_eq!(s.vendors[grok], before);
2223 }
2224
2225 #[test]
2226 fn save_writes_only_toggled_providers_and_round_trips() {
2227 let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
2228 let mut s = blank_state(VendorId::Anthropic);
2229 s.primary_choices = vec![VendorId::Anthropic];
2230 s.vendors[vendor_index(VendorId::Grok)].enabled = true;
2232 s.vendors[vendor_index(VendorId::Grok)].dirty = true;
2233 s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
2234 s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
2235 save_to_path(&s, &path).unwrap();
2236
2237 let raw = std::fs::read_to_string(&path).unwrap();
2238 assert!(raw.starts_with("# keep\n"), "{raw}");
2239 assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
2240 assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
2241 assert!(!raw.contains("[zai]"), "{raw}");
2245 assert!(!raw.contains("[openrouter]"), "{raw}");
2246
2247 let reloaded = Config::load_from(&path).unwrap();
2248 assert!(reloaded.is_enabled(VendorId::Grok));
2249 assert!(!reloaded.is_enabled(VendorId::Anthropic));
2250 assert!(
2251 reloaded.is_enabled(VendorId::Zai),
2252 "untouched default stays on"
2253 );
2254 }
2255
2256 #[test]
2257 fn an_untouched_overlay_save_writes_no_provider_sections() {
2258 let (_dir, path) = temp_config(None);
2259 let mut s = blank_state(VendorId::Anthropic);
2260 s.primary_choices = vec![VendorId::Anthropic];
2261 save_to_path(&s, &path).unwrap();
2262 let raw = std::fs::read_to_string(&path).unwrap();
2263 assert!(!raw.contains("enabled ="), "{raw}");
2264 assert!(!raw.contains("[grok]"), "{raw}");
2265 }
2266
2267 #[test]
2270 fn an_explicit_provider_off_wins_over_a_pasted_credential() {
2271 let (_dir, path) = temp_config(None);
2272 let mut s = blank_state(VendorId::Zai);
2273 s.primary_choices = vec![VendorId::Zai];
2274 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
2275 s.keys[key_index(VendorId::Zai)].dirty = true;
2276 s.vendors[vendor_index(VendorId::Zai)].enabled = false;
2277 s.vendors[vendor_index(VendorId::Zai)].dirty = true;
2278 save_to_path(&s, &path).unwrap();
2279 let raw = std::fs::read_to_string(&path).unwrap();
2280 assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
2281 assert!(raw.contains("enabled = false"), "{raw}");
2282 assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
2283 }
2284
2285 #[test]
2286 fn every_key_vendor_has_a_field() {
2287 for id in [
2289 VendorId::Zai,
2290 VendorId::Openrouter,
2291 VendorId::Deepseek,
2292 VendorId::Deepinfra,
2293 VendorId::Kilo,
2294 VendorId::Novita,
2295 VendorId::Synthetic,
2296 VendorId::Moonshot,
2297 VendorId::Grok,
2298 VendorId::Mistral,
2299 ] {
2300 assert!(
2301 KEY_VENDORS.iter().any(|kv| kv.id == id),
2302 "{id:?} has no key field"
2303 );
2304 }
2305 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
2307 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
2308 }
2309
2310 #[test]
2311 fn from_config_prefills_existing_keys() {
2312 let mut cfg = Config::default();
2313 cfg.kilo.api_key = Some("sk-kilo".into());
2314 let s = SettingsState::from_config(&cfg);
2315 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
2316 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
2317 }
2318
2319 #[test]
2320 fn from_config_prefills_the_notification_fields() {
2321 let mut cfg = Config::default();
2322 cfg.notifications.enabled = false;
2323 cfg.notifications.threshold = 100;
2324 let s = SettingsState::from_config_with(&cfg, |_| false);
2325 assert!(!s.notify_enabled);
2326 assert!(!s.notify_enabled_dirty);
2327 assert_eq!(s.notify_threshold.buf, "100");
2328 assert!(!s.notify_threshold.dirty);
2329 }
2330
2331 #[test]
2332 fn notification_toggle_flips_on_left_right_and_space() {
2333 let mut s = blank_state(VendorId::Anthropic);
2334 s.focus = Focus::NotifyEnabled;
2335 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2336 assert!(!s.notify_enabled);
2337 assert!(s.notify_enabled_dirty);
2338 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2339 assert!(s.notify_enabled);
2340 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2341 assert!(!s.notify_enabled);
2342 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
2344 assert!(!s.notify_enabled);
2345 }
2346
2347 #[test]
2348 fn threshold_edits_accept_digits_and_reject_everything_else() {
2349 let mut s = blank_state(VendorId::Anthropic);
2350 s.focus = Focus::NotifyThreshold;
2351 s.notify_threshold = KeyInput::default();
2353 for c in ['9', 'a', '.', '-', '→'] {
2354 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2355 }
2356 assert_eq!(s.notify_threshold.buf, "9");
2357 assert!(s.notify_threshold.dirty);
2358 handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
2359 assert_eq!(s.notify_threshold.buf, "98");
2360 handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
2361 assert_eq!(s.notify_threshold.buf, "9");
2362 }
2363
2364 #[test]
2365 fn save_writes_the_notification_fields_and_round_trips() {
2366 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2367 let mut s = blank_state(VendorId::Anthropic);
2368 s.notify_enabled = false;
2369 s.notify_enabled_dirty = true;
2370 s.notify_threshold = KeyInput::default();
2372 for c in "90".chars() {
2373 s.notify_threshold.insert_char(c);
2374 }
2375 save_to_path(&s, &path).unwrap();
2376
2377 let raw = std::fs::read_to_string(&path).unwrap();
2378 assert!(raw.contains("[notifications]"), "{raw}");
2379 assert!(raw.contains("enabled = false"), "{raw}");
2380 assert!(raw.contains("threshold = 90"), "{raw}");
2381 let reloaded = Config::load_from(&path).unwrap();
2383 assert!(!reloaded.notifications.enabled);
2384 assert_eq!(reloaded.notifications.threshold, 90);
2385 }
2386
2387 #[test]
2388 fn save_leaves_an_untouched_notification_section_alone() {
2389 let (_dir, path) = temp_config(None);
2390 let s = blank_state(VendorId::Anthropic);
2391 save_to_path(&s, &path).unwrap();
2392 let raw = std::fs::read_to_string(&path).unwrap();
2393 assert!(!raw.contains("[notifications]"), "{raw}");
2394 }
2395
2396 #[test]
2397 fn save_rejects_an_out_of_range_threshold_without_writing() {
2398 let (_dir, path) = temp_config(None);
2399 let mut s = blank_state(VendorId::Anthropic);
2400 for c in "300".chars() {
2401 s.notify_threshold.insert_char(c);
2402 }
2403 let err = save_to_path(&s, &path).unwrap_err().to_string();
2404 assert!(
2405 err.contains("[notifications] threshold must be a whole number between 1 and 100"),
2406 "{err}"
2407 );
2408 assert!(!path.exists());
2410
2411 let (_dir, path) = temp_config(None);
2413 let mut s = blank_state(VendorId::Anthropic);
2414 s.notify_threshold = KeyInput::default();
2415 s.notify_threshold.dirty = true;
2416 assert!(save_to_path(&s, &path).is_err());
2417 assert!(!path.exists());
2418 }
2419
2420 #[test]
2421 fn copilot_has_no_editable_credential_field() {
2422 assert!(
2423 !KEY_VENDORS
2424 .iter()
2425 .any(|vendor| vendor.id == VendorId::Copilot)
2426 );
2427 }
2428
2429 #[test]
2432 fn a_key_vendor_with_its_env_var_exported_is_offered() {
2433 let cfg = Config::default();
2434 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
2435
2436 let without = SettingsState::from_config_with(&cfg, |_| false);
2437 assert!(!without.primary_choices.contains(&VendorId::Ollama));
2438
2439 let with = SettingsState::from_config_with(&cfg, |name| name == env);
2440 assert!(
2441 with.primary_choices.contains(&VendorId::Ollama),
2442 "a key vendor whose env var is exported must be selectable: {:?}",
2443 with.primary_choices
2444 );
2445 }
2446
2447 #[test]
2448 fn from_config_offers_enabled_vendors_only() {
2449 let cfg = Config::default();
2450 let s = SettingsState::from_config_with(&cfg, |_| false);
2453 let mut expected = cfg.enabled_vendors();
2454 expected.push(VendorId::Copilot);
2455 assert_eq!(s.primary_choices, expected);
2456 assert!(!s.primary_choices.contains(&VendorId::Grok));
2459 assert!(s.primary_choices.contains(&s.primary));
2460 assert!(s.primary_choices.contains(&VendorId::Copilot));
2461 }
2462
2463 #[test]
2464 fn from_config_falls_back_when_configured_primary_is_disabled() {
2465 let mut cfg = Config::default();
2468 cfg.ui.primary = Some(VendorId::Grok);
2469 let s = SettingsState::from_config(&cfg);
2470 assert_ne!(s.primary, VendorId::Grok);
2471 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
2472 }
2473
2474 #[test]
2479 fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2480 let (_dir, path) = temp_config(Some(
2481 "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2482 ));
2483 let cfg = Config::load_from(&path).unwrap();
2484 let mut s = SettingsState::from_config_with(&cfg, |_| false);
2485 assert!(s.primary_choices.contains(&VendorId::Openrouter));
2486 assert_ne!(s.primary, VendorId::Openrouter);
2487
2488 s.notify_enabled = !s.notify_enabled;
2489 s.notify_enabled_dirty = true;
2490 save_to_path(&s, &path).unwrap();
2491
2492 let saved = Config::load_from(&path).unwrap();
2493 assert!(!saved.is_enabled(VendorId::Openrouter));
2494 }
2495
2496 #[test]
2497 fn key_input_insert_backspace_arrow() {
2498 let mut k = KeyInput::default();
2499 k.insert_char('a');
2500 k.insert_char('b');
2501 k.insert_char('c');
2502 assert_eq!(k.buf, "abc");
2503 assert_eq!(k.cursor, 3);
2504 assert!(k.dirty);
2505 k.move_left();
2506 k.move_left();
2507 assert_eq!(k.cursor, 1);
2508 k.insert_char('x');
2509 assert_eq!(k.buf, "axbc");
2510 assert_eq!(k.cursor, 2);
2511 k.backspace();
2512 assert_eq!(k.buf, "abc");
2513 assert_eq!(k.cursor, 1);
2514 }
2515
2516 #[test]
2517 fn key_input_masks_by_default_reveals_on_toggle() {
2518 let mut k = KeyInput::default();
2519 for c in "secret-key".chars() {
2520 k.insert_char(c);
2521 }
2522 assert_eq!(k.display(), "•".repeat(10));
2523 k.toggle_reveal();
2524 assert_eq!(k.display(), "secret-key");
2525 }
2526
2527 #[test]
2528 fn key_input_handles_unicode() {
2529 let mut k = KeyInput::default();
2530 k.insert_char('a');
2531 k.insert_char('→');
2532 k.insert_char('b');
2533 assert_eq!(k.buf, "a→b");
2534 assert_eq!(k.cursor, 3);
2535 k.move_left();
2536 k.backspace();
2537 assert_eq!(k.buf, "ab");
2538 }
2539
2540 #[test]
2541 fn value_text_shows_cursor_and_empty_states() {
2542 let mut k = KeyInput::default();
2543 assert_eq!(value_text(&k, false), "(empty)");
2544 assert_eq!(value_text(&k, true), "‸");
2545 k.insert_char('a');
2546 k.insert_char('b');
2547 assert_eq!(value_text(&k, true), "••‸");
2549 assert_eq!(value_text(&k, false), "••");
2550 }
2551
2552 #[test]
2553 fn save_writes_key_and_enables_vendor() {
2554 let (_dir, path) = temp_config(None);
2555 let mut s = blank_state(VendorId::Kilo);
2556 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2557 s.keys[key_index(VendorId::Kilo)].dirty = true;
2558 save_to_path(&s, &path).unwrap();
2559 let raw = std::fs::read_to_string(&path).unwrap();
2560 assert!(raw.contains("primary = \"kilo\""));
2561 assert!(raw.contains("[kilo]"));
2562 assert!(raw.contains("api_key = \"sk-kilo\""));
2563 assert!(raw.contains("enabled = true"));
2564 }
2565
2566 #[test]
2567 fn save_writes_minimal_toml_when_starting_empty() {
2568 let (_dir, path) = temp_config(None);
2569 let s = state_with("zk", "ok", VendorId::Zai);
2570 save_to_path(&s, &path).unwrap();
2571 let raw = std::fs::read_to_string(&path).unwrap();
2572 assert!(raw.contains("primary = \"zai\""));
2573 assert!(raw.contains("[zai]"));
2574 assert!(raw.contains("api_key = \"zk\""));
2575 assert!(raw.contains("[openrouter]"));
2576 assert!(raw.contains("api_key = \"ok\""));
2577 }
2578
2579 #[test]
2580 fn save_preserves_existing_comments_and_unrelated_fields() {
2581 let (_dir, path) = temp_config(Some(
2582 r##"# my comment
2583[ui]
2584# pre-existing comment
2585primary = "anthropic"
2586
2587[zai]
2588enabled = true
2589api_key_env = "ZAI_API_KEY"
2590# tier comment
2591plan_tier = "pro"
2592
2593[openrouter]
2594enabled = true
2595api_key_env = "OPENROUTER_API_KEY"
2596
2597[[openrouter.accounts]]
2598label = "work"
2599api_key_env = "OPENROUTER_WORK_API_KEY"
2600"##,
2601 ));
2602
2603 let s = state_with("zk2", "ok2", VendorId::Openrouter);
2604 save_to_path(&s, &path).unwrap();
2605
2606 let raw = std::fs::read_to_string(&path).unwrap();
2607 assert!(raw.contains("# my comment"));
2608 assert!(raw.contains("# pre-existing comment"));
2609 assert!(raw.contains("# tier comment"));
2610 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2611 assert!(raw.contains("[[openrouter.accounts]]"));
2612 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2613 assert!(raw.contains("plan_tier = \"pro\""));
2614 assert!(raw.contains("primary = \"openrouter\""));
2615 assert!(raw.contains("api_key = \"zk2\""));
2616 assert!(raw.contains("api_key = \"ok2\""));
2617 }
2618
2619 #[test]
2620 fn save_refuses_to_replace_an_unreadable_existing_config() {
2621 let (_dir, path) = temp_config(None);
2622 let original = [0xff, 0xfe, 0xfd];
2623 std::fs::write(&path, original).unwrap();
2624 let state = state_with("new-secret", "", VendorId::Zai);
2625
2626 assert!(save_to_path(&state, &path).is_err());
2627 assert_eq!(std::fs::read(&path).unwrap(), original);
2628 }
2629
2630 #[test]
2631 fn save_does_not_write_empty_key_when_dirty_but_blank() {
2632 let (_dir, path) = temp_config(None);
2633 let mut s = blank_state(VendorId::Anthropic);
2634 for k in &mut s.keys {
2636 k.dirty = true;
2637 }
2638 save_to_path(&s, &path).unwrap();
2639 let raw = std::fs::read_to_string(&path).unwrap();
2640 assert!(!raw.contains("api_key ="));
2641 }
2642
2643 #[test]
2644 #[cfg(unix)]
2645 fn save_chmods_to_600() {
2646 use std::os::unix::fs::PermissionsExt;
2647 let (_dir, path) = temp_config(None);
2648 let s = state_with("zk", "ok", VendorId::Zai);
2649 save_to_path(&s, &path).unwrap();
2650 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2651 assert_eq!(mode & 0o777, 0o600);
2652 }
2653
2654 #[test]
2655 fn key_vendor_for_slug_finds_only_the_key_table() {
2656 for vendor in KEY_VENDORS {
2658 assert_eq!(
2659 key_vendor_for_slug(vendor.id.slug()).map(|kv| kv.id),
2660 Some(vendor.id),
2661 "{} must resolve",
2662 vendor.id.slug()
2663 );
2664 }
2665 for id in VendorId::all() {
2668 if KEY_VENDORS.iter().any(|kv| kv.id == *id) {
2669 continue;
2670 }
2671 assert!(key_vendor_for_slug(id.slug()).is_none(), "{:?}", id);
2672 }
2673 assert!(key_vendor_for_slug("no-such-vendor").is_none());
2674 }
2675
2676 #[test]
2677 fn validate_key_value_refuses_empty_long_and_controlled() {
2678 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2679 assert!(validate_key_value(vendor, "test-key-value").is_ok());
2680 let empty = validate_key_value(vendor, "");
2681 assert!(empty.is_err());
2682 assert!(empty.unwrap_err().to_string().contains("is empty"));
2683 let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2684 let over = validate_key_value(vendor, &long);
2685 assert!(over.is_err());
2686 assert!(
2687 over.unwrap_err()
2688 .to_string()
2689 .contains(&format!("exceeds {MAX_API_KEY_BYTES}"))
2690 );
2691 let controlled = validate_key_value(vendor, "a\u{1b}[31mb");
2692 assert!(controlled.is_err());
2693 assert!(
2694 controlled
2695 .unwrap_err()
2696 .to_string()
2697 .contains("control characters")
2698 );
2699 let cfg = crate::config::Config::default();
2703 let bad = serde_json::json!({
2704 "schema_version": 1,
2705 "keys": { VendorId::Zai.slug(): { "action": "set", "value": "a\u{1b}[31mb" } }
2706 })
2707 .to_string();
2708 assert!(state_from_apply_request(&cfg, &bad).is_err());
2709 let good = serde_json::json!({
2710 "schema_version": 1,
2711 "keys": { VendorId::Zai.slug(): { "action": "set", "value": "test-key-value" } }
2712 })
2713 .to_string();
2714 assert!(state_from_apply_request(&cfg, &good).is_ok());
2715 }
2716
2717 #[test]
2718 fn inline_key_write_leaves_the_value_in_the_right_section() {
2719 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2720 let (_dir, path) = temp_config(Some("# keep me\n[ui]\nprimary = \"zai\"\n"));
2721 write_inline_key(&path, vendor, "test-key-value").unwrap();
2722 let raw = std::fs::read_to_string(&path).unwrap();
2723 assert!(raw.contains("[zai]"), "{raw}");
2724 assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2725 assert!(raw.contains("enabled = true"), "{raw}");
2727 assert!(raw.contains("# keep me"), "{raw}");
2729 assert!(raw.contains(r#"primary = "zai""#), "{raw}");
2730 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2732 assert!(snapshot.key.is_some());
2733 assert!(snapshot.enabled.is_some());
2734 }
2735
2736 #[test]
2737 #[cfg(unix)]
2738 fn inline_key_write_chmods_the_config_to_600() {
2739 use std::os::unix::fs::PermissionsExt;
2740 let vendor = key_vendor_for_slug(VendorId::Openrouter.slug()).unwrap();
2741 let (_dir, path) = temp_config(None);
2742 write_inline_key(&path, vendor, "test-key-value").unwrap();
2743 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2744 assert_eq!(mode & 0o777, 0o600);
2745 }
2746
2747 #[test]
2748 fn inline_key_write_refuses_invalid_values_without_touching_the_file() {
2749 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2750 let original = "[zai]\napi_key = \"old-key-value\"\n";
2751 let (_dir, path) = temp_config(Some(original));
2752 let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2753 for bad in ["", long.as_str(), "a\u{1b}[31mb"] {
2754 assert!(write_inline_key(&path, vendor, bad).is_err(), "{bad:?}");
2755 assert_eq!(
2756 std::fs::read_to_string(&path).unwrap(),
2757 original,
2758 "the file must not move for {bad:?}"
2759 );
2760 }
2761 let dir = tempfile::tempdir().unwrap();
2763 let empty = dir.path().join("config.toml");
2764 assert!(write_inline_key(&empty, vendor, "").is_err());
2765 assert!(!empty.exists());
2766 }
2767
2768 #[test]
2769 fn inline_key_snapshot_restores_the_exact_previous_state() {
2770 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2771 let original = "[ui]\nprimary = \"zai\"\n[zai]\n# my old key\napi_key = \"old-key-value\"\nenabled = false\n";
2774 let (_dir, path) = temp_config(Some(original));
2775 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2776 assert!(snapshot.file_existed);
2777 assert!(snapshot.section_existed);
2778 write_inline_key(&path, vendor, "test-key-value").unwrap();
2779 assert_ne!(std::fs::read_to_string(&path).unwrap(), original);
2780 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2781 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2782 }
2783
2784 #[test]
2785 fn inline_key_snapshot_restore_keeps_a_later_user_decision() {
2786 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2787 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2788 let (_dir, path) = temp_config(Some(original));
2789 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2790 write_inline_key(&path, vendor, "test-key-value").unwrap();
2791 write_inline_key(&path, vendor, "another-key-value").unwrap();
2794 crate::config::set_vendor_enabled(&path, vendor.id.slug(), false).unwrap();
2795 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2796 let raw = std::fs::read_to_string(&path).unwrap();
2797 assert!(raw.contains(r#"api_key = "another-key-value""#), "{raw}");
2799 assert!(raw.contains("enabled = false"), "{raw}");
2800 assert!(!raw.contains("test-key-value"), "{raw}");
2801 assert!(!raw.contains("old-key-value"), "{raw}");
2802 }
2803
2804 #[test]
2805 fn saved_inline_key_counts_an_empty_field_as_absent() {
2806 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2807 let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\n"));
2810 assert!(saved_inline_key(&snapshot_inline_key(&path, vendor).unwrap()).is_none());
2811 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"k\"\n"));
2812 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2813 assert_eq!(saved_inline_key(&snapshot).as_deref(), Some("k"));
2814 }
2815
2816 #[test]
2817 fn check_write_refuses_a_saved_nonempty_key() {
2818 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2819 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2820 let (_dir, path) = temp_config(Some(original));
2821 let error = match snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value") {
2822 Ok(_) => panic!("a saved key must not be replaced by the check's write"),
2823 Err(error) => error,
2824 };
2825 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2826 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2829 }
2830
2831 #[test]
2832 fn check_write_accepts_an_empty_field_and_returns_the_pre_state() {
2833 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2834 let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\nenabled = false\n"));
2835 let snapshot =
2838 snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value").unwrap();
2839 assert!(snapshot.file_existed);
2840 assert!(snapshot.section_existed);
2841 assert_eq!(
2844 saved_inline_key(&snapshot),
2845 None,
2846 "an empty field is no credential, even when captured"
2847 );
2848 let raw = std::fs::read_to_string(&path).unwrap();
2849 assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2850 assert!(raw.contains("enabled = true"), "{raw}");
2851 }
2852
2853 #[test]
2859 fn tui_save_and_check_write_serialize_on_the_config_lock() {
2860 use std::sync::mpsc;
2861 let (dir, path) = temp_config(Some("[ui]\nprimary = \"zai\"\n"));
2862 let _ = dir;
2863 let lock_path = crate::config::config_lock_path(&path);
2864
2865 let waiters =
2868 |path: &std::path::Path, tx: mpsc::Sender<()>, tx_check: mpsc::Sender<Result<()>>| {
2869 let tui_path = path.to_path_buf();
2870 let tui = std::thread::spawn(move || {
2871 let state = state_with("tui-key-value", "openrouter-key-value", VendorId::Zai);
2872 tx.send(()).unwrap();
2873 save_to_path(&state, &tui_path).unwrap();
2874 });
2875 let check_path = path.to_path_buf();
2876 let check = std::thread::spawn(move || {
2877 let vendor = key_vendor_for_slug("zai").unwrap();
2878 tx_check
2879 .send(
2880 snapshot_and_write_inline_key_if_absent(
2881 &check_path,
2882 vendor,
2883 "candidate-key-value",
2884 )
2885 .map(|_| ()),
2886 )
2887 .unwrap();
2888 });
2889 (tui, check)
2890 };
2891
2892 let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2897 .expect("the test takes the config lock");
2898 let (tx, rx) = mpsc::channel();
2899 let (tx_check, rx_check) = mpsc::channel();
2900 let (tui, check) = waiters(&path, tx, tx_check);
2901 rx.recv().unwrap();
2902 drop(held);
2903 let check_result = rx_check.recv().unwrap();
2904 tui.join().unwrap();
2905 check.join().unwrap();
2906 let raw = std::fs::read_to_string(&path).unwrap();
2907 assert!(
2908 raw.contains(r#"api_key = "tui-key-value""#),
2909 "the TUI's key must survive: {raw}"
2910 );
2911 assert!(
2912 !raw.contains("candidate-key-value"),
2913 "the candidate must not overwrite the TUI's key: {raw}"
2914 );
2915 if let Err(error) = check_result {
2916 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2919 }
2920
2921 let before = std::fs::read_to_string(&path).unwrap();
2925 let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2926 .expect("the test takes the config lock again");
2927 let (tx, rx) = mpsc::channel();
2928 let (tx_check, rx_check) = mpsc::channel();
2929 let (tui, check) = waiters(&path, tx, tx_check);
2930 rx.recv().unwrap();
2931 drop(held);
2932 let error = rx_check
2933 .recv()
2934 .unwrap()
2935 .expect_err("a saved key must refuse the candidate");
2936 tui.join().unwrap();
2937 check.join().unwrap();
2938 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2939 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2940 }
2941 #[test]
2942 fn inline_key_snapshot_removes_the_key_when_there_was_none() {
2943 let vendor = key_vendor_for_slug(VendorId::Deepseek.slug()).unwrap();
2944 let original = "[ui]\nprimary = \"deepseek\"\n";
2945 let (_dir, path) = temp_config(Some(original));
2946 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2947 assert!(!snapshot.section_existed);
2948 write_inline_key(&path, vendor, "test-key-value").unwrap();
2949 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2950 let raw = std::fs::read_to_string(&path).unwrap();
2951 assert!(!raw.contains("api_key"), "the key must be gone: {raw}");
2954 assert!(
2955 !raw.contains("[deepseek]"),
2956 "the section must be gone: {raw}"
2957 );
2958 assert!(raw.contains(r#"primary = "deepseek""#), "{raw}");
2959 }
2960
2961 #[test]
2962 fn inline_key_snapshot_of_a_missing_file_removes_it_on_restore() {
2963 let vendor = key_vendor_for_slug(VendorId::Kimi.slug()).unwrap();
2964 let (dir, path) = temp_config(None);
2965 let _ = dir;
2966 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2967 assert!(!snapshot.file_existed);
2968 assert!(!snapshot.section_existed);
2969 assert!(snapshot.key.is_none());
2970 assert!(snapshot.enabled.is_none());
2971 write_inline_key(&path, vendor, "test-key-value").unwrap();
2972 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2973 assert!(!path.exists(), "the file the check created must be gone");
2974 }
2975
2976 #[test]
2977 fn inline_key_snapshot_keeps_edits_made_elsewhere_in_the_meantime() {
2978 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2979 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2980 let (_dir, path) = temp_config(Some(original));
2981 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2982 write_inline_key(&path, vendor, "test-key-value").unwrap();
2983 let mut doc = crate::config::read_config_document(&path).unwrap();
2985 set_string(&mut doc, "zai", "api_key_env", "SOME_ENV_VAR").unwrap();
2986 crate::config::write_config_document(&path, &doc).unwrap();
2987 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2988 let raw = std::fs::read_to_string(&path).unwrap();
2989 assert!(raw.contains(r#"api_key = "old-key-value""#), "{raw}");
2991 assert!(raw.contains(r#"api_key_env = "SOME_ENV_VAR""#), "{raw}");
2993 }
2994
2995 #[test]
2996 fn tab_cycles_focus_from_primary_to_first_key() {
2997 let mut s = blank_state(VendorId::Anthropic);
2998 assert_eq!(
2999 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
3000 Action::Continue
3001 );
3002 assert_eq!(s.focus, Focus::Key(0));
3003 assert_eq!(
3004 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
3005 Action::Continue
3006 );
3007 assert_eq!(s.focus, Focus::Primary);
3008 }
3009
3010 #[test]
3011 fn esc_closes_without_saving() {
3012 let mut s = blank_state(VendorId::Anthropic);
3013 assert_eq!(
3014 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3015 Action::Close
3016 );
3017 }
3018
3019 #[test]
3020 fn left_right_cycles_primary_vendor() {
3021 let mut s = blank_state(VendorId::Anthropic);
3023 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3024 assert_eq!(s.primary, VendorId::AnthropicApi);
3025 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3026 assert_eq!(s.primary, VendorId::Openai);
3027 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3028 assert_eq!(s.primary, VendorId::AnthropicApi);
3029 }
3030
3031 #[test]
3032 fn left_right_offers_enabled_vendors_only() {
3033 let mut s = blank_state(VendorId::Anthropic);
3035 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
3036 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3037 assert_eq!(s.primary, VendorId::Grok);
3038 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3040 assert_eq!(s.primary, VendorId::Anthropic);
3041 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3042 assert_eq!(s.primary, VendorId::Grok);
3043 }
3044
3045 #[test]
3046 fn no_enabled_vendors_leaves_primary_selector_inert() {
3047 let mut s = blank_state(VendorId::Anthropic);
3048 s.primary_choices = vec![];
3049 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3050 assert_eq!(s.primary, VendorId::Anthropic);
3051 }
3052
3053 #[test]
3054 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
3055 let mut cfg = Config::default();
3056 cfg.ui.primary = Some(VendorId::Copilot);
3057 let state = SettingsState::from_config(&cfg);
3058
3059 assert!(state.primary_choices.contains(&VendorId::Copilot));
3060 assert_eq!(state.primary, VendorId::Anthropic);
3061 }
3062
3063 #[test]
3064 fn save_does_not_write_a_disabled_primary() {
3065 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
3068 let mut s = state_with("zk", "ok", VendorId::Grok);
3069 s.primary_choices = vec![VendorId::Anthropic];
3070 save_to_path(&s, &path).unwrap();
3071 let raw = std::fs::read_to_string(&path).unwrap();
3072 assert!(raw.contains("primary = \"anthropic\""));
3073 assert!(!raw.contains("primary = \"grok\""));
3074 assert!(raw.contains("zk"));
3076 }
3077
3078 #[test]
3079 fn save_removes_an_inline_key_the_user_cleared() {
3080 let (_dir, path) = temp_config(Some(
3083 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
3084 ));
3085 let mut s = blank_state(VendorId::Zai);
3086 s.primary_choices = vec![VendorId::Zai];
3087 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
3088 s.keys[key_index(VendorId::Zai)].dirty = true;
3089 save_to_path(&s, &path).unwrap();
3090 let raw = std::fs::read_to_string(&path).unwrap();
3091 assert!(!raw.contains("old-secret"));
3092 assert!(!raw.contains("api_key"));
3093 assert!(raw.contains("plan_tier = \"pro\""));
3095 }
3096
3097 #[test]
3098 fn save_clears_a_key_from_a_toml_inline_table() {
3099 let (_dir, path) = temp_config(Some(
3100 "zai = { enabled = false, api_key = \"remove-me\", plan_tier = \"pro\" } # keep me\n\
3101 [ui]\nprimary = \"anthropic\"\n",
3102 ));
3103 let cfg = Config::load_from(&path).unwrap();
3104 let mut state = SettingsState::from_config_with(&cfg, |_| false);
3105 let input = &mut state.keys[key_index(VendorId::Zai)];
3106 *input = KeyInput::default();
3107 input.dirty = true;
3108
3109 save_to_path(&state, &path).unwrap();
3110
3111 let reloaded = Config::load_from(&path).unwrap();
3112 assert!(reloaded.zai.api_key.is_none());
3113 assert!(!reloaded.zai.enabled);
3114 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3115 let raw = std::fs::read_to_string(&path).unwrap();
3116 assert!(!raw.contains("remove-me"));
3117 assert!(raw.contains("zai = {"));
3118 assert!(raw.contains("# keep me"));
3119 }
3120
3121 #[test]
3122 fn save_updates_a_key_and_enables_a_toml_inline_table() {
3123 let (_dir, path) = temp_config(Some(
3124 "zai = { enabled = false, api_key = \"old-secret\", plan_tier = \"pro\" } # keep me\n\
3125 [ui]\nprimary = \"anthropic\"\n",
3126 ));
3127 let cfg = Config::load_from(&path).unwrap();
3128 let mut state = SettingsState::from_config_with(&cfg, |_| false);
3129 let input = &mut state.keys[key_index(VendorId::Zai)];
3130 input.buf = "new-secret".into();
3131 input.dirty = true;
3132
3133 save_to_path(&state, &path).unwrap();
3134
3135 let reloaded = Config::load_from(&path).unwrap();
3136 assert_eq!(reloaded.zai.api_key.as_deref(), Some("new-secret"));
3137 assert!(reloaded.zai.enabled);
3138 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3139 let raw = std::fs::read_to_string(&path).unwrap();
3140 assert!(!raw.contains("old-secret"));
3141 assert!(raw.contains("new-secret"));
3142 assert!(raw.contains("zai = {"));
3143 assert!(raw.contains("# keep me"));
3144 }
3145
3146 #[test]
3147 fn enable_vendor_at_enables_a_toml_inline_table() {
3148 let (_dir, path) = temp_config(Some(
3149 "zai = { enabled = false, plan_tier = \"pro\" } # keep me\n\
3150 [ui]\nprimary = \"anthropic\"\n",
3151 ));
3152 enable_vendor_at(&path, VendorId::Zai).unwrap();
3153
3154 let reloaded = Config::load_from(&path).unwrap();
3155 assert!(reloaded.zai.enabled);
3156 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3157 let raw = std::fs::read_to_string(&path).unwrap();
3158 assert!(raw.contains("zai = {"));
3159 assert!(raw.contains("# keep me"));
3160 }
3161
3162 #[test]
3163 fn untouched_key_field_is_left_alone() {
3164 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
3166 let mut s = blank_state(VendorId::Zai);
3167 s.primary_choices = vec![VendorId::Zai];
3168 save_to_path(&s, &path).unwrap();
3169 let raw = std::fs::read_to_string(&path).unwrap();
3170 assert!(raw.contains("keep-me"));
3171 }
3172
3173 #[test]
3174 fn typing_edits_the_focused_key_only() {
3175 let mut s = blank_state(VendorId::Anthropic);
3176 s.focus = Focus::Key(key_index(VendorId::Grok));
3177 for c in "xai-abc".chars() {
3178 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
3179 }
3180 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
3181 assert!(s.keys[key_index(VendorId::Grok)].dirty);
3182 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
3184 }
3185
3186 #[test]
3187 fn ctrl_v_toggles_reveal_on_focused_key_field() {
3188 let mut s = blank_state(VendorId::Anthropic);
3189 let zi = key_index(VendorId::Zai);
3190 s.focus = Focus::Key(zi);
3191 s.keys[zi] = KeyInput::from_config(Some("secret"));
3192 assert!(!s.keys[zi].revealed);
3193 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3194 assert!(s.keys[zi].revealed);
3195 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3196 assert!(!s.keys[zi].revealed);
3197 }
3198
3199 #[test]
3200 fn control_chorded_chars_do_not_type_into_fields() {
3201 let mut s = blank_state(VendorId::Anthropic);
3202 s.focus = Focus::Key(0);
3203 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
3205 assert!(s.keys[0].buf.is_empty());
3206 assert!(!s.keys[0].dirty);
3207 assert_eq!(
3209 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3210 Action::Quit
3211 );
3212 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
3214 assert_eq!(s.keys[0].buf, "x");
3215 }
3216
3217 #[test]
3218 fn ctrl_v_on_non_key_focus_is_noop() {
3219 let mut s = blank_state(VendorId::Anthropic);
3220 s.focus = Focus::Primary;
3221 assert_eq!(
3223 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
3224 Action::Continue
3225 );
3226 }
3227
3228 fn state_focused_on_zai() -> SettingsState {
3229 let mut state = blank_state(VendorId::Anthropic);
3230 state.focus = Focus::Key(key_index(VendorId::Zai));
3231 state
3232 }
3233
3234 #[test]
3235 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
3236 let mut s = state_focused_on_zai();
3237 let zi = key_index(VendorId::Zai);
3238 assert_eq!(
3239 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3240 Action::Quit
3241 );
3242 assert!(s.keys[zi].buf.is_empty());
3243 assert!(!s.keys[zi].dirty);
3245 }
3246
3247 #[test]
3248 fn handle_key_alt_chord_does_not_type_into_key_field() {
3249 let mut s = state_focused_on_zai();
3250 let zi = key_index(VendorId::Zai);
3251 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
3252 assert!(s.keys[zi].buf.is_empty());
3253 assert!(!s.keys[zi].dirty);
3254 }
3255
3256 #[test]
3257 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
3258 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
3259 let mut s = state_focused_on_zai();
3260 let zi = key_index(VendorId::Zai);
3261 handle_key(&mut s, KeyCode::Char('x'), modifier);
3262 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
3263 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
3264 }
3265 }
3266
3267 #[test]
3268 fn handle_key_shift_still_types_uppercase() {
3269 let mut s = state_focused_on_zai();
3270 let zi = key_index(VendorId::Zai);
3271 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
3272 assert_eq!(s.keys[zi].buf, "A");
3273 assert!(s.keys[zi].dirty);
3274 }
3275
3276 #[test]
3277 fn handle_key_plain_space_still_cycles_primary_vendor() {
3278 let mut s = blank_state(VendorId::Anthropic);
3279 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
3280 assert_eq!(s.primary, VendorId::AnthropicApi);
3281 }
3282
3283 #[test]
3284 fn handle_key_ctrl_s_attempts_save_from_any_field() {
3285 let (_dir, path) = temp_config(None);
3286 let s = state_with("zk", "ok", VendorId::Zai);
3287 save_to_path(&s, &path).unwrap();
3288 let raw = std::fs::read_to_string(&path).unwrap();
3289 assert!(raw.contains("api_key = \"zk\""));
3290 }
3291 #[test]
3292 fn save_to_path_writes_kimi_key_when_dirty() {
3293 let (_dir, path) = temp_config(None);
3294 let mut s = blank_state(VendorId::Anthropic);
3295 let kimi = key_index(VendorId::Kimi);
3296 s.keys[kimi] = KeyInput::from_config(Some("kk"));
3297 s.keys[kimi].dirty = true;
3298 save_to_path(&s, &path).unwrap();
3299 let raw = std::fs::read_to_string(&path).unwrap();
3300 assert!(raw.contains("[kimi]"));
3301 assert!(raw.contains("api_key = \"kk\""));
3302 }
3303
3304 #[test]
3305 fn settings_save_uses_the_same_config_path_as_load() {
3306 assert_eq!(
3307 default_config_path().unwrap(),
3308 crate::config::resolved_path().unwrap()
3309 );
3310 }
3311
3312 #[test]
3313 fn native_snapshot_reports_key_state_without_serializing_secrets() {
3314 let mut cfg = Config::default();
3315 cfg.zai.api_key = Some("never-leak-this-key".into());
3316 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
3317 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
3318 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3319
3320 assert_eq!(parsed["schema_version"], 1);
3321 assert_eq!(parsed["primary"], "anthropic");
3322 let zai = parsed["keys"]
3323 .as_array()
3324 .unwrap()
3325 .iter()
3326 .find(|row| row["id"] == "zai")
3327 .unwrap();
3328 assert_eq!(zai["configured"], true);
3329 assert_eq!(zai["inline_configured"], true);
3330 assert_eq!(zai["environment_configured"], true);
3331 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
3332 assert!(!raw.contains("never-leak-this-key"));
3333 assert!(parsed.get("api_key").is_none());
3334 }
3335
3336 #[test]
3337 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
3338 let cfg = Config::default();
3339 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3340 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3341 assert!(
3342 parsed["primary_choices"]
3343 .as_array()
3344 .unwrap()
3345 .iter()
3346 .any(|row| row["id"] == "copilot")
3347 );
3348 assert!(
3349 !parsed["keys"]
3350 .as_array()
3351 .unwrap()
3352 .iter()
3353 .any(|row| row["id"] == "copilot")
3354 );
3355 }
3356
3357 #[test]
3358 fn native_key_only_patch_does_not_require_or_replace_primary() {
3359 let cfg = Config::default();
3360 let original_primary = SettingsState::from_config(&cfg).primary;
3361 let request = serde_json::json!({
3362 "schema_version": 1,
3363 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
3364 });
3365
3366 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3367 assert_eq!(state.primary, original_primary);
3368 let kimi_index = KEY_VENDORS
3369 .iter()
3370 .position(|vendor| vendor.id == VendorId::Kimi)
3371 .unwrap();
3372 assert!(state.keys[kimi_index].dirty);
3373 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
3374 }
3375
3376 #[test]
3377 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
3378 let (_dir, path) = temp_config(Some(
3379 r#"# keep this comment
3380[ui]
3381primary = "anthropic"
3382
3383[zai]
3384enabled = true
3385api_key_env = "ZAI_API_KEY"
3386plan_tier = "pro"
3387
3388[openrouter]
3389enabled = true
3390"#,
3391 ));
3392 let cfg = Config::load_from(&path).unwrap();
3393 let request = serde_json::json!({
3394 "schema_version": 1,
3395 "primary": "openrouter",
3396 "keys": {
3397 "zai": {"action": "set", "value": "new-zai-key"}
3398 }
3399 });
3400
3401 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3402 let raw = std::fs::read_to_string(&path).unwrap();
3403 assert!(raw.contains("# keep this comment"));
3404 assert!(raw.contains("plan_tier = \"pro\""));
3405 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
3406 assert!(raw.contains("primary = \"openrouter\""));
3407 assert!(raw.contains("api_key = \"new-zai-key\""));
3408 }
3409
3410 #[test]
3411 fn native_patch_distinguishes_clear_from_unchanged() {
3412 let (_dir, path) = temp_config(Some(
3413 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
3414 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
3415 ));
3416 let cfg = Config::load_from(&path).unwrap();
3417 let request = serde_json::json!({
3418 "schema_version": 1,
3419 "primary": "zai",
3420 "keys": {"zai": {"action": "clear"}}
3421 });
3422
3423 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3424 let raw = std::fs::read_to_string(&path).unwrap();
3425 assert!(!raw.contains("remove-me"));
3426 assert!(raw.contains("keep-me"));
3427 }
3428
3429 #[test]
3430 fn native_primary_selection_enables_copilot_without_writing_a_token() {
3431 let (_dir, path) = temp_config(Some(
3432 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
3433 ));
3434 let cfg = Config::load_from(&path).unwrap();
3435 let select = serde_json::json!({
3436 "schema_version": 1,
3437 "primary": "copilot"
3438 });
3439 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
3440 let raw = std::fs::read_to_string(&path).unwrap();
3441 assert!(raw.contains("enabled = true"));
3442 assert!(raw.contains("primary = \"copilot\""));
3443 assert!(!raw.contains("token ="));
3444 assert!(!raw.contains("token_env ="));
3445 }
3446
3447 #[test]
3448 fn native_patch_errors_never_echo_key_values() {
3449 let raw = serde_json::json!({
3450 "schema_version": 1,
3451 "primary": "anthropic",
3452 "keys": {
3453 "zai": {"action": "set", "value": "secret\nwith-control"}
3454 }
3455 })
3456 .to_string();
3457 let error = state_from_apply_request(&Config::default(), &raw)
3458 .unwrap_err()
3459 .to_string();
3460 assert!(!error.contains("secret"));
3461 assert!(error.contains("control characters"));
3462 }
3463
3464 #[test]
3465 fn native_patch_input_is_bounded_before_json_parsing() {
3466 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
3467 let error = read_settings_request(std::io::Cursor::new(oversized))
3468 .unwrap_err()
3469 .to_string();
3470 assert!(error.contains("exceeds"));
3471 }
3472
3473 #[test]
3476 fn native_snapshot_lists_every_provider_with_its_enabled_state() {
3477 let mut cfg = Config::default();
3478 cfg.grok.enabled = true;
3479 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3480 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3481
3482 let vendors = parsed["vendors"].as_array().unwrap();
3483 assert_eq!(vendors.len(), VendorId::all().len());
3484 let by_id = |slug: &str| {
3485 vendors
3486 .iter()
3487 .find(|row| row["id"] == slug)
3488 .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
3489 .clone()
3490 };
3491 assert_eq!(by_id("anthropic")["enabled"], true);
3492 assert_eq!(by_id("grok")["enabled"], true);
3493 assert_eq!(by_id("kimi")["enabled"], false);
3494 assert_eq!(by_id("anthropic")["label"], "Claude");
3496 assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
3497 }
3498
3499 #[test]
3500 fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
3501 let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
3502 let cfg = Config::load_from(&path).unwrap();
3503 let request = serde_json::json!({
3504 "schema_version": 1,
3505 "vendors": {"grok": true, "zai": false}
3506 });
3507
3508 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3509
3510 let raw = std::fs::read_to_string(&path).unwrap();
3511 assert!(raw.starts_with("# keep\n"), "{raw}");
3512 assert!(raw.contains("api_key = \"k\""), "{raw}");
3513 let reloaded = Config::load_from(&path).unwrap();
3514 assert!(reloaded.is_enabled(VendorId::Grok));
3515 assert!(!reloaded.is_enabled(VendorId::Zai));
3516 }
3517
3518 #[test]
3519 fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
3520 let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
3521 let cfg = Config::load_from(&path).unwrap();
3522 for bad in ["", "mytool", "not-a-vendor"] {
3523 let request = serde_json::json!({
3524 "schema_version": 1,
3525 "vendors": {bad: true}
3526 });
3527 let error = state_from_apply_request(&cfg, &request.to_string())
3528 .unwrap_err()
3529 .to_string();
3530 assert!(error.contains("unknown provider"), "{bad}: {error}");
3531 }
3532 assert_eq!(
3534 std::fs::read_to_string(&path).unwrap(),
3535 "[zai]\nenabled = true\n"
3536 );
3537 }
3538
3539 #[test]
3541 fn native_vendor_toggle_alone_is_a_valid_patch() {
3542 let cfg = Config::default();
3543 let request = serde_json::json!({
3544 "schema_version": 1,
3545 "vendors": {"kimi": true}
3546 });
3547 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3548 let kimi = vendor_index(VendorId::Kimi);
3549 assert_eq!(
3550 state.vendors[kimi],
3551 ProviderSwitch {
3552 enabled: true,
3553 dirty: true
3554 }
3555 );
3556 }
3557
3558 #[test]
3561 fn body_layout_positions_every_focusable_row_without_overlap() {
3562 let keys = 5;
3563 let vendors = 7;
3564 let layout = body_layout(keys, vendors);
3565 assert_eq!(layout.primary, 1);
3567 assert_eq!(layout.first_key, 4);
3568 assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
3570 assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
3571 assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
3572 assert_eq!(layout.save, layout.notify_threshold + 2);
3573 assert_eq!(layout.rows, layout.save + 1);
3574 let mut rows = vec![
3576 layout.primary,
3577 layout.notify_enabled,
3578 layout.notify_threshold,
3579 layout.save,
3580 ];
3581 rows.extend(layout.first_key..layout.first_key + keys);
3582 rows.extend(layout.first_vendor..layout.first_vendor + vendors);
3583 rows.sort_unstable();
3584 let mut unique = rows.clone();
3585 unique.dedup();
3586 assert_eq!(rows, unique);
3587 assert!(rows.iter().all(|row| *row < layout.rows));
3588 }
3589
3590 #[test]
3591 fn focus_line_matches_the_body_layout_for_every_focus_variant() {
3592 let state = blank_state(VendorId::Anthropic);
3593 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
3594 let cases = [
3595 (Focus::Primary, layout.primary),
3596 (Focus::Key(0), layout.first_key),
3597 (
3598 Focus::Key(KEY_VENDORS.len() - 1),
3599 layout.first_key + KEY_VENDORS.len() - 1,
3600 ),
3601 (Focus::Vendor(0), layout.first_vendor),
3602 (
3603 Focus::Vendor(state.vendors.len() - 1),
3604 layout.first_vendor + state.vendors.len() - 1,
3605 ),
3606 (Focus::NotifyEnabled, layout.notify_enabled),
3607 (Focus::NotifyThreshold, layout.notify_threshold),
3608 (Focus::Save, layout.save),
3609 ];
3610 for (focus, want) in cases {
3611 let mut s = state.clone();
3612 s.focus = focus;
3613 assert_eq!(focus_line(&s), want, "{focus:?}");
3614 }
3615 }
3616
3617 #[test]
3618 fn follow_focus_scroll_sticks_jumps_and_clamps() {
3619 assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
3621 assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
3622 assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
3624 assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
3625 assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
3627 assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
3629 assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
3630 assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
3632 assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
3633 }
3634
3635 #[test]
3638 fn render_records_clickable_hint_links_for_the_focused_control() {
3639 use ratatui::Terminal;
3640 use ratatui::backend::TestBackend;
3641
3642 let theme = crate::theme::Theme::default();
3643 let mut state = blank_state(VendorId::Anthropic);
3644 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3645 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3646 let mut hits = Vec::new();
3647 terminal
3648 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3649 .unwrap();
3650
3651 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3652 .iter()
3653 .filter_map(|(row, _)| match row {
3654 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3655 _ => None,
3656 })
3657 .collect();
3658 assert_eq!(
3661 hint_keys,
3662 vec![
3663 (KeyCode::Char(' '), KeyModifiers::NONE),
3664 (KeyCode::Char('s'), KeyModifiers::CONTROL),
3665 (KeyCode::Esc, KeyModifiers::NONE),
3666 ]
3667 );
3668 let ys: Vec<u16> = hits
3670 .iter()
3671 .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
3672 .map(|(_, rect)| rect.y)
3673 .collect();
3674 assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
3675
3676 let mut state = blank_state(VendorId::Anthropic);
3679 state.focus = Focus::Primary;
3680 let mut hits = Vec::new();
3681 terminal
3682 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3683 .unwrap();
3684 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3685 .iter()
3686 .filter_map(|(row, _)| match row {
3687 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3688 _ => None,
3689 })
3690 .collect();
3691 assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3692 assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3693 }
3694
3695 #[test]
3699 fn render_records_switch_cells_right_of_the_labels() {
3700 use ratatui::Terminal;
3701 use ratatui::backend::TestBackend;
3702
3703 let theme = crate::theme::Theme::default();
3704 let mut state = blank_state(VendorId::Anthropic);
3705 state.focus = Focus::Vendor(0);
3706 let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap();
3707 let mut hits = Vec::new();
3708 terminal
3709 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3710 .unwrap();
3711
3712 let focus_of = |i: usize| {
3713 hits.iter().find_map(|(row, rect)| match row {
3714 SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3715 _ => None,
3716 })
3717 };
3718 for (i, id) in VendorId::all().iter().enumerate() {
3719 let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3720 let switch = hits
3721 .iter()
3722 .find_map(|(row, rect)| match row {
3723 SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3724 Some(*rect)
3725 }
3726 _ => None,
3727 })
3728 .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3729 let expected_x =
3734 5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3735 let value_len = if state.vendors[i].enabled { 2 } else { 3 }; let focused = state.focus == Focus::Vendor(i);
3737 assert_eq!(switch.y, focus_rect.y);
3738 assert_eq!(switch.x, focus_rect.x + expected_x);
3739 assert_eq!(
3740 switch.width,
3741 (if focused { 6 } else { 2 }) + value_len,
3742 "rendered value segment"
3743 );
3744 assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3745 }
3746 assert!(hits.iter().any(|(row, _)| matches!(
3748 row,
3749 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3750 )));
3751 }
3752
3753 #[test]
3756 fn render_records_primary_arrow_cells_when_focused() {
3757 use ratatui::Terminal;
3758 use ratatui::backend::TestBackend;
3759
3760 let theme = crate::theme::Theme::default();
3761 let mut state = blank_state(VendorId::Anthropic);
3762 state.focus = Focus::Primary;
3763 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3764 let mut hits = Vec::new();
3765 terminal
3766 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3767 .unwrap();
3768
3769 let focus_rect = hits
3770 .iter()
3771 .find_map(|(row, rect)| match row {
3772 SettingsRow::Focus(Focus::Primary) => Some(*rect),
3773 _ => None,
3774 })
3775 .expect("primary row has a focus target");
3776 let prev = hits.iter().find_map(|(row, rect)| match row {
3777 SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3778 _ => None,
3779 });
3780 let next = hits.iter().find_map(|(row, rect)| match row {
3781 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3782 _ => None,
3783 });
3784 let prev = prev.expect("focused primary has a ◀ cell");
3785 let next = next.expect("focused primary has a ▶ cell");
3786 assert_eq!(prev.x, focus_rect.x + 5);
3787 assert_eq!(prev.width, 2);
3788 assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3789 assert_eq!(next.width, 2);
3790 assert_eq!(prev.y, next.y);
3791
3792 let mut state = blank_state(VendorId::Anthropic);
3795 state.focus = Focus::Vendor(0);
3796 let mut unfocused_hits = Vec::new();
3797 terminal
3798 .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3799 .unwrap();
3800 assert!(
3801 !unfocused_hits
3802 .iter()
3803 .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3804 );
3805 }
3806
3807 #[test]
3811 fn primary_arrow_cells_sit_at_fixed_columns() {
3812 use ratatui::Terminal;
3813 use ratatui::backend::TestBackend;
3814
3815 let theme = crate::theme::Theme::default();
3816 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3817 let mut arrow_x = |primary: VendorId| {
3818 let mut state = blank_state(primary);
3819 state.focus = Focus::Primary;
3820 let mut hits = Vec::new();
3821 terminal
3822 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3823 .unwrap();
3824 hits.iter()
3825 .find_map(|(row, rect)| match row {
3826 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3827 _ => None,
3828 })
3829 .expect("focused primary has a ▶ cell")
3830 };
3831 assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3832 }
3833
3834 #[test]
3838 fn picker_key_handling_selects_and_closes() {
3839 let mut s = blank_state(VendorId::Anthropic);
3840 s.focus = Focus::Primary;
3841 s.picker = Some(PrimaryPicker {
3842 cursor: 0,
3843 scroll: 0,
3844 });
3845 let count = s.primary_choices.len();
3846 assert!(count > 1);
3847
3848 assert_eq!(
3849 handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3850 Action::Continue
3851 );
3852 assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3853 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3855 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3856 assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3857
3858 let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3860 assert_eq!(
3861 handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3862 Action::Continue
3863 );
3864 assert_eq!(s.primary, expected);
3865 assert!(s.picker.is_none());
3866
3867 s.picker = Some(PrimaryPicker {
3870 cursor: 2,
3871 scroll: 0,
3872 });
3873 assert_eq!(
3874 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3875 Action::Continue
3876 );
3877 assert!(s.picker.is_none());
3878 assert_eq!(
3879 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3880 Action::Close
3881 );
3882 }
3883
3884 #[test]
3886 fn open_picker_records_one_click_row_per_choice() {
3887 use ratatui::Terminal;
3888 use ratatui::backend::TestBackend;
3889
3890 let theme = crate::theme::Theme::default();
3891 let mut state = blank_state(VendorId::Anthropic);
3892 state.focus = Focus::Primary;
3893 state.picker = Some(PrimaryPicker {
3894 cursor: 0,
3895 scroll: 0,
3896 });
3897 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3898 let mut hits = Vec::new();
3899 terminal
3900 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3901 .unwrap();
3902
3903 let picks: Vec<usize> = hits
3904 .iter()
3905 .filter_map(|(row, _)| match row {
3906 SettingsRow::Pick(i) => Some(*i),
3907 _ => None,
3908 })
3909 .collect();
3910 assert_eq!(picks.len(), state.primary_choices.len());
3911 assert_eq!(picks.first().copied(), Some(0));
3912 assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3913 }
3914
3915 #[test]
3919 fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3920 use ratatui::Terminal;
3921 use ratatui::backend::TestBackend;
3922
3923 let backend = TestBackend::new(100, 24);
3924 let mut terminal = Terminal::new(backend).unwrap();
3925 let theme = crate::theme::Theme::default();
3926 let mut state = blank_state(VendorId::Anthropic);
3927 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3928 let mut hits = Vec::new();
3929
3930 terminal
3931 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3932 .unwrap();
3933
3934 let rendered = buffer_text(terminal.backend().buffer());
3935 assert!(
3936 rendered.iter().any(|line| line.contains("Grok")),
3937 "focused provider row must be visible: {rendered:?}"
3938 );
3939 let mut state = blank_state(VendorId::Anthropic);
3942 state.focus = Focus::Save;
3943 let mut hits = Vec::new();
3944 terminal
3945 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3946 .unwrap();
3947 let rendered = buffer_text(terminal.backend().buffer());
3948 assert!(
3949 rendered.iter().any(|line| line.contains("Save")),
3950 "Save must stay visible: {rendered:?}"
3951 );
3952 }
3953
3954 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3956 let area = buffer.area;
3957 let mut lines = Vec::new();
3958 for y in area.top()..area.bottom() {
3959 let mut line = String::new();
3960 for x in area.left()..area.right() {
3961 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3962 }
3963 lines.push(line.trim_end().to_string());
3964 }
3965 lines
3966 }
3967}