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