Skip to main content

rlmctl_common/
config.rs

1use crate::{Error, Limit, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Maximum config file size (1 MB) - prevents YAML bomb DoS attacks
8const MAX_CONFIG_SIZE: u64 = 1_048_576;
9
10/// Upper bound for guard sizes given in MB (16 TiB). Far above any real
11/// host, and low enough that converting to bytes cannot overflow.
12pub const MAX_GUARD_MB: u64 = 16 * 1024 * 1024;
13/// Upper bound for guard durations given in seconds (one day).
14pub 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    /// Freeze-guard daemon configuration. Skipped on serialize when at defaults
23    /// so saving profiles doesn't pollute config.yaml with a guard block.
24    #[serde(default, skip_serializing_if = "GuardConfig::is_default")]
25    pub guard: GuardConfig,
26
27    /// Persistent application limit rules, enforced continuously by rlm-guard.
28    /// Keyed by rule name (defaults to the executable basename). Omitted from
29    /// serialized output when empty.
30    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
31    pub rules: HashMap<String, AppRule>,
32}
33
34/// A persistent application limit rule. Instances whose executable basename is
35/// in `match_exe` are placed into a shared `app-<name>` cgroup with these limits.
36/// Limits are stored inline (a snapshot), not as a reference to a profile.
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct AppRule {
40    /// Executable basenames this rule matches.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub match_exe: Vec<String>,
43
44    /// Memory limit (e.g., "4G").
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub memory: Option<String>,
47
48    /// CPU limit (e.g., "75%").
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub cpu: Option<String>,
51
52    /// I/O read bandwidth limit (e.g., "100M").
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub io_read: Option<String>,
55
56    /// I/O write bandwidth limit (e.g., "50M").
57    #[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/// Configuration for the `rlm-guard` freeze-guard daemon. Every field defaults,
97/// so a missing `guard:` section (or any missing key) yields a working setup.
98#[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    /// Send desktop notifications at all. When on, the guard shows one
106    /// notification per app it pauses or slows down, and clears it when the
107    /// app is released.
108    pub notify: bool,
109    /// Also warn once a minute while memory is running low and no app is held.
110    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    /// Reject values the guard cannot act on safely. Called by rlm-guard at
132    /// startup and by `rlm guard status` / `rlm doctor`.
133    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/// Pressure thresholds (PSI percentages and a MemAvailable backstop).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct GuardTrigger {
187    /// PSI `some` avg10 (%) at which to start warning.
188    pub psi_some_warn: f64,
189    /// PSI `some` avg10 (%) at which to start acting (High).
190    pub psi_some_high: f64,
191    /// PSI `full` avg10 (%) considered Critical.
192    pub psi_full_critical: f64,
193    /// Hard floor: act if MemAvailable drops below this many MB.
194    pub mem_available_floor_mb: u64,
195    /// Act only while MemAvailable is below this percentage of MemTotal (or below the floor).
196    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/// Timing/hysteresis knobs.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(default, deny_unknown_fields)]
214pub struct GuardTiming {
215    /// How long a freeze is held before auto-thaw.
216    pub freeze_hold_secs: u64,
217    /// How long pressure must stay Calm before caps are lifted.
218    pub calm_hold_secs: u64,
219    /// Minimum gap before the same PID may be frozen again (else it's capped).
220    pub freeze_cooldown_secs: u64,
221    /// Sampling interval.
222    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/// Victim-selection knobs.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(default, deny_unknown_fields)]
239pub struct GuardSelection {
240    /// Ignore processes smaller than this (MB of RSS+swap).
241    pub min_rss_mb: u64,
242    /// Process names to NEVER act on. These ADD to the built-in protect-list.
243    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
255/// Process names always protected from the guard, regardless of config.
256pub 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    // Terminal emulators. Most run as their own unit in app.slice with the
276    // shells in other scopes, so without this they could be frozen.
277    // Matched on the exe basename; terminator is a Python script, so its
278    // exe is python3 and it matches on comm instead.
279    "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    // Terminal multiplexers. The tmux server sets its comm to "tmux: server".
293    "tmux",
294    "tmux: server",
295    "screen",
296];
297
298/// Built-in protect names plus the user's additions from `guard.selection.protect`.
299pub 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
307/// A process is protected if its full executable basename is in `set`, or,
308/// when the executable is unreadable, if its comm is. comm is truncated to 15
309/// characters by the kernel, so it only matches short names.
310pub 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    /// Executables this profile matches
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub match_exe: Vec<String>,
320
321    /// Memory limit (e.g., "2G")
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub memory: Option<String>,
324
325    /// CPU limit (e.g., "50%")
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub cpu: Option<String>,
328
329    /// I/O read bandwidth limit (e.g., "100M")
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub io_read: Option<String>,
332
333    /// I/O write bandwidth limit (e.g., "50M")
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub io_write: Option<String>,
336}
337
338impl Profile {
339    /// Validate that this profile's limit values parse and that it sets at
340    /// least one limit (an all-empty profile is never useful).
341    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
383/// Built-in preset profiles
384pub 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    /// Load config from default locations (user overrides system)
440    pub fn load() -> Result<Self> {
441        let mut config = Config::default();
442
443        // System config
444        let system_path = PathBuf::from("/etc/rlm/config.yaml");
445        if system_path.exists() {
446            config.merge_from(&system_path)?;
447        }
448
449        // User config
450        if let Some(user_path) = Self::user_config_path() {
451            if user_path.exists() {
452                config.merge_from(&user_path)?;
453            }
454
455            // Load profiles from profiles.d/
456            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    /// `load()` plus guard validation. rlm-guard refuses to start on `Err`.
469    pub fn load_validated() -> Result<Self> {
470        let c = Self::load()?;
471        c.guard.validate()?;
472        Ok(c)
473    }
474
475    /// Load config from a specific file
476    pub fn load_from(path: &Path) -> Result<Self> {
477        // Check file size to prevent YAML bomb DoS
478        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        // A non-default guard block in a loaded file takes effect.
496        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    /// Path of the per-user config file, `~/.config/rlm/config.yaml`.
514    pub fn user_config_path() -> Option<PathBuf> {
515        dirs::config_dir().map(|d| d.join("rlm").join("config.yaml"))
516    }
517
518    /// Find a profile by name (includes built-in presets): an exact match
519    /// wins, otherwise a case-insensitive match is used if exactly one
520    /// profile name matches.
521    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    /// Resolve `name` to a real profile name: an exact match wins, otherwise
527    /// the single case-insensitive match, or `None` if there is no match or
528    /// more than one.
529    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    /// All profile names (user profiles plus built-in presets), sorted
544    /// case-insensitively (ties broken by the name itself).
545    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    /// Get all profiles including built-in presets (user profiles override)
556    pub fn all_profiles(&self) -> HashMap<String, Profile> {
557        let mut all = builtin_presets();
558        // User profiles override built-in
559        for (name, profile) in &self.profiles {
560            all.insert(name.clone(), profile.clone());
561        }
562        all
563    }
564
565    /// Add or replace a persistent application rule.
566    pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
567        self.rules.insert(name.into(), rule);
568    }
569
570    /// Remove a persistent rule by name. Returns true if a rule was removed.
571    pub fn remove_rule(&mut self, name: &str) -> bool {
572        self.rules.remove(name).is_some()
573    }
574
575    /// Save config to user config path (atomic write)
576    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        // Atomic write: write to temp file, then rename
588        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    /// Terminals and multiplexers are protected by their exe basename, and
673    /// tmux also by the comm "tmux: server" it sets on its server process.
674    #[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    /// Every size and duration has an upper bound: the bound itself is
785    /// accepted, one past it is rejected with a message naming the field.
786    #[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}