Skip to main content

ctx_tui/
config.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4use toml::{Table, Value};
5
6use crate::layout::{LayoutError, Node, coerce_string, default_layout, parse_layout};
7use crate::multiplexer::MultiplexerKind;
8
9/// The home directory of a passwd entry produced by `lookup`.
10///
11/// getpwnam/getpwuid share a static buffer; ctx only calls them from these
12/// rare fallbacks, never concurrently with themselves in practice.
13fn passwd_home(lookup: impl FnOnce() -> *mut libc::passwd) -> Option<PathBuf> {
14    use std::os::unix::ffi::OsStrExt;
15
16    let entry = lookup();
17    if entry.is_null() {
18        return None;
19    }
20    let dir = unsafe { (*entry).pw_dir };
21    if dir.is_null() {
22        return None;
23    }
24    let bytes = unsafe { std::ffi::CStr::from_ptr(dir) }.to_bytes();
25    Some(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
26}
27
28fn user_home(user: &str) -> Option<PathBuf> {
29    let name = std::ffi::CString::new(user).ok()?;
30    passwd_home(|| unsafe { libc::getpwnam(name.as_ptr()) })
31}
32
33/// $HOME, falling back to the pwd database like Python's Path.home().
34pub(crate) fn home() -> PathBuf {
35    if let Some(home) = env::var_os("HOME").filter(|home| !home.is_empty()) {
36        return PathBuf::from(home);
37    }
38    passwd_home(|| unsafe { libc::getpwuid(libc::getuid()) }).unwrap_or_else(|| PathBuf::from("/"))
39}
40
41fn xdg_dir(variable: &str, fallback: &str) -> PathBuf {
42    match env::var(variable) {
43        Ok(value) if !value.is_empty() => PathBuf::from(value),
44        _ => home().join(fallback),
45    }
46}
47
48pub fn config_path() -> PathBuf {
49    xdg_dir("XDG_CONFIG_HOME", ".config")
50        .join("ctx")
51        .join("config.toml")
52}
53
54fn data_dir() -> PathBuf {
55    xdg_dir("XDG_DATA_HOME", ".local/share").join("ctx")
56}
57
58fn expand_user(path: &str) -> PathBuf {
59    if path == "~" {
60        return home();
61    }
62    if let Some(rest) = path.strip_prefix("~/") {
63        return home().join(rest);
64    }
65    if let Some(rest) = path.strip_prefix('~') {
66        // ~user forms resolve via the pwd database, like Python's
67        // expanduser; an unknown user stays verbatim, also like it.
68        let (user, sub) = match rest.split_once('/') {
69            Some((user, sub)) => (user, Some(sub)),
70            None => (rest, None),
71        };
72        if let Some(dir) = user_home(user) {
73            return match sub {
74                Some(sub) => dir.join(sub),
75                None => dir,
76            };
77        }
78    }
79    PathBuf::from(path)
80}
81
82#[derive(Debug, thiserror::Error)]
83pub enum ConfigError {
84    #[error("{0}")]
85    Config(String),
86    #[error("{0}")]
87    Layout(#[from] LayoutError),
88}
89
90fn config_err<T>(message: impl Into<String>) -> Result<T, ConfigError> {
91    Err(ConfigError::Config(message.into()))
92}
93
94pub const BUILTIN_STATUS: &[&str] = &["agent", "github"];
95
96/// A named status column in listings, filled by a command or a built-in.
97///
98/// `interval` is the column's sampling period in seconds; None picks the
99/// provider's default.
100#[derive(Debug, Clone, PartialEq)]
101pub struct StatusColumn {
102    pub name: String,
103    pub command: Option<String>,
104    pub builtin: Option<String>,
105    pub interval: Option<f64>,
106}
107
108/// TUI colours; the defaults stick to the terminal's ANSI palette.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Theme {
111    pub foreground: String,
112    pub selection: String,
113    pub border_active: String,
114    pub border_inactive: String,
115}
116
117impl Default for Theme {
118    fn default() -> Theme {
119        Theme {
120            foreground: "ansi_default".to_string(),
121            selection: "ansi_blue".to_string(),
122            border_active: "ansi_blue".to_string(),
123            border_inactive: "ansi_default".to_string(),
124        }
125    }
126}
127
128#[derive(Debug, Clone, PartialEq)]
129pub struct Config {
130    pub contexts_dir: PathBuf,
131    pub repos_dir: PathBuf,
132    pub archive_dir: PathBuf,
133    pub branch_prefix: String,
134    pub multiplexer: MultiplexerKind,
135    pub nerd_font: bool,
136    pub layout: Node,
137    pub status: Vec<StatusColumn>,
138    pub theme: Theme,
139}
140
141impl Default for Config {
142    fn default() -> Config {
143        let data = data_dir();
144        Config {
145            contexts_dir: data.join("contexts"),
146            repos_dir: data.join("repos"),
147            archive_dir: data.join("archive"),
148            branch_prefix: String::new(),
149            multiplexer: MultiplexerKind::Tmux,
150            nerd_font: true,
151            layout: default_layout(),
152            status: Vec::new(),
153            theme: Theme::default(),
154        }
155    }
156}
157
158pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
159    let text = match std::fs::read_to_string(path) {
160        Ok(text) => text,
161        // Only absence means defaults; an existing config that cannot be
162        // read must not be silently ignored.
163        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
164        Err(err) => return config_err(format!("cannot read {}: {err}", path.display())),
165    };
166    let data: Table =
167        toml::from_str(&text).map_err(|exc| ConfigError::Config(exc.message().to_string()))?;
168    let mut cfg = Config::default();
169    if let Some(value) = data.get("contexts_dir") {
170        cfg.contexts_dir = expand_user(&coerce_string(value));
171    }
172    if let Some(value) = data.get("repos_dir") {
173        cfg.repos_dir = expand_user(&coerce_string(value));
174    }
175    if let Some(value) = data.get("archive_dir") {
176        cfg.archive_dir = expand_user(&coerce_string(value));
177    }
178    if let Some(value) = data.get("branch_prefix") {
179        cfg.branch_prefix = coerce_string(value);
180    }
181    if let Some(value) = data.get("multiplexer") {
182        let raw = coerce_string(value);
183        match MultiplexerKind::parse(&raw) {
184            Some(kind) => cfg.multiplexer = kind,
185            None => {
186                return config_err(format!(
187                    "unknown multiplexer '{raw}' (supported: {})",
188                    MultiplexerKind::names()
189                ));
190            }
191        }
192    }
193    if let Some(value) = data.get("nerd_font") {
194        match value {
195            Value::Boolean(flag) => cfg.nerd_font = *flag,
196            _ => return config_err("nerd_font must be a boolean"),
197        }
198    }
199    if let Some(value) = data.get("layout") {
200        match value {
201            Value::Table(table) => cfg.layout = parse_layout(table)?,
202            _ => return config_err("layout must be a table"),
203        }
204    }
205    if let Some(value) = data.get("status") {
206        cfg.status = parse_status(value)?;
207    }
208    if let Some(value) = data.get("theme") {
209        cfg.theme = parse_theme(value)?;
210    }
211    Ok(cfg)
212}
213
214// Hex only: the TUI toolkit's colour names are an implementation detail.
215fn is_hex_colour(value: &str) -> bool {
216    let Some(digits) = value.strip_prefix('#') else {
217        return false;
218    };
219    digits.len() == 6 && digits.chars().all(|c| c.is_ascii_hexdigit())
220}
221
222fn parse_theme(data: &Value) -> Result<Theme, ConfigError> {
223    let Value::Table(data) = data else {
224        return config_err("theme must be a table");
225    };
226    const KNOWN: &[&str] = &[
227        "foreground",
228        "selection",
229        "border_active",
230        "border_inactive",
231    ];
232    let mut unknown: Vec<&str> = data
233        .keys()
234        .map(String::as_str)
235        .filter(|key| !KNOWN.contains(key))
236        .collect();
237    if !unknown.is_empty() {
238        unknown.sort_unstable();
239        return config_err(format!("unknown theme key(s): {}", unknown.join(", ")));
240    }
241    let mut theme = Theme::default();
242    for (key, value) in data {
243        match value {
244            Value::String(colour) if is_hex_colour(colour) => {
245                let field = match key.as_str() {
246                    "foreground" => &mut theme.foreground,
247                    "selection" => &mut theme.selection,
248                    "border_active" => &mut theme.border_active,
249                    "border_inactive" => &mut theme.border_inactive,
250                    _ => unreachable!("unknown keys were rejected above"),
251                };
252                *field = colour.clone();
253            }
254            _ => {
255                return config_err(format!("theme {key} must be a hex colour like '#2d3f76'"));
256            }
257        }
258    }
259    Ok(theme)
260}
261
262fn parse_status(data: &Value) -> Result<Vec<StatusColumn>, ConfigError> {
263    let Value::Array(data) = data else {
264        return config_err("status must be an array of tables ([[status]])");
265    };
266    let mut columns = Vec::new();
267    for entry in data {
268        let entry = match entry {
269            Value::Table(table) if table.contains_key("name") => table,
270            _ => return config_err("each [[status]] needs a name"),
271        };
272        if entry.contains_key("command") == entry.contains_key("builtin") {
273            return config_err("each [[status]] needs either a command or a builtin");
274        }
275        let builtin = entry.get("builtin").map(coerce_string);
276        if let Some(builtin) = &builtin
277            && !BUILTIN_STATUS.contains(&builtin.as_str())
278        {
279            return config_err(format!(
280                "unknown status builtin '{builtin}' (supported: {})",
281                BUILTIN_STATUS.join(", ")
282            ));
283        }
284        let interval = match entry.get("interval") {
285            None => None,
286            Some(Value::Integer(seconds)) if *seconds >= 0 => Some(*seconds as f64),
287            Some(Value::Float(seconds)) if *seconds >= 0.0 => Some(*seconds),
288            Some(_) => {
289                return config_err("status interval must be a non-negative number of seconds");
290            }
291        };
292        columns.push(StatusColumn {
293            name: coerce_string(&entry["name"]),
294            command: entry.get("command").map(coerce_string),
295            builtin,
296            interval,
297        });
298    }
299    let mut names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
300    names.sort_unstable();
301    names.dedup();
302    if names.len() != columns.len() {
303        return config_err("status names must be unique");
304    }
305    Ok(columns)
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::layout::Pane;
312
313    fn write_config(dir: &Path, text: &str) -> PathBuf {
314        let path = dir.join("config.toml");
315        std::fs::write(&path, text).unwrap();
316        path
317    }
318
319    fn load(text: &str) -> Result<Config, ConfigError> {
320        let dir = tempfile::tempdir().unwrap();
321        load_config(&write_config(dir.path(), text))
322    }
323
324    fn err(text: &str) -> String {
325        load(text).expect_err("config must be rejected").to_string()
326    }
327
328    fn column(
329        name: &str,
330        command: Option<&str>,
331        builtin: Option<&str>,
332        interval: Option<f64>,
333    ) -> StatusColumn {
334        StatusColumn {
335            name: name.to_string(),
336            command: command.map(str::to_string),
337            builtin: builtin.map(str::to_string),
338            interval,
339        }
340    }
341
342    #[test]
343    fn missing_file_gives_defaults() {
344        let dir = tempfile::tempdir().unwrap();
345
346        let cfg = load_config(&dir.path().join("missing.toml")).unwrap();
347
348        assert_eq!(cfg, Config::default());
349    }
350
351    #[test]
352    fn contexts_dir_override() {
353        let cfg = load("contexts_dir = \"/data/contexts\"").unwrap();
354
355        assert_eq!(cfg.contexts_dir, PathBuf::from("/data/contexts"));
356    }
357
358    #[test]
359    fn repos_dir_override_expands_user() {
360        let cfg = load("repos_dir = \"~/repos\"").unwrap();
361
362        assert_eq!(cfg.repos_dir, home().join("repos"));
363    }
364
365    #[test]
366    fn archive_dir_override_expands_user() {
367        let cfg = load("archive_dir = \"~/archive\"").unwrap();
368
369        assert_eq!(cfg.archive_dir, home().join("archive"));
370    }
371
372    #[test]
373    fn tilde_user_paths_resolve_via_the_pwd_database() {
374        let root_home = user_home("root").expect("root exists in passwd");
375
376        assert_eq!(expand_user("~root"), root_home);
377        assert_eq!(expand_user("~root/repos"), root_home.join("repos"));
378        // Unknown users stay verbatim, like Python's expanduser.
379        assert_eq!(
380            expand_user("~no-such-user-xyz/repos"),
381            PathBuf::from("~no-such-user-xyz/repos")
382        );
383    }
384
385    #[test]
386    fn unreadable_config_errors_instead_of_defaulting() {
387        use std::os::unix::fs::PermissionsExt;
388
389        let dir = tempfile::tempdir().unwrap();
390        let path = write_config(dir.path(), "branch_prefix = \"mb/\"");
391        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
392
393        let result = load_config(&path);
394
395        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
396        assert!(
397            result
398                .expect_err("must not default")
399                .to_string()
400                .contains("cannot read")
401        );
402    }
403
404    #[test]
405    fn branch_prefix_override() {
406        assert_eq!(
407            load("branch_prefix = \"mb/\"").unwrap().branch_prefix,
408            "mb/"
409        );
410    }
411
412    #[test]
413    fn status_columns_override() {
414        let cfg = load(
415            "[[status]]\nname = \"claude\"\nbuiltin = \"agent\"\n\n\
416             [[status]]\nname = \"ci\"\ncommand = \"my-ci-status\"",
417        )
418        .unwrap();
419
420        assert_eq!(
421            cfg.status,
422            vec![
423                column("claude", None, Some("agent"), None),
424                column("ci", Some("my-ci-status"), None, None),
425            ]
426        );
427    }
428
429    #[test]
430    fn no_status_columns_by_default() {
431        let dir = tempfile::tempdir().unwrap();
432
433        let cfg = load_config(&dir.path().join("missing.toml")).unwrap();
434
435        assert_eq!(cfg.status, vec![]);
436    }
437
438    #[test]
439    fn status_requires_a_name() {
440        assert!(err("[[status]]\ncommand = \"true\"").contains("needs a name"));
441    }
442
443    #[test]
444    fn status_requires_a_command_or_a_builtin() {
445        assert!(err("[[status]]\nname = \"ci\"").contains("either a command or a builtin"));
446    }
447
448    #[test]
449    fn status_rejects_a_command_combined_with_a_builtin() {
450        let text = "[[status]]\nname = \"ci\"\ncommand = \"true\"\nbuiltin = \"agent\"";
451
452        assert!(err(text).contains("either a command or a builtin"));
453    }
454
455    #[test]
456    fn status_interval_override() {
457        let cfg = load("[[status]]\nname = \"ci\"\nbuiltin = \"github\"\ninterval = 60").unwrap();
458
459        assert_eq!(
460            cfg.status,
461            vec![column("ci", None, Some("github"), Some(60.0))]
462        );
463    }
464
465    #[test]
466    fn status_rejects_negative_intervals() {
467        let text = "[[status]]\nname = \"ci\"\ncommand = \"true\"\ninterval = -1";
468
469        assert!(err(text).contains("non-negative number"));
470    }
471
472    #[test]
473    fn status_rejects_non_numeric_intervals() {
474        let text = "[[status]]\nname = \"ci\"\ncommand = \"true\"\ninterval = \"60\"";
475
476        assert!(err(text).contains("non-negative number"));
477    }
478
479    #[test]
480    fn status_rejects_unknown_builtins() {
481        assert!(
482            err("[[status]]\nname = \"ci\"\nbuiltin = \"gitlab\"")
483                .contains("unknown status builtin 'gitlab'")
484        );
485    }
486
487    #[test]
488    fn status_rejects_duplicate_names() {
489        let text = "[[status]]\nname = \"ci\"\ncommand = \"true\"\n\n\
490                    [[status]]\nname = \"ci\"\nbuiltin = \"github\"";
491
492        assert!(err(text).contains("unique"));
493    }
494
495    #[test]
496    fn theme_defaults_to_the_ansi_palette() {
497        let dir = tempfile::tempdir().unwrap();
498
499        let cfg = load_config(&dir.path().join("missing.toml")).unwrap();
500
501        assert_eq!(cfg.theme, Theme::default());
502        assert_eq!(cfg.theme.selection, "ansi_blue");
503    }
504
505    #[test]
506    fn theme_override() {
507        let cfg = load("[theme]\nselection = \"#2d3f76\"\nborder_active = \"#ff966c\"").unwrap();
508
509        assert_eq!(cfg.theme.selection, "#2d3f76");
510        assert_eq!(cfg.theme.border_active, "#ff966c");
511        assert_eq!(cfg.theme.foreground, "ansi_default");
512    }
513
514    #[test]
515    fn theme_rejects_unknown_keys() {
516        assert!(err("[theme]\nselektion = \"#2d3f76\"").contains("unknown theme key"));
517    }
518
519    #[test]
520    fn theme_rejects_malformed_colours() {
521        assert!(err("[theme]\nselection = \"#12\"").contains("colour"));
522    }
523
524    #[test]
525    fn theme_rejects_colour_names() {
526        // Toolkit colour names are an implementation detail, not config surface.
527        assert!(err("[theme]\nselection = \"ansi_blue\"").contains("hex colour"));
528    }
529
530    #[test]
531    fn multiplexer_override() {
532        let cfg = load("multiplexer = \"zellij\"").unwrap();
533
534        assert_eq!(cfg.multiplexer, MultiplexerKind::Zellij);
535    }
536
537    #[test]
538    fn layout_override() {
539        let cfg = load("layout = { command = \"nvim\" }").unwrap();
540
541        assert_eq!(
542            cfg.layout,
543            Node::Pane(Pane {
544                command: Some("nvim".to_string()),
545                ..Pane::default()
546            })
547        );
548    }
549
550    #[test]
551    fn unknown_multiplexer_rejected() {
552        assert!(err("multiplexer = \"screen\"").contains("unknown multiplexer"));
553    }
554
555    #[test]
556    fn nerd_font_is_on_by_default_and_can_be_disabled() {
557        let dir = tempfile::tempdir().unwrap();
558        assert!(
559            load_config(&dir.path().join("missing.toml"))
560                .unwrap()
561                .nerd_font
562        );
563        assert!(!load("nerd_font = false").unwrap().nerd_font);
564    }
565
566    #[test]
567    fn nerd_font_rejects_non_booleans() {
568        assert!(err("nerd_font = \"yes\"").contains("nerd_font"));
569    }
570}