281 lines
8.5 KiB
Rust
281 lines
8.5 KiB
Rust
//! Configuration validation
|
|
|
|
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow};
|
|
use std::collections::HashSet;
|
|
use thiserror::Error;
|
|
|
|
/// Validation error
|
|
#[derive(Debug, Clone, Error)]
|
|
pub enum ValidationError {
|
|
#[error("Entry '{entry_id}': {message}")]
|
|
EntryError { entry_id: String, message: String },
|
|
|
|
#[error("Duplicate entry ID: {0}")]
|
|
DuplicateEntryId(String),
|
|
|
|
#[error("Invalid time format '{value}': {message}")]
|
|
InvalidTimeFormat { value: String, message: String },
|
|
|
|
#[error("Invalid day specification: {0}")]
|
|
InvalidDaySpec(String),
|
|
|
|
#[error("Warning threshold {seconds}s >= max_run {max_run}s for entry '{entry_id}'")]
|
|
WarningExceedsMaxRun {
|
|
entry_id: String,
|
|
seconds: u64,
|
|
max_run: u64,
|
|
},
|
|
|
|
#[error("Global config error: {0}")]
|
|
GlobalError(String),
|
|
}
|
|
|
|
/// Validate a raw configuration
|
|
pub fn validate_config(config: &RawConfig) -> Vec<ValidationError> {
|
|
let mut errors = Vec::new();
|
|
|
|
// Check for duplicate entry IDs
|
|
let mut seen_ids = HashSet::new();
|
|
for entry in &config.entries {
|
|
if !seen_ids.insert(&entry.id) {
|
|
errors.push(ValidationError::DuplicateEntryId(entry.id.clone()));
|
|
}
|
|
}
|
|
|
|
// Validate each entry
|
|
for entry in &config.entries {
|
|
errors.extend(validate_entry(entry, config));
|
|
}
|
|
|
|
errors
|
|
}
|
|
|
|
fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError> {
|
|
let mut errors = Vec::new();
|
|
|
|
// Validate kind
|
|
match &entry.kind {
|
|
RawEntryKind::Process { argv, .. } => {
|
|
if argv.is_empty() {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry.id.clone(),
|
|
message: "argv cannot be empty".into(),
|
|
});
|
|
}
|
|
}
|
|
RawEntryKind::Snap { snap_name, .. } => {
|
|
if snap_name.is_empty() {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry.id.clone(),
|
|
message: "snap_name cannot be empty".into(),
|
|
});
|
|
}
|
|
}
|
|
RawEntryKind::Vm { driver, .. } => {
|
|
if driver.is_empty() {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry.id.clone(),
|
|
message: "VM driver cannot be empty".into(),
|
|
});
|
|
}
|
|
}
|
|
RawEntryKind::Media { library_id, .. } => {
|
|
if library_id.is_empty() {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry.id.clone(),
|
|
message: "library_id cannot be empty".into(),
|
|
});
|
|
}
|
|
}
|
|
RawEntryKind::Custom { type_name, .. } => {
|
|
if type_name.is_empty() {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry.id.clone(),
|
|
message: "type_name cannot be empty".into(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate availability windows
|
|
if let Some(avail) = &entry.availability {
|
|
for window in &avail.windows {
|
|
errors.extend(validate_time_window(window, &entry.id));
|
|
}
|
|
}
|
|
|
|
// Validate warning thresholds vs max_run
|
|
let max_run = entry
|
|
.limits
|
|
.as_ref()
|
|
.and_then(|l| l.max_run_seconds)
|
|
.or(config.daemon.default_max_run_seconds);
|
|
|
|
if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) {
|
|
for warning in warnings {
|
|
if warning.seconds_before >= max_run {
|
|
errors.push(ValidationError::WarningExceedsMaxRun {
|
|
entry_id: entry.id.clone(),
|
|
seconds: warning.seconds_before,
|
|
max_run,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
errors
|
|
}
|
|
|
|
fn validate_time_window(window: &RawTimeWindow, entry_id: &str) -> Vec<ValidationError> {
|
|
let mut errors = Vec::new();
|
|
|
|
// Validate days
|
|
if let Err(e) = parse_days(&window.days) {
|
|
errors.push(ValidationError::EntryError {
|
|
entry_id: entry_id.to_string(),
|
|
message: e,
|
|
});
|
|
}
|
|
|
|
// Validate start time
|
|
if let Err(e) = parse_time(&window.start) {
|
|
errors.push(ValidationError::InvalidTimeFormat {
|
|
value: window.start.clone(),
|
|
message: e,
|
|
});
|
|
}
|
|
|
|
// Validate end time
|
|
if let Err(e) = parse_time(&window.end) {
|
|
errors.push(ValidationError::InvalidTimeFormat {
|
|
value: window.end.clone(),
|
|
message: e,
|
|
});
|
|
}
|
|
|
|
errors
|
|
}
|
|
|
|
/// Parse HH:MM time format
|
|
pub fn parse_time(s: &str) -> Result<(u8, u8), String> {
|
|
let parts: Vec<&str> = s.split(':').collect();
|
|
if parts.len() != 2 {
|
|
return Err("Expected HH:MM format".into());
|
|
}
|
|
|
|
let hour: u8 = parts[0]
|
|
.parse()
|
|
.map_err(|_| "Invalid hour".to_string())?;
|
|
let minute: u8 = parts[1]
|
|
.parse()
|
|
.map_err(|_| "Invalid minute".to_string())?;
|
|
|
|
if hour >= 24 {
|
|
return Err("Hour must be 0-23".into());
|
|
}
|
|
if minute >= 60 {
|
|
return Err("Minute must be 0-59".into());
|
|
}
|
|
|
|
Ok((hour, minute))
|
|
}
|
|
|
|
/// Parse days specification
|
|
pub fn parse_days(days: &RawDays) -> Result<u8, String> {
|
|
match days {
|
|
RawDays::Preset(preset) => match preset.to_lowercase().as_str() {
|
|
"all" | "every" | "daily" => Ok(0x7F),
|
|
"weekdays" => Ok(0x1F), // Mon-Fri
|
|
"weekends" => Ok(0x60), // Sat-Sun
|
|
other => Err(format!("Unknown day preset: {}", other)),
|
|
},
|
|
RawDays::List(list) => {
|
|
let mut mask = 0u8;
|
|
for day in list {
|
|
let bit = match day.to_lowercase().as_str() {
|
|
"mon" | "monday" => 1 << 0,
|
|
"tue" | "tuesday" => 1 << 1,
|
|
"wed" | "wednesday" => 1 << 2,
|
|
"thu" | "thursday" => 1 << 3,
|
|
"fri" | "friday" => 1 << 4,
|
|
"sat" | "saturday" => 1 << 5,
|
|
"sun" | "sunday" => 1 << 6,
|
|
other => return Err(format!("Unknown day: {}", other)),
|
|
};
|
|
mask |= bit;
|
|
}
|
|
Ok(mask)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_time() {
|
|
assert_eq!(parse_time("14:30").unwrap(), (14, 30));
|
|
assert_eq!(parse_time("00:00").unwrap(), (0, 0));
|
|
assert_eq!(parse_time("23:59").unwrap(), (23, 59));
|
|
|
|
assert!(parse_time("24:00").is_err());
|
|
assert!(parse_time("12:60").is_err());
|
|
assert!(parse_time("invalid").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_days() {
|
|
assert_eq!(parse_days(&RawDays::Preset("weekdays".into())).unwrap(), 0x1F);
|
|
assert_eq!(parse_days(&RawDays::Preset("weekends".into())).unwrap(), 0x60);
|
|
assert_eq!(parse_days(&RawDays::Preset("all".into())).unwrap(), 0x7F);
|
|
|
|
assert_eq!(
|
|
parse_days(&RawDays::List(vec!["mon".into(), "wed".into(), "fri".into()])).unwrap(),
|
|
0b10101
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_duplicate_id_detection() {
|
|
let config = RawConfig {
|
|
config_version: 1,
|
|
daemon: Default::default(),
|
|
entries: vec![
|
|
RawEntry {
|
|
id: "game".into(),
|
|
label: "Game 1".into(),
|
|
icon: None,
|
|
kind: RawEntryKind::Process {
|
|
argv: vec!["game1".into()],
|
|
env: Default::default(),
|
|
cwd: None,
|
|
},
|
|
availability: None,
|
|
limits: None,
|
|
warnings: None,
|
|
disabled: false,
|
|
disabled_reason: None,
|
|
},
|
|
RawEntry {
|
|
id: "game".into(),
|
|
label: "Game 2".into(),
|
|
icon: None,
|
|
kind: RawEntryKind::Process {
|
|
argv: vec!["game2".into()],
|
|
env: Default::default(),
|
|
cwd: None,
|
|
},
|
|
availability: None,
|
|
limits: None,
|
|
warnings: None,
|
|
disabled: false,
|
|
disabled_reason: None,
|
|
},
|
|
],
|
|
};
|
|
|
|
let errors = validate_config(&config);
|
|
assert!(errors.iter().any(|e| matches!(e, ValidationError::DuplicateEntryId(_))));
|
|
}
|
|
}
|