xlb 0.8.18

Chunked, Bao-verified blob distribution with multi-source concurrent fetch (LAN + peer + edge), app-namespaced
//! Bandwidth policy + auto-governors (xlb-4).
//!
//! Three governors layer over [`BandwidthPolicy`]:
//! - **Battery** — when on battery power, effective caps drop to
//!   [`BwCaps::passive()`] regardless of configured policy.
//! - **Metered** — when the OS reports a metered network connection, same.
//! - **Disk-budget** — when the local cache approaches `cache_budget_bytes`,
//!   LRU eviction runs before accepting new blobs (see [`crate::AssetClass`]).
//!
//! # Battery auto-detection
//!
//! [`BandwidthGovernor::probe_os`] checks the platform for battery/power
//! state. On macOS it reads the `PMUPowerSource` key via `ioreg`. On Linux it
//! reads `/sys/class/power_supply/*/status`. The result is a best-effort hint;
//! a detection failure leaves the governor unchanged (defaults to AC mode).
//!
//! For a "manual test on a laptop" (per the xlb-4 accept criteria), run:
//!
//! ```text
//! cargo test -p xlb  # all tests green on AC
//! # disconnect from AC power
//! # then run: cargo test -p xlb -- --nocapture bandwidth  # should log passive
//! ```
//!
//! Unit tests use [`BandwidthGovernor::set_battery`] / [`BandwidthGovernor::set_metered`]
//! to control state without requiring OS interaction.

use std::sync::{Arc, RwLock};

use crate::{BandwidthPolicy, BwCaps, PeerTier};

// ─── GovernorState ────────────────────────────────────────────────────────────

#[derive(Default, Debug, Clone)]
struct GovernorState {
    on_battery: bool,
    metered: bool,
}

// ─── BandwidthGovernor ────────────────────────────────────────────────────────

/// Applies [`BandwidthPolicy`] through auto-governors for battery, metered
/// connections, and disk-budget pressure.
///
/// ```
/// use xlb::{BandwidthGovernor, BandwidthPolicy, BwCaps, PeerTier};
///
/// let g = BandwidthGovernor::new(
///     BandwidthPolicy::default()
///         .role(PeerTier::Workstation, BwCaps { up_mbit: 5, down_mbit: 50 }),
/// );
///
/// // AC power: returns configured caps.
/// assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 5);
///
/// // Battery: drops to passive.
/// g.set_battery(true);
/// assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 0);
/// ```
#[derive(Clone)]
pub struct BandwidthGovernor {
    policy: BandwidthPolicy,
    state: Arc<RwLock<GovernorState>>,
}

impl BandwidthGovernor {
    /// Create a governor wrapping `policy`. Starts in AC / unmetered mode.
    pub fn new(policy: BandwidthPolicy) -> Self {
        Self { policy, state: Arc::new(RwLock::new(GovernorState::default())) }
    }

    /// Return effective [`BwCaps`] for `tier`.
    ///
    /// Returns [`BwCaps::passive()`] when on battery or a metered connection;
    /// otherwise delegates to the configured [`BandwidthPolicy`].
    pub fn effective_caps(&self, tier: PeerTier) -> BwCaps {
        let s = self.state.read().unwrap();
        if s.on_battery || s.metered {
            return BwCaps::passive();
        }
        self.policy.caps_for(tier)
    }

    /// `true` when any governor is forcing passive mode.
    pub fn is_passive(&self) -> bool {
        let s = self.state.read().unwrap();
        s.on_battery || s.metered
    }

    /// `true` when the battery governor has flagged on-battery power.
    pub fn is_on_battery(&self) -> bool {
        self.state.read().unwrap().on_battery
    }

    /// `true` when the metered-connection governor is active.
    pub fn is_metered(&self) -> bool {
        self.state.read().unwrap().metered
    }

    /// Override battery state (for testing or OS-event callbacks).
    pub fn set_battery(&self, on_battery: bool) {
        self.state.write().unwrap().on_battery = on_battery;
    }

    /// Override metered-connection state (for testing or OS-event callbacks).
    pub fn set_metered(&self, metered: bool) {
        self.state.write().unwrap().metered = metered;
    }

    /// Probe the OS for power-source state and update the governor.
    ///
    /// Failure is silently ignored — a detection error leaves the governor in
    /// its current state. Call this at startup and on relevant OS events.
    pub fn probe_os(&self) {
        if let Some(on_battery) = detect_battery() {
            self.set_battery(on_battery);
        }
    }

