git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/power_plan.rs (32.5K)

  1 //! Power modes and the adapter states they are assigned to: a named set of
  2 //! levers per mode, and a small table saying which mode runs when the
  3 //! machine is plugged in and which when it runs on battery.
  4 //!
  5 //! Shared between the two sides of the feature, which is the point of the
  6 //! module: the Power page edits the modes and the assignment, and
  7 //! `cce-power-apply` (this crate's helper binary) applies them as root —
  8 //! from udev when the Mains supply flips, at boot, and on demand when the
  9 //! page changes a lever of the mode that is running right now. One parser,
 10 //! one apply path, one value guard, so the two sides cannot drift.
 11 //!
 12 //! The plan lives at [`PLAN_PATH`], root-owned, because the applier runs as
 13 //! root outside any session: it has no `$HOME` to look in, and a root daemon
 14 //! taking its orders from a user-writable file would be a privilege boundary
 15 //! drawn in the wrong place. Writes go through the helper under pkexec, the
 16 //! same one-prompt path every lever change in this app already takes.
 17 //!
 18 //! ```kdl
 19 //! mode "performance" {
 20 //!     profile "performance"
 21 //!     turbo "on"
 22 //! }
 23 //! mode "power-saver" {
 24 //!     profile "low-power"
 25 //!     igpu_max_mhz 800
 26 //! }
 27 //! assign {
 28 //!     ac "performance"
 29 //!     battery "power-saver"
 30 //! }
 31 //! ```
 32 //!
 33 //! A lever absent from a mode is left alone when that mode becomes active —
 34 //! "not set" means "don't touch", never "reset to a default". The older
 35 //! per-source form of this file (top-level `ac` / `battery` blocks of
 36 //! levers, before modes existed) still parses: each block becomes the mode
 37 //! that source is assigned to by default, which is exactly the behavior it
 38 //! had.
 39 
 40 use std::collections::BTreeMap;
 41 use std::path::{Path, PathBuf};
 42 
 43 pub const PLAN_PATH: &str = "/etc/cce/power.kdl";
 44 /// Where `ccebuild install-system` puts the helper. The udev rule and the
 45 /// system unit both name this path, so its presence is what "automatic
 46 /// switching is installed" means to the page.
 47 pub const HELPER_SYSTEM_PATH: &str = "/usr/bin/cce-power-apply";
 48 pub const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/90-cce-power-apply.rules";
 49 
 50 /// A power-adapter state. Defaults to `Ac`: a host with no Mains supply has
 51 /// nothing to unplug.
 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
 53 pub enum Source {
 54     #[default]
 55     Ac,
 56     Battery,
 57 }
 58 
 59 impl Source {
 60     pub const ALL: [Source; 2] = [Source::Ac, Source::Battery];
 61 
 62     /// The key in the `assign` block, and the CLI spelling.
 63     pub fn key(self) -> &'static str {
 64         match self {
 65             Source::Ac => "ac",
 66             Source::Battery => "battery",
 67         }
 68     }
 69 
 70     pub fn parse(s: &str) -> Option<Source> {
 71         Source::ALL.into_iter().find(|v| v.key() == s)
 72     }
 73 
 74     pub fn label(self) -> &'static str {
 75         match self {
 76             Source::Ac => "Plugged In",
 77             Source::Battery => "On Battery",
 78         }
 79     }
 80 
 81     /// The mode a source runs when the plan says nothing about it. These are
 82     /// also what the pre-modes file format migrates onto, so an old plan
 83     /// keeps behaving exactly as it did.
 84     pub fn default_mode(self) -> Mode {
 85         match self {
 86             Source::Ac => Mode::Performance,
 87             Source::Battery => Mode::PowerSaver,
 88         }
 89     }
 90 }
 91 
 92 /// A named set of lever values. The set is fixed rather than user-extensible:
 93 /// the page picks a mode from a dropdown, and there is deliberately no
 94 /// naming UI to keep a mode's identity stable across the plan file, the
 95 /// helper's CLI and the assignment table.
 96 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
 97 pub enum Mode {
 98     Performance,
 99     #[default]
100     Balanced,
101     PowerSaver,
102 }
103 
104 impl Mode {
105     pub const ALL: [Mode; 3] = [Mode::Performance, Mode::Balanced, Mode::PowerSaver];
106 
107     /// The name in the plan file, and the CLI spelling.
108     pub fn key(self) -> &'static str {
109         match self {
110             Mode::Performance => "performance",
111             Mode::Balanced => "balanced",
112             Mode::PowerSaver => "power-saver",
113         }
114     }
115 
116     pub fn parse(s: &str) -> Option<Mode> {
117         Mode::ALL.into_iter().find(|m| m.key() == s)
118     }
119 
120     pub fn label(self) -> &'static str {
121         match self {
122             Mode::Performance => "Performance",
123             Mode::Balanced => "Balanced",
124             Mode::PowerSaver => "Power Saver",
125         }
126     }
127 }
128 
129 /// The levers that make sense per mode. The battery charge limit is
130 /// deliberately not one: it is a charging policy, not something to flip when
131 /// the mode changes.
132 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
133 pub enum Lever {
134     Profile,
135     Epp,
136     Governor,
137     Turbo,
138     IgpuMaxMhz,
139     Aspm,
140     AudioIdleSecs,
141     GpuLimitW,
142 }
143 impl Lever {
144     pub const ALL: [Lever; 8] = [
145         Lever::Profile,
146         Lever::Epp,
147         Lever::Governor,
148         Lever::Turbo,
149         Lever::IgpuMaxMhz,
150         Lever::Aspm,
151         Lever::AudioIdleSecs,
152         Lever::GpuLimitW,
153     ];
154 
155     /// The node name in the plan file, and the CLI spelling.
156     pub fn key(self) -> &'static str {
157         match self {
158             Lever::Profile => "profile",
159             Lever::Epp => "epp",
160             Lever::Governor => "governor",
161             Lever::Turbo => "turbo",
162             Lever::IgpuMaxMhz => "igpu_max_mhz",
163             Lever::Aspm => "aspm",
164             Lever::AudioIdleSecs => "audio_idle_secs",
165             Lever::GpuLimitW => "gpu_limit_w",
166         }
167     }
168 
169     pub fn parse(s: &str) -> Option<Lever> {
170         Lever::ALL.into_iter().find(|l| l.key() == s)
171     }
172 
173     pub fn label(self) -> &'static str {
174         match self {
175             Lever::Profile => "Power Profile",
176             Lever::Epp => "CPU Energy Preference",
177             Lever::Governor => "CPU Governor",
178             Lever::Turbo => "CPU Turbo Boost",
179             Lever::IgpuMaxMhz => "Integrated GPU Max Clock",
180             Lever::Aspm => "PCIe Power Management",
181             Lever::AudioIdleSecs => "Audio Codec Idle",
182             Lever::GpuLimitW => "GPU Power Limit",
183         }
184     }
185 
186     /// Numeric levers are stored as KDL integers; the rest as strings.
187     pub fn is_numeric(self) -> bool {
188         matches!(self, Lever::IgpuMaxMhz | Lever::AudioIdleSecs | Lever::GpuLimitW)
189     }
190 
191     /// Shape check on a value before it goes anywhere near sysfs or a shell
192     /// line: a sysfs token, an unsigned integer, or on/off for turbo. This is
193     /// the guard `set` and `apply` share; range checks against the hardware
194     /// happen in [`apply_lever`], where the ranges can be read.
195     pub fn value_ok(self, v: &str) -> bool {
196         match self {
197             Lever::Turbo => v == "on" || v == "off",
198             l if l.is_numeric() => v.parse::<u32>().is_ok(),
199             _ => sysfs_token_ok(v),
200         }
201     }
202 }
203 
204 /// Sysfs tokens travel into sysfs writes and (from the page) a `pkexec`
205 /// argv, so only the shapes sysfs itself produces are allowed through —
206 /// anything else is dropped, not escaped.
207 pub fn sysfs_token_ok(s: &str) -> bool {
208     !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
209 }
210 
211 /// The levers of every mode, plus which mode each adapter state runs.
212 #[derive(Debug, Clone, Default, PartialEq, Eq)]
213 pub struct PowerPlan {
214     modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
215     assign: BTreeMap<Source, Mode>,
216 }
217 
218 impl PowerPlan {
219     /// One mode's levers. Absent and empty are the same thing to every
220     /// caller, so a mode nothing has been set on reads as an empty set.
221     pub fn levers(&self, mode: Mode) -> impl Iterator<Item = (Lever, &str)> {
222         self.modes
223             .get(&mode)
224             .into_iter()
225             .flat_map(|m| m.iter().map(|(l, v)| (*l, v.as_str())))
226     }
227 
228     pub fn get(&self, mode: Mode, lever: Lever) -> Option<&str> {
229         self.modes.get(&mode)?.get(&lever).map(String::as_str)
230     }
231 
232     /// Record a value on a mode, or clear it with `None`. Rejects a
233     /// malformed value rather than storing it.
234     pub fn put(&mut self, mode: Mode, lever: Lever, value: Option<&str>) -> Result<(), String> {
235         match value {
236             None => {
237                 if let Some(set) = self.modes.get_mut(&mode) {
238                     set.remove(&lever);
239                 }
240             }
241             Some(v) if lever.value_ok(v) => {
242                 self.modes.entry(mode).or_default().insert(lever, v.to_string());
243             }
244             Some(v) => return Err(format!("{:?} is not a valid value for {}", v, lever.key())),
245         }
246         Ok(())
247     }
248 
249     /// The mode an adapter state runs; unassigned falls back to the source's
250     /// own default rather than to "do nothing", so a fresh plan still has a
251     /// mode to edit and apply.
252     pub fn assigned(&self, source: Source) -> Mode {
253         self.assign.get(&source).copied().unwrap_or_else(|| source.default_mode())
254     }
255 
256     pub fn assign(&mut self, source: Source, mode: Mode) {
257         self.assign.insert(source, mode);
258     }
259 
260     /// No lever set on any mode. The assignment alone is not content: it
261     /// changes nothing until some mode has a lever in it.
262     pub fn is_empty(&self) -> bool {
263         self.modes.values().all(BTreeMap::is_empty)
264     }
265 
266     pub fn parse(text: &str) -> Result<PowerPlan, String> {
267         let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
268         let mut plan = PowerPlan::default();
269         for node in doc.nodes() {
270             let name = node.name().value();
271             match name {
272                 "mode" => {
273                     let key = node
274                         .get(0)
275                         .and_then(|e| e.value().as_string())
276                         .ok_or_else(|| "mode needs a name, e.g. mode \"balanced\"".to_string())?;
277                     let mode = Mode::parse(key).ok_or_else(|| format!("unknown mode {:?}", key))?;
278                     plan.read_levers(node, mode, key)?;
279                 }
280                 "assign" => {
281                     let Some(children) = node.children() else { continue };
282                     for child in children.nodes() {
283                         let sname = child.name().value();
284                         let source = Source::parse(sname)
285                             .ok_or_else(|| format!("unknown power source {:?} under assign", sname))?;
286                         let key = child
287                             .get(0)
288                             .and_then(|e| e.value().as_string())
289                             .ok_or_else(|| format!("assign.{} needs a mode name", sname))?;
290                         let mode = Mode::parse(key)
291                             .ok_or_else(|| format!("unknown mode {:?} assigned to {}", key, sname))?;
292                         plan.assign(source, mode);
293                     }
294                 }
295                 // The pre-modes file: a bare block of levers per adapter
296                 // state. Each becomes that state's default mode, which is
297                 // what it was already doing.
298                 _ => match Source::parse(name) {
299                     Some(source) => {
300                         let mode = source.default_mode();
301                         plan.read_levers(node, mode, name)?;
302                         plan.assign(source, mode);
303                     }
304                     None => return Err(format!("unknown block {:?}", name)),
305                 },
306             }
307         }
308         Ok(plan)
309     }
310 
311     /// The lever children of one block, into `mode`. `what` names the block
312     /// in errors, since the same reader serves both file formats.
313     fn read_levers(&mut self, node: &kdl::KdlNode, mode: Mode, what: &str) -> Result<(), String> {
314         let Some(children) = node.children() else { return Ok(()) };
315         for child in children.nodes() {
316             let name = child.name().value();
317             let Some(lever) = Lever::parse(name) else {
318                 return Err(format!("unknown lever {:?} under {}", name, what));
319             };
320             let value = match child.get(0).map(|e| e.value()) {
321                 Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
322                 Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
323                 _ => return Err(format!("{}.{} needs one string or integer value", what, name)),
324             };
325             self.put(mode, lever, Some(&value))?;
326         }
327         Ok(())
328     }
329 
330     pub fn to_kdl(&self) -> String {
331         let mut doc = kdl::KdlDocument::new();
332         for mode in Mode::ALL {
333             let mut block = kdl::KdlNode::new("mode");
334             block.push(kdl::KdlEntry::new(mode.key()));
335             let children = block.ensure_children();
336             for (lever, value) in self.levers(mode) {
337                 let mut node = kdl::KdlNode::new(lever.key());
338                 if lever.is_numeric() {
339                     // Validated on the way in, so this parse cannot fail;
340                     // fall back to the string form rather than panicking.
341                     match value.parse::<i64>() {
342                         Ok(n) => node.push(kdl::KdlEntry::new(n)),
343                         Err(_) => node.push(kdl::KdlEntry::new(value)),
344                     }
345                 } else {
346                     node.push(kdl::KdlEntry::new(value));
347                 }
348                 children.nodes_mut().push(node);
349             }
350             doc.nodes_mut().push(block);
351         }
352         let mut assign = kdl::KdlNode::new("assign");
353         let children = assign.ensure_children();
354         for source in Source::ALL {
355             // Resolved, not just what was stored: the file then says out
356             // loud what the applier will do on every adapter state.
357             let mut node = kdl::KdlNode::new(source.key());
358             node.push(kdl::KdlEntry::new(self.assigned(source).key()));
359             children.nodes_mut().push(node);
360         }
361         doc.nodes_mut().push(assign);
362         doc.fmt();
363         let mut out = String::from(
364             "// Power modes and their adapter-state assignment, edited from the\n\
365              // System Interface's Power page and applied by cce-power-apply (udev,\n\
366              // boot, and on each change). A lever missing from a mode is left\n\
367              // untouched when that mode becomes active.\n",
368         );
369         out.push_str(&doc.to_string());
370         out
371     }
372 
373     /// The plan on disk; a missing file is an empty plan, an unreadable or
374     /// malformed one is an error (the applier must not guess at half a plan).
375     pub fn load_from(path: &Path) -> Result<PowerPlan, String> {
376         match std::fs::read_to_string(path) {
377             Ok(text) => PowerPlan::parse(&text).map_err(|e| format!("{}: {}", path.display(), e)),
378             Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PowerPlan::default()),
379             Err(e) => Err(format!("{}: {}", path.display(), e)),
380         }
381     }
382 
383     pub fn load() -> Result<PowerPlan, String> {
384         Self::load_from(Path::new(PLAN_PATH))
385     }
386 
387     /// Write atomically (temp file + rename) so a reader never sees a torn
388     /// plan; the directory is created if this is the first write.
389     pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
390         if let Some(dir) = path.parent() {
391             std::fs::create_dir_all(dir)?;
392         }
393         let tmp = path.with_extension("kdl.tmp");
394         std::fs::write(&tmp, self.to_kdl())?;
395         std::fs::rename(&tmp, path)
396     }
397 
398     pub fn save(&self) -> std::io::Result<()> {
399         self.save_to(Path::new(PLAN_PATH))
400     }
401 }
402 
403 /// Which source the machine is on, from (type, online) pairs of the
404 /// power_supply class: any Mains supply that is online means plugged in.
405 /// No Mains supply at all (a desktop) reads as plugged in too — there is
406 /// nothing to unplug.
407 pub fn source_from_supplies<'a>(supplies: impl IntoIterator<Item = (&'a str, bool)>) -> Source {
408     let mut saw_mains = false;
409     for (kind, online) in supplies {
410         if kind == "Mains" {
411             saw_mains = true;
412             if online {
413                 return Source::Ac;
414             }
415         }
416     }
417     if saw_mains { Source::Battery } else { Source::Ac }
418 }
419 
420 fn read_trim(path: &Path) -> Option<String> {
421     std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
422 }
423 
424 /// The live source, from /sys/class/power_supply.
425 pub fn current_source() -> Source {
426     let mut pairs: Vec<(String, bool)> = Vec::new();
427     if let Ok(rd) = std::fs::read_dir("/sys/class/power_supply") {
428         for e in rd.flatten() {
429             let p = e.path();
430             let kind = read_trim(&p.join("type")).unwrap_or_default();
431             let online = read_trim(&p.join("online")).is_some_and(|s| s == "1");
432             pairs.push((kind, online));
433         }
434     }
435     source_from_supplies(pairs.iter().map(|(k, o)| (k.as_str(), *o)))
436 }
437 
438 /// The state of the root side that applies a mode on plug and unplug.
439 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
440 pub enum Automation {
441     /// No helper at the system path, or no udev rule to start it. The plan
442     /// is only applied when the page itself changes a lever.
443     #[default]
444     Missing,
445     /// Both installed, but the helper predates modes: it cannot parse a
446     /// plan with `mode` blocks, so it applies nothing on plug or unplug —
447     /// and it rejects the page's own `set`/`assign` calls too.
448     Stale,
449     Ready,
450 }
451 
452 /// Whether a helper binary speaks the current, mode-shaped CLI.
453 ///
454 /// Asked by running it with no arguments, which prints its usage and exits
455 /// 2 without touching anything. Deliberately NOT a string search inside the
456 /// file: a hit would prove freshness but a miss proves nothing (link-time
457 /// constant merging eats literals), and a false "stale" is the worse error.
458 ///
459 /// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
460 /// way nothing reports: it takes the pkexec prompt, reads the mode name as
461 /// an adapter state, and exits 2 — so a pick costs the user an
462 /// authentication and changes nothing.
463 pub fn helper_speaks_modes(path: &Path) -> bool {
464     std::process::Command::new(path)
465         .output()
466         .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)))
467 }
468 
469 /// The usage text of a helper that knows about modes names `apply-mode`;
470 /// the pre-modes one lists only `apply`, `set` and `show`.
471 fn usage_speaks_modes(usage: &str) -> bool {
472     usage.contains("apply-mode")
473 }
474 
475 /// Whether the root-side pieces are in place — the helper at its system
476 /// path, the udev rule that starts it, and a helper new enough to read the
477 /// plan this app writes.
478 pub fn automation_status() -> Automation {
479     let helper = Path::new(HELPER_SYSTEM_PATH);
480     if !helper.exists() || !Path::new(UDEV_RULE_PATH).exists() {
481         return Automation::Missing;
482     }
483     if helper_speaks_modes(helper) { Automation::Ready } else { Automation::Stale }
484 }
485 
486 // ── Applying (root) ─────────────────────────────────────────────────────
487 
488 fn write_sysfs(path: &Path, value: &str) -> Result<(), String> {
489     std::fs::write(path, value).map_err(|e| format!("{}: {}", path.display(), e))
490 }
491 
492 /// `/sys/devices/system/cpu/cpu<N>` for every core, sorted.
493 fn cpu_dirs() -> Vec<PathBuf> {
494     let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/devices/system/cpu")
495         .map(|rd| {
496             rd.flatten()
497                 .map(|e| e.path())
498                 .filter(|p| {
499                     p.file_name()
500                         .and_then(|n| n.to_str())
501                         .is_some_and(|n| n.strip_prefix("cpu").is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())))
502                 })
503                 .collect()
504         })
505         .unwrap_or_default();
506     v.sort();
507     v
508 }
509 
510 /// Every /sys/class/drm/card* exposing the i915/xe clock knob.
511 fn drm_cards_with_freq() -> Vec<PathBuf> {
512     let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/drm")
513         .map(|rd| {
514             rd.flatten()
515                 .map(|e| e.path())
516                 .filter(|p| {
517                     p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card"))
518                         && p.join("gt_max_freq_mhz").exists()
519                 })
520                 .collect()
521         })
522         .unwrap_or_default();
523     v.sort();
524     v
525 }
526 
527 /// Write one lever to every interface it covers. Runs as root; the page
528 /// never calls this directly, it goes through the helper under pkexec.
529 /// Errors name the file and the reason so the unit's journal is useful.
530 pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
531     if !lever.value_ok(value) {
532         return Err(format!("{:?} is not a valid value for {}", value, lever.key()));
533     }
534     match lever {
535         Lever::Profile => write_sysfs(Path::new("/sys/firmware/acpi/platform_profile"), value),
536         Lever::Epp | Lever::Governor => {
537             // Every core: both are per-cpu and a partial write would leave the
538             // package split across preferences.
539             let file = if lever == Lever::Epp { "energy_performance_preference" } else { "scaling_governor" };
540             let mut any = false;
541             for cpu in cpu_dirs() {
542                 let p = cpu.join("cpufreq").join(file);
543                 if p.exists() {
544                     any = true;
545                     write_sysfs(&p, value)?;
546                 }
547             }
548             if any { Ok(()) } else { Err(format!("no cpufreq/{} on this host", file)) }
549         }
550         Lever::Turbo => {
551             let no_turbo = if value == "on" { "0" } else { "1" };
552             write_sysfs(Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo"), no_turbo)
553         }
554         Lever::IgpuMaxMhz => {
555             let mhz: u32 = value.parse().map_err(|_| "bad MHz".to_string())?;
556             let cards = drm_cards_with_freq();
557             if cards.is_empty() {
558                 return Err("no DRM card exposes gt_max_freq_mhz".to_string());
559             }
560             for card in cards {
561                 // Bounded by the hardware's own reported range, per card.
562                 let rd = |n: &str| read_trim(&card.join(n)).and_then(|s| s.parse::<u32>().ok());
563                 let lo = rd("gt_RPn_freq_mhz").unwrap_or(0);
564                 let hi = rd("gt_RP0_freq_mhz").unwrap_or(u32::MAX);
565                 if mhz < lo || mhz > hi {
566                     return Err(format!("{} MHz is outside {}'s {}–{} MHz range", mhz, card.display(), lo, hi));
567                 }
568                 write_sysfs(&card.join("gt_max_freq_mhz"), value)?;
569             }
570             Ok(())
571         }
572         Lever::Aspm => write_sysfs(Path::new("/sys/module/pcie_aspm/parameters/policy"), value),
573         Lever::AudioIdleSecs => {
574             let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
575             if secs > 3600 {
576                 return Err("audio idle timeout above an hour".to_string());
577             }
578             write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
579         }
580         Lever::GpuLimitW => {
581             // nvidia-smi validates the watts against the card's own min/max
582             // and refuses anything outside them, so it is the range check.
583             let out = std::process::Command::new("nvidia-smi")
584                 .args(["-pl", value])
585                 .output()
586                 .map_err(|e| format!("nvidia-smi: {}", e))?;
587             if out.status.success() {
588                 Ok(())
589             } else {
590                 let msg = String::from_utf8_lossy(&out.stderr);
591                 let msg = if msg.trim().is_empty() { String::from_utf8_lossy(&out.stdout) } else { msg };
592                 Err(format!("nvidia-smi -pl {}: {}", value, msg.trim()))
593             }
594         }
595     }
596 }
597 
598 /// Apply every lever one mode sets. Failures are per lever — a missing
599 /// NVIDIA driver must not stop the CPU profile from landing — and come back
600 /// to the caller, which logs them.
601 pub fn apply_mode(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, Result<(), String>)> {
602     plan.levers(mode)
603         .map(|(lever, value)| (lever, apply_lever(lever, value)))
604         .collect::<Vec<_>>()
605 }
606 
607 /// Apply whichever mode is assigned to one adapter state.
608 pub fn apply_source(plan: &PowerPlan, source: Source) -> Vec<(Lever, Result<(), String>)> {
609     apply_mode(plan, plan.assigned(source))
610 }
611 
612 #[cfg(test)]
613 mod tests {
614     use super::*;
615 
616     fn levers_of(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, String)> {
617         plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect()
618     }
619 
620     #[test]
621     fn kdl_round_trip_keeps_modes_assignment_and_types() {
622         let mut plan = PowerPlan::default();
623         plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
624         plan.put(Mode::Performance, Lever::Turbo, Some("on")).unwrap();
625         plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
626         plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("800")).unwrap();
627         plan.put(Mode::PowerSaver, Lever::GpuLimitW, Some("40")).unwrap();
628         plan.assign(Source::Battery, Mode::Balanced);
629         let text = plan.to_kdl();
630         // Numbers are written as KDL integers, tokens as strings.
631         assert!(text.contains("igpu_max_mhz 800"), "{text}");
632         assert!(text.contains("profile \"low-power\""), "{text}");
633         assert!(text.contains("mode \"power-saver\""), "{text}");
634         assert!(text.contains("battery \"balanced\""), "{text}");
635         // The file says every assignment out loud, so what comes back is the
636         // same plan with the AC default written down — and writing it again
637         // is a fixed point.
638         let back = PowerPlan::parse(&text).unwrap();
639         assert_eq!(levers_of(&back, Mode::Performance), levers_of(&plan, Mode::Performance));
640         assert_eq!(levers_of(&back, Mode::PowerSaver), levers_of(&plan, Mode::PowerSaver));
641         for source in Source::ALL {
642             assert_eq!(back.assigned(source), plan.assigned(source));
643         }
644         assert_eq!(back.to_kdl(), text);
645     }
646 
647     #[test]
648     fn parse_accepts_empty_and_partial_files() {
649         assert_eq!(PowerPlan::parse("").unwrap(), PowerPlan::default());
650         let p = PowerPlan::parse("mode \"balanced\" {\n  epp \"power\"\n}\n").unwrap();
651         assert_eq!(p.get(Mode::Balanced, Lever::Epp), Some("power"));
652         assert_eq!(p.get(Mode::Performance, Lever::Epp), None);
653         // Nothing assigned: each adapter state keeps its default mode.
654         assert_eq!(p.assigned(Source::Ac), Mode::Performance);
655         assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
656     }
657 
658     #[test]
659     fn the_pre_modes_file_migrates_onto_the_default_modes() {
660         // What /etc/cce/power.kdl looked like before modes existed: a bare
661         // block of levers per adapter state, applied on plug and unplug.
662         let old = "ac {\n  profile \"performance\"\n}\nbattery {\n  profile \"low-power\"\n  igpu_max_mhz 800\n}\n";
663         let p = PowerPlan::parse(old).unwrap();
664         // Each block landed on the mode its source runs, so the same levers
665         // still apply on the same adapter states.
666         assert_eq!(p.assigned(Source::Ac), Mode::Performance);
667         assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
668         assert_eq!(p.get(Mode::Performance, Lever::Profile), Some("performance"));
669         assert_eq!(p.get(Mode::PowerSaver, Lever::IgpuMaxMhz), Some("800"));
670         assert!(levers_of(&p, Mode::Balanced).is_empty());
671         // And it rewrites in the new shape.
672         assert!(p.to_kdl().contains("mode \"performance\""));
673         assert_eq!(PowerPlan::parse(&p.to_kdl()).unwrap(), p);
674     }
675 
676     #[test]
677     fn parse_rejects_unknown_names_and_bad_values() {
678         assert!(PowerPlan::parse("mode \"balanced\" {\n  brightness 50\n}\n").is_err());
679         assert!(PowerPlan::parse("mode \"turbo-max\" {\n}\n").is_err());
680         assert!(PowerPlan::parse("assign {\n  ac \"turbo-max\"\n}\n").is_err());
681         assert!(PowerPlan::parse("assign {\n  usb \"balanced\"\n}\n").is_err());
682         assert!(PowerPlan::parse("levers {\n  profile \"performance\"\n}\n").is_err());
683         // A shell metacharacter never survives into the plan.
684         assert!(PowerPlan::parse("mode \"balanced\" {\n  profile \"x;reboot\"\n}\n").is_err());
685         // Turbo is on/off only.
686         assert!(PowerPlan::parse("mode \"balanced\" {\n  turbo \"yes\"\n}\n").is_err());
687         // A numeric lever given a token.
688         assert!(PowerPlan::parse("mode \"balanced\" {\n  gpu_limit_w \"max\"\n}\n").is_err());
689     }
690 
691     #[test]
692     fn put_none_clears_and_bad_values_are_refused() {
693         let mut plan = PowerPlan::default();
694         plan.put(Mode::Balanced, Lever::Governor, Some("powersave")).unwrap();
695         assert!(plan.put(Mode::Balanced, Lever::Governor, Some("$(rm)")).is_err());
696         assert_eq!(plan.get(Mode::Balanced, Lever::Governor), Some("powersave"));
697         plan.put(Mode::Balanced, Lever::Governor, None).unwrap();
698         assert!(plan.is_empty());
699         // An assignment alone is not content — it changes nothing until a
700         // mode has a lever in it.
701         plan.assign(Source::Ac, Mode::Balanced);
702         assert!(plan.is_empty());
703     }
704 
705     #[test]
706     fn assignment_is_per_source_and_two_states_may_share_a_mode() {
707         let mut plan = PowerPlan::default();
708         plan.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
709         plan.assign(Source::Ac, Mode::Balanced);
710         plan.assign(Source::Battery, Mode::Balanced);
711         assert_eq!(plan.assigned(Source::Ac), Mode::Balanced);
712         assert_eq!(plan.assigned(Source::Battery), Mode::Balanced);
713         assert_eq!(PowerPlan::parse(&plan.to_kdl()).unwrap(), plan);
714         // Both states named, so nothing was left to a default.
715         assert!(plan.to_kdl().contains("ac \"balanced\""));
716     }
717 
718     #[test]
719     fn value_guard_shapes() {
720         assert!(Lever::Profile.value_ok("balance_power"));
721         assert!(Lever::Profile.value_ok("low-power"));
722         assert!(!Lever::Profile.value_ok(""));
723         assert!(!Lever::Profile.value_ok("a b"));
724         assert!(!Lever::Profile.value_ok("x;reboot"));
725         assert!(Lever::Turbo.value_ok("off"));
726         assert!(!Lever::Turbo.value_ok("0"));
727         assert!(Lever::AudioIdleSecs.value_ok("10"));
728         assert!(!Lever::AudioIdleSecs.value_ok("-1"));
729         assert!(!Lever::AudioIdleSecs.value_ok("ten"));
730     }
731 
732     #[test]
733     fn source_follows_the_mains_supply() {
734         // Battery discharging, USB-C sources idle, AC offline → battery.
735         let unplugged = [("Battery", false), ("USB", false), ("Mains", false)];
736         assert_eq!(source_from_supplies(unplugged), Source::Battery);
737         let plugged = [("Battery", false), ("Mains", true)];
738         assert_eq!(source_from_supplies(plugged), Source::Ac);
739         // A desktop with no Mains device has nothing to unplug.
740         assert_eq!(source_from_supplies([("Battery", false)]), Source::Ac);
741         assert_eq!(source_from_supplies([]), Source::Ac);
742     }
743 
744     #[test]
745     fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
746         // What this binary prints today.
747         assert!(usage_speaks_modes(
748             "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply apply-mode <mode>\n"
749         ));
750         // What the pre-modes one printed — the copy that silently rejects
751         // every pick the page makes.
752         assert!(!usage_speaks_modes(
753             "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply set <ac|battery> <lever> <value|unset>\n       cce-power-apply show\n"
754         ));
755         // A helper that cannot be run at all is not a helper that speaks.
756         assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
757     }
758 
759     #[test]
760     fn keys_round_trip() {
761         for l in Lever::ALL {
762             assert_eq!(Lever::parse(l.key()), Some(l));
763         }
764         for s in Source::ALL {
765             assert_eq!(Source::parse(s.key()), Some(s));
766         }
767         for m in Mode::ALL {
768             assert_eq!(Mode::parse(m.key()), Some(m));
769         }
770         assert_eq!(Lever::parse("brightness"), None);
771         assert_eq!(Mode::parse("ac"), None);
772     }
773 
774     #[test]
775     fn save_and_load_through_a_temp_dir() {
776         let dir = std::env::temp_dir().join(format!("cce-power-plan-test-{}", std::process::id()));
777         let path = dir.join("nested").join("power.kdl");
778         // Missing file is an empty plan, not an error.
779         assert_eq!(PowerPlan::load_from(&path).unwrap(), PowerPlan::default());
780         let mut plan = PowerPlan::default();
781         plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
782         plan.assign(Source::Ac, Mode::Balanced);
783         plan.assign(Source::Battery, Mode::PowerSaver);
784         plan.save_to(&path).unwrap();
785         assert_eq!(PowerPlan::load_from(&path).unwrap(), plan);
786         // Garbage on disk is reported, not silently emptied.
787         std::fs::write(&path, "mode \"balanced\" {\n  profile \n").unwrap();
788         assert!(PowerPlan::load_from(&path).is_err());
789         let _ = std::fs::remove_dir_all(&dir);
790     }
791 }