joularcore 0.2.0

Joular Core is a platform to measure power and energy across all systems, OSes and devices
Documentation
/*
 * Copyright (c) 2025-2026, Adel Noureddine.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the
 * GNU Lesser General Public License v3.0 only (LGPL-3.0-only)
 * which accompanies this distribution, and is available at
 * https://www.gnu.org/licenses/lgpl-3.0.en.html
 *
 * Author : Adel Noureddine
 */

//! The CPU-utilization bookkeeping every backend shares.
//!
//! Power attribution divides CPU power in proportion to CPU time, so a backend
//! has to report whole-system and per-process usage over the *same* interval.
//! Only the reading differs between platforms — `/proc/stat` on Linux,
//! `GetSystemTimes` on Windows, `host_statistics64` on macOS — so the
//! bookkeeping around it lives here once and each backend supplies the reader.

use crate::config::AppMatch;
use crate::sensor::{
    AppCpuUtilization, AppSample, CpuTotals, CpuUtilization, ProcessCpuUtilization,
};
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
#[cfg(not(target_os = "windows"))]
use sysinfo::{ProcessesToUpdate, System};

/// Whole-system CPU utilization, sampled from a platform's own counters.
pub(crate) struct TotalsSampler {
    /// Reads this platform's cumulative counters.
    read: fn() -> Option<CpuTotals>,
    /// The most recent snapshot, or `None` before the first successful read.
    last: Option<CpuTotals>,
}

impl TotalsSampler {
    /// Create a sampler that reads through `read`, taking an initial snapshot
    /// so the first [`CpuUtilization::cpu_utilization`] call has a baseline.
    pub(crate) fn new(read: fn() -> Option<CpuTotals>) -> Self {
        Self { read, last: read() }
    }
}

impl CpuUtilization for TotalsSampler {
    fn cpu_utilization(&mut self) -> f64 {
        // A failed read must not overwrite the baseline: doing so would report
        // one bogus sample now and a second one on the next call.
        let Some(current) = (self.read)() else {
            log::debug!("CPU counters are unreadable; reporting 0% for this sample");
            return 0.0;
        };

        match self.last.replace(current) {
            Some(previous) => current.utilization_since(&previous),
            None => 0.0,
        }
    }

    fn last_totals(&self) -> Option<CpuTotals> {
        self.last
    }
}

/// A process's share of the machine, as the ratio of two counter deltas.
///
/// Linux and Windows both expose a cumulative per-process CPU time and a
/// system-wide total *in the same unit*, so a process's share is one delta over
/// the other. Each backend reads its own two numbers — `/proc` on Linux, the
/// Win32 clocks on Windows — and hands them here, so only the arithmetic is
/// shared.
///
/// macOS cannot use this — which is why it is compiled only for the other two.
/// Its process clock counts mach ticks while its host clock counts scheduler
/// ticks, and the two cannot be differenced against each other. See the macOS
/// backend for what it does instead.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Debug, Default)]
pub(crate) struct CpuTimeDelta {
    /// Counters from the previous call, or `None` before the first one.
    previous: Option<Snapshot>,
}

#[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Clone, Copy, Debug)]
struct Snapshot {
    cpu_total: u64,
    process_time: u64,
}

#[cfg(any(target_os = "linux", target_os = "windows"))]
impl CpuTimeDelta {
    /// Fraction of total CPU capacity `process_time` used since the previous
    /// call, given the matching system-wide `cpu_total`.
    ///
    /// The first call only establishes the baseline and returns `0.0`.
    pub(crate) fn share(&mut self, cpu_total: u64, process_time: u64) -> f64 {
        let current = Snapshot {
            cpu_total,
            process_time,
        };
        let Some(previous) = self.previous.replace(current) else {
            return 0.0;
        };

        let cpu_delta = cpu_total.saturating_sub(previous.cpu_total);
        let process_delta = process_time.saturating_sub(previous.process_time);

        if cpu_delta == 0 {
            0.0
        } else {
            process_delta as f64 / cpu_delta as f64
        }
    }
}

