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 protect_set(extra: &[String]) -> HashSet<String> {
300 BUILTIN_PROTECT
301 .iter()
302 .map(|s| (*s).to_string())
303 .chain(extra.iter().cloned())
304 .collect()
305}
306
307pub fn is_protected(set: &HashSet<String>, comm: &str, exe: Option<&str>) -> bool {
311 exe.is_some_and(|e| set.contains(e)) || set.contains(comm)
312}
313
314#[derive(Debug, Clone, Default, Serialize, Deserialize)]
315#[serde(deny_unknown_fields)]
316pub struct Profile {
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub match_exe: Vec<String>,
320
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub memory: Option<String>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub cpu: Option<String>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub io_read: Option<String>,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub io_write: Option<String>,
336}
337
338impl Profile {
339 pub fn validate(&self) -> Result<()> {
342 let l = self.to_limit()?;
343 if l.is_empty() {
344 return Err(Error::Config("profile sets no limits".into()));
345 }
346 Ok(())
347 }
348
349 pub fn to_limit(&self) -> Result<Limit> {
350 use crate::{CpuLimit, IoLimit, MemoryLimit};
351
352 let read_bps = self
353 .io_read
354 .as_ref()
355 .map(|s| IoLimit::parse_bps(s))
356 .transpose()?;
357 let write_bps = self
358 .io_write
359 .as_ref()
360 .map(|s| IoLimit::parse_bps(s))
361 .transpose()?;
362 let io = if read_bps.is_some() || write_bps.is_some() {
363 Some(IoLimit {
364 read_bps,
365 write_bps,
366 })
367 } else {
368 None
369 };
370
371 Ok(Limit {
372 memory: self
373 .memory
374 .as_ref()
375 .map(|s| MemoryLimit::parse(s))
376 .transpose()?,
377 cpu: self.cpu.as_ref().map(|s| CpuLimit::parse(s)).transpose()?,
378 io,
379 })
380 }
381}
382
383pub fn builtin_presets() -> HashMap<String, Profile> {
385 let mut presets = HashMap::new();
386
387 presets.insert(
388 "Light".to_string(),
389 Profile {
390 match_exe: Vec::new(),
391 memory: Some("512M".to_string()),
392 cpu: Some("25%".to_string()),
393 io_read: None,
394 io_write: None,
395 },
396 );
397
398 presets.insert(
399 "Medium".to_string(),
400 Profile {
401 match_exe: Vec::new(),
402 memory: Some("2G".to_string()),
403 cpu: Some("50%".to_string()),
404 io_read: Some("50M".to_string()),
405 io_write: Some("25M".to_string()),
406 },
407 );
408
409 presets.insert(
410 "Heavy".to_string(),
411 Profile {
412 match_exe: Vec::new(),
413 memory: Some("4G".to_string()),
414 cpu: Some("100%".to_string()),
415 io_read: Some("100M".to_string()),
416 io_write: Some("50M".to_string()),
417 },
418 );
419
420 presets.insert(
421 "Browser".to_string(),
422 Profile {
423 match_exe: vec![
424 "firefox".to_string(),
425 "chrome".to_string(),
426 "chromium".to_string(),
427 ],
428 memory: Some("4G".to_string()),
429 cpu: Some("75%".to_string()),
430 io_read: None,
431 io_write: None,
432 },
433 );
434
435 presets
436}
437
438impl Config {
439 pub fn load() -> Result<Self> {
441 let mut config = Config::default();
442
443 let system_path = PathBuf::from("/etc/rlm/config.yaml");
445 if system_path.exists() {
446 config.merge_from(&system_path)?;
447 }
448
449 if let Some(user_path) = Self::user_config_path() {
451 if user_path.exists() {
452 config.merge_from(&user_path)?;
453 }
454
455 let profiles_dir = user_path
457 .parent()
458 .map(|p| p.join("profiles.d"))
459 .unwrap_or_else(|| PathBuf::from("profiles.d"));
460 if profiles_dir.exists() {
461 config.load_profiles_dir(&profiles_dir)?;
462 }
463 }
464
465 Ok(config)
466 }
467
468 pub fn load_validated() -> Result<Self> {
470 let c = Self::load()?;
471 c.guard.validate()?;
472 Ok(c)
473 }
474
475 pub fn load_from(path: &Path) -> Result<Self> {
477 let metadata = fs::metadata(path)?;
479 if metadata.len() > MAX_CONFIG_SIZE {
480 return Err(Error::Config(format!(
481 "config file {} exceeds maximum size of 1MB",
482 path.display()
483 )));
484 }
485
486 let content = fs::read_to_string(path)?;
487 serde_yaml_ng::from_str(&content)
488 .map_err(|e| Error::Config(format!("failed to parse {}: {e}", path.display())))
489 }
490
491 fn merge_from(&mut self, path: &Path) -> Result<()> {
492 let other = Self::load_from(path)?;
493 self.profiles.extend(other.profiles);
494 self.rules.extend(other.rules);
495 if !other.guard.is_default() {
497 self.guard = other.guard;
498 }
499 Ok(())
500 }
501
502 fn load_profiles_dir(&mut self, dir: &Path) -> Result<()> {
503 for entry in fs::read_dir(dir)? {
504 let entry = entry?;
505 let path = entry.path();
506 if path.extension().is_some_and(|e| e == "yaml" || e == "yml") {
507 self.merge_from(&path)?;
508 }
509 }
510 Ok(())
511 }
512
513 pub fn user_config_path() -> Option<PathBuf> {
515 dirs::config_dir().map(|d| d.join("rlm").join("config.yaml"))
516 }
517
518 pub fn get_profile(&self, name: &str) -> Option<Profile> {
522 let resolved = self.resolve_profile_name(name)?;
523 self.all_profiles().get(&resolved).cloned()
524 }
525
526 pub fn resolve_profile_name(&self, name: &str) -> Option<String> {
530 let all = self.all_profiles();
531 if all.contains_key(name) {
532 return Some(name.to_string());
533 }
534 let mut matches = all.keys().filter(|k| k.eq_ignore_ascii_case(name));
535 let first = matches.next()?.clone();
536 if matches.next().is_some() {
537 None
538 } else {
539 Some(first)
540 }
541 }
542
543 pub fn profile_names(&self) -> Vec<String> {
546 let mut names: Vec<String> = self.all_profiles().into_keys().collect();
547 names.sort_by(|a, b| {
548 a.to_lowercase()
549 .cmp(&b.to_lowercase())
550 .then_with(|| a.cmp(b))
551 });
552 names
553 }
554
555 pub fn all_profiles(&self) -> HashMap<String, Profile> {
557 let mut all = builtin_presets();
558 for (name, profile) in &self.profiles {
560 all.insert(name.clone(), profile.clone());
561 }
562 all
563 }
564
565 pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
567 self.rules.insert(name.into(), rule);
568 }
569
570 pub fn remove_rule(&mut self, name: &str) -> bool {
572 self.rules.remove(name).is_some()
573 }
574
575 pub fn save(&self) -> Result<()> {
577 let path = Self::user_config_path()
578 .ok_or_else(|| Error::Config("No config directory found".into()))?;
579
580 if let Some(parent) = path.parent() {
581 fs::create_dir_all(parent)?;
582 }
583
584 let yaml = serde_yaml_ng::to_string(self)
585 .map_err(|e| Error::Config(format!("Failed to serialize config: {e}")))?;
586
587 let tmp_path = path.with_extension("yaml.tmp");
589 fs::write(&tmp_path, &yaml)?;
590 fs::rename(&tmp_path, &path)?;
591 Ok(())
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
600 fn app_rule_to_limit_parses_fields() {
601 let rule = AppRule {
602 match_exe: vec!["firefox".into()],
603 memory: Some("4G".into()),
604 cpu: Some("75%".into()),
605 io_read: None,
606 io_write: None,
607 };
608 let limit = rule.to_limit().unwrap();
609 assert_eq!(limit.memory.unwrap().bytes(), 4 * 1024 * 1024 * 1024);
610 assert_eq!(limit.cpu.unwrap().percent(), 75);
611 assert!(limit.io.is_none());
612 }
613
614 #[test]
615 fn app_rule_invalid_limit_errors() {
616 let rule = AppRule {
617 match_exe: vec!["x".into()],
618 memory: Some("notasize".into()),
619 ..Default::default()
620 };
621 assert!(rule.to_limit().is_err());
622 }
623
624 #[test]
625 fn empty_rules_omitted_from_yaml() {
626 let cfg = Config::default();
627 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
628 assert!(
629 !yaml.contains("rules:"),
630 "empty rules must be omitted: {yaml}"
631 );
632 }
633
634 #[test]
635 fn rules_round_trip_through_yaml() {
636 let mut cfg = Config::default();
637 cfg.add_rule(
638 "firefox",
639 AppRule {
640 match_exe: vec!["firefox".into()],
641 memory: Some("4G".into()),
642 cpu: Some("75%".into()),
643 io_read: None,
644 io_write: None,
645 },
646 );
647 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
648 assert!(yaml.contains("rules:"));
649 let back: Config = serde_yaml_ng::from_str(&yaml).unwrap();
650 let r = back.rules.get("firefox").expect("rule present");
651 assert_eq!(r.match_exe, vec!["firefox".to_string()]);
652 assert_eq!(r.memory.as_deref(), Some("4G"));
653 }
654
655 #[test]
656 fn add_and_remove_rule() {
657 let mut cfg = Config::default();
658 cfg.add_rule("code", AppRule::default());
659 assert!(cfg.rules.contains_key("code"));
660 assert!(cfg.remove_rule("code"));
661 assert!(!cfg.remove_rule("code"));
662 assert!(cfg.rules.is_empty());
663 }
664
665 #[test]
666 fn protect_set_merges_builtin_and_extra() {
667 let s = protect_set(&["gnome-control-center".into()]);
668 assert!(s.contains("gnome-shell"));
669 assert!(s.contains("gnome-control-center"));
670 }
671
672 #[test]
675 fn terminals_and_multiplexers_are_protected() {
676 let s = protect_set(&[]);
677 for exe in [
678 "gnome-terminal-server",
679 "ptyxis",
680 "ptyxis-agent",
681 "kgx",
682 "konsole",
683 "kitty",
684 "alacritty",
685 "wezterm-gui",
686 "foot",
687 "tilix",
688 "xfce4-terminal",
689 "xterm",
690 "terminator",
691 "tmux",
692 "screen",
693 ] {
694 assert!(is_protected(&s, "x", Some(exe)), "{exe}");
695 }
696 assert!(is_protected(&s, "tmux: server", None));
697 }
698
699 #[test]
700 fn is_protected_prefers_full_exe_name_over_truncated_comm() {
701 let s = protect_set(&["gnome-control-center".into()]);
702 assert!(is_protected(
703 &s,
704 "gnome-control-c",
705 Some("gnome-control-center")
706 ));
707 assert!(
708 !is_protected(&s, "gnome-control-c", None),
709 "truncated comm alone cannot match"
710 );
711 assert!(is_protected(&s, "bash", None));
712 assert!(!is_protected(&s, "firefox", Some("firefox")));
713 }
714
715 #[test]
716 fn readme_guard_example_parses_and_validates() {
717 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";
718 let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
719 cfg.guard.validate().unwrap();
720 assert_eq!(cfg.guard.trigger.act_below_available_pct, 20);
721 }
722
723 #[test]
724 fn notify_pressure_is_off_by_default_and_accepted() {
725 assert!(GuardConfig::default().notify);
726 assert!(!GuardConfig::default().notify_pressure);
727 let cfg: Config = serde_yaml_ng::from_str("guard:\n notify_pressure: true\n").unwrap();
728 cfg.guard.validate().unwrap();
729 assert!(cfg.guard.notify_pressure);
730 assert!(cfg.guard.notify, "other keys keep their defaults");
731 }
732
733 #[test]
734 fn unknown_guard_key_is_an_error() {
735 let err = serde_yaml_ng::from_str::<Config>("guard:\n selection: { min_rss: 100 }\n")
736 .unwrap_err()
737 .to_string();
738 assert!(err.contains("min_rss"), "{err}");
739 }
740
741 #[test]
742 fn unknown_top_level_and_profile_keys_are_errors() {
743 assert!(serde_yaml_ng::from_str::<Config>("gaurd:\n enabled: false\n").is_err());
744 assert!(serde_yaml_ng::from_str::<Config>("profiles:\n a: { memroy: 2G }\n").is_err());
745 }
746
747 #[test]
748 fn claude_md_profile_example_still_parses() {
749 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";
750 let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
751 assert_eq!(cfg.profiles["browser"].memory.as_deref(), Some("4G"));
752 }
753
754 #[test]
755 fn default_guard_config_validates() {
756 GuardConfig::default().validate().unwrap();
757 }
758
759 #[test]
760 #[allow(clippy::type_complexity)]
761 fn validate_rejects_bad_values() {
762 let bad: Vec<Box<dyn Fn(&mut GuardConfig)>> = vec![
763 Box::new(|c| {
764 c.trigger.psi_some_warn = 40.0;
765 c.trigger.psi_some_high = 30.0
766 }),
767 Box::new(|c| c.trigger.psi_full_critical = 0.0),
768 Box::new(|c| c.trigger.psi_some_high = f64::NAN),
769 Box::new(|c| c.trigger.act_below_available_pct = 0),
770 Box::new(|c| c.trigger.act_below_available_pct = 101),
771 Box::new(|c| c.timing.sample_interval_ms = 0),
772 Box::new(|c| c.timing.freeze_hold_secs = 0),
773 Box::new(|c| c.timing.calm_hold_secs = 0),
774 Box::new(|c| c.timing.freeze_cooldown_secs = 1),
775 Box::new(|c| c.selection.protect = vec![" ".into()]),
776 ];
777 for (i, f) in bad.iter().enumerate() {
778 let mut c = GuardConfig::default();
779 f(&mut c);
780 assert!(c.validate().is_err(), "case {i} should be rejected");
781 }
782 }
783
784 #[test]
787 #[allow(clippy::type_complexity)]
788 fn validate_enforces_upper_bounds() {
789 let cases: Vec<(&str, Box<dyn Fn(&mut GuardConfig, u64)>, u64)> = vec![
790 (
791 "trigger.mem_available_floor_mb",
792 Box::new(|c, v| c.trigger.mem_available_floor_mb = v),
793 MAX_GUARD_MB,
794 ),
795 (
796 "timing.calm_hold_secs",
797 Box::new(|c, v| c.timing.calm_hold_secs = v),
798 MAX_GUARD_SECS,
799 ),
800 (
801 "timing.freeze_cooldown_secs",
802 Box::new(|c, v| c.timing.freeze_cooldown_secs = v),
803 MAX_GUARD_SECS,
804 ),
805 (
806 "selection.min_rss_mb",
807 Box::new(|c, v| c.selection.min_rss_mb = v),
808 MAX_GUARD_MB,
809 ),
810 (
811 "timing.freeze_hold_secs",
812 Box::new(|c, v| c.timing.freeze_hold_secs = v),
813 60,
814 ),
815 (
816 "timing.sample_interval_ms",
817 Box::new(|c, v| c.timing.sample_interval_ms = v),
818 60_000,
819 ),
820 (
821 "trigger.act_below_available_pct",
822 Box::new(|c, v| c.trigger.act_below_available_pct = v),
823 100,
824 ),
825 ];
826 for (field, set, max) in &cases {
827 let mut c = GuardConfig::default();
828 set(&mut c, *max);
829 c.validate()
830 .unwrap_or_else(|e| panic!("{field} = {max} must be accepted: {e}"));
831 for v in [max + 1, u64::MAX] {
832 let mut c = GuardConfig::default();
833 set(&mut c, v);
834 let err = c.validate().expect_err(field).to_string();
835 assert!(err.contains(field), "{field} = {v}: {err}");
836 }
837 }
838 }
839
840 #[test]
841 fn profile_lookup_is_case_insensitive_when_unique() {
842 let cfg = Config::default();
843 assert!(cfg.get_profile("browser").is_some());
844 assert!(cfg.get_profile("MEDIUM").is_some());
845 assert!(cfg.get_profile("nope").is_none());
846 }
847
848 #[test]
849 fn exact_profile_name_wins_and_ambiguity_is_refused() {
850 let mut cfg = Config::default();
851 cfg.profiles.insert(
852 "browser".into(),
853 Profile {
854 memory: Some("1G".into()),
855 ..Default::default()
856 },
857 );
858 assert_eq!(
859 cfg.get_profile("browser").unwrap().memory.as_deref(),
860 Some("1G")
861 );
862 assert_eq!(
863 cfg.get_profile("Browser").unwrap().memory.as_deref(),
864 Some("4G")
865 );
866 assert!(
867 cfg.get_profile("BROWSER").is_none(),
868 "two case-insensitive matches"
869 );
870 }
871
872 #[test]
873 fn profile_names_are_sorted_case_insensitively() {
874 let mut cfg = Config::default();
875 cfg.profiles.insert(
876 "aaa".into(),
877 Profile {
878 cpu: Some("10%".into()),
879 ..Default::default()
880 },
881 );
882 assert_eq!(
883 cfg.profile_names(),
884 vec!["aaa", "Browser", "Heavy", "Light", "Medium"]
885 );
886 }
887
888 #[test]
889 fn profile_validate_rejects_empty_and_invalid() {
890 assert!(Profile::default().validate().is_err());
891 assert!(Profile {
892 memory: Some("lots".into()),
893 ..Default::default()
894 }
895 .validate()
896 .is_err());
897 assert!(Profile {
898 cpu: Some("50%".into()),
899 ..Default::default()
900 }
901 .validate()
902 .is_ok());
903 }
904
905 #[test]
906 fn load_from_names_the_file_on_parse_error() {
907 let dir = tempfile::tempdir().unwrap();
908 let p = dir.path().join("config.yaml");
909 std::fs::write(&p, "profiles: [\n").unwrap();
910 let err = Config::load_from(&p).unwrap_err().to_string();
911 assert!(err.contains("config.yaml"), "{err}");
912 }
913}