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 #[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 pub search_dirs: Vec<SearchDirEntry>,
60
61 #[serde(default)]
63 pub session: SessionConfig,
64
65 #[serde(default)]
67 pub theme: ThemeConfig,
68
69 #[serde(default)]
72 pub keys: KeysConfig,
73
74 #[serde(default)]
76 pub agent: AgentConfig,
77}
78
79#[derive(Debug, Serialize, Deserialize, Clone)]
87#[serde(deny_unknown_fields, default)]
88pub struct AgentConfig {
89 pub enabled: bool,
92 pub poll_interval_ms: u64,
94 #[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#[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 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 pub split_command: Option<String>,
159}
160
161#[derive(Debug, Deserialize, Serialize, Clone)]
164#[serde(deny_unknown_fields, default)]
165pub struct ThemeConfig {
166 #[serde(deserialize_with = "deserialize_color")]
168 pub accent: ThemeColor,
169 #[serde(deserialize_with = "deserialize_color")]
171 pub secondary: ThemeColor,
172 #[serde(deserialize_with = "deserialize_color")]
174 pub tertiary: ThemeColor,
175 #[serde(deserialize_with = "deserialize_color")]
177 pub success: ThemeColor,
178 #[serde(deserialize_with = "deserialize_color")]
180 pub error: ThemeColor,
181 #[serde(deserialize_with = "deserialize_color")]
183 pub warning: ThemeColor,
184 #[serde(deserialize_with = "deserialize_color")]
186 pub muted: ThemeColor,
187 #[serde(deserialize_with = "deserialize_color")]
189 pub border: ThemeColor,
190 #[serde(deserialize_with = "deserialize_color")]
192 pub hint: ThemeColor,
193 #[serde(deserialize_with = "deserialize_color")]
195 pub highlight_fg: ThemeColor,
196}
197
198macro_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
231macro_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 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 pub fn resolve_alias(s: &str) -> &str {
258 match s {
259 $($($($alias)|+ => $name,)?)*
260 other => other,
261 }
262 }
263
264 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
358pub 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
377pub fn config_file_exists() -> bool {
379 config_file().exists()
380}
381
382pub 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 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
410pub 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 assert!(dirs.len() <= 1);
503 if let Some((d, depth)) = dirs.first() {
504 assert!(!d.to_string_lossy().contains('~'));
505 assert_eq!(*depth, 1); }
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 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 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 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 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 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 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 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}