1use crate::{Error, Limit, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const MAX_CONFIG_SIZE: u64 = 1_048_576;
9
10pub const MAX_GUARD_MB: u64 = 16 * 1024 * 1024;
13pub const MAX_GUARD_SECS: u64 = 86_400;
15
16#[derive(Debug, Default, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19 #[serde(default)]
20 pub profiles: HashMap<String, Profile>,
21
22 #[serde(default, skip_serializing_if = "GuardConfig::is_default")]
25 pub guard: GuardConfig,
26
27 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
31 pub rules: HashMap<String, AppRule>,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct AppRule {
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub match_exe: Vec<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub memory: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub cpu: Option<String>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub io_read: Option<String>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub io_write: Option<String>,
59}
60
61impl AppRule {
62 pub fn to_limit(&self) -> Result<Limit> {
63 use crate::{CpuLimit, IoLimit, MemoryLimit};
64
65 let read_bps = self
66 .io_read
67 .as_ref()
68 .map(|s| IoLimit::parse_bps(s))
69 .transpose()?;
70 let write_bps = self
71 .io_write
72 .as_ref()
73 .map(|s| IoLimit::parse_bps(s))
74 .transpose()?;
75 let io = if read_bps.is_some() || write_bps.is_some() {
76 Some(IoLimit {
77 read_bps,
78 write_bps,
79 })
80 } else {
81 None
82 };
83
84 Ok(Limit {
85 memory: self
86 .memory
87 .as_ref()
88 .map(|s| MemoryLimit::parse(s))
89 .transpose()?,
90 cpu: self.cpu.as_ref().map(|s| CpuLimit::parse(s)).transpose()?,
91 io,
92 })
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct GuardConfig {
101 pub enabled: bool,
102 pub trigger: GuardTrigger,
103 pub timing: GuardTiming,
104 pub selection: GuardSelection,
105 pub notify: bool,
109 pub notify_pressure: bool,
111}
112
113impl Default for GuardConfig {
114 fn default() -> Self {
115 Self {
116 enabled: true,
117 trigger: GuardTrigger::default(),
118 timing: GuardTiming::default(),
119 selection: GuardSelection::default(),
120 notify: true,
121 notify_pressure: false,
122 }
123 }
124}
125
126impl GuardConfig {
127 pub fn is_default(&self) -> bool {
128 *self == GuardConfig::default()
129 }
130
131 pub fn validate(&self) -> Result<()> {
134 let bad = |m: &str| Err(Error::Config(format!("guard: {m}")));
135 let t = &self.trigger;
136 let pct = |v: f64| v > 0.0 && v <= 100.0;
137 if !pct(t.psi_some_warn) || !pct(t.psi_some_high) || !pct(t.psi_full_critical) {
138 return bad("trigger PSI thresholds must be between 0 (exclusive) and 100");
139 }
140 if t.psi_some_warn >= t.psi_some_high {
141 return bad("trigger.psi_some_warn must be below trigger.psi_some_high");
142 }
143 if !(1..=100).contains(&t.act_below_available_pct) {
144 return bad("trigger.act_below_available_pct must be between 1 and 100");
145 }
146 if t.mem_available_floor_mb > MAX_GUARD_MB {
147 return bad(&format!(
148 "trigger.mem_available_floor_mb must be at most {MAX_GUARD_MB}"
149 ));
150 }
151 let tm = &self.timing;
152 if !(100..=60_000).contains(&tm.sample_interval_ms) {
153 return bad("timing.sample_interval_ms must be between 100 and 60000");
154 }
155 if !(1..=60).contains(&tm.freeze_hold_secs) {
156 return bad("timing.freeze_hold_secs must be between 1 and 60");
157 }
158 if !(1..=MAX_GUARD_SECS).contains(&tm.calm_hold_secs) {
159 return bad(&format!(
160 "timing.calm_hold_secs must be between 1 and {MAX_GUARD_SECS}"
161 ));
162 }
163 if tm.freeze_cooldown_secs < tm.freeze_hold_secs {
164 return bad("timing.freeze_cooldown_secs must be at least timing.freeze_hold_secs");
165 }
166 if tm.freeze_cooldown_secs > MAX_GUARD_SECS {
167 return bad(&format!(
168 "timing.freeze_cooldown_secs must be at most {MAX_GUARD_SECS}"
169 ));
170 }
171 if self.selection.min_rss_mb > MAX_GUARD_MB {
172 return bad(&format!(
173 "selection.min_rss_mb must be at most {MAX_GUARD_MB}"
174 ));
175 }
176 if self.selection.protect.iter().any(|p| p.trim().is_empty()) {
177 return bad("selection.protect must not contain empty names");
178 }
179 Ok(())
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct GuardTrigger {
187 pub psi_some_warn: f64,
189 pub psi_some_high: f64,
191 pub psi_full_critical: f64,
193 pub mem_available_floor_mb: u64,
195 pub act_below_available_pct: u64,
197}
198
199impl Default for GuardTrigger {
200 fn default() -> Self {
201 Self {
202 psi_some_warn: 10.0,
203 psi_some_high: 30.0,
204 psi_full_critical: 10.0,
205 mem_available_floor_mb: 400,
206 act_below_available_pct: 20,
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(default, deny_unknown_fields)]
214pub struct GuardTiming {
215 pub freeze_hold_secs: u64,
217 pub calm_hold_secs: u64,
219 pub freeze_cooldown_secs: u64,
221 pub sample_interval_ms: u64,
223}
224
225impl Default for GuardTiming {
226 fn default() -> Self {
227 Self {
228 freeze_hold_secs: 5,
229 calm_hold_secs: 30,
230 freeze_cooldown_secs: 60,
231 sample_interval_ms: 1000,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(default, deny_unknown_fields)]
239pub struct GuardSelection {
240 pub min_rss_mb: u64,
242 pub protect: Vec<String>,
244}
245
246impl Default for GuardSelection {
247 fn default() -> Self {
248 Self {
249 min_rss_mb: 200,
250 protect: Vec::new(),
251 }
252 }
253}
254
255pub const BUILTIN_PROTECT: &[&str] = &[
257 "gnome-shell",
258 "kwin_wayland",
259 "kwin_x11",
260 "plasmashell",
261 "sway",
262 "Hyprland",
263 "Xwayland",
264 "Xorg",
265 "sshd",
266 "systemd",
267 "dbus-daemon",
268 "pipewire",
269 "wireplumber",
270 "pulseaudio",
271 "rlm-guard",
272 "bash",
273 "zsh",
274 "fish",
275 "gnome-terminal-server",
280 "ptyxis",
281 "ptyxis-agent",
282 "kgx",
283 "konsole",
284 "kitty",
285 "alacritty",
286 "wezterm-gui",
287 "foot",
288 "tilix",
289 "xfce4-terminal",
290 "xterm",
291 "terminator",
292 "tmux",
294 "tmux: server",
295 "screen",
296];
297
298pub fn versioned_rule_name(exe: &str) -> Option<String> {
302 if exe.chars().any(char::is_alphabetic) {
303 None
304 } else {
305 Some(format!(
306 "This app's program name is a version number ({exe}), so a saved rule would stop matching after an update."
307 ))
308 }
309}
310
311pub fn protect_set(extra: &[String]) -> HashSet<String> {
313 BUILTIN_PROTECT
314 .iter()
315 .map(|s| (*s).to_string())
316 .chain(extra.iter().cloned())
317 .collect()
318}
319
320pub fn is_protected(set: &HashSet<String>, comm: &str, exe: Option<&str>) -> bool {
324 exe.is_some_and(|e| set.contains(e)) || set.contains(comm)
325}
326
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct Profile {
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub match_exe: Vec<String>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub memory: Option<String>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub cpu: Option<String>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub io_read: Option<String>,
345
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub io_write: Option<String>,
349}
350
351impl Profile {
352 pub fn validate(&self) -> Result<()> {
355 let l = self.to_limit()?;
356 if l.is_empty() {
357 return Err(Error::Config("profile sets no limits".into()));
358 }
359 Ok(())
360 }
361
362 pub fn to_limit(&self) -> Result<Limit> {
363 use crate::{CpuLimit, IoLimit, MemoryLimit};
364
365 let read_bps = self
366 .io_read
367 .as_ref()
368 .map(|s| IoLimit::parse_bps(s))
369 .transpose()?;
370 let write_bps = self
371 .io_write
372 .as_ref()
373 .map(|s| IoLimit::parse_bps(s))
374 .transpose()?;
375 let io = if read_bps.is_some() || write_bps.is_some() {
376 Some(IoLimit {
377 read_bps,
378 write_bps,
379 })
380 } else {
381 None
382 };
383
384 Ok(Limit {
385 memory: self
386 .memory
387 .as_ref()
388 .map(|s| MemoryLimit::parse(s))
389 .transpose()?,
390 cpu: self.cpu.as_ref().map(|s| CpuLimit::parse(s)).transpose()?,
391 io,
392 })
393 }
394}
395
396pub fn builtin_presets() -> HashMap<String, Profile> {
398 let mut presets = HashMap::new();
399
400 presets.insert(
401 "Light".to_string(),
402 Profile {
403 match_exe: Vec::new(),
404 memory: Some("512M".to_string()),
405 cpu: Some("25%".to_string()),
406 io_read: None,
407 io_write: None,
408 },
409 );
410
411 presets.insert(
412 "Medium".to_string(),
413 Profile {
414 match_exe: Vec::new(),
415 memory: Some("2G".to_string()),
416 cpu: Some("50%".to_string()),
417 io_read: Some("50M".to_string()),
418 io_write: Some("25M".to_string()),
419 },
420 );
421
422 presets.insert(
423 "Heavy".to_string(),
424 Profile {
425 match_exe: Vec::new(),
426 memory: Some("4G".to_string()),
427 cpu: Some("100%".to_string()),
428 io_read: Some("100M".to_string()),
429 io_write: Some("50M".to_string()),
430 },
431 );
432
433 presets.insert(
434 "Browser".to_string(),
435 Profile {
436 match_exe: vec![
437 "firefox".to_string(),
438 "chrome".to_string(),
439 "chromium".to_string(),
440 ],
441 memory: Some("4G".to_string()),
442 cpu: Some("75%".to_string()),
443 io_read: None,
444 io_write: None,
445 },
446 );
447
448 presets
449}
450
451impl Config {
452 pub fn load() -> Result<Self> {
454 let mut config = Config::default();
455
456 let system_path = PathBuf::from("/etc/rlm/config.yaml");
458 if system_path.exists() {
459 config.merge_from(&system_path)?;
460 }
461
462 if let Some(user_path) = Self::user_config_path() {
464 if user_path.exists() {
465 config.merge_from(&user_path)?;
466 }
467
468 let profiles_dir = user_path
470 .parent()
471 .map(|p| p.join("profiles.d"))
472 .unwrap_or_else(|| PathBuf::from("profiles.d"));
473 if profiles_dir.exists() {
474 config.load_profiles_dir(&profiles_dir)?;
475 }
476 }
477
478 Ok(config)
479 }
480
481 pub fn load_validated() -> Result<Self> {
483 let c = Self::load()?;
484 c.guard.validate()?;
485 Ok(c)
486 }
487
488 pub fn load_from(path: &Path) -> Result<Self> {
490 let metadata = fs::metadata(path)?;
492 if metadata.len() > MAX_CONFIG_SIZE {
493 return Err(Error::Config(format!(
494 "config file {} exceeds maximum size of 1MB",
495 path.display()
496 )));
497 }
498
499 let content = fs::read_to_string(path)?;
500 serde_yaml_ng::from_str(&content)
501 .map_err(|e| Error::Config(format!("failed to parse {}: {e}", path.display())))
502 }
503
504 fn merge_from(&mut self, path: &Path) -> Result<()> {
505 let other = Self::load_from(path)?;
506 self.profiles.extend(other.profiles);
507 self.rules.extend(other.rules);
508 if !other.guard.is_default() {
510 self.guard = other.guard;
511 }
512 Ok(())
513 }
514
515 fn load_profiles_dir(&mut self, dir: &Path) -> Result<()> {
516 for entry in fs::read_dir(dir)? {
517 let entry = entry?;
518 let path = entry.path();
519 if path.extension().is_some_and(|e| e == "yaml" || e == "yml") {
520 self.merge_from(&path)?;
521 }
522 }
523 Ok(())
524 }
525
526 pub fn user_config_path() -> Option<PathBuf> {
528 dirs::config_dir().map(|d| d.join("rlm").join("config.yaml"))
529 }
530
531 pub fn get_profile(&self, name: &str) -> Option<Profile> {
535 let resolved = self.resolve_profile_name(name)?;
536 self.all_profiles().get(&resolved).cloned()
537 }
538
539 pub fn resolve_profile_name(&self, name: &str) -> Option<String> {
543 let all = self.all_profiles();
544 if all.contains_key(name) {
545 return Some(name.to_string());
546 }
547 let mut matches = all.keys().filter(|k| k.eq_ignore_ascii_case(name));
548 let first = matches.next()?.clone();
549 if matches.next().is_some() {
550 None
551 } else {
552 Some(first)
553 }
554 }
555
556 pub fn profile_names(&self) -> Vec<String> {
559 let mut names: Vec<String> = self.all_profiles().into_keys().collect();
560 names.sort_by(|a, b| {
561 a.to_lowercase()
562 .cmp(&b.to_lowercase())
563 .then_with(|| a.cmp(b))
564 });
565 names
566 }
567
568 pub fn all_profiles(&self) -> HashMap<String, Profile> {
570 let mut all = builtin_presets();
571 for (name, profile) in &self.profiles {
573 all.insert(name.clone(), profile.clone());
574 }
575 all
576 }
577
578 pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
580 self.rules.insert(name.into(), rule);
581 }
582
583 pub fn remove_rule(&mut self, name: &str) -> bool {
585 self.rules.remove(name).is_some()
586 }
587
588 pub fn save(&self) -> Result<()> {
590 let path = Self::user_config_path()
591 .ok_or_else(|| Error::Config("No config directory found".into()))?;
592
593 if let Some(parent) = path.parent() {
594 fs::create_dir_all(parent)?;
595 }
596
597 let yaml = serde_yaml_ng::to_string(self)
598 .map_err(|e| Error::Config(format!("Failed to serialize config: {e}")))?;
599
600 let tmp_path = path.with_extension("yaml.tmp");
602 fs::write(&tmp_path, &yaml)?;
603 fs::rename(&tmp_path, &path)?;
604 Ok(())
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn versioned_program_names_make_no_rule() {
614 assert_eq!(
615 versioned_rule_name("2.1.283").as_deref(),
616 Some(
617 "This app's program name is a version number (2.1.283), so a saved rule would stop matching after an update."
618 )
619 );
620 assert!(versioned_rule_name("").is_some());
621 assert_eq!(versioned_rule_name("firefox"), None);
622 assert_eq!(versioned_rule_name("python3.12"), None);
623 }
624
625 #[test]
626 fn app_rule_to_limit_parses_fields() {
627 let rule = AppRule {
628 match_exe: vec!["firefox".into()],
629 memory: Some("4G".into()),
630 cpu: Some("75%".into()),
631 io_read: None,
632 io_write: None,
633 };
634 let limit = rule.to_limit().unwrap();
635 assert_eq!(limit.memory.unwrap().bytes(), 4 * 1024 * 1024 * 1024);
636 assert_eq!(limit.cpu.unwrap().percent(), 75);
637 assert!(limit.io.is_none());
638 }
639
640 #[test]
641 fn app_rule_invalid_limit_errors() {
642 let rule = AppRule {
643 match_exe: vec!["x".into()],
644 memory: Some("notasize".into()),
645 ..Default::default()
646 };
647 assert!(rule.to_limit().is_err());
648 }
649
650 #[test]
651 fn empty_rules_omitted_from_yaml() {
652 let cfg = Config::default();
653 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
654 assert!(
655 !yaml.contains("rules:"),
656 "empty rules must be omitted: {yaml}"
657 );
658 }
659
660 #[test]
661 fn rules_round_trip_through_yaml() {
662 let mut cfg = Config::default();
663 cfg.add_rule(
664 "firefox",
665 AppRule {
666 match_exe: vec!["firefox".into()],
667 memory: Some("4G".into()),
668 cpu: Some("75%".into()),
669 io_read: None,
670 io_write: None,
671 },
672 );
673 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
674 assert!(yaml.contains("rules:"));
675 let back: Config = serde_yaml_ng::from_str(&yaml).unwrap();
676 let r = back.rules.get("firefox").expect("rule present");
677 assert_eq!(r.match_exe, vec!["firefox".to_string()]);
678 assert_eq!(r.memory.as_deref(), Some("4G"));
679 }
680
681 #[test]
682 fn add_and_remove_rule() {
683 let mut cfg = Config::default();
684 cfg.add_rule("code", AppRule::default());
685 assert!(cfg.rules.contains_key("code"));
686 assert!(cfg.remove_rule("code"));
687 assert!(!cfg.remove_rule("code"));
688 assert!(cfg.rules.is_empty());
689 }
690
691 #[test]
692 fn protect_set_merges_builtin_and_extra() {
693 let s = protect_set(&["gnome-control-center".into()]);
694 assert!(s.contains("gnome-shell"));
695 assert!(s.contains("gnome-control-center"));
696 }
697
698 #[test]
701 fn terminals_and_multiplexers_are_protected() {
702 let s = protect_set(&[]);
703 for exe in [
704 "gnome-terminal-server",
705 "ptyxis",
706 "ptyxis-agent",
707 "kgx",
708 "konsole",
709 "kitty",
710 "alacritty",
711 "wezterm-gui",
712 "foot",
713 "tilix",
714 "xfce4-terminal",
715 "xterm",
716 "terminator",
717 "tmux",
718 "screen",
719 ] {
720 assert!(is_protected(&s, "x", Some(exe)), "{exe}");
721 }
722 assert!(is_protected(&s, "tmux: server", None));
723 }
724
725 #[test]
726 fn is_protected_prefers_full_exe_name_over_truncated_comm() {
727 let s = protect_set(&["gnome-control-center".into()]);
728 assert!(is_protected(
729 &s,
730 "gnome-control-c",
731 Some("gnome-control-center")
732 ));
733 assert!(
734 !is_protected(&s, "gnome-control-c", None),
735 "truncated comm alone cannot match"
736 );
737 assert!(is_protected(&s, "bash", None));
738 assert!(!is_protected(&s, "firefox", Some("firefox")));
739 }
740
741 #[test]
742 fn readme_guard_example_parses_and_validates() {
743 let yaml = "guard:\n enabled: true\n trigger: { psi_some_warn: 10, psi_some_high: 30, psi_full_critical: 10, mem_available_floor_mb: 400 }\n timing: { freeze_hold_secs: 5, calm_hold_secs: 30, freeze_cooldown_secs: 60, sample_interval_ms: 1000 }\n selection: { min_rss_mb: 200, protect: [] }\n notify: true\n notify_pressure: false\n";
744 let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
745 cfg.guard.validate().unwrap();
746 assert_eq!(cfg.guard.trigger.act_below_available_pct, 20);
747 }
748
749 #[test]
750 fn notify_pressure_is_off_by_default_and_accepted() {
751 assert!(GuardConfig::default().notify);
752 assert!(!GuardConfig::default().notify_pressure);
753 let cfg: Config = serde_yaml_ng::from_str("guard:\n notify_pressure: true\n").unwrap();
754 cfg.guard.validate().unwrap();
755 assert!(cfg.guard.notify_pressure);
756 assert!(cfg.guard.notify, "other keys keep their defaults");
757 }
758
759 #[test]
760 fn unknown_guard_key_is_an_error() {
761 let err = serde_yaml_ng::from_str::<Config>("guard:\n selection: { min_rss: 100 }\n")
762 .unwrap_err()
763 .to_string();
764 assert!(err.contains("min_rss"), "{err}");
765 }
766
767 #[test]
768 fn unknown_top_level_and_profile_keys_are_errors() {
769 assert!(serde_yaml_ng::from_str::<Config>("gaurd:\n enabled: false\n").is_err());
770 assert!(serde_yaml_ng::from_str::<Config>("profiles:\n a: { memroy: 2G }\n").is_err());
771 }
772
773 #[test]
774 fn claude_md_profile_example_still_parses() {
775 let yaml = "profiles:\n browser:\n match_exe: [firefox, chrome]\n memory: \"4G\"\n cpu: \"75%\"\n io_read: \"100M\"\n io_write: \"50M\"\n";
776 let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
777 assert_eq!(cfg.profiles["browser"].memory.as_deref(), Some("4G"));
778 }
779
780 #[test]
781 fn default_guard_config_validates() {
782 GuardConfig::default().validate().unwrap();
783 }
784
785 #[test]
786 #[allow(clippy::type_complexity)]
787 fn validate_rejects_bad_values() {
788 let bad: Vec<Box<dyn Fn(&mut GuardConfig)>> = vec![
789 Box::new(|c| {
790 c.trigger.psi_some_warn = 40.0;
791 c.trigger.psi_some_high = 30.0
792 }),
793 Box::new(|c| c.trigger.psi_full_critical = 0.0),
794 Box::new(|c| c.trigger.psi_some_high = f64::NAN),
795 Box::new(|c| c.trigger.act_below_available_pct = 0),
796 Box::new(|c| c.trigger.act_below_available_pct = 101),
797 Box::new(|c| c.timing.sample_interval_ms = 0),
798 Box::new(|c| c.timing.freeze_hold_secs = 0),
799 Box::new(|c| c.timing.calm_hold_secs = 0),
800 Box::new(|c| c.timing.freeze_cooldown_secs = 1),
801 Box::new(|c| c.selection.protect = vec![" ".into()]),
802 ];
803 for (i, f) in bad.iter().enumerate() {
804 let mut c = GuardConfig::default();
805 f(&mut c);
806 assert!(c.validate().is_err(), "case {i} should be rejected");
807 }
808 }
809
810 #[test]
813 #[allow(clippy::type_complexity)]
814 fn validate_enforces_upper_bounds() {
815 let cases: Vec<(&str, Box<dyn Fn(&mut GuardConfig, u64)>, u64)> = vec![
816 (
817 "trigger.mem_available_floor_mb",
818 Box::new(|c, v| c.trigger.mem_available_floor_mb = v),
819 MAX_GUARD_MB,
820 ),
821 (
822 "timing.calm_hold_secs",
823 Box::new(|c, v| c.timing.calm_hold_secs = v),
824 MAX_GUARD_SECS,
825 ),
826 (
827 "timing.freeze_cooldown_secs",
828 Box::new(|c, v| c.timing.freeze_cooldown_secs = v),
829 MAX_GUARD_SECS,
830 ),
831 (
832 "selection.min_rss_mb",
833 Box::new(|c, v| c.selection.min_rss_mb = v),
834 MAX_GUARD_MB,
835 ),
836 (
837 "timing.freeze_hold_secs",
838 Box::new(|c, v| c.timing.freeze_hold_secs = v),
839 60,
840 ),
841 (
842 "timing.sample_interval_ms",
843 Box::new(|c, v| c.timing.sample_interval_ms = v),
844 60_000,
845 ),
846 (
847 "trigger.act_below_available_pct",
848 Box::new(|c, v| c.trigger.act_below_available_pct = v),
849 100,
850 ),
851 ];
852 for (field, set, max) in &cases {
853 let mut c = GuardConfig::default();
854 set(&mut c, *max);
855 c.validate()
856 .unwrap_or_else(|e| panic!("{field} = {max} must be accepted: {e}"));
857 for v in [max + 1, u64::MAX] {
858 let mut c = GuardConfig::default();
859 set(&mut c, v);
860 let err = c.validate().expect_err(field).to_string();
861 assert!(err.contains(field), "{field} = {v}: {err}");
862 }
863 }
864 }
865
866 #[test]
867 fn profile_lookup_is_case_insensitive_when_unique() {
868 let cfg = Config::default();
869 assert!(cfg.get_profile("browser").is_some());
870 assert!(cfg.get_profile("MEDIUM").is_some());
871 assert!(cfg.get_profile("nope").is_none());
872 }
873
874 #[test]
875 fn exact_profile_name_wins_and_ambiguity_is_refused() {
876 let mut cfg = Config::default();
877 cfg.profiles.insert(
878 "browser".into(),
879 Profile {
880 memory: Some("1G".into()),
881 ..Default::default()
882 },
883 );
884 assert_eq!(
885 cfg.get_profile("browser").unwrap().memory.as_deref(),
886 Some("1G")
887 );
888 assert_eq!(
889 cfg.get_profile("Browser").unwrap().memory.as_deref(),
890 Some("4G")
891 );
892 assert!(
893 cfg.get_profile("BROWSER").is_none(),
894 "two case-insensitive matches"
895 );
896 }
897
898 #[test]
899 fn profile_names_are_sorted_case_insensitively() {
900 let mut cfg = Config::default();
901 cfg.profiles.insert(
902 "aaa".into(),
903 Profile {
904 cpu: Some("10%".into()),
905 ..Default::default()
906 },
907 );
908 assert_eq!(
909 cfg.profile_names(),
910 vec!["aaa", "Browser", "Heavy", "Light", "Medium"]
911 );
912 }
913
914 #[test]
915 fn profile_validate_rejects_empty_and_invalid() {
916 assert!(Profile::default().validate().is_err());
917 assert!(Profile {
918 memory: Some("lots".into()),
919 ..Default::default()
920 }
921 .validate()
922 .is_err());
923 assert!(Profile {
924 cpu: Some("50%".into()),
925 ..Default::default()
926 }
927 .validate()
928 .is_ok());
929 }
930
931 #[test]
932 fn load_from_names_the_file_on_parse_error() {
933 let dir = tempfile::tempdir().unwrap();
934 let p = dir.path().join("config.yaml");
935 std::fs::write(&p, "profiles: [\n").unwrap();
936 let err = Config::load_from(&p).unwrap_err().to_string();
937 assert!(err.contains("config.yaml"), "{err}");
938 }
939}