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 sits the Providers section (#244): one on/off switch
13//! per known vendor, writing `enabled = true/false` under its own config
14//! section. Below that are the `[notifications]` fields: a toggle for the
15//! quota-threshold alerts and their threshold in percent. Everything persists
16//! through `toml_edit` so the existing config keeps its comments,
17//! whitespace, and unrelated fields. Writing a key also flips that vendor's
18//! `enabled = true` (the opt-in vendors are disabled by default), so "paste the
19//! credential and save" is all it takes — an explicit off switch toggled in
20//! the same save wins. Files with inline credentials are atomically written
21//! and `chmod 600`ed.
22
23use std::collections::BTreeMap;
24use std::io::BufRead;
25use std::path::{Path, PathBuf};
26
27use ratatui::Frame;
28use ratatui::layout::{Constraint, Direction, Layout, Rect};
29use ratatui::style::Modifier;
30use ratatui::text::{Line, Span};
31use ratatui::widgets::{Clear, Paragraph};
32use ratatui_bubbletea_theme::BubbleTheme;
33use serde::{Deserialize, Serialize};
34use toml_edit::{DocumentMut, value};
35
36use crate::config::{
37    Config, edit_config_document, is_valid_env_var_name, read_config_document, set_bool, set_value,
38};
39use crate::error::{AppError, Result};
40use crate::theme::Theme;
41use crate::tui::style::bubble_theme;
42use crate::vendor::VendorId;
43
44/// A vendor that authenticates with an inline credential. The order of this
45/// table is the tab order of the credential fields and the layout of the
46/// state's `keys` vec.
47pub struct KeyVendor {
48    pub id: VendorId,
49    pub label: &'static str,
50    pub section: &'static str,
51    /// Config field that stores the credential (`api_key` for most vendors).
52    pub config_key: &'static str,
53    /// Human-readable name shown in the native settings form.
54    pub secret_label: &'static str,
55    /// Extra hint after the env var (e.g. "management key"). Empty for none.
56    pub note: &'static str,
57}
58
59pub const KEY_VENDORS: &[KeyVendor] = &[
60    KeyVendor {
61        id: VendorId::AnthropicApi,
62        label: "Anthropic API",
63        section: VendorId::AnthropicApi.config_section(),
64        config_key: "api_key",
65        secret_label: "API key",
66        note: "admin key — monthly spend",
67    },
68    KeyVendor {
69        id: VendorId::Zai,
70        label: "Z.AI",
71        section: VendorId::Zai.config_section(),
72        config_key: "api_key",
73        secret_label: "API key",
74        note: "",
75    },
76    KeyVendor {
77        id: VendorId::Openrouter,
78        label: "OpenRouter",
79        section: VendorId::Openrouter.config_section(),
80        config_key: "api_key",
81        secret_label: "API key",
82        note: "",
83    },
84    KeyVendor {
85        id: VendorId::Deepseek,
86        label: "DeepSeek",
87        section: VendorId::Deepseek.config_section(),
88        config_key: "api_key",
89        secret_label: "API key",
90        note: "",
91    },
92    KeyVendor {
93        id: VendorId::Deepinfra,
94        label: "DeepInfra",
95        section: VendorId::Deepinfra.config_section(),
96        config_key: "api_key",
97        secret_label: "API key",
98        note: "billing balance and monthly spend",
99    },
100    KeyVendor {
101        id: VendorId::Kimi,
102        label: "Kimi",
103        section: VendorId::Kimi.config_section(),
104        config_key: "api_key",
105        secret_label: "API key",
106        note: "coding-plan usage",
107    },
108    KeyVendor {
109        id: VendorId::Kilo,
110        label: "Kilo",
111        section: VendorId::Kilo.config_section(),
112        config_key: "api_key",
113        secret_label: "API key",
114        note: "",
115    },
116    KeyVendor {
117        id: VendorId::Novita,
118        label: "Novita",
119        section: VendorId::Novita.config_section(),
120        config_key: "api_key",
121        secret_label: "API key",
122        note: "",
123    },
124    KeyVendor {
125        id: VendorId::Synthetic,
126        label: "Synthetic",
127        section: VendorId::Synthetic.config_section(),
128        config_key: "api_key",
129        secret_label: "API key",
130        note: "weekly credit quota",
131    },
132    KeyVendor {
133        id: VendorId::DeepRelay,
134        label: "DeepRelay",
135        section: VendorId::DeepRelay.config_section(),
136        config_key: "api_key",
137        secret_label: "API key",
138        note: "key needs the billing:read scope",
139    },
140    KeyVendor {
141        id: VendorId::Mistral,
142        label: "Mistral",
143        section: VendorId::Mistral.config_section(),
144        config_key: "api_key",
145        secret_label: "API key",
146        note: "key needs the see_org_inference_usage permission",
147    },
148    KeyVendor {
149        id: VendorId::Moonshot,
150        label: "Moonshot",
151        section: VendorId::Moonshot.config_section(),
152        config_key: "api_key",
153        secret_label: "API key",
154        note: "account balance",
155    },
156    KeyVendor {
157        id: VendorId::Grok,
158        label: "Grok",
159        section: VendorId::Grok.config_section(),
160        config_key: "api_key",
161        secret_label: "API key",
162        note: "management key, not the inference key",
163    },
164    KeyVendor {
165        id: VendorId::Minimax,
166        label: "MiniMax",
167        section: VendorId::Minimax.config_section(),
168        config_key: "api_key",
169        secret_label: "API key",
170        note: "Token Plan subscription key",
171    },
172    KeyVendor {
173        id: VendorId::OpenCodeGo,
174        label: "OpenCode Go",
175        section: VendorId::OpenCodeGo.config_section(),
176        config_key: "api_key",
177        secret_label: "API key",
178        note: "usage quota",
179    },
180    KeyVendor {
181        id: VendorId::Ollama,
182        label: "Ollama Cloud",
183        section: VendorId::Ollama.config_section(),
184        config_key: "api_key",
185        secret_label: "API key",
186        note: "ollama.com/settings/keys",
187    },
188    KeyVendor {
189        id: VendorId::OrcaRouter,
190        label: "OrcaRouter",
191        section: VendorId::OrcaRouter.config_section(),
192        config_key: "api_key",
193        secret_label: "API key",
194        note: "credit balance",
195    },
196    KeyVendor {
197        id: VendorId::Lyceum,
198        label: "Lyceum",
199        section: VendorId::Lyceum.config_section(),
200        config_key: "api_key",
201        secret_label: "API key",
202        note: "billing credits",
203    },
204];
205
206/// How many providers the on/off section lists: every known vendor, in
207/// `VendorId::all()` order (#244). A new vendor grows this automatically; a
208/// guard test pins the focus ring to the real enumeration.
209pub const PROVIDER_SWITCH_COUNT: usize = VendorId::all().len();
210
211/// Which control has keyboard focus. `Key(i)` indexes into [`KEY_VENDORS`];
212/// `Vendor(i)` indexes the provider on/off rows, parallel to
213/// `SettingsState::vendors` / `VendorId::all()`.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum Focus {
216    Primary,
217    Key(usize),
218    Vendor(usize),
219    NotifyEnabled,
220    NotifyThreshold,
221    Save,
222}
223
224/// An interactive row recorded for mouse hit-testing during the settings draw.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum SettingsRow {
227    /// A focusable control (primary picker, a key field, or the save row).
228    Focus(Focus),
229    /// A value cell that focuses its row and sends the synthetic key through
230    /// [`handle_key`]: space toggles provider / quota-alerts switches,
231    /// `←`/`→` step the primary-vendor radio over its ◀ ▶ arrows.
232    Switch(Focus, KeyCode, KeyModifiers),
233    /// The primary vendor's name cell: clicking opens (or the popup is open
234    /// and a click outside closes) the picker popup.
235    OpenPicker,
236    /// A primary-vendor choice row in the open picker popup; clicking selects
237    /// that vendor.
238    Pick(usize),
239    /// A hint-footer "link": clicking sends the synthetic key through
240    /// [`handle_key`], so save/close/toggle behave exactly as if pressed.
241    HintKey(KeyCode, KeyModifiers),
242}
243
244impl Focus {
245    pub fn next(self) -> Self {
246        match self {
247            Focus::Primary => Focus::Key(0),
248            Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
249            Focus::Key(_) => Focus::Vendor(0),
250            Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
251            Focus::Vendor(_) => Focus::NotifyEnabled,
252            Focus::NotifyEnabled => Focus::NotifyThreshold,
253            Focus::NotifyThreshold => Focus::Save,
254            Focus::Save => Focus::Primary,
255        }
256    }
257    pub fn prev(self) -> Self {
258        match self {
259            Focus::Primary => Focus::Save,
260            Focus::Key(0) => Focus::Primary,
261            Focus::Key(i) => Focus::Key(i - 1),
262            Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
263            Focus::Vendor(i) => Focus::Vendor(i - 1),
264            Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
265            Focus::NotifyThreshold => Focus::NotifyEnabled,
266            Focus::Save => Focus::NotifyThreshold,
267        }
268    }
269}
270
271/// Open state of the primary-vendor picker popup: `cursor` indexes
272/// [`SettingsState::primary_choices`], `scroll` is the list's scroll offset.
273#[derive(Debug, Clone, Copy, Default)]
274pub struct PrimaryPicker {
275    pub cursor: usize,
276    pub scroll: u16,
277}
278
279/// Per-field text-input state — cursor + buffer + reveal flag.
280#[derive(Debug, Clone, Default)]
281pub struct KeyInput {
282    pub buf: String,
283    /// Char-index cursor position (0..=buf.chars().count()).
284    pub cursor: usize,
285    /// When true, the field renders the actual characters; otherwise `•`.
286    pub revealed: bool,
287    /// True after the user has typed/edited; only then does save write the
288    /// value back (avoids clobbering an existing key with the empty
289    /// placeholder the user opened the dialog with).
290    pub dirty: bool,
291}
292
293impl KeyInput {
294    pub fn from_config(initial: Option<&str>) -> Self {
295        let buf = initial.unwrap_or("").to_string();
296        let cursor = buf.chars().count();
297        Self {
298            buf,
299            cursor,
300            revealed: false,
301            dirty: false,
302        }
303    }
304
305    pub fn insert_char(&mut self, c: char) {
306        let byte_idx = self.char_to_byte(self.cursor);
307        self.buf.insert(byte_idx, c);
308        self.cursor += 1;
309        self.dirty = true;
310    }
311
312    pub fn backspace(&mut self) {
313        if self.cursor == 0 {
314            return;
315        }
316        let prev_byte = self.char_to_byte(self.cursor - 1);
317        let cur_byte = self.char_to_byte(self.cursor);
318        self.buf.replace_range(prev_byte..cur_byte, "");
319        self.cursor -= 1;
320        self.dirty = true;
321    }
322
323    pub fn delete(&mut self) {
324        let n = self.buf.chars().count();
325        if self.cursor >= n {
326            return;
327        }
328        let cur_byte = self.char_to_byte(self.cursor);
329        let next_byte = self.char_to_byte(self.cursor + 1);
330        self.buf.replace_range(cur_byte..next_byte, "");
331        self.dirty = true;
332    }
333
334    pub fn move_left(&mut self) {
335        if self.cursor > 0 {
336            self.cursor -= 1;
337        }
338    }
339    pub fn move_right(&mut self) {
340        if self.cursor < self.buf.chars().count() {
341            self.cursor += 1;
342        }
343    }
344    pub fn move_home(&mut self) {
345        self.cursor = 0;
346    }
347    pub fn move_end(&mut self) {
348        self.cursor = self.buf.chars().count();
349    }
350    pub fn toggle_reveal(&mut self) {
351        self.revealed = !self.revealed;
352    }
353
354    /// Render for display — bullets when masked, raw chars when revealed.
355    pub fn display(&self) -> String {
356        if self.revealed {
357            self.buf.clone()
358        } else {
359            "•".repeat(self.buf.chars().count())
360        }
361    }
362
363    fn char_to_byte(&self, char_idx: usize) -> usize {
364        self.buf
365            .char_indices()
366            .map(|(b, _)| b)
367            .chain(std::iter::once(self.buf.len()))
368            .nth(char_idx)
369            .unwrap_or(self.buf.len())
370    }
371}
372
373/// One provider's on/off switch (#244), parallel to `VendorId::all()`.
374/// `dirty` follows the same only-write-what-the-user-touched rule the
375/// notification fields use.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub struct ProviderSwitch {
378    pub enabled: bool,
379    pub dirty: bool,
380}
381
382/// Mutable state of the overlay while open.
383#[derive(Debug, Clone)]
384pub struct SettingsState {
385    pub focus: Focus,
386    /// Enabled vendors only. The primary selector must not offer a value that
387    /// cannot actually be used by the widget or TUI.
388    pub primary_choices: Vec<VendorId>,
389    pub primary: VendorId,
390    /// One input per [`KEY_VENDORS`] entry, same order.
391    pub keys: Vec<KeyInput>,
392    /// One on/off switch per known vendor ([`VendorId::all()` order), so any
393    /// provider can be turned on or off from the overlay (#244).
394    pub vendors: Vec<ProviderSwitch>,
395    /// `[notifications] enabled` toggle. Written back only once toggled.
396    pub notify_enabled: bool,
397    pub notify_enabled_dirty: bool,
398    /// `[notifications] threshold`, edited as digits only (1..=100 at save).
399    pub notify_threshold: KeyInput,
400    /// One-line status displayed in the footer ("saved …", "save failed …").
401    pub status: String,
402    /// First body line scrolled into view. The overlay body outgrew every
403    /// terminal once every provider got a row, so rendering follows focus.
404    pub scroll: u16,
405    /// Open picker popup for the primary-vendor radio; `None` while closed.
406    pub picker: Option<PrimaryPicker>,
407}
408
409impl SettingsState {
410    pub fn from_config(cfg: &Config) -> Self {
411        Self::from_config_with(cfg, |name| {
412            std::env::var_os(name).is_some_and(|v| !v.is_empty())
413        })
414    }
415
416    /// [`Self::from_config`] with an injected environment lookup.
417    ///
418    /// The overlay offers a key-only vendor whose env var is already exported,
419    /// so a user need not hand-edit `config.toml` to select it. That is a read
420    /// of ambient state, which a test must never depend on: the AUR `check()`
421    /// runs `cargo test` during `makepkg`, so a test that branched on the real
422    /// environment would fail the install for anyone who exports, say,
423    /// `OLLAMA_API_KEY`. Tests pass their own lookup here.
424    pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
425        let keys = KEY_VENDORS
426            .iter()
427            .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
428            .collect();
429        let mut primary_choices = cfg.enabled_vendors();
430        // Copilot credentials belong to GitHub CLI, so a login cannot write a
431        // local key that would also opt it in. Offer it explicitly instead:
432        // selecting it persists both the primary and `enabled = true`.
433        if !primary_choices.contains(&VendorId::Copilot) {
434            primary_choices.push(VendorId::Copilot);
435        }
436        // Key-only vendors are opt-in: without an inline `api_key` and with
437        // the env var empty, the fetch would fail on the first cycle. A user
438        // who already exported the env var (e.g. `OLLAMA_API_KEY`) and wants
439        // to flip `enabled = true` from inside the TUI has to be able to
440        // select the vendor here — otherwise they'd have to hand-edit
441        // `config.toml`, which is the workflow this overlay exists to avoid.
442        // We treat a non-empty env var as a sufficient signal of reachability.
443        for kv in KEY_VENDORS {
444            if primary_choices.contains(&kv.id) {
445                continue;
446            }
447            let env = cfg.api_key_env_for(kv.id);
448            let exported = is_valid_env_var_name(env) && env_set(env);
449            if cfg.inline_api_key(kv.id).is_some() || exported {
450                primary_choices.push(kv.id);
451            }
452        }
453        // A configured but disabled primary is ineffective. Display the first
454        // enabled vendor instead; when none are enabled retain the historical
455        // Anthropic fallback in memory without inventing a persisted primary.
456        // Copilot and the key vendors above are offered while disabled, but
457        // only as an explicit pick: shown as the current primary, an untouched
458        // save would write their `enabled = true` back.
459        let primary = cfg
460            .ui
461            .primary
462            .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
463            .or_else(|| primary_choices.first().copied())
464            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
465        Self {
466            focus: Focus::Primary,
467            primary_choices,
468            primary,
469            keys,
470            vendors: VendorId::all()
471                .iter()
472                .map(|id| ProviderSwitch {
473                    enabled: cfg.is_enabled(*id),
474                    dirty: false,
475                })
476                .collect(),
477            notify_enabled: cfg.notifications.enabled,
478            notify_enabled_dirty: false,
479            notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
480            status: String::new(),
481            scroll: 0,
482            picker: None,
483        }
484    }
485
486    /// The focused key input, if a key row is focused.
487    fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
488        match self.focus {
489            Focus::Key(i) => self.keys.get_mut(i),
490            _ => None,
491        }
492    }
493
494    /// Move focus through the ring. [`Focus::next`]/[`Focus::prev`] own the
495    /// order; these wrappers keep the call sites reading as state methods.
496    fn next_focus(&self) -> Focus {
497        self.focus.next()
498    }
499
500    fn prev_focus(&self) -> Focus {
501        self.focus.prev()
502    }
503}
504
505/// What the key handler asks the host app to do next.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum Action {
508    /// Stay open, keep listening for keys.
509    Continue,
510    /// Close the overlay (discard or save already happened).
511    Close,
512    /// Save just succeeded — caller should refresh affected vendors.
513    SavedAndClose,
514    /// Quit the host TUI. Ctrl-C remains global even while the overlay owns
515    /// keyboard focus.
516    Quit,
517}
518
519/// Permission note appended to the "saved" status line. The overlay `chmod
520/// 600`s the file on Unix; Windows has no such step, so the note is empty there.
521#[cfg(unix)]
522const PERMS_NOTE: &str = " (chmod 600)";
523#[cfg(not(unix))]
524const PERMS_NOTE: &str = "";
525
526fn saved_status() -> String {
527    format!(
528        "saved to {}{}",
529        crate::config::config_path_hint(),
530        PERMS_NOTE
531    )
532}
533
534/// Key map. Returns the action to perform after the keypress.
535pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
536    // The vendor picker consumes every key while open (Ctrl-C stays global),
537    // so Esc closes the popup before the modal's Esc arm can.
538    if state.picker.is_some()
539        && !(matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL))
540    {
541        return handle_picker_key(state, code);
542    }
543    if matches!(code, KeyCode::Esc) {
544        return Action::Close;
545    }
546    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
547        return Action::Quit;
548    }
549    // Ctrl-S triggers save from any field.
550    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
551        return try_save(state);
552    }
553    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
554        if let Some(input) = state.focused_key_mut() {
555            input.toggle_reveal();
556        }
557        return Action::Continue;
558    }
559    match code {
560        KeyCode::Tab | KeyCode::Down => {
561            state.focus = state.next_focus();
562            return Action::Continue;
563        }
564        KeyCode::BackTab | KeyCode::Up => {
565            state.focus = state.prev_focus();
566            return Action::Continue;
567        }
568        _ => {}
569    }
570
571    // A modifier chord is not text. The overlay swallows every key while open,
572    // so every unhandled chord must be ignored rather than corrupting the
573    // secret silently. SHIFT is deliberately not rejected — it is how
574    // uppercase arrives. Ctrl-C was handled above because it is a global quit.
575    if matches!(code, KeyCode::Char(_))
576        && mods.intersects(
577            KeyModifiers::CONTROL
578                | KeyModifiers::ALT
579                | KeyModifiers::SUPER
580                | KeyModifiers::HYPER
581                | KeyModifiers::META,
582        )
583    {
584        return Action::Continue;
585    }
586
587    // Field-specific handling.
588    match state.focus {
589        Focus::Primary => handle_primary(state, code),
590        Focus::Key(i) => {
591            if let Some(input) = state.keys.get_mut(i) {
592                handle_input(input, code);
593            }
594        }
595        Focus::Vendor(i) => handle_provider_switch(state, i, code),
596        Focus::NotifyEnabled => handle_notify_enabled(state, code),
597        Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
598        Focus::Save => {
599            if matches!(code, KeyCode::Enter) {
600                return try_save(state);
601            }
602        }
603    }
604    Action::Continue
605}
606
607fn try_save(state: &mut SettingsState) -> Action {
608    match save_to_config_default(state) {
609        Ok(()) => {
610            state.status = saved_status();
611            Action::SavedAndClose
612        }
613        Err(e) => {
614            state.status = format!("save failed: {e}");
615            Action::Continue
616        }
617    }
618}
619
620/// Key handling while the primary-vendor picker is open: ↑/↓ move the cursor
621/// (wrapping), Enter/space select and close, Esc closes without selecting.
622fn handle_picker_key(state: &mut SettingsState, code: KeyCode) -> Action {
623    let count = state.primary_choices.len();
624    if count == 0 {
625        state.picker = None;
626        return Action::Continue;
627    }
628    let Some(picker) = state.picker.as_mut() else {
629        return Action::Continue;
630    };
631    match code {
632        KeyCode::Up => picker.cursor = (picker.cursor + count - 1) % count,
633        KeyCode::Down => picker.cursor = (picker.cursor + 1) % count,
634        KeyCode::Enter | KeyCode::Char(' ') => {
635            state.primary = state.primary_choices[picker.cursor];
636            state.picker = None;
637        }
638        KeyCode::Esc => state.picker = None,
639        _ => {}
640    }
641    Action::Continue
642}
643
644fn handle_primary(state: &mut SettingsState, code: KeyCode) {
645    // Left/Right cycles the primary-vendor radio over enabled vendors only.
646    let choices = &state.primary_choices;
647    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
648        return;
649    };
650    let step = match code {
651        KeyCode::Left => -1,
652        KeyCode::Right | KeyCode::Char(' ') => 1,
653        _ => return,
654    };
655    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
656}
657
658fn handle_input(input: &mut KeyInput, code: KeyCode) {
659    match code {
660        KeyCode::Char(c) => input.insert_char(c),
661        KeyCode::Backspace => input.backspace(),
662        KeyCode::Delete => input.delete(),
663        KeyCode::Left => input.move_left(),
664        KeyCode::Right => input.move_right(),
665        KeyCode::Home => input.move_home(),
666        KeyCode::End => input.move_end(),
667        _ => {}
668    }
669}
670
671/// The `[notifications] enabled` toggle. Left/Right/Space flip it; any other
672/// key leaves it alone.
673fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
674    if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
675        state.notify_enabled = !state.notify_enabled;
676        state.notify_enabled_dirty = true;
677    }
678}
679
680/// One `[<vendor>] enabled` row (#244): Left/Right/Space flip the switch;
681/// any other key leaves it — and its dirty flag — alone, so an untouched
682/// overlay save never writes a section the config never had.
683fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
684    if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
685        && let Some(switch) = state.vendors.get_mut(index)
686    {
687        switch.enabled = !switch.enabled;
688        switch.dirty = true;
689    }
690}
691
692/// The `[notifications] threshold` field: an edit buffer that only accepts
693/// digits, so the value can never be something save would have to reject as
694/// non-numeric (range is still checked at save).
695fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
696    match code {
697        KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
698        KeyCode::Backspace => input.backspace(),
699        KeyCode::Delete => input.delete(),
700        KeyCode::Left => input.move_left(),
701        KeyCode::Right => input.move_right(),
702        KeyCode::Home => input.move_home(),
703        KeyCode::End => input.move_end(),
704        _ => {}
705    }
706}
707
708/// Save to the platform config path (creating it). On success, signal a running
709/// Waybar (`SIGRTMIN+13`) so a `signal: 13` module refreshes immediately.
710fn save_to_config_default(state: &SettingsState) -> Result<()> {
711    let path = default_config_path()?;
712    if let Some(parent) = path.parent() {
713        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
714    }
715    save_to_path(state, &path)?;
716    crate::waybar::request_refresh();
717    Ok(())
718}
719
720/// Same as `save_to_config_default` but with an explicit path — exposed for
721/// tests. Writing a non-empty credential also sets that vendor's `enabled = true`.
722pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
723    // One locked read-modify-write: a key check's candidate write and this
724    // save queue on the same lock instead of overwriting each other.
725    edit_config_document(path, |doc| {
726        // Remove fields written by the short-lived inline Copilot-token design.
727        // GitHub CLI owns the OAuth credential now; retaining a secret this app
728        // neither reads nor supports would be misleading and unsafe.
729        if let Some(table) = doc
730            .get_mut("copilot")
731            .and_then(toml_edit::Item::as_table_like_mut)
732        {
733            table.remove("token");
734            table.remove("token_env");
735        }
736
737        // Only Copilot is deliberately offered before it is enabled: choosing it
738        // is the explicit opt-in after the GitHub CLI login. The env-only key
739        // vendors (any `KEY_VENDORS` entry whose credential the user reached via
740        // `OLLAMA_API_KEY` or another env var) are also offered before they are
741        // enabled, and selecting them is the explicit opt-in for that vendor —
742        // otherwise a user could pick a vendor in the overlay and the fetch
743        // would still fail because `enabled = false`. Every other choice remains
744        // enabled-only, so no failed provider is persisted as primary.
745        if state.primary_choices.contains(&state.primary) {
746            set_string(doc, "ui", "primary", state.primary.slug())?;
747            if state.primary == VendorId::Copilot
748                || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
749            {
750                set_bool(doc, state.primary.config_section(), "enabled", true)?;
751            }
752        }
753
754        for (i, kv) in KEY_VENDORS.iter().enumerate() {
755            let Some(input) = state.keys.get(i) else {
756                continue;
757            };
758            update_key(doc, kv, input)?;
759        }
760
761        // Provider on/off switches (#244): written after the credential fields so
762        // an explicit off wins over the enable-a-pasted-key-implied rule above,
763        // and each one goes through the same whitelisted setter the native
764        // settings bridge uses. Only toggled rows are written, so an untouched
765        // overlay save never creates a `[<vendor>]` section the config never had.
766        for (i, id) in VendorId::all().iter().enumerate() {
767            let Some(switch) = state.vendors.get(i) else {
768                continue;
769            };
770            if switch.dirty {
771                crate::config::set_vendor_enabled_in_doc(doc, id.slug(), switch.enabled)?;
772            }
773        }
774
775        // [notifications]: each field is written only when the user touched it,
776        // so an untouched overlay save leaves an absent/commented section alone.
777        if state.notify_enabled_dirty {
778            set_bool(doc, "notifications", "enabled", state.notify_enabled)?;
779        }
780        if state.notify_threshold.dirty {
781            let raw = state.notify_threshold.buf.trim();
782            let threshold = raw.parse::<u8>();
783            match threshold {
784                Ok(threshold) if (1..=100).contains(&threshold) => {
785                    set_value(
786                        doc,
787                        "notifications",
788                        "threshold",
789                        Some(toml_edit::Value::from(i64::from(threshold))),
790                    )?;
791                }
792                _ => {
793                    return Err(AppError::Other(format!(
794                        "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
795                    )));
796                }
797            }
798        }
799
800        Ok(())
801    })
802}
803
804/// The credential rules every apply path enforces: the TUI's `settings
805/// apply` and the tray's `set-api-key` check must refuse the same keys, so
806/// the rule lives here instead of in each caller.
807fn reject_invalid_key(secret_label: &str, vendor_slug: &str, value: &str) -> Result<()> {
808    if value.is_empty() {
809        return Err(AppError::Other(format!(
810            "{secret_label} for {vendor_slug:?} is empty; use the clear action to remove it"
811        )));
812    }
813    if value.len() > MAX_API_KEY_BYTES {
814        return Err(AppError::Other(format!(
815            "{secret_label} for {vendor_slug:?} exceeds {MAX_API_KEY_BYTES} bytes"
816        )));
817    }
818    if value.chars().any(char::is_control) {
819        return Err(AppError::Other(format!(
820            "{secret_label} for {vendor_slug:?} contains control characters"
821        )));
822    }
823    Ok(())
824}
825
826/// The key vendor a credential request names, by `VendorId::slug()`. The
827/// tray's `set-api-key` sends the slug its provider row carries; anything
828/// else — including the OAuth vendors' slugs — has no credential field.
829pub(crate) fn key_vendor_for_slug(slug: &str) -> Option<&'static KeyVendor> {
830    KEY_VENDORS.iter().find(|vendor| vendor.id.slug() == slug)
831}
832
833/// Validate one credential the way [`state_from_apply_request`] does, so
834/// the tray's `set-api-key` check and the TUI's save refuse the same keys.
835pub(crate) fn validate_key_value(vendor: &KeyVendor, value: &str) -> Result<()> {
836    reject_invalid_key(vendor.secret_label, vendor.id.slug(), value)
837}
838
839/// One credential field as found in the config: the value with the
840/// decoration `toml_edit` attached to it. Cloned out of the parsed
841/// document, so a restore puts it back byte for byte — comments and all.
842/// No `Debug`: the value is a secret, and a backtrace of the snapshot must
843/// not print it.
844pub(crate) struct KeyItemSnapshot {
845    key_decor: toml_edit::Decor,
846    value: toml_edit::Value,
847}
848
849/// The config state the `set-api-key` check must be able to give back when
850/// the provider refuses the new key: whether the file existed, whether the
851/// vendor's section did, and the `api_key` and `enabled` fields exactly as
852/// found. A restore touches only those two fields — everything else in the
853/// document is left as the document holds it at restore time.
854pub(crate) struct KeySnapshot {
855    /// Recorded for the tests and for the snapshot's own contract; the
856    /// restore's file-removal step now keys off the document being left
857    /// empty by [`crate::config::edit_config_document`], not off this flag.
858    #[cfg_attr(not(test), allow(dead_code))]
859    pub file_existed: bool,
860    pub section_existed: bool,
861    pub key: Option<KeyItemSnapshot>,
862    pub enabled: Option<KeyItemSnapshot>,
863}
864
865/// Capture one credential field — its value and its key decoration — from
866/// the vendor's section, when it holds a value. A field that holds a table
867/// is not a credential: it captures as absent, and the restore removes it.
868fn capture_key_item(table: &dyn toml_edit::TableLike, name: &str) -> Option<KeyItemSnapshot> {
869    let (key, item) = table.get_key_value(name)?;
870    let value = item.as_value()?.clone();
871    Some(KeyItemSnapshot {
872        key_decor: key.leaf_decor().clone(),
873        value,
874    })
875}
876
877/// Snapshot the credential state the `set-api-key` check must be able to
878/// give back: read the config with `toml_edit` (comments intact) and clone
879/// the vendor's `api_key` and `enabled` fields out of it. A missing file
880/// is an empty document, so the snapshot simply records that nothing was
881/// there; an unparseable config is an error rather than a guess.
882pub(crate) fn snapshot_inline_key(path: &Path, vendor: &KeyVendor) -> Result<KeySnapshot> {
883    let file_existed = path.exists();
884    let doc = read_config_document(path)?;
885    Ok(key_snapshot_of_doc(&doc, file_existed, vendor))
886}
887
888/// [`snapshot_inline_key`] over a document already in hand — the capture
889/// the locked check write runs inside [`edit_config_document`], where the
890/// file's state is settled under the lock.
891fn key_snapshot_of_doc(
892    doc: &toml_edit::DocumentMut,
893    file_existed: bool,
894    vendor: &KeyVendor,
895) -> KeySnapshot {
896    match doc
897        .get(vendor.section)
898        .and_then(toml_edit::Item::as_table_like)
899    {
900        Some(table) => KeySnapshot {
901            file_existed,
902            section_existed: true,
903            key: capture_key_item(table, vendor.config_key),
904            enabled: capture_key_item(table, "enabled"),
905        },
906        None => KeySnapshot {
907            file_existed,
908            section_existed: false,
909            key: None,
910            enabled: None,
911        },
912    }
913}
914
915/// The verdict the key check publishes when a non-empty key is already
916/// saved: the dialog only exists for a provider without an inline
917/// credential, and a saved key is replaced in the TUI settings, not
918/// checked here.
919pub(crate) const KEY_ALREADY_SAVED_MESSAGE: &str =
920    "A key is already saved in config.toml; replace it in the TUI settings.";
921
922/// The vendor's saved inline key by the catalog's rule — the same one
923/// `Config::inline_api_key` applies: an absent field, or a field that holds
924/// the empty string, is no credential. An `api_key = ''` left by a cleared
925/// field must not block the dialog the catalog offers for it.
926pub(crate) fn saved_inline_key(snapshot: &KeySnapshot) -> Option<String> {
927    snapshot
928        .key
929        .as_ref()
930        .and_then(|item| item.value.as_str())
931        .filter(|value| !value.is_empty())
932        .map(str::to_string)
933}
934
935/// Write one inline credential the way the overlay's save does: `toml_edit`,
936/// the vendor's own section, `chmod 600` through the locked
937/// [`edit_config_document`]. A non-empty credential also opts the vendor in,
938/// like `update_key`: a key that stays disabled is a key the fetch never
939/// sees. The value is validated first, so a refused key never touches the
940/// file. The key check itself uses
941/// [`snapshot_and_write_inline_key_if_absent`], which re-checks the
942/// "no key yet" precondition under the same lock; this unconditional write
943/// is what a user-driven save does, and the tests stand in for it.
944#[cfg_attr(not(test), allow(dead_code))]
945pub(crate) fn write_inline_key(path: &Path, vendor: &KeyVendor, value: &str) -> Result<()> {
946    validate_key_value(vendor, value)?;
947    edit_config_document(path, |doc| {
948        set_string(doc, vendor.section, vendor.config_key, value)?;
949        set_bool(doc, vendor.section, "enabled", true)
950    })
951}
952
953/// The key check's whole read-modify-write in one locked step: snapshot the
954/// credential state, and only while no key is still saved, write `value` —
955/// plus the vendor's opt-in — and return the snapshot the later restore
956/// compares against. The snapshot, the "no key yet" condition, and the
957/// write happen under the same config lock the TUI's save takes, so a TUI
958/// save that lands between any two of the steps is either fully before
959/// (the condition sees its key and the check refuses with
960/// [`KEY_ALREADY_SAVED_MESSAGE`], leaving the file alone) or fully after
961/// (its write is the later decision and wins) — the candidate can no longer
962/// overwrite a key the TUI saved in the window between deciding and
963/// writing. An empty value counts as absent, by the catalog's rule; the
964/// value is validated first, so a refused key never touches the file.
965pub(crate) fn snapshot_and_write_inline_key_if_absent(
966    path: &Path,
967    vendor: &KeyVendor,
968    value: &str,
969) -> Result<KeySnapshot> {
970    validate_key_value(vendor, value)?;
971    edit_config_document(path, |doc| {
972        let snapshot = key_snapshot_of_doc(doc, path.exists(), vendor);
973        if saved_inline_key(&snapshot).is_some() {
974            return Err(AppError::Other(KEY_ALREADY_SAVED_MESSAGE.to_string()));
975        }
976        set_string(doc, vendor.section, vendor.config_key, value)?;
977        set_bool(doc, vendor.section, "enabled", true)?;
978        Ok(snapshot)
979    })
980}
981
982/// Give the config back the state [`snapshot_inline_key`] captured,
983/// compare-and-restore: the check wrote `candidate` as the key and `true`
984/// as the vendor's `enabled` switch, so each field is put back — with its
985/// captured value and decoration, or removed when it was absent — only
986/// while it still holds exactly what the check wrote. A later decision on
987/// the same field, made while the check waited for the provider, belongs
988/// to the user now and survives. No other field in the file is touched,
989/// so edits made to it in the meantime survive the restore. A section the
990/// check created is removed when nothing else is left in it, and a file
991/// the check created is removed when the document is left empty —
992/// [`edit_config_document`] owns that step. No validation runs: the
993/// captured values already lived in the user's config. The whole
994/// compare-and-restore is one locked read-modify-write under the same lock
995/// the TUI's save takes, so a save made while the check waited is seen
996/// here, not clobbered by it.
997pub(crate) fn restore_inline_key_snapshot(
998    path: &Path,
999    vendor: &KeyVendor,
1000    snapshot: &KeySnapshot,
1001    candidate: &str,
1002) -> Result<()> {
1003    edit_config_document(path, |doc| {
1004        let table = doc
1005            .entry(vendor.section)
1006            .or_insert_with(toml_edit::table)
1007            .as_table_like_mut()
1008            .ok_or_else(|| {
1009                AppError::Other(format!("config.toml: [{}] is not a table", vendor.section))
1010            })?;
1011        // Compare-and-restore: the check wrote `candidate` as the key and
1012        // `true` as the vendor's `enabled` switch, so each field goes back —
1013        // with its captured value and decoration, or out when it was absent —
1014        // only while it still holds exactly what the check wrote. A later
1015        // decision on the same field, made while the check waited for the
1016        // provider (a key saved, the vendor switched off), belongs to the user
1017        // now and survives.
1018        let current_key = table
1019            .get(vendor.config_key)
1020            .and_then(toml_edit::Item::as_str);
1021        match &snapshot.key {
1022            Some(captured) if current_key == Some(candidate) => {
1023                if let Some((mut key, item)) = table.get_key_value_mut(vendor.config_key) {
1024                    *key.leaf_decor_mut() = captured.key_decor.clone();
1025                    *item = toml_edit::Item::Value(captured.value.clone());
1026                }
1027            }
1028            None if current_key == Some(candidate) => {
1029                table.remove(vendor.config_key);
1030            }
1031            // The field holds someone else's value: leave it.
1032            _ => {}
1033        }
1034        let current_enabled = table.get("enabled").and_then(toml_edit::Item::as_bool);
1035        match &snapshot.enabled {
1036            Some(captured) if current_enabled == Some(true) => {
1037                if let Some((mut key, item)) = table.get_key_value_mut("enabled") {
1038                    *key.leaf_decor_mut() = captured.key_decor.clone();
1039                    *item = toml_edit::Item::Value(captured.value.clone());
1040                }
1041            }
1042            None if current_enabled == Some(true) => {
1043                table.remove("enabled");
1044            }
1045            // The user switched the vendor: leave their decision.
1046            _ => {}
1047        }
1048        // A section the check created must not survive the restore as an
1049        // empty husk — but one that holds anything the check did not write
1050        // does.
1051        if !snapshot.section_existed
1052            && doc
1053                .get(vendor.section)
1054                .and_then(toml_edit::Item::as_table_like)
1055                .is_some_and(|table| table.is_empty())
1056        {
1057            doc.remove(vendor.section);
1058        }
1059        Ok(())
1060    })
1061}
1062
1063/// Apply one credential field to the document. Untouched fields are left
1064/// alone; a field the user cleared is *removed*, so an inline secret can be
1065/// deleted from the overlay rather than lingering in the file. Writing a
1066/// non-empty credential also opts the vendor in — the opt-in vendors would
1067/// otherwise never fetch.
1068fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
1069    if !input.dirty {
1070        return Ok(());
1071    }
1072    if input.buf.is_empty() {
1073        if let Some(table) = doc
1074            .get_mut(vendor.section)
1075            .and_then(toml_edit::Item::as_table_like_mut)
1076        {
1077            table.remove(vendor.config_key);
1078        }
1079        return Ok(());
1080    }
1081    set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
1082    set_bool(doc, vendor.section, "enabled", true)
1083}
1084
1085/// Set or update a string field in a TOML section, preserving comments and
1086/// formatting of unaffected nodes.
1087fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
1088    let table = doc
1089        .entry(section)
1090        .or_insert_with(toml_edit::table)
1091        .as_table_like_mut()
1092        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
1093
1094    if let Some(item) = table.get_mut(key)
1095        && let Some(v) = item.as_value_mut()
1096    {
1097        *v = toml_edit::Value::from(new_value);
1098        v.decor_mut().set_prefix(" ");
1099        return Ok(());
1100    }
1101    table.insert(key, value(new_value));
1102    Ok(())
1103}
1104
1105fn default_config_path() -> Result<PathBuf> {
1106    // Save back to the same file Config::load() selected. On macOS this may be
1107    // the legacy ~/.config path when the canonical Application Support file is
1108    // absent; writing a new canonical file would shadow the existing config on
1109    // the next load and silently discard all settings the overlay did not copy.
1110    crate::config::resolved_path()
1111        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
1112}
1113
1114// ─── Native frontend bridge ───────────────────────────────────────────────
1115
1116/// Versioned, non-secret description consumed by native desktop frontends.
1117/// Inline key values are deliberately represented only as booleans: a
1118/// long-lived shell process never needs to receive credentials just to draw a
1119/// settings form.
1120#[derive(Debug, Serialize)]
1121struct SettingsSnapshot {
1122    schema_version: u8,
1123    primary: String,
1124    primary_choices: Vec<PrimaryChoice>,
1125    keys: Vec<KeyStatus>,
1126    /// Every known provider's on/off switch (#244) — enabled ones and
1127    /// disabled ones, because turning a provider on is the point.
1128    vendors: Vec<VendorStatus>,
1129}
1130
1131#[derive(Debug, Serialize)]
1132struct PrimaryChoice {
1133    id: String,
1134    label: String,
1135}
1136
1137#[derive(Debug, Serialize)]
1138struct KeyStatus {
1139    id: String,
1140    label: String,
1141    environment: String,
1142    secret_label: String,
1143    note: String,
1144    configured: bool,
1145    inline_configured: bool,
1146    environment_configured: bool,
1147}
1148
1149/// One provider row of the native settings form: its shared display name and
1150/// whether config.toml currently enables it (#244).
1151#[derive(Debug, Serialize)]
1152struct VendorStatus {
1153    id: String,
1154    label: String,
1155    enabled: bool,
1156}
1157
1158/// Additive patch accepted on stdin by `ai-usagebar settings apply`.
1159/// Missing keys remain byte-for-byte untouched. `clear` explicitly removes an
1160/// inline key, matching the TUI overlay's existing empty-dirty-field behavior.
1161/// `vendors` carries provider on/off toggles (#244); its slugs are validated
1162/// against the built-in vendor list before anything is written.
1163#[derive(Debug, Deserialize)]
1164#[serde(deny_unknown_fields)]
1165struct ApplyRequest {
1166    schema_version: u8,
1167    primary: Option<String>,
1168    #[serde(default)]
1169    keys: BTreeMap<String, KeyMutation>,
1170    #[serde(default)]
1171    vendors: BTreeMap<String, bool>,
1172}
1173
1174#[derive(Debug, Deserialize)]
1175#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
1176enum KeyMutation {
1177    Set { value: String },
1178    Clear,
1179}
1180
1181const SETTINGS_SCHEMA_VERSION: u8 = 1;
1182const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
1183/// `pub(crate)`: the tray's `set-api-key` check enforces the same rule.
1184pub(crate) const MAX_API_KEY_BYTES: usize = 16 * 1024;
1185
1186fn snapshot_from_config_with(
1187    cfg: &Config,
1188    environment_configured: impl Fn(&str) -> bool,
1189) -> SettingsSnapshot {
1190    let state = SettingsState::from_config(cfg);
1191    let primary_choices = state
1192        .primary_choices
1193        .iter()
1194        .map(|id| PrimaryChoice {
1195            id: id.slug().to_string(),
1196            label: id.display_name().to_string(),
1197        })
1198        .collect();
1199    let keys = KEY_VENDORS
1200        .iter()
1201        .map(|vendor| {
1202            let environment = cfg.api_key_env_for(vendor.id);
1203            let inline_configured = cfg.inline_api_key(vendor.id).is_some();
1204            let environment_configured = environment_configured(environment);
1205            KeyStatus {
1206                id: vendor.id.slug().to_string(),
1207                label: vendor.label.to_string(),
1208                environment: environment.to_string(),
1209                secret_label: vendor.secret_label.to_string(),
1210                note: vendor.note.to_string(),
1211                configured: inline_configured || environment_configured,
1212                inline_configured,
1213                environment_configured,
1214            }
1215        })
1216        .collect();
1217    let vendors = VendorId::all()
1218        .iter()
1219        .map(|id| VendorStatus {
1220            id: id.slug().to_string(),
1221            label: id.display_name().to_string(),
1222            enabled: cfg.is_enabled(*id),
1223        })
1224        .collect();
1225    SettingsSnapshot {
1226        schema_version: SETTINGS_SCHEMA_VERSION,
1227        primary: state.primary.slug().to_string(),
1228        primary_choices,
1229        keys,
1230        vendors,
1231    }
1232}
1233
1234fn settings_snapshot_json(cfg: &Config) -> Result<String> {
1235    Ok(serde_json::to_string(&snapshot_from_config_with(
1236        cfg,
1237        |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
1238    ))?)
1239}
1240
1241#[cfg(test)]
1242fn settings_snapshot_json_with(
1243    cfg: &Config,
1244    environment_configured: impl Fn(&str) -> bool,
1245) -> Result<String> {
1246    Ok(serde_json::to_string(&snapshot_from_config_with(
1247        cfg,
1248        environment_configured,
1249    ))?)
1250}
1251
1252fn vendor_from_slug(slug: &str) -> Option<VendorId> {
1253    VendorId::from_slug(slug)
1254}
1255
1256fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
1257    let request: ApplyRequest = serde_json::from_str(raw)?;
1258    if request.schema_version != SETTINGS_SCHEMA_VERSION {
1259        return Err(AppError::Other(format!(
1260            "unsupported settings schema version {}",
1261            request.schema_version
1262        )));
1263    }
1264
1265    let mut state = SettingsState::from_config(cfg);
1266    if let Some(primary) = request.primary {
1267        let id = vendor_from_slug(&primary)
1268            .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
1269        if !state.primary_choices.contains(&id) {
1270            return Err(AppError::Other(format!(
1271                "primary vendor {primary:?} is not enabled"
1272            )));
1273        }
1274        state.primary = id;
1275    }
1276
1277    for (id, mutation) in request.keys {
1278        let index = KEY_VENDORS
1279            .iter()
1280            .position(|vendor| vendor.id.slug() == id)
1281            .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
1282        let input = &mut state.keys[index];
1283        match mutation {
1284            KeyMutation::Set { value } => {
1285                reject_invalid_key(KEY_VENDORS[index].secret_label, &id, &value)?;
1286                input.buf = value;
1287            }
1288            KeyMutation::Clear => input.buf.clear(),
1289        }
1290        input.cursor = input.buf.chars().count();
1291        input.dirty = true;
1292        input.revealed = false;
1293    }
1294
1295    // Provider on/off toggles (#244): the slug is the whitelist — anything
1296    // that names no built-in vendor is refused, so a patch can never create
1297    // or flip an arbitrary config section.
1298    for (slug, enabled) in request.vendors {
1299        let index = VendorId::all()
1300            .iter()
1301            .position(|id| id.slug() == slug)
1302            .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
1303        state.vendors[index] = ProviderSwitch {
1304            enabled,
1305            dirty: true,
1306        };
1307    }
1308    Ok(state)
1309}
1310
1311#[cfg(test)]
1312fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
1313    let state = state_from_apply_request(cfg, raw)?;
1314    save_to_path(&state, path)
1315}
1316
1317fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
1318    let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
1319    let mut bytes = Vec::new();
1320    limited.read_until(b'\n', &mut bytes)?;
1321    if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
1322        return Err(AppError::Other(format!(
1323            "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
1324        )));
1325    }
1326    if bytes.last() == Some(&b'\n') {
1327        bytes.pop();
1328        if bytes.last() == Some(&b'\r') {
1329            bytes.pop();
1330        }
1331    }
1332    String::from_utf8(bytes)
1333        .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
1334}
1335
1336fn apply_settings_from_stdin() -> Result<()> {
1337    let raw = read_settings_request(std::io::stdin().lock())?;
1338    let cfg = Config::load()?;
1339    let state = state_from_apply_request(&cfg, &raw)?;
1340    save_to_config_default(&state)
1341}
1342
1343/// Explicit user opt-in, unlike discovery which respects an existing false.
1344fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
1345    edit_config_document(path, |doc| {
1346        set_bool(doc, vendor.config_section(), "enabled", true)
1347    })
1348}
1349
1350/// Administrative settings bridge for native frontends. `show` never emits a
1351/// secret; `apply` accepts its patch only over stdin so keys do not appear in
1352/// argv or the process environment.
1353pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1354    let result = match action {
1355        crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1356            .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1357            .map(|()| {
1358                crate::waybar::request_refresh();
1359                println!(r#"{{"ok":true}}"#);
1360            }),
1361        crate::widget::cli::SettingsAction::Show => Config::load()
1362            .and_then(|cfg| settings_snapshot_json(&cfg))
1363            .map(|json| println!("{json}")),
1364        crate::widget::cli::SettingsAction::Apply => {
1365            apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1366        }
1367    };
1368    match result {
1369        Ok(()) => 0,
1370        Err(error) => {
1371            eprintln!("settings: {error}");
1372            1
1373        }
1374    }
1375}
1376
1377// ─── Render ────────────────────────────────────────────────────────────────
1378
1379/// Line positions inside the rendered overlay body. `render` builds its lines
1380/// in exactly this order and `focus_line` reads them back, so the two cannot
1381/// drift apart silently — the guard tests pin them to each other.
1382struct BodyLayout {
1383    primary: usize,
1384    first_key: usize,
1385    first_vendor: usize,
1386    notify_enabled: usize,
1387    notify_threshold: usize,
1388    save: usize,
1389    /// Body line count without the optional status row.
1390    rows: usize,
1391}
1392
1393fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1394    let first_key = 4;
1395    let first_vendor = first_key + keys + 2;
1396    let notify_enabled = first_vendor + vendors + 2;
1397    BodyLayout {
1398        primary: 1,
1399        first_key,
1400        first_vendor,
1401        notify_enabled,
1402        notify_threshold: notify_enabled + 1,
1403        save: notify_enabled + 3,
1404        rows: notify_enabled + 4,
1405    }
1406}
1407
1408/// The body line the focused control renders on.
1409fn focus_line(state: &SettingsState) -> usize {
1410    let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1411    match state.focus {
1412        Focus::Primary => layout.primary,
1413        Focus::Key(i) => layout.first_key + i,
1414        Focus::Vendor(i) => layout.first_vendor + i,
1415        Focus::NotifyEnabled => layout.notify_enabled,
1416        Focus::NotifyThreshold => layout.notify_threshold,
1417        Focus::Save => layout.save,
1418    }
1419}
1420
1421/// Scroll offset keeping `focused` on screen: unchanged while it is already
1422/// visible, the smallest jump that reveals it when not, clamped to the
1423/// scrollable range. Pure over its inputs.
1424fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1425    if viewport == 0 || total == 0 {
1426        return 0;
1427    }
1428    let max_offset = (total as u16).saturating_sub(viewport);
1429    let focused = focused.min(total - 1) as u16;
1430    let wanted = if focused < current {
1431        focused
1432    } else if focused >= current.saturating_add(viewport) {
1433        // Pin the newly focused line to the bottom edge so what follows it
1434        // (another row, the Save button) still peeks into view.
1435        (focused + 1).saturating_sub(viewport)
1436    } else {
1437        current
1438    };
1439    wanted.min(max_offset)
1440}
1441
1442/// Render the modal overlay over `area`.
1443pub fn render(
1444    f: &mut Frame,
1445    area: Rect,
1446    state: &mut SettingsState,
1447    theme: &Theme,
1448    hits: &mut Vec<(SettingsRow, Rect)>,
1449) {
1450    let modal = centered_rect(74, 88, area);
1451    f.render_widget(Clear, modal);
1452
1453    let bubble = bubble_theme(theme);
1454    let block = bubble.titled_modal_block(" Settings ");
1455    let inner = block.inner(modal);
1456    f.render_widget(block, modal);
1457
1458    // Body (everything but the pinned hint) + a 1-line hint footer.
1459    let chunks = Layout::default()
1460        .direction(Direction::Vertical)
1461        .constraints([Constraint::Min(0), Constraint::Length(1)])
1462        .split(inner);
1463
1464    // The body long outgrew every terminal once each provider got a row, so
1465    // the view follows the focus ring. Compute the offset up front: hit rects
1466    // must be recorded at the *rendered* row (unscrolled position minus the
1467    // scroll offset), or a click selects the control `scroll` rows away from
1468    // the one the user sees.
1469    let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1470    let total = layout.rows + usize::from(!state.status.is_empty());
1471    let scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1472    state.scroll = scroll;
1473
1474    // A row's on-screen y is the body top plus its line index minus the scroll
1475    // offset (each rendered line is exactly one row). Recorded alongside each
1476    // interactive row so a mouse click maps back to a focus target. Rows
1477    // scrolled past either edge of the body are not rendered and get no rect.
1478    let row_at = |line: usize| {
1479        let top = chunks[0].y;
1480        let bottom = top.saturating_add(chunks[0].height);
1481        let y = top.saturating_add(line as u16).checked_sub(scroll)?;
1482        (y >= top && y < bottom).then_some(Rect::new(inner.x, y, inner.width, 1))
1483    };
1484
1485    // — Primary vendor + API keys header —
1486    let mut lines: Vec<Line> = vec![
1487        section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1488        primary_line(state, &bubble),
1489        Line::from(""),
1490        section_header("API keys", "all key providers", &bubble),
1491    ];
1492    if let Some(r) = row_at(1) {
1493        // The focused radio renders "◀ name ▶" over a name padded to the
1494        // widest choice, so the arrows sit at fixed columns however long the
1495        // current pick is. The arrow cells click-step the radio; the name
1496        // cell opens the picker popup. Unfocused rows show no arrows — the
1497        // first click only focuses.
1498        if state.focus == Focus::Primary {
1499            let pad = primary_name_pad(state);
1500            hits.push((
1501                SettingsRow::Switch(Focus::Primary, KeyCode::Left, KeyModifiers::NONE),
1502                Rect::new(r.x.saturating_add(5), r.y, 2, 1),
1503            ));
1504            hits.push((
1505                SettingsRow::Switch(Focus::Primary, KeyCode::Right, KeyModifiers::NONE),
1506                Rect::new(r.x.saturating_add(9 + pad), r.y, 2, 1),
1507            ));
1508            hits.push((
1509                SettingsRow::OpenPicker,
1510                Rect::new(r.x.saturating_add(7), r.y, pad + 2, 1),
1511            ));
1512        }
1513        hits.push((SettingsRow::Focus(Focus::Primary), r));
1514    }
1515
1516    for (i, kv) in KEY_VENDORS.iter().enumerate() {
1517        let focused = state.focus == Focus::Key(i);
1518        if let Some(r) = row_at(lines.len()) {
1519            hits.push((SettingsRow::Focus(Focus::Key(i)), r));
1520        }
1521        lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1522    }
1523
1524    lines.push(Line::from(""));
1525
1526    // — Providers on/off (#244) —
1527    lines.push(section_header(
1528        "Providers",
1529        "which vendors the report, bar and TUI fetch at all",
1530        &bubble,
1531    ));
1532    for (i, id) in VendorId::all().iter().enumerate() {
1533        let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1534            enabled: false,
1535            dirty: false,
1536        });
1537        let focused = state.focus == Focus::Vendor(i);
1538        if let Some(r) = row_at(lines.len()) {
1539            // The switch cell is pushed first so a click on the on/off value
1540            // toggles the row instead of only focusing it. Its rect covers
1541            // exactly the rendered value segment: not the name tail (a name
1542            // past the 11-column padding pushes the value right) and not the
1543            // empty space right of the row.
1544            let (value_x, value_w) =
1545                switch_cell(id.display_name(), switch_value(switch.enabled), focused);
1546            hits.push((
1547                SettingsRow::Switch(Focus::Vendor(i), KeyCode::Char(' '), KeyModifiers::NONE),
1548                Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1549            ));
1550            hits.push((SettingsRow::Focus(Focus::Vendor(i)), r));
1551        }
1552        lines.push(provider_row(id, switch, focused, &bubble));
1553    }
1554    lines.push(Line::from(""));
1555
1556    // — Notifications —
1557    lines.push(section_header(
1558        "Notifications",
1559        "desktop alert when a quota window crosses the threshold",
1560        &bubble,
1561    ));
1562    if let Some(r) = row_at(lines.len()) {
1563        let (value_x, value_w) = switch_cell(
1564            "Quota alerts",
1565            switch_value(state.notify_enabled),
1566            state.focus == Focus::NotifyEnabled,
1567        );
1568        hits.push((
1569            SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), KeyModifiers::NONE),
1570            Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1571        ));
1572        hits.push((SettingsRow::Focus(Focus::NotifyEnabled), r));
1573    }
1574    lines.push(notify_enabled_line(state, &bubble));
1575    if let Some(r) = row_at(lines.len()) {
1576        hits.push((SettingsRow::Focus(Focus::NotifyThreshold), r));
1577    }
1578    lines.push(notify_threshold_line(state, &bubble));
1579    lines.push(Line::from(""));
1580
1581    // — Save + status —
1582    if let Some(r) = row_at(lines.len()) {
1583        hits.push((SettingsRow::Focus(Focus::Save), r));
1584    }
1585    lines.push(save_line(state.focus == Focus::Save, &bubble));
1586    if !state.status.is_empty() {
1587        let ok = state.status.starts_with("saved");
1588        let mark = if ok { "  ✓ " } else { "  ✗ " };
1589        let style = if ok { bubble.accent } else { bubble.selected };
1590        lines.push(Line::from(vec![
1591            Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1592            Span::styled(state.status.clone(), bubble.muted),
1593        ]));
1594    }
1595
1596    // The view follows the focus ring; `scroll` was computed (and persisted
1597    // into the state) before the hit rects were recorded.
1598    f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
1599
1600    // Context-aware hint footer.
1601    /// One hint segment: the key label, its description, and the synthetic key a
1602    /// click sends through [`handle_key`] (`None` for pure key hints).
1603    type HintSegment<'a> = (&'a str, &'a str, Option<(KeyCode, KeyModifiers)>);
1604
1605    // Context-aware hint footer. Each segment is (key, description, click):
1606    // segments with a key payload are actionable "links" — clicking one sends
1607    // that synthetic key through `handle_key`, so save/close/toggle stay in
1608    // one place. Pure key hints (move/type/digits) get no click rect.
1609    let segments: &[HintSegment] = match state.focus {
1610        Focus::Primary => &[
1611            ("↑↓/tab", "move", None),
1612            (
1613                "←→",
1614                "change vendor",
1615                Some((KeyCode::Right, KeyModifiers::NONE)),
1616            ),
1617            (
1618                "^S",
1619                "save",
1620                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1621            ),
1622            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1623        ],
1624        Focus::Key(_) => &[
1625            ("↑↓/tab", "move", None),
1626            ("type", "edit key", None),
1627            (
1628                "^V",
1629                "reveal",
1630                Some((KeyCode::Char('v'), KeyModifiers::CONTROL)),
1631            ),
1632            (
1633                "^S",
1634                "save",
1635                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1636            ),
1637            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1638        ],
1639        Focus::Vendor(_) | Focus::NotifyEnabled => &[
1640            ("↑↓/tab", "move", None),
1641            (
1642                "←→/space",
1643                "toggle",
1644                Some((KeyCode::Char(' '), KeyModifiers::NONE)),
1645            ),
1646            (
1647                "^S",
1648                "save",
1649                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1650            ),
1651            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1652        ],
1653        Focus::NotifyThreshold => &[
1654            ("↑↓/tab", "move", None),
1655            ("type", "digits 1-100", None),
1656            (
1657                "^S",
1658                "save",
1659                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1660            ),
1661            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1662        ],
1663        Focus::Save => &[
1664            ("↑↓/tab", "move", None),
1665            (
1666                "enter/^S",
1667                "save",
1668                Some((KeyCode::Enter, KeyModifiers::NONE)),
1669            ),
1670            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1671        ],
1672    };
1673    let hint = bubble.help_line(segments.iter().map(|(key, desc, _)| (*key, *desc)));
1674    f.render_widget(Paragraph::new(hint), chunks[1]);
1675
1676    // Help draws each segment as "key description" joined by " • " (3 cells,
1677    // same separator the main footer replicates for its click targets), left
1678    // aligned from the row's first cell. Record the same cells for segments
1679    // with a click payload, clipped to the visible row width.
1680    let right = chunks[1].x.saturating_add(chunks[1].width);
1681    let mut x = chunks[1].x;
1682    for (key, desc, action) in segments {
1683        let width = (crate::display::text_width(key) as u16)
1684            .saturating_add(1)
1685            .saturating_add(crate::display::text_width(desc) as u16);
1686        if let Some((code, mods)) = action
1687            && x < right
1688        {
1689            let visible = width.min(right.saturating_sub(x));
1690            if visible > 0 {
1691                hits.push((
1692                    SettingsRow::HintKey(*code, *mods),
1693                    Rect::new(x, chunks[1].y, visible, 1),
1694                ));
1695            }
1696        }
1697        x = x.saturating_add(width).saturating_add(3);
1698    }
1699
1700    // The primary-vendor picker floats over the body when open, drawn last so
1701    // it lands on top of everything already rendered.
1702    if state.picker.is_some() {
1703        render_primary_picker(f, state, &bubble, chunks[0], hits);
1704    }
1705}
1706
1707/// The primary-vendor dropdown: anchored under the radio row, listing
1708/// [`SettingsState::primary_choices`] with the cursor scrolled into view.
1709/// Rows are recorded as [`SettingsRow::Pick`] click targets.
1710fn render_primary_picker(
1711    f: &mut Frame,
1712    state: &mut SettingsState,
1713    theme: &BubbleTheme,
1714    body: Rect,
1715    hits: &mut Vec<(SettingsRow, Rect)>,
1716) {
1717    let count = state.primary_choices.len();
1718    if count == 0 {
1719        return;
1720    }
1721    let pad = primary_name_pad(state) as usize;
1722    let width = u16::try_from(pad + 6).unwrap_or(u16::MAX).min(body.width);
1723    if width < 4 {
1724        return;
1725    }
1726    // Anchor under the radio row (body line 1), capped to the body bottom.
1727    let height = (count as u16 + 2)
1728        .min(body.bottom().saturating_sub(body.y + 2))
1729        .max(3);
1730    let area = Rect::new(
1731        body.x.saturating_add(2),
1732        body.y.saturating_add(2),
1733        width,
1734        height,
1735    );
1736
1737    f.render_widget(Clear, area);
1738    let block = theme.titled_modal_block(" Primary vendor ");
1739    let inner = block.inner(area);
1740    f.render_widget(block, area);
1741
1742    let Some(picker) = state.picker.as_mut() else {
1743        return;
1744    };
1745    let viewport = inner.height;
1746    picker.scroll = follow_focus_scroll(picker.cursor, count, viewport, picker.scroll);
1747    for (index, vendor) in state
1748        .primary_choices
1749        .iter()
1750        .enumerate()
1751        .skip(picker.scroll as usize)
1752        .take(viewport as usize)
1753    {
1754        let y = inner.y + u16::try_from(index - picker.scroll as usize).unwrap_or(u16::MAX);
1755        let cursor = picker.cursor == index;
1756        let current = *vendor == state.primary;
1757        let style = if cursor {
1758            theme.accent.add_modifier(Modifier::BOLD)
1759        } else if current {
1760            theme.title
1761        } else {
1762            theme.text
1763        };
1764        let line = Line::from(vec![
1765            theme.span(if cursor { "▸ " } else { "  " }),
1766            Span::styled(format!("{:<pad$}", vendor.display_name(), pad = pad), style),
1767            theme.span(if current { "  ✓" } else { "" }),
1768        ]);
1769        f.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
1770        hits.push((
1771            SettingsRow::Pick(index),
1772            Rect::new(inner.x, y, inner.width, 1),
1773        ));
1774    }
1775}
1776
1777fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1778    Line::from(vec![
1779        theme.span(" "),
1780        Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1781        theme.muted(format!("   — {sub}")),
1782    ])
1783}
1784
1785/// Display width the primary radio pads its name to: the widest choice, so
1786/// the ◀/▶ arrows sit at fixed columns however long the current pick is.
1787fn primary_name_pad(state: &SettingsState) -> u16 {
1788    state
1789        .primary_choices
1790        .iter()
1791        .map(|v| crate::display::text_width(v.display_name()) as u16)
1792        .max()
1793        .unwrap_or(1)
1794}
1795
1796fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1797    let focused = state.focus == Focus::Primary;
1798    let name = state.primary.display_name().to_string();
1799    if focused {
1800        let pad = primary_name_pad(state) as usize;
1801        Line::from(vec![
1802            theme.span("   "),
1803            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1804            Span::styled("◀ ", theme.accent),
1805            Span::styled(
1806                format!(" {:<pad$} ", name, pad = pad),
1807                theme
1808                    .selected
1809                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1810            ),
1811            Span::styled(" ▶", theme.accent),
1812            theme.muted("    ← → or click to change"),
1813        ])
1814    } else {
1815        Line::from(vec![theme.span("     "), Span::styled(name, theme.text)])
1816    }
1817}
1818
1819fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1820    let label = format!("{:<11}", kv.label);
1821    let value = value_text(input, focused);
1822
1823    // Env / status suffix: env-var name, whether an env override is set, note.
1824    let env_name = kv.id.api_key_env();
1825    let env_set = std::env::var(env_name)
1826        .map(|v| !v.is_empty())
1827        .unwrap_or(false);
1828    let mut suffix = format!("   {env_name}");
1829    if env_set {
1830        suffix.push_str(" · env set (overrides)");
1831    }
1832    if !kv.note.is_empty() {
1833        suffix.push_str(&format!(" · {}", kv.note));
1834    }
1835
1836    if focused {
1837        let val_style = if input.buf.is_empty() {
1838            theme.accent.add_modifier(Modifier::BOLD)
1839        } else {
1840            theme.selected.add_modifier(Modifier::REVERSED)
1841        };
1842        let mut spans = vec![
1843            theme.span("  "),
1844            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1845            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1846            Span::styled(format!(" {value} "), val_style),
1847        ];
1848        if input.revealed {
1849            spans.push(theme.muted("  [revealed]"));
1850        }
1851        spans.push(theme.muted(suffix));
1852        Line::from(spans)
1853    } else {
1854        let val_style = if input.buf.is_empty() {
1855            theme.muted
1856        } else {
1857            theme.text
1858        };
1859        Line::from(vec![
1860            theme.span("    "),
1861            Span::styled(label, theme.text),
1862            Span::styled(format!(" {value}"), val_style),
1863            theme.muted(suffix),
1864        ])
1865    }
1866}
1867
1868/// The value column: `(empty)` / a cursor when focused-empty / masked or
1869/// revealed buffer with a cursor mark inserted when focused.
1870fn value_text(input: &KeyInput, focused: bool) -> String {
1871    if input.buf.is_empty() {
1872        return if focused {
1873            "‸".to_string()
1874        } else {
1875            "(empty)".to_string()
1876        };
1877    }
1878    let base = input.display();
1879    if !focused {
1880        return base;
1881    }
1882    let mut chars: Vec<char> = base.chars().collect();
1883    let pos = input.cursor.min(chars.len());
1884    chars.insert(pos, '‸');
1885    chars.into_iter().collect()
1886}
1887
1888/// Labels in provider / notification rows are padded to at least 11 columns;
1889/// the renderers and the mouse hit-test share this so a click lands where the
1890/// value actually renders.
1891fn padded_label(label: &str) -> String {
1892    format!("{:<11}", label)
1893}
1894
1895/// The on/off text a provider / quota switch renders.
1896fn switch_value(enabled: bool) -> &'static str {
1897    if enabled { "on" } else { "off" }
1898}
1899
1900/// The value cell's offset and rendered width. Mirrors provider_row /
1901/// notify_enabled_line: a 5-cell focus prefix, the padded label, then either
1902/// `" ◀ on ▶ "` when focused or `"  on"` when unfocused. Names past 11
1903/// columns push the cell right — never assume a fixed column.
1904fn switch_cell(label: &str, value: &str, focused: bool) -> (u16, u16) {
1905    let value_x = 5 + crate::display::text_width(&padded_label(label)) as u16;
1906    let decoration_w = if focused { 6 } else { 2 };
1907    let value_w = decoration_w + crate::display::text_width(value) as u16;
1908    (value_x, value_w)
1909}
1910
1911/// One `[<vendor>] enabled` row (#244): the shared display name plus an
1912/// on/off value styled like the notification toggle.
1913fn provider_row(
1914    id: &VendorId,
1915    switch: ProviderSwitch,
1916    focused: bool,
1917    theme: &BubbleTheme,
1918) -> Line<'static> {
1919    let label = padded_label(id.display_name());
1920    let value = switch_value(switch.enabled);
1921    if focused {
1922        Line::from(vec![
1923            theme.span("   "),
1924            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1925            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1926            Span::styled(
1927                format!(" ◀ {value} ▶ "),
1928                theme
1929                    .selected
1930                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1931            ),
1932        ])
1933    } else {
1934        Line::from(vec![
1935            theme.span("     "),
1936            Span::styled(label, theme.text),
1937            Span::styled(format!("  {value}"), theme.muted),
1938        ])
1939    }
1940}
1941
1942/// `[notifications] enabled` row — a plain on/off toggle styled like the
1943/// primary selector.
1944fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1945    let focused = state.focus == Focus::NotifyEnabled;
1946    let label = padded_label("Quota alerts");
1947    let value = switch_value(state.notify_enabled);
1948    if focused {
1949        Line::from(vec![
1950            theme.span("   "),
1951            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1952            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1953            Span::styled(
1954                format!(" ◀ {value} ▶ "),
1955                theme
1956                    .selected
1957                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1958            ),
1959        ])
1960    } else {
1961        Line::from(vec![
1962            theme.span("     "),
1963            Span::styled(label, theme.text),
1964            Span::styled(format!("  {value}"), theme.muted),
1965        ])
1966    }
1967}
1968
1969/// `[notifications] threshold` row — the digits are never masked, so the
1970/// number is readable at a glance.
1971fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1972    let focused = state.focus == Focus::NotifyThreshold;
1973    let label = format!("{:<11}", "Threshold %");
1974    let input = &state.notify_threshold;
1975    let value = if input.buf.is_empty() {
1976        if focused {
1977            "‸".to_string()
1978        } else {
1979            "(97)".to_string()
1980        }
1981    } else {
1982        let mut chars: Vec<char> = input.buf.chars().collect();
1983        if focused {
1984            let pos = input.cursor.min(chars.len());
1985            chars.insert(pos, '‸');
1986        }
1987        chars.into_iter().collect()
1988    };
1989    if focused {
1990        Line::from(vec![
1991            theme.span("  "),
1992            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1993            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1994            Span::styled(
1995                format!(" {value} "),
1996                theme
1997                    .selected
1998                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1999            ),
2000            theme.muted("   1-100"),
2001        ])
2002    } else {
2003        Line::from(vec![
2004            theme.span("    "),
2005            Span::styled(label, theme.text),
2006            Span::styled(format!(" {value}"), theme.text),
2007            theme.muted("   1-100"),
2008        ])
2009    }
2010}
2011
2012fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
2013    let style = if focused {
2014        theme
2015            .selected
2016            .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2017    } else {
2018        theme.accent.add_modifier(Modifier::BOLD)
2019    };
2020    let marker = if focused { "▸ " } else { "  " };
2021    Line::from(vec![
2022        theme.span("   "),
2023        Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
2024        Span::styled("  Save  (Ctrl-S)  ", style),
2025    ])
2026}
2027
2028/// Center a rectangle of `percent_x * percent_y` over `r`.
2029fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2030    let popup_h = (r.height * percent_y) / 100;
2031    let popup_w = (r.width * percent_x) / 100;
2032    Rect {
2033        x: r.x + (r.width - popup_w) / 2,
2034        y: r.y + (r.height - popup_h) / 2,
2035        width: popup_w,
2036        height: popup_h,
2037    }
2038}
2039
2040// crossterm types live behind ratatui; re-exported here for handle_key callers.
2041pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2042
2043#[cfg(test)]
2044mod tests {
2045    use super::*;
2046    use tempfile::TempDir;
2047
2048    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
2049        crate::cache::closed_temp_file("config.toml", initial)
2050    }
2051
2052    fn key_index(id: VendorId) -> usize {
2053        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
2054    }
2055
2056    #[test]
2057    fn explicit_enable_preserves_other_settings_and_is_idempotent() {
2058        let dir = tempfile::tempdir().unwrap();
2059        let path = dir.path().join("config.toml");
2060        let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
2061        std::fs::write(&path, original).unwrap();
2062        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2063        let expected = original.replacen("enabled = false", "enabled = true", 1);
2064        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2065        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2066        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2067    }
2068
2069    #[test]
2070    fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let path = dir.path().join("nested/config.toml");
2073        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2074        assert!(
2075            std::fs::read_to_string(&path)
2076                .unwrap()
2077                .contains("enabled = true")
2078        );
2079        let broken = "[anthropic\n";
2080        std::fs::write(&path, broken).unwrap();
2081        assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
2082        assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
2083    }
2084
2085    fn blank_state(primary: VendorId) -> SettingsState {
2086        SettingsState {
2087            focus: Focus::Primary,
2088            primary_choices: VendorId::all().to_vec(),
2089            primary,
2090            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
2091            vendors: VendorId::all()
2092                .iter()
2093                .map(|id| ProviderSwitch {
2094                    enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
2095                    dirty: false,
2096                })
2097                .collect(),
2098            notify_enabled: true,
2099            notify_enabled_dirty: false,
2100            notify_threshold: KeyInput::from_config(Some("97")),
2101            status: String::new(),
2102            scroll: 0,
2103            picker: None,
2104        }
2105    }
2106
2107    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
2108    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
2109        let mut s = blank_state(primary);
2110        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
2111        s.keys[key_index(VendorId::Zai)].dirty = true;
2112        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
2113        s.keys[key_index(VendorId::Openrouter)].dirty = true;
2114        s
2115    }
2116
2117    #[test]
2118    fn focus_cycles_through_primary_all_keys_notifications_and_save() {
2119        let mut f = Focus::Primary;
2120        let mut seen = vec![f];
2121        // Full cycle = Primary + N key rows + every provider switch + both
2122        // notification fields + Save.
2123        for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
2124            f = f.next();
2125            seen.push(f);
2126        }
2127        // Primary, Key(0..n), Vendor(0..m), NotifyEnabled, NotifyThreshold,
2128        // Save, Primary.
2129        assert_eq!(seen.first(), Some(&Focus::Primary));
2130        assert_eq!(seen.last(), Some(&Focus::Primary));
2131        assert!(seen.contains(&Focus::Key(0)));
2132        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
2133        assert!(seen.contains(&Focus::Vendor(0)));
2134        assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
2135        assert!(seen.contains(&Focus::NotifyEnabled));
2136        assert!(seen.contains(&Focus::NotifyThreshold));
2137        assert!(seen.contains(&Focus::Save));
2138        // prev() is the inverse of next() at every joint of the ring (#244
2139        // inserted the provider rows between the credentials and alerts).
2140        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
2141        assert_eq!(Focus::Save.prev().next(), Focus::Save);
2142        assert_eq!(
2143            Focus::Key(KEY_VENDORS.len() - 1).next(),
2144            Focus::Vendor(0),
2145            "the first provider row follows the last credential"
2146        );
2147        assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
2148        assert_eq!(
2149            Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
2150            Focus::NotifyEnabled
2151        );
2152        assert_eq!(
2153            Focus::NotifyEnabled.prev(),
2154            Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
2155        );
2156        assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
2157        assert_eq!(Focus::Primary.prev(), Focus::Save);
2158    }
2159
2160    /// The focus ring must track the real vendor enumeration, not a stale
2161    /// copy: a vendor added without this notice would make the last row
2162    /// unreachable.
2163    #[test]
2164    fn provider_switch_count_matches_the_vendor_enumeration() {
2165        assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
2166    }
2167
2168    #[test]
2169    fn from_config_prefills_provider_switches_from_enabled_vendors() {
2170        let mut cfg = Config::default();
2171        cfg.grok.enabled = true;
2172        cfg.anthropic.enabled = false;
2173        let s = SettingsState::from_config_with(&cfg, |_| false);
2174        for (i, id) in VendorId::all().iter().enumerate() {
2175            assert_eq!(
2176                s.vendors[i],
2177                ProviderSwitch {
2178                    enabled: cfg.is_enabled(*id),
2179                    dirty: false
2180                },
2181                "{}",
2182                id.slug()
2183            );
2184        }
2185        assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
2186        assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
2187    }
2188
2189    fn vendor_index(id: VendorId) -> usize {
2190        VendorId::all().iter().position(|v| *v == id).unwrap()
2191    }
2192
2193    #[test]
2194    fn provider_switch_flips_on_left_right_and_space() {
2195        let mut s = blank_state(VendorId::Anthropic);
2196        let grok = vendor_index(VendorId::Grok);
2197        s.focus = Focus::Vendor(grok);
2198        assert!(!s.vendors[grok].enabled);
2199        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2200        assert_eq!(
2201            s.vendors[grok],
2202            ProviderSwitch {
2203                enabled: true,
2204                dirty: true
2205            }
2206        );
2207        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2208        assert!(!s.vendors[grok].enabled);
2209        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2210        assert!(s.vendors[grok].enabled);
2211        // Any other key leaves the switch (and its dirty flag) alone.
2212        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2213        assert!(s.vendors[grok].enabled);
2214        assert!(s.vendors[grok].dirty);
2215        // A control chord never toggles.
2216        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
2217        assert!(s.vendors[grok].enabled);
2218        // Tab moves focus without touching the switch.
2219        let before = s.vendors[grok];
2220        handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
2221        assert_ne!(s.focus, Focus::Vendor(grok));
2222        assert_eq!(s.vendors[grok], before);
2223    }
2224
2225    #[test]
2226    fn save_writes_only_toggled_providers_and_round_trips() {
2227        let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
2228        let mut s = blank_state(VendorId::Anthropic);
2229        s.primary_choices = vec![VendorId::Anthropic];
2230        // Turn Grok on and Anthropic off; leave every other switch untouched.
2231        s.vendors[vendor_index(VendorId::Grok)].enabled = true;
2232        s.vendors[vendor_index(VendorId::Grok)].dirty = true;
2233        s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
2234        s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
2235        save_to_path(&s, &path).unwrap();
2236
2237        let raw = std::fs::read_to_string(&path).unwrap();
2238        assert!(raw.starts_with("# keep\n"), "{raw}");
2239        assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
2240        assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
2241        // A vendor nobody touched keeps whatever the file had: no new
2242        // `[zai] enabled = true` materializes, and a default-on vendor the
2243        // file never mentioned stays unwritten.
2244        assert!(!raw.contains("[zai]"), "{raw}");
2245        assert!(!raw.contains("[openrouter]"), "{raw}");
2246
2247        let reloaded = Config::load_from(&path).unwrap();
2248        assert!(reloaded.is_enabled(VendorId::Grok));
2249        assert!(!reloaded.is_enabled(VendorId::Anthropic));
2250        assert!(
2251            reloaded.is_enabled(VendorId::Zai),
2252            "untouched default stays on"
2253        );
2254    }
2255
2256    #[test]
2257    fn an_untouched_overlay_save_writes_no_provider_sections() {
2258        let (_dir, path) = temp_config(None);
2259        let mut s = blank_state(VendorId::Anthropic);
2260        s.primary_choices = vec![VendorId::Anthropic];
2261        save_to_path(&s, &path).unwrap();
2262        let raw = std::fs::read_to_string(&path).unwrap();
2263        assert!(!raw.contains("enabled ="), "{raw}");
2264        assert!(!raw.contains("[grok]"), "{raw}");
2265    }
2266
2267    /// The explicit switch is the user's word for it: pasting a credential
2268    /// auto-enables its vendor, and an off toggled in the same save wins.
2269    #[test]
2270    fn an_explicit_provider_off_wins_over_a_pasted_credential() {
2271        let (_dir, path) = temp_config(None);
2272        let mut s = blank_state(VendorId::Zai);
2273        s.primary_choices = vec![VendorId::Zai];
2274        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
2275        s.keys[key_index(VendorId::Zai)].dirty = true;
2276        s.vendors[vendor_index(VendorId::Zai)].enabled = false;
2277        s.vendors[vendor_index(VendorId::Zai)].dirty = true;
2278        save_to_path(&s, &path).unwrap();
2279        let raw = std::fs::read_to_string(&path).unwrap();
2280        assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
2281        assert!(raw.contains("enabled = false"), "{raw}");
2282        assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
2283    }
2284
2285    #[test]
2286    fn every_key_vendor_has_a_field() {
2287        // Every enabled-by-key vendor must be reachable in the form.
2288        for id in [
2289            VendorId::Zai,
2290            VendorId::Openrouter,
2291            VendorId::Deepseek,
2292            VendorId::Deepinfra,
2293            VendorId::Kilo,
2294            VendorId::Novita,
2295            VendorId::Synthetic,
2296            VendorId::Moonshot,
2297            VendorId::Grok,
2298            VendorId::Mistral,
2299        ] {
2300            assert!(
2301                KEY_VENDORS.iter().any(|kv| kv.id == id),
2302                "{id:?} has no key field"
2303            );
2304        }
2305        // OAuth vendors are intentionally absent.
2306        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
2307        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
2308    }
2309
2310    #[test]
2311    fn from_config_prefills_existing_keys() {
2312        let mut cfg = Config::default();
2313        cfg.kilo.api_key = Some("sk-kilo".into());
2314        let s = SettingsState::from_config(&cfg);
2315        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
2316        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
2317    }
2318
2319    #[test]
2320    fn from_config_prefills_the_notification_fields() {
2321        let mut cfg = Config::default();
2322        cfg.notifications.enabled = false;
2323        cfg.notifications.threshold = 100;
2324        let s = SettingsState::from_config_with(&cfg, |_| false);
2325        assert!(!s.notify_enabled);
2326        assert!(!s.notify_enabled_dirty);
2327        assert_eq!(s.notify_threshold.buf, "100");
2328        assert!(!s.notify_threshold.dirty);
2329    }
2330
2331    #[test]
2332    fn notification_toggle_flips_on_left_right_and_space() {
2333        let mut s = blank_state(VendorId::Anthropic);
2334        s.focus = Focus::NotifyEnabled;
2335        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2336        assert!(!s.notify_enabled);
2337        assert!(s.notify_enabled_dirty);
2338        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2339        assert!(s.notify_enabled);
2340        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2341        assert!(!s.notify_enabled);
2342        // Any other key leaves the toggle (and its dirty flag) alone.
2343        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
2344        assert!(!s.notify_enabled);
2345    }
2346
2347    #[test]
2348    fn threshold_edits_accept_digits_and_reject_everything_else() {
2349        let mut s = blank_state(VendorId::Anthropic);
2350        s.focus = Focus::NotifyThreshold;
2351        // Start from an empty buffer to observe each accepted char.
2352        s.notify_threshold = KeyInput::default();
2353        for c in ['9', 'a', '.', '-', '→'] {
2354            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2355        }
2356        assert_eq!(s.notify_threshold.buf, "9");
2357        assert!(s.notify_threshold.dirty);
2358        handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
2359        assert_eq!(s.notify_threshold.buf, "98");
2360        handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
2361        assert_eq!(s.notify_threshold.buf, "9");
2362    }
2363
2364    #[test]
2365    fn save_writes_the_notification_fields_and_round_trips() {
2366        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2367        let mut s = blank_state(VendorId::Anthropic);
2368        s.notify_enabled = false;
2369        s.notify_enabled_dirty = true;
2370        // Start from an empty buffer so the written value is exactly "90".
2371        s.notify_threshold = KeyInput::default();
2372        for c in "90".chars() {
2373            s.notify_threshold.insert_char(c);
2374        }
2375        save_to_path(&s, &path).unwrap();
2376
2377        let raw = std::fs::read_to_string(&path).unwrap();
2378        assert!(raw.contains("[notifications]"), "{raw}");
2379        assert!(raw.contains("enabled = false"), "{raw}");
2380        assert!(raw.contains("threshold = 90"), "{raw}");
2381        // The written file parses back through the same config path.
2382        let reloaded = Config::load_from(&path).unwrap();
2383        assert!(!reloaded.notifications.enabled);
2384        assert_eq!(reloaded.notifications.threshold, 90);
2385    }
2386
2387    #[test]
2388    fn save_leaves_an_untouched_notification_section_alone() {
2389        let (_dir, path) = temp_config(None);
2390        let s = blank_state(VendorId::Anthropic);
2391        save_to_path(&s, &path).unwrap();
2392        let raw = std::fs::read_to_string(&path).unwrap();
2393        assert!(!raw.contains("[notifications]"), "{raw}");
2394    }
2395
2396    #[test]
2397    fn save_rejects_an_out_of_range_threshold_without_writing() {
2398        let (_dir, path) = temp_config(None);
2399        let mut s = blank_state(VendorId::Anthropic);
2400        for c in "300".chars() {
2401            s.notify_threshold.insert_char(c);
2402        }
2403        let err = save_to_path(&s, &path).unwrap_err().to_string();
2404        assert!(
2405            err.contains("[notifications] threshold must be a whole number between 1 and 100"),
2406            "{err}"
2407        );
2408        // A failed save must not leave a partial file behind.
2409        assert!(!path.exists());
2410
2411        // An empty dirty buffer is the same refusal.
2412        let (_dir, path) = temp_config(None);
2413        let mut s = blank_state(VendorId::Anthropic);
2414        s.notify_threshold = KeyInput::default();
2415        s.notify_threshold.dirty = true;
2416        assert!(save_to_path(&s, &path).is_err());
2417        assert!(!path.exists());
2418    }
2419
2420    #[test]
2421    fn copilot_has_no_editable_credential_field() {
2422        assert!(
2423            !KEY_VENDORS
2424                .iter()
2425                .any(|vendor| vendor.id == VendorId::Copilot)
2426        );
2427    }
2428
2429    /// The exported-env-var path is real behaviour and deserves a test of its
2430    /// own — just not one that reads the machine it runs on.
2431    #[test]
2432    fn a_key_vendor_with_its_env_var_exported_is_offered() {
2433        let cfg = Config::default();
2434        let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
2435
2436        let without = SettingsState::from_config_with(&cfg, |_| false);
2437        assert!(!without.primary_choices.contains(&VendorId::Ollama));
2438
2439        let with = SettingsState::from_config_with(&cfg, |name| name == env);
2440        assert!(
2441            with.primary_choices.contains(&VendorId::Ollama),
2442            "a key vendor whose env var is exported must be selectable: {:?}",
2443            with.primary_choices
2444        );
2445    }
2446
2447    #[test]
2448    fn from_config_offers_enabled_vendors_only() {
2449        let cfg = Config::default();
2450        // No ambient environment: this must not depend on whether the machine
2451        // running `cargo test` happens to export OLLAMA_API_KEY or friends.
2452        let s = SettingsState::from_config_with(&cfg, |_| false);
2453        let mut expected = cfg.enabled_vendors();
2454        expected.push(VendorId::Copilot);
2455        assert_eq!(s.primary_choices, expected);
2456        // API-key opt-in vendors are disabled by default and must not be
2457        // offered. Copilot is the exception: choosing it enables it safely.
2458        assert!(!s.primary_choices.contains(&VendorId::Grok));
2459        assert!(s.primary_choices.contains(&s.primary));
2460        assert!(s.primary_choices.contains(&VendorId::Copilot));
2461    }
2462
2463    #[test]
2464    fn from_config_falls_back_when_configured_primary_is_disabled() {
2465        // Grok is opt-in; a config naming it as primary without enabling it
2466        // must display the first enabled vendor instead.
2467        let mut cfg = Config::default();
2468        cfg.ui.primary = Some(VendorId::Grok);
2469        let s = SettingsState::from_config(&cfg);
2470        assert_ne!(s.primary, VendorId::Grok);
2471        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
2472    }
2473
2474    /// Switching off the vendor that is still the primary is a state a save
2475    /// can produce. A key vendor that keeps its key stays on offer, so the
2476    /// overlay must not reopen with it selected: the next save, whatever it
2477    /// changed, would take that as the user's pick and switch it back on.
2478    #[test]
2479    fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2480        let (_dir, path) = temp_config(Some(
2481            "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2482        ));
2483        let cfg = Config::load_from(&path).unwrap();
2484        let mut s = SettingsState::from_config_with(&cfg, |_| false);
2485        assert!(s.primary_choices.contains(&VendorId::Openrouter));
2486        assert_ne!(s.primary, VendorId::Openrouter);
2487
2488        s.notify_enabled = !s.notify_enabled;
2489        s.notify_enabled_dirty = true;
2490        save_to_path(&s, &path).unwrap();
2491
2492        let saved = Config::load_from(&path).unwrap();
2493        assert!(!saved.is_enabled(VendorId::Openrouter));
2494    }
2495
2496    #[test]
2497    fn key_input_insert_backspace_arrow() {
2498        let mut k = KeyInput::default();
2499        k.insert_char('a');
2500        k.insert_char('b');
2501        k.insert_char('c');
2502        assert_eq!(k.buf, "abc");
2503        assert_eq!(k.cursor, 3);
2504        assert!(k.dirty);
2505        k.move_left();
2506        k.move_left();
2507        assert_eq!(k.cursor, 1);
2508        k.insert_char('x');
2509        assert_eq!(k.buf, "axbc");
2510        assert_eq!(k.cursor, 2);
2511        k.backspace();
2512        assert_eq!(k.buf, "abc");
2513        assert_eq!(k.cursor, 1);
2514    }
2515
2516    #[test]
2517    fn key_input_masks_by_default_reveals_on_toggle() {
2518        let mut k = KeyInput::default();
2519        for c in "secret-key".chars() {
2520            k.insert_char(c);
2521        }
2522        assert_eq!(k.display(), "•".repeat(10));
2523        k.toggle_reveal();
2524        assert_eq!(k.display(), "secret-key");
2525    }
2526
2527    #[test]
2528    fn key_input_handles_unicode() {
2529        let mut k = KeyInput::default();
2530        k.insert_char('a');
2531        k.insert_char('→');
2532        k.insert_char('b');
2533        assert_eq!(k.buf, "a→b");
2534        assert_eq!(k.cursor, 3);
2535        k.move_left();
2536        k.backspace();
2537        assert_eq!(k.buf, "ab");
2538    }
2539
2540    #[test]
2541    fn value_text_shows_cursor_and_empty_states() {
2542        let mut k = KeyInput::default();
2543        assert_eq!(value_text(&k, false), "(empty)");
2544        assert_eq!(value_text(&k, true), "‸");
2545        k.insert_char('a');
2546        k.insert_char('b');
2547        // masked + cursor at end
2548        assert_eq!(value_text(&k, true), "••‸");
2549        assert_eq!(value_text(&k, false), "••");
2550    }
2551
2552    #[test]
2553    fn save_writes_key_and_enables_vendor() {
2554        let (_dir, path) = temp_config(None);
2555        let mut s = blank_state(VendorId::Kilo);
2556        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2557        s.keys[key_index(VendorId::Kilo)].dirty = true;
2558        save_to_path(&s, &path).unwrap();
2559        let raw = std::fs::read_to_string(&path).unwrap();
2560        assert!(raw.contains("primary = \"kilo\""));
2561        assert!(raw.contains("[kilo]"));
2562        assert!(raw.contains("api_key = \"sk-kilo\""));
2563        assert!(raw.contains("enabled = true"));
2564    }
2565
2566    #[test]
2567    fn save_writes_minimal_toml_when_starting_empty() {
2568        let (_dir, path) = temp_config(None);
2569        let s = state_with("zk", "ok", VendorId::Zai);
2570        save_to_path(&s, &path).unwrap();
2571        let raw = std::fs::read_to_string(&path).unwrap();
2572        assert!(raw.contains("primary = \"zai\""));
2573        assert!(raw.contains("[zai]"));
2574        assert!(raw.contains("api_key = \"zk\""));
2575        assert!(raw.contains("[openrouter]"));
2576        assert!(raw.contains("api_key = \"ok\""));
2577    }
2578
2579    #[test]
2580    fn save_preserves_existing_comments_and_unrelated_fields() {
2581        let (_dir, path) = temp_config(Some(
2582            r##"# my comment
2583[ui]
2584# pre-existing comment
2585primary = "anthropic"
2586
2587[zai]
2588enabled = true
2589api_key_env = "ZAI_API_KEY"
2590# tier comment
2591plan_tier = "pro"
2592
2593[openrouter]
2594enabled = true
2595api_key_env = "OPENROUTER_API_KEY"
2596
2597[[openrouter.accounts]]
2598label = "work"
2599api_key_env = "OPENROUTER_WORK_API_KEY"
2600"##,
2601        ));
2602
2603        let s = state_with("zk2", "ok2", VendorId::Openrouter);
2604        save_to_path(&s, &path).unwrap();
2605
2606        let raw = std::fs::read_to_string(&path).unwrap();
2607        assert!(raw.contains("# my comment"));
2608        assert!(raw.contains("# pre-existing comment"));
2609        assert!(raw.contains("# tier comment"));
2610        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2611        assert!(raw.contains("[[openrouter.accounts]]"));
2612        assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2613        assert!(raw.contains("plan_tier = \"pro\""));
2614        assert!(raw.contains("primary = \"openrouter\""));
2615        assert!(raw.contains("api_key = \"zk2\""));
2616        assert!(raw.contains("api_key = \"ok2\""));
2617    }
2618
2619    #[test]
2620    fn save_refuses_to_replace_an_unreadable_existing_config() {
2621        let (_dir, path) = temp_config(None);
2622        let original = [0xff, 0xfe, 0xfd];
2623        std::fs::write(&path, original).unwrap();
2624        let state = state_with("new-secret", "", VendorId::Zai);
2625
2626        assert!(save_to_path(&state, &path).is_err());
2627        assert_eq!(std::fs::read(&path).unwrap(), original);
2628    }
2629
2630    #[test]
2631    fn save_does_not_write_empty_key_when_dirty_but_blank() {
2632        let (_dir, path) = temp_config(None);
2633        let mut s = blank_state(VendorId::Anthropic);
2634        // Focus each key, do nothing but mark dirty (blank).
2635        for k in &mut s.keys {
2636            k.dirty = true;
2637        }
2638        save_to_path(&s, &path).unwrap();
2639        let raw = std::fs::read_to_string(&path).unwrap();
2640        assert!(!raw.contains("api_key ="));
2641    }
2642
2643    #[test]
2644    #[cfg(unix)]
2645    fn save_chmods_to_600() {
2646        use std::os::unix::fs::PermissionsExt;
2647        let (_dir, path) = temp_config(None);
2648        let s = state_with("zk", "ok", VendorId::Zai);
2649        save_to_path(&s, &path).unwrap();
2650        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2651        assert_eq!(mode & 0o777, 0o600);
2652    }
2653
2654    #[test]
2655    fn key_vendor_for_slug_finds_only_the_key_table() {
2656        // Every key vendor resolves by slug...
2657        for vendor in KEY_VENDORS {
2658            assert_eq!(
2659                key_vendor_for_slug(vendor.id.slug()).map(|kv| kv.id),
2660                Some(vendor.id),
2661                "{} must resolve",
2662                vendor.id.slug()
2663            );
2664        }
2665        // ...and no vendor outside the table does: the OAuth vendors' slugs
2666        // and the custom ids have no credential field to fill.
2667        for id in VendorId::all() {
2668            if KEY_VENDORS.iter().any(|kv| kv.id == *id) {
2669                continue;
2670            }
2671            assert!(key_vendor_for_slug(id.slug()).is_none(), "{:?}", id);
2672        }
2673        assert!(key_vendor_for_slug("no-such-vendor").is_none());
2674    }
2675
2676    #[test]
2677    fn validate_key_value_refuses_empty_long_and_controlled() {
2678        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2679        assert!(validate_key_value(vendor, "test-key-value").is_ok());
2680        let empty = validate_key_value(vendor, "");
2681        assert!(empty.is_err());
2682        assert!(empty.unwrap_err().to_string().contains("is empty"));
2683        let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2684        let over = validate_key_value(vendor, &long);
2685        assert!(over.is_err());
2686        assert!(
2687            over.unwrap_err()
2688                .to_string()
2689                .contains(&format!("exceeds {MAX_API_KEY_BYTES}"))
2690        );
2691        let controlled = validate_key_value(vendor, "a\u{1b}[31mb");
2692        assert!(controlled.is_err());
2693        assert!(
2694            controlled
2695                .unwrap_err()
2696                .to_string()
2697                .contains("control characters")
2698        );
2699        // The apply request enforces the same rule through the same code:
2700        // one accepted by the helper must be accepted by the request, and
2701        // one refused by the helper must be refused there.
2702        let cfg = crate::config::Config::default();
2703        let bad = serde_json::json!({
2704            "schema_version": 1,
2705            "keys": { VendorId::Zai.slug(): { "action": "set", "value": "a\u{1b}[31mb" } }
2706        })
2707        .to_string();
2708        assert!(state_from_apply_request(&cfg, &bad).is_err());
2709        let good = serde_json::json!({
2710            "schema_version": 1,
2711            "keys": { VendorId::Zai.slug(): { "action": "set", "value": "test-key-value" } }
2712        })
2713        .to_string();
2714        assert!(state_from_apply_request(&cfg, &good).is_ok());
2715    }
2716
2717    #[test]
2718    fn inline_key_write_leaves_the_value_in_the_right_section() {
2719        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2720        let (_dir, path) = temp_config(Some("# keep me\n[ui]\nprimary = \"zai\"\n"));
2721        write_inline_key(&path, vendor, "test-key-value").unwrap();
2722        let raw = std::fs::read_to_string(&path).unwrap();
2723        assert!(raw.contains("[zai]"), "{raw}");
2724        assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2725        // A non-empty credential opts the vendor in, as the overlay does.
2726        assert!(raw.contains("enabled = true"), "{raw}");
2727        // And everything else of the config survives.
2728        assert!(raw.contains("# keep me"), "{raw}");
2729        assert!(raw.contains(r#"primary = "zai""#), "{raw}");
2730        // The file is back to a parseable config with the value captured.
2731        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2732        assert!(snapshot.key.is_some());
2733        assert!(snapshot.enabled.is_some());
2734    }
2735
2736    #[test]
2737    #[cfg(unix)]
2738    fn inline_key_write_chmods_the_config_to_600() {
2739        use std::os::unix::fs::PermissionsExt;
2740        let vendor = key_vendor_for_slug(VendorId::Openrouter.slug()).unwrap();
2741        let (_dir, path) = temp_config(None);
2742        write_inline_key(&path, vendor, "test-key-value").unwrap();
2743        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2744        assert_eq!(mode & 0o777, 0o600);
2745    }
2746
2747    #[test]
2748    fn inline_key_write_refuses_invalid_values_without_touching_the_file() {
2749        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2750        let original = "[zai]\napi_key = \"old-key-value\"\n";
2751        let (_dir, path) = temp_config(Some(original));
2752        let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2753        for bad in ["", long.as_str(), "a\u{1b}[31mb"] {
2754            assert!(write_inline_key(&path, vendor, bad).is_err(), "{bad:?}");
2755            assert_eq!(
2756                std::fs::read_to_string(&path).unwrap(),
2757                original,
2758                "the file must not move for {bad:?}"
2759            );
2760        }
2761        // A missing config is created only by a valid value.
2762        let dir = tempfile::tempdir().unwrap();
2763        let empty = dir.path().join("config.toml");
2764        assert!(write_inline_key(&empty, vendor, "").is_err());
2765        assert!(!empty.exists());
2766    }
2767
2768    #[test]
2769    fn inline_key_snapshot_restores_the_exact_previous_state() {
2770        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2771        // An old key with the vendor off, and a comment on the key's line:
2772        // the restore must give all of it back, byte for byte.
2773        let original = "[ui]\nprimary = \"zai\"\n[zai]\n# my old key\napi_key = \"old-key-value\"\nenabled = false\n";
2774        let (_dir, path) = temp_config(Some(original));
2775        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2776        assert!(snapshot.file_existed);
2777        assert!(snapshot.section_existed);
2778        write_inline_key(&path, vendor, "test-key-value").unwrap();
2779        assert_ne!(std::fs::read_to_string(&path).unwrap(), original);
2780        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2781        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2782    }
2783
2784    #[test]
2785    fn inline_key_snapshot_restore_keeps_a_later_user_decision() {
2786        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2787        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2788        let (_dir, path) = temp_config(Some(original));
2789        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2790        write_inline_key(&path, vendor, "test-key-value").unwrap();
2791        // While the check waits: the user saves another key and switches
2792        // the vendor off, with the real writers.
2793        write_inline_key(&path, vendor, "another-key-value").unwrap();
2794        crate::config::set_vendor_enabled(&path, vendor.id.slug(), false).unwrap();
2795        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2796        let raw = std::fs::read_to_string(&path).unwrap();
2797        // Both fields hold the user's later decisions, not the snapshot.
2798        assert!(raw.contains(r#"api_key = "another-key-value""#), "{raw}");
2799        assert!(raw.contains("enabled = false"), "{raw}");
2800        assert!(!raw.contains("test-key-value"), "{raw}");
2801        assert!(!raw.contains("old-key-value"), "{raw}");
2802    }
2803
2804    #[test]
2805    fn saved_inline_key_counts_an_empty_field_as_absent() {
2806        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2807        // The catalog's rule: `Config::inline_api_key` filters the empty
2808        // string, so a cleared field offers the key dialog again.
2809        let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\n"));
2810        assert!(saved_inline_key(&snapshot_inline_key(&path, vendor).unwrap()).is_none());
2811        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"k\"\n"));
2812        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2813        assert_eq!(saved_inline_key(&snapshot).as_deref(), Some("k"));
2814    }
2815
2816    #[test]
2817    fn check_write_refuses_a_saved_nonempty_key() {
2818        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2819        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2820        let (_dir, path) = temp_config(Some(original));
2821        let error = match snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value") {
2822            Ok(_) => panic!("a saved key must not be replaced by the check's write"),
2823            Err(error) => error,
2824        };
2825        assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2826        // The refusal is inside the locked read-modify-write: nothing was
2827        // written.
2828        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2829    }
2830
2831    #[test]
2832    fn check_write_accepts_an_empty_field_and_returns_the_pre_state() {
2833        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2834        let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\nenabled = false\n"));
2835        // The catalog offers the dialog for this file, so the conditional
2836        // write must too: the empty value counts as absent.
2837        let snapshot =
2838            snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value").unwrap();
2839        assert!(snapshot.file_existed);
2840        assert!(snapshot.section_existed);
2841        // The snapshot is the state before the write: the empty field, and
2842        // the vendor still off — what a later restore compares against.
2843        assert_eq!(
2844            saved_inline_key(&snapshot),
2845            None,
2846            "an empty field is no credential, even when captured"
2847        );
2848        let raw = std::fs::read_to_string(&path).unwrap();
2849        assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2850        assert!(raw.contains("enabled = true"), "{raw}");
2851    }
2852
2853    /// F6's scenario under the lock: the TUI's save and the check's
2854    /// conditional write both wait on the lock the test holds from the
2855    /// outside, and once it is released they run one after the other — in
2856    /// either order the key the TUI saved is never overwritten by the
2857    /// candidate.
2858    #[test]
2859    fn tui_save_and_check_write_serialize_on_the_config_lock() {
2860        use std::sync::mpsc;
2861        let (dir, path) = temp_config(Some("[ui]\nprimary = \"zai\"\n"));
2862        let _ = dir;
2863        let lock_path = crate::config::config_lock_path(&path);
2864
2865        // Both real writers, queued on the lock the test holds: the TUI's
2866        // `save_to_path` and the check's locked snapshot—write.
2867        let waiters =
2868            |path: &std::path::Path, tx: mpsc::Sender<()>, tx_check: mpsc::Sender<Result<()>>| {
2869                let tui_path = path.to_path_buf();
2870                let tui = std::thread::spawn(move || {
2871                    let state = state_with("tui-key-value", "openrouter-key-value", VendorId::Zai);
2872                    tx.send(()).unwrap();
2873                    save_to_path(&state, &tui_path).unwrap();
2874                });
2875                let check_path = path.to_path_buf();
2876                let check = std::thread::spawn(move || {
2877                    let vendor = key_vendor_for_slug("zai").unwrap();
2878                    tx_check
2879                        .send(
2880                            snapshot_and_write_inline_key_if_absent(
2881                                &check_path,
2882                                vendor,
2883                                "candidate-key-value",
2884                            )
2885                            .map(|_| ()),
2886                        )
2887                        .unwrap();
2888                });
2889                (tui, check)
2890            };
2891
2892        // Round 1: no key is saved yet. Both writers wait, the lock is
2893        // released, and whatever order the lock grants, the TUI's key is
2894        // what is left — the candidate either refused or got replaced by
2895        // the TUI's later save.
2896        let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2897            .expect("the test takes the config lock");
2898        let (tx, rx) = mpsc::channel();
2899        let (tx_check, rx_check) = mpsc::channel();
2900        let (tui, check) = waiters(&path, tx, tx_check);
2901        rx.recv().unwrap();
2902        drop(held);
2903        let check_result = rx_check.recv().unwrap();
2904        tui.join().unwrap();
2905        check.join().unwrap();
2906        let raw = std::fs::read_to_string(&path).unwrap();
2907        assert!(
2908            raw.contains(r#"api_key = "tui-key-value""#),
2909            "the TUI's key must survive: {raw}"
2910        );
2911        assert!(
2912            !raw.contains("candidate-key-value"),
2913            "the candidate must not overwrite the TUI's key: {raw}"
2914        );
2915        if let Err(error) = check_result {
2916            // The TUI's save ran first: the check saw its key and refused
2917            // with the existing saved-key message.
2918            assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2919        }
2920
2921        // Round 2: the TUI's key is saved now. The same two writers wait
2922        // again; whatever order the lock grants, the check must refuse and
2923        // the file must not change.
2924        let before = std::fs::read_to_string(&path).unwrap();
2925        let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2926            .expect("the test takes the config lock again");
2927        let (tx, rx) = mpsc::channel();
2928        let (tx_check, rx_check) = mpsc::channel();
2929        let (tui, check) = waiters(&path, tx, tx_check);
2930        rx.recv().unwrap();
2931        drop(held);
2932        let error = rx_check
2933            .recv()
2934            .unwrap()
2935            .expect_err("a saved key must refuse the candidate");
2936        tui.join().unwrap();
2937        check.join().unwrap();
2938        assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2939        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2940    }
2941    #[test]
2942    fn inline_key_snapshot_removes_the_key_when_there_was_none() {
2943        let vendor = key_vendor_for_slug(VendorId::Deepseek.slug()).unwrap();
2944        let original = "[ui]\nprimary = \"deepseek\"\n";
2945        let (_dir, path) = temp_config(Some(original));
2946        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2947        assert!(!snapshot.section_existed);
2948        write_inline_key(&path, vendor, "test-key-value").unwrap();
2949        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2950        let raw = std::fs::read_to_string(&path).unwrap();
2951        // The key is gone, the section the check created is gone, and
2952        // everything else of the file is intact.
2953        assert!(!raw.contains("api_key"), "the key must be gone: {raw}");
2954        assert!(
2955            !raw.contains("[deepseek]"),
2956            "the section must be gone: {raw}"
2957        );
2958        assert!(raw.contains(r#"primary = "deepseek""#), "{raw}");
2959    }
2960
2961    #[test]
2962    fn inline_key_snapshot_of_a_missing_file_removes_it_on_restore() {
2963        let vendor = key_vendor_for_slug(VendorId::Kimi.slug()).unwrap();
2964        let (dir, path) = temp_config(None);
2965        let _ = dir;
2966        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2967        assert!(!snapshot.file_existed);
2968        assert!(!snapshot.section_existed);
2969        assert!(snapshot.key.is_none());
2970        assert!(snapshot.enabled.is_none());
2971        write_inline_key(&path, vendor, "test-key-value").unwrap();
2972        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2973        assert!(!path.exists(), "the file the check created must be gone");
2974    }
2975
2976    #[test]
2977    fn inline_key_snapshot_keeps_edits_made_elsewhere_in_the_meantime() {
2978        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2979        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2980        let (_dir, path) = temp_config(Some(original));
2981        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2982        write_inline_key(&path, vendor, "test-key-value").unwrap();
2983        // An edit the check did not make: a new field in the same section.
2984        let mut doc = crate::config::read_config_document(&path).unwrap();
2985        set_string(&mut doc, "zai", "api_key_env", "SOME_ENV_VAR").unwrap();
2986        crate::config::write_config_document(&path, &doc).unwrap();
2987        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2988        let raw = std::fs::read_to_string(&path).unwrap();
2989        // The snapshot's state is back...
2990        assert!(raw.contains(r#"api_key = "old-key-value""#), "{raw}");
2991        // ...and the foreign edit survived, untouched by the snapshot.
2992        assert!(raw.contains(r#"api_key_env = "SOME_ENV_VAR""#), "{raw}");
2993    }
2994
2995    #[test]
2996    fn tab_cycles_focus_from_primary_to_first_key() {
2997        let mut s = blank_state(VendorId::Anthropic);
2998        assert_eq!(
2999            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
3000            Action::Continue
3001        );
3002        assert_eq!(s.focus, Focus::Key(0));
3003        assert_eq!(
3004            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
3005            Action::Continue
3006        );
3007        assert_eq!(s.focus, Focus::Primary);
3008    }
3009
3010    #[test]
3011    fn esc_closes_without_saving() {
3012        let mut s = blank_state(VendorId::Anthropic);
3013        assert_eq!(
3014            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3015            Action::Close
3016        );
3017    }
3018
3019    #[test]
3020    fn left_right_cycles_primary_vendor() {
3021        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
3022        let mut s = blank_state(VendorId::Anthropic);
3023        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3024        assert_eq!(s.primary, VendorId::AnthropicApi);
3025        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3026        assert_eq!(s.primary, VendorId::Openai);
3027        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3028        assert_eq!(s.primary, VendorId::AnthropicApi);
3029    }
3030
3031    #[test]
3032    fn left_right_offers_enabled_vendors_only() {
3033        // The selector must never land on a vendor the widget cannot use.
3034        let mut s = blank_state(VendorId::Anthropic);
3035        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
3036        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3037        assert_eq!(s.primary, VendorId::Grok);
3038        // Wraps within the enabled set rather than walking into disabled ones.
3039        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3040        assert_eq!(s.primary, VendorId::Anthropic);
3041        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3042        assert_eq!(s.primary, VendorId::Grok);
3043    }
3044
3045    #[test]
3046    fn no_enabled_vendors_leaves_primary_selector_inert() {
3047        let mut s = blank_state(VendorId::Anthropic);
3048        s.primary_choices = vec![];
3049        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3050        assert_eq!(s.primary, VendorId::Anthropic);
3051    }
3052
3053    #[test]
3054    fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
3055        let mut cfg = Config::default();
3056        cfg.ui.primary = Some(VendorId::Copilot);
3057        let state = SettingsState::from_config(&cfg);
3058
3059        assert!(state.primary_choices.contains(&VendorId::Copilot));
3060        assert_eq!(state.primary, VendorId::Anthropic);
3061    }
3062
3063    #[test]
3064    fn save_does_not_write_a_disabled_primary() {
3065        // Saving an API key must not persist a primary the resolver would
3066        // ignore; an existing value in the file stays untouched.
3067        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
3068        let mut s = state_with("zk", "ok", VendorId::Grok);
3069        s.primary_choices = vec![VendorId::Anthropic];
3070        save_to_path(&s, &path).unwrap();
3071        let raw = std::fs::read_to_string(&path).unwrap();
3072        assert!(raw.contains("primary = \"anthropic\""));
3073        assert!(!raw.contains("primary = \"grok\""));
3074        // The keys still saved.
3075        assert!(raw.contains("zk"));
3076    }
3077
3078    #[test]
3079    fn save_removes_an_inline_key_the_user_cleared() {
3080        // Clearing the field in the overlay must delete the secret from the
3081        // file — otherwise there is no way to remove it short of hand-editing.
3082        let (_dir, path) = temp_config(Some(
3083            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
3084        ));
3085        let mut s = blank_state(VendorId::Zai);
3086        s.primary_choices = vec![VendorId::Zai];
3087        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
3088        s.keys[key_index(VendorId::Zai)].dirty = true;
3089        save_to_path(&s, &path).unwrap();
3090        let raw = std::fs::read_to_string(&path).unwrap();
3091        assert!(!raw.contains("old-secret"));
3092        assert!(!raw.contains("api_key"));
3093        // Unrelated fields in the same section survive.
3094        assert!(raw.contains("plan_tier = \"pro\""));
3095    }
3096
3097    #[test]
3098    fn save_clears_a_key_from_a_toml_inline_table() {
3099        let (_dir, path) = temp_config(Some(
3100            "zai = { enabled = false, api_key = \"remove-me\", plan_tier = \"pro\" } # keep me\n\
3101             [ui]\nprimary = \"anthropic\"\n",
3102        ));
3103        let cfg = Config::load_from(&path).unwrap();
3104        let mut state = SettingsState::from_config_with(&cfg, |_| false);
3105        let input = &mut state.keys[key_index(VendorId::Zai)];
3106        *input = KeyInput::default();
3107        input.dirty = true;
3108
3109        save_to_path(&state, &path).unwrap();
3110
3111        let reloaded = Config::load_from(&path).unwrap();
3112        assert!(reloaded.zai.api_key.is_none());
3113        assert!(!reloaded.zai.enabled);
3114        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3115        let raw = std::fs::read_to_string(&path).unwrap();
3116        assert!(!raw.contains("remove-me"));
3117        assert!(raw.contains("zai = {"));
3118        assert!(raw.contains("# keep me"));
3119    }
3120
3121    #[test]
3122    fn save_updates_a_key_and_enables_a_toml_inline_table() {
3123        let (_dir, path) = temp_config(Some(
3124            "zai = { enabled = false, api_key = \"old-secret\", plan_tier = \"pro\" } # keep me\n\
3125             [ui]\nprimary = \"anthropic\"\n",
3126        ));
3127        let cfg = Config::load_from(&path).unwrap();
3128        let mut state = SettingsState::from_config_with(&cfg, |_| false);
3129        let input = &mut state.keys[key_index(VendorId::Zai)];
3130        input.buf = "new-secret".into();
3131        input.dirty = true;
3132
3133        save_to_path(&state, &path).unwrap();
3134
3135        let reloaded = Config::load_from(&path).unwrap();
3136        assert_eq!(reloaded.zai.api_key.as_deref(), Some("new-secret"));
3137        assert!(reloaded.zai.enabled);
3138        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3139        let raw = std::fs::read_to_string(&path).unwrap();
3140        assert!(!raw.contains("old-secret"));
3141        assert!(raw.contains("new-secret"));
3142        assert!(raw.contains("zai = {"));
3143        assert!(raw.contains("# keep me"));
3144    }
3145
3146    #[test]
3147    fn enable_vendor_at_enables_a_toml_inline_table() {
3148        let (_dir, path) = temp_config(Some(
3149            "zai = { enabled = false, plan_tier = \"pro\" } # keep me\n\
3150             [ui]\nprimary = \"anthropic\"\n",
3151        ));
3152        enable_vendor_at(&path, VendorId::Zai).unwrap();
3153
3154        let reloaded = Config::load_from(&path).unwrap();
3155        assert!(reloaded.zai.enabled);
3156        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3157        let raw = std::fs::read_to_string(&path).unwrap();
3158        assert!(raw.contains("zai = {"));
3159        assert!(raw.contains("# keep me"));
3160    }
3161
3162    #[test]
3163    fn untouched_key_field_is_left_alone() {
3164        // Not dirty => the file's existing secret must survive a save.
3165        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
3166        let mut s = blank_state(VendorId::Zai);
3167        s.primary_choices = vec![VendorId::Zai];
3168        save_to_path(&s, &path).unwrap();
3169        let raw = std::fs::read_to_string(&path).unwrap();
3170        assert!(raw.contains("keep-me"));
3171    }
3172
3173    #[test]
3174    fn typing_edits_the_focused_key_only() {
3175        let mut s = blank_state(VendorId::Anthropic);
3176        s.focus = Focus::Key(key_index(VendorId::Grok));
3177        for c in "xai-abc".chars() {
3178            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
3179        }
3180        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
3181        assert!(s.keys[key_index(VendorId::Grok)].dirty);
3182        // No other field was touched.
3183        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
3184    }
3185
3186    #[test]
3187    fn ctrl_v_toggles_reveal_on_focused_key_field() {
3188        let mut s = blank_state(VendorId::Anthropic);
3189        let zi = key_index(VendorId::Zai);
3190        s.focus = Focus::Key(zi);
3191        s.keys[zi] = KeyInput::from_config(Some("secret"));
3192        assert!(!s.keys[zi].revealed);
3193        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3194        assert!(s.keys[zi].revealed);
3195        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3196        assert!(!s.keys[zi].revealed);
3197    }
3198
3199    #[test]
3200    fn control_chorded_chars_do_not_type_into_fields() {
3201        let mut s = blank_state(VendorId::Anthropic);
3202        s.focus = Focus::Key(0);
3203        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
3204        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
3205        assert!(s.keys[0].buf.is_empty());
3206        assert!(!s.keys[0].dirty);
3207        // Ctrl-C quits the host TUI even while the overlay owns focus.
3208        assert_eq!(
3209            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3210            Action::Quit
3211        );
3212        // A plain char still types normally.
3213        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
3214        assert_eq!(s.keys[0].buf, "x");
3215    }
3216
3217    #[test]
3218    fn ctrl_v_on_non_key_focus_is_noop() {
3219        let mut s = blank_state(VendorId::Anthropic);
3220        s.focus = Focus::Primary;
3221        // Must not panic when no key field is focused.
3222        assert_eq!(
3223            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
3224            Action::Continue
3225        );
3226    }
3227
3228    fn state_focused_on_zai() -> SettingsState {
3229        let mut state = blank_state(VendorId::Anthropic);
3230        state.focus = Focus::Key(key_index(VendorId::Zai));
3231        state
3232    }
3233
3234    #[test]
3235    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
3236        let mut s = state_focused_on_zai();
3237        let zi = key_index(VendorId::Zai);
3238        assert_eq!(
3239            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3240            Action::Quit
3241        );
3242        assert!(s.keys[zi].buf.is_empty());
3243        // Untouched means save still leaves an existing key on disk alone.
3244        assert!(!s.keys[zi].dirty);
3245    }
3246
3247    #[test]
3248    fn handle_key_alt_chord_does_not_type_into_key_field() {
3249        let mut s = state_focused_on_zai();
3250        let zi = key_index(VendorId::Zai);
3251        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
3252        assert!(s.keys[zi].buf.is_empty());
3253        assert!(!s.keys[zi].dirty);
3254    }
3255
3256    #[test]
3257    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
3258        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
3259            let mut s = state_focused_on_zai();
3260            let zi = key_index(VendorId::Zai);
3261            handle_key(&mut s, KeyCode::Char('x'), modifier);
3262            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
3263            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
3264        }
3265    }
3266
3267    #[test]
3268    fn handle_key_shift_still_types_uppercase() {
3269        let mut s = state_focused_on_zai();
3270        let zi = key_index(VendorId::Zai);
3271        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
3272        assert_eq!(s.keys[zi].buf, "A");
3273        assert!(s.keys[zi].dirty);
3274    }
3275
3276    #[test]
3277    fn handle_key_plain_space_still_cycles_primary_vendor() {
3278        let mut s = blank_state(VendorId::Anthropic);
3279        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
3280        assert_eq!(s.primary, VendorId::AnthropicApi);
3281    }
3282
3283    #[test]
3284    fn handle_key_ctrl_s_attempts_save_from_any_field() {
3285        let (_dir, path) = temp_config(None);
3286        let s = state_with("zk", "ok", VendorId::Zai);
3287        save_to_path(&s, &path).unwrap();
3288        let raw = std::fs::read_to_string(&path).unwrap();
3289        assert!(raw.contains("api_key = \"zk\""));
3290    }
3291    #[test]
3292    fn save_to_path_writes_kimi_key_when_dirty() {
3293        let (_dir, path) = temp_config(None);
3294        let mut s = blank_state(VendorId::Anthropic);
3295        let kimi = key_index(VendorId::Kimi);
3296        s.keys[kimi] = KeyInput::from_config(Some("kk"));
3297        s.keys[kimi].dirty = true;
3298        save_to_path(&s, &path).unwrap();
3299        let raw = std::fs::read_to_string(&path).unwrap();
3300        assert!(raw.contains("[kimi]"));
3301        assert!(raw.contains("api_key = \"kk\""));
3302    }
3303
3304    #[test]
3305    fn settings_save_uses_the_same_config_path_as_load() {
3306        assert_eq!(
3307            default_config_path().unwrap(),
3308            crate::config::resolved_path().unwrap()
3309        );
3310    }
3311
3312    #[test]
3313    fn native_snapshot_reports_key_state_without_serializing_secrets() {
3314        let mut cfg = Config::default();
3315        cfg.zai.api_key = Some("never-leak-this-key".into());
3316        cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
3317        let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
3318        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3319
3320        assert_eq!(parsed["schema_version"], 1);
3321        assert_eq!(parsed["primary"], "anthropic");
3322        let zai = parsed["keys"]
3323            .as_array()
3324            .unwrap()
3325            .iter()
3326            .find(|row| row["id"] == "zai")
3327            .unwrap();
3328        assert_eq!(zai["configured"], true);
3329        assert_eq!(zai["inline_configured"], true);
3330        assert_eq!(zai["environment_configured"], true);
3331        assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
3332        assert!(!raw.contains("never-leak-this-key"));
3333        assert!(parsed.get("api_key").is_none());
3334    }
3335
3336    #[test]
3337    fn native_snapshot_offers_copilot_primary_without_a_token_field() {
3338        let cfg = Config::default();
3339        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3340        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3341        assert!(
3342            parsed["primary_choices"]
3343                .as_array()
3344                .unwrap()
3345                .iter()
3346                .any(|row| row["id"] == "copilot")
3347        );
3348        assert!(
3349            !parsed["keys"]
3350                .as_array()
3351                .unwrap()
3352                .iter()
3353                .any(|row| row["id"] == "copilot")
3354        );
3355    }
3356
3357    #[test]
3358    fn native_key_only_patch_does_not_require_or_replace_primary() {
3359        let cfg = Config::default();
3360        let original_primary = SettingsState::from_config(&cfg).primary;
3361        let request = serde_json::json!({
3362            "schema_version": 1,
3363            "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
3364        });
3365
3366        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3367        assert_eq!(state.primary, original_primary);
3368        let kimi_index = KEY_VENDORS
3369            .iter()
3370            .position(|vendor| vendor.id == VendorId::Kimi)
3371            .unwrap();
3372        assert!(state.keys[kimi_index].dirty);
3373        assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
3374    }
3375
3376    #[test]
3377    fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
3378        let (_dir, path) = temp_config(Some(
3379            r#"# keep this comment
3380[ui]
3381primary = "anthropic"
3382
3383[zai]
3384enabled = true
3385api_key_env = "ZAI_API_KEY"
3386plan_tier = "pro"
3387
3388[openrouter]
3389enabled = true
3390"#,
3391        ));
3392        let cfg = Config::load_from(&path).unwrap();
3393        let request = serde_json::json!({
3394            "schema_version": 1,
3395            "primary": "openrouter",
3396            "keys": {
3397                "zai": {"action": "set", "value": "new-zai-key"}
3398            }
3399        });
3400
3401        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3402        let raw = std::fs::read_to_string(&path).unwrap();
3403        assert!(raw.contains("# keep this comment"));
3404        assert!(raw.contains("plan_tier = \"pro\""));
3405        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
3406        assert!(raw.contains("primary = \"openrouter\""));
3407        assert!(raw.contains("api_key = \"new-zai-key\""));
3408    }
3409
3410    #[test]
3411    fn native_patch_distinguishes_clear_from_unchanged() {
3412        let (_dir, path) = temp_config(Some(
3413            "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
3414             [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
3415        ));
3416        let cfg = Config::load_from(&path).unwrap();
3417        let request = serde_json::json!({
3418            "schema_version": 1,
3419            "primary": "zai",
3420            "keys": {"zai": {"action": "clear"}}
3421        });
3422
3423        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3424        let raw = std::fs::read_to_string(&path).unwrap();
3425        assert!(!raw.contains("remove-me"));
3426        assert!(raw.contains("keep-me"));
3427    }
3428
3429    #[test]
3430    fn native_primary_selection_enables_copilot_without_writing_a_token() {
3431        let (_dir, path) = temp_config(Some(
3432            "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
3433        ));
3434        let cfg = Config::load_from(&path).unwrap();
3435        let select = serde_json::json!({
3436            "schema_version": 1,
3437            "primary": "copilot"
3438        });
3439        apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
3440        let raw = std::fs::read_to_string(&path).unwrap();
3441        assert!(raw.contains("enabled = true"));
3442        assert!(raw.contains("primary = \"copilot\""));
3443        assert!(!raw.contains("token ="));
3444        assert!(!raw.contains("token_env ="));
3445    }
3446
3447    #[test]
3448    fn native_patch_errors_never_echo_key_values() {
3449        let raw = serde_json::json!({
3450            "schema_version": 1,
3451            "primary": "anthropic",
3452            "keys": {
3453                "zai": {"action": "set", "value": "secret\nwith-control"}
3454            }
3455        })
3456        .to_string();
3457        let error = state_from_apply_request(&Config::default(), &raw)
3458            .unwrap_err()
3459            .to_string();
3460        assert!(!error.contains("secret"));
3461        assert!(error.contains("control characters"));
3462    }
3463
3464    #[test]
3465    fn native_patch_input_is_bounded_before_json_parsing() {
3466        let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
3467        let error = read_settings_request(std::io::Cursor::new(oversized))
3468            .unwrap_err()
3469            .to_string();
3470        assert!(error.contains("exceeds"));
3471    }
3472
3473    // ─── Provider on/off switches (#244) ─────────────────────────────────────
3474
3475    #[test]
3476    fn native_snapshot_lists_every_provider_with_its_enabled_state() {
3477        let mut cfg = Config::default();
3478        cfg.grok.enabled = true;
3479        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3480        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3481
3482        let vendors = parsed["vendors"].as_array().unwrap();
3483        assert_eq!(vendors.len(), VendorId::all().len());
3484        let by_id = |slug: &str| {
3485            vendors
3486                .iter()
3487                .find(|row| row["id"] == slug)
3488                .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
3489                .clone()
3490        };
3491        assert_eq!(by_id("anthropic")["enabled"], true);
3492        assert_eq!(by_id("grok")["enabled"], true);
3493        assert_eq!(by_id("kimi")["enabled"], false);
3494        // Labels come from the shared display-name source, not a second table.
3495        assert_eq!(by_id("anthropic")["label"], "Claude");
3496        assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
3497    }
3498
3499    #[test]
3500    fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
3501        let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
3502        let cfg = Config::load_from(&path).unwrap();
3503        let request = serde_json::json!({
3504            "schema_version": 1,
3505            "vendors": {"grok": true, "zai": false}
3506        });
3507
3508        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3509
3510        let raw = std::fs::read_to_string(&path).unwrap();
3511        assert!(raw.starts_with("# keep\n"), "{raw}");
3512        assert!(raw.contains("api_key = \"k\""), "{raw}");
3513        let reloaded = Config::load_from(&path).unwrap();
3514        assert!(reloaded.is_enabled(VendorId::Grok));
3515        assert!(!reloaded.is_enabled(VendorId::Zai));
3516    }
3517
3518    #[test]
3519    fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
3520        let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
3521        let cfg = Config::load_from(&path).unwrap();
3522        for bad in ["", "mytool", "not-a-vendor"] {
3523            let request = serde_json::json!({
3524                "schema_version": 1,
3525                "vendors": {bad: true}
3526            });
3527            let error = state_from_apply_request(&cfg, &request.to_string())
3528                .unwrap_err()
3529                .to_string();
3530            assert!(error.contains("unknown provider"), "{bad}: {error}");
3531        }
3532        // A rejected patch leaves the file untouched.
3533        assert_eq!(
3534            std::fs::read_to_string(&path).unwrap(),
3535            "[zai]\nenabled = true\n"
3536        );
3537    }
3538
3539    /// A vendor-toggle-only patch needs neither a primary nor a key change.
3540    #[test]
3541    fn native_vendor_toggle_alone_is_a_valid_patch() {
3542        let cfg = Config::default();
3543        let request = serde_json::json!({
3544            "schema_version": 1,
3545            "vendors": {"kimi": true}
3546        });
3547        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3548        let kimi = vendor_index(VendorId::Kimi);
3549        assert_eq!(
3550            state.vendors[kimi],
3551            ProviderSwitch {
3552                enabled: true,
3553                dirty: true
3554            }
3555        );
3556    }
3557
3558    // ─── Overlay scroll (#244: the body outgrew small terminals) ────────────
3559
3560    #[test]
3561    fn body_layout_positions_every_focusable_row_without_overlap() {
3562        let keys = 5;
3563        let vendors = 7;
3564        let layout = body_layout(keys, vendors);
3565        // Credentials: header at 3, rows 4..9, blank 9.
3566        assert_eq!(layout.primary, 1);
3567        assert_eq!(layout.first_key, 4);
3568        // Providers: blank after keys, header, then rows.
3569        assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
3570        assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
3571        assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
3572        assert_eq!(layout.save, layout.notify_threshold + 2);
3573        assert_eq!(layout.rows, layout.save + 1);
3574        // No two focusable rows share a line.
3575        let mut rows = vec![
3576            layout.primary,
3577            layout.notify_enabled,
3578            layout.notify_threshold,
3579            layout.save,
3580        ];
3581        rows.extend(layout.first_key..layout.first_key + keys);
3582        rows.extend(layout.first_vendor..layout.first_vendor + vendors);
3583        rows.sort_unstable();
3584        let mut unique = rows.clone();
3585        unique.dedup();
3586        assert_eq!(rows, unique);
3587        assert!(rows.iter().all(|row| *row < layout.rows));
3588    }
3589
3590    #[test]
3591    fn focus_line_matches_the_body_layout_for_every_focus_variant() {
3592        let state = blank_state(VendorId::Anthropic);
3593        let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
3594        let cases = [
3595            (Focus::Primary, layout.primary),
3596            (Focus::Key(0), layout.first_key),
3597            (
3598                Focus::Key(KEY_VENDORS.len() - 1),
3599                layout.first_key + KEY_VENDORS.len() - 1,
3600            ),
3601            (Focus::Vendor(0), layout.first_vendor),
3602            (
3603                Focus::Vendor(state.vendors.len() - 1),
3604                layout.first_vendor + state.vendors.len() - 1,
3605            ),
3606            (Focus::NotifyEnabled, layout.notify_enabled),
3607            (Focus::NotifyThreshold, layout.notify_threshold),
3608            (Focus::Save, layout.save),
3609        ];
3610        for (focus, want) in cases {
3611            let mut s = state.clone();
3612            s.focus = focus;
3613            assert_eq!(focus_line(&s), want, "{focus:?}");
3614        }
3615    }
3616
3617    #[test]
3618    fn follow_focus_scroll_sticks_jumps_and_clamps() {
3619        // Already visible: unchanged.
3620        assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
3621        assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
3622        // Below the viewport: pinned to the bottom edge.
3623        assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
3624        assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
3625        // Above the window: jumps up to the focused line.
3626        assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
3627        // Never past the scrollable range.
3628        assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
3629        assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
3630        // Degenerate viewport/total: no scrolling.
3631        assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
3632        assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
3633    }
3634
3635    /// Actionable hint segments record `HintKey` click rects on the hint row;
3636    /// pure key hints (move/type/digits) never do.
3637    #[test]
3638    fn render_records_clickable_hint_links_for_the_focused_control() {
3639        use ratatui::Terminal;
3640        use ratatui::backend::TestBackend;
3641
3642        let theme = crate::theme::Theme::default();
3643        let mut state = blank_state(VendorId::Anthropic);
3644        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3645        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3646        let mut hits = Vec::new();
3647        terminal
3648            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3649            .unwrap();
3650
3651        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3652            .iter()
3653            .filter_map(|(row, _)| match row {
3654                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3655                _ => None,
3656            })
3657            .collect();
3658        // Toggle (space), save (^S) and close (esc) are links, recorded in
3659        // segment order on a Vendor row.
3660        assert_eq!(
3661            hint_keys,
3662            vec![
3663                (KeyCode::Char(' '), KeyModifiers::NONE),
3664                (KeyCode::Char('s'), KeyModifiers::CONTROL),
3665                (KeyCode::Esc, KeyModifiers::NONE),
3666            ]
3667        );
3668        // Every link lives on the single hint row, below the body.
3669        let ys: Vec<u16> = hits
3670            .iter()
3671            .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
3672            .map(|(_, rect)| rect.y)
3673            .collect();
3674        assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
3675
3676        // On the Primary row the change-vendor link is a Right key instead of
3677        // the toggle, and pure hints never become clickable anywhere.
3678        let mut state = blank_state(VendorId::Anthropic);
3679        state.focus = Focus::Primary;
3680        let mut hits = Vec::new();
3681        terminal
3682            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3683            .unwrap();
3684        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3685            .iter()
3686            .filter_map(|(row, _)| match row {
3687                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3688                _ => None,
3689            })
3690            .collect();
3691        assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3692        assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3693    }
3694
3695    /// Every provider row records a switch-cell click target right of the
3696    /// label columns, on the same row as its focus target; the quota-alerts
3697    /// row records one too.
3698    #[test]
3699    fn render_records_switch_cells_right_of_the_labels() {
3700        use ratatui::Terminal;
3701        use ratatui::backend::TestBackend;
3702
3703        let theme = crate::theme::Theme::default();
3704        let mut state = blank_state(VendorId::Anthropic);
3705        state.focus = Focus::Vendor(0);
3706        let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap();
3707        let mut hits = Vec::new();
3708        terminal
3709            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3710            .unwrap();
3711
3712        let focus_of = |i: usize| {
3713            hits.iter().find_map(|(row, rect)| match row {
3714                SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3715                _ => None,
3716            })
3717        };
3718        for (i, id) in VendorId::all().iter().enumerate() {
3719            let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3720            let switch = hits
3721                .iter()
3722                .find_map(|(row, rect)| match row {
3723                    SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3724                        Some(*rect)
3725                    }
3726                    _ => None,
3727                })
3728                .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3729            // The value cell starts after the 5-cell prefix + the padded
3730            // label (names past 11 columns push it right) and covers only
3731            // the rendered value — never the name tail or the trailing
3732            // empty row space.
3733            let expected_x =
3734                5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3735            let value_len = if state.vendors[i].enabled { 2 } else { 3 }; // "on" / "off"
3736            let focused = state.focus == Focus::Vendor(i);
3737            assert_eq!(switch.y, focus_rect.y);
3738            assert_eq!(switch.x, focus_rect.x + expected_x);
3739            assert_eq!(
3740                switch.width,
3741                (if focused { 6 } else { 2 }) + value_len,
3742                "rendered value segment"
3743            );
3744            assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3745        }
3746        // The quota-alerts row has a switch cell as well.
3747        assert!(hits.iter().any(|(row, _)| matches!(
3748            row,
3749            SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3750        )));
3751    }
3752
3753    /// The focused primary radio renders ◀ name ▶: those arrow cells become
3754    /// click targets that step the vendor radio both ways.
3755    #[test]
3756    fn render_records_primary_arrow_cells_when_focused() {
3757        use ratatui::Terminal;
3758        use ratatui::backend::TestBackend;
3759
3760        let theme = crate::theme::Theme::default();
3761        let mut state = blank_state(VendorId::Anthropic);
3762        state.focus = Focus::Primary;
3763        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3764        let mut hits = Vec::new();
3765        terminal
3766            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3767            .unwrap();
3768
3769        let focus_rect = hits
3770            .iter()
3771            .find_map(|(row, rect)| match row {
3772                SettingsRow::Focus(Focus::Primary) => Some(*rect),
3773                _ => None,
3774            })
3775            .expect("primary row has a focus target");
3776        let prev = hits.iter().find_map(|(row, rect)| match row {
3777            SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3778            _ => None,
3779        });
3780        let next = hits.iter().find_map(|(row, rect)| match row {
3781            SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3782            _ => None,
3783        });
3784        let prev = prev.expect("focused primary has a ◀ cell");
3785        let next = next.expect("focused primary has a ▶ cell");
3786        assert_eq!(prev.x, focus_rect.x + 5);
3787        assert_eq!(prev.width, 2);
3788        assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3789        assert_eq!(next.width, 2);
3790        assert_eq!(prev.y, next.y);
3791
3792        // Unfocused (arrows only render on the focused radio), the row shows
3793        // no switch cells: only the focus target exists.
3794        let mut state = blank_state(VendorId::Anthropic);
3795        state.focus = Focus::Vendor(0);
3796        let mut unfocused_hits = Vec::new();
3797        terminal
3798            .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3799            .unwrap();
3800        assert!(
3801            !unfocused_hits
3802                .iter()
3803                .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3804        );
3805    }
3806
3807    /// Regression: ▶ used to sit at 9 + the current name's width, so it
3808    /// moved on every cycle. The name pads to the widest choice now — the
3809    /// arrow columns are identical whichever vendor is picked.
3810    #[test]
3811    fn primary_arrow_cells_sit_at_fixed_columns() {
3812        use ratatui::Terminal;
3813        use ratatui::backend::TestBackend;
3814
3815        let theme = crate::theme::Theme::default();
3816        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3817        let mut arrow_x = |primary: VendorId| {
3818            let mut state = blank_state(primary);
3819            state.focus = Focus::Primary;
3820            let mut hits = Vec::new();
3821            terminal
3822                .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3823                .unwrap();
3824            hits.iter()
3825                .find_map(|(row, rect)| match row {
3826                    SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3827                    _ => None,
3828                })
3829                .expect("focused primary has a ▶ cell")
3830        };
3831        assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3832    }
3833
3834    /// The picker consumes keys while open: ↑/↓ move the cursor (wrapping),
3835    /// Enter/space select and close, Esc closes without selecting — and with
3836    /// the picker closed, Esc closes the modal again.
3837    #[test]
3838    fn picker_key_handling_selects_and_closes() {
3839        let mut s = blank_state(VendorId::Anthropic);
3840        s.focus = Focus::Primary;
3841        s.picker = Some(PrimaryPicker {
3842            cursor: 0,
3843            scroll: 0,
3844        });
3845        let count = s.primary_choices.len();
3846        assert!(count > 1);
3847
3848        assert_eq!(
3849            handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3850            Action::Continue
3851        );
3852        assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3853        // Wraps at the top.
3854        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3855        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3856        assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3857
3858        // Enter selects the cursor's choice and closes the popup.
3859        let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3860        assert_eq!(
3861            handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3862            Action::Continue
3863        );
3864        assert_eq!(s.primary, expected);
3865        assert!(s.picker.is_none());
3866
3867        // Esc closes the picker without selecting; the next Esc closes the
3868        // modal.
3869        s.picker = Some(PrimaryPicker {
3870            cursor: 2,
3871            scroll: 0,
3872        });
3873        assert_eq!(
3874            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3875            Action::Continue
3876        );
3877        assert!(s.picker.is_none());
3878        assert_eq!(
3879            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3880            Action::Close
3881        );
3882    }
3883
3884    /// The open picker records one click row per choice.
3885    #[test]
3886    fn open_picker_records_one_click_row_per_choice() {
3887        use ratatui::Terminal;
3888        use ratatui::backend::TestBackend;
3889
3890        let theme = crate::theme::Theme::default();
3891        let mut state = blank_state(VendorId::Anthropic);
3892        state.focus = Focus::Primary;
3893        state.picker = Some(PrimaryPicker {
3894            cursor: 0,
3895            scroll: 0,
3896        });
3897        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3898        let mut hits = Vec::new();
3899        terminal
3900            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3901            .unwrap();
3902
3903        let picks: Vec<usize> = hits
3904            .iter()
3905            .filter_map(|(row, _)| match row {
3906                SettingsRow::Pick(i) => Some(*i),
3907                _ => None,
3908            })
3909            .collect();
3910        assert_eq!(picks.len(), state.primary_choices.len());
3911        assert_eq!(picks.first().copied(), Some(0));
3912        assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3913    }
3914
3915    /// End-to-end through the real renderer: on a terminal shorter than the
3916    /// overlay body, focusing a late provider switch keeps it and the Save
3917    /// button on screen — the reason the scroll exists.
3918    #[test]
3919    fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3920        use ratatui::Terminal;
3921        use ratatui::backend::TestBackend;
3922
3923        let backend = TestBackend::new(100, 24);
3924        let mut terminal = Terminal::new(backend).unwrap();
3925        let theme = crate::theme::Theme::default();
3926        let mut state = blank_state(VendorId::Anthropic);
3927        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3928        let mut hits = Vec::new();
3929
3930        terminal
3931            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3932            .unwrap();
3933
3934        let rendered = buffer_text(terminal.backend().buffer());
3935        assert!(
3936            rendered.iter().any(|line| line.contains("Grok")),
3937            "focused provider row must be visible: {rendered:?}"
3938        );
3939        // The Save control is the last thing focus moves to; keep it on
3940        // screen too (it renders after the provider rows).
3941        let mut state = blank_state(VendorId::Anthropic);
3942        state.focus = Focus::Save;
3943        let mut hits = Vec::new();
3944        terminal
3945            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3946            .unwrap();
3947        let rendered = buffer_text(terminal.backend().buffer());
3948        assert!(
3949            rendered.iter().any(|line| line.contains("Save")),
3950            "Save must stay visible: {rendered:?}"
3951        );
3952    }
3953
3954    /// Every line of a TestBackend buffer as a String, for substring checks.
3955    fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3956        let area = buffer.area;
3957        let mut lines = Vec::new();
3958        for y in area.top()..area.bottom() {
3959            let mut line = String::new();
3960            for x in area.left()..area.right() {
3961                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3962            }
3963            lines.push(line.trim_end().to_string());
3964        }
3965        lines
3966    }
3967}