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
 */

//! Configuration for a monitoring session.
//!
//! [`MonitorConfig`] describes **what to measure**: the target, the component,
//! and how power is attributed. It is plain data — start from
//! [`MonitorConfig::default`], set the fields you care about, then hand it to
//! [`crate::monitor::JoularCoreMonitor::from_config`].
//!
//! ```
//! use joularcore::config::{MonitorConfig, Target};
//! use std::time::Duration;
//!
//! let config = MonitorConfig {
//!     target: Target::App("firefox".to_string()),
//!     app_refresh_interval: Duration::from_secs(5),
//!     ..Default::default()
//! };
//! ```
//!
//! Where samples *go* is configured separately, on
//! [`crate::output::OutputBundle`], and where they are *read from* is chosen by
//! the sensors you hand to the monitor — see the `vm` module, under the `vm`
//! feature, for reading power from a file instead of a hardware sensor.

use std::time::Duration;

/// A hardware component that can be measured on its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Component {
    /// The CPU package.
    Cpu,
    /// The GPU.
    Gpu,
}

/// What a monitoring session attributes power to, on top of whole-system power.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Target {
    /// Whole-system power only; no per-process attribution.
    #[default]
    System,
    /// A single process, identified by its PID.
    Pid(u32),
    /// Every process of an application, identified by its executable name.
    App(String),
}

impl Target {
    /// Create a target for an application by executable name.
    #[must_use]
    pub fn app(name: impl Into<String>) -> Self {
        Self::App(name.into())
    }

    /// Create a target for a process ID.
    #[must_use]
    pub fn pid(pid: u32) -> Self {
        Self::Pid(pid)
    }
}

/// How far Joular Core may go to obtain the privileges a sensor needs.
///
/// Only macOS acts on this today: `powermetrics` must run as root. Every other
/// backend ignores it.
///
/// Neither policy ever prompts for a password. A library that pops a password
/// dialog behind its caller's back is a surprise the caller cannot intercept,
/// so a program that needs elevation either runs elevated itself, or caches a
/// `sudo` credential first and then uses [`ElevationPolicy::SudoNonInteractive`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ElevationPolicy {
    /// Never elevate. If the sensor needs root and the process is not root,
    /// the sensor reports [`crate::Error::PermissionDenied`] on every read.
    ///
    /// This is the default.
    #[default]
    Never,
    /// Use `sudo -n`, which succeeds only if a sudo credential is already
    /// cached. Never prompts, never blocks.
    SudoNonInteractive,
}

/// How an application name is matched against running processes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AppMatch {
    /// The executable name must match exactly, ignoring case and a trailing
    /// `.exe` on Windows. The default.
    #[default]
    Exact,
    /// The executable name must contain the given string, ignoring case.
    ///
    /// Convenient for helper processes (`firefox` also matching
    /// `firefox-bin`), but it over-matches: `code` also matches
    /// `codesign`.
    Contains,
}

/// Everything a monitoring session needs to know.
///
/// Plain data: start from [`MonitorConfig::default`] and override the fields you
/// care about.
///
/// ```
/// use joularcore::{MonitorConfig, Target};
///
/// let config = MonitorConfig {
///     target: Target::Pid(42),
///     ..Default::default()
/// };
/// ```
#[derive(Clone, Debug)]
pub struct MonitorConfig {
    /// What to attribute power to. Defaults to [`Target::System`].
    pub target: Target,
    /// Restrict measurement to a single component. `None` measures both.
    ///
    /// Skipping a component also skips its sensor read, which avoids the cost
    /// of running `nvidia-smi` or the `powermetrics` GPU sampler.
    pub component: Option<Component>,
    /// How an application name is matched against running processes.
    pub app_match: AppMatch,
    /// How often to rescan for the processes of a [`Target::App`].
    ///
    /// `Duration::ZERO` rescans on every sample. Defaults to 3 seconds.
    pub app_refresh_interval: Duration,
    /// Idle CPU power removed before per-process attribution, in watts.
    /// `None` attributes from raw CPU power.
    ///
    /// To measure the floor rather than name it, call
    /// [`crate::monitor::JoularCoreMonitor::calibrate_cpu_idle_baseline`] on a
    /// built monitor. That blocks for as long as you ask it to, which is why it
    /// is an explicit call and not a setting: building a monitor never sleeps.
    pub cpu_idle_baseline: Option<f64>,
    /// How far to go to obtain privileged sensor access.
    pub elevation: ElevationPolicy,
}

/// The default application PID rescan interval.
pub const DEFAULT_APP_REFRESH_INTERVAL: Duration = Duration::from_secs(3);

impl Default for MonitorConfig {
    fn default() -> Self {
        Self {
            target: Target::default(),
            component: None,
            app_match: AppMatch::default(),
            app_refresh_interval: DEFAULT_APP_REFRESH_INTERVAL,
            cpu_idle_baseline: None,
            elevation: ElevationPolicy::default(),
        }
    }
}

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

    #[test]
    fn the_defaults_measure_the_whole_system_without_elevating() {
        // These are what a caller gets by writing `..Default::default()`, so
        // they are part of the contract rather than an implementation detail.
        let config = MonitorConfig::default();

        assert_eq!(config.target, Target::System);
        assert_eq!(config.component, None);
        assert_eq!(config.app_match, AppMatch::Exact);
        assert_eq!(config.app_refresh_interval, DEFAULT_APP_REFRESH_INTERVAL);
        assert_eq!(config.cpu_idle_baseline, None);
        assert_eq!(config.elevation, ElevationPolicy::Never);
    }

    #[test]
    fn target_helpers_create_expected_variants() {
        assert_eq!(Target::app("firefox"), Target::App("firefox".into()));
        assert_eq!(Target::pid(1234), Target::Pid(1234));
    }
}