use super::config::{HealthDef, ServiceConfig, TargetConfig};
use super::signal;
pub fn validate_service(config: &ServiceConfig) -> Vec<String> {
let mut errors = Vec::new();
if config.service.name.is_empty() {
errors.push("service.name cannot be empty".to_string());
} else if !is_valid_name(&config.service.name) {
errors.push(format!(
"service.name '{}' contains invalid characters (use alphanumeric, dash, underscore)",
config.service.name
));
}
if config.service.exec.is_empty() {
errors.push("service.exec cannot be empty".to_string());
}
if config.lifecycle.restart_delay_ms == 0 {
errors.push("lifecycle.restart_delay_ms must be > 0".to_string());
}
if config.lifecycle.restart_delay_max_ms == 0 {
errors.push("lifecycle.restart_delay_max_ms must be > 0".to_string());
}
if config.lifecycle.restart_delay_max_ms < config.lifecycle.restart_delay_ms {
errors.push("lifecycle.restart_delay_max_ms must be >= restart_delay_ms".to_string());
}
if config.lifecycle.start_timeout_ms == 0 {
errors.push("lifecycle.start_timeout_ms must be > 0".to_string());
}
if config.lifecycle.stop_timeout_ms == 0 {
errors.push("lifecycle.stop_timeout_ms must be > 0".to_string());
}
if signal::parse(&config.lifecycle.stop_signal).is_none() {
errors.push(format!(
"lifecycle.stop_signal '{}' is not a valid signal",
config.lifecycle.stop_signal
));
}
if let Some(health) = &config.health {
let common = match health {
HealthDef::Tcp { common, .. } => common,
HealthDef::Http { common, .. } => common,
HealthDef::Exec { common, .. } => common,
};
if common.retries == 0 {
errors.push("health.retries must be > 0".to_string());
}
if common.interval_ms == 0 {
errors.push("health.interval_ms must be > 0".to_string());
}
if common.timeout_ms == 0 {
errors.push("health.timeout_ms must be > 0".to_string());
}
match health {
HealthDef::Tcp { target, .. } | HealthDef::Exec { target, .. } => {
if target.is_empty() {
errors.push("health.target cannot be empty".to_string());
}
}
HealthDef::Http {
target,
expect_status,
..
} => {
if target.is_empty() {
errors.push("health.target cannot be empty".to_string());
}
if *expect_status == 0 {
errors.push("health.expect_status must be > 0".to_string());
}
}
}
}
if config.logging.buffer_lines == 0 {
errors.push("logging.buffer_lines must be > 0".to_string());
}
for dep in &config.dependencies.requires {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.requires contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.after {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.after contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.wants {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.wants contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.conflicts {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.conflicts contains invalid name '{}'",
dep
));
}
}
errors
}
pub fn validate_target(config: &TargetConfig) -> Vec<String> {
let mut errors = Vec::new();
if config.target.name.is_empty() {
errors.push("target.name cannot be empty".to_string());
} else if !is_valid_name(&config.target.name) {
errors.push(format!(
"target.name '{}' contains invalid characters (use alphanumeric, dash, underscore)",
config.target.name
));
}
for dep in &config.dependencies.requires {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.requires contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.after {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.after contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.wants {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.wants contains invalid name '{}'",
dep
));
}
}
for dep in &config.dependencies.conflicts {
if !is_valid_name(dep) {
errors.push(format!(
"dependencies.conflicts contains invalid name '{}'",
dep
));
}
}
errors
}
fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdk::config::{
DependencyDef, HealthCommon, LifecycleDef, LoggingDef, ServiceDef, TargetDef,
};
use std::collections::HashMap;
fn minimal_service() -> ServiceConfig {
ServiceConfig {
service: ServiceDef {
name: "test".to_string(),
exec: "/bin/test".to_string(),
dir: None,
oneshot: false,
env: HashMap::new(),
status: crate::Status::default(),
class: crate::ServiceClass::default(),
critical: false,
},
dependencies: DependencyDef::default(),
lifecycle: LifecycleDef::default(),
health: None,
logging: LoggingDef::default(),
}
}
#[test]
fn test_valid_service() {
let config = minimal_service();
assert!(validate_service(&config).is_empty());
}
#[test]
fn test_empty_name() {
let mut config = minimal_service();
config.service.name = "".to_string();
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("name cannot be empty")));
}
#[test]
fn test_invalid_name_chars() {
let mut config = minimal_service();
config.service.name = "test service".to_string(); let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("invalid characters")));
}
#[test]
fn test_valid_name_chars() {
let mut config = minimal_service();
config.service.name = "my-service_v1.0".to_string();
assert!(validate_service(&config).is_empty());
}
#[test]
fn test_empty_exec() {
let mut config = minimal_service();
config.service.exec = "".to_string();
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("exec cannot be empty")));
}
#[test]
fn test_invalid_stop_signal() {
let mut config = minimal_service();
config.lifecycle.stop_signal = "INVALID".to_string();
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("not a valid signal")));
}
#[test]
fn test_zero_timeout() {
let mut config = minimal_service();
config.lifecycle.start_timeout_ms = 0;
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("start_timeout_ms")));
}
#[test]
fn test_health_check_validation() {
let mut config = minimal_service();
config.health = Some(HealthDef::Http {
target: "".to_string(),
expect_status: 0,
common: HealthCommon {
interval_ms: 0,
timeout_ms: 0,
retries: 0,
start_period_ms: 0,
},
});
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("health.retries")));
assert!(errors.iter().any(|e| e.contains("health.interval_ms")));
assert!(errors.iter().any(|e| e.contains("health.target")));
assert!(errors.iter().any(|e| e.contains("health.expect_status")));
}
#[test]
fn test_invalid_dependency_name() {
let mut config = minimal_service();
config.dependencies.requires = vec!["valid".to_string(), "in valid".to_string()];
let errors = validate_service(&config);
assert!(errors.iter().any(|e| e.contains("invalid name")));
}
#[test]
fn test_valid_target() {
let config = TargetConfig {
target: TargetDef {
name: "multi-user".to_string(),
critical: false,
},
dependencies: DependencyDef {
requires: vec!["network".to_string()],
..Default::default()
},
};
assert!(validate_target(&config).is_empty());
}
#[test]
fn test_invalid_target_name() {
let config = TargetConfig {
target: TargetDef {
name: "".to_string(),
critical: false,
},
dependencies: DependencyDef::default(),
};
let errors = validate_target(&config);
assert!(errors.iter().any(|e| e.contains("name cannot be empty")));
}
#[test]
fn test_is_valid_name() {
assert!(is_valid_name("test"));
assert!(is_valid_name("my-service"));
assert!(is_valid_name("my_service"));
assert!(is_valid_name("my.service"));
assert!(is_valid_name("service123"));
assert!(!is_valid_name(""));
assert!(!is_valid_name("my service"));
assert!(!is_valid_name("service@host"));
}
}