Developer Guide
This guide covers architecture, extending rouser, and coding standards for contributors.
Table of Contents
- Architecture Overview
- Project Structure
- Adding New Metrics Modules
- Implementing New Inhibitor Modules
- Testing
- Coding Standards
- Build and Release
Architecture Overview
High-Level Architecture
┌──────────────┐ ┌───────────┐ ┌───────────┐
│ Config │───▶│ Core │◀───│ Metrics │
│ Loader │ │ Logic │ │ Collectors│
└──────────────┘ └─────┬─────┘ └───────────┘
│
┌────▼────┐
│Threshold│
│Manager │
└────┬────┘
│
┌────▼────┐
│Inhibitor│
└────┬────┘
│
org.freedesktop.login1
Core Components
Config Loader (src/config.rs)
Parses and validates TOML configuration:
- Reads configuration from file
- Applies environment variable overrides
- Validates all values against constraints
- Returns immutable
Configstruct
pub struct Config {
pub daemon: DaemonConfig,
pub thresholds: ThresholdsConfig,
pub timing: TimingConfig,
pub inhibition: InhibitionConfig,
// ... other sections
}
Core Logic (src/service.rs)
Main event loop coordinating all components:
- Initializes metric collectors
- Runs periodic metric collection
- Checks thresholds and updates inhibition state
- Handles errors gracefully
pub struct Service {
config: Config,
cpu_collector: CpuCollector,
gpu_collector: GpuCollector,
network_collector: NetworkCollector,
disk_collector: DiskCollector,
threshold_manager: ThresholdManager,
inhibitor: Option<Login1Inhibitor>,
}
impl Service {
pub async fn run(&mut self) -> Result<()> {
loop {
let metrics = self.collect_all_metrics().await;
let should_inhibit = self.threshold_manager.check(&metrics);
self.update_inhibition_state(should_inhibit).await?;
}
}
}
Metric Collectors (src/metrics/)
Modular collectors for different system metrics:
cpu.rs- CPU usage from/proc/statgpu.rs- GPU utilization via NVML (NVIDIA) or sysfs (AMD/Intel)network.rs- Network I/O from/proc/net/devdisk.rs- Disk activity from/proc/diskstats
Each collector implements the MetricCollector trait:
pub trait MetricCollector: Send + Sync {
async fn collect(&self) -> Result<f64>;
fn name(&self) -> &str;
}
Threshold Manager (src/service.rs)
Tracks metrics over time and determines inhibition state:
- Maintains smoothing state for each metric
- Applies asymmetric EMA smoothing
- Tracks duration above/below thresholds
- Manages cooldown periods
pub struct ThresholdManager {
cpu_state: SmoothingState,
gpu_state: SmoothingState,
network_state: SmoothingState,
disk_state: SmoothingState,
// ... per-GPU states
duration_threshold: Duration,
cooldown_duration: Duration,
}
Inhibitor (src/inhibit.rs)
Interfaces with systemd login1 D-Bus API:
- Acquires sleep inhibition lock
- Releases lock when dropped (RAII pattern)
- Tracks inhibition cookie for debugging
pub struct Login1Inhibitor {
fd: Option<RawFd>,
cookie: Option<String>,
}
impl Login1Inhibitor {
pub async fn acquire(what: &str, mode: &str) -> Result<Self> {
// Call org.freedesktop.login1.Manager.Inhibit
}
}
impl Drop for Login1Inhibitor {
fn drop(&mut self) {
// Close fd to release inhibition
}
}
Project Structure
rouser/
├── Cargo.toml # Project manifest and dependencies
├── README.md # Project overview
├── docs/ # Documentation (this file)
│ ├── introduction.md
│ ├── installation.md
│ ├── configuration.md
│ ├── command-line.md
│ ├── systemd-user-service.md
│ ├── metrics-overview.md
│ ├── averaging.md
│ └── developer-guide.md
├── etc/ # Example configuration files
│ └── rouser/
│ └── config.toml.example
├── src/
│ ├── main.rs # Entry point
│ ├── config.rs # Configuration parsing
│ ├── service.rs # Core service logic
│ ├── inhibit.rs # D-Bus inhibition
│ └── metrics/ # Metric collectors
│ ├── mod.rs # Module exports
│ ├── cpu.rs # CPU metrics
│ ├── gpu.rs # GPU metrics
│ ├── network.rs # Network metrics
│ └── disk.rs # Disk metrics
└── tests/ # Integration tests (if any)
Key Files
| File | Purpose |
|---|---|
src/main.rs |
Application entry point, CLI parsing |
src/config.rs |
TOML configuration loading and validation |
src/service.rs |
Main service loop, threshold management |
src/inhibit.rs |
D-Bus inhibition implementation |
src/metrics/mod.rs |
Metric collector trait and exports |
src/metrics/*.rs |
Individual metric collectors |
Adding New Metrics Modules
Step 1: Create the Collector Module
Create a new file in src/metrics/:
// src/metrics/memory.rs
use crate::config::Config;
use anyhow::Result;
pub struct MemoryCollector {
// Configuration if needed
}
impl MemoryCollector {
pub fn new(_config: &Config) -> Self {
Self {}
}
}
#[async_trait::async_trait]
impl super::MetricCollector for MemoryCollector {
async fn collect(&self) -> Result<f64> {
// Read from /proc/meminfo or other source
let content = tokio::fs::read_to_string("/proc/meminfo").await?;
// Parse memory values
let total = parse_meminfo_value(&content, "MemTotal:");
let available = parse_meminfo_value(&content, "MemAvailable:");
// Calculate usage percentage
if total > 0 {
let used = total - available;
Ok((used as f64 / total as f64) * 100.0)
} else {
Ok(0.0)
}
}
fn name(&self) -> &str {
"memory"
}
}
fn parse_meminfo_value(content: &str, key: &str) -> u64 {
content
.lines()
.find(|line| line.starts_with(key))
.and_then(|line| {
line.split_whitespace()
.nth(1)
.and_then(|v| v.parse().ok())
})
.unwrap_or(0)
}
Step 2: Add to Module Exports
Update src/metrics/mod.rs:
pub mod cpu;
pub mod gpu;
pub mod network;
pub mod disk;
pub mod memory; // New module
pub use cpu::CpuCollector;
pub use gpu::GpuCollector;
pub use network::NetworkCollector;
pub use disk::DiskCollector;
pub use memory::MemoryCollector;
pub trait MetricCollector: Send + Sync {
async fn collect(&self) -> Result<f64>;
fn name(&self) -> &str;
}
Step 3: Update Configuration
Add threshold configuration in src/config.rs:
#[derive(Debug, Deserialize)]
pub struct ThresholdsConfig {
pub cpu_usage: f64,
pub gpu_usage: f64,
pub network_io: f64,
pub disk_activity: f64,
pub memory_usage: Option<f64>, // Optional new metric
}
Step 4: Update Core Service
Add collector to Service in src/service.rs:
pub struct Service {
// ... existing fields
memory_collector: Option<MemoryCollector>, // New field
}
impl Service {
pub fn new(config: Config) -> Result<Self> {
Ok(Self {
// ... existing initializers
memory_collector: config.thresholds.memory_usage.map(|_| {
MemoryCollector::new(&config)
}),
})
}
async fn collect_all_metrics(&self) -> Metrics {
let metrics = Metrics {
cpu: self.cpu_collector.collect().await,
gpu: self.gpu_collector.collect().await,
network: self.network_collector.collect().await,
disk: self.disk_collector.collect().await,
memory: self.memory_collector.as_ref()
.map(|c| futures::poll!(c.collect()))
.flatten()
.unwrap_or(0.0), // Fallback if collector unavailable
};
debug!("Collected metrics: {:?}", metrics);
metrics
}
}
Step 5: Update Threshold Manager
Add smoothing state for the new metric:
pub struct ThresholdManager {
// ... existing fields
memory_state: Option<SmoothingState>,
}
impl ThresholdManager {
pub fn new(config: &Config) -> Self {
Self {
// ... existing initializers
memory_state: config.thresholds.memory_usage.map(|_| {
SmoothingState::new(config.thresholds.memory_ema_alpha.unwrap_or(0.1))
}),
}
}
pub fn check(&self, config: &Config) -> bool {
// Check each metric against its threshold using smoothed values
let cpu_ok = self.check_metric(&self.cpu_state, metrics.cpu.usage(), config.metrics.cpu.threshold);
let gpu_ok = self.gpu_states.iter().all(|state| {
self.check_metric(state, /* GPU value */, config.metrics.gpu.threshold)
});
// ... similar for network and disk
cpu_ok || gpu_ok || /* others */ false
}
}
Implementing New Inhibitor Modules
Step 1: Define the Inhibitor Trait
Create a trait for inhibitor implementations:
// src/inhibit.rs
pub trait SleepInhibitor: Send + Sync {
/// Acquire the inhibition lock
async fn acquire(&mut self, what: &str, description: &str) -> Result<()>;
/// Release the inhibition lock
async fn release(&mut self);
/// Check if currently inhibited
fn is_inhibited(&self) -> bool;
}
Step 2: Implement a New Inhibitor
Example: Custom inhibitor for a specific use case:
// src/inhibit/custom.rs
use super::SleepInhibitor;
use anyhow::Result;
pub struct CustomInhibitor {
inhibited: bool,
cookie: Option<String>,
}
impl CustomInhibitor {
pub fn new() -> Self {
Self {
inhibited: false,
cookie: None,
}
}
}
impl Default for CustomInhibitor {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl SleepInhibitor for CustomInhibitor {
async fn acquire(&mut self, what: &str, description: &str) -> Result<()> {
// Custom inhibition logic here
// Could write to a file, make HTTP request, etc.
self.inhibited = true;
self.cookie = Some(format!("custom-{}", Uuid::new_v4()));
info!("Acquired custom inhibition: {} - {}", what, description);
Ok(())
}
async fn release(&mut self) {
if self.inhibited {
info!("Released custom inhibition: {}", self.cookie.as_ref().unwrap());
self.inhibited = false;
self.cookie = None;
}
}
fn is_inhibited(&self) -> bool {
self.inhibited
}
}
Step 3: Update Configuration
Add inhibitor selection to config:
#[derive(Debug, Deserialize)]
pub struct InhibitionConfig {
#[serde(default = "default_inhibitor_type")]
pub inhibitor_type: String, // "login1", "custom", etc.
#[serde(default = "default_what")]
pub what: String,
#[serde(default = "default_mode")]
pub mode: String,
}
fn default_inhibitor_type() -> String {
"login1".to_string()
}
Step 4: Factory Pattern for Inhibitors
Create a factory to instantiate inhibitors:
// src/inhibit.rs
pub enum InhibitorType {
Login1,
Custom,
}
impl SleepInhibitorFactory {
pub fn create(config: &InhibitionConfig) -> Result<Box<dyn SleepInhibitor>> {
match config.inhibitor_type.as_str() {
"login1" => Ok(Box::new(Login1Inhibitor::new())),
"custom" => Ok(Box::new(CustomInhibitor::new())),
other => Err(anyhow!("Unknown inhibitor type: {}", other)),
}
}
}
Testing
Unit Tests
Write unit tests for individual components:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ema_smoothing_rising() {
let mut state = SmoothingState::new(0.1);
// Rising edge should respond quickly
let result = state.update(20.0); // Initial
assert!((result - 20.0).abs() < 0.01);
let result = state.update(100.0); // Spike
assert!((result - 36.0).abs() < 0.1); // Should be ~36% (fast response)
}
#[test]
fn test_ema_smoothing_falling() {
let mut state = SmoothingState::new(0.1);
// Start at high value
for _ in 0..10 {
state.update(100.0);
}
// Falling edge should be slow
let result = state.update(20.0);
assert!(result > 90.0); // Should still be high
}
#[test]
fn test_threshold_manager_duration() {
let config = Config::default();
let mut manager = ThresholdManager::new(&config);
// Feed values above threshold
for _ in 0..6 { // 6 samples at 5s = 30s
manager.update_cpu(85.0); // Above 80% threshold
}
assert!(manager.should_inhibit());
}
}
Integration Tests
Test the full service loop:
#[cfg(test)]
mod integration_tests {
use tokio::time::{timeout, Duration};
#[tokio::test]
async fn test_service_runs() {
let config = Config {
daemon: DaemonConfig {
update_interval: Duration::from_millis(100), // Fast for testing
..Default::default()
},
metrics: MetricsConfig {
cpu: CpuConfig { threshold: 50.0, ..Default::default() },
gpu: GpuConfig::default(),
network: NetworkConfig::default(),
disk: DiskConfig::default(),
},
timing: TimingConfig {
duration_threshold: Duration::from_millis(200),
cooldown_duration: Duration::from_millis(100),
},
inhibition: InhibitionConfig {
what: "sleep".to_string(),
mode: "block".to_string(),
..Default::default()
},
..Default::default()
};
let mut service = Service::new(config).unwrap();
// Run for a short time with timeout
let result = timeout(
Duration::from_secs(5),
service.run()
).await;
assert!(result.is_ok());
}
}
Running Tests
# Run all tests
cargo test
# Run specific test
cargo test test_ema_smoothing_rising
# Run with output
cargo test -- --nocapture
# Run integration tests only
cargo test --test '*'
Coding Standards
Code Style
rouser follows Rust's official style guidelines with minor additions:
Formatting
# Format code
cargo fmt
# Check formatting
cargo fmt --check
Linting
# Run clippy
cargo clippy -- -D warnings
# Fix auto-fixable warnings
cargo clippy --fix
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Structs | PascalCase | ThresholdManager, CpuCollector |
| Types/Traits | PascalCase | MetricCollector, InhibitorType |
| Functions | snake_case | collect_cpu_usage, should_inhibit |
| Variables | snake_case | cpu_usage, duration_threshold |
| Constants | SCREAMING_SNAKE_CASE | DEFAULT_ALPHA, MAX_RETRIES |
| Modules | snake_case | threshold_manager, sleep_inhibitor |
Error Handling
Use anyhow::Result for error handling:
use anyhow::{Context, Result};
fn parse_config(path: &Path) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.context("Failed to parse TOML")?;
Ok(config)
}
Use thiserror for custom error types:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GpuError {
#[error("NVIDIA drivers not installed")]
NvidiaNotAvailable,
#[error("AMD/Intel sysfs not available")]
SysfsNotAvailable,
#[error("Failed to parse GPU usage: {0}")]
ParseError(String),
}
Async/Await
Use async/await for I/O operations:
// Good: Proper async/await usage
pub async fn collect_metrics(&self) -> Result<f64> {
let content = tokio::fs::read_to_string("/proc/stat").await?;
Ok(parse_cpu_usage(&content))
}
// Bad: Blocking in async context
pub async fn collect_metrics_bad(&self) -> Result<f64> {
let content = std::fs::read_to_string("/proc/stat")?; // Blocks!
Ok(parse_cpu_usage(&content))
}
Use tokio::spawn for parallel operations:
let (cpu, gpu) = tokio::join!(
cpu_collector.collect(),
gpu_collector.collect()
);
Logging
Use the tracing crate for logging:
use tracing::{debug, info, warn, error};
pub fn process_metrics(metrics: &Metrics) {
debug!("Collected metrics: {:?}", metrics);
if metrics.cpu > 90.0 {
warn!("High CPU usage: {:.1}%", metrics.cpu);
}
if should_inhibit {
info!("Inhibiting sleep due to high activity");
}
if error_occurred {
error!("Failed to collect GPU metrics: {}", error);
}
}
Documentation
Document public APIs:
/// CPU usage collector from /proc/stat
pub struct CpuCollector {
last_stats: Option<CpuStats>,
}
impl CpuCollector {
/// Create a new CPU collector
pub fn new() -> Self {
Self { last_stats: None }
}
/// Collect current CPU usage percentage (0-100)
///
/// # Returns
///
/// CPU usage as a percentage (0.0 to 100.0)
pub async fn collect(&self) -> Result<f64> {
// ... implementation
}
}
Tests
Write tests for all public functions:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_input() {
// Test happy path
}
#[test]
fn test_edge_cases() {
// Test boundary values
}
#[test]
fn test_error_handling() {
// Test error cases
}
}
Build and Release
Building
# Debug build (default)
cargo build
# Release build
cargo build --release
# Cross-compile for Linux musl
rustup target add x86_64-unknown-linux-musl
cargo build --target x86_64-unknown-linux-musl --release
Running
# Run with default config
cargo run
# Run with custom config
cargo run -- --config /path/to/config.toml
# Dry run mode
cargo run -- --dry-run --duration 60s
# Validate config
cargo run -- --validate-config /etc/rouser/config.toml
Testing
# Run all tests
cargo test
# Run with coverage (requires cargo-tarpaulin)
cargo tarpaulin --out Html
# Integration tests
cargo test --test integration
Code Quality
# Format code
cargo fmt
# Lint with clippy
cargo clippy -- -D warnings
# Check documentation
cargo doc --open
Releasing
- Update version in
Cargo.toml - Update
CHANGELOG.mdwith changes - Tag release:
git tag v0.1.0 && git push --tags - Build release binary:
bash cargo build --release - Create tarball:
bash tar -czvf rouser-0.1.0-x86_64-unknown-linux-gnu.tar.gz \ target/release/rouser README.md LICENSE - Upload to GitHub releases
Contributing
Getting Started
- Fork the repository
- Clone locally:
git clone https://github.com/owaindjones/rouser.git - Create a branch:
git checkout -b feature/your-feature - Make changes and write tests
- Run
cargo test && cargo fmt && cargo clippy -- -D warnings - Commit and push:
git push origin feature/your-feature - Open a pull request
Pull Request Guidelines
- Follow the coding standards above
- Include tests for new functionality
- Update documentation as needed
- Keep PRs focused and small
- Write clear commit messages
See Also
- Installation - Getting started with rouser
- Configuration Reference - Configuration options
- Metrics Overview - How metrics are collected