Skip to main content

ai_usagebar/tui/
settings.rs

1//! Settings overlay — opened from the TUI by pressing `s`. Lets the user pick
2//! the primary vendor and paste a credential for any API-key-authenticated vendor
3//! (including Z.AI, Kimi, MiniMax, and the balance vendors) without hand-editing
4//! config.toml. Anthropic, OpenAI, GitHub Copilot, Cursor, Kiro, Antigravity,
5//! Grok Bot, and Command Code authenticate through official or local product
6//! state, so they have no credential field here — there is nothing to paste,
7//! and a field would only imply otherwise. Kimi keeps
8//! its credential field because a platform key is still one of its two credentials, but
9//! a subscriber whose credential is the Kimi Code CLI login has nothing to paste
10//! and enables `[kimi]` in config.toml instead.
11//!
12//! Below the credentials sit the `[notifications]` fields: a toggle for the
13//! quota-threshold alerts and their threshold in percent. Everything persists
14//! through `toml_edit` so the existing config keeps its comments,
15//! whitespace, and unrelated fields. Writing a key also flips that vendor's
16//! `enabled = true` (the opt-in vendors are disabled by default), so "paste the
17//! credential and save" is all it takes. Files with inline credentials are atomically written
18//! and `chmod 600`ed.
19
20use std::collections::BTreeMap;
21use std::io::BufRead;
22use std::path::{Path, PathBuf};
23
24use ratatui::Frame;
25use ratatui::layout::{Constraint, Direction, Layout, Rect};
26use ratatui::style::Modifier;
27use ratatui::text::{Line, Span};
28use ratatui::widgets::{Clear, Paragraph};
29use ratatui_bubbletea_theme::BubbleTheme;
30use serde::{Deserialize, Serialize};
31use toml_edit::{DocumentMut, value};
32
33use crate::config::{
34    Config, is_valid_env_var_name, read_config_document, set_bool, set_value, write_config_document,
35};
36use crate::error::{AppError, Result};
37use crate::theme::Theme;
38use crate::tui::style::bubble_theme;
39use crate::vendor::VendorId;
40
41/// A vendor that authenticates with an inline credential. The order of this
42/// table is the tab order of the credential fields and the layout of the
43/// state's `keys` vec.
44pub struct KeyVendor {
45    pub id: VendorId,
46    pub label: &'static str,
47    pub section: &'static str,
48    /// Config field that stores the credential (`api_key` for most vendors).
49    pub config_key: &'static str,
50    /// Human-readable name shown in the native settings form.
51    pub secret_label: &'static str,
52    /// Extra hint after the env var (e.g. "management key"). Empty for none.
53    pub note: &'static str,
54}
55
56pub const KEY_VENDORS: &[KeyVendor] = &[
57    KeyVendor {
58        id: VendorId::AnthropicApi,
59        label: "Anthropic API",
60        section: VendorId::AnthropicApi.config_section(),
61        config_key: "api_key",
62        secret_label: "API key",
63        note: "admin key — monthly spend",
64    },
65    KeyVendor {
66        id: VendorId::Zai,
67        label: "Z.AI",
68        section: VendorId::Zai.config_section(),
69        config_key: "api_key",
70        secret_label: "API key",
71        note: "",
72    },
73    KeyVendor {
74        id: VendorId::Openrouter,
75        label: "OpenRouter",
76        section: VendorId::Openrouter.config_section(),
77        config_key: "api_key",
78        secret_label: "API key",
79        note: "",
80    },
81    KeyVendor {
82        id: VendorId::Deepseek,
83        label: "DeepSeek",
84        section: VendorId::Deepseek.config_section(),
85        config_key: "api_key",
86        secret_label: "API key",
87        note: "",
88    },
89    KeyVendor {
90        id: VendorId::Kimi,
91        label: "Kimi",
92        section: VendorId::Kimi.config_section(),
93        config_key: "api_key",
94        secret_label: "API key",
95        note: "coding-plan usage",
96    },
97    KeyVendor {
98        id: VendorId::Kilo,
99        label: "Kilo",
100        section: VendorId::Kilo.config_section(),
101        config_key: "api_key",
102        secret_label: "API key",
103        note: "",
104    },
105    KeyVendor {
106        id: VendorId::Novita,
107        label: "Novita",
108        section: VendorId::Novita.config_section(),
109        config_key: "api_key",
110        secret_label: "API key",
111        note: "",
112    },
113    KeyVendor {
114        id: VendorId::Moonshot,
115        label: "Moonshot",
116        section: VendorId::Moonshot.config_section(),
117        config_key: "api_key",
118        secret_label: "API key",
119        note: "account balance",
120    },
121    KeyVendor {
122        id: VendorId::Grok,
123        label: "Grok",
124        section: VendorId::Grok.config_section(),
125        config_key: "api_key",
126        secret_label: "API key",
127        note: "management key, not the inference key",
128    },
129    KeyVendor {
130        id: VendorId::Minimax,
131        label: "MiniMax",
132        section: VendorId::Minimax.config_section(),
133        config_key: "api_key",
134        secret_label: "API key",
135        note: "Token Plan subscription key",
136    },
137    KeyVendor {
138        id: VendorId::OpenCodeGo,
139        label: "OpenCode Go",
140        section: VendorId::OpenCodeGo.config_section(),
141        config_key: "api_key",
142        secret_label: "API key",
143        note: "usage quota",
144    },
145    KeyVendor {
146        id: VendorId::Ollama,
147        label: "Ollama Cloud",
148        section: VendorId::Ollama.config_section(),
149        config_key: "api_key",
150        secret_label: "API key",
151        note: "ollama.com/settings/keys",
152    },
153    KeyVendor {
154        id: VendorId::OrcaRouter,
155        label: "OrcaRouter",
156        section: VendorId::OrcaRouter.config_section(),
157        config_key: "api_key",
158        secret_label: "API key",
159        note: "credit balance",
160    },
161];
162
163/// Which control has keyboard focus. `Key(i)` indexes into [`KEY_VENDORS`].
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Focus {
166    Primary,
167    Key(usize),
168    NotifyEnabled,
169    NotifyThreshold,
170    Save,
171}
172
173impl Focus {
174    pub fn next(self) -> Self {
175        match self {
176            Focus::Primary => Focus::Key(0),
177            Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
178            Focus::Key(_) => Focus::NotifyEnabled,
179            Focus::NotifyEnabled => Focus::NotifyThreshold,
180            Focus::NotifyThreshold => Focus::Save,
181            Focus::Save => Focus::Primary,
182        }
183    }
184    pub fn prev(self) -> Self {
185        match self {
186            Focus::Primary => Focus::Save,
187            Focus::Key(0) => Focus::Primary,
188            Focus::Key(i) => Focus::Key(i - 1),
189            Focus::Save => Focus::NotifyThreshold,
190            Focus::NotifyThreshold => Focus::NotifyEnabled,
191            Focus::NotifyEnabled => Focus::Key(KEY_VENDORS.len() - 1),
192        }
193    }
194}
195
196/// Per-field text-input state — cursor + buffer + reveal flag.
197#[derive(Debug, Clone, Default)]
198pub struct KeyInput {
199    pub buf: String,
200    /// Char-index cursor position (0..=buf.chars().count()).
201    pub cursor: usize,
202    /// When true, the field renders the actual characters; otherwise `•`.
203    pub revealed: bool,
204    /// True after the user has typed/edited; only then does save write the
205    /// value back (avoids clobbering an existing key with the empty
206    /// placeholder the user opened the dialog with).
207    pub dirty: bool,
208}
209
210impl KeyInput {
211    pub fn from_config(initial: Option<&str>) -> Self {
212        let buf = initial.unwrap_or("").to_string();
213        let cursor = buf.chars().count();
214        Self {
215            buf,
216            cursor,
217            revealed: false,
218            dirty: false,
219        }
220    }
221
222    pub fn insert_char(&mut self, c: char) {
223        let byte_idx = self.char_to_byte(self.cursor);
224        self.buf.insert(byte_idx, c);
225        self.cursor += 1;
226        self.dirty = true;
227    }
228
229    pub fn backspace(&mut self) {
230        if self.cursor == 0 {
231            return;
232        }
233        let prev_byte = self.char_to_byte(self.cursor - 1);
234        let cur_byte = self.char_to_byte(self.cursor);
235        self.buf.replace_range(prev_byte..cur_byte, "");
236        self.cursor -= 1;
237        self.dirty = true;
238    }
239
240    pub fn delete(&mut self) {
241        let n = self.buf.chars().count();
242        if self.cursor >= n {
243            return;
244        }
245        let cur_byte = self.char_to_byte(self.cursor);
246        let next_byte = self.char_to_byte(self.cursor + 1);
247        self.buf.replace_range(cur_byte..next_byte, "");
248        self.dirty = true;
249    }
250
251    pub fn move_left(&mut self) {
252        if self.cursor > 0 {
253            self.cursor -= 1;
254        }
255    }
256    pub fn move_right(&mut self) {
257        if self.cursor < self.buf.chars().count() {
258            self.cursor += 1;
259        }
260    }
261    pub fn move_home(&mut self) {
262        self.cursor = 0;
263    }
264    pub fn move_end(&mut self) {
265        self.cursor = self.buf.chars().count();
266    }
267    pub fn toggle_reveal(&mut self) {
268        self.revealed = !self.revealed;
269    }
270
271    /// Render for display — bullets when masked, raw chars when revealed.
272    pub fn display(&self) -> String {
273        if self.revealed {
274            self.buf.clone()
275        } else {
276            "•".repeat(self.buf.chars().count())
277        }
278    }
279
280    fn char_to_byte(&self, char_idx: usize) -> usize {
281        self.buf
282            .char_indices()
283            .map(|(b, _)| b)
284            .chain(std::iter::once(self.buf.len()))
285            .nth(char_idx)
286            .unwrap_or(self.buf.len())
287    }
288}
289
290/// Mutable state of the overlay while open.
291#[derive(Debug, Clone)]
292pub struct SettingsState {
293    pub focus: Focus,
294    /// Enabled vendors only. The primary selector must not offer a value that
295    /// cannot actually be used by the widget or TUI.
296    pub primary_choices: Vec<VendorId>,
297    pub primary: VendorId,
298    /// One input per [`KEY_VENDORS`] entry, same order.
299    pub keys: Vec<KeyInput>,
300    /// `[notifications] enabled` toggle. Written back only once toggled.
301    pub notify_enabled: bool,
302    pub notify_enabled_dirty: bool,
303    /// `[notifications] threshold`, edited as digits only (1..=100 at save).
304    pub notify_threshold: KeyInput,
305    /// One-line status displayed in the footer ("saved …", "save failed …").
306    pub status: String,
307}
308
309impl SettingsState {
310    pub fn from_config(cfg: &Config) -> Self {
311        Self::from_config_with(cfg, |name| {
312            std::env::var_os(name).is_some_and(|v| !v.is_empty())
313        })
314    }
315
316    /// [`Self::from_config`] with an injected environment lookup.
317    ///
318    /// The overlay offers a key-only vendor whose env var is already exported,
319    /// so a user need not hand-edit `config.toml` to select it. That is a read
320    /// of ambient state, which a test must never depend on: the AUR `check()`
321    /// runs `cargo test` during `makepkg`, so a test that branched on the real
322    /// environment would fail the install for anyone who exports, say,
323    /// `OLLAMA_API_KEY`. Tests pass their own lookup here.
324    pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
325        let keys = KEY_VENDORS
326            .iter()
327            .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
328            .collect();
329        let mut primary_choices = cfg.enabled_vendors();
330        // Copilot credentials belong to GitHub CLI, so a login cannot write a
331        // local key that would also opt it in. Offer it explicitly instead:
332        // selecting it persists both the primary and `enabled = true`.
333        if !primary_choices.contains(&VendorId::Copilot) {
334            primary_choices.push(VendorId::Copilot);
335        }
336        // Key-only vendors are opt-in: without an inline `api_key` and with
337        // the env var empty, the fetch would fail on the first cycle. A user
338        // who already exported the env var (e.g. `OLLAMA_API_KEY`) and wants
339        // to flip `enabled = true` from inside the TUI has to be able to
340        // select the vendor here — otherwise they'd have to hand-edit
341        // `config.toml`, which is the workflow this overlay exists to avoid.
342        // We treat a non-empty env var as a sufficient signal of reachability.
343        for kv in KEY_VENDORS {
344            if primary_choices.contains(&kv.id) {
345                continue;
346            }
347            let env = cfg.api_key_env_for(kv.id);
348            let exported = is_valid_env_var_name(env) && env_set(env);
349            if cfg.inline_api_key(kv.id).is_some() || exported {
350                primary_choices.push(kv.id);
351            }
352        }
353        // A configured but disabled primary is ineffective. Display the first
354        // enabled vendor instead; when none are enabled retain the historical
355        // Anthropic fallback in memory without inventing a persisted primary.
356        let primary = cfg
357            .ui
358            .primary
359            .filter(|vendor| {
360                primary_choices.contains(vendor)
361                    && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
362            })
363            .or_else(|| primary_choices.first().copied())
364            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
365        Self {
366            focus: Focus::Primary,
367            primary_choices,
368            primary,
369            keys,
370            notify_enabled: cfg.notifications.enabled,
371            notify_enabled_dirty: false,
372            notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
373            status: String::new(),
374        }
375    }
376
377    /// The focused key input, if a key row is focused.
378    fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
379        match self.focus {
380            Focus::Key(i) => self.keys.get_mut(i),
381            _ => None,
382        }
383    }
384}
385
386/// What the key handler asks the host app to do next.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Action {
389    /// Stay open, keep listening for keys.
390    Continue,
391    /// Close the overlay (discard or save already happened).
392    Close,
393    /// Save just succeeded — caller should refresh affected vendors.
394    SavedAndClose,
395    /// Quit the host TUI. Ctrl-C remains global even while the overlay owns
396    /// keyboard focus.
397    Quit,
398}
399
400/// Permission note appended to the "saved" status line. The overlay `chmod
401/// 600`s the file on Unix; Windows has no such step, so the note is empty there.
402#[cfg(unix)]
403const PERMS_NOTE: &str = " (chmod 600)";
404#[cfg(not(unix))]
405const PERMS_NOTE: &str = "";
406
407fn saved_status() -> String {
408    format!(
409        "saved to {}{}",
410        crate::config::config_path_hint(),
411        PERMS_NOTE
412    )
413}
414
415/// Key map. Returns the action to perform after the keypress.
416pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
417    if matches!(code, KeyCode::Esc) {
418        return Action::Close;
419    }
420    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
421        return Action::Quit;
422    }
423    // Ctrl-S triggers save from any field.
424    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
425        return try_save(state);
426    }
427    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
428        if let Some(input) = state.focused_key_mut() {
429            input.toggle_reveal();
430        }
431        return Action::Continue;
432    }
433    match code {
434        KeyCode::Tab | KeyCode::Down => {
435            state.focus = state.focus.next();
436            return Action::Continue;
437        }
438        KeyCode::BackTab | KeyCode::Up => {
439            state.focus = state.focus.prev();
440            return Action::Continue;
441        }
442        _ => {}
443    }
444
445    // A modifier chord is not text. The overlay swallows every key while open,
446    // so every unhandled chord must be ignored rather than corrupting the
447    // secret silently. SHIFT is deliberately not rejected — it is how
448    // uppercase arrives. Ctrl-C was handled above because it is a global quit.
449    if matches!(code, KeyCode::Char(_))
450        && mods.intersects(
451            KeyModifiers::CONTROL
452                | KeyModifiers::ALT
453                | KeyModifiers::SUPER
454                | KeyModifiers::HYPER
455                | KeyModifiers::META,
456        )
457    {
458        return Action::Continue;
459    }
460
461    // Field-specific handling.
462    match state.focus {
463        Focus::Primary => handle_primary(state, code),
464        Focus::Key(i) => {
465            if let Some(input) = state.keys.get_mut(i) {
466                handle_input(input, code);
467            }
468        }
469        Focus::NotifyEnabled => handle_notify_enabled(state, code),
470        Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
471        Focus::Save => {
472            if matches!(code, KeyCode::Enter) {
473                return try_save(state);
474            }
475        }
476    }
477    Action::Continue
478}
479
480fn try_save(state: &mut SettingsState) -> Action {
481    match save_to_config_default(state) {
482        Ok(()) => {
483            state.status = saved_status();
484            Action::SavedAndClose
485        }
486        Err(e) => {
487            state.status = format!("save failed: {e}");
488            Action::Continue
489        }
490    }
491}
492
493fn handle_primary(state: &mut SettingsState, code: KeyCode) {
494    // Left/Right cycles the primary-vendor radio over enabled vendors only.
495    let choices = &state.primary_choices;
496    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
497        return;
498    };
499    let step = match code {
500        KeyCode::Left => -1,
501        KeyCode::Right | KeyCode::Char(' ') => 1,
502        _ => return,
503    };
504    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
505}
506
507fn handle_input(input: &mut KeyInput, code: KeyCode) {
508    match code {
509        KeyCode::Char(c) => input.insert_char(c),
510        KeyCode::Backspace => input.backspace(),
511        KeyCode::Delete => input.delete(),
512        KeyCode::Left => input.move_left(),
513        KeyCode::Right => input.move_right(),
514        KeyCode::Home => input.move_home(),
515        KeyCode::End => input.move_end(),
516        _ => {}
517    }
518}
519
520/// The `[notifications] enabled` toggle. Left/Right/Space flip it; any other
521/// key leaves it alone.
522fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
523    if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
524        state.notify_enabled = !state.notify_enabled;
525        state.notify_enabled_dirty = true;
526    }
527}
528
529/// The `[notifications] threshold` field: an edit buffer that only accepts
530/// digits, so the value can never be something save would have to reject as
531/// non-numeric (range is still checked at save).
532fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
533    match code {
534        KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
535        KeyCode::Backspace => input.backspace(),
536        KeyCode::Delete => input.delete(),
537        KeyCode::Left => input.move_left(),
538        KeyCode::Right => input.move_right(),
539        KeyCode::Home => input.move_home(),
540        KeyCode::End => input.move_end(),
541        _ => {}
542    }
543}
544
545/// Save to the platform config path (creating it). On success, signal a running
546/// Waybar (`SIGRTMIN+13`) so a `signal: 13` module refreshes immediately.
547fn save_to_config_default(state: &SettingsState) -> Result<()> {
548    let path = default_config_path()?;
549    if let Some(parent) = path.parent() {
550        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
551    }
552    save_to_path(state, &path)?;
553    crate::waybar::request_refresh();
554    Ok(())
555}
556
557/// Same as `save_to_config_default` but with an explicit path — exposed for
558/// tests. Writing a non-empty credential also sets that vendor's `enabled = true`.
559pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
560    let mut doc = read_config_document(path)?;
561
562    // Remove fields written by the short-lived inline Copilot-token design.
563    // GitHub CLI owns the OAuth credential now; retaining a secret this app
564    // neither reads nor supports would be misleading and unsafe.
565    if let Some(table) = doc
566        .get_mut("copilot")
567        .and_then(toml_edit::Item::as_table_mut)
568    {
569        table.remove("token");
570        table.remove("token_env");
571    }
572
573    // Only Copilot is deliberately offered before it is enabled: choosing it
574    // is the explicit opt-in after the GitHub CLI login. The env-only key
575    // vendors (any `KEY_VENDORS` entry whose credential the user reached via
576    // `OLLAMA_API_KEY` or another env var) are also offered before they are
577    // enabled, and selecting them is the explicit opt-in for that vendor —
578    // otherwise a user could pick a vendor in the overlay and the fetch
579    // would still fail because `enabled = false`. Every other choice remains
580    // enabled-only, so no failed provider is persisted as primary.
581    if state.primary_choices.contains(&state.primary) {
582        set_string(&mut doc, "ui", "primary", state.primary.slug())?;
583        if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
584        {
585            set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
586        }
587    }
588
589    for (i, kv) in KEY_VENDORS.iter().enumerate() {
590        let Some(input) = state.keys.get(i) else {
591            continue;
592        };
593        update_key(&mut doc, kv, input)?;
594    }
595
596    // [notifications]: each field is written only when the user touched it,
597    // so an untouched overlay save leaves an absent/commented section alone.
598    if state.notify_enabled_dirty {
599        set_bool(&mut doc, "notifications", "enabled", state.notify_enabled)?;
600    }
601    if state.notify_threshold.dirty {
602        let raw = state.notify_threshold.buf.trim();
603        let threshold = raw.parse::<u8>();
604        match threshold {
605            Ok(threshold) if (1..=100).contains(&threshold) => {
606                set_value(
607                    &mut doc,
608                    "notifications",
609                    "threshold",
610                    Some(toml_edit::Value::from(i64::from(threshold))),
611                )?;
612            }
613            _ => {
614                return Err(AppError::Other(format!(
615                    "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
616                )));
617            }
618        }
619    }
620
621    write_config_document(path, &doc)
622}
623
624/// Apply one credential field to the document. Untouched fields are left
625/// alone; a field the user cleared is *removed*, so an inline secret can be
626/// deleted from the overlay rather than lingering in the file. Writing a
627/// non-empty credential also opts the vendor in — the opt-in vendors would
628/// otherwise never fetch.
629fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
630    if !input.dirty {
631        return Ok(());
632    }
633    if input.buf.is_empty() {
634        if let Some(table) = doc
635            .get_mut(vendor.section)
636            .and_then(toml_edit::Item::as_table_mut)
637        {
638            table.remove(vendor.config_key);
639        }
640        return Ok(());
641    }
642    set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
643    set_bool(doc, vendor.section, "enabled", true)
644}
645
646/// Set or update a string field in a TOML section, preserving comments and
647/// formatting of unaffected nodes.
648fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
649    let table = doc
650        .entry(section)
651        .or_insert_with(toml_edit::table)
652        .as_table_mut()
653        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
654
655    if let Some(item) = table.get_mut(key)
656        && let Some(v) = item.as_value_mut()
657    {
658        *v = toml_edit::Value::from(new_value);
659        v.decor_mut().set_prefix(" ");
660        return Ok(());
661    }
662    table.insert(key, value(new_value));
663    Ok(())
664}
665
666fn default_config_path() -> Result<PathBuf> {
667    // Save back to the same file Config::load() selected. On macOS this may be
668    // the legacy ~/.config path when the canonical Application Support file is
669    // absent; writing a new canonical file would shadow the existing config on
670    // the next load and silently discard all settings the overlay did not copy.
671    crate::config::resolved_path()
672        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
673}
674
675// ─── Native frontend bridge ───────────────────────────────────────────────
676
677/// Versioned, non-secret description consumed by native desktop frontends.
678/// Inline key values are deliberately represented only as booleans: a
679/// long-lived shell process never needs to receive credentials just to draw a
680/// settings form.
681#[derive(Debug, Serialize)]
682struct SettingsSnapshot {
683    schema_version: u8,
684    primary: String,
685    primary_choices: Vec<PrimaryChoice>,
686    keys: Vec<KeyStatus>,
687}
688
689#[derive(Debug, Serialize)]
690struct PrimaryChoice {
691    id: String,
692    label: String,
693}
694
695#[derive(Debug, Serialize)]
696struct KeyStatus {
697    id: String,
698    label: String,
699    environment: String,
700    secret_label: String,
701    note: String,
702    configured: bool,
703    inline_configured: bool,
704    environment_configured: bool,
705}
706
707/// Additive patch accepted on stdin by `ai-usagebar settings apply`.
708/// Missing keys remain byte-for-byte untouched. `clear` explicitly removes an
709/// inline key, matching the TUI overlay's existing empty-dirty-field behavior.
710#[derive(Debug, Deserialize)]
711#[serde(deny_unknown_fields)]
712struct ApplyRequest {
713    schema_version: u8,
714    primary: Option<String>,
715    #[serde(default)]
716    keys: BTreeMap<String, KeyMutation>,
717}
718
719#[derive(Debug, Deserialize)]
720#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
721enum KeyMutation {
722    Set { value: String },
723    Clear,
724}
725
726const SETTINGS_SCHEMA_VERSION: u8 = 1;
727const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
728const MAX_API_KEY_BYTES: usize = 16 * 1024;
729
730fn snapshot_from_config_with(
731    cfg: &Config,
732    environment_configured: impl Fn(&str) -> bool,
733) -> SettingsSnapshot {
734    let state = SettingsState::from_config(cfg);
735    let primary_choices = state
736        .primary_choices
737        .iter()
738        .map(|id| PrimaryChoice {
739            id: id.slug().to_string(),
740            label: id.display_name().to_string(),
741        })
742        .collect();
743    let keys = KEY_VENDORS
744        .iter()
745        .map(|vendor| {
746            let environment = cfg.api_key_env_for(vendor.id);
747            let inline_configured = cfg.inline_api_key(vendor.id).is_some();
748            let environment_configured = environment_configured(environment);
749            KeyStatus {
750                id: vendor.id.slug().to_string(),
751                label: vendor.label.to_string(),
752                environment: environment.to_string(),
753                secret_label: vendor.secret_label.to_string(),
754                note: vendor.note.to_string(),
755                configured: inline_configured || environment_configured,
756                inline_configured,
757                environment_configured,
758            }
759        })
760        .collect();
761    SettingsSnapshot {
762        schema_version: SETTINGS_SCHEMA_VERSION,
763        primary: state.primary.slug().to_string(),
764        primary_choices,
765        keys,
766    }
767}
768
769fn settings_snapshot_json(cfg: &Config) -> Result<String> {
770    Ok(serde_json::to_string(&snapshot_from_config_with(
771        cfg,
772        |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
773    ))?)
774}
775
776#[cfg(test)]
777fn settings_snapshot_json_with(
778    cfg: &Config,
779    environment_configured: impl Fn(&str) -> bool,
780) -> Result<String> {
781    Ok(serde_json::to_string(&snapshot_from_config_with(
782        cfg,
783        environment_configured,
784    ))?)
785}
786
787fn vendor_from_slug(slug: &str) -> Option<VendorId> {
788    VendorId::all().iter().copied().find(|id| id.slug() == slug)
789}
790
791fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
792    let request: ApplyRequest = serde_json::from_str(raw)?;
793    if request.schema_version != SETTINGS_SCHEMA_VERSION {
794        return Err(AppError::Other(format!(
795            "unsupported settings schema version {}",
796            request.schema_version
797        )));
798    }
799
800    let mut state = SettingsState::from_config(cfg);
801    if let Some(primary) = request.primary {
802        let id = vendor_from_slug(&primary)
803            .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
804        if !state.primary_choices.contains(&id) {
805            return Err(AppError::Other(format!(
806                "primary vendor {primary:?} is not enabled"
807            )));
808        }
809        state.primary = id;
810    }
811
812    for (id, mutation) in request.keys {
813        let index = KEY_VENDORS
814            .iter()
815            .position(|vendor| vendor.id.slug() == id)
816            .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
817        let input = &mut state.keys[index];
818        match mutation {
819            KeyMutation::Set { value } => {
820                if value.is_empty() {
821                    return Err(AppError::Other(format!(
822                        "{} for {id:?} is empty; use the clear action to remove it",
823                        KEY_VENDORS[index].secret_label
824                    )));
825                }
826                if value.len() > MAX_API_KEY_BYTES {
827                    return Err(AppError::Other(format!(
828                        "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
829                        KEY_VENDORS[index].secret_label
830                    )));
831                }
832                if value.chars().any(char::is_control) {
833                    return Err(AppError::Other(format!(
834                        "{} for {id:?} contains control characters",
835                        KEY_VENDORS[index].secret_label
836                    )));
837                }
838                input.buf = value;
839            }
840            KeyMutation::Clear => input.buf.clear(),
841        }
842        input.cursor = input.buf.chars().count();
843        input.dirty = true;
844        input.revealed = false;
845    }
846    Ok(state)
847}
848
849#[cfg(test)]
850fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
851    let state = state_from_apply_request(cfg, raw)?;
852    save_to_path(&state, path)
853}
854
855fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
856    let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
857    let mut bytes = Vec::new();
858    limited.read_until(b'\n', &mut bytes)?;
859    if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
860        return Err(AppError::Other(format!(
861            "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
862        )));
863    }
864    if bytes.last() == Some(&b'\n') {
865        bytes.pop();
866        if bytes.last() == Some(&b'\r') {
867            bytes.pop();
868        }
869    }
870    String::from_utf8(bytes)
871        .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
872}
873
874fn apply_settings_from_stdin() -> Result<()> {
875    let raw = read_settings_request(std::io::stdin().lock())?;
876    let cfg = Config::load()?;
877    let state = state_from_apply_request(&cfg, &raw)?;
878    save_to_config_default(&state)
879}
880
881/// Explicit user opt-in, unlike discovery which respects an existing false.
882fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
883    let mut doc = read_config_document(path)?;
884    let before = doc.to_string();
885    set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
886    if doc.to_string() != before {
887        write_config_document(path, &doc)?;
888    }
889    Ok(())
890}
891
892/// Administrative settings bridge for native frontends. `show` never emits a
893/// secret; `apply` accepts its patch only over stdin so keys do not appear in
894/// argv or the process environment.
895pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
896    let result = match action {
897        crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
898            .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
899            .map(|()| {
900                crate::waybar::request_refresh();
901                println!(r#"{{"ok":true}}"#);
902            }),
903        crate::widget::cli::SettingsAction::Show => Config::load()
904            .and_then(|cfg| settings_snapshot_json(&cfg))
905            .map(|json| println!("{json}")),
906        crate::widget::cli::SettingsAction::Apply => {
907            apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
908        }
909    };
910    match result {
911        Ok(()) => 0,
912        Err(error) => {
913            eprintln!("settings: {error}");
914            1
915        }
916    }
917}
918
919// ─── Render ────────────────────────────────────────────────────────────────
920
921/// Render the modal overlay over `area`.
922pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
923    let modal = centered_rect(74, 88, area);
924    f.render_widget(Clear, modal);
925
926    let bubble = bubble_theme(theme);
927    let block = bubble.titled_modal_block(" Settings ");
928    let inner = block.inner(modal);
929    f.render_widget(block, modal);
930
931    // Body (everything but the pinned hint) + a 1-line hint footer.
932    let chunks = Layout::default()
933        .direction(Direction::Vertical)
934        .constraints([Constraint::Min(0), Constraint::Length(1)])
935        .split(inner);
936
937    // — Primary vendor + credentials header —
938    let mut lines: Vec<Line> = vec![
939        section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
940        primary_line(state, &bubble),
941        Line::from(""),
942        section_header(
943            "Credentials",
944            "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
945            &bubble,
946        ),
947    ];
948    for (i, kv) in KEY_VENDORS.iter().enumerate() {
949        let focused = state.focus == Focus::Key(i);
950        lines.push(key_row(kv, &state.keys[i], focused, &bubble));
951    }
952    lines.push(Line::from(""));
953
954    // — Notifications —
955    lines.push(section_header(
956        "Notifications",
957        "desktop alert when a quota window crosses the threshold",
958        &bubble,
959    ));
960    lines.push(notify_enabled_line(state, &bubble));
961    lines.push(notify_threshold_line(state, &bubble));
962    lines.push(Line::from(""));
963
964    // — Save + status —
965    lines.push(save_line(state.focus == Focus::Save, &bubble));
966    if !state.status.is_empty() {
967        let ok = state.status.starts_with("saved");
968        let mark = if ok { "  ✓ " } else { "  ✗ " };
969        let style = if ok { bubble.accent } else { bubble.selected };
970        lines.push(Line::from(vec![
971            Span::styled(mark, style.add_modifier(Modifier::BOLD)),
972            Span::styled(state.status.clone(), bubble.muted),
973        ]));
974    }
975
976    f.render_widget(Paragraph::new(lines), chunks[0]);
977
978    // Context-aware hint footer.
979    let hint = match state.focus {
980        Focus::Primary => bubble.help_line([
981            ("↑↓/tab", "move"),
982            ("←→", "change vendor"),
983            ("^S", "save"),
984            ("esc", "close"),
985        ]),
986        Focus::Key(_) => bubble.help_line([
987            ("↑↓/tab", "move"),
988            ("type", "edit key"),
989            ("^V", "reveal"),
990            ("^S", "save"),
991            ("esc", "close"),
992        ]),
993        Focus::NotifyEnabled => bubble.help_line([
994            ("↑↓/tab", "move"),
995            ("←→/space", "toggle"),
996            ("^S", "save"),
997            ("esc", "close"),
998        ]),
999        Focus::NotifyThreshold => bubble.help_line([
1000            ("↑↓/tab", "move"),
1001            ("type", "digits 1-100"),
1002            ("^S", "save"),
1003            ("esc", "close"),
1004        ]),
1005        Focus::Save => {
1006            bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
1007        }
1008    };
1009    f.render_widget(Paragraph::new(hint), chunks[1]);
1010}
1011
1012fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1013    Line::from(vec![
1014        theme.span(" "),
1015        Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1016        theme.muted(format!("   — {sub}")),
1017    ])
1018}
1019
1020fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1021    let focused = state.focus == Focus::Primary;
1022    let name = state.primary.display_name().to_string();
1023    if focused {
1024        Line::from(vec![
1025            theme.span("   "),
1026            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1027            Span::styled("◀ ", theme.accent),
1028            Span::styled(
1029                format!(" {name} "),
1030                theme
1031                    .selected
1032                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1033            ),
1034            Span::styled(" ▶", theme.accent),
1035            theme.muted("    ← → to change"),
1036        ])
1037    } else {
1038        Line::from(vec![theme.span("     "), Span::styled(name, theme.text)])
1039    }
1040}
1041
1042fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1043    let label = format!("{:<11}", kv.label);
1044    let value = value_text(input, focused);
1045
1046    // Env / status suffix: env-var name, whether an env override is set, note.
1047    let env_name = kv.id.api_key_env();
1048    let env_set = std::env::var(env_name)
1049        .map(|v| !v.is_empty())
1050        .unwrap_or(false);
1051    let mut suffix = format!("   {env_name}");
1052    if env_set {
1053        suffix.push_str(" · env set (overrides)");
1054    }
1055    if !kv.note.is_empty() {
1056        suffix.push_str(&format!(" · {}", kv.note));
1057    }
1058
1059    if focused {
1060        let val_style = if input.buf.is_empty() {
1061            theme.accent.add_modifier(Modifier::BOLD)
1062        } else {
1063            theme.selected.add_modifier(Modifier::REVERSED)
1064        };
1065        let mut spans = vec![
1066            theme.span("  "),
1067            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1068            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1069            Span::styled(format!(" {value} "), val_style),
1070        ];
1071        if input.revealed {
1072            spans.push(theme.muted("  [revealed]"));
1073        }
1074        spans.push(theme.muted(suffix));
1075        Line::from(spans)
1076    } else {
1077        let val_style = if input.buf.is_empty() {
1078            theme.muted
1079        } else {
1080            theme.text
1081        };
1082        Line::from(vec![
1083            theme.span("    "),
1084            Span::styled(label, theme.text),
1085            Span::styled(format!(" {value}"), val_style),
1086            theme.muted(suffix),
1087        ])
1088    }
1089}
1090
1091/// The value column: `(empty)` / a cursor when focused-empty / masked or
1092/// revealed buffer with a cursor mark inserted when focused.
1093fn value_text(input: &KeyInput, focused: bool) -> String {
1094    if input.buf.is_empty() {
1095        return if focused {
1096            "‸".to_string()
1097        } else {
1098            "(empty)".to_string()
1099        };
1100    }
1101    let base = input.display();
1102    if !focused {
1103        return base;
1104    }
1105    let mut chars: Vec<char> = base.chars().collect();
1106    let pos = input.cursor.min(chars.len());
1107    chars.insert(pos, '‸');
1108    chars.into_iter().collect()
1109}
1110
1111/// `[notifications] enabled` row — a plain on/off toggle styled like the
1112/// primary selector.
1113fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1114    let focused = state.focus == Focus::NotifyEnabled;
1115    let label = format!("{:<11}", "Quota alerts");
1116    let value = if state.notify_enabled { "on" } else { "off" };
1117    if focused {
1118        Line::from(vec![
1119            theme.span("   "),
1120            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1121            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1122            Span::styled(
1123                format!(" ◀ {value} ▶ "),
1124                theme
1125                    .selected
1126                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1127            ),
1128        ])
1129    } else {
1130        Line::from(vec![
1131            theme.span("     "),
1132            Span::styled(label, theme.text),
1133            Span::styled(format!("  {value}"), theme.muted),
1134        ])
1135    }
1136}
1137
1138/// `[notifications] threshold` row — the digits are never masked, so the
1139/// number is readable at a glance.
1140fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1141    let focused = state.focus == Focus::NotifyThreshold;
1142    let label = format!("{:<11}", "Threshold %");
1143    let input = &state.notify_threshold;
1144    let value = if input.buf.is_empty() {
1145        if focused {
1146            "‸".to_string()
1147        } else {
1148            "(97)".to_string()
1149        }
1150    } else {
1151        let mut chars: Vec<char> = input.buf.chars().collect();
1152        if focused {
1153            let pos = input.cursor.min(chars.len());
1154            chars.insert(pos, '‸');
1155        }
1156        chars.into_iter().collect()
1157    };
1158    if focused {
1159        Line::from(vec![
1160            theme.span("  "),
1161            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1162            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1163            Span::styled(
1164                format!(" {value} "),
1165                theme
1166                    .selected
1167                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1168            ),
1169            theme.muted("   1-100"),
1170        ])
1171    } else {
1172        Line::from(vec![
1173            theme.span("    "),
1174            Span::styled(label, theme.text),
1175            Span::styled(format!(" {value}"), theme.text),
1176            theme.muted("   1-100"),
1177        ])
1178    }
1179}
1180
1181fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
1182    let style = if focused {
1183        theme
1184            .selected
1185            .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1186    } else {
1187        theme.accent.add_modifier(Modifier::BOLD)
1188    };
1189    let marker = if focused { "▸ " } else { "  " };
1190    Line::from(vec![
1191        theme.span("   "),
1192        Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1193        Span::styled("  Save  (Ctrl-S)  ", style),
1194    ])
1195}
1196
1197/// Center a rectangle of `percent_x * percent_y` over `r`.
1198fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1199    let popup_h = (r.height * percent_y) / 100;
1200    let popup_w = (r.width * percent_x) / 100;
1201    Rect {
1202        x: r.x + (r.width - popup_w) / 2,
1203        y: r.y + (r.height - popup_h) / 2,
1204        width: popup_w,
1205        height: popup_h,
1206    }
1207}
1208
1209// crossterm types live behind ratatui; re-exported here for handle_key callers.
1210pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use tempfile::TempDir;
1216
1217    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1218        crate::cache::closed_temp_file("config.toml", initial)
1219    }
1220
1221    fn key_index(id: VendorId) -> usize {
1222        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1223    }
1224
1225    #[test]
1226    fn explicit_enable_preserves_other_settings_and_is_idempotent() {
1227        let dir = tempfile::tempdir().unwrap();
1228        let path = dir.path().join("config.toml");
1229        let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
1230        std::fs::write(&path, original).unwrap();
1231        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1232        let expected = original.replacen("enabled = false", "enabled = true", 1);
1233        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1234        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1235        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1236    }
1237
1238    #[test]
1239    fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
1240        let dir = tempfile::tempdir().unwrap();
1241        let path = dir.path().join("nested/config.toml");
1242        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1243        assert!(
1244            std::fs::read_to_string(&path)
1245                .unwrap()
1246                .contains("enabled = true")
1247        );
1248        let broken = "[anthropic\n";
1249        std::fs::write(&path, broken).unwrap();
1250        assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
1251        assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
1252    }
1253
1254    fn blank_state(primary: VendorId) -> SettingsState {
1255        SettingsState {
1256            focus: Focus::Primary,
1257            primary_choices: VendorId::all().to_vec(),
1258            primary,
1259            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1260            notify_enabled: true,
1261            notify_enabled_dirty: false,
1262            notify_threshold: KeyInput::from_config(Some("97")),
1263            status: String::new(),
1264        }
1265    }
1266
1267    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
1268    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1269        let mut s = blank_state(primary);
1270        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1271        s.keys[key_index(VendorId::Zai)].dirty = true;
1272        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1273        s.keys[key_index(VendorId::Openrouter)].dirty = true;
1274        s
1275    }
1276
1277    #[test]
1278    fn focus_cycles_through_primary_all_keys_notifications_and_save() {
1279        let mut f = Focus::Primary;
1280        let mut seen = vec![f];
1281        // Full cycle = Primary + N key rows + both notification fields + Save.
1282        for _ in 0..(KEY_VENDORS.len() + 4) {
1283            f = f.next();
1284            seen.push(f);
1285        }
1286        // Primary, Key(0..n), NotifyEnabled, NotifyThreshold, Save, Primary.
1287        assert_eq!(seen.first(), Some(&Focus::Primary));
1288        assert_eq!(seen.last(), Some(&Focus::Primary));
1289        assert!(seen.contains(&Focus::Key(0)));
1290        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1291        assert!(seen.contains(&Focus::NotifyEnabled));
1292        assert!(seen.contains(&Focus::NotifyThreshold));
1293        assert!(seen.contains(&Focus::Save));
1294        // prev() is the inverse of next().
1295        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1296        assert_eq!(Focus::Save.prev().next(), Focus::Save);
1297        assert_eq!(
1298            Focus::NotifyEnabled.prev(),
1299            Focus::Key(KEY_VENDORS.len() - 1)
1300        );
1301        assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
1302        assert_eq!(Focus::Primary.prev(), Focus::Save);
1303    }
1304
1305    #[test]
1306    fn every_key_vendor_has_a_field() {
1307        // Every enabled-by-key vendor must be reachable in the form.
1308        for id in [
1309            VendorId::Zai,
1310            VendorId::Openrouter,
1311            VendorId::Deepseek,
1312            VendorId::Kilo,
1313            VendorId::Novita,
1314            VendorId::Moonshot,
1315            VendorId::Grok,
1316        ] {
1317            assert!(
1318                KEY_VENDORS.iter().any(|kv| kv.id == id),
1319                "{id:?} has no key field"
1320            );
1321        }
1322        // OAuth vendors are intentionally absent.
1323        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1324        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1325    }
1326
1327    #[test]
1328    fn from_config_prefills_existing_keys() {
1329        let mut cfg = Config::default();
1330        cfg.kilo.api_key = Some("sk-kilo".into());
1331        let s = SettingsState::from_config(&cfg);
1332        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1333        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1334    }
1335
1336    #[test]
1337    fn from_config_prefills_the_notification_fields() {
1338        let mut cfg = Config::default();
1339        cfg.notifications.enabled = false;
1340        cfg.notifications.threshold = 100;
1341        let s = SettingsState::from_config_with(&cfg, |_| false);
1342        assert!(!s.notify_enabled);
1343        assert!(!s.notify_enabled_dirty);
1344        assert_eq!(s.notify_threshold.buf, "100");
1345        assert!(!s.notify_threshold.dirty);
1346    }
1347
1348    #[test]
1349    fn notification_toggle_flips_on_left_right_and_space() {
1350        let mut s = blank_state(VendorId::Anthropic);
1351        s.focus = Focus::NotifyEnabled;
1352        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1353        assert!(!s.notify_enabled);
1354        assert!(s.notify_enabled_dirty);
1355        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1356        assert!(s.notify_enabled);
1357        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1358        assert!(!s.notify_enabled);
1359        // Any other key leaves the toggle (and its dirty flag) alone.
1360        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
1361        assert!(!s.notify_enabled);
1362    }
1363
1364    #[test]
1365    fn threshold_edits_accept_digits_and_reject_everything_else() {
1366        let mut s = blank_state(VendorId::Anthropic);
1367        s.focus = Focus::NotifyThreshold;
1368        // Start from an empty buffer to observe each accepted char.
1369        s.notify_threshold = KeyInput::default();
1370        for c in ['9', 'a', '.', '-', '→'] {
1371            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1372        }
1373        assert_eq!(s.notify_threshold.buf, "9");
1374        assert!(s.notify_threshold.dirty);
1375        handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
1376        assert_eq!(s.notify_threshold.buf, "98");
1377        handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
1378        assert_eq!(s.notify_threshold.buf, "9");
1379    }
1380
1381    #[test]
1382    fn save_writes_the_notification_fields_and_round_trips() {
1383        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1384        let mut s = blank_state(VendorId::Anthropic);
1385        s.notify_enabled = false;
1386        s.notify_enabled_dirty = true;
1387        // Start from an empty buffer so the written value is exactly "90".
1388        s.notify_threshold = KeyInput::default();
1389        for c in "90".chars() {
1390            s.notify_threshold.insert_char(c);
1391        }
1392        save_to_path(&s, &path).unwrap();
1393
1394        let raw = std::fs::read_to_string(&path).unwrap();
1395        assert!(raw.contains("[notifications]"), "{raw}");
1396        assert!(raw.contains("enabled = false"), "{raw}");
1397        assert!(raw.contains("threshold = 90"), "{raw}");
1398        // The written file parses back through the same config path.
1399        let reloaded = Config::load_from(&path).unwrap();
1400        assert!(!reloaded.notifications.enabled);
1401        assert_eq!(reloaded.notifications.threshold, 90);
1402    }
1403
1404    #[test]
1405    fn save_leaves_an_untouched_notification_section_alone() {
1406        let (_dir, path) = temp_config(None);
1407        let s = blank_state(VendorId::Anthropic);
1408        save_to_path(&s, &path).unwrap();
1409        let raw = std::fs::read_to_string(&path).unwrap();
1410        assert!(!raw.contains("[notifications]"), "{raw}");
1411    }
1412
1413    #[test]
1414    fn save_rejects_an_out_of_range_threshold_without_writing() {
1415        let (_dir, path) = temp_config(None);
1416        let mut s = blank_state(VendorId::Anthropic);
1417        for c in "300".chars() {
1418            s.notify_threshold.insert_char(c);
1419        }
1420        let err = save_to_path(&s, &path).unwrap_err().to_string();
1421        assert!(
1422            err.contains("[notifications] threshold must be a whole number between 1 and 100"),
1423            "{err}"
1424        );
1425        // A failed save must not leave a partial file behind.
1426        assert!(!path.exists());
1427
1428        // An empty dirty buffer is the same refusal.
1429        let (_dir, path) = temp_config(None);
1430        let mut s = blank_state(VendorId::Anthropic);
1431        s.notify_threshold = KeyInput::default();
1432        s.notify_threshold.dirty = true;
1433        assert!(save_to_path(&s, &path).is_err());
1434        assert!(!path.exists());
1435    }
1436
1437    #[test]
1438    fn copilot_has_no_editable_credential_field() {
1439        assert!(
1440            !KEY_VENDORS
1441                .iter()
1442                .any(|vendor| vendor.id == VendorId::Copilot)
1443        );
1444    }
1445
1446    /// The exported-env-var path is real behaviour and deserves a test of its
1447    /// own — just not one that reads the machine it runs on.
1448    #[test]
1449    fn a_key_vendor_with_its_env_var_exported_is_offered() {
1450        let cfg = Config::default();
1451        let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
1452
1453        let without = SettingsState::from_config_with(&cfg, |_| false);
1454        assert!(!without.primary_choices.contains(&VendorId::Ollama));
1455
1456        let with = SettingsState::from_config_with(&cfg, |name| name == env);
1457        assert!(
1458            with.primary_choices.contains(&VendorId::Ollama),
1459            "a key vendor whose env var is exported must be selectable: {:?}",
1460            with.primary_choices
1461        );
1462    }
1463
1464    #[test]
1465    fn from_config_offers_enabled_vendors_only() {
1466        let cfg = Config::default();
1467        // No ambient environment: this must not depend on whether the machine
1468        // running `cargo test` happens to export OLLAMA_API_KEY or friends.
1469        let s = SettingsState::from_config_with(&cfg, |_| false);
1470        let mut expected = cfg.enabled_vendors();
1471        expected.push(VendorId::Copilot);
1472        assert_eq!(s.primary_choices, expected);
1473        // API-key opt-in vendors are disabled by default and must not be
1474        // offered. Copilot is the exception: choosing it enables it safely.
1475        assert!(!s.primary_choices.contains(&VendorId::Grok));
1476        assert!(s.primary_choices.contains(&s.primary));
1477        assert!(s.primary_choices.contains(&VendorId::Copilot));
1478    }
1479
1480    #[test]
1481    fn from_config_falls_back_when_configured_primary_is_disabled() {
1482        // Grok is opt-in; a config naming it as primary without enabling it
1483        // must display the first enabled vendor instead.
1484        let mut cfg = Config::default();
1485        cfg.ui.primary = Some(VendorId::Grok);
1486        let s = SettingsState::from_config(&cfg);
1487        assert_ne!(s.primary, VendorId::Grok);
1488        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1489    }
1490
1491    #[test]
1492    fn key_input_insert_backspace_arrow() {
1493        let mut k = KeyInput::default();
1494        k.insert_char('a');
1495        k.insert_char('b');
1496        k.insert_char('c');
1497        assert_eq!(k.buf, "abc");
1498        assert_eq!(k.cursor, 3);
1499        assert!(k.dirty);
1500        k.move_left();
1501        k.move_left();
1502        assert_eq!(k.cursor, 1);
1503        k.insert_char('x');
1504        assert_eq!(k.buf, "axbc");
1505        assert_eq!(k.cursor, 2);
1506        k.backspace();
1507        assert_eq!(k.buf, "abc");
1508        assert_eq!(k.cursor, 1);
1509    }
1510
1511    #[test]
1512    fn key_input_masks_by_default_reveals_on_toggle() {
1513        let mut k = KeyInput::default();
1514        for c in "secret-key".chars() {
1515            k.insert_char(c);
1516        }
1517        assert_eq!(k.display(), "•".repeat(10));
1518        k.toggle_reveal();
1519        assert_eq!(k.display(), "secret-key");
1520    }
1521
1522    #[test]
1523    fn key_input_handles_unicode() {
1524        let mut k = KeyInput::default();
1525        k.insert_char('a');
1526        k.insert_char('→');
1527        k.insert_char('b');
1528        assert_eq!(k.buf, "a→b");
1529        assert_eq!(k.cursor, 3);
1530        k.move_left();
1531        k.backspace();
1532        assert_eq!(k.buf, "ab");
1533    }
1534
1535    #[test]
1536    fn value_text_shows_cursor_and_empty_states() {
1537        let mut k = KeyInput::default();
1538        assert_eq!(value_text(&k, false), "(empty)");
1539        assert_eq!(value_text(&k, true), "‸");
1540        k.insert_char('a');
1541        k.insert_char('b');
1542        // masked + cursor at end
1543        assert_eq!(value_text(&k, true), "••‸");
1544        assert_eq!(value_text(&k, false), "••");
1545    }
1546
1547    #[test]
1548    fn save_writes_key_and_enables_vendor() {
1549        let (_dir, path) = temp_config(None);
1550        let mut s = blank_state(VendorId::Kilo);
1551        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1552        s.keys[key_index(VendorId::Kilo)].dirty = true;
1553        save_to_path(&s, &path).unwrap();
1554        let raw = std::fs::read_to_string(&path).unwrap();
1555        assert!(raw.contains("primary = \"kilo\""));
1556        assert!(raw.contains("[kilo]"));
1557        assert!(raw.contains("api_key = \"sk-kilo\""));
1558        assert!(raw.contains("enabled = true"));
1559    }
1560
1561    #[test]
1562    fn save_writes_minimal_toml_when_starting_empty() {
1563        let (_dir, path) = temp_config(None);
1564        let s = state_with("zk", "ok", VendorId::Zai);
1565        save_to_path(&s, &path).unwrap();
1566        let raw = std::fs::read_to_string(&path).unwrap();
1567        assert!(raw.contains("primary = \"zai\""));
1568        assert!(raw.contains("[zai]"));
1569        assert!(raw.contains("api_key = \"zk\""));
1570        assert!(raw.contains("[openrouter]"));
1571        assert!(raw.contains("api_key = \"ok\""));
1572    }
1573
1574    #[test]
1575    fn save_preserves_existing_comments_and_unrelated_fields() {
1576        let (_dir, path) = temp_config(Some(
1577            r##"# my comment
1578[ui]
1579# pre-existing comment
1580primary = "anthropic"
1581
1582[zai]
1583enabled = true
1584api_key_env = "ZAI_API_KEY"
1585# tier comment
1586plan_tier = "pro"
1587
1588[openrouter]
1589enabled = true
1590api_key_env = "OPENROUTER_API_KEY"
1591
1592[[openrouter.accounts]]
1593label = "work"
1594api_key_env = "OPENROUTER_WORK_API_KEY"
1595"##,
1596        ));
1597
1598        let s = state_with("zk2", "ok2", VendorId::Openrouter);
1599        save_to_path(&s, &path).unwrap();
1600
1601        let raw = std::fs::read_to_string(&path).unwrap();
1602        assert!(raw.contains("# my comment"));
1603        assert!(raw.contains("# pre-existing comment"));
1604        assert!(raw.contains("# tier comment"));
1605        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1606        assert!(raw.contains("[[openrouter.accounts]]"));
1607        assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1608        assert!(raw.contains("plan_tier = \"pro\""));
1609        assert!(raw.contains("primary = \"openrouter\""));
1610        assert!(raw.contains("api_key = \"zk2\""));
1611        assert!(raw.contains("api_key = \"ok2\""));
1612    }
1613
1614    #[test]
1615    fn save_refuses_to_replace_an_unreadable_existing_config() {
1616        let (_dir, path) = temp_config(None);
1617        let original = [0xff, 0xfe, 0xfd];
1618        std::fs::write(&path, original).unwrap();
1619        let state = state_with("new-secret", "", VendorId::Zai);
1620
1621        assert!(save_to_path(&state, &path).is_err());
1622        assert_eq!(std::fs::read(&path).unwrap(), original);
1623    }
1624
1625    #[test]
1626    fn save_does_not_write_empty_key_when_dirty_but_blank() {
1627        let (_dir, path) = temp_config(None);
1628        let mut s = blank_state(VendorId::Anthropic);
1629        // Focus each key, do nothing but mark dirty (blank).
1630        for k in &mut s.keys {
1631            k.dirty = true;
1632        }
1633        save_to_path(&s, &path).unwrap();
1634        let raw = std::fs::read_to_string(&path).unwrap();
1635        assert!(!raw.contains("api_key ="));
1636    }
1637
1638    #[test]
1639    #[cfg(unix)]
1640    fn save_chmods_to_600() {
1641        use std::os::unix::fs::PermissionsExt;
1642        let (_dir, path) = temp_config(None);
1643        let s = state_with("zk", "ok", VendorId::Zai);
1644        save_to_path(&s, &path).unwrap();
1645        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1646        assert_eq!(mode & 0o777, 0o600);
1647    }
1648
1649    #[test]
1650    fn tab_cycles_focus_from_primary_to_first_key() {
1651        let mut s = blank_state(VendorId::Anthropic);
1652        assert_eq!(
1653            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1654            Action::Continue
1655        );
1656        assert_eq!(s.focus, Focus::Key(0));
1657        assert_eq!(
1658            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1659            Action::Continue
1660        );
1661        assert_eq!(s.focus, Focus::Primary);
1662    }
1663
1664    #[test]
1665    fn esc_closes_without_saving() {
1666        let mut s = blank_state(VendorId::Anthropic);
1667        assert_eq!(
1668            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1669            Action::Close
1670        );
1671    }
1672
1673    #[test]
1674    fn left_right_cycles_primary_vendor() {
1675        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
1676        let mut s = blank_state(VendorId::Anthropic);
1677        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1678        assert_eq!(s.primary, VendorId::AnthropicApi);
1679        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1680        assert_eq!(s.primary, VendorId::Openai);
1681        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1682        assert_eq!(s.primary, VendorId::AnthropicApi);
1683    }
1684
1685    #[test]
1686    fn left_right_offers_enabled_vendors_only() {
1687        // The selector must never land on a vendor the widget cannot use.
1688        let mut s = blank_state(VendorId::Anthropic);
1689        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1690        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1691        assert_eq!(s.primary, VendorId::Grok);
1692        // Wraps within the enabled set rather than walking into disabled ones.
1693        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1694        assert_eq!(s.primary, VendorId::Anthropic);
1695        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1696        assert_eq!(s.primary, VendorId::Grok);
1697    }
1698
1699    #[test]
1700    fn no_enabled_vendors_leaves_primary_selector_inert() {
1701        let mut s = blank_state(VendorId::Anthropic);
1702        s.primary_choices = vec![];
1703        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1704        assert_eq!(s.primary, VendorId::Anthropic);
1705    }
1706
1707    #[test]
1708    fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
1709        let mut cfg = Config::default();
1710        cfg.ui.primary = Some(VendorId::Copilot);
1711        let state = SettingsState::from_config(&cfg);
1712
1713        assert!(state.primary_choices.contains(&VendorId::Copilot));
1714        assert_eq!(state.primary, VendorId::Anthropic);
1715    }
1716
1717    #[test]
1718    fn save_does_not_write_a_disabled_primary() {
1719        // Saving an API key must not persist a primary the resolver would
1720        // ignore; an existing value in the file stays untouched.
1721        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1722        let mut s = state_with("zk", "ok", VendorId::Grok);
1723        s.primary_choices = vec![VendorId::Anthropic];
1724        save_to_path(&s, &path).unwrap();
1725        let raw = std::fs::read_to_string(&path).unwrap();
1726        assert!(raw.contains("primary = \"anthropic\""));
1727        assert!(!raw.contains("primary = \"grok\""));
1728        // The keys still saved.
1729        assert!(raw.contains("zk"));
1730    }
1731
1732    #[test]
1733    fn save_removes_an_inline_key_the_user_cleared() {
1734        // Clearing the field in the overlay must delete the secret from the
1735        // file — otherwise there is no way to remove it short of hand-editing.
1736        let (_dir, path) = temp_config(Some(
1737            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1738        ));
1739        let mut s = blank_state(VendorId::Zai);
1740        s.primary_choices = vec![VendorId::Zai];
1741        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1742        s.keys[key_index(VendorId::Zai)].dirty = true;
1743        save_to_path(&s, &path).unwrap();
1744        let raw = std::fs::read_to_string(&path).unwrap();
1745        assert!(!raw.contains("old-secret"));
1746        assert!(!raw.contains("api_key"));
1747        // Unrelated fields in the same section survive.
1748        assert!(raw.contains("plan_tier = \"pro\""));
1749    }
1750
1751    #[test]
1752    fn untouched_key_field_is_left_alone() {
1753        // Not dirty => the file's existing secret must survive a save.
1754        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1755        let mut s = blank_state(VendorId::Zai);
1756        s.primary_choices = vec![VendorId::Zai];
1757        save_to_path(&s, &path).unwrap();
1758        let raw = std::fs::read_to_string(&path).unwrap();
1759        assert!(raw.contains("keep-me"));
1760    }
1761
1762    #[test]
1763    fn typing_edits_the_focused_key_only() {
1764        let mut s = blank_state(VendorId::Anthropic);
1765        s.focus = Focus::Key(key_index(VendorId::Grok));
1766        for c in "xai-abc".chars() {
1767            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1768        }
1769        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1770        assert!(s.keys[key_index(VendorId::Grok)].dirty);
1771        // No other field was touched.
1772        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1773    }
1774
1775    #[test]
1776    fn ctrl_v_toggles_reveal_on_focused_key_field() {
1777        let mut s = blank_state(VendorId::Anthropic);
1778        let zi = key_index(VendorId::Zai);
1779        s.focus = Focus::Key(zi);
1780        s.keys[zi] = KeyInput::from_config(Some("secret"));
1781        assert!(!s.keys[zi].revealed);
1782        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1783        assert!(s.keys[zi].revealed);
1784        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1785        assert!(!s.keys[zi].revealed);
1786    }
1787
1788    #[test]
1789    fn control_chorded_chars_do_not_type_into_fields() {
1790        let mut s = blank_state(VendorId::Anthropic);
1791        s.focus = Focus::Key(0);
1792        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
1793        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1794        assert!(s.keys[0].buf.is_empty());
1795        assert!(!s.keys[0].dirty);
1796        // Ctrl-C quits the host TUI even while the overlay owns focus.
1797        assert_eq!(
1798            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1799            Action::Quit
1800        );
1801        // A plain char still types normally.
1802        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1803        assert_eq!(s.keys[0].buf, "x");
1804    }
1805
1806    #[test]
1807    fn ctrl_v_on_non_key_focus_is_noop() {
1808        let mut s = blank_state(VendorId::Anthropic);
1809        s.focus = Focus::Primary;
1810        // Must not panic when no key field is focused.
1811        assert_eq!(
1812            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1813            Action::Continue
1814        );
1815    }
1816
1817    fn state_focused_on_zai() -> SettingsState {
1818        let mut state = blank_state(VendorId::Anthropic);
1819        state.focus = Focus::Key(key_index(VendorId::Zai));
1820        state
1821    }
1822
1823    #[test]
1824    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1825        let mut s = state_focused_on_zai();
1826        let zi = key_index(VendorId::Zai);
1827        assert_eq!(
1828            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1829            Action::Quit
1830        );
1831        assert!(s.keys[zi].buf.is_empty());
1832        // Untouched means save still leaves an existing key on disk alone.
1833        assert!(!s.keys[zi].dirty);
1834    }
1835
1836    #[test]
1837    fn handle_key_alt_chord_does_not_type_into_key_field() {
1838        let mut s = state_focused_on_zai();
1839        let zi = key_index(VendorId::Zai);
1840        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1841        assert!(s.keys[zi].buf.is_empty());
1842        assert!(!s.keys[zi].dirty);
1843    }
1844
1845    #[test]
1846    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1847        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1848            let mut s = state_focused_on_zai();
1849            let zi = key_index(VendorId::Zai);
1850            handle_key(&mut s, KeyCode::Char('x'), modifier);
1851            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1852            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1853        }
1854    }
1855
1856    #[test]
1857    fn handle_key_shift_still_types_uppercase() {
1858        let mut s = state_focused_on_zai();
1859        let zi = key_index(VendorId::Zai);
1860        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1861        assert_eq!(s.keys[zi].buf, "A");
1862        assert!(s.keys[zi].dirty);
1863    }
1864
1865    #[test]
1866    fn handle_key_plain_space_still_cycles_primary_vendor() {
1867        let mut s = blank_state(VendorId::Anthropic);
1868        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1869        assert_eq!(s.primary, VendorId::AnthropicApi);
1870    }
1871
1872    #[test]
1873    fn handle_key_ctrl_s_attempts_save_from_any_field() {
1874        let (_dir, path) = temp_config(None);
1875        let s = state_with("zk", "ok", VendorId::Zai);
1876        save_to_path(&s, &path).unwrap();
1877        let raw = std::fs::read_to_string(&path).unwrap();
1878        assert!(raw.contains("api_key = \"zk\""));
1879    }
1880    #[test]
1881    fn save_to_path_writes_kimi_key_when_dirty() {
1882        let (_dir, path) = temp_config(None);
1883        let mut s = blank_state(VendorId::Anthropic);
1884        let kimi = key_index(VendorId::Kimi);
1885        s.keys[kimi] = KeyInput::from_config(Some("kk"));
1886        s.keys[kimi].dirty = true;
1887        save_to_path(&s, &path).unwrap();
1888        let raw = std::fs::read_to_string(&path).unwrap();
1889        assert!(raw.contains("[kimi]"));
1890        assert!(raw.contains("api_key = \"kk\""));
1891    }
1892
1893    #[test]
1894    fn settings_save_uses_the_same_config_path_as_load() {
1895        assert_eq!(
1896            default_config_path().unwrap(),
1897            crate::config::resolved_path().unwrap()
1898        );
1899    }
1900
1901    #[test]
1902    fn native_snapshot_reports_key_state_without_serializing_secrets() {
1903        let mut cfg = Config::default();
1904        cfg.zai.api_key = Some("never-leak-this-key".into());
1905        cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
1906        let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
1907        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1908
1909        assert_eq!(parsed["schema_version"], 1);
1910        assert_eq!(parsed["primary"], "anthropic");
1911        let zai = parsed["keys"]
1912            .as_array()
1913            .unwrap()
1914            .iter()
1915            .find(|row| row["id"] == "zai")
1916            .unwrap();
1917        assert_eq!(zai["configured"], true);
1918        assert_eq!(zai["inline_configured"], true);
1919        assert_eq!(zai["environment_configured"], true);
1920        assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
1921        assert!(!raw.contains("never-leak-this-key"));
1922        assert!(parsed.get("api_key").is_none());
1923    }
1924
1925    #[test]
1926    fn native_snapshot_offers_copilot_primary_without_a_token_field() {
1927        let cfg = Config::default();
1928        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
1929        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1930        assert!(
1931            parsed["primary_choices"]
1932                .as_array()
1933                .unwrap()
1934                .iter()
1935                .any(|row| row["id"] == "copilot")
1936        );
1937        assert!(
1938            !parsed["keys"]
1939                .as_array()
1940                .unwrap()
1941                .iter()
1942                .any(|row| row["id"] == "copilot")
1943        );
1944    }
1945
1946    #[test]
1947    fn native_key_only_patch_does_not_require_or_replace_primary() {
1948        let cfg = Config::default();
1949        let original_primary = SettingsState::from_config(&cfg).primary;
1950        let request = serde_json::json!({
1951            "schema_version": 1,
1952            "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
1953        });
1954
1955        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
1956        assert_eq!(state.primary, original_primary);
1957        let kimi_index = KEY_VENDORS
1958            .iter()
1959            .position(|vendor| vendor.id == VendorId::Kimi)
1960            .unwrap();
1961        assert!(state.keys[kimi_index].dirty);
1962        assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
1963    }
1964
1965    #[test]
1966    fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
1967        let (_dir, path) = temp_config(Some(
1968            r#"# keep this comment
1969[ui]
1970primary = "anthropic"
1971
1972[zai]
1973enabled = true
1974api_key_env = "ZAI_API_KEY"
1975plan_tier = "pro"
1976
1977[openrouter]
1978enabled = true
1979"#,
1980        ));
1981        let cfg = Config::load_from(&path).unwrap();
1982        let request = serde_json::json!({
1983            "schema_version": 1,
1984            "primary": "openrouter",
1985            "keys": {
1986                "zai": {"action": "set", "value": "new-zai-key"}
1987            }
1988        });
1989
1990        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1991        let raw = std::fs::read_to_string(&path).unwrap();
1992        assert!(raw.contains("# keep this comment"));
1993        assert!(raw.contains("plan_tier = \"pro\""));
1994        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1995        assert!(raw.contains("primary = \"openrouter\""));
1996        assert!(raw.contains("api_key = \"new-zai-key\""));
1997    }
1998
1999    #[test]
2000    fn native_patch_distinguishes_clear_from_unchanged() {
2001        let (_dir, path) = temp_config(Some(
2002            "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2003             [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2004        ));
2005        let cfg = Config::load_from(&path).unwrap();
2006        let request = serde_json::json!({
2007            "schema_version": 1,
2008            "primary": "zai",
2009            "keys": {"zai": {"action": "clear"}}
2010        });
2011
2012        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2013        let raw = std::fs::read_to_string(&path).unwrap();
2014        assert!(!raw.contains("remove-me"));
2015        assert!(raw.contains("keep-me"));
2016    }
2017
2018    #[test]
2019    fn native_primary_selection_enables_copilot_without_writing_a_token() {
2020        let (_dir, path) = temp_config(Some(
2021            "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2022        ));
2023        let cfg = Config::load_from(&path).unwrap();
2024        let select = serde_json::json!({
2025            "schema_version": 1,
2026            "primary": "copilot"
2027        });
2028        apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2029        let raw = std::fs::read_to_string(&path).unwrap();
2030        assert!(raw.contains("enabled = true"));
2031        assert!(raw.contains("primary = \"copilot\""));
2032        assert!(!raw.contains("token ="));
2033        assert!(!raw.contains("token_env ="));
2034    }
2035
2036    #[test]
2037    fn native_patch_errors_never_echo_key_values() {
2038        let raw = serde_json::json!({
2039            "schema_version": 1,
2040            "primary": "anthropic",
2041            "keys": {
2042                "zai": {"action": "set", "value": "secret\nwith-control"}
2043            }
2044        })
2045        .to_string();
2046        let error = state_from_apply_request(&Config::default(), &raw)
2047            .unwrap_err()
2048            .to_string();
2049        assert!(!error.contains("secret"));
2050        assert!(error.contains("control characters"));
2051    }
2052
2053    #[test]
2054    fn native_patch_input_is_bounded_before_json_parsing() {
2055        let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2056        let error = read_settings_request(std::io::Cursor::new(oversized))
2057            .unwrap_err()
2058            .to_string();
2059        assert!(error.contains("exceeds"));
2060    }
2061}