1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct TuiConfig {
22 pub minimum_event_level: EventLevel,
26 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
132pub fn config_path() -> Option<PathBuf> {
134 paths::user_data_root()
135 .ok()
136 .map(|root| root.join(CONFIG_FILE_NAME))
137}
138
139pub 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
157pub 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
169pub 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
180fn 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}