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