use lesavka_common::lesavka::CapturePowerState; #[cfg(not(coverage))] use { anyhow::{Context, Result, anyhow}, std::process::Command, std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }, tokio::sync::Mutex, tracing::{info, warn}, }; #[cfg(not(coverage))] #[derive(Debug, Default)] struct CapturePowerInner { active_leases: u32, manual_override: Option, } #[cfg(not(coverage))] #[derive(Debug, Clone)] pub struct CapturePowerManager { unit: Arc, inner: Arc>, } #[cfg(not(coverage))] #[derive(Clone)] pub struct CapturePowerLease { manager: CapturePowerManager, released: Arc, } #[cfg(not(coverage))] #[derive(Debug)] struct UnitSnapshot { available: bool, enabled: bool, detail: String, } #[cfg(not(coverage))] impl CapturePowerManager { pub fn new() -> Self { let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "relay.service".to_string()); Self { unit: Arc::::from(unit), inner: Arc::new(Mutex::new(CapturePowerInner::default())), } } pub async fn acquire(&self) -> CapturePowerLease { let (desired, unit, leases, manual_override) = { let mut inner = self.inner.lock().await; inner.active_leases = inner.active_leases.saturating_add(1); ( desired_state(&inner), self.unit.to_string(), inner.active_leases, inner.manual_override, ) }; if let Err(err) = sync_unit_state(unit.as_str(), desired).await { warn!(unit = %unit, leases, desired, ?manual_override, ?err, "capture power sync failed on acquire"); } CapturePowerLease { manager: self.clone(), released: Arc::new(AtomicBool::new(false)), } } pub async fn set_manual(&self, enabled: bool) -> Result { let unit = self.unit.to_string(); { let mut inner = self.inner.lock().await; inner.manual_override = Some(enabled); } sync_unit_state(unit.as_str(), enabled).await?; self.snapshot().await } pub async fn set_auto(&self) -> Result { let unit = self.unit.to_string(); let desired = { let mut inner = self.inner.lock().await; inner.manual_override = None; desired_state(&inner) }; sync_unit_state(unit.as_str(), desired).await?; self.snapshot().await } pub async fn snapshot(&self) -> Result { let (active_leases, manual_override) = { let inner = self.inner.lock().await; (inner.active_leases, inner.manual_override) }; let unit = self.unit.to_string(); let snapshot = inspect_unit(unit.as_str()).await?; Ok(CapturePowerState { available: snapshot.available, enabled: snapshot.enabled, unit, detail: snapshot.detail, active_leases, mode: match manual_override { Some(true) => "forced-on".to_string(), Some(false) => "forced-off".to_string(), None => "auto".to_string(), }, }) } async fn release_one(&self) { let (desired, unit, leases, manual_override) = { let mut inner = self.inner.lock().await; inner.active_leases = inner.active_leases.saturating_sub(1); ( desired_state(&inner), self.unit.to_string(), inner.active_leases, inner.manual_override, ) }; if let Err(err) = sync_unit_state(unit.as_str(), desired).await { warn!( unit = %unit, leases, desired, ?manual_override, ?err, "capture power sync failed on release" ); } else { info!( unit = %unit, leases, desired, ?manual_override, "capture power synced" ); } } } #[cfg(not(coverage))] impl Drop for CapturePowerLease { fn drop(&mut self) { if self.released.swap(true, Ordering::AcqRel) { return; } let manager = self.manager.clone(); tokio::spawn(async move { manager.release_one().await; }); } } #[cfg(not(coverage))] fn desired_state(inner: &CapturePowerInner) -> bool { inner.manual_override.unwrap_or(inner.active_leases > 0) } #[cfg(not(coverage))] async fn inspect_unit(unit: &str) -> Result { let unit = unit.to_string(); tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str())) .await .map_err(|err| anyhow!("capture power inspect task failed: {err}"))? } #[cfg(not(coverage))] fn inspect_unit_blocking(unit: &str) -> Result { let output = Command::new("systemctl") .args([ "show", unit, "--property=LoadState,ActiveState,SubState", "--value", ]) .output() .with_context(|| format!("querying systemd unit {unit}"))?; if !output.status.success() { return Err(anyhow!( "systemctl show {unit} failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); let mut lines = stdout.lines(); let load_state = lines.next().unwrap_or_default().trim().to_string(); let active_state = lines.next().unwrap_or_default().trim().to_string(); let sub_state = lines.next().unwrap_or_default().trim().to_string(); let available = !load_state.is_empty() && load_state != "not-found"; let enabled = active_state == "active"; let detail = if available { format!("{active_state}/{sub_state}") } else { "unit not found".to_string() }; Ok(UnitSnapshot { available, enabled, detail, }) } #[cfg(not(coverage))] async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> { let unit = unit.to_string(); tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled)) .await .map_err(|err| anyhow!("capture power sync task failed: {err}"))? } #[cfg(not(coverage))] fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> { let action = if enabled { "start" } else { "stop" }; let status = Command::new("systemctl") .args([action, unit]) .status() .with_context(|| format!("running systemctl {action} {unit}"))?; if status.success() { Ok(()) } else { Err(anyhow!("systemctl {action} {unit} failed with {status}")) } } #[cfg(coverage)] #[derive(Debug, Clone, Default)] pub struct CapturePowerManager; #[cfg(coverage)] #[derive(Clone, Default)] pub struct CapturePowerLease; #[cfg(coverage)] impl CapturePowerManager { pub fn new() -> Self { Self } pub async fn acquire(&self) -> CapturePowerLease { CapturePowerLease } pub async fn set_manual(&self, enabled: bool) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled, unit: "relay.service".to_string(), detail: if enabled { "active/running".to_string() } else { "inactive/dead".to_string() }, active_leases: 0, mode: if enabled { "forced-on".to_string() } else { "forced-off".to_string() }, }) } pub async fn set_auto(&self) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled: false, unit: "relay.service".to_string(), detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), }) } pub async fn snapshot(&self) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled: false, unit: "relay.service".to_string(), detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), }) } } #[cfg(all(test, coverage))] mod tests { use super::*; #[tokio::test] async fn coverage_stub_reports_auto_snapshot() { let state = CapturePowerManager::new() .snapshot() .await .expect("snapshot"); assert!(state.available); assert!(!state.enabled); assert_eq!(state.mode, "auto"); } #[tokio::test] async fn coverage_stub_toggles_manual_modes() { let manager = CapturePowerManager::new(); let on = manager.set_manual(true).await.expect("on"); assert!(on.enabled); assert_eq!(on.mode, "forced-on"); let off = manager.set_manual(false).await.expect("off"); assert!(!off.enabled); assert_eq!(off.mode, "forced-off"); } #[tokio::test] async fn coverage_stub_returns_to_auto_mode() { let manager = CapturePowerManager::new(); let state = manager.set_auto().await.expect("auto"); assert!(state.available); assert!(!state.enabled); assert_eq!(state.mode, "auto"); } }