Skip to main content

cookie_agent_tui/
config.rs

1//! Strict, independent TUI configuration (`tui.toml`).
2//!
3//! The file lives at `~/.cookie-agent/tui.toml`. There is no workspace layer or
4//! environment variable override. A missing file yields defaults; a malformed
5//! file or any unknown key fails with an actionable path/key error. Error
6//! messages quote the file's location and the offending key or TOML parse
7//! context, never file contents.
8
9use std::path::{Path, PathBuf};
10
11use cookie_agent_protocol::paths;
12use serde::Deserialize;
13
14use crate::state::EventLevel;
15use crate::theme::ThemeKind;
16
17pub const CONFIG_FILE_NAME: &str = "tui.toml";
18
19/// Parsed TUI configuration with all defaults applied.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct TuiConfig {
22    /// Minimum diagnostic level rendered in the conversation. Rows below the
23    /// threshold stay in the session projection and reappear when the level
24    /// is lowered at runtime.
25    pub minimum_event_level: EventLevel,
26    /// Optional theme preference; `None` and `Auto` use terminal/environment
27    /// detection (`COOKIE_THEME`, OSC 11, `COLORFGBG`).
28    pub theme: Option<ThemePreference>,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum ThemePreference {
33    Auto,
34    Default,
35    Dark,
36    Mono,
37    HighContrast,
38}
39
40impl ThemePreference {
41    pub const fn explicit_kind(self) -> Option<ThemeKind> {
42        match self {
43            Self::Auto => None,
44            Self::Default => Some(ThemeKind::Default),
45            Self::Dark => Some(ThemeKind::Dark),
46            Self::Mono => Some(ThemeKind::Mono),
47            Self::HighContrast => Some(ThemeKind::HighContrast),
48        }
49    }
50}
51
52impl Default for TuiConfig {
53    fn default() -> Self {
54        Self {
55            minimum_event_level: EventLevel::Warning,
56            theme: None,
57        }
58    }
59}
60
61#[derive(Debug)]
62pub enum TuiConfigError {
63    Io { path: PathBuf, message: String },
64    Malformed { path: PathBuf, message: String },
65}
66impl std::fmt::Display for TuiConfigError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Io { path, message } => {
70                write!(f, "cannot read TUI config {}: {message}", path.display())
71            }
72            Self::Malformed { path, message } => write!(
73                f,
74                "invalid TUI config {}: {message} (see docs/tui.toml.example)",
75                path.display()
76            ),
77        }
78    }
79}
80
81impl std::error::Error for TuiConfigError {}
82
83#[derive(Deserialize)]
84#[serde(deny_unknown_fields)]
85struct TuiConfigFile {
86    minimum_event_level: Option<LevelName>,
87    theme: Option<ThemeName>,
88}
89
90#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
91#[serde(rename_all = "lowercase")]
92enum LevelName {
93    Debug,
94    Info,
95    Warning,
96    Error,
97}
98
99#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
100#[serde(rename_all = "kebab-case")]
101enum ThemeName {
102    Auto,
103    Default,
104    Dark,
105    Mono,
106    HighContrast,
107}
108
109impl From<LevelName> for EventLevel {
110    fn from(level: LevelName) -> Self {
111        match level {
112            LevelName::Debug => Self::Debug,
113            LevelName::Info => Self::Info,
114            LevelName::Warning => Self::Warning,
115            LevelName::Error => Self::Error,
116        }
117    }
118}
119
120impl From<ThemeName> for ThemePreference {
121    fn from(theme: ThemeName) -> Self {
122        match theme {
123            ThemeName::Auto => Self::Auto,
124            ThemeName::Default => Self::Default,
125            ThemeName::Dark => Self::Dark,
126            ThemeName::Mono => Self::Mono,
127            ThemeName::HighContrast => Self::HighContrast,
128        }
129    }
130}
131
132/// The platform config path for this process environment.
133pub fn config_path() -> Option<PathBuf> {
134    paths::user_data_root()
135        .ok()
136        .map(|root| root.join(CONFIG_FILE_NAME))
137}
138
139/// Parse TOML text strictly. Unknown keys, wrong types, and invalid enum
140/// values are errors naming the offending key; the input text is never
141/// echoed back.
142pub fn parse(text: &str, path: &Path) -> Result<TuiConfig, TuiConfigError> {
143    let file: TuiConfigFile = toml::from_str(text).map_err(|error| TuiConfigError::Malformed {
144        path: path.to_owned(),
145        message: sanitize_toml_error(text, &error),
146    })?;
147    let defaults = TuiConfig::default();
148    Ok(TuiConfig {
149        minimum_event_level: file
150            .minimum_event_level
151            .map(EventLevel::from)
152            .unwrap_or(defaults.minimum_event_level),
153        theme: file.theme.map(ThemePreference::from),
154    })
155}
156
157/// Load the config from an explicit path. A missing file yields defaults.
158pub fn load_from(path: &Path) -> Result<TuiConfig, TuiConfigError> {
159    match std::fs::read_to_string(path) {
160        Ok(text) => parse(&text, path),
161        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(TuiConfig::default()),
162        Err(error) => Err(TuiConfigError::Io {
163            path: path.to_owned(),
164            message: error.to_string(),
165        }),
166    }
167}
168
169/// Load from the platform config path, or an explicit override path.
170pub fn load(override_path: Option<&Path>) -> Result<TuiConfig, TuiConfigError> {
171    match override_path {
172        Some(path) => load_from(path),
173        None => match config_path() {
174            Some(path) => load_from(&path),
175            None => Ok(TuiConfig::default()),
176        },
177    }
178}
179
180/// TOML errors do not always name the offending key; recover it from the
181/// error span by taking the line's key token. Values are never included.
182fn sanitize_toml_error(text: &str, error: &toml::de::Error) -> String {
183    if let Some(span) = error.span() {
184        let mut offset = 0;
185        for line in text.split('\n') {
186            let line_end = offset + line.len();
187            if span.start >= offset && span.start <= line_end {
188                if let Some(key) = line.split('=').next().map(str::trim)
189                    && !key.is_empty()
190                {
191                    let kind = if error.message().contains("unknown field") {
192                        "unknown key"
193                    } else {
194                        "invalid value for key"
195                    };
196                    return format!("{kind} `{key}`");
197                }
198                return format!("malformed TOML at byte offset {}", span.start);
199            }
200            offset = line_end + 1;
201        }
202    }
203    "malformed TOML".into()
204}
205
206#[cfg(test)]
207mod tests {
208    use super::{EventLevel, ThemePreference, TuiConfig, load_from, parse};
209    use std::path::Path;
210
211    #[test]
212    fn defaults_hide_debug_and_info_diagnostics() {
213        let config = TuiConfig::default();
214        assert_eq!(config.minimum_event_level, EventLevel::Warning);
215        assert_eq!(config.theme, None);
216    }
217
218    #[test]
219    fn full_document_and_empty_document_parse() {
220        let path = Path::new("tui.toml");
221        let config = parse(
222            "minimum_event_level = \"debug\"\ntheme = \"high-contrast\"\n",
223            path,
224        )
225        .expect("full config");
226        assert_eq!(config.minimum_event_level, EventLevel::Debug);
227        assert_eq!(config.theme, Some(ThemePreference::HighContrast));
228        assert_eq!(parse("", path).expect("empty"), TuiConfig::default());
229        assert_eq!(
230            parse("theme = \"mono\"\n", path).expect("theme only").theme,
231            Some(ThemePreference::Mono)
232        );
233        assert_eq!(
234            parse("theme = \"dark\"\n", path).expect("dark theme").theme,
235            Some(ThemePreference::Dark)
236        );
237        assert_eq!(
238            parse("theme = \"auto\"\n", path).expect("auto theme").theme,
239            Some(ThemePreference::Auto)
240        );
241    }
242
243    #[test]
244    fn unknown_keys_and_invalid_values_fail_with_actionable_key_names() {
245        let path = Path::new("tui.toml");
246        let error = parse("minimum_event_level = \"trace\"\n", path).expect_err("bad level");
247        let message = error.to_string();
248        assert!(message.contains("tui.toml"));
249        assert!(message.contains("minimum_event_level"), "{message}");
250        assert!(!message.contains("trace"), "no raw value echo: {message}");
251
252        let error = parse("unknown_key = 1\n", path).expect_err("unknown key");
253        assert!(error.to_string().contains("unknown_key"));
254
255        let error = parse("minimum_event_level = 3\n", path).expect_err("wrong type");
256        assert!(error.to_string().contains("minimum_event_level"));
257
258        let error = parse("not = [toml\n", path).expect_err("syntax error");
259        assert!(error.to_string().contains("tui.toml"));
260    }
261
262    #[test]
263    fn missing_file_uses_defaults_and_explicit_path_loads() {
264        let directory = tempfile::tempdir().expect("tempdir");
265        let missing = directory.path().join("absent.toml");
266        assert_eq!(
267            super::load_from(&missing).expect("missing"),
268            TuiConfig::default()
269        );
270        let present = directory.path().join("tui.toml");
271        std::fs::write(&present, "minimum_event_level = \"info\"\n").expect("write");
272        assert_eq!(
273            super::load_from(&present)
274                .expect("load")
275                .minimum_event_level,
276            EventLevel::Info
277        );
278    }
279
280    #[test]
281    fn malformed_unknown_and_permission_errors_preserve_path_without_values() {
282        let directory = tempfile::tempdir().expect("tempdir");
283        let malformed = directory.path().join("malformed.toml");
284        std::fs::write(&malformed, "theme = \"secret-invalid-value\"\n").expect("write");
285        let message = load_from(&malformed).expect_err("malformed").to_string();
286        assert!(message.contains(&malformed.display().to_string()));
287        assert!(message.contains("theme"));
288        assert!(!message.contains("secret-invalid-value"));
289
290        let unknown = directory.path().join("unknown.toml");
291        std::fs::write(&unknown, "unexpected_setting = \"secret-value\"\n").expect("write");
292        let message = load_from(&unknown).expect_err("unknown key").to_string();
293        assert!(message.contains(&unknown.display().to_string()));
294        assert!(message.contains("unexpected_setting"));
295        assert!(!message.contains("secret-value"));
296
297        #[cfg(unix)]
298        {
299            use std::os::unix::fs::PermissionsExt as _;
300
301            let unreadable = directory.path().join("unreadable.toml");
302            std::fs::write(&unreadable, "theme = \"mono\"\n").expect("write");
303            std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000))
304                .expect("remove permissions");
305            let error = load_from(&unreadable).expect_err("permission error");
306            assert!(
307                error
308                    .to_string()
309                    .contains(&unreadable.display().to_string())
310            );
311        }
312    }
313}