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

src/power_plan.rs (49.7K)

   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 //! Two levers are not sysfs: **animations** and the two **idle timeouts**
  34 //! (`idle_display_off_secs`, `idle_sleep_secs`). The applier records them
  35 //! as files under `/run/cce` ([`cce_ui::motion::STATE_PATH`],
  36 //! [`IDLE_DISPLAY_OFF_PATH`], [`IDLE_SLEEP_PATH`]) that the compositor and
  37 //! the toolkit follow without a reload; the idle files override the
  38 //! compositor's `idle { }` block while they exist, so battery can darken
  39 //! the display sooner than the desk does.
  40 //!
  41 //! The battery **charge limit** is in the plan too, but belongs to no mode
  42 //! (`charge_limit { start 75; end 80 }`, [`ChargeLimit`]): it is a charging
  43 //! policy, and one that changed on every plug and unplug would defeat it.
  44 //! The applier writes it on every `apply` — at boot, on each adapter change
  45 //! and after every wake — because the firmware does not keep it: until
  46 //! 2026-10-06 the Power page wrote sysfs once and recorded nothing, and a
  47 //! battery that ran flat came back charging to 100%.
  48 //!
  49 //! A lever absent from a mode is left alone when that mode becomes active —
  50 //! "not set" means "don't touch", never "reset to a default". The older
  51 //! per-source form of this file (top-level `ac` / `battery` blocks of
  52 //! levers, before modes existed) still parses: each block becomes the mode
  53 //! that source is assigned to by default, which is exactly the behavior it
  54 //! had.
  55 
  56 use std::collections::BTreeMap;
  57 use std::path::{Path, PathBuf};
  58 
  59 pub const PLAN_PATH: &str = "/etc/cce/power.kdl";
  60 /// Where `ccebuild install-system` puts the helper. The udev rule and the
  61 /// system unit both name this path, so its presence is what "automatic
  62 /// switching is installed" means to the page.
  63 pub const HELPER_SYSTEM_PATH: &str = "/usr/bin/cce-power-apply";
  64 pub const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/90-cce-power-apply.rules";
  65 
  66 /// A power-adapter state. Defaults to `Ac`: a host with no Mains supply has
  67 /// nothing to unplug.
  68 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
  69 pub enum Source {
  70     #[default]
  71     Ac,
  72     Battery,
  73 }
  74 
  75 impl Source {
  76     pub const ALL: [Source; 2] = [Source::Ac, Source::Battery];
  77 
  78     /// The key in the `assign` block, and the CLI spelling.
  79     pub fn key(self) -> &'static str {
  80         match self {
  81             Source::Ac => "ac",
  82             Source::Battery => "battery",
  83         }
  84     }
  85 
  86     pub fn parse(s: &str) -> Option<Source> {
  87         Source::ALL.into_iter().find(|v| v.key() == s)
  88     }
  89 
  90     pub fn label(self) -> &'static str {
  91         match self {
  92             Source::Ac => "Plugged In",
  93             Source::Battery => "On Battery",
  94         }
  95     }
  96 
  97     /// The mode a source runs when the plan says nothing about it. These are
  98     /// also what the pre-modes file format migrates onto, so an old plan
  99     /// keeps behaving exactly as it did.
 100     pub fn default_mode(self) -> Mode {
 101         match self {
 102             Source::Ac => Mode::Performance,
 103             Source::Battery => Mode::PowerSaver,
 104         }
 105     }
 106 }
 107 
 108 /// A named set of lever values. The set is fixed rather than user-extensible:
 109 /// the page picks a mode from a dropdown, and there is deliberately no
 110 /// naming UI to keep a mode's identity stable across the plan file, the
 111 /// helper's CLI and the assignment table.
 112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
 113 pub enum Mode {
 114     Performance,
 115     #[default]
 116     Balanced,
 117     PowerSaver,
 118 }
 119 
 120 impl Mode {
 121     pub const ALL: [Mode; 3] = [Mode::Performance, Mode::Balanced, Mode::PowerSaver];
 122 
 123     /// The name in the plan file, and the CLI spelling.
 124     pub fn key(self) -> &'static str {
 125         match self {
 126             Mode::Performance => "performance",
 127             Mode::Balanced => "balanced",
 128             Mode::PowerSaver => "power-saver",
 129         }
 130     }
 131 
 132     pub fn parse(s: &str) -> Option<Mode> {
 133         Mode::ALL.into_iter().find(|m| m.key() == s)
 134     }
 135 
 136     pub fn label(self) -> &'static str {
 137         match self {
 138             Mode::Performance => "Performance",
 139             Mode::Balanced => "Balanced",
 140             Mode::PowerSaver => "Power Saver",
 141         }
 142     }
 143 }
 144 
 145 /// The levers that make sense per mode. The battery charge limit is
 146 /// deliberately not one: it is a charging policy, not something to flip when
 147 /// the mode changes, so it is plan-wide ([`ChargeLimit`]).
 148 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 149 pub enum Lever {
 150     Profile,
 151     Epp,
 152     Governor,
 153     Turbo,
 154     IgpuMaxMhz,
 155     Aspm,
 156     AudioIdleSecs,
 157     GpuLimitW,
 158     Animations,
 159     IdleDisplayOffSecs,
 160     IdleSleepSecs,
 161 }
 162 impl Lever {
 163     pub const ALL: [Lever; 11] = [
 164         Lever::Profile,
 165         Lever::Epp,
 166         Lever::Governor,
 167         Lever::Turbo,
 168         Lever::IgpuMaxMhz,
 169         Lever::Aspm,
 170         Lever::AudioIdleSecs,
 171         Lever::GpuLimitW,
 172         Lever::Animations,
 173         Lever::IdleDisplayOffSecs,
 174         Lever::IdleSleepSecs,
 175     ];
 176 
 177     /// The node name in the plan file, and the CLI spelling.
 178     pub fn key(self) -> &'static str {
 179         match self {
 180             Lever::Profile => "profile",
 181             Lever::Epp => "epp",
 182             Lever::Governor => "governor",
 183             Lever::Turbo => "turbo",
 184             Lever::IgpuMaxMhz => "igpu_max_mhz",
 185             Lever::Aspm => "aspm",
 186             Lever::AudioIdleSecs => "audio_idle_secs",
 187             Lever::GpuLimitW => "gpu_limit_w",
 188             Lever::Animations => "animations",
 189             Lever::IdleDisplayOffSecs => "idle_display_off_secs",
 190             Lever::IdleSleepSecs => "idle_sleep_secs",
 191         }
 192     }
 193 
 194     pub fn parse(s: &str) -> Option<Lever> {
 195         Lever::ALL.into_iter().find(|l| l.key() == s)
 196     }
 197 
 198     pub fn label(self) -> &'static str {
 199         match self {
 200             Lever::Profile => "Power Profile",
 201             Lever::Epp => "CPU Energy Preference",
 202             Lever::Governor => "CPU Governor",
 203             Lever::Turbo => "CPU Turbo Boost",
 204             Lever::IgpuMaxMhz => "Integrated GPU Max Clock",
 205             Lever::Aspm => "PCIe Power Management",
 206             Lever::AudioIdleSecs => "Audio Codec Idle",
 207             Lever::GpuLimitW => "GPU Power Limit",
 208             Lever::Animations => "Animations",
 209             Lever::IdleDisplayOffSecs => "Display Off After",
 210             Lever::IdleSleepSecs => "Sleep After",
 211         }
 212     }
 213 
 214     /// Numeric levers are stored as KDL integers; the rest as strings.
 215     pub fn is_numeric(self) -> bool {
 216         matches!(
 217             self,
 218             Lever::IgpuMaxMhz
 219                 | Lever::AudioIdleSecs
 220                 | Lever::GpuLimitW
 221                 | Lever::IdleDisplayOffSecs
 222                 | Lever::IdleSleepSecs
 223         )
 224     }
 225 
 226     /// Shape check on a value before it goes anywhere near sysfs or a shell
 227     /// line: a sysfs token, an unsigned integer, or on/off for turbo and
 228     /// animations. This is
 229     /// the guard `set` and `apply` share; range checks against the hardware
 230     /// happen in [`apply_lever`], where the ranges can be read.
 231     pub fn value_ok(self, v: &str) -> bool {
 232         match self {
 233             Lever::Turbo | Lever::Animations => v == "on" || v == "off",
 234             l if l.is_numeric() => v.parse::<u32>().is_ok(),
 235             _ => sysfs_token_ok(v),
 236         }
 237     }
 238 }
 239 
 240 /// Sysfs tokens travel into sysfs writes and (from the page) a `pkexec`
 241 /// argv, so only the shapes sysfs itself produces are allowed through —
 242 /// anything else is dropped, not escaped.
 243 pub fn sysfs_token_ok(s: &str) -> bool {
 244     !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
 245 }
 246 
 247 /// The battery's charge window, in whole percent: charging stops at `end`
 248 /// and, once stopped, resumes only below `start` — so a pack held at 80%
 249 /// is not topped up from 79% every few minutes. Either half absent means
 250 /// "leave that threshold alone", like an absent lever.
 251 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
 252 pub struct ChargeLimit {
 253     pub start: Option<u32>,
 254     pub end: Option<u32>,
 255 }
 256 
 257 impl ChargeLimit {
 258     pub fn is_unset(&self) -> bool {
 259         self.start.is_none() && self.end.is_none()
 260     }
 261 
 262     /// The kernel's ranges (start 0–99, end 1–100), and a start below the
 263     /// end, since a window that resumes at or above where it stops is not
 264     /// one the firmware will take.
 265     pub fn check(&self) -> Result<(), String> {
 266         if self.start.is_some_and(|s| s > 99) {
 267             return Err("charge_limit start must be 0–99".to_string());
 268         }
 269         if self.end.is_some_and(|e| e == 0 || e > 100) {
 270             return Err("charge_limit end must be 1–100".to_string());
 271         }
 272         if let (Some(s), Some(e)) = (self.start, self.end) {
 273             if s >= e {
 274                 return Err(format!("charge_limit start {} must be below end {}", s, e));
 275             }
 276         }
 277         Ok(())
 278     }
 279 }
 280 
 281 impl std::fmt::Display for ChargeLimit {
 282     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 283         match (self.start, self.end) {
 284             (Some(s), Some(e)) => write!(f, "start {}%, end {}%", s, e),
 285             (Some(s), None) => write!(f, "start {}%", s),
 286             (None, Some(e)) => write!(f, "end {}%", e),
 287             (None, None) => write!(f, "not set"),
 288         }
 289     }
 290 }
 291 
 292 /// The levers of every mode, which mode each adapter state runs, and the
 293 /// plan-wide battery charge limit.
 294 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 295 pub struct PowerPlan {
 296     modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
 297     assign: BTreeMap<Source, Mode>,
 298     charge: ChargeLimit,
 299 }
 300 
 301 impl PowerPlan {
 302     /// One mode's levers. Absent and empty are the same thing to every
 303     /// caller, so a mode nothing has been set on reads as an empty set.
 304     pub fn levers(&self, mode: Mode) -> impl Iterator<Item = (Lever, &str)> {
 305         self.modes
 306             .get(&mode)
 307             .into_iter()
 308             .flat_map(|m| m.iter().map(|(l, v)| (*l, v.as_str())))
 309     }
 310 
 311     pub fn get(&self, mode: Mode, lever: Lever) -> Option<&str> {
 312         self.modes.get(&mode)?.get(&lever).map(String::as_str)
 313     }
 314 
 315     /// Record a value on a mode, or clear it with `None`. Rejects a
 316     /// malformed value rather than storing it.
 317     pub fn put(&mut self, mode: Mode, lever: Lever, value: Option<&str>) -> Result<(), String> {
 318         match value {
 319             None => {
 320                 if let Some(set) = self.modes.get_mut(&mode) {
 321                     set.remove(&lever);
 322                 }
 323             }
 324             Some(v) if lever.value_ok(v) => {
 325                 self.modes.entry(mode).or_default().insert(lever, v.to_string());
 326             }
 327             Some(v) => return Err(format!("{:?} is not a valid value for {}", v, lever.key())),
 328         }
 329         Ok(())
 330     }
 331 
 332     /// The mode an adapter state runs; unassigned falls back to the source's
 333     /// own default rather than to "do nothing", so a fresh plan still has a
 334     /// mode to edit and apply.
 335     pub fn assigned(&self, source: Source) -> Mode {
 336         self.assign.get(&source).copied().unwrap_or_else(|| source.default_mode())
 337     }
 338 
 339     pub fn assign(&mut self, source: Source, mode: Mode) {
 340         self.assign.insert(source, mode);
 341     }
 342 
 343     pub fn charge_limit(&self) -> ChargeLimit {
 344         self.charge
 345     }
 346 
 347     /// Record the charge window. Rejects one that fails
 348     /// [`ChargeLimit::check`] rather than storing it.
 349     pub fn set_charge_limit(&mut self, limit: ChargeLimit) -> Result<(), String> {
 350         limit.check()?;
 351         self.charge = limit;
 352         Ok(())
 353     }
 354 
 355     /// No lever set on any mode. The assignment alone is not content: it
 356     /// changes nothing until some mode has a lever in it.
 357     pub fn is_empty(&self) -> bool {
 358         self.modes.values().all(BTreeMap::is_empty)
 359     }
 360 
 361     pub fn parse(text: &str) -> Result<PowerPlan, String> {
 362         let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
 363         let mut plan = PowerPlan::default();
 364         for node in doc.nodes() {
 365             let name = node.name().value();
 366             match name {
 367                 "mode" => {
 368                     let key = node
 369                         .get(0)
 370                         .and_then(|e| e.value().as_string())
 371                         .ok_or_else(|| "mode needs a name, e.g. mode \"balanced\"".to_string())?;
 372                     let mode = Mode::parse(key).ok_or_else(|| format!("unknown mode {:?}", key))?;
 373                     plan.read_levers(node, mode, key)?;
 374                 }
 375                 "assign" => {
 376                     let Some(children) = node.children() else { continue };
 377                     for child in children.nodes() {
 378                         let sname = child.name().value();
 379                         let source = Source::parse(sname)
 380                             .ok_or_else(|| format!("unknown power source {:?} under assign", sname))?;
 381                         let key = child
 382                             .get(0)
 383                             .and_then(|e| e.value().as_string())
 384                             .ok_or_else(|| format!("assign.{} needs a mode name", sname))?;
 385                         let mode = Mode::parse(key)
 386                             .ok_or_else(|| format!("unknown mode {:?} assigned to {}", key, sname))?;
 387                         plan.assign(source, mode);
 388                     }
 389                 }
 390                 "charge_limit" => {
 391                     let mut limit = ChargeLimit::default();
 392                     if let Some(children) = node.children() {
 393                         for child in children.nodes() {
 394                             let cname = child.name().value();
 395                             let pct = child
 396                                 .get(0)
 397                                 .and_then(|e| e.value().as_i64())
 398                                 .and_then(|n| u32::try_from(n).ok())
 399                                 .ok_or_else(|| format!("charge_limit.{} needs one whole percent", cname))?;
 400                             match cname {
 401                                 "start" => limit.start = Some(pct),
 402                                 "end" => limit.end = Some(pct),
 403                                 _ => return Err(format!("unknown setting {:?} under charge_limit", cname)),
 404                             }
 405                         }
 406                     }
 407                     plan.set_charge_limit(limit)?;
 408                 }
 409                 // The pre-modes file: a bare block of levers per adapter
 410                 // state. Each becomes that state's default mode, which is
 411                 // what it was already doing.
 412                 _ => match Source::parse(name) {
 413                     Some(source) => {
 414                         let mode = source.default_mode();
 415                         plan.read_levers(node, mode, name)?;
 416                         plan.assign(source, mode);
 417                     }
 418                     None => return Err(format!("unknown block {:?}", name)),
 419                 },
 420             }
 421         }
 422         Ok(plan)
 423     }
 424 
 425     /// The lever children of one block, into `mode`. `what` names the block
 426     /// in errors, since the same reader serves both file formats.
 427     fn read_levers(&mut self, node: &kdl::KdlNode, mode: Mode, what: &str) -> Result<(), String> {
 428         let Some(children) = node.children() else { return Ok(()) };
 429         for child in children.nodes() {
 430             let name = child.name().value();
 431             let Some(lever) = Lever::parse(name) else {
 432                 return Err(format!("unknown lever {:?} under {}", name, what));
 433             };
 434             let value = match child.get(0).map(|e| e.value()) {
 435                 Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
 436                 Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
 437                 _ => return Err(format!("{}.{} needs one string or integer value", what, name)),
 438             };
 439             self.put(mode, lever, Some(&value))?;
 440         }
 441         Ok(())
 442     }
 443 
 444     pub fn to_kdl(&self) -> String {
 445         let mut doc = kdl::KdlDocument::new();
 446         for mode in Mode::ALL {
 447             let mut block = kdl::KdlNode::new("mode");
 448             block.push(kdl::KdlEntry::new(mode.key()));
 449             let children = block.ensure_children();
 450             for (lever, value) in self.levers(mode) {
 451                 let mut node = kdl::KdlNode::new(lever.key());
 452                 if lever.is_numeric() {
 453                     // Validated on the way in, so this parse cannot fail;
 454                     // fall back to the string form rather than panicking.
 455                     match value.parse::<i64>() {
 456                         Ok(n) => node.push(kdl::KdlEntry::new(n)),
 457                         Err(_) => node.push(kdl::KdlEntry::new(value)),
 458                     }
 459                 } else {
 460                     node.push(kdl::KdlEntry::new(value));
 461                 }
 462                 children.nodes_mut().push(node);
 463             }
 464             doc.nodes_mut().push(block);
 465         }
 466         let mut assign = kdl::KdlNode::new("assign");
 467         let children = assign.ensure_children();
 468         for source in Source::ALL {
 469             // Resolved, not just what was stored: the file then says out
 470             // loud what the applier will do on every adapter state.
 471             let mut node = kdl::KdlNode::new(source.key());
 472             node.push(kdl::KdlEntry::new(self.assigned(source).key()));
 473             children.nodes_mut().push(node);
 474         }
 475         doc.nodes_mut().push(assign);
 476         if !self.charge.is_unset() {
 477             let mut block = kdl::KdlNode::new("charge_limit");
 478             let children = block.ensure_children();
 479             for (name, pct) in [("start", self.charge.start), ("end", self.charge.end)] {
 480                 if let Some(pct) = pct {
 481                     let mut node = kdl::KdlNode::new(name);
 482                     node.push(kdl::KdlEntry::new(i64::from(pct)));
 483                     children.nodes_mut().push(node);
 484                 }
 485             }
 486             doc.nodes_mut().push(block);
 487         }
 488         doc.fmt();
 489         let mut out = String::from(
 490             "// Power modes and their adapter-state assignment, edited from the\n\
 491              // System Interface's Power page and applied by cce-power-apply (udev,\n\
 492              // boot, and on each change). A lever missing from a mode is left\n\
 493              // untouched when that mode becomes active. The charge limit belongs\n\
 494              // to no mode and is re-applied on every change.\n",
 495         );
 496         out.push_str(&doc.to_string());
 497         out
 498     }
 499 
 500     /// The plan on disk; a missing file is an empty plan, an unreadable or
 501     /// malformed one is an error (the applier must not guess at half a plan).
 502     pub fn load_from(path: &Path) -> Result<PowerPlan, String> {
 503         match std::fs::read_to_string(path) {
 504             Ok(text) => PowerPlan::parse(&text).map_err(|e| format!("{}: {}", path.display(), e)),
 505             Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PowerPlan::default()),
 506             Err(e) => Err(format!("{}: {}", path.display(), e)),
 507         }
 508     }
 509 
 510     pub fn load() -> Result<PowerPlan, String> {
 511         Self::load_from(Path::new(PLAN_PATH))
 512     }
 513 
 514     /// Write atomically (temp file + rename) so a reader never sees a torn
 515     /// plan; the directory is created if this is the first write.
 516     pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
 517         if let Some(dir) = path.parent() {
 518             std::fs::create_dir_all(dir)?;
 519         }
 520         let tmp = path.with_extension("kdl.tmp");
 521         std::fs::write(&tmp, self.to_kdl())?;
 522         std::fs::rename(&tmp, path)
 523     }
 524 
 525     pub fn save(&self) -> std::io::Result<()> {
 526         self.save_to(Path::new(PLAN_PATH))
 527     }
 528 }
 529 
 530 /// Which source the machine is on, from (type, online) pairs of the
 531 /// power_supply class: any Mains supply that is online means plugged in.
 532 /// No Mains supply at all (a desktop) reads as plugged in too — there is
 533 /// nothing to unplug.
 534 pub fn source_from_supplies<'a>(supplies: impl IntoIterator<Item = (&'a str, bool)>) -> Source {
 535     let mut saw_mains = false;
 536     for (kind, online) in supplies {
 537         if kind == "Mains" {
 538             saw_mains = true;
 539             if online {
 540                 return Source::Ac;
 541             }
 542         }
 543     }
 544     if saw_mains { Source::Battery } else { Source::Ac }
 545 }
 546 
 547 fn read_trim(path: &Path) -> Option<String> {
 548     std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
 549 }
 550 
 551 /// The live source, from /sys/class/power_supply.
 552 pub fn current_source() -> Source {
 553     let mut pairs: Vec<(String, bool)> = Vec::new();
 554     if let Ok(rd) = std::fs::read_dir("/sys/class/power_supply") {
 555         for e in rd.flatten() {
 556             let p = e.path();
 557             let kind = read_trim(&p.join("type")).unwrap_or_default();
 558             let online = read_trim(&p.join("online")).is_some_and(|s| s == "1");
 559             pairs.push((kind, online));
 560         }
 561     }
 562     source_from_supplies(pairs.iter().map(|(k, o)| (k.as_str(), *o)))
 563 }
 564 
 565 /// The state of the root side that applies a mode on plug and unplug.
 566 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
 567 pub enum Automation {
 568     /// No helper at the system path, or no udev rule to start it. The plan
 569     /// is only applied when the page itself changes a lever.
 570     #[default]
 571     Missing,
 572     /// Both installed, but the helper predates modes or one of the levers:
 573     /// it cannot parse a plan that uses them, so it applies nothing on plug
 574     /// or unplug — and it rejects the page's own `set`/`assign` calls too.
 575     Stale,
 576     Ready,
 577 }
 578 
 579 /// Whether a helper binary speaks the current CLI: modes, and every lever
 580 /// this page can send it.
 581 ///
 582 /// Asked by running it with no arguments, which prints its usage and exits
 583 /// 2 without touching anything. Deliberately NOT a string search inside the
 584 /// file: a hit would prove freshness but a miss proves nothing (link-time
 585 /// constant merging eats literals), and a false "stale" is the worse error.
 586 ///
 587 /// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
 588 /// way nothing reports: it takes the pkexec prompt, reads the mode name as
 589 /// an adapter state, and exits 2 — so a pick costs the user an
 590 /// authentication and changes nothing. A helper that knows modes but
 591 /// predates a lever fails the same way for that lever alone, which is why
 592 /// the lever list is part of the check.
 593 ///
 594 /// The answer is kept per path and modification time: the Power page asks
 595 /// on every five-second refresh, and until 2026-10-05 each one ran the
 596 /// helper. A reinstalled helper has a new mtime and is asked again.
 597 pub fn helper_speaks_modes(path: &Path) -> bool {
 598     type Seen = Vec<(PathBuf, Option<std::time::SystemTime>, bool)>;
 599     static SEEN: std::sync::Mutex<Seen> = std::sync::Mutex::new(Vec::new());
 600     let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
 601     if let Some(&(_, _, ok)) = SEEN.lock().unwrap().iter().find(|(p, t, _)| p == path && *t == mtime) {
 602         return ok;
 603     }
 604     let ok = std::process::Command::new(path)
 605         .output()
 606         .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)));
 607     let mut seen = SEEN.lock().unwrap();
 608     seen.retain(|(p, _, _)| p != path);
 609     seen.push((path.to_path_buf(), mtime, ok));
 610     ok
 611 }
 612 
 613 /// The usage text of a helper that knows about modes names `apply-mode`
 614 /// (the pre-modes one lists only `apply`, `set` and `show`), and its
 615 /// `levers:` line names every lever it accepts. It must also name
 616 /// `charge-limit`: a helper from before the charge limit cannot parse a
 617 /// plan holding one, so it would apply nothing on plug or unplug.
 618 fn usage_speaks_modes(usage: &str) -> bool {
 619     let levers: Vec<&str> = usage
 620         .lines()
 621         .find_map(|l| l.trim().strip_prefix("levers:"))
 622         .map(|l| l.split_whitespace().collect())
 623         .unwrap_or_default();
 624     usage.contains("apply-mode")
 625         && usage.contains("charge-limit")
 626         && Lever::ALL.iter().all(|l| levers.contains(&l.key()))
 627 }
 628 
 629 /// Whether the root-side pieces are in place — the helper at its system
 630 /// path, the udev rule that starts it, and a helper new enough to read the
 631 /// plan this app writes.
 632 pub fn automation_status() -> Automation {
 633     let helper = Path::new(HELPER_SYSTEM_PATH);
 634     if !helper.exists() || !Path::new(UDEV_RULE_PATH).exists() {
 635         return Automation::Missing;
 636     }
 637     if helper_speaks_modes(helper) { Automation::Ready } else { Automation::Stale }
 638 }
 639 
 640 // ── Applying (root) ─────────────────────────────────────────────────────
 641 
 642 fn write_sysfs(path: &Path, value: &str) -> Result<(), String> {
 643     std::fs::write(path, value).map_err(|e| format!("{}: {}", path.display(), e))
 644 }
 645 
 646 /// `/sys/devices/system/cpu/cpu<N>` for every core, sorted.
 647 fn cpu_dirs() -> Vec<PathBuf> {
 648     let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/devices/system/cpu")
 649         .map(|rd| {
 650             rd.flatten()
 651                 .map(|e| e.path())
 652                 .filter(|p| {
 653                     p.file_name()
 654                         .and_then(|n| n.to_str())
 655                         .is_some_and(|n| n.strip_prefix("cpu").is_some_and(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())))
 656                 })
 657                 .collect()
 658         })
 659         .unwrap_or_default();
 660     v.sort();
 661     v
 662 }
 663 
 664 /// Every /sys/class/drm/card* exposing the i915/xe clock knob.
 665 fn drm_cards_with_freq() -> Vec<PathBuf> {
 666     let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/drm")
 667         .map(|rd| {
 668             rd.flatten()
 669                 .map(|e| e.path())
 670                 .filter(|p| {
 671                     p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card"))
 672                         && p.join("gt_max_freq_mhz").exists()
 673                 })
 674                 .collect()
 675         })
 676         .unwrap_or_default();
 677     v.sort();
 678     v
 679 }
 680 
 681 /// Write one lever to every interface it covers. Runs as root; the page
 682 /// never calls this directly, it goes through the helper under pkexec.
 683 /// Errors name the file and the reason so the unit's journal is useful.
 684 pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
 685     if !lever.value_ok(value) {
 686         return Err(format!("{:?} is not a valid value for {}", value, lever.key()));
 687     }
 688     match lever {
 689         Lever::Profile => write_sysfs(Path::new("/sys/firmware/acpi/platform_profile"), value),
 690         Lever::Epp | Lever::Governor => {
 691             // Every core: both are per-cpu and a partial write would leave the
 692             // package split across preferences.
 693             let file = if lever == Lever::Epp { "energy_performance_preference" } else { "scaling_governor" };
 694             let mut any = false;
 695             for cpu in cpu_dirs() {
 696                 let p = cpu.join("cpufreq").join(file);
 697                 if p.exists() {
 698                     any = true;
 699                     write_sysfs(&p, value)?;
 700                 }
 701             }
 702             if any { Ok(()) } else { Err(format!("no cpufreq/{} on this host", file)) }
 703         }
 704         Lever::Turbo => {
 705             let no_turbo = if value == "on" { "0" } else { "1" };
 706             write_sysfs(Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo"), no_turbo)
 707         }
 708         Lever::IgpuMaxMhz => {
 709             let mhz: u32 = value.parse().map_err(|_| "bad MHz".to_string())?;
 710             let cards = drm_cards_with_freq();
 711             if cards.is_empty() {
 712                 return Err("no DRM card exposes gt_max_freq_mhz".to_string());
 713             }
 714             for card in cards {
 715                 // Bounded by the hardware's own reported range, per card.
 716                 let rd = |n: &str| read_trim(&card.join(n)).and_then(|s| s.parse::<u32>().ok());
 717                 let lo = rd("gt_RPn_freq_mhz").unwrap_or(0);
 718                 let hi = rd("gt_RP0_freq_mhz").unwrap_or(u32::MAX);
 719                 if mhz < lo || mhz > hi {
 720                     return Err(format!("{} MHz is outside {}'s {}–{} MHz range", mhz, card.display(), lo, hi));
 721                 }
 722                 write_sysfs(&card.join("gt_max_freq_mhz"), value)?;
 723             }
 724             Ok(())
 725         }
 726         Lever::Aspm => write_sysfs(Path::new("/sys/module/pcie_aspm/parameters/policy"), value),
 727         Lever::AudioIdleSecs => {
 728             let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
 729             if secs > 3600 {
 730                 return Err("audio idle timeout above an hour".to_string());
 731             }
 732             write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
 733         }
 734         Lever::Animations => write_run_state(Path::new(cce_ui::motion::STATE_PATH), value),
 735         Lever::IdleDisplayOffSecs | Lever::IdleSleepSecs => {
 736             let secs: u32 = value.parse().map_err(|_| "bad seconds".to_string())?;
 737             if secs > 86_400 {
 738                 return Err("idle timeout above a day".to_string());
 739             }
 740             let path = if lever == Lever::IdleDisplayOffSecs { IDLE_DISPLAY_OFF_PATH } else { IDLE_SLEEP_PATH };
 741             write_run_state(Path::new(path), value)
 742         }
 743         Lever::GpuLimitW => {
 744             // nvidia-smi validates the watts against the card's own min/max
 745             // and refuses anything outside them, so it is the range check.
 746             let out = std::process::Command::new("nvidia-smi")
 747                 .args(["-pl", value])
 748                 .output()
 749                 .map_err(|e| format!("nvidia-smi: {}", e))?;
 750             if out.status.success() {
 751                 Ok(())
 752             } else {
 753                 let msg = String::from_utf8_lossy(&out.stderr);
 754                 let msg = if msg.trim().is_empty() { String::from_utf8_lossy(&out.stdout) } else { msg };
 755                 Err(format!("nvidia-smi -pl {}: {}", value, msg.trim()))
 756             }
 757         }
 758     }
 759 }
 760 
 761 /// Every battery with a charge-limit knob, `/sys/class/power_supply/BAT*`,
 762 /// sorted.
 763 fn charge_batteries() -> Vec<PathBuf> {
 764     let mut v: Vec<PathBuf> = std::fs::read_dir("/sys/class/power_supply")
 765         .map(|rd| {
 766             rd.flatten()
 767                 .map(|e| e.path())
 768                 .filter(|p| {
 769                     p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("BAT"))
 770                         && p.join("charge_control_end_threshold").exists()
 771                 })
 772                 .collect()
 773         })
 774         .unwrap_or_default();
 775     v.sort();
 776     v
 777 }
 778 
 779 /// Whether the start threshold goes in before the end. The firmware
 780 /// (thinkpad_acpi) refuses a start above the end in force and an end below
 781 /// the start in force, so a window moving up must raise its end first and
 782 /// one moving down must lower its start first; `current_end` is what the
 783 /// battery reports now. Written start-then-end blindly, 75/80 → 85/90
 784 /// fails on the start.
 785 fn start_first(limit: ChargeLimit, current_end: Option<u32>) -> bool {
 786     match (limit.start, current_end) {
 787         (Some(s), Some(cur)) => s <= cur,
 788         _ => true,
 789     }
 790 }
 791 
 792 /// Write the charge window to every battery that has one. A threshold
 793 /// already at its value is not rewritten; a start the battery has no file
 794 /// for is reported after the end has landed, since the end is the half that
 795 /// protects the pack.
 796 pub fn apply_charge_limit(limit: ChargeLimit) -> Result<(), String> {
 797     limit.check()?;
 798     if limit.is_unset() {
 799         return Ok(());
 800     }
 801     let batteries = charge_batteries();
 802     if batteries.is_empty() {
 803         return Err("no battery exposes charge_control_end_threshold".to_string());
 804     }
 805     const START: &str = "charge_control_start_threshold";
 806     const END: &str = "charge_control_end_threshold";
 807     let mut missing_start = Vec::new();
 808     for bat in batteries {
 809         let read = |file: &str| read_trim(&bat.join(file)).and_then(|s| s.parse::<u32>().ok());
 810         let start = match limit.start {
 811             Some(s) if bat.join(START).exists() => Some((START, s)),
 812             Some(_) => {
 813                 missing_start.push(bat.display().to_string());
 814                 None
 815             }
 816             None => None,
 817         };
 818         let end = limit.end.map(|e| (END, e));
 819         let order = if start_first(limit, read(END)) { [start, end] } else { [end, start] };
 820         for (file, pct) in order.into_iter().flatten() {
 821             if read(file) != Some(pct) {
 822                 write_sysfs(&bat.join(file), &pct.to_string())?;
 823             }
 824         }
 825     }
 826     if missing_start.is_empty() {
 827         Ok(())
 828     } else {
 829         Err(format!("no {} on {}; only the end was applied", START, missing_start.join(", ")))
 830     }
 831 }
 832 
 833 /// Where the idle-timeout levers land, in seconds (0 = never). The
 834 /// compositor's idle manager polls both and lets a present file override
 835 /// its `idle { }` block; a missing file means "the config's value". The
 836 /// paths are repeated in `cce-fx`'s `idle.rs` (it cannot depend on this
 837 /// crate), so a rename must land on both sides.
 838 pub const IDLE_DISPLAY_OFF_PATH: &str = "/run/cce/idle_display_off";
 839 pub const IDLE_SLEEP_PATH: &str = "/run/cce/idle_sleep";
 840 
 841 /// Record a session-wide switch where every session reads it: the
 842 /// animations file ([`cce_ui::motion::STATE_PATH`]) and the idle-timeout
 843 /// files. Not sysfs, but the same shape of lever: root writes it when the
 844 /// mode changes, and the compositor and every cce-ui client follow it
 845 /// without a restart. Under /run, not in anyone's config: this runs as root
 846 /// with no session and no `$HOME`, and a tmpfs file is rewritten at boot by
 847 /// the same coldplug run that applies the rest of the mode, so it can never
 848 /// outlive the plan that set it.
 849 fn write_run_state(path: &Path, value: &str) -> Result<(), String> {
 850     use std::os::unix::fs::PermissionsExt;
 851     let dir = path.parent().expect("run-state path has a parent");
 852     std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
 853     // Temp + rename so a reader never sees a torn value; world-readable
 854     // because every session's processes read it.
 855     let tmp = path.with_extension("tmp");
 856     write_sysfs(&tmp, value)?;
 857     std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))
 858         .map_err(|e| format!("{}: {}", tmp.display(), e))?;
 859     std::fs::rename(&tmp, path).map_err(|e| format!("{}: {}", path.display(), e))
 860 }
 861 
 862 /// The order levers are written in. The governor goes first: intel_pstate
 863 /// refuses any energy preference but "performance" while the `performance`
 864 /// governor is in force (EBUSY), and switching governors resets the
 865 /// preference to the one it cached. Written in plan order (alphabetical by
 866 /// `Lever`), the battery mode's `epp "power"` failed on every unplug and
 867 /// left all cores at EPP=performance under the powersave governor. Every
 868 /// other lever is independent and keeps plan order.
 869 fn apply_order<'a>(levers: impl Iterator<Item = (Lever, &'a str)>) -> Vec<(Lever, &'a str)> {
 870     let mut v: Vec<(Lever, &str)> = levers.collect();
 871     v.sort_by_key(|(lever, _)| (*lever != Lever::Governor) as u8);
 872     v
 873 }
 874 
 875 /// Apply every lever one mode sets. Failures are per lever — a missing
 876 /// NVIDIA driver must not stop the CPU profile from landing — and come back
 877 /// to the caller, which logs them.
 878 pub fn apply_mode(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, Result<(), String>)> {
 879     apply_order(plan.levers(mode))
 880         .into_iter()
 881         .map(|(lever, value)| (lever, apply_lever(lever, value)))
 882         .collect::<Vec<_>>()
 883 }
 884 
 885 /// Apply whichever mode is assigned to one adapter state.
 886 pub fn apply_source(plan: &PowerPlan, source: Source) -> Vec<(Lever, Result<(), String>)> {
 887     apply_mode(plan, plan.assigned(source))
 888 }
 889 
 890 #[cfg(test)]
 891 mod tests {
 892     use super::*;
 893 
 894     fn levers_of(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, String)> {
 895         plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect()
 896     }
 897 
 898     #[test]
 899     fn governor_is_written_before_epp() {
 900         // The power-saver mode as shipped: plan order puts epp before
 901         // governor, and intel_pstate rejects that pairing.
 902         let mut plan = PowerPlan::default();
 903         plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
 904         plan.put(Mode::PowerSaver, Lever::Epp, Some("power")).unwrap();
 905         plan.put(Mode::PowerSaver, Lever::Governor, Some("powersave")).unwrap();
 906         plan.put(Mode::PowerSaver, Lever::Turbo, Some("off")).unwrap();
 907         let order: Vec<Lever> = apply_order(plan.levers(Mode::PowerSaver)).into_iter().map(|(l, _)| l).collect();
 908         assert_eq!(order[0], Lever::Governor, "{order:?}");
 909         // The rest keep plan order, and nothing is dropped or duplicated.
 910         assert_eq!(order, vec![Lever::Governor, Lever::Profile, Lever::Epp, Lever::Turbo]);
 911         // A mode without a governor is untouched.
 912         let mut bare = PowerPlan::default();
 913         bare.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
 914         let order: Vec<Lever> = apply_order(bare.levers(Mode::Balanced)).into_iter().map(|(l, _)| l).collect();
 915         assert_eq!(order, vec![Lever::Epp]);
 916     }
 917 
 918     #[test]
 919     fn kdl_round_trip_keeps_modes_assignment_and_types() {
 920         let mut plan = PowerPlan::default();
 921         plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
 922         plan.put(Mode::Performance, Lever::Turbo, Some("on")).unwrap();
 923         plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
 924         plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("800")).unwrap();
 925         plan.put(Mode::PowerSaver, Lever::GpuLimitW, Some("40")).unwrap();
 926         plan.assign(Source::Battery, Mode::Balanced);
 927         let text = plan.to_kdl();
 928         // Numbers are written as KDL integers, tokens as strings.
 929         assert!(text.contains("igpu_max_mhz 800"), "{text}");
 930         assert!(text.contains("profile \"low-power\""), "{text}");
 931         assert!(text.contains("mode \"power-saver\""), "{text}");
 932         assert!(text.contains("battery \"balanced\""), "{text}");
 933         // The file says every assignment out loud, so what comes back is the
 934         // same plan with the AC default written down — and writing it again
 935         // is a fixed point.
 936         let back = PowerPlan::parse(&text).unwrap();
 937         assert_eq!(levers_of(&back, Mode::Performance), levers_of(&plan, Mode::Performance));
 938         assert_eq!(levers_of(&back, Mode::PowerSaver), levers_of(&plan, Mode::PowerSaver));
 939         for source in Source::ALL {
 940             assert_eq!(back.assigned(source), plan.assigned(source));
 941         }
 942         assert_eq!(back.to_kdl(), text);
 943     }
 944 
 945     #[test]
 946     fn parse_accepts_empty_and_partial_files() {
 947         assert_eq!(PowerPlan::parse("").unwrap(), PowerPlan::default());
 948         let p = PowerPlan::parse("mode \"balanced\" {\n  epp \"power\"\n}\n").unwrap();
 949         assert_eq!(p.get(Mode::Balanced, Lever::Epp), Some("power"));
 950         assert_eq!(p.get(Mode::Performance, Lever::Epp), None);
 951         // Nothing assigned: each adapter state keeps its default mode.
 952         assert_eq!(p.assigned(Source::Ac), Mode::Performance);
 953         assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
 954     }
 955 
 956     #[test]
 957     fn the_pre_modes_file_migrates_onto_the_default_modes() {
 958         // What /etc/cce/power.kdl looked like before modes existed: a bare
 959         // block of levers per adapter state, applied on plug and unplug.
 960         let old = "ac {\n  profile \"performance\"\n}\nbattery {\n  profile \"low-power\"\n  igpu_max_mhz 800\n}\n";
 961         let p = PowerPlan::parse(old).unwrap();
 962         // Each block landed on the mode its source runs, so the same levers
 963         // still apply on the same adapter states.
 964         assert_eq!(p.assigned(Source::Ac), Mode::Performance);
 965         assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
 966         assert_eq!(p.get(Mode::Performance, Lever::Profile), Some("performance"));
 967         assert_eq!(p.get(Mode::PowerSaver, Lever::IgpuMaxMhz), Some("800"));
 968         assert!(levers_of(&p, Mode::Balanced).is_empty());
 969         // And it rewrites in the new shape.
 970         assert!(p.to_kdl().contains("mode \"performance\""));
 971         assert_eq!(PowerPlan::parse(&p.to_kdl()).unwrap(), p);
 972     }
 973 
 974     #[test]
 975     fn parse_rejects_unknown_names_and_bad_values() {
 976         assert!(PowerPlan::parse("mode \"balanced\" {\n  brightness 50\n}\n").is_err());
 977         assert!(PowerPlan::parse("mode \"turbo-max\" {\n}\n").is_err());
 978         assert!(PowerPlan::parse("assign {\n  ac \"turbo-max\"\n}\n").is_err());
 979         assert!(PowerPlan::parse("assign {\n  usb \"balanced\"\n}\n").is_err());
 980         assert!(PowerPlan::parse("levers {\n  profile \"performance\"\n}\n").is_err());
 981         // A shell metacharacter never survives into the plan.
 982         assert!(PowerPlan::parse("mode \"balanced\" {\n  profile \"x;reboot\"\n}\n").is_err());
 983         // Turbo is on/off only.
 984         assert!(PowerPlan::parse("mode \"balanced\" {\n  turbo \"yes\"\n}\n").is_err());
 985         // A numeric lever given a token.
 986         assert!(PowerPlan::parse("mode \"balanced\" {\n  gpu_limit_w \"max\"\n}\n").is_err());
 987     }
 988 
 989     #[test]
 990     fn put_none_clears_and_bad_values_are_refused() {
 991         let mut plan = PowerPlan::default();
 992         plan.put(Mode::Balanced, Lever::Governor, Some("powersave")).unwrap();
 993         assert!(plan.put(Mode::Balanced, Lever::Governor, Some("$(rm)")).is_err());
 994         assert_eq!(plan.get(Mode::Balanced, Lever::Governor), Some("powersave"));
 995         plan.put(Mode::Balanced, Lever::Governor, None).unwrap();
 996         assert!(plan.is_empty());
 997         // An assignment alone is not content — it changes nothing until a
 998         // mode has a lever in it.
 999         plan.assign(Source::Ac, Mode::Balanced);
1000         assert!(plan.is_empty());
1001     }
1002 
1003     #[test]
1004     fn assignment_is_per_source_and_two_states_may_share_a_mode() {
1005         let mut plan = PowerPlan::default();
1006         plan.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
1007         plan.assign(Source::Ac, Mode::Balanced);
1008         plan.assign(Source::Battery, Mode::Balanced);
1009         assert_eq!(plan.assigned(Source::Ac), Mode::Balanced);
1010         assert_eq!(plan.assigned(Source::Battery), Mode::Balanced);
1011         assert_eq!(PowerPlan::parse(&plan.to_kdl()).unwrap(), plan);
1012         // Both states named, so nothing was left to a default.
1013         assert!(plan.to_kdl().contains("ac \"balanced\""));
1014     }
1015 
1016     #[test]
1017     fn value_guard_shapes() {
1018         assert!(Lever::Profile.value_ok("balance_power"));
1019         assert!(Lever::Profile.value_ok("low-power"));
1020         assert!(!Lever::Profile.value_ok(""));
1021         assert!(!Lever::Profile.value_ok("a b"));
1022         assert!(!Lever::Profile.value_ok("x;reboot"));
1023         assert!(Lever::Turbo.value_ok("off"));
1024         assert!(!Lever::Turbo.value_ok("0"));
1025         assert!(Lever::Animations.value_ok("on"));
1026         assert!(!Lever::Animations.value_ok("false"));
1027         assert!(Lever::AudioIdleSecs.value_ok("10"));
1028         assert!(!Lever::AudioIdleSecs.value_ok("-1"));
1029         assert!(!Lever::AudioIdleSecs.value_ok("ten"));
1030         assert!(Lever::IdleDisplayOffSecs.value_ok("0"));
1031         assert!(Lever::IdleSleepSecs.value_ok("1800"));
1032         assert!(!Lever::IdleSleepSecs.value_ok("30m"));
1033         assert!(Lever::IdleDisplayOffSecs.is_numeric());
1034     }
1035 
1036     #[test]
1037     fn source_follows_the_mains_supply() {
1038         // Battery discharging, USB-C sources idle, AC offline → battery.
1039         let unplugged = [("Battery", false), ("USB", false), ("Mains", false)];
1040         assert_eq!(source_from_supplies(unplugged), Source::Battery);
1041         let plugged = [("Battery", false), ("Mains", true)];
1042         assert_eq!(source_from_supplies(plugged), Source::Ac);
1043         // A desktop with no Mains device has nothing to unplug.
1044         assert_eq!(source_from_supplies([("Battery", false)]), Source::Ac);
1045         assert_eq!(source_from_supplies([]), Source::Ac);
1046     }
1047 
1048     #[test]
1049     fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
1050         // What this binary prints today.
1051         let levers = Lever::ALL.iter().map(|l| l.key()).collect::<Vec<_>>().join(" ");
1052         let current = format!(
1053             "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply apply-mode <mode>\n       \
1054              cce-power-apply charge-limit <start|unset> <end|unset>\n \
1055              modes:  performance balanced power-saver\n levers: {levers}\n"
1056         );
1057         assert!(usage_speaks_modes(&current));
1058         // One that knows modes but predates a lever takes the prompt and
1059         // rejects that lever — as stale, for that pick, as a pre-modes one.
1060         let older = current.replace(" animations", "");
1061         assert!(!usage_speaks_modes(&older), "{older}");
1062         // One from before the charge limit cannot even parse a plan that
1063         // holds one.
1064         let no_charge = current.replace("charge-limit", "");
1065         assert!(!usage_speaks_modes(&no_charge), "{no_charge}");
1066         // What the pre-modes one printed — the copy that silently rejects
1067         // every pick the page makes.
1068         assert!(!usage_speaks_modes(
1069             "usage: cce-power-apply apply [ac|battery]\n       cce-power-apply set <ac|battery> <lever> <value|unset>\n       cce-power-apply show\n"
1070         ));
1071         // A helper that cannot be run at all is not a helper that speaks.
1072         assert!(!helper_speaks_modes(Path::new("/nonexistent/cce-power-apply")));
1073     }
1074 
1075     #[test]
1076     fn the_charge_limit_is_plan_wide_and_round_trips() {
1077         let text = "mode \"performance\" { aspm \"performance\"; }\n\
1078                     charge_limit { start 75; end 80; }\n";
1079         let plan = PowerPlan::parse(text).unwrap();
1080         assert_eq!(plan.charge_limit(), ChargeLimit { start: Some(75), end: Some(80) });
1081         // It is in no mode's levers, and survives a write and a re-read.
1082         assert_eq!(levers_of(&plan, Mode::Performance), [(Lever::Aspm, "performance".to_string())]);
1083         let again = PowerPlan::parse(&plan.to_kdl()).unwrap();
1084         assert_eq!(again.charge_limit(), plan.charge_limit());
1085         assert_eq!(levers_of(&again, Mode::Performance), levers_of(&plan, Mode::Performance));
1086         // Half a window keeps only that half; none writes no block at all.
1087         let end_only = PowerPlan::parse("charge_limit { end 60; }").unwrap();
1088         assert_eq!(end_only.charge_limit(), ChargeLimit { start: None, end: Some(60) });
1089         assert_eq!(PowerPlan::parse(&end_only.to_kdl()).unwrap().charge_limit(), end_only.charge_limit());
1090         assert!(!PowerPlan::default().to_kdl().contains("charge_limit"));
1091     }
1092 
1093     #[test]
1094     fn a_charge_window_must_be_one_the_firmware_takes() {
1095         for bad in [
1096             "charge_limit { start 80; end 80; }",
1097             "charge_limit { start 90; end 80; }",
1098             "charge_limit { end 0; }",
1099             "charge_limit { end 101; }",
1100             "charge_limit { start 100; }",
1101             "charge_limit { start -1; }",
1102             "charge_limit { end \"80\"; }",
1103             "charge_limit { stop 80; }",
1104         ] {
1105             assert!(PowerPlan::parse(bad).is_err(), "{bad}");
1106         }
1107         let mut plan = PowerPlan::default();
1108         assert!(plan.set_charge_limit(ChargeLimit { start: Some(85), end: Some(80) }).is_err());
1109         assert!(plan.charge_limit().is_unset(), "a refused window is not stored");
1110         plan.set_charge_limit(ChargeLimit { start: Some(0), end: Some(100) }).unwrap();
1111     }
1112 
1113     #[test]
1114     fn a_charge_window_moving_up_writes_its_end_first() {
1115         let window = |s, e| ChargeLimit { start: Some(s), end: Some(e) };
1116         // 75/80 → 85/90: a start of 85 over an end of 80 is refused.
1117         assert!(!start_first(window(85, 90), Some(80)));
1118         // 75/80 → 55/60, and the factory 0/100 → 75/80: start first, or the
1119         // new end would sit below the old start.
1120         assert!(start_first(window(55, 60), Some(80)));
1121         assert!(start_first(window(75, 80), Some(100)));
1122         // An end the battery does not report, or no start to order.
1123         assert!(start_first(window(75, 80), None));
1124         assert!(start_first(ChargeLimit { start: None, end: Some(80) }, Some(60)));
1125     }
1126 
1127     #[test]
1128     fn keys_round_trip() {
1129         for l in Lever::ALL {
1130             assert_eq!(Lever::parse(l.key()), Some(l));
1131         }
1132         for s in Source::ALL {
1133             assert_eq!(Source::parse(s.key()), Some(s));
1134         }
1135         for m in Mode::ALL {
1136             assert_eq!(Mode::parse(m.key()), Some(m));
1137         }
1138         assert_eq!(Lever::parse("brightness"), None);
1139         assert_eq!(Mode::parse("ac"), None);
1140     }
1141 
1142     #[test]
1143     fn save_and_load_through_a_temp_dir() {
1144         let dir = std::env::temp_dir().join(format!("cce-power-plan-test-{}", std::process::id()));
1145         let path = dir.join("nested").join("power.kdl");
1146         // Missing file is an empty plan, not an error.
1147         assert_eq!(PowerPlan::load_from(&path).unwrap(), PowerPlan::default());
1148         let mut plan = PowerPlan::default();
1149         plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
1150         plan.assign(Source::Ac, Mode::Balanced);
1151         plan.assign(Source::Battery, Mode::PowerSaver);
1152         plan.save_to(&path).unwrap();
1153         assert_eq!(PowerPlan::load_from(&path).unwrap(), plan);
1154         // Garbage on disk is reported, not silently emptied.
1155         std::fs::write(&path, "mode \"balanced\" {\n  profile \n").unwrap();
1156         assert!(PowerPlan::load_from(&path).is_err());
1157         let _ = std::fs::remove_dir_all(&dir);
1158     }
1159 }