1use std::collections::BTreeMap;
19use std::io::BufRead;
20use std::path::{Path, PathBuf};
21
22use ratatui::Frame;
23use ratatui::layout::{Constraint, Direction, Layout, Rect};
24use ratatui::style::Modifier;
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Clear, Paragraph};
27use ratatui_bubbletea_theme::BubbleTheme;
28use serde::{Deserialize, Serialize};
29use toml_edit::{DocumentMut, value};
30
31use crate::config::{Config, read_config_document, set_bool, write_config_document};
32use crate::error::{AppError, Result};
33use crate::theme::Theme;
34use crate::tui::style::bubble_theme;
35use crate::vendor::VendorId;
36
37pub struct KeyVendor {
41 pub id: VendorId,
42 pub label: &'static str,
43 pub section: &'static str,
44 pub config_key: &'static str,
46 pub secret_label: &'static str,
48 pub note: &'static str,
50}
51
52pub const KEY_VENDORS: &[KeyVendor] = &[
53 KeyVendor {
54 id: VendorId::AnthropicApi,
55 label: "Anthropic API",
56 section: VendorId::AnthropicApi.config_section(),
57 config_key: "api_key",
58 secret_label: "API key",
59 note: "admin key — monthly spend",
60 },
61 KeyVendor {
62 id: VendorId::Zai,
63 label: "Z.AI",
64 section: VendorId::Zai.config_section(),
65 config_key: "api_key",
66 secret_label: "API key",
67 note: "",
68 },
69 KeyVendor {
70 id: VendorId::Openrouter,
71 label: "OpenRouter",
72 section: VendorId::Openrouter.config_section(),
73 config_key: "api_key",
74 secret_label: "API key",
75 note: "",
76 },
77 KeyVendor {
78 id: VendorId::Deepseek,
79 label: "DeepSeek",
80 section: VendorId::Deepseek.config_section(),
81 config_key: "api_key",
82 secret_label: "API key",
83 note: "",
84 },
85 KeyVendor {
86 id: VendorId::Kimi,
87 label: "Kimi",
88 section: VendorId::Kimi.config_section(),
89 config_key: "api_key",
90 secret_label: "API key",
91 note: "coding-plan usage",
92 },
93 KeyVendor {
94 id: VendorId::Kilo,
95 label: "Kilo",
96 section: VendorId::Kilo.config_section(),
97 config_key: "api_key",
98 secret_label: "API key",
99 note: "",
100 },
101 KeyVendor {
102 id: VendorId::Novita,
103 label: "Novita",
104 section: VendorId::Novita.config_section(),
105 config_key: "api_key",
106 secret_label: "API key",
107 note: "",
108 },
109 KeyVendor {
110 id: VendorId::Moonshot,
111 label: "Moonshot",
112 section: VendorId::Moonshot.config_section(),
113 config_key: "api_key",
114 secret_label: "API key",
115 note: "account balance",
116 },
117 KeyVendor {
118 id: VendorId::Grok,
119 label: "Grok",
120 section: VendorId::Grok.config_section(),
121 config_key: "api_key",
122 secret_label: "API key",
123 note: "management key, not the inference key",
124 },
125 KeyVendor {
126 id: VendorId::Minimax,
127 label: "MiniMax",
128 section: VendorId::Minimax.config_section(),
129 config_key: "api_key",
130 secret_label: "API key",
131 note: "Token Plan subscription key",
132 },
133 KeyVendor {
134 id: VendorId::OpenCodeGo,
135 label: "OpenCode Go",
136 section: VendorId::OpenCodeGo.config_section(),
137 config_key: "api_key",
138 secret_label: "API key",
139 note: "usage quota",
140 },
141];
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Focus {
146 Primary,
147 Key(usize),
148 Save,
149}
150
151impl Focus {
152 pub fn next(self) -> Self {
153 match self {
154 Focus::Primary => Focus::Key(0),
155 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
156 Focus::Key(_) => Focus::Save,
157 Focus::Save => Focus::Primary,
158 }
159 }
160 pub fn prev(self) -> Self {
161 match self {
162 Focus::Primary => Focus::Save,
163 Focus::Key(0) => Focus::Primary,
164 Focus::Key(i) => Focus::Key(i - 1),
165 Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct KeyInput {
173 pub buf: String,
174 pub cursor: usize,
176 pub revealed: bool,
178 pub dirty: bool,
182}
183
184impl KeyInput {
185 pub fn from_config(initial: Option<&str>) -> Self {
186 let buf = initial.unwrap_or("").to_string();
187 let cursor = buf.chars().count();
188 Self {
189 buf,
190 cursor,
191 revealed: false,
192 dirty: false,
193 }
194 }
195
196 pub fn insert_char(&mut self, c: char) {
197 let byte_idx = self.char_to_byte(self.cursor);
198 self.buf.insert(byte_idx, c);
199 self.cursor += 1;
200 self.dirty = true;
201 }
202
203 pub fn backspace(&mut self) {
204 if self.cursor == 0 {
205 return;
206 }
207 let prev_byte = self.char_to_byte(self.cursor - 1);
208 let cur_byte = self.char_to_byte(self.cursor);
209 self.buf.replace_range(prev_byte..cur_byte, "");
210 self.cursor -= 1;
211 self.dirty = true;
212 }
213
214 pub fn delete(&mut self) {
215 let n = self.buf.chars().count();
216 if self.cursor >= n {
217 return;
218 }
219 let cur_byte = self.char_to_byte(self.cursor);
220 let next_byte = self.char_to_byte(self.cursor + 1);
221 self.buf.replace_range(cur_byte..next_byte, "");
222 self.dirty = true;
223 }
224
225 pub fn move_left(&mut self) {
226 if self.cursor > 0 {
227 self.cursor -= 1;
228 }
229 }
230 pub fn move_right(&mut self) {
231 if self.cursor < self.buf.chars().count() {
232 self.cursor += 1;
233 }
234 }
235 pub fn move_home(&mut self) {
236 self.cursor = 0;
237 }
238 pub fn move_end(&mut self) {
239 self.cursor = self.buf.chars().count();
240 }
241 pub fn toggle_reveal(&mut self) {
242 self.revealed = !self.revealed;
243 }
244
245 pub fn display(&self) -> String {
247 if self.revealed {
248 self.buf.clone()
249 } else {
250 "•".repeat(self.buf.chars().count())
251 }
252 }
253
254 fn char_to_byte(&self, char_idx: usize) -> usize {
255 self.buf
256 .char_indices()
257 .map(|(b, _)| b)
258 .chain(std::iter::once(self.buf.len()))
259 .nth(char_idx)
260 .unwrap_or(self.buf.len())
261 }
262}
263
264#[derive(Debug, Clone)]
266pub struct SettingsState {
267 pub focus: Focus,
268 pub primary_choices: Vec<VendorId>,
271 pub primary: VendorId,
272 pub keys: Vec<KeyInput>,
274 pub status: String,
276}
277
278impl SettingsState {
279 pub fn from_config(cfg: &Config) -> Self {
280 let keys = KEY_VENDORS
281 .iter()
282 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
283 .collect();
284 let mut primary_choices = cfg.enabled_vendors();
285 if !primary_choices.contains(&VendorId::Copilot) {
289 primary_choices.push(VendorId::Copilot);
290 }
291 let primary = cfg
295 .ui
296 .primary
297 .filter(|vendor| {
298 primary_choices.contains(vendor)
299 && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
300 })
301 .or_else(|| primary_choices.first().copied())
302 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
303 Self {
304 focus: Focus::Primary,
305 primary_choices,
306 primary,
307 keys,
308 status: String::new(),
309 }
310 }
311
312 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
314 match self.focus {
315 Focus::Key(i) => self.keys.get_mut(i),
316 _ => None,
317 }
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum Action {
324 Continue,
326 Close,
328 SavedAndClose,
330 Quit,
333}
334
335#[cfg(unix)]
338const PERMS_NOTE: &str = " (chmod 600)";
339#[cfg(not(unix))]
340const PERMS_NOTE: &str = "";
341
342fn saved_status() -> String {
343 format!(
344 "saved to {}{}",
345 crate::config::config_path_hint(),
346 PERMS_NOTE
347 )
348}
349
350pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
352 if matches!(code, KeyCode::Esc) {
353 return Action::Close;
354 }
355 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
356 return Action::Quit;
357 }
358 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
360 return try_save(state);
361 }
362 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
363 if let Some(input) = state.focused_key_mut() {
364 input.toggle_reveal();
365 }
366 return Action::Continue;
367 }
368 match code {
369 KeyCode::Tab | KeyCode::Down => {
370 state.focus = state.focus.next();
371 return Action::Continue;
372 }
373 KeyCode::BackTab | KeyCode::Up => {
374 state.focus = state.focus.prev();
375 return Action::Continue;
376 }
377 _ => {}
378 }
379
380 if matches!(code, KeyCode::Char(_))
385 && mods.intersects(
386 KeyModifiers::CONTROL
387 | KeyModifiers::ALT
388 | KeyModifiers::SUPER
389 | KeyModifiers::HYPER
390 | KeyModifiers::META,
391 )
392 {
393 return Action::Continue;
394 }
395
396 match state.focus {
398 Focus::Primary => handle_primary(state, code),
399 Focus::Key(i) => {
400 if let Some(input) = state.keys.get_mut(i) {
401 handle_input(input, code);
402 }
403 }
404 Focus::Save => {
405 if matches!(code, KeyCode::Enter) {
406 return try_save(state);
407 }
408 }
409 }
410 Action::Continue
411}
412
413fn try_save(state: &mut SettingsState) -> Action {
414 match save_to_config_default(state) {
415 Ok(()) => {
416 state.status = saved_status();
417 Action::SavedAndClose
418 }
419 Err(e) => {
420 state.status = format!("save failed: {e}");
421 Action::Continue
422 }
423 }
424}
425
426fn handle_primary(state: &mut SettingsState, code: KeyCode) {
427 let choices = &state.primary_choices;
429 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
430 return;
431 };
432 let step = match code {
433 KeyCode::Left => -1,
434 KeyCode::Right | KeyCode::Char(' ') => 1,
435 _ => return,
436 };
437 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
438}
439
440fn handle_input(input: &mut KeyInput, code: KeyCode) {
441 match code {
442 KeyCode::Char(c) => input.insert_char(c),
443 KeyCode::Backspace => input.backspace(),
444 KeyCode::Delete => input.delete(),
445 KeyCode::Left => input.move_left(),
446 KeyCode::Right => input.move_right(),
447 KeyCode::Home => input.move_home(),
448 KeyCode::End => input.move_end(),
449 _ => {}
450 }
451}
452
453fn save_to_config_default(state: &SettingsState) -> Result<()> {
456 let path = default_config_path()?;
457 if let Some(parent) = path.parent() {
458 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
459 }
460 save_to_path(state, &path)?;
461 crate::waybar::request_refresh();
462 Ok(())
463}
464
465pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
468 let mut doc = read_config_document(path)?;
469
470 if let Some(table) = doc
474 .get_mut("copilot")
475 .and_then(toml_edit::Item::as_table_mut)
476 {
477 table.remove("token");
478 table.remove("token_env");
479 }
480
481 if state.primary_choices.contains(&state.primary) {
485 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
486 if state.primary == VendorId::Copilot {
487 set_bool(&mut doc, "copilot", "enabled", true)?;
488 }
489 }
490
491 for (i, kv) in KEY_VENDORS.iter().enumerate() {
492 let Some(input) = state.keys.get(i) else {
493 continue;
494 };
495 update_key(&mut doc, kv, input)?;
496 }
497
498 write_config_document(path, &doc)
499}
500
501fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
507 if !input.dirty {
508 return Ok(());
509 }
510 if input.buf.is_empty() {
511 if let Some(table) = doc
512 .get_mut(vendor.section)
513 .and_then(toml_edit::Item::as_table_mut)
514 {
515 table.remove(vendor.config_key);
516 }
517 return Ok(());
518 }
519 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
520 set_bool(doc, vendor.section, "enabled", true)
521}
522
523fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
526 let table = doc
527 .entry(section)
528 .or_insert_with(toml_edit::table)
529 .as_table_mut()
530 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
531
532 if let Some(item) = table.get_mut(key)
533 && let Some(v) = item.as_value_mut()
534 {
535 *v = toml_edit::Value::from(new_value);
536 v.decor_mut().set_prefix(" ");
537 return Ok(());
538 }
539 table.insert(key, value(new_value));
540 Ok(())
541}
542
543fn default_config_path() -> Result<PathBuf> {
544 crate::config::resolved_path()
549 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
550}
551
552#[derive(Debug, Serialize)]
559struct SettingsSnapshot {
560 schema_version: u8,
561 primary: String,
562 primary_choices: Vec<PrimaryChoice>,
563 keys: Vec<KeyStatus>,
564}
565
566#[derive(Debug, Serialize)]
567struct PrimaryChoice {
568 id: String,
569 label: String,
570}
571
572#[derive(Debug, Serialize)]
573struct KeyStatus {
574 id: String,
575 label: String,
576 environment: String,
577 secret_label: String,
578 note: String,
579 configured: bool,
580 inline_configured: bool,
581 environment_configured: bool,
582}
583
584#[derive(Debug, Deserialize)]
588#[serde(deny_unknown_fields)]
589struct ApplyRequest {
590 schema_version: u8,
591 primary: Option<String>,
592 #[serde(default)]
593 keys: BTreeMap<String, KeyMutation>,
594}
595
596#[derive(Debug, Deserialize)]
597#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
598enum KeyMutation {
599 Set { value: String },
600 Clear,
601}
602
603const SETTINGS_SCHEMA_VERSION: u8 = 1;
604const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
605const MAX_API_KEY_BYTES: usize = 16 * 1024;
606
607fn snapshot_from_config_with(
608 cfg: &Config,
609 environment_configured: impl Fn(&str) -> bool,
610) -> SettingsSnapshot {
611 let state = SettingsState::from_config(cfg);
612 let primary_choices = state
613 .primary_choices
614 .iter()
615 .map(|id| PrimaryChoice {
616 id: id.slug().to_string(),
617 label: id.display_name().to_string(),
618 })
619 .collect();
620 let keys = KEY_VENDORS
621 .iter()
622 .map(|vendor| {
623 let environment = cfg.api_key_env_for(vendor.id);
624 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
625 let environment_configured = environment_configured(environment);
626 KeyStatus {
627 id: vendor.id.slug().to_string(),
628 label: vendor.label.to_string(),
629 environment: environment.to_string(),
630 secret_label: vendor.secret_label.to_string(),
631 note: vendor.note.to_string(),
632 configured: inline_configured || environment_configured,
633 inline_configured,
634 environment_configured,
635 }
636 })
637 .collect();
638 SettingsSnapshot {
639 schema_version: SETTINGS_SCHEMA_VERSION,
640 primary: state.primary.slug().to_string(),
641 primary_choices,
642 keys,
643 }
644}
645
646fn settings_snapshot_json(cfg: &Config) -> Result<String> {
647 Ok(serde_json::to_string(&snapshot_from_config_with(
648 cfg,
649 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
650 ))?)
651}
652
653#[cfg(test)]
654fn settings_snapshot_json_with(
655 cfg: &Config,
656 environment_configured: impl Fn(&str) -> bool,
657) -> Result<String> {
658 Ok(serde_json::to_string(&snapshot_from_config_with(
659 cfg,
660 environment_configured,
661 ))?)
662}
663
664fn vendor_from_slug(slug: &str) -> Option<VendorId> {
665 VendorId::all().iter().copied().find(|id| id.slug() == slug)
666}
667
668fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
669 let request: ApplyRequest = serde_json::from_str(raw)?;
670 if request.schema_version != SETTINGS_SCHEMA_VERSION {
671 return Err(AppError::Other(format!(
672 "unsupported settings schema version {}",
673 request.schema_version
674 )));
675 }
676
677 let mut state = SettingsState::from_config(cfg);
678 if let Some(primary) = request.primary {
679 let id = vendor_from_slug(&primary)
680 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
681 if !state.primary_choices.contains(&id) {
682 return Err(AppError::Other(format!(
683 "primary vendor {primary:?} is not enabled"
684 )));
685 }
686 state.primary = id;
687 }
688
689 for (id, mutation) in request.keys {
690 let index = KEY_VENDORS
691 .iter()
692 .position(|vendor| vendor.id.slug() == id)
693 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
694 let input = &mut state.keys[index];
695 match mutation {
696 KeyMutation::Set { value } => {
697 if value.is_empty() {
698 return Err(AppError::Other(format!(
699 "{} for {id:?} is empty; use the clear action to remove it",
700 KEY_VENDORS[index].secret_label
701 )));
702 }
703 if value.len() > MAX_API_KEY_BYTES {
704 return Err(AppError::Other(format!(
705 "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
706 KEY_VENDORS[index].secret_label
707 )));
708 }
709 if value.chars().any(char::is_control) {
710 return Err(AppError::Other(format!(
711 "{} for {id:?} contains control characters",
712 KEY_VENDORS[index].secret_label
713 )));
714 }
715 input.buf = value;
716 }
717 KeyMutation::Clear => input.buf.clear(),
718 }
719 input.cursor = input.buf.chars().count();
720 input.dirty = true;
721 input.revealed = false;
722 }
723 Ok(state)
724}
725
726#[cfg(test)]
727fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
728 let state = state_from_apply_request(cfg, raw)?;
729 save_to_path(&state, path)
730}
731
732fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
733 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
734 let mut bytes = Vec::new();
735 limited.read_until(b'\n', &mut bytes)?;
736 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
737 return Err(AppError::Other(format!(
738 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
739 )));
740 }
741 if bytes.last() == Some(&b'\n') {
742 bytes.pop();
743 if bytes.last() == Some(&b'\r') {
744 bytes.pop();
745 }
746 }
747 String::from_utf8(bytes)
748 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
749}
750
751fn apply_settings_from_stdin() -> Result<()> {
752 let raw = read_settings_request(std::io::stdin().lock())?;
753 let cfg = Config::load()?;
754 let state = state_from_apply_request(&cfg, &raw)?;
755 save_to_config_default(&state)
756}
757
758pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
762 let result = match action {
763 crate::widget::cli::SettingsAction::Show => Config::load()
764 .and_then(|cfg| settings_snapshot_json(&cfg))
765 .map(|json| println!("{json}")),
766 crate::widget::cli::SettingsAction::Apply => {
767 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
768 }
769 };
770 match result {
771 Ok(()) => 0,
772 Err(error) => {
773 eprintln!("settings: {error}");
774 1
775 }
776 }
777}
778
779pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
783 let modal = centered_rect(74, 88, area);
784 f.render_widget(Clear, modal);
785
786 let bubble = bubble_theme(theme);
787 let block = bubble.titled_modal_block(" Settings ");
788 let inner = block.inner(modal);
789 f.render_widget(block, modal);
790
791 let chunks = Layout::default()
793 .direction(Direction::Vertical)
794 .constraints([Constraint::Min(0), Constraint::Length(1)])
795 .split(inner);
796
797 let mut lines: Vec<Line> = vec![
799 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
800 primary_line(state, &bubble),
801 Line::from(""),
802 section_header(
803 "Credentials",
804 "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
805 &bubble,
806 ),
807 ];
808 for (i, kv) in KEY_VENDORS.iter().enumerate() {
809 let focused = state.focus == Focus::Key(i);
810 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
811 }
812 lines.push(Line::from(""));
813
814 lines.push(save_line(state.focus == Focus::Save, &bubble));
816 if !state.status.is_empty() {
817 let ok = state.status.starts_with("saved");
818 let mark = if ok { " ✓ " } else { " ✗ " };
819 let style = if ok { bubble.accent } else { bubble.selected };
820 lines.push(Line::from(vec![
821 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
822 Span::styled(state.status.clone(), bubble.muted),
823 ]));
824 }
825
826 f.render_widget(Paragraph::new(lines), chunks[0]);
827
828 let hint = match state.focus {
830 Focus::Primary => bubble.help_line([
831 ("↑↓/tab", "move"),
832 ("←→", "change vendor"),
833 ("^S", "save"),
834 ("esc", "close"),
835 ]),
836 Focus::Key(_) => bubble.help_line([
837 ("↑↓/tab", "move"),
838 ("type", "edit key"),
839 ("^V", "reveal"),
840 ("^S", "save"),
841 ("esc", "close"),
842 ]),
843 Focus::Save => {
844 bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
845 }
846 };
847 f.render_widget(Paragraph::new(hint), chunks[1]);
848}
849
850fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
851 Line::from(vec![
852 theme.span(" "),
853 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
854 theme.muted(format!(" — {sub}")),
855 ])
856}
857
858fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
859 let focused = state.focus == Focus::Primary;
860 let name = state.primary.display_name().to_string();
861 if focused {
862 Line::from(vec![
863 theme.span(" "),
864 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
865 Span::styled("◀ ", theme.accent),
866 Span::styled(
867 format!(" {name} "),
868 theme
869 .selected
870 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
871 ),
872 Span::styled(" ▶", theme.accent),
873 theme.muted(" ← → to change"),
874 ])
875 } else {
876 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
877 }
878}
879
880fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
881 let label = format!("{:<11}", kv.label);
882 let value = value_text(input, focused);
883
884 let env_name = kv.id.api_key_env();
886 let env_set = std::env::var(env_name)
887 .map(|v| !v.is_empty())
888 .unwrap_or(false);
889 let mut suffix = format!(" {env_name}");
890 if env_set {
891 suffix.push_str(" · env set (overrides)");
892 }
893 if !kv.note.is_empty() {
894 suffix.push_str(&format!(" · {}", kv.note));
895 }
896
897 if focused {
898 let val_style = if input.buf.is_empty() {
899 theme.accent.add_modifier(Modifier::BOLD)
900 } else {
901 theme.selected.add_modifier(Modifier::REVERSED)
902 };
903 let mut spans = vec![
904 theme.span(" "),
905 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
906 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
907 Span::styled(format!(" {value} "), val_style),
908 ];
909 if input.revealed {
910 spans.push(theme.muted(" [revealed]"));
911 }
912 spans.push(theme.muted(suffix));
913 Line::from(spans)
914 } else {
915 let val_style = if input.buf.is_empty() {
916 theme.muted
917 } else {
918 theme.text
919 };
920 Line::from(vec![
921 theme.span(" "),
922 Span::styled(label, theme.text),
923 Span::styled(format!(" {value}"), val_style),
924 theme.muted(suffix),
925 ])
926 }
927}
928
929fn value_text(input: &KeyInput, focused: bool) -> String {
932 if input.buf.is_empty() {
933 return if focused {
934 "‸".to_string()
935 } else {
936 "(empty)".to_string()
937 };
938 }
939 let base = input.display();
940 if !focused {
941 return base;
942 }
943 let mut chars: Vec<char> = base.chars().collect();
944 let pos = input.cursor.min(chars.len());
945 chars.insert(pos, '‸');
946 chars.into_iter().collect()
947}
948
949fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
950 let style = if focused {
951 theme
952 .selected
953 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
954 } else {
955 theme.accent.add_modifier(Modifier::BOLD)
956 };
957 let marker = if focused { "▸ " } else { " " };
958 Line::from(vec![
959 theme.span(" "),
960 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
961 Span::styled(" Save (Ctrl-S) ", style),
962 ])
963}
964
965fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
967 let popup_h = (r.height * percent_y) / 100;
968 let popup_w = (r.width * percent_x) / 100;
969 Rect {
970 x: r.x + (r.width - popup_w) / 2,
971 y: r.y + (r.height - popup_h) / 2,
972 width: popup_w,
973 height: popup_h,
974 }
975}
976
977pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983 use tempfile::TempDir;
984
985 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
986 crate::cache::closed_temp_file("config.toml", initial)
987 }
988
989 fn key_index(id: VendorId) -> usize {
990 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
991 }
992
993 fn blank_state(primary: VendorId) -> SettingsState {
994 SettingsState {
995 focus: Focus::Primary,
996 primary_choices: VendorId::all().to_vec(),
997 primary,
998 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
999 status: String::new(),
1000 }
1001 }
1002
1003 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1005 let mut s = blank_state(primary);
1006 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1007 s.keys[key_index(VendorId::Zai)].dirty = true;
1008 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1009 s.keys[key_index(VendorId::Openrouter)].dirty = true;
1010 s
1011 }
1012
1013 #[test]
1014 fn focus_cycles_through_primary_all_keys_and_save() {
1015 let mut f = Focus::Primary;
1016 let mut seen = vec![f];
1017 for _ in 0..(KEY_VENDORS.len() + 2) {
1019 f = f.next();
1020 seen.push(f);
1021 }
1022 assert_eq!(seen.first(), Some(&Focus::Primary));
1024 assert_eq!(seen.last(), Some(&Focus::Primary));
1025 assert!(seen.contains(&Focus::Key(0)));
1026 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1027 assert!(seen.contains(&Focus::Save));
1028 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1030 assert_eq!(Focus::Save.prev().next(), Focus::Save);
1031 assert_eq!(Focus::Primary.prev(), Focus::Save);
1032 }
1033
1034 #[test]
1035 fn every_key_vendor_has_a_field() {
1036 for id in [
1038 VendorId::Zai,
1039 VendorId::Openrouter,
1040 VendorId::Deepseek,
1041 VendorId::Kilo,
1042 VendorId::Novita,
1043 VendorId::Moonshot,
1044 VendorId::Grok,
1045 ] {
1046 assert!(
1047 KEY_VENDORS.iter().any(|kv| kv.id == id),
1048 "{id:?} has no key field"
1049 );
1050 }
1051 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1053 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1054 }
1055
1056 #[test]
1057 fn from_config_prefills_existing_keys() {
1058 let mut cfg = Config::default();
1059 cfg.kilo.api_key = Some("sk-kilo".into());
1060 let s = SettingsState::from_config(&cfg);
1061 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1062 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1063 }
1064
1065 #[test]
1066 fn copilot_has_no_editable_credential_field() {
1067 assert!(
1068 !KEY_VENDORS
1069 .iter()
1070 .any(|vendor| vendor.id == VendorId::Copilot)
1071 );
1072 }
1073
1074 #[test]
1075 fn from_config_offers_enabled_vendors_only() {
1076 let cfg = Config::default();
1077 let s = SettingsState::from_config(&cfg);
1078 let mut expected = cfg.enabled_vendors();
1079 expected.push(VendorId::Copilot);
1080 assert_eq!(s.primary_choices, expected);
1081 assert!(!s.primary_choices.contains(&VendorId::Grok));
1084 assert!(s.primary_choices.contains(&s.primary));
1085 assert!(s.primary_choices.contains(&VendorId::Copilot));
1086 }
1087
1088 #[test]
1089 fn from_config_falls_back_when_configured_primary_is_disabled() {
1090 let mut cfg = Config::default();
1093 cfg.ui.primary = Some(VendorId::Grok);
1094 let s = SettingsState::from_config(&cfg);
1095 assert_ne!(s.primary, VendorId::Grok);
1096 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1097 }
1098
1099 #[test]
1100 fn key_input_insert_backspace_arrow() {
1101 let mut k = KeyInput::default();
1102 k.insert_char('a');
1103 k.insert_char('b');
1104 k.insert_char('c');
1105 assert_eq!(k.buf, "abc");
1106 assert_eq!(k.cursor, 3);
1107 assert!(k.dirty);
1108 k.move_left();
1109 k.move_left();
1110 assert_eq!(k.cursor, 1);
1111 k.insert_char('x');
1112 assert_eq!(k.buf, "axbc");
1113 assert_eq!(k.cursor, 2);
1114 k.backspace();
1115 assert_eq!(k.buf, "abc");
1116 assert_eq!(k.cursor, 1);
1117 }
1118
1119 #[test]
1120 fn key_input_masks_by_default_reveals_on_toggle() {
1121 let mut k = KeyInput::default();
1122 for c in "secret-key".chars() {
1123 k.insert_char(c);
1124 }
1125 assert_eq!(k.display(), "•".repeat(10));
1126 k.toggle_reveal();
1127 assert_eq!(k.display(), "secret-key");
1128 }
1129
1130 #[test]
1131 fn key_input_handles_unicode() {
1132 let mut k = KeyInput::default();
1133 k.insert_char('a');
1134 k.insert_char('→');
1135 k.insert_char('b');
1136 assert_eq!(k.buf, "a→b");
1137 assert_eq!(k.cursor, 3);
1138 k.move_left();
1139 k.backspace();
1140 assert_eq!(k.buf, "ab");
1141 }
1142
1143 #[test]
1144 fn value_text_shows_cursor_and_empty_states() {
1145 let mut k = KeyInput::default();
1146 assert_eq!(value_text(&k, false), "(empty)");
1147 assert_eq!(value_text(&k, true), "‸");
1148 k.insert_char('a');
1149 k.insert_char('b');
1150 assert_eq!(value_text(&k, true), "••‸");
1152 assert_eq!(value_text(&k, false), "••");
1153 }
1154
1155 #[test]
1156 fn save_writes_key_and_enables_vendor() {
1157 let (_dir, path) = temp_config(None);
1158 let mut s = blank_state(VendorId::Kilo);
1159 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1160 s.keys[key_index(VendorId::Kilo)].dirty = true;
1161 save_to_path(&s, &path).unwrap();
1162 let raw = std::fs::read_to_string(&path).unwrap();
1163 assert!(raw.contains("primary = \"kilo\""));
1164 assert!(raw.contains("[kilo]"));
1165 assert!(raw.contains("api_key = \"sk-kilo\""));
1166 assert!(raw.contains("enabled = true"));
1167 }
1168
1169 #[test]
1170 fn save_writes_minimal_toml_when_starting_empty() {
1171 let (_dir, path) = temp_config(None);
1172 let s = state_with("zk", "ok", VendorId::Zai);
1173 save_to_path(&s, &path).unwrap();
1174 let raw = std::fs::read_to_string(&path).unwrap();
1175 assert!(raw.contains("primary = \"zai\""));
1176 assert!(raw.contains("[zai]"));
1177 assert!(raw.contains("api_key = \"zk\""));
1178 assert!(raw.contains("[openrouter]"));
1179 assert!(raw.contains("api_key = \"ok\""));
1180 }
1181
1182 #[test]
1183 fn save_preserves_existing_comments_and_unrelated_fields() {
1184 let (_dir, path) = temp_config(Some(
1185 r##"# my comment
1186[ui]
1187# pre-existing comment
1188primary = "anthropic"
1189
1190[zai]
1191enabled = true
1192api_key_env = "ZAI_API_KEY"
1193# tier comment
1194plan_tier = "pro"
1195
1196[openrouter]
1197enabled = true
1198api_key_env = "OPENROUTER_API_KEY"
1199
1200[[openrouter.accounts]]
1201label = "work"
1202api_key_env = "OPENROUTER_WORK_API_KEY"
1203"##,
1204 ));
1205
1206 let s = state_with("zk2", "ok2", VendorId::Openrouter);
1207 save_to_path(&s, &path).unwrap();
1208
1209 let raw = std::fs::read_to_string(&path).unwrap();
1210 assert!(raw.contains("# my comment"));
1211 assert!(raw.contains("# pre-existing comment"));
1212 assert!(raw.contains("# tier comment"));
1213 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1214 assert!(raw.contains("[[openrouter.accounts]]"));
1215 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1216 assert!(raw.contains("plan_tier = \"pro\""));
1217 assert!(raw.contains("primary = \"openrouter\""));
1218 assert!(raw.contains("api_key = \"zk2\""));
1219 assert!(raw.contains("api_key = \"ok2\""));
1220 }
1221
1222 #[test]
1223 fn save_refuses_to_replace_an_unreadable_existing_config() {
1224 let (_dir, path) = temp_config(None);
1225 let original = [0xff, 0xfe, 0xfd];
1226 std::fs::write(&path, original).unwrap();
1227 let state = state_with("new-secret", "", VendorId::Zai);
1228
1229 assert!(save_to_path(&state, &path).is_err());
1230 assert_eq!(std::fs::read(&path).unwrap(), original);
1231 }
1232
1233 #[test]
1234 fn save_does_not_write_empty_key_when_dirty_but_blank() {
1235 let (_dir, path) = temp_config(None);
1236 let mut s = blank_state(VendorId::Anthropic);
1237 for k in &mut s.keys {
1239 k.dirty = true;
1240 }
1241 save_to_path(&s, &path).unwrap();
1242 let raw = std::fs::read_to_string(&path).unwrap();
1243 assert!(!raw.contains("api_key ="));
1244 }
1245
1246 #[test]
1247 #[cfg(unix)]
1248 fn save_chmods_to_600() {
1249 use std::os::unix::fs::PermissionsExt;
1250 let (_dir, path) = temp_config(None);
1251 let s = state_with("zk", "ok", VendorId::Zai);
1252 save_to_path(&s, &path).unwrap();
1253 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1254 assert_eq!(mode & 0o777, 0o600);
1255 }
1256
1257 #[test]
1258 fn tab_cycles_focus_from_primary_to_first_key() {
1259 let mut s = blank_state(VendorId::Anthropic);
1260 assert_eq!(
1261 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1262 Action::Continue
1263 );
1264 assert_eq!(s.focus, Focus::Key(0));
1265 assert_eq!(
1266 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1267 Action::Continue
1268 );
1269 assert_eq!(s.focus, Focus::Primary);
1270 }
1271
1272 #[test]
1273 fn esc_closes_without_saving() {
1274 let mut s = blank_state(VendorId::Anthropic);
1275 assert_eq!(
1276 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1277 Action::Close
1278 );
1279 }
1280
1281 #[test]
1282 fn left_right_cycles_primary_vendor() {
1283 let mut s = blank_state(VendorId::Anthropic);
1285 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1286 assert_eq!(s.primary, VendorId::AnthropicApi);
1287 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1288 assert_eq!(s.primary, VendorId::Openai);
1289 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1290 assert_eq!(s.primary, VendorId::AnthropicApi);
1291 }
1292
1293 #[test]
1294 fn left_right_offers_enabled_vendors_only() {
1295 let mut s = blank_state(VendorId::Anthropic);
1297 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1298 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1299 assert_eq!(s.primary, VendorId::Grok);
1300 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1302 assert_eq!(s.primary, VendorId::Anthropic);
1303 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1304 assert_eq!(s.primary, VendorId::Grok);
1305 }
1306
1307 #[test]
1308 fn no_enabled_vendors_leaves_primary_selector_inert() {
1309 let mut s = blank_state(VendorId::Anthropic);
1310 s.primary_choices = vec![];
1311 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1312 assert_eq!(s.primary, VendorId::Anthropic);
1313 }
1314
1315 #[test]
1316 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
1317 let mut cfg = Config::default();
1318 cfg.ui.primary = Some(VendorId::Copilot);
1319 let state = SettingsState::from_config(&cfg);
1320
1321 assert!(state.primary_choices.contains(&VendorId::Copilot));
1322 assert_eq!(state.primary, VendorId::Anthropic);
1323 }
1324
1325 #[test]
1326 fn save_does_not_write_a_disabled_primary() {
1327 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1330 let mut s = state_with("zk", "ok", VendorId::Grok);
1331 s.primary_choices = vec![VendorId::Anthropic];
1332 save_to_path(&s, &path).unwrap();
1333 let raw = std::fs::read_to_string(&path).unwrap();
1334 assert!(raw.contains("primary = \"anthropic\""));
1335 assert!(!raw.contains("primary = \"grok\""));
1336 assert!(raw.contains("zk"));
1338 }
1339
1340 #[test]
1341 fn save_removes_an_inline_key_the_user_cleared() {
1342 let (_dir, path) = temp_config(Some(
1345 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1346 ));
1347 let mut s = blank_state(VendorId::Zai);
1348 s.primary_choices = vec![VendorId::Zai];
1349 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1350 s.keys[key_index(VendorId::Zai)].dirty = true;
1351 save_to_path(&s, &path).unwrap();
1352 let raw = std::fs::read_to_string(&path).unwrap();
1353 assert!(!raw.contains("old-secret"));
1354 assert!(!raw.contains("api_key"));
1355 assert!(raw.contains("plan_tier = \"pro\""));
1357 }
1358
1359 #[test]
1360 fn untouched_key_field_is_left_alone() {
1361 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1363 let mut s = blank_state(VendorId::Zai);
1364 s.primary_choices = vec![VendorId::Zai];
1365 save_to_path(&s, &path).unwrap();
1366 let raw = std::fs::read_to_string(&path).unwrap();
1367 assert!(raw.contains("keep-me"));
1368 }
1369
1370 #[test]
1371 fn typing_edits_the_focused_key_only() {
1372 let mut s = blank_state(VendorId::Anthropic);
1373 s.focus = Focus::Key(key_index(VendorId::Grok));
1374 for c in "xai-abc".chars() {
1375 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1376 }
1377 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1378 assert!(s.keys[key_index(VendorId::Grok)].dirty);
1379 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1381 }
1382
1383 #[test]
1384 fn ctrl_v_toggles_reveal_on_focused_key_field() {
1385 let mut s = blank_state(VendorId::Anthropic);
1386 let zi = key_index(VendorId::Zai);
1387 s.focus = Focus::Key(zi);
1388 s.keys[zi] = KeyInput::from_config(Some("secret"));
1389 assert!(!s.keys[zi].revealed);
1390 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1391 assert!(s.keys[zi].revealed);
1392 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1393 assert!(!s.keys[zi].revealed);
1394 }
1395
1396 #[test]
1397 fn control_chorded_chars_do_not_type_into_fields() {
1398 let mut s = blank_state(VendorId::Anthropic);
1399 s.focus = Focus::Key(0);
1400 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1402 assert!(s.keys[0].buf.is_empty());
1403 assert!(!s.keys[0].dirty);
1404 assert_eq!(
1406 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1407 Action::Quit
1408 );
1409 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1411 assert_eq!(s.keys[0].buf, "x");
1412 }
1413
1414 #[test]
1415 fn ctrl_v_on_non_key_focus_is_noop() {
1416 let mut s = blank_state(VendorId::Anthropic);
1417 s.focus = Focus::Primary;
1418 assert_eq!(
1420 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1421 Action::Continue
1422 );
1423 }
1424
1425 fn state_focused_on_zai() -> SettingsState {
1426 let mut state = blank_state(VendorId::Anthropic);
1427 state.focus = Focus::Key(key_index(VendorId::Zai));
1428 state
1429 }
1430
1431 #[test]
1432 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1433 let mut s = state_focused_on_zai();
1434 let zi = key_index(VendorId::Zai);
1435 assert_eq!(
1436 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1437 Action::Quit
1438 );
1439 assert!(s.keys[zi].buf.is_empty());
1440 assert!(!s.keys[zi].dirty);
1442 }
1443
1444 #[test]
1445 fn handle_key_alt_chord_does_not_type_into_key_field() {
1446 let mut s = state_focused_on_zai();
1447 let zi = key_index(VendorId::Zai);
1448 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1449 assert!(s.keys[zi].buf.is_empty());
1450 assert!(!s.keys[zi].dirty);
1451 }
1452
1453 #[test]
1454 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1455 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1456 let mut s = state_focused_on_zai();
1457 let zi = key_index(VendorId::Zai);
1458 handle_key(&mut s, KeyCode::Char('x'), modifier);
1459 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1460 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1461 }
1462 }
1463
1464 #[test]
1465 fn handle_key_shift_still_types_uppercase() {
1466 let mut s = state_focused_on_zai();
1467 let zi = key_index(VendorId::Zai);
1468 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1469 assert_eq!(s.keys[zi].buf, "A");
1470 assert!(s.keys[zi].dirty);
1471 }
1472
1473 #[test]
1474 fn handle_key_plain_space_still_cycles_primary_vendor() {
1475 let mut s = blank_state(VendorId::Anthropic);
1476 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1477 assert_eq!(s.primary, VendorId::AnthropicApi);
1478 }
1479
1480 #[test]
1481 fn handle_key_ctrl_s_attempts_save_from_any_field() {
1482 let (_dir, path) = temp_config(None);
1483 let s = state_with("zk", "ok", VendorId::Zai);
1484 save_to_path(&s, &path).unwrap();
1485 let raw = std::fs::read_to_string(&path).unwrap();
1486 assert!(raw.contains("api_key = \"zk\""));
1487 }
1488 #[test]
1489 fn save_to_path_writes_kimi_key_when_dirty() {
1490 let (_dir, path) = temp_config(None);
1491 let mut s = blank_state(VendorId::Anthropic);
1492 let kimi = key_index(VendorId::Kimi);
1493 s.keys[kimi] = KeyInput::from_config(Some("kk"));
1494 s.keys[kimi].dirty = true;
1495 save_to_path(&s, &path).unwrap();
1496 let raw = std::fs::read_to_string(&path).unwrap();
1497 assert!(raw.contains("[kimi]"));
1498 assert!(raw.contains("api_key = \"kk\""));
1499 }
1500
1501 #[test]
1502 fn settings_save_uses_the_same_config_path_as_load() {
1503 assert_eq!(
1504 default_config_path().unwrap(),
1505 crate::config::resolved_path().unwrap()
1506 );
1507 }
1508
1509 #[test]
1510 fn native_snapshot_reports_key_state_without_serializing_secrets() {
1511 let mut cfg = Config::default();
1512 cfg.zai.api_key = Some("never-leak-this-key".into());
1513 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
1514 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
1515 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1516
1517 assert_eq!(parsed["schema_version"], 1);
1518 assert_eq!(parsed["primary"], "anthropic");
1519 let zai = parsed["keys"]
1520 .as_array()
1521 .unwrap()
1522 .iter()
1523 .find(|row| row["id"] == "zai")
1524 .unwrap();
1525 assert_eq!(zai["configured"], true);
1526 assert_eq!(zai["inline_configured"], true);
1527 assert_eq!(zai["environment_configured"], true);
1528 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
1529 assert!(!raw.contains("never-leak-this-key"));
1530 assert!(parsed.get("api_key").is_none());
1531 }
1532
1533 #[test]
1534 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
1535 let cfg = Config::default();
1536 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
1537 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1538 assert!(
1539 parsed["primary_choices"]
1540 .as_array()
1541 .unwrap()
1542 .iter()
1543 .any(|row| row["id"] == "copilot")
1544 );
1545 assert!(
1546 !parsed["keys"]
1547 .as_array()
1548 .unwrap()
1549 .iter()
1550 .any(|row| row["id"] == "copilot")
1551 );
1552 }
1553
1554 #[test]
1555 fn native_key_only_patch_does_not_require_or_replace_primary() {
1556 let cfg = Config::default();
1557 let original_primary = SettingsState::from_config(&cfg).primary;
1558 let request = serde_json::json!({
1559 "schema_version": 1,
1560 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
1561 });
1562
1563 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
1564 assert_eq!(state.primary, original_primary);
1565 let kimi_index = KEY_VENDORS
1566 .iter()
1567 .position(|vendor| vendor.id == VendorId::Kimi)
1568 .unwrap();
1569 assert!(state.keys[kimi_index].dirty);
1570 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
1571 }
1572
1573 #[test]
1574 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
1575 let (_dir, path) = temp_config(Some(
1576 r#"# keep this comment
1577[ui]
1578primary = "anthropic"
1579
1580[zai]
1581enabled = true
1582api_key_env = "ZAI_API_KEY"
1583plan_tier = "pro"
1584
1585[openrouter]
1586enabled = true
1587"#,
1588 ));
1589 let cfg = Config::load_from(&path).unwrap();
1590 let request = serde_json::json!({
1591 "schema_version": 1,
1592 "primary": "openrouter",
1593 "keys": {
1594 "zai": {"action": "set", "value": "new-zai-key"}
1595 }
1596 });
1597
1598 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1599 let raw = std::fs::read_to_string(&path).unwrap();
1600 assert!(raw.contains("# keep this comment"));
1601 assert!(raw.contains("plan_tier = \"pro\""));
1602 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1603 assert!(raw.contains("primary = \"openrouter\""));
1604 assert!(raw.contains("api_key = \"new-zai-key\""));
1605 }
1606
1607 #[test]
1608 fn native_patch_distinguishes_clear_from_unchanged() {
1609 let (_dir, path) = temp_config(Some(
1610 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
1611 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
1612 ));
1613 let cfg = Config::load_from(&path).unwrap();
1614 let request = serde_json::json!({
1615 "schema_version": 1,
1616 "primary": "zai",
1617 "keys": {"zai": {"action": "clear"}}
1618 });
1619
1620 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1621 let raw = std::fs::read_to_string(&path).unwrap();
1622 assert!(!raw.contains("remove-me"));
1623 assert!(raw.contains("keep-me"));
1624 }
1625
1626 #[test]
1627 fn native_primary_selection_enables_copilot_without_writing_a_token() {
1628 let (_dir, path) = temp_config(Some(
1629 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
1630 ));
1631 let cfg = Config::load_from(&path).unwrap();
1632 let select = serde_json::json!({
1633 "schema_version": 1,
1634 "primary": "copilot"
1635 });
1636 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
1637 let raw = std::fs::read_to_string(&path).unwrap();
1638 assert!(raw.contains("enabled = true"));
1639 assert!(raw.contains("primary = \"copilot\""));
1640 assert!(!raw.contains("token ="));
1641 assert!(!raw.contains("token_env ="));
1642 }
1643
1644 #[test]
1645 fn native_patch_errors_never_echo_key_values() {
1646 let raw = serde_json::json!({
1647 "schema_version": 1,
1648 "primary": "anthropic",
1649 "keys": {
1650 "zai": {"action": "set", "value": "secret\nwith-control"}
1651 }
1652 })
1653 .to_string();
1654 let error = state_from_apply_request(&Config::default(), &raw)
1655 .unwrap_err()
1656 .to_string();
1657 assert!(!error.contains("secret"));
1658 assert!(error.contains("control characters"));
1659 }
1660
1661 #[test]
1662 fn native_patch_input_is_bounded_before_json_parsing() {
1663 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
1664 let error = read_settings_request(std::io::Cursor::new(oversized))
1665 .unwrap_err()
1666 .to_string();
1667 assert!(error.contains("exceeds"));
1668 }
1669}