    /// True if the configured policy has any non-passive entries.
    pub fn has_policy(&self) -> bool {
        !matches!(
            self.policy.caps_for(PeerTier::Workstation),
            BwCaps { up_mbit: 0, .. }
        )
    }
}

impl std::fmt::Debug for BandwidthGovernor {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = self.state.read().unwrap();
        f.debug_struct("BandwidthGovernor")
            .field("on_battery", &s.on_battery)
            .field("metered", &s.metered)
            .field("is_passive", &(s.on_battery || s.metered))
            .finish()
    }
}

// ─── OS detection ─────────────────────────────────────────────────────────────

/// Returns `Some(true)` when on battery, `Some(false)` on AC, `None` on error.
#[cfg(target_os = "macos")]
fn detect_battery() -> Option<bool> {
    let out = std::process::Command::new("pmset")
        .args(["-g", "ps"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    // "Now drawing from 'Battery Power'" vs "'AC Power'"
    if text.contains("Battery Power") {
        Some(true)
    } else if text.contains("AC Power") {
        Some(false)
    } else {
        None
    }
}

#[cfg(target_os = "linux")]
fn detect_battery() -> Option<bool> {
    let base = std::path::Path::new("/sys/class/power_supply");
    if !base.exists() {
        return None;
    }
    for entry in std::fs::read_dir(base).ok()? {
        let entry = entry.ok()?;
        let status_path = entry.path().join("status");
        if let Ok(status) = std::fs::read_to_string(&status_path) {
            let s = status.trim();
            if s == "Discharging" {
                return Some(true);
            }
            if s == "Charging" || s == "Full" {
                return Some(false);
            }
        }
    }
    None
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn detect_battery() -> Option<bool> {
    None
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn policy() -> BandwidthPolicy {
        BandwidthPolicy::default()
            .role(PeerTier::Cloud, BwCaps { up_mbit: 1000, down_mbit: 10_000 })
            .role(PeerTier::Camp, BwCaps { up_mbit: 10, down_mbit: 100 })
            .role(PeerTier::Workstation, BwCaps { up_mbit: 5, down_mbit: 50 })
            .role(PeerTier::Mobile, BwCaps::passive())
    }

    #[test]
    fn caps_by_tier_on_ac() {
        let g = BandwidthGovernor::new(policy());
        assert_eq!(g.effective_caps(PeerTier::Cloud).up_mbit, 1000);
        assert_eq!(g.effective_caps(PeerTier::Camp).up_mbit, 10);
        assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 5);
        assert_eq!(g.effective_caps(PeerTier::Mobile).up_mbit, 0);
    }

    #[test]
    fn battery_forces_passive() {
        let g = BandwidthGovernor::new(policy());
        assert!(!g.is_passive());

        g.set_battery(true);
        assert!(g.is_passive());

        let caps = g.effective_caps(PeerTier::Cloud);
        assert_eq!(caps.up_mbit, 0, "battery must drop Cloud to passive");
        assert_eq!(caps.down_mbit, 50, "down_mbit matches BwCaps::passive()");
    }

    #[test]
    fn metered_forces_passive() {
        let g = BandwidthGovernor::new(policy());
        g.set_metered(true);
        assert!(g.is_passive());
        assert_eq!(g.effective_caps(PeerTier::Camp).up_mbit, 0);
    }

    #[test]
    fn battery_override_is_togglable() {
        let g = BandwidthGovernor::new(policy());
        g.set_battery(true);
        assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 0);

        g.set_battery(false);
        assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 5);
    }

    #[test]
    fn metered_and_battery_independent() {
        let g = BandwidthGovernor::new(policy());
        g.set_battery(true);
        g.set_metered(true);
        assert!(g.is_passive());

        g.set_battery(false);
        assert!(g.is_passive(), "metered alone still passive");

        g.set_metered(false);
        assert!(!g.is_passive(), "both off → active");
    }

    #[test]
    fn unconfigured_tier_defaults_to_passive() {
        let g = BandwidthGovernor::new(BandwidthPolicy::default());
        // Default policy has no role entries; all tiers fall back to passive.
        assert_eq!(g.effective_caps(PeerTier::Workstation).up_mbit, 0);
    }

    #[test]
    fn clone_shares_state() {
        let g1 = BandwidthGovernor::new(policy());
        let g2 = g1.clone();

        g1.set_battery(true);
        assert!(g2.is_passive(), "clone must share Arc<RwLock<GovernorState>>");
    }

    #[test]
    fn probe_os_does_not_panic() {
        // We don't assert the battery state (unknown in CI), just that the
        // probe never panics.
        let g = BandwidthGovernor::new(policy());
        g.probe_os();
    }
}