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