1use crate::{Error, Limit, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const MAX_CONFIG_SIZE: u64 = 1_048_576;
9
10pub const MAX_GUARD_MB: u64 = 16 * 1024 * 1024;
13pub const MAX_GUARD_SECS: u64 = 86_400;
15
16#[derive(Debug, Default, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19 #[serde(default)]
20 pub profiles: HashMap<String, Profile>,
21
22 #[serde(default, skip_serializing_if = "GuardConfig::is_default")]
25 pub guard: GuardConfig,
26
27 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
31 pub rules: HashMap<String, AppRule>,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct AppRule {
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub match_exe: Vec<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub memory: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub cpu: Option<String>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub io_read: Option<String>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub io_write: Option<String>,
59}
60
61impl AppRule {
62 pub fn to_limit(&self) -> Result<Limit> {
63 use crate::{CpuLimit, IoLimit, MemoryLimit};
64
65 let read_bps = self
66 .io_read
67 .as_ref()
68 .map(|s| IoLimit::parse_bps(s))
69 .transpose()?;
70 let write_bps = self
71 .io_write
72 .as_ref()
73 .map(|s| IoLimit::parse_bps(s))
74 .transpose()?;
75 let io = if read_bps.is_some() || write_bps.is_some() {
76 Some(IoLimit {
77 read_bps,
78 write_bps,
79 })
80 } else {
81 None
82 };
83
84 Ok(Limit {
85 memory: self
86 .memory
87 .as_ref()
88 .map(|s| MemoryLimit::parse(s))
89 .transpose()?,
90 cpu: self.cpu.as_ref().map(|s| CpuLimit::parse(s)).transpose()?,
91 io,
92 })
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct GuardConfig {
101 pub enabled: bool,
102 pub trigger: GuardTrigger,
103 pub timing: GuardTiming,
104 pub selection: GuardSelection,
105 pub notify: bool,
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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(default, deny_unknown_fields)]
180pub struct GuardTrigger {
181 pub psi_some_warn: f64,
183 pub psi_some_high: f64,
185 pub psi_full_critical: f64,
187 pub mem_available_floor_mb: u64,
189 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct GuardTiming {
209 pub freeze_hold_secs: u64,
211 pub calm_hold_secs: u64,
213 pub freeze_cooldown_secs: u64,
215 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(default, deny_unknown_fields)]
233pub struct GuardSelection {
234 pub min_rss_mb: u64,
236 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
249pub 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 "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 "tmux",
288 "tmux: server",
289 "screen",
290];
291
292pub 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
301pub 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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub match_exe: Vec<String>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub memory: Option<String>,
318
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub cpu: Option<String>,
322
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub io_read: Option<String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub io_write: Option<String>,
330}
331
332impl Profile {
333 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
377pub 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 pub fn load() -> Result<Self> {
435 let mut config = Config::default();
436
437 let system_path = PathBuf::from("/etc/rlm/config.yaml");
439 if system_path.exists() {
440 config.merge_from(&system_path)?;
441 }
442
443 if let Some(user_path) = Self::user_config_path() {
445 if user_path.exists() {
446 config.merge_from(&user_path)?;
447 }
448
449 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 pub fn load_validated() -> Result<Self> {
464 let c = Self::load()?;
465 c.guard.validate()?;
466 Ok(c)
467 }
468
469 pub fn load_from(path: &Path) -> Result<Self> {
471 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 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 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 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 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 pub fn all_profiles(&self) -> HashMap<String, Profile> {
550 let mut all = builtin_presets();
551 for (name, profile) in &self.profiles {
553 all.insert(name.clone(), profile.clone());
554 }
555 all
556 }
557
558 pub fn add_rule(&mut self, name: impl Into<String>, rule: AppRule) {
560 self.rules.insert(name.into(), rule);
561 }
562
563 pub fn remove_rule(&mut self, name: &str) -> bool {
565 self.rules.remove(name).is_some()
566 }
567
568 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 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 #[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 #[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}