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/// Why a rule keyed by the program name `exe` would stop matching after an
299/// update, if it would: a name with no letters (such as `2.1.283`) is a
300/// version number, and the next release installs a program with a new one.
301pub 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
311/// Built-in protect names plus the user's additions from `guard.selection.protect`.
312pub 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
320/// A process is protected if its full executable basename is in `set`, or,
321/// when the executable is unreadable, if its comm is. comm is truncated to 15
322/// characters by the kernel, so it only matches short names.
323pub 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    /// Executables this profile matches
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub match_exe: Vec<String>,
333
334    /// Memory limit (e.g., "2G")
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub memory: Option<String>,
337
338    /// CPU limit (e.g., "50%")
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub cpu: Option<String>,
341
342    /// I/O read bandwidth limit (e.g., "100M")
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub io_read: Option<String>,
345
346    /// I/O write bandwidth limit (e.g., "50M")
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub io_write: Option<String>,
349}
350
351impl Profile {
352    /// Validate that this profile's limit values parse and that it sets at
353    /// least one limit (an all-empty profile is never useful).
354    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
396/// Built-in preset profiles
397pub 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    /// Load config from default locations (user overrides system)
453    pub fn load() -> Result<Self> {
454        let mut config = Config::default();
455
456        // System config
457        let system_path = PathBuf::from("/etc/rlm/config.yaml");
458        if system_path.exists() {
459            config.merge_from(&system_path)?;
460        }
461
462        // User config
463        if let Some(user_path) = Self::user_config_path() {
464            if user_path.exists() {
465                config.merge_from(&user_path)?;
466            }
467
468            // Load profiles from profiles.d/
469            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    /// `load()` plus guard validation. rlm-guard refuses to start on `Err`.
482    pub fn load_validated() -> Result<Self> {
483        let c = Self::load()?;
484        c.guard.validate()?;
485        Ok(c)
486    }
487
488    /// Load config from a specific file
489    pub fn load_from(path: &Path) -> Result<Self> {
490        // Check file size to prevent YAML bomb DoS
491        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        // A non-default guard block in a loaded file takes effect.
509        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    /// Path of the per-user config file, `~/.config/rlm/config.yaml`.
527    pub fn user_config_path() -> Option<PathBuf> {
528        dirs::config_dir().map(|d| d.join("rlm").join("config.yaml"))
529    }
530
531    /// Find a profile by name (includes built-in presets): an exact match
532    /// wins, otherwise a case-insensitive match is used if exactly one
533    /// profile name matches.
534    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    /// Resolve `name` to a real profile name: an exact match wins, otherwise
540    /// the single case-insensitive match, or `None` if there is no match or
541    /// more than one.
542    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    /// All profile names (user profiles plus built-in presets), sorted
557    /// case-insensitively (ties broken by the name itself).
558    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    /// Get all profiles including built-in presets (user profiles override)
569    pub fn all_profiles(&self) -> HashMap<String, Profile> {
570        let mut all = builtin_presets();
571        // User profiles override built-in
572        for (name, profile) in &self.profiles {
573            all.insert(name.clone(), profile.clone());
574        }
575        all
576    }
577
578    /// Add or replace a persistent application rule.
579    pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
580        self.rules.insert(name.into(), rule);
581    }
582
583    /// Remove a persistent rule by name. Returns true if a rule was removed.
584    pub fn remove_rule(&mut self, name: &str) -> bool {
585        self.rules.remove(name).is_some()
586    }
587
588    /// Save config to user config path (atomic write)
589    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        // Atomic write: write to temp file, then rename
601        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    /// Terminals and multiplexers are protected by their exe basename, and
699    /// tmux also by the comm "tmux: server" it sets on its server process.
700    #[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    /// Every size and duration has an upper bound: the bound itself is
811    /// accepted, one past it is rejected with a message naming the field.
812    #[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}