1
//! Config tab: curated key→value viewer/editor.
2
//!
3
//! No `list-config` native exists; a native enumerating all keys would
4
//! enable full enumeration. Until then, only the keys in [`KNOWN_CONFIG_KEYS`]
5
//! are shown.
6

            
7
use cli_core::render::{RenderError, WireValue, parse_wire, reparse_list};
8
use ratatui::Frame;
9
use ratatui::layout::{Constraint, Rect};
10
use ratatui::style::{Color, Modifier, Style};
11
use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState};
12
use scripting::nomiscript::{Value, format_value, list_to_vec};
13

            
14
/// Curated config keys shown in this tab.
15
pub const KNOWN_CONFIG_KEYS: &[&str] = &["locale", "userregistrytimeout"];
16

            
17
/// Per-key fetch state.
18
#[derive(Debug, Clone, PartialEq)]
19
pub enum ConfigCell {
20
    /// A `get-config` request is in flight with this envelope id.
21
    Loading { id: i64 },
22
    /// Reply received; the value is the string from `:config-value`.
23
    Loaded(String),
24
    /// The key has no value set — `get-config` returned `(:config-value nil)`.
25
    Unset,
26
    /// A genuine failure: server error, or an envelope/parse error.
27
    Error(String),
28
}
29

            
30
pub struct ConfigTab {
31
    pub entries: Vec<(String, ConfigCell)>,
32
    pub selected: usize,
33
}
34

            
35
impl ConfigTab {
36
    #[must_use]
37
170
    pub fn new() -> Self {
38
        Self {
39
170
            entries: KNOWN_CONFIG_KEYS
40
170
                .iter()
41
340
                .map(|k| ((*k).to_string(), ConfigCell::Unset))
42
170
                .collect(),
43
            selected: 0,
44
        }
45
170
    }
46

            
47
    /// All entries are `Unset` — used to decide whether `ensure_tab_loaded`
48
    /// should issue fetches.
49
    #[must_use]
50
2
    pub fn is_idle(&self) -> bool {
51
2
        self.entries
52
2
            .iter()
53
3
            .all(|(_, c)| matches!(c, ConfigCell::Unset))
54
2
    }
55

            
56
    /// Mark the entry for `key` as `Loading`.
57
7
    pub fn set_loading(&mut self, key: &str, id: i64) {
58
8
        if let Some((_, cell)) = self.entries.iter_mut().find(|(k, _)| k == key) {
59
7
            *cell = ConfigCell::Loading { id };
60
7
        }
61
7
    }
62

            
63
    /// Process a routed reply for `key`.
64
    ///
65
    /// A server error maps to `Unset` (key not set); an envelope or parse
66
    /// error maps to `Error`; a successful reply extracts `:config-value`.
67
8
    pub fn on_reply(&mut self, key: &str, wire: &str) {
68
8
        let new_state = parse_config_reply(wire);
69
8
        if let Some((_, cell)) = self.entries.iter_mut().find(|(k, _)| k == key) {
70
8
            *cell = new_state;
71
8
        }
72
8
    }
73

            
74
    /// Reset all entries to `Unset` so the next tab-switch re-fetches.
75
1
    pub fn reset(&mut self) {
76
2
        for (_, cell) in &mut self.entries {
77
2
            *cell = ConfigCell::Unset;
78
2
        }
79
1
    }
80

            
81
    /// Whether any entry is still waiting for a reply.
82
    #[must_use]
83
3
    pub fn any_loading(&self) -> bool {
84
3
        self.entries
85
3
            .iter()
86
5
            .any(|(_, c)| matches!(c, ConfigCell::Loading { .. }))
87
3
    }
88

            
89
    /// Key name at the selected row.
90
    #[must_use]
91
1
    pub fn selected_key(&self) -> Option<&str> {
92
1
        self.entries.get(self.selected).map(|(k, _)| k.as_str())
93
1
    }
94

            
95
    /// Current display value at the selected row; empty string if not loaded.
96
    #[must_use]
97
2
    pub fn selected_value(&self) -> &str {
98
2
        match self.entries.get(self.selected) {
99
1
            Some((_, ConfigCell::Loaded(v))) => v.as_str(),
100
1
            _ => "",
101
        }
102
2
    }
103

            
104
101
    pub fn select_next(&mut self) {
105
101
        if self.selected + 1 < self.entries.len() {
106
2
            self.selected += 1;
107
99
        }
108
101
    }
109

            
110
1
    pub fn select_prev(&mut self) {
111
1
        self.selected = self.selected.saturating_sub(1);
112
1
    }
113

            
114
    pub fn draw(&self, frame: &mut Frame, area: Rect) {
115
        let block = Block::default().borders(Borders::ALL).title("Config");
116
        let header_style = Style::default().add_modifier(Modifier::BOLD);
117
        let selected_style = Style::default().bg(Color::DarkGray);
118

            
119
        let header = Row::new(vec![
120
            Cell::from("Key").style(header_style),
121
            Cell::from("Value").style(header_style),
122
        ]);
123

            
124
        let rows: Vec<Row> = self
125
            .entries
126
            .iter()
127
            .enumerate()
128
            .map(|(i, (key, cell))| {
129
                let display: &str = match cell {
130
                    ConfigCell::Loaded(v) => v.as_str(),
131
                    ConfigCell::Loading { .. } => "(loading\u{2026})",
132
                    ConfigCell::Unset => "(unset)",
133
                    ConfigCell::Error(e) => e.as_str(),
134
                };
135
                let style = if i == self.selected {
136
                    selected_style
137
                } else {
138
                    Style::default()
139
                };
140
                Row::new(vec![Cell::from(key.as_str()), Cell::from(display)]).style(style)
141
            })
142
            .collect();
143

            
144
        let widths = [Constraint::Percentage(40), Constraint::Percentage(60)];
145
        let table = Table::new(rows, widths).header(header).block(block);
146
        let mut state = TableState::default().with_selected(Some(self.selected));
147
        frame.render_stateful_widget(table, area, &mut state);
148
    }
149
}
150

            
151
impl Default for ConfigTab {
152
    fn default() -> Self {
153
        Self::new()
154
    }
155
}
156

            
157
/// Build the `(set-config <key> <value>)` s-expression.
158
2
pub fn build_set_config_form(key: &str, value: &str) -> String {
159
    use cli_core::eval::escape_str;
160
2
    format!("(set-config {} {})", escape_str(key), escape_str(value))
161
2
}
162

            
163
8
fn parse_config_reply(wire: &str) -> ConfigCell {
164
    // An UNSET key is NOT a server error: `get-config` returns
165
    // `(:config-value nil)` for a missing field (rpc config.rs run_get_config).
166
    // A server error therefore means a genuine failure (bad arg / DB / runtime)
167
    // and must surface as such, not be hidden as "(unset)".
168
8
    match parse_wire(wire) {
169
2
        Err(RenderError::Server { code, message }) => {
170
2
            ConfigCell::Error(format!("[{code}] {message}"))
171
        }
172
        Err(e) => ConfigCell::Error(e.to_string()),
173
6
        Ok(WireValue::Value(Value::String(ref s))) => extract_config_value(s),
174
        Ok(WireValue::Value(ref other)) => {
175
            ConfigCell::Error(format!("unexpected reply type: {}", format_value(other)))
176
        }
177
    }
178
8
}
179

            
180
6
fn extract_config_value(plist_str: &str) -> ConfigCell {
181
6
    let parsed = match reparse_list(plist_str) {
182
6
        Ok(v) => v,
183
        Err(e) => return ConfigCell::Error(format!("parse error: {e}")),
184
    };
185
6
    match plist_config_value(&parsed) {
186
        // `(:config-value nil)` is how the native reports a key with no value set.
187
1
        Some(Value::Nil) => ConfigCell::Unset,
188
5
        Some(Value::String(s)) => ConfigCell::Loaded(s),
189
        Some(other) => ConfigCell::Loaded(format_value(&other)),
190
        None => ConfigCell::Error(format!("missing :config-value in: {plist_str}")),
191
    }
192
6
}
193

            
194
6
fn plist_config_value(value: &Value) -> Option<Value> {
195
6
    let items = list_to_vec(value)?;
196
6
    let pos = items
197
6
        .iter()
198
6
        .position(|v| matches!(v, Value::Symbol(s) if s == ":config-value"))?;
199
6
    items.get(pos + 1).cloned()
200
6
}
201

            
202
#[cfg(test)]
203
mod tests;