Skip to main content

kiosk_core/config/
mod.rs

1pub mod keys;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use std::{
6    fmt::Write as _,
7    fs,
8    io::Write as _,
9    path::{Path, PathBuf},
10};
11
12pub use keys::{Command, KeysConfig};
13
14pub const APP_NAME: &str = "kiosk";
15
16fn config_dir() -> PathBuf {
17    // Use ~/.config on both Linux and macOS (not ~/Library/Application Support)
18    #[cfg(unix)]
19    {
20        if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME")
21            && !xdg_config_home.is_empty()
22        {
23            return PathBuf::from(xdg_config_home).join(APP_NAME);
24        }
25        dirs::home_dir()
26            .expect("Unable to find home directory")
27            .join(".config")
28            .join(APP_NAME)
29    }
30    #[cfg(windows)]
31    {
32        dirs::config_dir()
33            .expect("Unable to find config directory")
34            .join(APP_NAME)
35    }
36}
37
38fn config_file() -> PathBuf {
39    config_dir().join("config.toml")
40}
41
42pub const DEFAULT_SEARCH_DEPTH: u16 = 1;
43
44#[derive(Debug, Serialize, Deserialize, Clone)]
45#[serde(untagged)]
46pub enum SearchDirEntry {
47    Simple(String),
48    Rich { path: String, depth: Option<u16> },
49}
50
51#[derive(Debug, Serialize, Deserialize, Clone, Default)]
52#[serde(deny_unknown_fields)]
53pub struct Config {
54    /// Directories to scan for git repositories. Each directory can be scanned to a specified depth, with a default of 1 (i.e. just the top level).
55    /// Supports `~` for the home directory. For example:
56    /// ```toml
57    /// search_dirs = ["~/Development", { path = "~/Work", depth = 2 }]
58    /// ```
59    pub search_dirs: Vec<SearchDirEntry>,
60
61    /// Layout when creating a new tmux session.
62    #[serde(default)]
63    pub session: SessionConfig,
64
65    /// Color theme configuration.
66    #[serde(default)]
67    pub theme: ThemeConfig,
68
69    /// Key binding configuration.
70    /// To unbind an inherited key mapping, assign it to `noop`.
71    #[serde(default)]
72    pub keys: KeysConfig,
73
74    /// Agent detection configuration.
75    #[serde(default)]
76    pub agent: AgentConfig,
77}
78
79/// Agent detection configuration.
80///
81/// ```toml
82/// [agent]
83/// enabled = true
84/// poll_interval_ms = 2000
85/// ```
86#[derive(Debug, Serialize, Deserialize, Clone)]
87#[serde(deny_unknown_fields, default)]
88pub struct AgentConfig {
89    /// Whether agent status detection is enabled.
90    /// Set to `false` to completely disable agent polling and status display.
91    pub enabled: bool,
92    /// Interval in milliseconds between agent status polls.
93    pub poll_interval_ms: u64,
94    /// Label text for each agent state shown in the branch picker.
95    #[serde(default)]
96    pub labels: AgentLabelsConfig,
97}
98
99impl Default for AgentConfig {
100    fn default() -> Self {
101        Self {
102            enabled: true,
103            poll_interval_ms: 500,
104            labels: AgentLabelsConfig::default(),
105        }
106    }
107}
108
109/// Label text for each agent state shown in the branch picker.
110///
111/// ```toml
112/// [agent.labels]
113/// running = "[RUNNING]"
114/// waiting = "[WAITING]"
115/// idle = "[IDLE]"
116/// unknown = "[UNKNOWN]"
117/// ```
118#[derive(Debug, Serialize, Deserialize, Clone)]
119#[serde(deny_unknown_fields, default)]
120pub struct AgentLabelsConfig {
121    pub running: String,
122    pub waiting: String,
123    pub idle: String,
124    pub unknown: String,
125}
126
127impl Default for AgentLabelsConfig {
128    fn default() -> Self {
129        Self {
130            running: "[RUNNING]".to_string(),
131            waiting: "[WAITING]".to_string(),
132            idle: "[IDLE]".to_string(),
133            unknown: "[UNKNOWN]".to_string(),
134        }
135    }
136}
137
138impl AgentLabelsConfig {
139    /// Maximum char count across all labels (for fixed-width column padding).
140    pub fn max_label_width(&self) -> usize {
141        [&self.running, &self.waiting, &self.idle, &self.unknown]
142            .iter()
143            .map(|s| s.chars().count())
144            .max()
145            .unwrap_or(0)
146    }
147}
148
149#[derive(Debug, Serialize, Deserialize, Clone, Default)]
150#[serde(deny_unknown_fields)]
151pub struct SessionConfig {
152    /// Command to run in a split pane when creating a new session. For example, to open
153    /// Helix in a vertical split:
154    /// ```toml
155    /// [session]
156    /// split_command = "hx"
157    /// ```
158    pub split_command: Option<String>,
159}
160
161// The struct must be defined outside the macro so that xtask's syn parser
162// can discover it for README doc generation.
163#[derive(Debug, Deserialize, Serialize, Clone)]
164#[serde(deny_unknown_fields, default)]
165pub struct ThemeConfig {
166    /// Primary accent color (default: "magenta").
167    #[serde(deserialize_with = "deserialize_color")]
168    pub accent: ThemeColor,
169    /// Secondary accent color (default: "cyan").
170    #[serde(deserialize_with = "deserialize_color")]
171    pub secondary: ThemeColor,
172    /// Tertiary accent color (default: "green").
173    #[serde(deserialize_with = "deserialize_color")]
174    pub tertiary: ThemeColor,
175    /// Success/positive color (default: "green").
176    #[serde(deserialize_with = "deserialize_color")]
177    pub success: ThemeColor,
178    /// Error color (default: "red").
179    #[serde(deserialize_with = "deserialize_color")]
180    pub error: ThemeColor,
181    /// Warning color (default: "yellow").
182    #[serde(deserialize_with = "deserialize_color")]
183    pub warning: ThemeColor,
184    /// Muted/dim text color (default: "`dark_gray`").
185    #[serde(deserialize_with = "deserialize_color")]
186    pub muted: ThemeColor,
187    /// Border color (default: "`dark_gray`").
188    #[serde(deserialize_with = "deserialize_color")]
189    pub border: ThemeColor,
190    /// Hint/key binding color (default: "blue").
191    #[serde(deserialize_with = "deserialize_color")]
192    pub hint: ThemeColor,
193    /// Foreground color for highlighted/selected items (default: "black").
194    #[serde(deserialize_with = "deserialize_color")]
195    pub highlight_fg: ThemeColor,
196}
197
198/// Single source of truth for theme defaults. Generates the `Default` impl
199/// so adding a field only requires updating one place (plus the struct above).
200macro_rules! theme_defaults {
201    ($($field:ident => $color:ident),* $(,)?) => {
202        impl Default for ThemeConfig {
203            fn default() -> Self {
204                Self {
205                    $($field: ThemeColor::Named(NamedColor::$color)),*
206                }
207            }
208        }
209    };
210}
211
212theme_defaults! {
213    accent       => Magenta,
214    secondary    => Cyan,
215    tertiary     => Green,
216    success      => Green,
217    error        => Red,
218    warning      => Yellow,
219    muted        => DarkGray,
220    border       => DarkGray,
221    hint         => Blue,
222    highlight_fg => Black,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum ThemeColor {
227    Named(NamedColor),
228    Rgb(u8, u8, u8),
229}
230
231/// Single source of truth for every `NamedColor` variant, its canonical config
232/// string, and any accepted aliases. The macro generates the enum plus `all()`,
233/// `as_str()`, `resolve_alias()`, and `aliases()`.
234macro_rules! define_named_colors {
235    ($(
236        $variant:ident {
237            name: $name:literal
238            $(, aliases: [$($alias:literal),+ $(,)?])?
239        }
240    ),* $(,)?) => {
241        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
242        pub enum NamedColor { $($variant),* }
243
244        impl NamedColor {
245            /// All named colours with their canonical config strings.
246            pub const fn all() -> &'static [(&'static str, NamedColor)] {
247                &[$(($name, NamedColor::$variant)),*]
248            }
249
250            pub const fn as_str(self) -> &'static str {
251                match self {
252                    $(NamedColor::$variant => $name),*
253                }
254            }
255
256            /// Resolve alternative spellings to canonical names.
257            pub fn resolve_alias(s: &str) -> &str {
258                match s {
259                    $($($($alias)|+ => $name,)?)*
260                    other => other,
261                }
262            }
263
264            /// All (alias, canonical) pairs for documentation.
265            pub const fn aliases() -> &'static [(&'static str, &'static str)] {
266                &[$($( $( ($alias, $name), )+ )?)*]
267            }
268        }
269    };
270}
271
272define_named_colors! {
273    Black   { name: "black" },
274    Red     { name: "red" },
275    Green   { name: "green" },
276    Yellow  { name: "yellow" },
277    Blue    { name: "blue" },
278    Magenta { name: "magenta" },
279    Cyan    { name: "cyan" },
280    White   { name: "white" },
281    Gray    { name: "gray", aliases: ["grey"] },
282    DarkGray { name: "dark_gray", aliases: ["darkgray", "dark_grey", "darkgrey"] },
283}
284
285impl std::fmt::Display for ThemeColor {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            Self::Named(n) => f.write_str(n.as_str()),
289            Self::Rgb(r, g, b) => write!(f, "#{r:02x}{g:02x}{b:02x}"),
290        }
291    }
292}
293
294impl Serialize for ThemeColor {
295    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
296        serializer.serialize_str(&self.to_string())
297    }
298}
299
300impl ThemeColor {
301    pub fn parse(s: &str) -> Option<Self> {
302        if let Some(hex) = s.strip_prefix('#')
303            && hex.len() == 6
304        {
305            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
306            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
307            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
308            return Some(Self::Rgb(r, g, b));
309        }
310        let lower = s.to_lowercase();
311        let lookup = NamedColor::resolve_alias(&lower);
312        NamedColor::all()
313            .iter()
314            .find(|(name, _)| *name == lookup)
315            .map(|(_, color)| Self::Named(*color))
316    }
317}
318
319fn deserialize_color<'de, D>(deserializer: D) -> Result<ThemeColor, D::Error>
320where
321    D: serde::Deserializer<'de>,
322{
323    let s = String::deserialize(deserializer)?;
324    ThemeColor::parse(&s).ok_or_else(|| {
325        let names: Vec<&str> = NamedColor::all().iter().map(|(name, _)| *name).collect();
326        serde::de::Error::custom(format!(
327            "invalid color '{s}': expected a named color ({}) or hex (#rrggbb)",
328            names.join(", "),
329        ))
330    })
331}
332
333impl Config {
334    pub fn resolved_search_dirs(&self) -> Vec<(PathBuf, u16)> {
335        self.search_dirs
336            .iter()
337            .filter_map(|entry| {
338                let (path_str, depth) = match entry {
339                    SearchDirEntry::Simple(path) => (path.as_str(), DEFAULT_SEARCH_DEPTH),
340                    SearchDirEntry::Rich { path, depth } => {
341                        (path.as_str(), depth.unwrap_or(DEFAULT_SEARCH_DEPTH))
342                    }
343                };
344
345                let resolved_path =
346                    crate::paths::expand_tilde(path_str).unwrap_or_else(|| PathBuf::from(path_str));
347
348                if resolved_path.is_dir() {
349                    Some((resolved_path, depth))
350                } else {
351                    None
352                }
353            })
354            .collect()
355    }
356}
357
358/// Minimum allowed poll interval to prevent accidental busy loops.
359pub const MIN_POLL_INTERVAL_MS: u64 = 100;
360
361pub fn load_config_from_str(s: &str) -> Result<Config> {
362    let config: Config = toml::from_str(s)?;
363    validate_config(&config)?;
364    Ok(config)
365}
366
367fn validate_config(config: &Config) -> Result<()> {
368    if config.agent.poll_interval_ms < MIN_POLL_INTERVAL_MS {
369        anyhow::bail!(
370            "agent.poll_interval_ms must be at least {MIN_POLL_INTERVAL_MS}ms, got {}",
371            config.agent.poll_interval_ms
372        );
373    }
374    Ok(())
375}
376
377/// Check whether the default config file exists
378pub fn config_file_exists() -> bool {
379    config_file().exists()
380}
381
382/// Format a minimal config TOML string from search directories.
383pub fn format_default_config(dirs: &[String]) -> String {
384    let mut content = String::from(
385        "# See https://github.com/thomasschafer/kiosk/?tab=readme-ov-file#configuration for all options\n\n",
386    );
387    content.push_str("search_dirs = [");
388    for (i, d) in dirs.iter().enumerate() {
389        if i > 0 {
390            content.push_str(", ");
391        }
392        content.push('"');
393        // Escape for valid TOML basic strings
394        for c in d.chars() {
395            match c {
396                '\\' => content.push_str("\\\\"),
397                '"' => content.push_str("\\\""),
398                c if c.is_control() => {
399                    write!(content, "\\u{:04X}", c as u32).unwrap();
400                }
401                _ => content.push(c),
402            }
403        }
404        content.push('"');
405    }
406    content.push_str("]\n");
407    content
408}
409
410/// Write a default config file with the specified search directories.
411/// Creates parent directories as needed. Returns the path written to.
412pub fn write_default_config(dirs: &[String]) -> Result<PathBuf> {
413    let path = config_file();
414    if let Some(dir) = path.parent() {
415        fs::create_dir_all(dir)?;
416    }
417    let content = format_default_config(dirs);
418    let mut file = match fs::OpenOptions::new()
419        .write(true)
420        .create_new(true)
421        .open(&path)
422    {
423        Ok(file) => file,
424        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
425            anyhow::bail!("Config file already exists at {}", path.display());
426        }
427        Err(e) => return Err(e.into()),
428    };
429    file.write_all(content.as_bytes())?;
430    Ok(path)
431}
432
433pub fn load_config(config_override: Option<&Path>) -> Result<Config> {
434    let config_file = match config_override {
435        Some(path) => path.to_path_buf(),
436        None => config_file(),
437    };
438    if !config_file.exists() {
439        anyhow::bail!("Config file not found at {}", config_file.display());
440    }
441    let contents = fs::read_to_string(&config_file)?;
442    let config: Config = toml::from_str(&contents)?;
443    validate_config(&config)?;
444    Ok(config)
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_minimal_config() {
453        let config = load_config_from_str(r#"search_dirs = ["~/Development"]"#).unwrap();
454        assert!(
455            matches!(&config.search_dirs[0], SearchDirEntry::Simple(s) if s == "~/Development")
456        );
457        assert!(config.session.split_command.is_none());
458    }
459
460    #[test]
461    fn test_full_config() {
462        let config = load_config_from_str(
463            r#"
464search_dirs = ["~/Development", "~/Work"]
465
466[session]
467split_command = "hx"
468"#,
469        )
470        .unwrap();
471        assert_eq!(config.search_dirs.len(), 2);
472        assert!(
473            matches!(&config.search_dirs[0], SearchDirEntry::Simple(s) if s == "~/Development")
474        );
475        assert!(matches!(&config.search_dirs[1], SearchDirEntry::Simple(s) if s == "~/Work"));
476        assert_eq!(config.session.split_command.as_deref(), Some("hx"));
477    }
478
479    #[test]
480    fn test_empty_config_fails() {
481        let result = load_config_from_str("");
482        assert!(result.is_err());
483    }
484
485    #[test]
486    fn test_unknown_field_rejected() {
487        let result = load_config_from_str(
488            r#"
489search_dirs = ["~/Development"]
490unknown_field = true
491"#,
492        );
493        assert!(result.is_err());
494    }
495
496    #[test]
497    fn test_tilde_expansion() {
498        let config =
499            load_config_from_str(r#"search_dirs = ["~/", "~/nonexistent_dir_xyz"]"#).unwrap();
500        let dirs = config.resolved_search_dirs();
501        // ~ should resolve to home (which exists), nonexistent should be filtered
502        assert!(dirs.len() <= 1);
503        if let Some((d, depth)) = dirs.first() {
504            assert!(!d.to_string_lossy().contains('~'));
505            assert_eq!(*depth, 1); // default depth
506        }
507    }
508
509    #[test]
510    fn test_theme_config_defaults() {
511        let config = load_config_from_str(r#"search_dirs = ["~/Development"]"#).unwrap();
512        assert_eq!(config.theme.accent, ThemeColor::Named(NamedColor::Magenta));
513        assert_eq!(config.theme.secondary, ThemeColor::Named(NamedColor::Cyan));
514        assert_eq!(config.theme.tertiary, ThemeColor::Named(NamedColor::Green));
515        assert_eq!(config.theme.success, ThemeColor::Named(NamedColor::Green));
516        assert_eq!(config.theme.error, ThemeColor::Named(NamedColor::Red));
517        assert_eq!(config.theme.warning, ThemeColor::Named(NamedColor::Yellow));
518        assert_eq!(config.theme.muted, ThemeColor::Named(NamedColor::DarkGray));
519        assert_eq!(config.theme.border, ThemeColor::Named(NamedColor::DarkGray));
520        assert_eq!(config.theme.hint, ThemeColor::Named(NamedColor::Blue));
521        assert_eq!(
522            config.theme.highlight_fg,
523            ThemeColor::Named(NamedColor::Black)
524        );
525    }
526
527    #[test]
528    fn test_theme_config_custom() {
529        let config = load_config_from_str(
530            r##"
531search_dirs = ["~/Development"]
532
533[theme]
534accent = "blue"
535secondary = "#ff00ff"
536"##,
537        )
538        .unwrap();
539        assert_eq!(config.theme.accent, ThemeColor::Named(NamedColor::Blue));
540        assert_eq!(config.theme.secondary, ThemeColor::Rgb(255, 0, 255));
541        assert_eq!(config.theme.success, ThemeColor::Named(NamedColor::Green));
542    }
543
544    #[test]
545    fn test_theme_invalid_color_rejected() {
546        let result = load_config_from_str(
547            r#"
548search_dirs = ["~/Development"]
549
550[theme]
551accent = "notacolor"
552"#,
553        );
554        assert!(result.is_err());
555        let err = result.unwrap_err().to_string();
556        assert!(err.contains("invalid color"), "Error was: {err}");
557    }
558
559    #[test]
560    fn test_theme_color_parse() {
561        assert_eq!(
562            ThemeColor::parse("magenta"),
563            Some(ThemeColor::Named(NamedColor::Magenta))
564        );
565        assert_eq!(
566            ThemeColor::parse("RED"),
567            Some(ThemeColor::Named(NamedColor::Red))
568        );
569        assert_eq!(
570            ThemeColor::parse("#ff0000"),
571            Some(ThemeColor::Rgb(255, 0, 0))
572        );
573        assert_eq!(
574            ThemeColor::parse("gray"),
575            Some(ThemeColor::Named(NamedColor::Gray))
576        );
577        assert_eq!(
578            ThemeColor::parse("grey"),
579            Some(ThemeColor::Named(NamedColor::Gray))
580        );
581        assert_eq!(
582            ThemeColor::parse("dark_gray"),
583            Some(ThemeColor::Named(NamedColor::DarkGray))
584        );
585        assert_eq!(
586            ThemeColor::parse("darkgray"),
587            Some(ThemeColor::Named(NamedColor::DarkGray))
588        );
589        assert_eq!(
590            ThemeColor::parse("dark_grey"),
591            Some(ThemeColor::Named(NamedColor::DarkGray))
592        );
593        assert_eq!(ThemeColor::parse("notacolor"), None);
594        assert_eq!(ThemeColor::parse("#fff"), None);
595        assert_eq!(ThemeColor::parse("#zzzzzz"), None);
596    }
597
598    #[test]
599    fn test_theme_unknown_field_rejected() {
600        let result = load_config_from_str(
601            r#"
602search_dirs = ["~/Development"]
603
604[theme]
605accent = "blue"
606unknown = "bad"
607"#,
608        );
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn test_named_color_all_matches_as_str() {
614        for (name, color) in NamedColor::all() {
615            assert_eq!(
616                color.as_str(),
617                *name,
618                "NamedColor::{color:?} has mismatched all() and as_str()"
619            );
620        }
621    }
622
623    #[test]
624    fn test_named_color_all_are_parseable() {
625        for (name, color) in NamedColor::all() {
626            assert_eq!(
627                ThemeColor::parse(name),
628                Some(ThemeColor::Named(*color)),
629                "NamedColor canonical name '{name}' should parse"
630            );
631        }
632    }
633
634    #[test]
635    fn test_named_color_aliases_resolve() {
636        for (alias, canonical) in NamedColor::aliases() {
637            assert_eq!(
638                NamedColor::resolve_alias(alias),
639                *canonical,
640                "Alias '{alias}' should resolve to '{canonical}'"
641            );
642            assert!(
643                ThemeColor::parse(alias).is_some(),
644                "Alias '{alias}' should parse as a valid color"
645            );
646        }
647    }
648
649    #[test]
650    fn test_format_default_config_is_valid_toml() {
651        let dirs = vec!["~/Development".to_string(), "~/Work".to_string()];
652        let content = format_default_config(&dirs);
653        assert!(content.contains("search_dirs"));
654        let _config: Config = toml::from_str(&content).unwrap();
655    }
656
657    #[test]
658    fn test_format_default_config_roundtrip() {
659        let dirs = vec!["~/Projects".to_string(), "~/Code".to_string()];
660        let content = format_default_config(&dirs);
661        let config = load_config_from_str(&content).unwrap();
662        let paths: Vec<String> = config
663            .search_dirs
664            .iter()
665            .map(|e| match e {
666                SearchDirEntry::Simple(s) => s.clone(),
667                SearchDirEntry::Rich { path, .. } => path.clone(),
668            })
669            .collect();
670        assert_eq!(paths, dirs);
671    }
672
673    #[test]
674    fn test_format_default_config_escapes_special_chars() {
675        let dirs = vec![
676            "C:\\Users\\Tom".to_string(),
677            "path with \"quotes\"".to_string(),
678        ];
679        let content = format_default_config(&dirs);
680        // Should produce valid TOML despite special characters
681        let config = load_config_from_str(&content).unwrap();
682        let paths: Vec<String> = config
683            .search_dirs
684            .iter()
685            .map(|e| match e {
686                SearchDirEntry::Simple(s) => s.clone(),
687                SearchDirEntry::Rich { path, .. } => path.clone(),
688            })
689            .collect();
690        assert_eq!(paths, dirs);
691    }
692
693    #[test]
694    fn test_format_default_config_empty_dirs() {
695        let content = format_default_config(&[]);
696        assert!(content.contains("search_dirs = []"));
697    }
698
699    #[test]
700    fn test_config_file_exists_returns_false_for_missing() {
701        // This relies on the test not having a kiosk config in the default location,
702        // which is fragile. Instead just verify the function doesn't panic.
703        let _ = config_file_exists();
704    }
705
706    #[test]
707    fn test_format_default_config_single_dir() {
708        let dirs = vec!["~/Dev".to_string()];
709        let content = format_default_config(&dirs);
710        let config = load_config_from_str(&content).unwrap();
711        assert_eq!(config.search_dirs.len(), 1);
712    }
713
714    #[test]
715    fn test_rich_search_dirs() {
716        let config = load_config_from_str(
717            r#"search_dirs = [
718                "~/Development",
719                { path = "~/Work", depth = 3 },
720                { path = "~/Projects" }
721            ]"#,
722        )
723        .unwrap();
724        assert_eq!(config.search_dirs.len(), 3);
725
726        assert!(
727            matches!(&config.search_dirs[0], SearchDirEntry::Simple(s) if s == "~/Development")
728        );
729        match &config.search_dirs[1] {
730            SearchDirEntry::Rich { path, depth } => {
731                assert_eq!(path, "~/Work");
732                assert_eq!(*depth, Some(3));
733            }
734            SearchDirEntry::Simple(_) => panic!("Expected Rich variant"),
735        }
736        match &config.search_dirs[2] {
737            SearchDirEntry::Rich { path, depth } => {
738                assert_eq!(path, "~/Projects");
739                assert_eq!(*depth, None);
740            }
741            SearchDirEntry::Simple(_) => panic!("Expected Rich variant"),
742        }
743    }
744
745    #[test]
746    fn test_write_default_config_creates_file() {
747        let tmp = tempfile::tempdir().unwrap();
748        let path = tmp.path().join("kiosk").join("config.toml");
749        // Temporarily override config_file() is not possible, so test
750        // format_default_config + fs::write manually to verify the flow.
751        let dirs = vec!["~/Development".to_string()];
752        let content = format_default_config(&dirs);
753        fs::create_dir_all(path.parent().unwrap()).unwrap();
754        fs::write(&path, &content).unwrap();
755        let loaded = load_config_from_str(&fs::read_to_string(&path).unwrap()).unwrap();
756        assert_eq!(loaded.search_dirs.len(), 1);
757    }
758
759    #[test]
760    fn test_agent_config_defaults() {
761        let config = load_config_from_str(r#"search_dirs = ["~/Dev"]"#).unwrap();
762        assert_eq!(config.agent.poll_interval_ms, 500);
763    }
764
765    #[test]
766    fn test_agent_config_custom_poll_interval() {
767        let config = load_config_from_str(
768            r#"
769search_dirs = ["~/Dev"]
770
771[agent]
772poll_interval_ms = 5000
773"#,
774        )
775        .unwrap();
776        assert_eq!(config.agent.poll_interval_ms, 5000);
777    }
778
779    #[test]
780    fn test_agent_config_rejects_unknown_fields() {
781        let result = load_config_from_str(
782            r#"
783search_dirs = ["~/Dev"]
784
785[agent]
786poll_interval_ms = 2000
787unknown_field = true
788"#,
789        );
790        assert!(result.is_err());
791    }
792
793    #[test]
794    fn test_agent_config_enabled_defaults_to_true() {
795        let config = load_config_from_str(r#"search_dirs = ["~/Dev"]"#).unwrap();
796        assert!(config.agent.enabled);
797    }
798
799    #[test]
800    fn test_agent_config_enabled_false() {
801        let config = load_config_from_str(
802            r#"
803search_dirs = ["~/Dev"]
804
805[agent]
806enabled = false
807"#,
808        )
809        .unwrap();
810        assert!(!config.agent.enabled);
811        // poll_interval_ms should still default
812        assert_eq!(config.agent.poll_interval_ms, 500);
813    }
814
815    #[test]
816    fn test_agent_config_enabled_true_explicit() {
817        let config = load_config_from_str(
818            r#"
819search_dirs = ["~/Dev"]
820
821[agent]
822enabled = true
823poll_interval_ms = 3000
824"#,
825        )
826        .unwrap();
827        assert!(config.agent.enabled);
828        assert_eq!(config.agent.poll_interval_ms, 3000);
829    }
830
831    #[test]
832    fn test_agent_config_only_poll_interval() {
833        // Setting only poll_interval_ms should keep enabled as default (true)
834        let config = load_config_from_str(
835            r#"
836search_dirs = ["~/Dev"]
837
838[agent]
839poll_interval_ms = 500
840"#,
841        )
842        .unwrap();
843        assert!(config.agent.enabled);
844        assert_eq!(config.agent.poll_interval_ms, 500);
845    }
846
847    #[test]
848    fn test_agent_config_only_enabled() {
849        // Setting only enabled should keep poll_interval_ms as default
850        let config = load_config_from_str(
851            r#"
852search_dirs = ["~/Dev"]
853
854[agent]
855enabled = false
856"#,
857        )
858        .unwrap();
859        assert!(!config.agent.enabled);
860        assert_eq!(config.agent.poll_interval_ms, 500);
861    }
862
863    #[test]
864    fn test_agent_config_poll_interval_minimum_enforced() {
865        let result = load_config_from_str(
866            r#"
867search_dirs = ["~/Dev"]
868
869[agent]
870poll_interval_ms = 50
871"#,
872        );
873        assert!(result.is_err());
874        let err = result.unwrap_err().to_string();
875        assert!(
876            err.contains("at least"),
877            "Error should mention minimum: {err}"
878        );
879    }
880
881    #[test]
882    fn test_agent_config_poll_interval_zero_rejected() {
883        let result = load_config_from_str(
884            r#"
885search_dirs = ["~/Dev"]
886
887[agent]
888poll_interval_ms = 0
889"#,
890        );
891        assert!(result.is_err());
892    }
893
894    #[test]
895    fn test_agent_config_poll_interval_at_minimum_accepted() {
896        let config = load_config_from_str(
897            r#"
898search_dirs = ["~/Dev"]
899
900[agent]
901poll_interval_ms = 100
902"#,
903        )
904        .unwrap();
905        assert_eq!(config.agent.poll_interval_ms, 100);
906    }
907
908    #[test]
909    fn test_write_default_config_create_new_rejects_existing() {
910        let tmp = tempfile::tempdir().unwrap();
911        let path = tmp.path().join("config.toml");
912        fs::write(&path, "existing").unwrap();
913
914        let result = fs::OpenOptions::new()
915            .write(true)
916            .create_new(true)
917            .open(&path);
918        assert!(result.is_err());
919        assert_eq!(
920            result.unwrap_err().kind(),
921            std::io::ErrorKind::AlreadyExists
922        );
923    }
924
925    #[test]
926    fn test_agent_labels_defaults() {
927        let labels = AgentLabelsConfig::default();
928        assert_eq!(labels.running, "[RUNNING]");
929        assert_eq!(labels.waiting, "[WAITING]");
930        assert_eq!(labels.idle, "[IDLE]");
931        assert_eq!(labels.unknown, "[UNKNOWN]");
932    }
933
934    #[test]
935    fn test_agent_labels_custom() {
936        let config = load_config_from_str(
937            r#"
938search_dirs = ["~/Dev"]
939
940[agent.labels]
941running = "ACTIVE"
942waiting = "PEND"
943idle = "OFF"
944unknown = "N/A"
945"#,
946        )
947        .unwrap();
948        assert_eq!(config.agent.labels.running, "ACTIVE");
949        assert_eq!(config.agent.labels.waiting, "PEND");
950        assert_eq!(config.agent.labels.idle, "OFF");
951        assert_eq!(config.agent.labels.unknown, "N/A");
952    }
953
954    #[test]
955    fn test_agent_labels_partial_override() {
956        let config = load_config_from_str(
957            r#"
958search_dirs = ["~/Dev"]
959
960[agent.labels]
961running = "GO"
962"#,
963        )
964        .unwrap();
965        assert_eq!(config.agent.labels.running, "GO");
966        assert_eq!(config.agent.labels.waiting, "[WAITING]");
967        assert_eq!(config.agent.labels.idle, "[IDLE]");
968        assert_eq!(config.agent.labels.unknown, "[UNKNOWN]");
969    }
970
971    #[test]
972    fn test_agent_labels_full_custom_no_brackets() {
973        let config = load_config_from_str(
974            r#"
975search_dirs = ["~/Dev"]
976
977[agent.labels]
978running = "RUN"
979waiting = "WAIT"
980idle = "IDLE"
981unknown = "??"
982"#,
983        )
984        .unwrap();
985        assert_eq!(config.agent.labels.running, "RUN");
986        assert_eq!(config.agent.labels.waiting, "WAIT");
987        assert_eq!(config.agent.labels.idle, "IDLE");
988        assert_eq!(config.agent.labels.unknown, "??");
989        assert_eq!(config.agent.labels.max_label_width(), 4);
990    }
991
992    #[test]
993    fn test_agent_labels_rejects_unknown_fields() {
994        let result = load_config_from_str(
995            r#"
996search_dirs = ["~/Dev"]
997
998[agent.labels]
999running = "RUN"
1000bogus = "BAD"
1001"#,
1002        );
1003        assert!(result.is_err());
1004    }
1005
1006    #[test]
1007    fn test_agent_labels_max_width() {
1008        let labels = AgentLabelsConfig::default();
1009        // "[RUNNING]", "[WAITING]", "[UNKNOWN]" are 9 chars; "[IDLE]" is 6
1010        assert_eq!(labels.max_label_width(), 9);
1011
1012        let labels = AgentLabelsConfig {
1013            running: "RUNNING".to_string(),
1014            waiting: "W".to_string(),
1015            idle: "I".to_string(),
1016            unknown: "?".to_string(),
1017        };
1018        assert_eq!(labels.max_label_width(), 7);
1019    }
1020}