/// Whether `process_name` denotes the application `app_name`.
///
/// Matching ignores case. Under [`AppMatch::Exact`] a trailing `.exe` on either
/// side is ignored too, so `firefox` matches `firefox.exe`.
pub(crate) fn matches_app_name(process_name: &str, app_name: &str, app_match: AppMatch) -> bool {
    fn strip_exe(name: &str) -> &str {
        name.strip_suffix(".exe").unwrap_or(name)
    }

    let process_name = process_name.to_lowercase();
    let app_name = app_name.to_lowercase();

    match app_match {
        AppMatch::Exact => strip_exe(&process_name) == strip_exe(&app_name),
        AppMatch::Contains => process_name.contains(&app_name),
    }
}

/// How this platform enumerates an application's processes.
///
/// Exactly one of these is compiled per target, so there is no runtime choice to
/// make: `sysinfo` everywhere except Windows, which uses a toolhelp snapshot.
#[cfg(target_os = "windows")]
type Pids = crate::platform::windows::cpu::ToolhelpPids;
#[cfg(not(target_os = "windows"))]
type Pids = SysinfoPids;

/// Process enumeration through `sysinfo`.
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Default)]
pub(crate) struct SysinfoPids {
    system: System,
}

#[cfg(not(target_os = "windows"))]
impl SysinfoPids {
    /// PIDs of every live process whose name matches `app_name`.
    fn matching_pids(&mut self, app_name: &str, app_match: AppMatch) -> Vec<u32> {
        self.system.refresh_processes(ProcessesToUpdate::All, true);

        self.system
            .processes()
            .iter()
            // Threads report the CPU time of their process, so counting them
            // would multiply the application's usage by its thread count.
            .filter(|(_, process)| process.thread_kind().is_none())
            .filter(|(_, process)| {
                matches_app_name(&process.name().to_string_lossy(), app_name, app_match)
            })
            .map(|(pid, _)| pid.as_u32())
            .collect()
    }
}

/// Tracks every process of an application.
///
/// Each backend plugs in its own per-process tracker, and this holds the
/// enumeration, caching and bookkeeping around it — the same for every platform.
pub(crate) struct AppMonitor {
    /// One tracker per live PID.
    process_trackers: HashMap<u32, Box<dyn ProcessCpuUtilization>>,
    /// Creates a tracker for a newly seen PID.
    tracker_factory: fn() -> Box<dyn ProcessCpuUtilization>,
    /// How processes are enumerated on this platform.
    pid_source: Pids,
    /// When the process list was last rescanned.
    last_pid_sweep: Option<Instant>,
    /// Result of that rescan.
    cached_pids: Vec<u32>,
    /// How long a rescan stays valid. `ZERO` rescans every sample.
    sweep_interval: Duration,
    /// How process names are compared against the application name.
    app_match: AppMatch,
}

impl AppMonitor {
    /// A tracker for every process of one application, building per-process
    /// trackers with `tracker_factory`.
    ///
    /// This is what every backend's `Platform::app_cpu_usage` returns; only the
    /// factory differs between them.
    pub(crate) fn boxed(
        tracker_factory: fn() -> Box<dyn ProcessCpuUtilization>,
        sweep_interval: Duration,
        app_match: AppMatch,
    ) -> Box<dyn AppCpuUtilization> {
        Box::new(Self {
            process_trackers: HashMap::new(),
            tracker_factory,
            pid_source: Pids::default(),
            last_pid_sweep: None,
            cached_pids: Vec::new(),
            sweep_interval,
            app_match,
        })
    }

    /// PIDs of every process matching `app_name`, rescanning at most once per
    /// sweep interval.
    fn pids_for(&mut self, app_name: &str) -> Vec<u32> {
        if !self.sweep_interval.is_zero()
            && let Some(last) = self.last_pid_sweep
            && last.elapsed() < self.sweep_interval
        {
            return self.cached_pids.clone();
        }

        let pids = self.pid_source.matching_pids(app_name, self.app_match);

        self.cached_pids = pids.clone();
        self.last_pid_sweep = Some(Instant::now());

        pids
    }

    /// Drop trackers for dead PIDs and create trackers for new ones.
    fn sync_trackers(&mut self, current_pids: &[u32]) {
        let live: HashSet<u32> = current_pids.iter().copied().collect();
        self.process_trackers.retain(|pid, _| live.contains(pid));

        for &pid in current_pids {
            self.process_trackers
                .entry(pid)
                .or_insert_with(self.tracker_factory);
        }
    }
}

