use std::{
ffi::OsString,
fmt, fs, io,
num::NonZeroUsize,
ops::Range,
path::{self, PathBuf},
str::FromStr,
};
use clap::{ArgGroup, Parser, Subcommand};
use crate::{fan::Thresholds, probe::Temp};
pub(crate) type Percentage = u8;
#[derive(Clone, Debug)]
pub(crate) struct PwmSettings {
pub filepath: PathBuf,
pub thresholds: Thresholds,
}
impl FromStr for PwmSettings {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut tokens = s.rsplitn(3, ':');
let stop = tokens
.next()
.ok_or("Missing stop value")?
.parse()
.map_err(|_| "Invalid stop value")?;
let start = tokens
.next()
.ok_or("Missing start value")?
.parse()
.map_err(|_| "Invalid start value")?;
let filepath = tokens.next().ok_or("Missing filepath")?.into();
Ok(Self {
filepath,
thresholds: Thresholds {
min_start: start,
max_stop: stop,
},
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct HwmonSettings {
pub filepath: PathBuf,
pub temp: Option<Range<Temp>>,
}
impl FromStr for HwmonSettings {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut tokens = s.splitn(3, ':');
let filepath = tokens.next().ok_or("Missing filepath")?.into();
let start = tokens
.next()
.map(str::parse)
.transpose()
.map_err(|_| "Invalid min speed temp value")?;
let end = tokens
.next()
.map(str::parse)
.transpose()
.map_err(|_| "Invalid max speed temp value")?;
Ok(Self {
filepath,
temp: if let (Some(start), Some(end)) = (start, end) {
Some(Range { start, end })
} else {
None
},
})
}
}
fn percentage(s: &str) -> Result<u8, String> {
clap_num::number_range(s, 0, 100)
}
#[derive(Parser, Debug)]
#[command(version, about)]
pub(crate) struct Args {
#[arg(short, default_value_t = log::Level::Info)]
pub verbosity: log::Level,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Debug)]
pub(crate) enum DriveSelector {
Interface(String),
DrivePath(PathBuf),
}
impl DriveSelector {
pub(crate) fn to_drive_paths(&self) -> io::Result<Vec<PathBuf>> {
match self {
DriveSelector::Interface(itf) => {
let drives = fs::read_dir("/dev/disk/by-id")?.collect::<io::Result<Vec<_>>>()?;
let prefix = format!("{itf}-");
Ok(drives
.into_iter()
.map(|e| e.path())
.filter(|p| {
p.file_name().and_then(|f| f.to_str()).is_some_and(|f| {
f.starts_with(&prefix)
&& !f.trim_end_matches(char::is_numeric).ends_with("-part")
})
})
.collect())
}
DriveSelector::DrivePath(p) => Ok(vec![p.to_owned()]),
}
}
}
impl fmt::Display for DriveSelector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DriveSelector::Interface(itf) => write!(f, "{itf}"),
DriveSelector::DrivePath(p) => write!(f, "{p:?}"),
}
}
}
impl FromStr for DriveSelector {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.contains(path::MAIN_SEPARATOR) {
Ok(Self::DrivePath(s.into()))
} else {
Ok(Self::Interface(s.to_owned()))
}
}
}
#[derive(clap::Args, Debug)]
#[command(group(ArgGroup::new("fan_control").required(true).multiple(true).args(["pwm", "fan_cmd"])))]
pub(crate) struct DaemonArgs {
#[arg(short, long, num_args = 1.., required = true)]
pub drives: Vec<DriveSelector>,
#[arg(short, long, num_args = 1..)]
pub pwm: Vec<PwmSettings>,
#[arg(short, long, num_args = 1..)]
pub fan_cmd: Vec<OsString>,
#[arg(short = 't', long, value_name = "TEMP", num_args = 2, default_values_t = vec![30.0, 50.0])]
drive_temp_range: Vec<Temp>,
#[arg(short, long, default_value_t = 20, value_parser=percentage)]
pub min_fan_speed_prct: Percentage,
#[arg(short, long, default_value = "20s")]
pub interval: humantime::Duration,
#[arg(short, long, default_value_t = NonZeroUsize::MIN)]
pub average: NonZeroUsize,
#[arg(short = 'w', long)]
pub hwmons: Vec<HwmonSettings>,
#[arg(long, default_value_t = 7634)]
pub hddtemp_daemon_port: u16,
#[cfg(feature = "temp-log")]
#[arg(long)]
pub temp_log: Option<PathBuf>,
#[cfg(feature = "temp-log")]
#[arg(long)]
pub temp_log_max_files: Option<NonZeroUsize>,
#[arg(short, long)]
pub restore_fan_settings: bool,
}
impl DaemonArgs {
#[expect(clippy::indexing_slicing)]
pub(crate) fn drive_temp_range(&self) -> Range<Temp> {
Range {
start: self.drive_temp_range[0],
end: self.drive_temp_range[1],
}
}
}
#[expect(clippy::large_enum_variant)]
#[derive(Subcommand, Debug)]
pub(crate) enum Command {
Daemon(DaemonArgs),
PwmTest {
#[arg(short, long, num_args = 1.., required = true)]
pwm: Vec<PathBuf>,
},
#[cfg(feature = "generate-extras")]
GenManPages {
dir: PathBuf,
},
#[cfg(feature = "generate-extras")]
#[group(required = true, multiple = false)]
GenShellCompletions {
#[arg(short = 's', long, default_value = None)]
shell: Option<clap_complete::Shell>,
dir: Option<PathBuf>,
},
}
#[cfg(test)]
mod tests {
use clap::CommandFactory as _;
use super::*;
fn base_args() -> Vec<&'static str> {
vec![env!("CARGO_BIN_NAME"), "daemon", "-d", "/dev/sda"]
}
#[test]
fn daemon_requires_pwm_or_fan_cmd() {
let result = Args::try_parse_from(base_args());
assert!(result.is_err());
}
#[test]
fn daemon_accepts_pwm_only() {
let mut args = base_args();
args.extend(["-p", "/sys/class/hwmon/hwmon0/pwm1:200:75"]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_accepts_fan_cmd_only() {
let mut args = base_args();
args.extend(["-f", "/usr/bin/fan_set"]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_accepts_both_pwm_and_fan_cmd() {
let mut args = base_args();
args.extend([
"-p",
"/sys/class/hwmon/hwmon0/pwm1:200:75",
"-f",
"/usr/bin/fan_set",
]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_accepts_multiple_pwm() {
let mut args = base_args();
args.extend([
"-p",
"/sys/class/hwmon/hwmon0/pwm1:200:75",
"/sys/class/hwmon/hwmon0/pwm2:180:60",
]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_accepts_multiple_fan_cmd() {
let mut args = base_args();
args.extend(["-f", "/usr/bin/fan_set1", "/usr/bin/fan_set2"]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_accepts_multiple_pwm_and_multiple_fan_cmd() {
let mut args = base_args();
args.extend([
"-p",
"/sys/class/hwmon/hwmon0/pwm1:200:75",
"/sys/class/hwmon/hwmon0/pwm2:180:60",
"-f",
"/usr/bin/fan_set1",
"/usr/bin/fan_set2",
]);
let result = Args::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn daemon_default_average() {
let mut args = base_args();
args.extend(["-p", "/sys/class/hwmon/hwmon0/pwm1:200:75"]);
let parsed = Args::try_parse_from(args).unwrap();
match parsed.command {
Command::Daemon(daemon) => assert_eq!(daemon.average.get(), 1),
#[cfg(feature = "generate-extras")]
Command::GenManPages { .. } | Command::GenShellCompletions { .. } => {
panic!("expected Daemon")
}
Command::PwmTest { .. } => panic!("expected Daemon"),
}
}
#[test]
fn daemon_custom_average() {
let mut args = base_args();
args.extend(["-p", "/sys/class/hwmon/hwmon0/pwm1:200:75", "-a", "5"]);
let parsed = Args::try_parse_from(args).unwrap();
match parsed.command {
Command::Daemon(daemon) => assert_eq!(daemon.average.get(), 5),
#[cfg(feature = "generate-extras")]
Command::GenManPages { .. } | Command::GenShellCompletions { .. } => {
panic!("expected Daemon")
}
Command::PwmTest { .. } => panic!("expected Daemon"),
}
}
#[test]
fn daemon_rejects_average_zero() {
let mut args = base_args();
args.extend(["-p", "/sys/class/hwmon/hwmon0/pwm1:200:75", "-a", "0"]);
let result = Args::try_parse_from(args);
assert!(result.is_err());
}
#[cfg(feature = "generate-extras")]
#[test]
fn gen_shell_completions_single_shell() {
let result = Args::try_parse_from([
env!("CARGO_BIN_NAME"),
"gen-shell-completions",
"-s",
"bash",
]);
assert!(result.is_ok());
}
#[cfg(feature = "generate-extras")]
#[test]
fn gen_shell_completions_all_in_dir() {
let result =
Args::try_parse_from([env!("CARGO_BIN_NAME"), "gen-shell-completions", "/tmp"]);
assert!(result.is_ok());
}
#[cfg(feature = "generate-extras")]
#[test]
fn gen_shell_completions_rejects_shell_and_dir() {
let result = Args::try_parse_from([
env!("CARGO_BIN_NAME"),
"gen-shell-completions",
"-s",
"bash",
"/tmp",
]);
assert!(result.is_err());
}
#[cfg(feature = "generate-extras")]
#[test]
fn gen_shell_completions_rejects_no_args() {
let result = Args::try_parse_from([env!("CARGO_BIN_NAME"), "gen-shell-complete"]);
assert!(result.is_err());
}
#[test]
fn verify_cli() {
Args::command().debug_assert();
}
}