use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub encryption: EncryptionConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub audit: crate::audit::AuditConfig,
#[serde(default)]
pub optimizer: OptimizerConfig,
#[serde(default)]
pub authentication: AuthenticationConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub materialized_views: MaterializedViewConfig,
#[serde(default)]
pub vector: VectorConfig,
#[serde(default)]
pub sync: SyncConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub locks: LockConfig,
#[serde(default)]
pub dump: DumpConfig,
#[serde(default)]
pub resource_quotas: ResourceQuotaConfig,
#[serde(default)]
pub api: ApiConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
storage: StorageConfig::default(),
encryption: EncryptionConfig::default(),
server: ServerConfig::default(),
performance: PerformanceConfig::default(),
audit: crate::audit::AuditConfig::default(),
optimizer: OptimizerConfig::default(),
authentication: AuthenticationConfig::default(),
compression: CompressionConfig::default(),
materialized_views: MaterializedViewConfig::default(),
vector: VectorConfig::default(),
sync: SyncConfig::default(),
session: SessionConfig::default(),
locks: LockConfig::default(),
dump: DumpConfig::default(),
resource_quotas: ResourceQuotaConfig::default(),
api: ApiConfig::default(),
}
}
}
impl Config {
pub fn in_memory() -> Self {
Self {
storage: StorageConfig {
path: None,
memory_only: true,
wal_enabled: false, ..Default::default()
},
audit: crate::audit::AuditConfig::default(),
..Default::default()
}
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)
.map_err(|e| crate::Error::config(format!("Failed to parse config: {}", e)))?;
Ok(config)
}
pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> crate::Result<()> {
let content = toml::to_string_pretty(self)
.map_err(|e| crate::Error::config(format!("Failed to serialize config: {}", e)))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn validate(&self) -> crate::Result<()> {
self.session.validate()?;
self.locks.validate()?;
self.dump.validate()?;
self.resource_quotas.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub path: Option<PathBuf>,
pub memory_only: bool,
pub wal_enabled: bool,
pub wal_sync_mode: WalSyncModeConfig,
pub cache_size: usize,
pub compression: CompressionType,
pub time_travel_enabled: bool,
pub query_timeout_ms: Option<u64>,
pub statement_timeout_ms: Option<u64>,
pub transaction_isolation: TransactionIsolation,
#[serde(default = "default_slow_query_threshold")]
pub slow_query_threshold_ms: Option<u64>,
}
fn default_slow_query_threshold() -> Option<u64> {
Some(1000)
}
fn default_idle_timeout_secs() -> u64 {
300 }
impl Default for StorageConfig {
fn default() -> Self {
Self {
path: Some(PathBuf::from("./heliosdb-data")),
memory_only: false,
wal_enabled: true,
wal_sync_mode: WalSyncModeConfig::Sync, cache_size: 512 * 1024 * 1024, compression: CompressionType::Zstd,
time_travel_enabled: true, query_timeout_ms: None, statement_timeout_ms: None, transaction_isolation: TransactionIsolation::ReadCommitted, slow_query_threshold_ms: Some(1000), }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WalSyncModeConfig {
Sync,
Async,
GroupCommit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompressionType {
None,
Zstd,
Lz4,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TransactionIsolation {
ReadUncommitted,
ReadCommitted,
RepeatableRead,
Serializable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EncryptionConfig {
pub enabled: bool,
pub algorithm: EncryptionAlgorithm,
pub key_source: KeySource,
pub rotation_interval_days: u32,
#[serde(default)]
pub zke: ZkeEncryptionConfig,
}
impl Default for EncryptionConfig {
fn default() -> Self {
Self {
enabled: false,
algorithm: EncryptionAlgorithm::Aes256Gcm,
key_source: KeySource::Environment("HELIOSDB_ENCRYPTION_KEY".to_string()),
rotation_interval_days: 90,
zke: ZkeEncryptionConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZkeEncryptionConfig {
pub enabled: bool,
pub mode: ZkeMode,
pub require_key_hash: bool,
pub replay_protection: bool,
pub nonce_window_secs: u64,
pub max_cached_nonces: usize,
}
impl Default for ZkeEncryptionConfig {
fn default() -> Self {
Self {
enabled: false,
mode: ZkeMode::PerRequest,
require_key_hash: true,
replay_protection: true,
nonce_window_secs: 300,
max_cached_nonces: 10000,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZkeMode {
Full,
Hybrid,
PerRequest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EncryptionAlgorithm {
Aes256Gcm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeySource {
Environment(String),
File(PathBuf),
Kms {
provider: String,
key_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub listen_addr: String,
pub port: u16,
pub oracle_port: Option<u16>,
pub max_connections: usize,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
pub tls_enabled: bool,
pub tls_cert_path: Option<PathBuf>,
pub tls_key_path: Option<PathBuf>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
listen_addr: "127.0.0.1".to_string(),
port: 5432,
oracle_port: Some(1521), max_connections: 100,
idle_timeout_secs: default_idle_timeout_secs(),
tls_enabled: false,
tls_cert_path: None,
tls_key_path: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PerformanceConfig {
pub worker_threads: usize,
pub query_timeout_secs: u64,
pub simd_enabled: bool,
pub parallel_query: bool,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
worker_threads: num_cpus::get(),
query_timeout_secs: 300,
simd_enabled: true,
parallel_query: true,
}
}
}
mod num_cpus {
pub fn get() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OptimizerConfig {
pub enabled: bool,
pub enable_seqscan: bool,
pub enable_indexscan: bool,
pub enable_hashjoin: bool,
pub enable_mergejoin: bool,
pub enable_nestloop: bool,
pub seq_page_cost: f64,
pub random_page_cost: f64,
pub cpu_tuple_cost: f64,
pub cpu_index_tuple_cost: f64,
}
impl Default for OptimizerConfig {
fn default() -> Self {
Self {
enabled: true,
enable_seqscan: true,
enable_indexscan: true,
enable_hashjoin: true,
enable_mergejoin: true,
enable_nestloop: true,
seq_page_cost: 1.0,
random_page_cost: 4.0,
cpu_tuple_cost: 0.01,
cpu_index_tuple_cost: 0.005,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthenticationConfig {
pub enabled: bool,
pub method: AuthMethod,
pub jwt_secret: Option<String>,
pub jwt_expiration_secs: u64,
pub password_hash_algorithm: PasswordHashAlgorithm,
pub users_file: Option<PathBuf>,
}
impl Default for AuthenticationConfig {
fn default() -> Self {
Self {
enabled: false,
method: AuthMethod::Trust,
jwt_secret: None,
jwt_expiration_secs: 86400, password_hash_algorithm: PasswordHashAlgorithm::Argon2,
users_file: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Trust,
Password,
Jwt,
Ldap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PasswordHashAlgorithm {
Argon2,
Bcrypt,
Pbkdf2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CompressionConfig {
pub default_type: CompressionType,
pub level: i32,
pub enable_alp: bool,
pub enable_fsst: bool,
pub min_size_bytes: usize,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
default_type: CompressionType::Zstd,
level: 3,
enable_alp: true,
enable_fsst: true,
min_size_bytes: 1024, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MaterializedViewConfig {
pub auto_refresh_default: bool,
pub default_max_cpu_percent: u8,
pub refresh_check_interval_secs: u64,
pub max_concurrent_refreshes: usize,
pub enable_incremental: bool,
pub enable_delta_tracking: bool,
pub enable_scheduler: bool,
pub delta_retention_hours: u64,
}
impl Default for MaterializedViewConfig {
fn default() -> Self {
Self {
auto_refresh_default: false,
default_max_cpu_percent: 15,
refresh_check_interval_secs: 60,
max_concurrent_refreshes: 2,
enable_incremental: false, enable_delta_tracking: false, enable_scheduler: false, delta_retention_hours: 168, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VectorConfig {
pub default_index_type: VectorIndexType,
pub hnsw_ef_construction: usize,
pub hnsw_m: usize,
pub enable_pq: bool,
pub pq_subvectors: usize,
pub pq_bits: usize,
}
impl Default for VectorConfig {
fn default() -> Self {
Self {
default_index_type: VectorIndexType::Hnsw,
hnsw_ef_construction: 200,
hnsw_m: 16,
enable_pq: true,
pq_subvectors: 8,
pq_bits: 8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VectorIndexType {
Flat,
Hnsw,
Ivf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SyncConfig {
pub enabled: bool,
pub node_id: Option<String>,
pub server_url: Option<String>,
pub client_id: Option<String>,
pub sync_interval_secs: u64,
pub change_log_enabled: bool,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
enabled: false,
node_id: None,
server_url: None,
client_id: None,
sync_interval_secs: 30,
change_log_enabled: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
pub timeout_secs: u64,
pub max_sessions_per_user: u32,
pub cleanup_interval_secs: u64,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
timeout_secs: 3600,
max_sessions_per_user: 10,
cleanup_interval_secs: 300,
}
}
}
impl SessionConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.timeout_secs < 1 {
return Err(crate::Error::config(
"session.timeout_secs must be at least 1 second",
));
}
if self.max_sessions_per_user < 1 {
return Err(crate::Error::config(
"session.max_sessions_per_user must be at least 1",
));
}
if self.cleanup_interval_secs < 1 {
return Err(crate::Error::config(
"session.cleanup_interval_secs must be at least 1 second",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LockConfig {
pub timeout_ms: u32,
pub deadlock_check_interval_ms: u32,
pub max_lock_holders: u32,
}
impl Default for LockConfig {
fn default() -> Self {
Self {
timeout_ms: 30000,
deadlock_check_interval_ms: 100,
max_lock_holders: 10000,
}
}
}
impl LockConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.timeout_ms < 100 {
return Err(crate::Error::config(
"locks.timeout_ms must be at least 100 milliseconds",
));
}
if self.deadlock_check_interval_ms < 10 {
return Err(crate::Error::config(
"locks.deadlock_check_interval_ms must be at least 10 milliseconds",
));
}
if self.max_lock_holders < 1 {
return Err(crate::Error::config(
"locks.max_lock_holders must be at least 1",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DumpConfig {
pub auto_dump_enabled: bool,
pub schedule: String,
pub compression: String,
pub max_dump_size_mb: u64,
pub keep_dumps: usize,
pub dump_dir: String,
}
impl Default for DumpConfig {
fn default() -> Self {
Self {
auto_dump_enabled: false,
schedule: String::new(),
compression: "zstd".to_string(),
max_dump_size_mb: 10000,
keep_dumps: 10,
dump_dir: ".dumps".to_string(),
}
}
}
impl DumpConfig {
pub fn validate(&self) -> crate::Result<()> {
match self.compression.as_str() {
"zstd" | "gzip" | "none" => {}
_ => {
return Err(crate::Error::config(format!(
"dump.compression must be 'zstd', 'gzip', or 'none', got '{}'",
self.compression
)));
}
}
if self.max_dump_size_mb < 1 {
return Err(crate::Error::config(
"dump.max_dump_size_mb must be at least 1 MB",
));
}
if self.auto_dump_enabled && !self.schedule.is_empty() {
Self::validate_cron_schedule(&self.schedule)?;
}
Ok(())
}
fn validate_cron_schedule(schedule: &str) -> crate::Result<()> {
let parts: Vec<&str> = schedule.split_whitespace().collect();
if parts.len() != 5 {
return Err(crate::Error::config(format!(
"Invalid cron schedule format '{}'. Expected 5 fields: minute hour day month weekday",
schedule
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ResourceQuotaConfig {
pub memory_limit_per_user_mb: u64,
pub max_concurrent_queries: u32,
pub query_timeout_secs: u64,
}
impl Default for ResourceQuotaConfig {
fn default() -> Self {
Self {
memory_limit_per_user_mb: 1024,
max_concurrent_queries: 100,
query_timeout_secs: 300,
}
}
}
impl ResourceQuotaConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.memory_limit_per_user_mb < 1 {
return Err(crate::Error::config(
"resource_quotas.memory_limit_per_user_mb must be at least 1 MB",
));
}
if self.max_concurrent_queries < 1 {
return Err(crate::Error::config(
"resource_quotas.max_concurrent_queries must be at least 1",
));
}
if self.query_timeout_secs < 1 {
return Err(crate::Error::config(
"resource_quotas.query_timeout_secs must be at least 1 second",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiConfig {
pub jwt_secret: String,
pub anon_key: Option<String>,
pub service_role_key: Option<String>,
#[serde(default)]
pub oauth_providers: Vec<OAuthProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthProviderConfig {
pub name: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
jwt_secret: generate_random_secret(),
anon_key: None,
service_role_key: None,
oauth_providers: Vec::new(),
}
}
}
fn generate_random_secret() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let s = RandomState::new();
let h1 = s.build_hasher().finish();
let h2 = RandomState::new().build_hasher().finish();
format!("{h1:016x}{h2:016x}{h1:016x}{h2:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_config_default() {
let config = SessionConfig::default();
assert_eq!(config.timeout_secs, 3600);
assert_eq!(config.max_sessions_per_user, 10);
assert_eq!(config.cleanup_interval_secs, 300);
assert!(config.validate().is_ok());
}
#[test]
fn test_session_config_validation() {
let mut config = SessionConfig::default();
assert!(config.validate().is_ok());
config.timeout_secs = 0;
assert!(config.validate().is_err());
config.timeout_secs = 3600;
config.max_sessions_per_user = 0;
assert!(config.validate().is_err());
config.max_sessions_per_user = 10;
config.cleanup_interval_secs = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_lock_config_default() {
let config = LockConfig::default();
assert_eq!(config.timeout_ms, 30000);
assert_eq!(config.deadlock_check_interval_ms, 100);
assert_eq!(config.max_lock_holders, 10000);
assert!(config.validate().is_ok());
}
#[test]
fn test_lock_config_validation() {
let mut config = LockConfig::default();
assert!(config.validate().is_ok());
config.timeout_ms = 50;
assert!(config.validate().is_err());
config.timeout_ms = 30000;
config.deadlock_check_interval_ms = 5;
assert!(config.validate().is_err());
config.deadlock_check_interval_ms = 100;
config.max_lock_holders = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_dump_config_default() {
let config = DumpConfig::default();
assert!(!config.auto_dump_enabled);
assert_eq!(config.schedule, "");
assert_eq!(config.compression, "zstd");
assert_eq!(config.max_dump_size_mb, 10000);
assert_eq!(config.keep_dumps, 10);
assert_eq!(config.dump_dir, ".dumps");
assert!(config.validate().is_ok());
}
#[test]
fn test_dump_config_validation() {
let mut config = DumpConfig::default();
assert!(config.validate().is_ok());
config.compression = "invalid".to_string();
assert!(config.validate().is_err());
config.compression = "zstd".to_string();
config.compression = "gzip".to_string();
assert!(config.validate().is_ok());
config.compression = "none".to_string();
assert!(config.validate().is_ok());
config.compression = "zstd".to_string();
config.max_dump_size_mb = 0;
assert!(config.validate().is_err());
config.max_dump_size_mb = 10000;
config.auto_dump_enabled = true;
config.schedule = "0 */6 * * *".to_string();
assert!(config.validate().is_ok());
config.schedule = "invalid".to_string();
assert!(config.validate().is_err());
config.schedule = "".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_resource_quota_config_default() {
let config = ResourceQuotaConfig::default();
assert_eq!(config.memory_limit_per_user_mb, 1024);
assert_eq!(config.max_concurrent_queries, 100);
assert_eq!(config.query_timeout_secs, 300);
assert!(config.validate().is_ok());
}
#[test]
fn test_resource_quota_config_validation() {
let mut config = ResourceQuotaConfig::default();
assert!(config.validate().is_ok());
config.memory_limit_per_user_mb = 0;
assert!(config.validate().is_err());
config.memory_limit_per_user_mb = 1024;
config.max_concurrent_queries = 0;
assert!(config.validate().is_err());
config.max_concurrent_queries = 100;
config.query_timeout_secs = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_config_with_new_sections() {
let config = Config::default();
assert_eq!(config.session.timeout_secs, 3600);
assert_eq!(config.locks.timeout_ms, 30000);
assert_eq!(config.dump.compression, "zstd");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 1024);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_str = toml::to_string(&config).expect("Failed to serialize config");
assert!(toml_str.contains("[session]"));
assert!(toml_str.contains("[locks]"));
assert!(toml_str.contains("[dump]"));
assert!(toml_str.contains("[resource_quotas]"));
}
#[test]
fn test_config_deserialization() {
let toml_str = r#"
[storage]
memory_only = true
wal_enabled = false
wal_sync_mode = "sync"
cache_size = 536870912
compression = "Zstd"
time_travel_enabled = true
transaction_isolation = "READ_COMMITTED"
[session]
timeout_secs = 7200
max_sessions_per_user = 20
cleanup_interval_secs = 600
[locks]
timeout_ms = 60000
deadlock_check_interval_ms = 200
max_lock_holders = 20000
[dump]
auto_dump_enabled = true
schedule = "0 2 * * *"
compression = "gzip"
max_dump_size_mb = 5000
keep_dumps = 5
dump_dir = "/var/dumps"
[resource_quotas]
memory_limit_per_user_mb = 2048
max_concurrent_queries = 200
query_timeout_secs = 600
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize config");
assert_eq!(config.session.timeout_secs, 7200);
assert_eq!(config.session.max_sessions_per_user, 20);
assert_eq!(config.session.cleanup_interval_secs, 600);
assert_eq!(config.locks.timeout_ms, 60000);
assert_eq!(config.locks.deadlock_check_interval_ms, 200);
assert_eq!(config.locks.max_lock_holders, 20000);
assert!(config.dump.auto_dump_enabled);
assert_eq!(config.dump.schedule, "0 2 * * *");
assert_eq!(config.dump.compression, "gzip");
assert_eq!(config.dump.max_dump_size_mb, 5000);
assert_eq!(config.dump.keep_dumps, 5);
assert_eq!(config.dump.dump_dir, "/var/dumps");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 2048);
assert_eq!(config.resource_quotas.max_concurrent_queries, 200);
assert_eq!(config.resource_quotas.query_timeout_secs, 600);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_missing_sections() {
let toml_str = r#"
[storage]
memory_only = true
wal_enabled = false
wal_sync_mode = "sync"
cache_size = 536870912
compression = "Zstd"
time_travel_enabled = true
transaction_isolation = "READ_COMMITTED"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize config");
assert_eq!(config.session.timeout_secs, 3600);
assert_eq!(config.locks.timeout_ms, 30000);
assert_eq!(config.dump.compression, "zstd");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 1024);
assert!(config.validate().is_ok());
}
#[test]
fn test_cron_schedule_validation() {
assert!(DumpConfig::validate_cron_schedule("0 */6 * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("0 2 * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("*/15 * * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("0 0 1 * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("30 3 * * 0").is_ok());
assert!(DumpConfig::validate_cron_schedule("invalid").is_err());
assert!(DumpConfig::validate_cron_schedule("0 * * *").is_err()); assert!(DumpConfig::validate_cron_schedule("0 * * * * *").is_err()); assert!(DumpConfig::validate_cron_schedule("").is_err()); }
}