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    pub notify: bool,
106}
107
108impl Default for GuardConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            trigger: GuardTrigger::default(),
113            timing: GuardTiming::default(),
114            selection: GuardSelection::default(),
115            notify: true,
116        }
117    }
118}
119
120impl GuardConfig {
121    pub fn is_default(&self) -> bool {
122        *self == GuardConfig::default()
123    }
124
125    /// Reject values the guard cannot act on safely. Called by rlm-guard at
126    /// startup and by `rlm guard status` / `rlm doctor`.
127    pub fn validate(&self) -> Result<()> {
128        let bad = |m: &str| Err(Error::Config(format!("guard: {m}")));
129        let t = &self.trigger;
130        let pct = |v: f64| v > 0.0 && v <= 100.0;
131        if !pct(t.psi_some_warn) || !pct(t.psi_some_high) || !pct(t.psi_full_critical) {
132            return bad("trigger PSI thresholds must be between 0 (exclusive) and 100");
133        }
134        if t.psi_some_warn >= t.psi_some_high {
135            return bad("trigger.psi_some_warn must be below trigger.psi_some_high");
136        }
137        if !(1..=100).contains(&t.act_below_available_pct) {
138            return bad("trigger.act_below_available_pct must be between 1 and 100");
139        }
140        if t.mem_available_floor_mb > MAX_GUARD_MB {
141            return bad(&format!(
142                "trigger.mem_available_floor_mb must be at most {MAX_GUARD_MB}"
143            ));
144        }
145        let tm = &self.timing;
146        if !(100..=60_000).contains(&tm.sample_interval_ms) {
147            return bad("timing.sample_interval_ms must be between 100 and 60000");
148        }
149        if !(1..=60).contains(&tm.freeze_hold_secs) {
150            return bad("timing.freeze_hold_secs must be between 1 and 60");
151        }
152        if !(1..=MAX_GUARD_SECS).contains(&tm.calm_hold_secs) {
153            return bad(&format!(
154                "timing.calm_hold_secs must be between 1 and {MAX_GUARD_SECS}"
155            ));
156        }
157        if tm.freeze_cooldown_secs < tm.freeze_hold_secs {
158            return bad("timing.freeze_cooldown_secs must be at least timing.freeze_hold_secs");
159        }
160        if tm.freeze_cooldown_secs > MAX_GUARD_SECS {
161            return bad(&format!(
162                "timing.freeze_cooldown_secs must be at most {MAX_GUARD_SECS}"
163            ));
164        }
165        if self.selection.min_rss_mb > MAX_GUARD_MB {
166            return bad(&format!(
167                "selection.min_rss_mb must be at most {MAX_GUARD_MB}"
168            ));
169        }
170        if self.selection.protect.iter().any(|p| p.trim().is_empty()) {
171            return bad("selection.protect must not contain empty names");
172        }
173        Ok(())
174    }
175}
176
177/// Pressure thresholds (PSI percentages and a MemAvailable backstop).
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(default, deny_unknown_fields)]
180pub struct GuardTrigger {
181    /// PSI `some` avg10 (%) at which to start warning.
182    pub psi_some_warn: f64,
183    /// PSI `some` avg10 (%) at which to start acting (High).
184    pub psi_some_high: f64,
185    /// PSI `full` avg10 (%) considered Critical.
186    pub psi_full_critical: f64,
187    /// Hard floor: act if MemAvailable drops below this many MB.
188    pub mem_available_floor_mb: u64,
189    /// Act only while MemAvailable is below this percentage of MemTotal (or below the floor).
190    pub act_below_available_pct: u64,
191}
192
193impl Default for GuardTrigger {
194    fn default() -> Self {
195        Self {
196            psi_some_warn: 10.0,
197            psi_some_high: 30.0,
198            psi_full_critical: 10.0,
199            mem_available_floor_mb: 400,
200            act_below_available_pct: 20,
201        }
202    }
203}
204
205/// Timing/hysteresis knobs.
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct GuardTiming {
209    /// How long a freeze is held before auto-thaw.
210    pub freeze_hold_secs: u64,
211    /// How long pressure must stay Calm before caps are lifted.
212    pub calm_hold_secs: u64,
213    /// Minimum gap before the same PID may be frozen again (else it's capped).
214    pub freeze_cooldown_secs: u64,
215    /// Sampling interval.
216    pub sample_interval_ms: u64,
217}
218
219impl Default for GuardTiming {
220    fn default() -> Self {
221        Self {
222            freeze_hold_secs: 5,
223            calm_hold_secs: 30,
224            freeze_cooldown_secs: 60,
225            sample_interval_ms: 1000,
226        }
227    }
228}
229
230/// Victim-selection knobs.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(default, deny_unknown_fields)]
233pub struct GuardSelection {
234    /// Ignore processes smaller than this (MB of RSS+swap).
235    pub min_rss_mb: u64,
236    /// Process names to NEVER act on. These ADD to the built-in protect-list.
237    pub protect: Vec<String>,
238}
239
240impl Default for GuardSelection {
241    fn default() -> Self {
242        Self {
243            min_rss_mb: 200,
244            protect: Vec::new(),
245        }
246    }
247}
248
249/// Process names always protected from the guard, regardless of config.
250pub const BUILTIN_PROTECT: &[&str] = &[
251    "gnome-shell",
252    "kwin_wayland",
253    "kwin_x11",
254    "plasmashell",
255    "sway",
256    "Hyprland",
257    "Xwayland",
258    "Xorg",
259    "sshd",
260    "systemd",
261    "dbus-daemon",
262    "pipewire",
263    "wireplumber",
264    "pulseaudio",
265    "rlm-guard",
266    "bash",
267    "zsh",
268    "fish",
269    // Terminal emulators. Most run as their own unit in app.slice with the
270    // shells in other scopes, so without this they could be frozen.
271    // Matched on the exe basename; terminator is a Python script, so its
272    // exe is python3 and it matches on comm instead.
273    "gnome-terminal-server",
274    "ptyxis",
275    "ptyxis-agent",
276    "kgx",
277    "konsole",
278    "kitty",
279    "alacritty",
280    "wezterm-gui",
281    "foot",
282    "tilix",
283    "xfce4-terminal",
284    "xterm",
285    "terminator",
286    // Terminal multiplexers. The tmux server sets its comm to "tmux: server".
287    "tmux",
288    "tmux: server",
289    "screen",
290];
291
292/// Built-in protect names plus the user's additions from `guard.selection.protect`.
293pub fn protect_set(extra: &[String]) -> HashSet<String> {
294    BUILTIN_PROTECT
295        .iter()
296        .map(|s| (*s).to_string())
297        .chain(extra.iter().cloned())
298        .collect()
299}
300
301/// A process is protected if its full executable basename is in `set`, or,
302/// when the executable is unreadable, if its comm is. comm is truncated to 15
303/// characters by the kernel, so it only matches short names.
304pub fn is_protected(set: &HashSet<String>, comm: &str, exe: Option<&str>) -> bool {
305    exe.is_some_and(|e| set.contains(e)) || set.contains(comm)
306}
307
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(deny_unknown_fields)]
310pub struct Profile {
311    /// Executables this profile matches
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub match_exe: Vec<String>,
314
315    /// Memory limit (e.g., "2G")
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub memory: Option<String>,
318
319    /// CPU limit (e.g., "50%")
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub cpu: Option<String>,
322
323    /// I/O read bandwidth limit (e.g., "100M")
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub io_read: Option<String>,
326
327    /// I/O write bandwidth limit (e.g., "50M")
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub io_write: Option<String>,
330}
331
332impl Profile {
333    /// Validate that this profile's limit values parse and that it sets at
334    /// least one limit (an all-empty profile is never useful).
335    pub fn validate(&self) -> Result<()> {
336        let l = self.to_limit()?;
337        if l.is_empty() {
338            return Err(Error::Config("profile sets no limits".into()));
339        }
340        Ok(())
341    }
342
343    pub fn to_limit(&self) -> Result<Limit> {
344        use crate::{CpuLimit, IoLimit, MemoryLimit};
345
346        let read_bps = self
347            .io_read
348            .as_ref()
349            .map(|s| IoLimit::parse_bps(s))
350            .transpose()?;
351        let write_bps = self
352            .io_write
353            .as_ref()
354            .map(|s| IoLimit::parse_bps(s))
355            .transpose()?;
356        let io = if read_bps.is_some() || write_bps.is_some() {
357            Some(IoLimit {
358                read_bps,
359                write_bps,
360            })
361        } else {
362            None
363        };
364
365        Ok(Limit {
366            memory: self
367                .memory
368                .as_ref()
369                .map(|s| MemoryLimit::parse(s))
370                .transpose()?,
371            cpu: self.cpu.as_ref().map(|s| CpuLimit::parse(s)).transpose()?,
372            io,
373        })
374    }
375}
376
377/// Built-in preset profiles
378pub fn builtin_presets() -> HashMap<String, Profile> {
379    let mut presets = HashMap::new();
380
381    presets.insert(
382        "Light".to_string(),
383        Profile {
384            match_exe: Vec::new(),
385            memory: Some("512M".to_string()),
386            cpu: Some("25%".to_string()),
387            io_read: None,
388            io_write: None,
389        },
390    );
391
392    presets.insert(
393        "Medium".to_string(),
394        Profile {
395            match_exe: Vec::new(),
396            memory: Some("2G".to_string()),
397            cpu: Some("50%".to_string()),
398            io_read: Some("50M".to_string()),
399            io_write: Some("25M".to_string()),
400        },
401    );
402
403    presets.insert(
404        "Heavy".to_string(),
405        Profile {
406            match_exe: Vec::new(),
407            memory: Some("4G".to_string()),
408            cpu: Some("100%".to_string()),
409            io_read: Some("100M".to_string()),
410            io_write: Some("50M".to_string()),
411        },
412    );
413
414    presets.insert(
415        "Browser".to_string(),
416        Profile {
417            match_exe: vec![
418                "firefox".to_string(),
419                "chrome".to_string(),
420                "chromium".to_string(),
421            ],
422            memory: Some("4G".to_string()),
423            cpu: Some("75%".to_string()),
424            io_read: None,
425            io_write: None,
426        },
427    );
428
429    presets
430}
431
432impl Config {
433    /// Load config from default locations (user overrides system)
434    pub fn load() -> Result<Self> {
435        let mut config = Config::default();
436
437        // System config
438        let system_path = PathBuf::from("/etc/rlm/config.yaml");
439        if system_path.exists() {
440            config.merge_from(&system_path)?;
441        }
442
443        // User config
444        if let Some(user_path) = Self::user_config_path() {
445            if user_path.exists() {
446                config.merge_from(&user_path)?;
447            }
448
449            // Load profiles from profiles.d/
450            let profiles_dir = user_path
451                .parent()
452                .map(|p| p.join("profiles.d"))
453                .unwrap_or_else(|| PathBuf::from("profiles.d"));
454            if profiles_dir.exists() {
455                config.load_profiles_dir(&profiles_dir)?;
456            }
457        }
458
459        Ok(config)
460    }
461
462    /// `load()` plus guard validation. rlm-guard refuses to start on `Err`.
463    pub fn load_validated() -> Result<Self> {
464        let c = Self::load()?;
465        c.guard.validate()?;
466        Ok(c)
467    }
468
469    /// Load config from a specific file
470    pub fn load_from(path: &Path) -> Result<Self> {
471        // Check file size to prevent YAML bomb DoS
472        let metadata = fs::metadata(path)?;
473        if metadata.len() > MAX_CONFIG_SIZE {
474            return Err(Error::Config(format!(
475                "config file {} exceeds maximum size of 1MB",
476                path.display()
477            )));
478        }
479
480        let content = fs::read_to_string(path)?;
481        serde_yaml_ng::from_str(&content)
482            .map_err(|e| Error::Config(format!("failed to parse {}: {e}", path.display())))
483    }
484
485    fn merge_from(&mut self, path: &Path) -> Result<()> {
486        let other = Self::load_from(path)?;
487        self.profiles.extend(other.profiles);
488        self.rules.extend(other.rules);
489        // A non-default guard block in a loaded file takes effect.
490        if !other.guard.is_default() {
491            self.guard = other.guard;
492        }
493        Ok(())
494    }
495
496    fn load_profiles_dir(&mut self, dir: &Path) -> Result<()> {
497        for entry in fs::read_dir(dir)? {
498            let entry = entry?;
499            let path = entry.path();
500            if path.extension().is_some_and(|e| e == "yaml" || e == "yml") {
501                self.merge_from(&path)?;
502            }
503        }
504        Ok(())
505    }
506
507    fn user_config_path() -> Option<PathBuf> {
508        dirs::config_dir().map(|d| d.join("rlm").join("config.yaml"))
509    }
510
511    /// Find a profile by name (includes built-in presets): an exact match
512    /// wins, otherwise a case-insensitive match is used if exactly one
513    /// profile name matches.
514    pub fn get_profile(&self, name: &str) -> Option<Profile> {
515        let resolved = self.resolve_profile_name(name)?;
516        self.all_profiles().get(&resolved).cloned()
517    }
518
519    /// Resolve `name` to a real profile name: an exact match wins, otherwise
520    /// the single case-insensitive match, or `None` if there is no match or
521    /// more than one.
522    pub fn resolve_profile_name(&self, name: &str) -> Option<String> {
523        let all = self.all_profiles();
524        if all.contains_key(name) {
525            return Some(name.to_string());
526        }
527        let mut matches = all.keys().filter(|k| k.eq_ignore_ascii_case(name));
528        let first = matches.next()?.clone();
529        if matches.next().is_some() {
530            None
531        } else {
532            Some(first)
533        }
534    }
535
536    /// All profile names (user profiles plus built-in presets), sorted
537    /// case-insensitively (ties broken by the name itself).
538    pub fn profile_names(&self) -> Vec<String> {
539        let mut names: Vec<String> = self.all_profiles().into_keys().collect();
540        names.sort_by(|a, b| {
541            a.to_lowercase()
542                .cmp(&b.to_lowercase())
543                .then_with(|| a.cmp(b))
544        });
545        names
546    }
547
548    /// Get all profiles including built-in presets (user profiles override)
549    pub fn all_profiles(&self) -> HashMap<String, Profile> {
550        let mut all = builtin_presets();
551        // User profiles override built-in
552        for (name, profile) in &self.profiles {
553            all.insert(name.clone(), profile.clone());
554        }
555        all
556    }
557
558    /// Add or replace a persistent application rule.
559    pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
560        self.rules.insert(name.into(), rule);
561    }
562
563    /// Remove a persistent rule by name. Returns true if a rule was removed.
564    pub fn remove_rule(&mut self, name: &str) -> bool {
565        self.rules.remove(name).is_some()
566    }
567
568    /// Save config to user config path (atomic write)
569    pub fn save(&self) -> Result<()> {
570        let path = Self::user_config_path()
571            .ok_or_else(|| Error::Config("No config directory found".into()))?;
572
573        if let Some(parent) = path.parent() {
574            fs::create_dir_all(parent)?;
575        }
576
577        let yaml = serde_yaml_ng::to_string(self)
578            .map_err(|e| Error::Config(format!("Failed to serialize config: {e}")))?;
579
580        // Atomic write: write to temp file, then rename
581        let tmp_path = path.with_extension("yaml.tmp");
582        fs::write(&tmp_path, &yaml)?;
583        fs::rename(&tmp_path, &path)?;
584        Ok(())
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn app_rule_to_limit_parses_fields() {
594        let rule = AppRule {
595            match_exe: vec!["firefox".into()],
596            memory: Some("4G".into()),
597            cpu: Some("75%".into()),
598            io_read: None,
599            io_write: None,
600        };
601        let limit = rule.to_limit().unwrap();
602        assert_eq!(limit.memory.unwrap().bytes(), 4 * 1024 * 1024 * 1024);
603        assert_eq!(limit.cpu.unwrap().percent(), 75);
604        assert!(limit.io.is_none());
605    }
606
607    #[test]
608    fn app_rule_invalid_limit_errors() {
609        let rule = AppRule {
610            match_exe: vec!["x".into()],
611            memory: Some("notasize".into()),
612            ..Default::default()
613        };
614        assert!(rule.to_limit().is_err());
615    }
616
617    #[test]
618    fn empty_rules_omitted_from_yaml() {
619        let cfg = Config::default();
620        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
621        assert!(
622            !yaml.contains("rules:"),
623            "empty rules must be omitted: {yaml}"
624        );
625    }
626
627    #[test]
628    fn rules_round_trip_through_yaml() {
629        let mut cfg = Config::default();
630        cfg.add_rule(
631            "firefox",
632            AppRule {
633                match_exe: vec!["firefox".into()],
634                memory: Some("4G".into()),
635                cpu: Some("75%".into()),
636                io_read: None,
637                io_write: None,
638            },
639        );
640        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
641        assert!(yaml.contains("rules:"));
642        let back: Config = serde_yaml_ng::from_str(&yaml).unwrap();
643        let r = back.rules.get("firefox").expect("rule present");
644        assert_eq!(r.match_exe, vec!["firefox".to_string()]);
645        assert_eq!(r.memory.as_deref(), Some("4G"));
646    }
647
648    #[test]
649    fn add_and_remove_rule() {
650        let mut cfg = Config::default();
651        cfg.add_rule("code", AppRule::default());
652        assert!(cfg.rules.contains_key("code"));
653        assert!(cfg.remove_rule("code"));
654        assert!(!cfg.remove_rule("code"));
655        assert!(cfg.rules.is_empty());
656    }
657
658    #[test]
659    fn protect_set_merges_builtin_and_extra() {
660        let s = protect_set(&["gnome-control-center".into()]);
661        assert!(s.contains("gnome-shell"));
662        assert!(s.contains("gnome-control-center"));
663    }
664
665    /// Terminals and multiplexers are protected by their exe basename, and
666    /// tmux also by the comm "tmux: server" it sets on its server process.
667    #[test]
668    fn terminals_and_multiplexers_are_protected() {
669        let s = protect_set(&[]);
670        for exe in [
671            "gnome-terminal-server",
672            "ptyxis",
673            "ptyxis-agent",
674            "kgx",
675            "konsole",
676            "kitty",
677            "alacritty",
678            "wezterm-gui",
679            "foot",
680            "tilix",
681            "xfce4-terminal",
682            "xterm",
683            "terminator",
684            "tmux",
685            "screen",
686        ] {
687            assert!(is_protected(&s, "x", Some(exe)), "{exe}");
688        }
689        assert!(is_protected(&s, "tmux: server", None));
690    }
691
692    #[test]
693    fn is_protected_prefers_full_exe_name_over_truncated_comm() {
694        let s = protect_set(&["gnome-control-center".into()]);
695        assert!(is_protected(
696            &s,
697            "gnome-control-c",
698            Some("gnome-control-center")
699        ));
700        assert!(
701            !is_protected(&s, "gnome-control-c", None),
702            "truncated comm alone cannot match"
703        );
704        assert!(is_protected(&s, "bash", None));
705        assert!(!is_protected(&s, "firefox", Some("firefox")));
706    }
707
708    #[test]
709    fn readme_guard_example_parses_and_validates() {
710        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";
711        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
712        cfg.guard.validate().unwrap();
713        assert_eq!(cfg.guard.trigger.act_below_available_pct, 20);
714    }
715
716    #[test]
717    fn unknown_guard_key_is_an_error() {
718        let err = serde_yaml_ng::from_str::<Config>("guard:\n  selection: { min_rss: 100 }\n")
719            .unwrap_err()
720            .to_string();
721        assert!(err.contains("min_rss"), "{err}");
722    }
723
724    #[test]
725    fn unknown_top_level_and_profile_keys_are_errors() {
726        assert!(serde_yaml_ng::from_str::<Config>("gaurd:\n  enabled: false\n").is_err());
727        assert!(serde_yaml_ng::from_str::<Config>("profiles:\n  a: { memroy: 2G }\n").is_err());
728    }
729
730    #[test]
731    fn claude_md_profile_example_still_parses() {
732        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";
733        let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
734        assert_eq!(cfg.profiles["browser"].memory.as_deref(), Some("4G"));
735    }
736
737    #[test]
738    fn default_guard_config_validates() {
739        GuardConfig::default().validate().unwrap();
740    }
741
742    #[test]
743    #[allow(clippy::type_complexity)]
744    fn validate_rejects_bad_values() {
745        let bad: Vec<Box<dyn Fn(&mut GuardConfig)>> = vec![
746            Box::new(|c| {
747                c.trigger.psi_some_warn = 40.0;
748                c.trigger.psi_some_high = 30.0
749            }),
750            Box::new(|c| c.trigger.psi_full_critical = 0.0),
751            Box::new(|c| c.trigger.psi_some_high = f64::NAN),
752            Box::new(|c| c.trigger.act_below_available_pct = 0),
753            Box::new(|c| c.trigger.act_below_available_pct = 101),
754            Box::new(|c| c.timing.sample_interval_ms = 0),
755            Box::new(|c| c.timing.freeze_hold_secs = 0),
756            Box::new(|c| c.timing.calm_hold_secs = 0),
757            Box::new(|c| c.timing.freeze_cooldown_secs = 1),
758            Box::new(|c| c.selection.protect = vec!["  ".into()]),
759        ];
760        for (i, f) in bad.iter().enumerate() {
761            let mut c = GuardConfig::default();
762            f(&mut c);
763            assert!(c.validate().is_err(), "case {i} should be rejected");
764        }
765    }
766
767    /// Every size and duration has an upper bound: the bound itself is
768    /// accepted, one past it is rejected with a message naming the field.
769    #[test]
770    #[allow(clippy::type_complexity)]
771    fn validate_enforces_upper_bounds() {
772        let cases: Vec<(&str, Box<dyn Fn(&mut GuardConfig, u64)>, u64)> = vec![
773            (
774                "trigger.mem_available_floor_mb",
775                Box::new(|c, v| c.trigger.mem_available_floor_mb = v),
776                MAX_GUARD_MB,
777            ),
778            (
779                "timing.calm_hold_secs",
780                Box::new(|c, v| c.timing.calm_hold_secs = v),
781                MAX_GUARD_SECS,
782            ),
783            (
784                "timing.freeze_cooldown_secs",
785                Box::new(|c, v| c.timing.freeze_cooldown_secs = v),
786                MAX_GUARD_SECS,
787            ),
788            (
789                "selection.min_rss_mb",
790                Box::new(|c, v| c.selection.min_rss_mb = v),
791                MAX_GUARD_MB,
792            ),
793            (
794                "timing.freeze_hold_secs",
795                Box::new(|c, v| c.timing.freeze_hold_secs = v),
796                60,
797            ),
798            (
799                "timing.sample_interval_ms",
800                Box::new(|c, v| c.timing.sample_interval_ms = v),
801                60_000,
802            ),
803            (
804                "trigger.act_below_available_pct",
805                Box::new(|c, v| c.trigger.act_below_available_pct = v),
806                100,
807            ),
808        ];
809        for (field, set, max) in &cases {
810            let mut c = GuardConfig::default();
811            set(&mut c, *max);
812            c.validate()
813                .unwrap_or_else(|e| panic!("{field} = {max} must be accepted: {e}"));
814            for v in [max + 1, u64::MAX] {
815                let mut c = GuardConfig::default();
816                set(&mut c, v);
817                let err = c.validate().expect_err(field).to_string();
818                assert!(err.contains(field), "{field} = {v}: {err}");
819            }
820        }
821    }
822
823    #[test]
824    fn profile_lookup_is_case_insensitive_when_unique() {
825        let cfg = Config::default();
826        assert!(cfg.get_profile("browser").is_some());
827        assert!(cfg.get_profile("MEDIUM").is_some());
828        assert!(cfg.get_profile("nope").is_none());
829    }
830
831    #[test]
832    fn exact_profile_name_wins_and_ambiguity_is_refused() {
833        let mut cfg = Config::default();
834        cfg.profiles.insert(
835            "browser".into(),
836            Profile {
837                memory: Some("1G".into()),
838                ..Default::default()
839            },
840        );
841        assert_eq!(
842            cfg.get_profile("browser").unwrap().memory.as_deref(),
843            Some("1G")
844        );
845        assert_eq!(
846            cfg.get_profile("Browser").unwrap().memory.as_deref(),
847            Some("4G")
848        );
849        assert!(
850            cfg.get_profile("BROWSER").is_none(),
851            "two case-insensitive matches"
852        );
853    }
854
855    #[test]
856    fn profile_names_are_sorted_case_insensitively() {
857        let mut cfg = Config::default();
858        cfg.profiles.insert(
859            "aaa".into(),
860            Profile {
861                cpu: Some("10%".into()),
862                ..Default::default()
863            },
864        );
865        assert_eq!(
866            cfg.profile_names(),
867            vec!["aaa", "Browser", "Heavy", "Light", "Medium"]
868        );
869    }
870
871    #[test]
872    fn profile_validate_rejects_empty_and_invalid() {
873        assert!(Profile::default().validate().is_err());
874        assert!(Profile {
875            memory: Some("lots".into()),
876            ..Default::default()
877        }
878        .validate()
879        .is_err());
880        assert!(Profile {
881            cpu: Some("50%".into()),
882            ..Default::default()
883        }
884        .validate()
885        .is_ok());
886    }
887
888    #[test]
889    fn load_from_names_the_file_on_parse_error() {
890        let dir = tempfile::tempdir().unwrap();
891        let p = dir.path().join("config.yaml");
892        std::fs::write(&p, "profiles: [\n").unwrap();
893        let err = Config::load_from(&p).unwrap_err().to_string();
894        assert!(err.contains("config.yaml"), "{err}");
895    }
896}