impl AppCpuUtilization for AppMonitor {
    fn app_snapshot(&mut self, app_name: &str, cpu_total: Option<u64>) -> AppSample {
        let pids = self.pids_for(app_name);

        if pids.is_empty() {
            self.process_trackers.clear();
            return AppSample::default();
        }

        self.sync_trackers(&pids);

        let mut utilization = 0.0;
        for &pid in &pids {
            if let Some(tracker) = self.process_trackers.get_mut(&pid) {
                utilization += tracker.process_cpu_utilization(pid, cpu_total);
            }
        }

        AppSample { utilization, pids }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::sync::atomic::{AtomicUsize, Ordering};

    /// Successive fake counter readings, so the sampler can be driven without a
    /// real platform behind it.
    static READINGS: Mutex<Vec<Option<CpuTotals>>> = Mutex::new(Vec::new());
    static NEXT: AtomicUsize = AtomicUsize::new(0);

    fn scripted_read() -> Option<CpuTotals> {
        let readings = READINGS.lock().unwrap_or_else(|e| e.into_inner());
        let index = NEXT.fetch_add(1, Ordering::SeqCst);
        readings.get(index).copied().flatten()
    }

    fn script(readings: Vec<Option<CpuTotals>>) {
        *READINGS.lock().unwrap_or_else(|e| e.into_inner()) = readings;
        NEXT.store(0, Ordering::SeqCst);
    }

    // The sampler owns a `Mutex` of scripted state, so these three cases share
    // one test rather than racing each other across threads.
    #[test]
    fn totals_sampler_reports_usage_between_readings() {
        // Reading 0 is consumed by `new` as the baseline.
        script(vec![
            Some(CpuTotals::new(1_000, 800)),
            Some(CpuTotals::new(1_100, 850)),
            None,
            Some(CpuTotals::new(1_300, 900)),
        ]);

        let mut sampler = TotalsSampler::new(scripted_read);
        assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_000, 800)));

        // 100 ticks elapsed, 50 idle.
        assert_eq!(sampler.cpu_utilization(), 0.5);
        assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_100, 850)));

        // An unreadable sample reports 0% and must leave the baseline alone,
        // or the next call would measure against a stale snapshot.
        assert_eq!(sampler.cpu_utilization(), 0.0);
        assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_100, 850)));

        // 200 ticks elapsed since that preserved baseline, 50 idle.
        assert_eq!(sampler.cpu_utilization(), 0.75);
    }

    #[cfg(any(target_os = "linux", target_os = "windows"))]
    #[test]
    fn a_process_share_is_one_counter_delta_over_the_other() {
        let mut delta = CpuTimeDelta::default();

        // The first call only establishes the baseline.
        assert_eq!(delta.share(1_000, 0), 0.0);
        // The process used 25 of the 100 ticks the machine advanced by.
        assert_eq!(delta.share(1_100, 25), 0.25);
    }

    #[cfg(any(target_os = "linux", target_os = "windows"))]
    #[test]
    fn a_system_counter_that_does_not_advance_reports_no_usage() {
        // Two samples in the same tick would otherwise divide by zero.
        let mut delta = CpuTimeDelta::default();

        assert_eq!(delta.share(500, 0), 0.0);
        assert_eq!(delta.share(500, 25), 0.0);
    }

    #[cfg(any(target_os = "linux", target_os = "windows"))]
    #[test]
    fn counters_moving_backwards_do_not_underflow() {
        // A PID reused by a shorter-lived process can report less CPU time than
        // the one before it.
        let mut delta = CpuTimeDelta::default();

        assert_eq!(delta.share(1_000, 900), 0.0);
        assert_eq!(delta.share(1_100, 5), 0.0);
    }

    #[test]
    fn exact_match_ignores_case_and_exe_suffix() {
        assert!(matches_app_name("Firefox.exe", "firefox", AppMatch::Exact));
        assert!(matches_app_name("firefox", "Firefox.exe", AppMatch::Exact));
        assert!(!matches_app_name("firefox-bin", "firefox", AppMatch::Exact));
        assert!(!matches_app_name("codesign", "code", AppMatch::Exact));
    }

    #[test]
    fn contains_match_is_a_substring_test() {
        assert!(matches_app_name(
            "firefox-bin",
            "firefox",
            AppMatch::Contains
        ));
        assert!(matches_app_name("codesign", "code", AppMatch::Contains));
        assert!(!matches_app_name("bash", "firefox", AppMatch::Contains));
    }
}