Skip to main content

wisp/settings/
mod.rs

1#[cfg(feature = "testing")]
2pub mod overlay;
3#[cfg(not(feature = "testing"))]
4pub(crate) mod overlay;
5mod settings_model;
6mod themes;
7
8use clankerdiff_ratatui::theme::ReviewTheme;
9pub(crate) use settings_model::SettingsModel;
10pub(crate) use themes::{builtin_review_theme_choices, review_theme_choices};
11pub use themes::{list_theme_files, load_theme_file};
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use tracing::warn;
17use utils::fs::{JsonFileError, read_json, write_json_atomic};
18use utils::path::app_home;
19
20use crate::theme::ThemeLoadError;
21
22pub const DEFAULT_CONTENT_PADDING: usize = 2;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(default, rename_all = "camelCase")]
26pub struct UiSettings {
27    pub theme: ThemeSettings,
28    pub content_padding: Option<u16>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub status_line: Option<StatusLineSettings>,
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub keybindings: BTreeMap<KeyAction, String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub enum KeyAction {
38    Exit,
39    Cancel,
40    Submit,
41    OpenCommandPicker,
42    OpenFilePicker,
43    ToggleGitDiff,
44    CycleReasoning,
45    CycleMode,
46    OpenPromptSearch,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "source", rename_all = "lowercase", deny_unknown_fields)]
51pub enum ThemeSettings {
52    Builtin {
53        #[serde(deserialize_with = "deserialize_builtin_theme")]
54        id: String,
55    },
56    File {
57        #[serde(deserialize_with = "deserialize_theme_file")]
58        file: String,
59    },
60}
61
62impl Default for ThemeSettings {
63    fn default() -> Self {
64        Self::Builtin { id: "sage".into() }
65    }
66}
67
68impl ThemeSettings {
69    pub fn selection_id(&self) -> String {
70        match self {
71            Self::Builtin { id } => format!("builtin:{id}"),
72            Self::File { file } => format!("file:{file}"),
73        }
74    }
75
76    pub fn from_selection_id(value: &str) -> Result<Self, ThemeLoadError> {
77        if let Some(id) = value.strip_prefix("builtin:") {
78            clankerdiff_ratatui::theme::ReviewTheme::builtin(id)?;
79            Ok(Self::Builtin { id: id.into() })
80        } else if let Some(file) = value.strip_prefix("file:") {
81            themes::validate_file_name(file)?;
82            Ok(Self::File { file: file.into() })
83        } else {
84            Err(ThemeLoadError::InvalidFile(value.into()))
85        }
86    }
87}
88
89fn deserialize_theme_file<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
90    let file = String::deserialize(deserializer)?;
91    themes::validate_file_name(&file).map_err(serde::de::Error::custom)?;
92    Ok(file)
93}
94
95fn deserialize_builtin_theme<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
96    let id = String::deserialize(deserializer)?;
97    ReviewTheme::builtin(&id).map_err(serde::de::Error::custom)?;
98    Ok(id)
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct StatusLineSettings {
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub separator: Option<String>,
106    #[serde(default, deserialize_with = "deserialize_segments", skip_serializing_if = "Option::is_none")]
107    pub left: Option<Vec<StatusLineSegmentConfig>>,
108    #[serde(default, deserialize_with = "deserialize_segments", skip_serializing_if = "Option::is_none")]
109    pub right: Option<Vec<StatusLineSegmentConfig>>,
110}
111
112/// A configured status-line segment, as a tagged object
113/// (`{"type":"model","maxWidth":40}`).
114///
115/// A segment carrying no options may also be written as its bare name
116/// (`"model"`). Both clients read the same `~/.wisp/settings.json`, and a file
117/// serde rejects is discarded whole, so refusing the shorthand would silently
118/// reset every unrelated setting alongside it. Writing always uses the object
119/// form.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
122pub enum StatusLineSegmentConfig {
123    Cwd {
124        #[serde(default, rename = "maxWidth", skip_serializing_if = "Option::is_none")]
125        max_width: Option<u16>,
126    },
127    GitRef,
128    Agent,
129    Mode,
130    Model {
131        #[serde(default, rename = "maxWidth", skip_serializing_if = "Option::is_none")]
132        max_width: Option<u16>,
133    },
134    Reasoning,
135    Context,
136    ServerHealth,
137    Text {
138        value: String,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        style: Option<StatusLineStyle>,
141    },
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ResolvedStatusLineSettings {
146    pub separator: String,
147    pub left: Vec<StatusLineSegmentConfig>,
148    pub right: Vec<StatusLineSegmentConfig>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub enum StatusLineStyle {
154    Primary,
155    Secondary,
156    Muted,
157    Info,
158    Success,
159    Warning,
160    Error,
161}
162
163impl UiSettings {
164    /// Fill missing status-line fields while preserving explicit user settings.
165    pub fn with_default_status_line(mut self, default: StatusLineSettings) -> Self {
166        let current = self.status_line.unwrap_or_default();
167        self.status_line = Some(StatusLineSettings {
168            separator: current.separator.or(default.separator),
169            left: current.left.or(default.left),
170            right: current.right.or(default.right),
171        });
172        self
173    }
174}
175
176impl StatusLineSettings {
177    pub fn resolve(self) -> ResolvedStatusLineSettings {
178        ResolvedStatusLineSettings {
179            separator: self.separator.unwrap_or_else(default_separator),
180            left: self.left.unwrap_or_else(default_left_segments),
181            right: self.right.unwrap_or_else(default_right_segments),
182        }
183    }
184}
185
186#[derive(Deserialize)]
187#[serde(untagged)]
188enum SegmentWire {
189    Name(String),
190    Config(StatusLineSegmentConfig),
191}
192
193fn deserialize_segments<'de, D: serde::Deserializer<'de>>(
194    deserializer: D,
195) -> Result<Option<Vec<StatusLineSegmentConfig>>, D::Error> {
196    let segments = Option::<Vec<SegmentWire>>::deserialize(deserializer)?;
197    let parse = |segment| match segment {
198        SegmentWire::Name(name) => {
199            serde_json::from_value(serde_json::json!({ "type": name })).map_err(serde::de::Error::custom)
200        }
201        SegmentWire::Config(config) => Ok(config),
202    };
203    segments.map(|segments| segments.into_iter().map(parse).collect()).transpose()
204}
205
206fn default_separator() -> String {
207    " · ".to_string()
208}
209
210fn default_left_segments() -> Vec<StatusLineSegmentConfig> {
211    vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]
212}
213
214fn default_right_segments() -> Vec<StatusLineSegmentConfig> {
215    vec![
216        StatusLineSegmentConfig::Agent,
217        StatusLineSegmentConfig::Mode,
218        StatusLineSegmentConfig::Model { max_width: None },
219        StatusLineSegmentConfig::Reasoning,
220        StatusLineSegmentConfig::Context,
221        StatusLineSegmentConfig::ServerHealth,
222    ]
223}
224
225pub fn resolve_status_line_settings(settings: &UiSettings) -> ResolvedStatusLineSettings {
226    settings.status_line.clone().unwrap_or_default().resolve()
227}
228
229pub fn resolve_content_padding(settings: &UiSettings) -> usize {
230    settings.content_padding.map_or(DEFAULT_CONTENT_PADDING, |value| value.max(2) as usize)
231}
232
233pub fn load_or_create_settings() -> UiSettings {
234    settings_path().map_or_else(
235        || {
236            warn!("Unable to resolve Wisp settings path; using defaults");
237            UiSettings::default()
238        },
239        |path| load_or_create_at(&path),
240    )
241}
242
243pub fn save_settings(settings: &UiSettings) -> std::io::Result<()> {
244    let path = settings_path()
245        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Unable to resolve Wisp settings path"))?;
246    save_to_path(&path, settings)
247}
248
249fn wisp_home() -> Option<PathBuf> {
250    app_home("WISP_HOME", ".wisp")
251}
252
253fn settings_path() -> Option<PathBuf> {
254    wisp_home().map(|home| home.join("settings.json"))
255}
256
257fn load_or_create_at(path: &Path) -> UiSettings {
258    match read_json(path) {
259        Ok(Some(settings)) => settings,
260        Ok(None) => {
261            let defaults = UiSettings::default();
262            if let Err(error) = save_to_path(path, &defaults) {
263                warn!("Failed to write default settings to {}: {error}", path.display());
264            }
265            defaults
266        }
267        Err(error) => {
268            warn!("Failed to load settings: {error}");
269            UiSettings::default()
270        }
271    }
272}
273
274fn save_to_path(path: &Path, settings: &UiSettings) -> std::io::Result<()> {
275    write_json_atomic(path, settings).map_err(|error| match error {
276        JsonFileError::Io { source, .. } => source,
277        error @ JsonFileError::Json { .. } => std::io::Error::other(error),
278    })
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    #[test]
285    fn launcher_defaults_fill_only_missing_status_line_fields() {
286        let settings = UiSettings {
287            status_line: Some(StatusLineSettings {
288                separator: Some(" | ".to_string()),
289                left: None,
290                right: Some(Vec::new()),
291            }),
292            ..UiSettings::default()
293        };
294        let defaults = StatusLineSettings {
295            separator: Some(" · ".to_string()),
296            left: Some(vec![StatusLineSegmentConfig::GitRef]),
297            right: Some(vec![StatusLineSegmentConfig::Agent]),
298        };
299
300        let status_line = settings.with_default_status_line(defaults).status_line.unwrap();
301
302        assert_eq!(status_line.separator.as_deref(), Some(" | "));
303        assert_eq!(status_line.left, Some(vec![StatusLineSegmentConfig::GitRef]));
304        assert_eq!(status_line.right, Some(Vec::new()));
305    }
306
307    #[test]
308    fn status_line_segments_support_tagged_objects() {
309        let settings: UiSettings = serde_json::from_str(
310            r#"{
311                "statusLine": {
312                    "left": [{"type": "cwd"}, {"type": "gitRef"}],
313                    "right": [{"type": "agent"}, {"type": "model", "maxWidth": 32}]
314                }
315            }"#,
316        )
317        .unwrap();
318
319        let status_line = settings.status_line.unwrap();
320        assert_eq!(
321            status_line.left,
322            Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef])
323        );
324        assert_eq!(
325            status_line.right,
326            Some(vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: Some(32) }])
327        );
328    }
329
330    #[test]
331    fn shorthand_segment_names_are_read_as_optionless_segments() {
332        let settings: UiSettings = serde_json::from_str(
333            r#"{
334                "statusLine": {
335                    "left": ["cwd", "gitRef"],
336                    "right": ["agent", {"type": "model", "maxWidth": 32}]
337                }
338            }"#,
339        )
340        .unwrap();
341
342        let status_line = settings.status_line.unwrap();
343        assert_eq!(
344            status_line.left,
345            Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef])
346        );
347        assert_eq!(
348            status_line.right,
349            Some(vec![StatusLineSegmentConfig::Agent, StatusLineSegmentConfig::Model { max_width: Some(32) }])
350        );
351    }
352
353    /// A settings file serde rejects is discarded whole, taking every unrelated
354    /// setting with it, so the shorthand must not cost the reader its theme.
355    #[test]
356    fn a_shorthand_status_line_does_not_discard_the_rest_of_the_file() {
357        let settings: UiSettings = serde_json::from_str(
358            r#"{
359                "contentPadding": 4,
360                "theme": {"source": "file", "file": "nord.json"},
361                "statusLine": {"left": ["cwd"]}
362            }"#,
363        )
364        .unwrap();
365
366        assert_eq!(settings.content_padding, Some(4));
367        assert_eq!(settings.theme, ThemeSettings::File { file: "nord.json".into() });
368    }
369
370    #[test]
371    fn segments_always_serialize_as_objects() {
372        let settings = StatusLineSettings {
373            separator: None,
374            left: Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }, StatusLineSegmentConfig::GitRef]),
375            right: None,
376        };
377
378        assert_eq!(
379            serde_json::to_value(&settings).unwrap(),
380            serde_json::json!({"left": [{"type": "cwd"}, {"type": "gitRef"}]})
381        );
382    }
383
384    #[test]
385    fn text_segment_with_style_deserializes() {
386        let settings: UiSettings = serde_json::from_str(
387            r#"{
388                "statusLine": {
389                    "left": [{"type": "text", "value": "hello", "style": "warning"}]
390                }
391            }"#,
392        )
393        .unwrap();
394
395        let status_line = settings.status_line.unwrap();
396        assert_eq!(
397            status_line.left,
398            Some(vec![StatusLineSegmentConfig::Text {
399                value: "hello".to_string(),
400                style: Some(StatusLineStyle::Warning)
401            }])
402        );
403    }
404
405    #[test]
406    fn status_line_settings_present_are_no_longer_ignored() {
407        let settings: UiSettings = serde_json::from_str(
408            r#"{"contentPadding":4,"theme":{"source":"file","file":"nord.json"},"statusLine":{"left":[{"type":"cwd"}],"right":[{"type":"agent"}]}}"#,
409        )
410        .unwrap();
411
412        assert_eq!(settings.content_padding, Some(4));
413        assert_eq!(settings.theme, ThemeSettings::File { file: "nord.json".into() });
414        assert!(settings.status_line.is_some());
415        let sl = settings.status_line.unwrap();
416        assert_eq!(sl.left, Some(vec![StatusLineSegmentConfig::Cwd { max_width: None }]));
417        assert_eq!(sl.right, Some(vec![StatusLineSegmentConfig::Agent]));
418    }
419
420    #[test]
421    fn cwd_max_width_deserializes() {
422        let settings: UiSettings = serde_json::from_str(
423            r#"{
424                "statusLine": {
425                    "right": [{"type": "cwd", "maxWidth": 30}]
426                }
427            }"#,
428        )
429        .unwrap();
430
431        let sl = settings.status_line.unwrap();
432        assert_eq!(sl.right, Some(vec![StatusLineSegmentConfig::Cwd { max_width: Some(30) }]));
433    }
434
435    #[test]
436    fn cwd_max_width_serializes_as_object() {
437        let seg = StatusLineSegmentConfig::Cwd { max_width: Some(30) };
438        let json = serde_json::to_value(&seg).unwrap();
439        assert_eq!(json, serde_json::json!({"type": "cwd", "maxWidth": 30}));
440    }
441
442    #[test]
443    fn status_line_settings_rejects_unknown_fields() {
444        let err =
445            serde_json::from_str::<UiSettings>(r#"{"statusLine": {"left": [{"type":"cwd"}], "unknownField": true}}"#)
446                .unwrap_err();
447        assert!(
448            err.to_string().contains("unknown field"),
449            "should reject unknown fields in StatusLineSettings, got: {err}"
450        );
451    }
452
453    #[test]
454    fn status_line_segment_object_rejects_unknown_fields() {
455        let err = serde_json::from_str::<UiSettings>(
456            r#"{"statusLine": {"left": [{"type": "cwd", "maxWidth": 30, "foo": 42}]}}"#,
457        )
458        .unwrap_err();
459        let msg = err.to_string();
460        assert!(
461            msg.contains("unknown field") || msg.contains("did not match"),
462            "should reject unknown fields in segment objects, got: {msg}"
463        );
464    }
465
466    #[test]
467    fn invalid_status_line_style_is_rejected() {
468        let err = serde_json::from_str::<UiSettings>(
469            r#"{"statusLine": {"left": [{"type": "text", "value": "hi", "style": "notARealStyle"}]}}"#,
470        )
471        .unwrap_err();
472        let msg = err.to_string();
473        assert!(
474            msg.contains("unknown variant") || msg.contains("did not match"),
475            "should reject invalid style names, got: {msg}"
476        );
477    }
478}