Skip to main content

ai_usagebar/tui/
settings.rs

1//! Settings overlay — opened from the TUI by pressing `s`. Lets the user
2//! pick the primary vendor and set Z.AI / OpenRouter API keys without
3//! editing config.toml by hand.
4//!
5//! Persistence uses `toml_edit` so the existing config keeps its comments,
6//! whitespace, and unrelated fields. Files with inline keys are atomically
7//! written and `chmod 600`ed.
8
9use std::path::{Path, PathBuf};
10
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Block, Borders, Clear, Paragraph};
16use toml_edit::{DocumentMut, value};
17
18use crate::config::Config;
19use crate::error::{AppError, Result};
20use crate::theme::Theme;
21use crate::vendor::VendorId;
22
23/// Which input field has keyboard focus.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Focus {
26    Primary,
27    ZaiKey,
28    OpenrouterKey,
29    DeepseekKey,
30    SaveButton,
31}
32
33impl Focus {
34    pub fn next(self) -> Self {
35        match self {
36            Focus::Primary => Focus::ZaiKey,
37            Focus::ZaiKey => Focus::OpenrouterKey,
38            Focus::OpenrouterKey => Focus::DeepseekKey,
39            Focus::DeepseekKey => Focus::SaveButton,
40            Focus::SaveButton => Focus::Primary,
41        }
42    }
43    pub fn prev(self) -> Self {
44        match self {
45            Focus::Primary => Focus::SaveButton,
46            Focus::ZaiKey => Focus::Primary,
47            Focus::OpenrouterKey => Focus::ZaiKey,
48            Focus::DeepseekKey => Focus::OpenrouterKey,
49            Focus::SaveButton => Focus::DeepseekKey,
50        }
51    }
52}
53
54/// Per-field text-input state — cursor + buffer + reveal flag.
55#[derive(Debug, Clone, Default)]
56pub struct KeyInput {
57    pub buf: String,
58    /// Char-index cursor position (0..=buf.chars().count()).
59    pub cursor: usize,
60    /// When true, the field renders the actual characters; otherwise `•`.
61    pub revealed: bool,
62    /// True after the user has typed/edited; only then does save write
63    /// the value back (avoids clobbering an existing key with the empty
64    /// placeholder the user opened the dialog with).
65    pub dirty: bool,
66}
67
68impl KeyInput {
69    pub fn from_config(initial: Option<&str>) -> Self {
70        let buf = initial.unwrap_or("").to_string();
71        let cursor = buf.chars().count();
72        Self {
73            buf,
74            cursor,
75            revealed: false,
76            dirty: false,
77        }
78    }
79
80    pub fn insert_char(&mut self, c: char) {
81        let byte_idx = self.char_to_byte(self.cursor);
82        self.buf.insert(byte_idx, c);
83        self.cursor += 1;
84        self.dirty = true;
85    }
86
87    pub fn backspace(&mut self) {
88        if self.cursor == 0 {
89            return;
90        }
91        let prev_byte = self.char_to_byte(self.cursor - 1);
92        let cur_byte = self.char_to_byte(self.cursor);
93        self.buf.replace_range(prev_byte..cur_byte, "");
94        self.cursor -= 1;
95        self.dirty = true;
96    }
97
98    pub fn delete(&mut self) {
99        let n = self.buf.chars().count();
100        if self.cursor >= n {
101            return;
102        }
103        let cur_byte = self.char_to_byte(self.cursor);
104        let next_byte = self.char_to_byte(self.cursor + 1);
105        self.buf.replace_range(cur_byte..next_byte, "");
106        self.dirty = true;
107    }
108
109    pub fn move_left(&mut self) {
110        if self.cursor > 0 {
111            self.cursor -= 1;
112        }
113    }
114    pub fn move_right(&mut self) {
115        if self.cursor < self.buf.chars().count() {
116            self.cursor += 1;
117        }
118    }
119    pub fn move_home(&mut self) {
120        self.cursor = 0;
121    }
122    pub fn move_end(&mut self) {
123        self.cursor = self.buf.chars().count();
124    }
125    pub fn toggle_reveal(&mut self) {
126        self.revealed = !self.revealed;
127    }
128
129    /// Render for display — bullets when masked, raw chars when revealed.
130    pub fn display(&self) -> String {
131        if self.revealed {
132            self.buf.clone()
133        } else {
134            "•".repeat(self.buf.chars().count())
135        }
136    }
137
138    fn char_to_byte(&self, char_idx: usize) -> usize {
139        self.buf
140            .char_indices()
141            .map(|(b, _)| b)
142            .chain(std::iter::once(self.buf.len()))
143            .nth(char_idx)
144            .unwrap_or(self.buf.len())
145    }
146}
147
148/// Mutable state of the overlay while open.
149#[derive(Debug, Clone)]
150pub struct SettingsState {
151    pub focus: Focus,
152    pub primary: VendorId,
153    pub zai: KeyInput,
154    pub openrouter: KeyInput,
155    pub deepseek: KeyInput,
156    /// One-line status displayed in the footer ("Saved", "Error: ...", "").
157    pub status: String,
158}
159
160impl SettingsState {
161    pub fn from_config(cfg: &Config) -> Self {
162        Self {
163            focus: Focus::Primary,
164            primary: cfg.ui.primary.unwrap_or(VendorId::Anthropic),
165            zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
166            openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
167            deepseek: KeyInput::from_config(cfg.deepseek.api_key.as_deref()),
168            status: String::new(),
169        }
170    }
171}
172
173/// What the key handler asks the host app to do next.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Action {
176    /// Stay open, keep listening for keys.
177    Continue,
178    /// Close the overlay (discard or save already happened).
179    Close,
180    /// Save just succeeded — caller should refresh affected vendors.
181    SavedAndClose,
182}
183
184/// Permission note appended to the "saved" status line. The overlay `chmod
185/// 600`s the file on Unix; Windows has no such step, so the note is empty there
186/// (keeps the message platform-honest).
187#[cfg(unix)]
188const PERMS_NOTE: &str = " (chmod 600)";
189#[cfg(not(unix))]
190const PERMS_NOTE: &str = "";
191
192/// Status line after a successful save: the platform-resolved config path plus
193/// the platform-appropriate permission note.
194fn saved_status() -> String {
195    format!(
196        "saved to {}{}",
197        crate::config::config_path_hint(),
198        PERMS_NOTE
199    )
200}
201
202/// Key map. Returns the action to perform after the keypress.
203pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
204    // Esc always closes without saving.
205    if matches!(code, KeyCode::Esc) {
206        return Action::Close;
207    }
208    // Ctrl-S triggers save from any field.
209    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
210        return match save_to_config_default(state) {
211            Ok(()) => {
212                state.status = saved_status();
213                Action::SavedAndClose
214            }
215            Err(e) => {
216                state.status = format!("save failed: {e}");
217                Action::Continue
218            }
219        };
220    }
221    // Ctrl-V toggles reveal on the focused key field.
222    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
223        match state.focus {
224            Focus::ZaiKey => state.zai.toggle_reveal(),
225            Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
226            Focus::DeepseekKey => state.deepseek.toggle_reveal(),
227            _ => {}
228        }
229        return Action::Continue;
230    }
231
232    // Field navigation: Tab/Shift-Tab and Up/Down.
233    match code {
234        KeyCode::Tab => {
235            state.focus = state.focus.next();
236            return Action::Continue;
237        }
238        KeyCode::BackTab => {
239            state.focus = state.focus.prev();
240            return Action::Continue;
241        }
242        KeyCode::Down => {
243            state.focus = state.focus.next();
244            return Action::Continue;
245        }
246        KeyCode::Up => {
247            state.focus = state.focus.prev();
248            return Action::Continue;
249        }
250        _ => {}
251    }
252
253    // Field-specific handling.
254    match state.focus {
255        Focus::Primary => handle_primary(state, code),
256        Focus::ZaiKey => handle_input(&mut state.zai, code),
257        Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
258        Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
259        Focus::SaveButton => {
260            if matches!(code, KeyCode::Enter) {
261                return match save_to_config_default(state) {
262                    Ok(()) => {
263                        state.status = saved_status();
264                        Action::SavedAndClose
265                    }
266                    Err(e) => {
267                        state.status = format!("save failed: {e}");
268                        Action::Continue
269                    }
270                };
271            }
272        }
273    }
274    Action::Continue
275}
276
277fn handle_primary(state: &mut SettingsState, code: KeyCode) {
278    // Left/Right cycles the primary-vendor radio.
279    let all = VendorId::all();
280    let idx = all.iter().position(|v| *v == state.primary).unwrap_or(0) as i32;
281    let len = all.len() as i32;
282    let step = match code {
283        KeyCode::Left => -1,
284        KeyCode::Right | KeyCode::Char(' ') => 1,
285        _ => return,
286    };
287    state.primary = all[((idx + step).rem_euclid(len)) as usize];
288}
289
290fn handle_input(input: &mut KeyInput, code: KeyCode) {
291    match code {
292        KeyCode::Char(c) => input.insert_char(c),
293        KeyCode::Backspace => input.backspace(),
294        KeyCode::Delete => input.delete(),
295        KeyCode::Left => input.move_left(),
296        KeyCode::Right => input.move_right(),
297        KeyCode::Home => input.move_home(),
298        KeyCode::End => input.move_end(),
299        _ => {}
300    }
301}
302
303/// Save to `~/.config/ai-usagebar/config.toml` (or create it). On success,
304/// signal a running Waybar process (SIGRTMIN+13) so any module configured
305/// with `signal: 13` refreshes its exec output immediately — otherwise the
306/// bar text wouldn't reflect a new primary vendor until the next interval
307/// tick (up to 300s).
308fn save_to_config_default(state: &SettingsState) -> Result<()> {
309    let path = default_config_path()?;
310    if let Some(parent) = path.parent() {
311        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
312    }
313    save_to_path(state, &path)?;
314    crate::waybar::request_refresh();
315    Ok(())
316}
317
318/// Same as `save_to_config_default` but with an explicit path — exposed
319/// for tests.
320pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
321    let original = std::fs::read_to_string(path).unwrap_or_default();
322    let mut doc: DocumentMut = if original.trim().is_empty() {
323        DocumentMut::new()
324    } else {
325        original.parse().map_err(|e: toml_edit::TomlError| {
326            AppError::Other(format!("config.toml not parseable: {e}"))
327        })?
328    };
329
330    // [ui].primary
331    set_string(&mut doc, "ui", "primary", state.primary.slug())?;
332    // [zai].api_key — only write if dirty AND non-empty
333    if state.zai.dirty && !state.zai.buf.is_empty() {
334        set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
335    }
336    // [openrouter].api_key — same
337    if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
338        set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
339    }
340    // [deepseek].api_key — same
341    if state.deepseek.dirty && !state.deepseek.buf.is_empty() {
342        set_string(&mut doc, "deepseek", "api_key", &state.deepseek.buf)?;
343    }
344
345    let bytes = doc.to_string();
346    crate::cache::atomic_write(path, bytes.as_bytes())?;
347
348    // chmod 600 — only required when we wrote a secret, but always safe.
349    #[cfg(unix)]
350    {
351        use std::os::unix::fs::PermissionsExt;
352        if let Ok(meta) = std::fs::metadata(path) {
353            let mut perms = meta.permissions();
354            perms.set_mode(0o600);
355            let _ = std::fs::set_permissions(path, perms);
356        }
357    }
358    Ok(())
359}
360
361/// Set or update a string field in a TOML section, preserving comments and
362/// formatting of unaffected nodes. When the key already exists, we mutate its
363/// value in place (this keeps the leading comment attached to the key);
364/// otherwise we insert a new entry.
365fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
366    let table = doc
367        .entry(section)
368        .or_insert_with(toml_edit::table)
369        .as_table_mut()
370        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
371
372    if let Some(item) = table.get_mut(key) {
373        if let Some(v) = item.as_value_mut() {
374            *v = toml_edit::Value::from(new_value);
375            // Restore the surrounding decor (a space before `=` and after the
376            // value, matching toml_edit's default output).
377            v.decor_mut().set_prefix(" ");
378            return Ok(());
379        }
380    }
381    table.insert(key, value(new_value));
382    Ok(())
383}
384
385fn default_config_path() -> Result<PathBuf> {
386    crate::config::default_path()
387        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
388}
389
390/// Render the modal overlay over `area`.
391pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
392    let modal = centered_rect(60, 60, area);
393    // Clear underneath so the body is unreadable through us.
394    f.render_widget(Clear, modal);
395
396    let accent = parse_hex(&theme.blue).unwrap_or(Color::Cyan);
397    let fg = parse_hex(&theme.fg).unwrap_or(Color::White);
398    let dim = parse_hex(&theme.dim).unwrap_or(Color::DarkGray);
399
400    let block = Block::default()
401        .title(" Settings ")
402        .borders(Borders::ALL)
403        .border_style(Style::default().fg(accent).add_modifier(Modifier::BOLD));
404    let inner = block.inner(modal);
405    f.render_widget(block, modal);
406
407    let chunks = Layout::default()
408        .direction(Direction::Vertical)
409        .constraints([
410            Constraint::Length(1), // [0] primary label
411            Constraint::Length(2), // [1] primary radio row
412            Constraint::Length(1), // [2] spacer
413            Constraint::Length(1), // [3] zai label
414            Constraint::Length(2), // [4] zai input
415            Constraint::Length(1), // [5] openrouter label
416            Constraint::Length(2), // [6] openrouter input
417            Constraint::Length(1), // [7] deepseek label
418            Constraint::Length(2), // [8] deepseek input
419            Constraint::Length(1), // [9] spacer
420            Constraint::Length(1), // [10] save button
421            Constraint::Length(1), // [11] status
422            Constraint::Min(0),    // [12] hint
423        ])
424        .split(inner);
425
426    // Primary vendor.
427    f.render_widget(
428        Paragraph::new(label(
429            "Primary vendor",
430            state.focus == Focus::Primary,
431            fg,
432            accent,
433        )),
434        chunks[0],
435    );
436    f.render_widget(
437        Paragraph::new(render_radio(&state.primary, accent, dim)),
438        chunks[1],
439    );
440
441    // Z.AI key.
442    f.render_widget(
443        Paragraph::new(label(
444            "Z.AI API key (ZAI_API_KEY env wins if set)",
445            state.focus == Focus::ZaiKey,
446            fg,
447            accent,
448        )),
449        chunks[3],
450    );
451    f.render_widget(
452        Paragraph::new(render_input(
453            &state.zai,
454            state.focus == Focus::ZaiKey,
455            fg,
456            accent,
457            dim,
458        )),
459        chunks[4],
460    );
461
462    // OpenRouter key.
463    f.render_widget(
464        Paragraph::new(label(
465            "OpenRouter API key (OPENROUTER_API_KEY env wins if set)",
466            state.focus == Focus::OpenrouterKey,
467            fg,
468            accent,
469        )),
470        chunks[5],
471    );
472    f.render_widget(
473        Paragraph::new(render_input(
474            &state.openrouter,
475            state.focus == Focus::OpenrouterKey,
476            fg,
477            accent,
478            dim,
479        )),
480        chunks[6],
481    );
482
483    // DeepSeek key.
484    f.render_widget(
485        Paragraph::new(label(
486            "DeepSeek API key (DEEPSEEK_API_KEY env wins if set)",
487            state.focus == Focus::DeepseekKey,
488            fg,
489            accent,
490        )),
491        chunks[7],
492    );
493    f.render_widget(
494        Paragraph::new(render_input(
495            &state.deepseek,
496            state.focus == Focus::DeepseekKey,
497            fg,
498            accent,
499            dim,
500        )),
501        chunks[8],
502    );
503
504    // Save button.
505    let save_style = if state.focus == Focus::SaveButton {
506        Style::default()
507            .fg(accent)
508            .add_modifier(Modifier::BOLD | Modifier::REVERSED)
509    } else {
510        Style::default().fg(accent)
511    };
512    f.render_widget(
513        Paragraph::new(Line::from(Span::styled(
514            "   [ Save (Ctrl-S) ]   ",
515            save_style,
516        ))),
517        chunks[10],
518    );
519
520    // Status line.
521    if !state.status.is_empty() {
522        f.render_widget(
523            Paragraph::new(Line::from(Span::styled(
524                state.status.clone(),
525                Style::default().fg(dim),
526            ))),
527            chunks[11],
528        );
529    }
530
531    // Hint footer.
532    let hint = Line::from(vec![Span::styled(
533        "  Tab/↑↓ move · ←→ pick vendor · Ctrl-V reveal · Ctrl-S save · Esc cancel",
534        Style::default().fg(dim),
535    )]);
536    f.render_widget(Paragraph::new(hint), chunks[12]);
537}
538
539fn label(text: &str, focused: bool, fg: Color, accent: Color) -> Line<'static> {
540    let mut style = Style::default().fg(fg);
541    if focused {
542        style = style.fg(accent).add_modifier(Modifier::BOLD);
543    }
544    Line::from(Span::styled(format!("  {text}"), style))
545}
546
547fn render_radio(selected: &VendorId, accent: Color, dim: Color) -> Line<'static> {
548    let mut spans = vec![Span::raw("    ")];
549    for v in VendorId::all() {
550        let is_sel = v == selected;
551        let glyph = if is_sel { "●" } else { "○" };
552        let style = if is_sel {
553            Style::default().fg(accent).add_modifier(Modifier::BOLD)
554        } else {
555            Style::default().fg(dim)
556        };
557        spans.push(Span::styled(
558            format!("{glyph} {}  ", vendor_label(*v)),
559            style,
560        ));
561    }
562    Line::from(spans)
563}
564
565fn vendor_label(v: VendorId) -> &'static str {
566    match v {
567        VendorId::Anthropic => "Anthropic",
568        VendorId::Openai => "OpenAI",
569        VendorId::Zai => "Z.AI",
570        VendorId::Openrouter => "OpenRouter",
571        VendorId::Deepseek => "DeepSeek",
572    }
573}
574
575fn render_input(
576    input: &KeyInput,
577    focused: bool,
578    fg: Color,
579    accent: Color,
580    dim: Color,
581) -> Line<'static> {
582    let body = if input.buf.is_empty() {
583        "(empty)".to_string()
584    } else {
585        input.display()
586    };
587    let box_style = if focused {
588        Style::default().fg(accent).add_modifier(Modifier::BOLD)
589    } else {
590        Style::default().fg(fg)
591    };
592    let suffix_style = Style::default().fg(dim);
593    let suffix = if input.revealed { "  [revealed]" } else { "" };
594    let cursor_hint = if focused {
595        format!("  ▏cur:{}", input.cursor)
596    } else {
597        String::new()
598    };
599    Line::from(vec![
600        Span::styled(format!("    {body}"), box_style),
601        Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
602    ])
603}
604
605fn parse_hex(s: &str) -> Option<Color> {
606    let (r, g, b) = crate::theme::parse_hex_rgb(s)?;
607    Some(Color::Rgb(r, g, b))
608}
609
610/// Center a rectangle of `percent_x * percent_y` over `r`.
611fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
612    let popup_h = (r.height * percent_y) / 100;
613    let popup_w = (r.width * percent_x) / 100;
614    Rect {
615        x: r.x + (r.width - popup_w) / 2,
616        y: r.y + (r.height - popup_h) / 2,
617        width: popup_w,
618        height: popup_h,
619    }
620}
621
622// crossterm types live behind the bin; re-exported here for handle_key callers.
623pub use crossterm::event::{KeyCode, KeyModifiers};
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use tempfile::TempDir;
629
630    /// A config path inside a fresh `TempDir`, with no open handle on the file.
631    /// `save_to_path` rewrites atomically (rename over destination), which on
632    /// Windows fails if the destination is still open — so tests must not hold a
633    /// live `NamedTempFile` handle on the target. Returns the dir (kept alive by
634    /// the caller) and the path; the file may or may not exist yet.
635    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
636        let dir = TempDir::new().unwrap();
637        let path = dir.path().join("config.toml");
638        if let Some(contents) = initial {
639            std::fs::write(&path, contents).unwrap();
640        }
641        (dir, path)
642    }
643
644    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
645        let mut s = SettingsState {
646            focus: Focus::Primary,
647            primary,
648            zai: KeyInput::from_config(Some(zai)),
649            openrouter: KeyInput::from_config(Some(opr)),
650            deepseek: KeyInput::default(),
651            status: String::new(),
652        };
653        // Mark dirty so save writes them.
654        s.zai.dirty = true;
655        s.openrouter.dirty = true;
656        s
657    }
658
659    #[test]
660    fn focus_cycles_forward_and_backward() {
661        let order = [
662            Focus::Primary,
663            Focus::ZaiKey,
664            Focus::OpenrouterKey,
665            Focus::DeepseekKey,
666            Focus::SaveButton,
667        ];
668        let n = order.len();
669        for (i, f) in order.iter().enumerate() {
670            assert_eq!(f.next(), order[(i + 1) % n]);
671            assert_eq!(f.prev(), order[(i + n - 1) % n]);
672        }
673    }
674
675    #[test]
676    fn key_input_insert_backspace_arrow() {
677        let mut k = KeyInput::default();
678        k.insert_char('a');
679        k.insert_char('b');
680        k.insert_char('c');
681        assert_eq!(k.buf, "abc");
682        assert_eq!(k.cursor, 3);
683        assert!(k.dirty);
684        k.move_left();
685        k.move_left();
686        assert_eq!(k.cursor, 1);
687        k.insert_char('x'); // "axbc"
688        assert_eq!(k.buf, "axbc");
689        assert_eq!(k.cursor, 2);
690        k.backspace();
691        assert_eq!(k.buf, "abc");
692        assert_eq!(k.cursor, 1);
693    }
694
695    #[test]
696    fn key_input_masks_by_default_reveals_on_toggle() {
697        let mut k = KeyInput::default();
698        for c in "secret-key".chars() {
699            k.insert_char(c);
700        }
701        assert_eq!(k.display(), "•".repeat(10));
702        k.toggle_reveal();
703        assert_eq!(k.display(), "secret-key");
704    }
705
706    #[test]
707    fn key_input_handles_unicode() {
708        let mut k = KeyInput::default();
709        k.insert_char('a');
710        k.insert_char('→');
711        k.insert_char('b');
712        assert_eq!(k.buf, "a→b");
713        assert_eq!(k.cursor, 3);
714        k.move_left();
715        k.backspace(); // delete '→'
716        assert_eq!(k.buf, "ab");
717    }
718
719    #[test]
720    fn save_to_path_writes_minimal_toml_when_starting_empty() {
721        let (_dir, path) = temp_config(None);
722        let s = state_with("zk", "ok", VendorId::Zai);
723        save_to_path(&s, &path).unwrap();
724        let raw = std::fs::read_to_string(&path).unwrap();
725        assert!(raw.contains("primary = \"zai\""));
726        assert!(raw.contains("[zai]"));
727        assert!(raw.contains("api_key = \"zk\""));
728        assert!(raw.contains("[openrouter]"));
729        assert!(raw.contains("api_key = \"ok\""));
730    }
731
732    #[test]
733    fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
734        let (_dir, path) = temp_config(Some(
735            r##"# my comment
736[ui]
737# pre-existing comment
738primary = "anthropic"
739
740[zai]
741enabled = true
742api_key_env = "ZAI_API_KEY"
743# tier comment
744plan_tier = "pro"
745
746[openrouter]
747enabled = true
748api_key_env = "OPENROUTER_API_KEY"
749"##,
750        ));
751
752        let s = state_with("zk2", "ok2", VendorId::Openrouter);
753        save_to_path(&s, &path).unwrap();
754
755        let raw = std::fs::read_to_string(&path).unwrap();
756        // Comments survive.
757        assert!(raw.contains("# my comment"));
758        assert!(raw.contains("# pre-existing comment"));
759        assert!(raw.contains("# tier comment"));
760        // Unrelated fields survive.
761        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
762        assert!(raw.contains("plan_tier = \"pro\""));
763        // Primary updated.
764        assert!(raw.contains("primary = \"openrouter\""));
765        // Keys written.
766        assert!(raw.contains("api_key = \"zk2\""));
767        assert!(raw.contains("api_key = \"ok2\""));
768    }
769
770    #[test]
771    fn save_does_not_write_empty_key_when_dirty_but_blank() {
772        let (_dir, path) = temp_config(None);
773        let mut s = state_with("", "", VendorId::Anthropic);
774        // Mark dirty but leave buf empty (user opened dialog with empty
775        // field, focused it, did nothing).
776        s.zai.dirty = true;
777        s.openrouter.dirty = true;
778        save_to_path(&s, &path).unwrap();
779        let raw = std::fs::read_to_string(&path).unwrap();
780        // No `api_key = ""` lines should be written.
781        assert!(!raw.contains("api_key ="));
782    }
783
784    #[test]
785    #[cfg(unix)]
786    fn save_chmods_to_600() {
787        use std::os::unix::fs::PermissionsExt;
788        let (_dir, path) = temp_config(None);
789        let s = state_with("zk", "ok", VendorId::Zai);
790        save_to_path(&s, &path).unwrap();
791        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
792        assert_eq!(mode & 0o777, 0o600);
793    }
794
795    #[test]
796    fn handle_key_tab_cycles_focus() {
797        let mut s = SettingsState {
798            focus: Focus::Primary,
799            primary: VendorId::Anthropic,
800            zai: KeyInput::default(),
801            openrouter: KeyInput::default(),
802            deepseek: KeyInput::default(),
803            status: String::new(),
804        };
805        assert_eq!(
806            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
807            Action::Continue
808        );
809        assert_eq!(s.focus, Focus::ZaiKey);
810        assert_eq!(
811            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
812            Action::Continue
813        );
814        assert_eq!(s.focus, Focus::Primary);
815    }
816
817    #[test]
818    fn handle_key_esc_closes_without_saving() {
819        let mut s = SettingsState {
820            focus: Focus::Primary,
821            primary: VendorId::Anthropic,
822            zai: KeyInput::default(),
823            openrouter: KeyInput::default(),
824            deepseek: KeyInput::default(),
825            status: String::new(),
826        };
827        assert_eq!(
828            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
829            Action::Close
830        );
831    }
832
833    #[test]
834    fn handle_key_left_right_cycles_primary_vendor() {
835        let mut s = SettingsState {
836            focus: Focus::Primary,
837            primary: VendorId::Anthropic,
838            zai: KeyInput::default(),
839            openrouter: KeyInput::default(),
840            deepseek: KeyInput::default(),
841            status: String::new(),
842        };
843        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
844        assert_eq!(s.primary, VendorId::Openai);
845        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
846        assert_eq!(s.primary, VendorId::Zai);
847        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
848        assert_eq!(s.primary, VendorId::Openai);
849    }
850
851    #[test]
852    fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
853        let mut s = SettingsState {
854            focus: Focus::ZaiKey,
855            primary: VendorId::Anthropic,
856            zai: KeyInput::from_config(Some("secret")),
857            openrouter: KeyInput::default(),
858            deepseek: KeyInput::default(),
859            status: String::new(),
860        };
861        assert!(!s.zai.revealed);
862        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
863        assert!(s.zai.revealed);
864        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
865        assert!(!s.zai.revealed);
866    }
867
868    #[test]
869    fn handle_key_ctrl_s_attempts_save_from_any_field() {
870        let (_dir, path) = temp_config(None);
871        // We can't easily redirect default_config_path() in the test, so we
872        // exercise save_to_path directly instead.
873        let s = state_with("zk", "ok", VendorId::Zai);
874        save_to_path(&s, &path).unwrap();
875        let raw = std::fs::read_to_string(&path).unwrap();
876        assert!(raw.contains("api_key = \"zk\""));
877    }
878}