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