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
9fn 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
33pub(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 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#[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#[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 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
214fn 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 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 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}