use super::db::RELOAD_SENTINEL;
use croner::Cron;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
pub const STAKPAK_AUTOPILOT_CONFIG_PATH: &str = "~/.stakpak/autopilot.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleConfig {
#[serde(default)]
pub watch: ScheduleSettings,
#[serde(default)]
pub defaults: ScheduleDefaults,
#[serde(default)]
pub notifications: Option<NotificationConfig>,
#[serde(default)]
pub schedules: Vec<Schedule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleSettings {
#[serde(default = "default_db_path")]
pub db_path: String,
#[serde(default = "default_log_dir")]
pub log_dir: String,
}
impl Default for ScheduleSettings {
fn default() -> Self {
Self {
db_path: default_db_path(),
log_dir: default_log_dir(),
}
}
}
fn default_db_path() -> String {
"~/.stakpak/autopilot/autopilot.db".to_string()
}
fn default_log_dir() -> String {
"~/.stakpak/autopilot/logs".to_string()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckTriggerOn {
#[default]
Success,
Failure,
Any,
}
impl CheckTriggerOn {
pub fn should_trigger(&self, exit_code: i32) -> bool {
match self {
CheckTriggerOn::Success => exit_code == 0,
CheckTriggerOn::Failure => exit_code != 0,
CheckTriggerOn::Any => true,
}
}
}
impl std::fmt::Display for CheckTriggerOn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CheckTriggerOn::Success => write!(f, "success"),
CheckTriggerOn::Failure => write!(f, "failure"),
CheckTriggerOn::Any => write!(f, "any"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleDefaults {
#[serde(default = "default_profile")]
pub profile: String,
#[serde(default = "default_timeout", with = "humantime_serde")]
pub timeout: Duration,
#[serde(default = "default_check_timeout", with = "humantime_serde")]
pub check_timeout: Duration,
#[serde(default)]
pub enable_slack_tools: bool,
#[serde(default)]
pub enable_subagents: bool,
#[serde(default = "default_pause_on_approval")]
pub pause_on_approval: bool,
#[serde(default)]
pub sandbox: bool,
#[serde(default)]
pub trigger_on: CheckTriggerOn,
}
impl Default for ScheduleDefaults {
fn default() -> Self {
Self {
profile: default_profile(),
timeout: default_timeout(),
check_timeout: default_check_timeout(),
enable_slack_tools: false,
enable_subagents: false,
pause_on_approval: default_pause_on_approval(),
sandbox: false,
trigger_on: CheckTriggerOn::default(),
}
}
}
fn default_profile() -> String {
"default".to_string()
}
fn default_timeout() -> Duration {
Duration::from_secs(30 * 60) }
fn default_check_timeout() -> Duration {
Duration::from_secs(30) }
fn default_pause_on_approval() -> bool {
false }
fn default_schedule_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationConfig {
pub gateway_url: String,
#[serde(default)]
pub gateway_token: Option<String>,
#[serde(default)]
pub notify_on: Option<NotifyOn>,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub chat_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DeliveryConfig {
pub channel: String,
pub chat_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyOn {
All,
Completions,
Failures,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InteractionMode {
Silent,
#[default]
Interactive,
}
impl NotificationConfig {
pub fn should_notify(&self, schedule: &Schedule, success: bool) -> bool {
let mode = schedule
.notify_on
.or(self.notify_on)
.unwrap_or(NotifyOn::All);
match mode {
NotifyOn::All => true,
NotifyOn::Completions => success,
NotifyOn::Failures => !success,
NotifyOn::None => false,
}
}
pub fn default_delivery(&self) -> Option<DeliveryConfig> {
let channel = self.channel.as_ref()?;
let chat_id = self.chat_id.as_ref()?;
Some(DeliveryConfig {
channel: channel.clone(),
chat_id: chat_id.clone(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
pub name: String,
pub cron: String,
pub check: Option<String>,
#[serde(default, with = "option_humantime_serde")]
pub check_timeout: Option<Duration>,
pub trigger_on: Option<CheckTriggerOn>,
pub prompt: String,
pub profile: Option<String>,
pub board_id: Option<String>,
#[serde(default, with = "option_humantime_serde")]
pub timeout: Option<Duration>,
pub enable_slack_tools: Option<bool>,
pub enable_subagents: Option<bool>,
pub pause_on_approval: Option<bool>,
pub sandbox: Option<bool>,
pub notify_on: Option<NotifyOn>,
pub notify_channel: Option<String>,
pub notify_chat_id: Option<String>,
#[serde(default)]
pub interaction: InteractionMode,
#[serde(default = "default_schedule_enabled")]
pub enabled: bool,
}
impl Schedule {
pub fn effective_profile<'a>(&'a self, defaults: &'a ScheduleDefaults) -> &'a str {
self.profile.as_deref().unwrap_or(&defaults.profile)
}
pub fn effective_timeout(&self, defaults: &ScheduleDefaults) -> Duration {
self.timeout.unwrap_or(defaults.timeout)
}
pub fn effective_check_timeout(&self, defaults: &ScheduleDefaults) -> Duration {
self.check_timeout.unwrap_or(defaults.check_timeout)
}
pub fn effective_trigger_on(&self, defaults: &ScheduleDefaults) -> CheckTriggerOn {
self.trigger_on.unwrap_or(defaults.trigger_on)
}
pub fn effective_enable_slack_tools(&self, defaults: &ScheduleDefaults) -> bool {
self.enable_slack_tools
.unwrap_or(defaults.enable_slack_tools)
}
pub fn effective_enable_subagents(&self, defaults: &ScheduleDefaults) -> bool {
self.enable_subagents.unwrap_or(defaults.enable_subagents)
}
pub fn effective_pause_on_approval(&self, defaults: &ScheduleDefaults) -> bool {
self.pause_on_approval.unwrap_or(defaults.pause_on_approval)
}
pub fn effective_sandbox(&self, defaults: &ScheduleDefaults) -> bool {
self.sandbox.unwrap_or(defaults.sandbox)
}
pub fn effective_delivery(&self, notifications: &NotificationConfig) -> Option<DeliveryConfig> {
let channel = self
.notify_channel
.as_ref()
.cloned()
.or_else(|| notifications.channel.clone())?;
let chat_id = self
.notify_chat_id
.as_ref()
.cloned()
.or_else(|| notifications.chat_id.clone())?;
Some(DeliveryConfig { channel, chat_id })
}
}
mod option_humantime_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(d) => {
let s = humantime::format_duration(*d).to_string();
s.serialize(serializer)
}
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(s) => humantime::parse_duration(&s)
.map(Some)
.map_err(serde::de::Error::custom),
None => Ok(None),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to parse config file: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Invalid cron expression '{expression}' for schedule '{schedule}': {message}")]
InvalidCron {
schedule: String,
expression: String,
message: String,
},
#[error("Duplicate schedule name: '{0}'")]
DuplicateScheduleName(String),
#[error("Schedule name '{0}' is reserved")]
ReservedScheduleName(String),
#[error("Check script not found for schedule '{schedule}': {path}")]
CheckScriptNotFound { schedule: String, path: String },
#[error(
"Cannot expand '~' in check script path for schedule '{schedule}': {path}. Home directory for the running user could not be determined; use an absolute path."
)]
CheckScriptHomeDirUnavailable { schedule: String, path: String },
#[error(
"Cannot expand '~' in path: {path}. Home directory for the running user could not be determined; use an absolute path."
)]
PathHomeDirUnavailable { path: String },
#[error("Schedule '{0}' is missing required field: {1}")]
MissingRequiredField(String, String),
}
impl ScheduleConfig {
pub fn load_default() -> Result<Self, ConfigError> {
let path = expand_tilde_for_path(Path::new(STAKPAK_AUTOPILOT_CONFIG_PATH))?;
Self::load(&path)
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path.as_ref())?;
let config: ScheduleConfig = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn parse(content: &str) -> Result<Self, ConfigError> {
let config: ScheduleConfig = toml::from_str(content)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_unique_schedule_names()?;
self.validate_reserved_schedule_names()?;
self.validate_cron_expressions()?;
self.validate_runtime_paths()?;
self.validate_check_scripts()?;
Ok(())
}
fn validate_unique_schedule_names(&self) -> Result<(), ConfigError> {
let mut seen = HashSet::new();
for schedule in &self.schedules {
if !seen.insert(&schedule.name) {
return Err(ConfigError::DuplicateScheduleName(schedule.name.clone()));
}
}
Ok(())
}
fn validate_reserved_schedule_names(&self) -> Result<(), ConfigError> {
for schedule in &self.schedules {
if schedule.name.trim() == RELOAD_SENTINEL {
return Err(ConfigError::ReservedScheduleName(schedule.name.clone()));
}
}
Ok(())
}
fn validate_cron_expressions(&self) -> Result<(), ConfigError> {
for schedule in &self.schedules {
if let Err(e) = Cron::from_str(&schedule.cron) {
return Err(ConfigError::InvalidCron {
schedule: schedule.name.clone(),
expression: schedule.cron.clone(),
message: e.to_string(),
});
}
}
Ok(())
}
fn validate_runtime_paths(&self) -> Result<(), ConfigError> {
expand_tilde_for_path(Path::new(&self.watch.db_path))?;
expand_tilde_for_path(Path::new(&self.watch.log_dir))?;
Ok(())
}
fn validate_check_scripts(&self) -> Result<(), ConfigError> {
for schedule in &self.schedules {
if let Some(check_path) = &schedule.check {
let expanded =
expand_tilde_for_check_path(Path::new(check_path), schedule.name.as_str())?;
if !expanded.exists() {
return Err(ConfigError::CheckScriptNotFound {
schedule: schedule.name.clone(),
path: check_path.clone(),
});
}
}
}
Ok(())
}
pub fn db_path(&self) -> PathBuf {
expand_tilde(&self.watch.db_path)
}
pub fn log_dir(&self) -> PathBuf {
expand_tilde(&self.watch.log_dir)
}
pub fn apply_runtime_gateway_credentials(&mut self, url: &str, token: &str) {
if let Some(ref mut notifications) = self.notifications {
let trimmed = token.trim();
if !trimmed.is_empty() {
notifications.gateway_token = Some(trimmed.to_string());
}
if notifications.gateway_url.trim().is_empty() {
notifications.gateway_url = url.to_string();
}
}
}
pub fn notifications_points_to_local_loopback(&self) -> bool {
let Some(notifications) = &self.notifications else {
return false;
};
let url = notifications.gateway_url.to_ascii_lowercase();
url.contains("127.0.0.1") || url.contains("localhost")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TildeExpansionError {
HomeDirUnavailable,
}
fn current_user_home_dir() -> Option<PathBuf> {
dirs::home_dir().or_else(resolve_home_dir_from_system)
}
#[cfg(unix)]
fn resolve_home_dir_from_system() -> Option<PathBuf> {
use std::ffi::{CStr, OsStr};
use std::os::unix::ffi::OsStrExt;
use std::ptr;
unsafe {
let uid = libc::geteuid();
let mut pwd: libc::passwd = std::mem::zeroed();
let mut result: *mut libc::passwd = ptr::null_mut();
let mut buf_len = libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX);
if buf_len <= 0 {
buf_len = 4096;
}
let mut buf_len = usize::try_from(buf_len).unwrap_or(4096).max(1024);
let mut buf = vec![0u8; buf_len];
let rc = loop {
let rc = libc::getpwuid_r(
uid,
&mut pwd,
buf.as_mut_ptr().cast(),
buf.len(),
&mut result,
);
if rc == libc::ERANGE && buf_len < 65536 {
buf_len *= 2;
buf.resize(buf_len, 0);
continue;
}
break rc;
};
if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
return None;
}
let home_cstr = CStr::from_ptr(pwd.pw_dir);
let home_os = OsStr::from_bytes(home_cstr.to_bytes());
Some(PathBuf::from(home_os))
}
}
#[cfg(not(unix))]
fn resolve_home_dir_from_system() -> Option<PathBuf> {
None
}
fn expand_tilde_with_home(
path: &Path,
home: Option<&Path>,
) -> Result<PathBuf, TildeExpansionError> {
let path_str = path.to_string_lossy();
if path_str == "~" {
return home
.map(Path::to_path_buf)
.ok_or(TildeExpansionError::HomeDirUnavailable);
}
if let Some(stripped) = path_str
.strip_prefix("~/")
.or_else(|| path_str.strip_prefix("~\\"))
{
return home
.map(|home_dir| home_dir.join(stripped))
.ok_or(TildeExpansionError::HomeDirUnavailable);
}
Ok(path.to_path_buf())
}
fn expand_tilde_for_path(path: &Path) -> Result<PathBuf, ConfigError> {
let home = current_user_home_dir();
expand_tilde_with_home(path, home.as_deref()).map_err(|_| ConfigError::PathHomeDirUnavailable {
path: path.to_string_lossy().into_owned(),
})
}
fn expand_tilde_for_check_path(path: &Path, schedule_name: &str) -> Result<PathBuf, ConfigError> {
let home = current_user_home_dir();
expand_tilde_with_home(path, home.as_deref()).map_err(|_| {
ConfigError::CheckScriptHomeDirUnavailable {
schedule: schedule_name.to_string(),
path: path.to_string_lossy().into_owned(),
}
})
}
pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
let path_ref = path.as_ref();
let home = current_user_home_dir();
expand_tilde_with_home(path_ref, home.as_deref()).unwrap_or_else(|_| path_ref.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_parse_valid_config() {
let config_str = r#"
[watch]
db_path = "~/.stakpak/autopilot/autopilot.db"
log_dir = "~/.stakpak/autopilot/logs"
[defaults]
profile = "production"
timeout = "1h"
check_timeout = "1m"
[[schedules]]
name = "disk-cleanup"
cron = "*/15 * * * *"
prompt = "Check disk usage and clean up if needed"
profile = "maintenance"
timeout = "45m"
[[schedules]]
name = "health-check"
cron = "0 * * * *"
prompt = "Run health checks"
board_id = "board_123"
"#;
let config = ScheduleConfig::parse(config_str).expect("Should parse valid config");
assert_eq!(config.watch.db_path, "~/.stakpak/autopilot/autopilot.db");
assert_eq!(config.defaults.profile, "production");
assert_eq!(config.defaults.timeout, Duration::from_secs(3600));
assert_eq!(config.defaults.check_timeout, Duration::from_secs(60));
assert_eq!(config.schedules.len(), 2);
let schedule1 = &config.schedules[0];
assert_eq!(schedule1.name, "disk-cleanup");
assert_eq!(schedule1.cron, "*/15 * * * *");
assert_eq!(schedule1.profile, Some("maintenance".to_string()));
assert_eq!(schedule1.timeout, Some(Duration::from_secs(45 * 60)));
let schedule2 = &config.schedules[1];
assert_eq!(schedule2.name, "health-check");
assert_eq!(schedule2.board_id, Some("board_123".to_string()));
assert_eq!(schedule2.effective_profile(&config.defaults), "production");
}
#[test]
fn test_parse_minimal_config() {
let config_str = r#"
[[schedules]]
name = "simple"
cron = "0 0 * * *"
prompt = "Do something"
"#;
let config = ScheduleConfig::parse(config_str).expect("Should parse minimal config");
assert_eq!(config.watch.db_path, "~/.stakpak/autopilot/autopilot.db");
assert_eq!(config.defaults.profile, "default");
assert_eq!(config.defaults.timeout, Duration::from_secs(30 * 60));
assert_eq!(config.schedules.len(), 1);
}
#[test]
fn test_invalid_cron() {
let config_str = r#"
[[schedules]]
name = "bad-cron"
cron = "invalid cron expression"
prompt = "This should fail"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::InvalidCron { .. }));
if let ConfigError::InvalidCron { schedule, .. } = err {
assert_eq!(schedule, "bad-cron");
}
}
#[test]
fn test_duplicate_schedule_names() {
let config_str = r#"
[[schedules]]
name = "duplicate"
cron = "0 * * * *"
prompt = "First"
[[schedules]]
name = "duplicate"
cron = "0 0 * * *"
prompt = "Second"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::DuplicateScheduleName(_)));
if let ConfigError::DuplicateScheduleName(name) = err {
assert_eq!(name, "duplicate");
}
}
#[test]
fn test_reserved_schedule_name_rejected() {
let config_str = r#"
[[schedules]]
name = "__config_reload__"
cron = "0 * * * *"
prompt = "Reserved"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::ReservedScheduleName(_)));
if let ConfigError::ReservedScheduleName(name) = err {
assert_eq!(name, "__config_reload__");
}
}
#[test]
fn test_path_expansion() {
let expanded = expand_tilde("~/test/path");
assert!(!expanded.to_string_lossy().starts_with("~"));
let home = dirs::home_dir().expect("Should have home dir");
assert!(expanded.starts_with(&home));
assert!(expanded.ends_with("test/path"));
}
#[test]
fn test_expand_tilde_with_home_requires_home_for_tilde_paths() {
let result = expand_tilde_with_home(Path::new("~/test/path"), None);
assert_eq!(result, Err(TildeExpansionError::HomeDirUnavailable));
}
#[test]
fn test_expand_tilde_with_home_passthrough_for_non_tilde_paths() {
let input = Path::new("/tmp/check.sh");
let result = expand_tilde_with_home(input, None);
assert_eq!(result, Ok(input.to_path_buf()));
}
#[test]
fn test_default_values() {
let config_str = r#"
[[schedules]]
name = "test"
cron = "0 0 * * *"
prompt = "Test prompt"
"#;
let config = ScheduleConfig::parse(config_str).expect("Should parse");
let schedule = &config.schedules[0];
assert_eq!(schedule.effective_profile(&config.defaults), "default");
assert_eq!(
schedule.effective_timeout(&config.defaults),
Duration::from_secs(30 * 60)
);
assert_eq!(
schedule.effective_check_timeout(&config.defaults),
Duration::from_secs(30)
);
}
#[test]
fn test_various_cron_expressions() {
let expressions = [
"* * * * *", "*/5 * * * *", "0 0 * * *", "0 0 * * 0", "0 0 1 * *", "0 0 1 1 *", "30 4 1,15 * *", "0 0-5 * * *", "0 0 * * 1-5", "0 9 * * 1-5", ];
for expr in expressions {
let config_str = format!(
r#"
[[schedules]]
name = "test"
cron = "{}"
prompt = "Test"
"#,
expr
);
let result = ScheduleConfig::parse(&config_str);
assert!(
result.is_ok(),
"Should parse valid cron expression: {}",
expr
);
}
}
#[test]
fn test_humantime_durations() {
let config_str = r#"
[defaults]
timeout = "2h 30m"
check_timeout = "45s"
[[schedules]]
name = "test"
cron = "0 0 * * *"
prompt = "Test"
timeout = "1h 15m 30s"
check_timeout = "2m"
"#;
let config = ScheduleConfig::parse(config_str).expect("Should parse humantime durations");
assert_eq!(
config.defaults.timeout,
Duration::from_secs(2 * 3600 + 30 * 60)
);
assert_eq!(config.defaults.check_timeout, Duration::from_secs(45));
let schedule = &config.schedules[0];
assert_eq!(
schedule.timeout,
Some(Duration::from_secs(3600 + 15 * 60 + 30))
);
assert_eq!(schedule.check_timeout, Some(Duration::from_secs(120)));
}
#[test]
fn test_empty_schedules() {
let config_str = r#"
[watch]
db_path = "/custom/path/watch.db"
"#;
let config =
ScheduleConfig::parse(config_str).expect("Should parse config with no schedules");
assert!(config.schedules.is_empty());
}
#[test]
fn test_check_script_not_found() {
let config_str = r#"
[[schedules]]
name = "with-check"
cron = "0 * * * *"
prompt = "Test"
check = "/nonexistent/path/to/script.sh"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::CheckScriptNotFound { .. }));
if let ConfigError::CheckScriptNotFound { schedule, path } = err {
assert_eq!(schedule, "with-check");
assert_eq!(path, "/nonexistent/path/to/script.sh");
}
}
#[test]
fn test_missing_required_field_name() {
let config_str = r#"
[[schedules]]
cron = "0 * * * *"
prompt = "Test"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
}
#[test]
fn test_missing_required_field_cron() {
let config_str = r#"
[[schedules]]
name = "test"
prompt = "Test"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
}
#[test]
fn test_missing_required_field_prompt() {
let config_str = r#"
[[schedules]]
name = "test"
cron = "0 * * * *"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
}
#[test]
fn test_check_trigger_on_should_trigger() {
assert!(CheckTriggerOn::Success.should_trigger(0));
assert!(!CheckTriggerOn::Success.should_trigger(1));
assert!(!CheckTriggerOn::Success.should_trigger(2));
assert!(!CheckTriggerOn::Success.should_trigger(-1));
assert!(!CheckTriggerOn::Failure.should_trigger(0));
assert!(CheckTriggerOn::Failure.should_trigger(1));
assert!(CheckTriggerOn::Failure.should_trigger(2));
assert!(CheckTriggerOn::Failure.should_trigger(-1));
assert!(CheckTriggerOn::Any.should_trigger(0));
assert!(CheckTriggerOn::Any.should_trigger(1));
assert!(CheckTriggerOn::Any.should_trigger(2));
assert!(CheckTriggerOn::Any.should_trigger(-1));
}
#[test]
fn test_check_trigger_on_default() {
assert_eq!(CheckTriggerOn::default(), CheckTriggerOn::Success);
}
#[test]
fn test_check_trigger_on_display() {
assert_eq!(CheckTriggerOn::Success.to_string(), "success");
assert_eq!(CheckTriggerOn::Failure.to_string(), "failure");
assert_eq!(CheckTriggerOn::Any.to_string(), "any");
}
#[test]
fn test_check_trigger_on_parsing() {
let config_str = r#"
[[schedules]]
name = "success-trigger"
cron = "0 * * * *"
prompt = "Test"
trigger_on = "success"
[[schedules]]
name = "failure-trigger"
cron = "0 * * * *"
prompt = "Test"
trigger_on = "failure"
[[schedules]]
name = "any-trigger"
cron = "0 * * * *"
prompt = "Test"
trigger_on = "any"
[[schedules]]
name = "default-trigger"
cron = "0 * * * *"
prompt = "Test"
"#;
let config = ScheduleConfig::parse(config_str).expect("Should parse trigger_on values");
assert_eq!(config.schedules.len(), 4);
assert_eq!(
config.schedules[0].trigger_on,
Some(CheckTriggerOn::Success)
);
assert_eq!(
config.schedules[1].trigger_on,
Some(CheckTriggerOn::Failure)
);
assert_eq!(config.schedules[2].trigger_on, Some(CheckTriggerOn::Any));
assert_eq!(config.schedules[3].trigger_on, None);
}
#[test]
fn test_check_trigger_on_defaults_fallback() {
let config_str = r#"
[defaults]
trigger_on = "failure"
[[schedules]]
name = "uses-default"
cron = "0 * * * *"
prompt = "Test"
[[schedules]]
name = "overrides-default"
cron = "0 * * * *"
prompt = "Test"
trigger_on = "success"
"#;
let config =
ScheduleConfig::parse(config_str).expect("Should parse trigger_on with defaults");
assert_eq!(
config.schedules[0].effective_trigger_on(&config.defaults),
CheckTriggerOn::Failure
);
assert_eq!(
config.schedules[1].effective_trigger_on(&config.defaults),
CheckTriggerOn::Success
);
}
#[test]
fn test_trigger_on_invalid_value() {
let config_str = r#"
[[schedules]]
name = "invalid"
cron = "0 * * * *"
prompt = "Test"
trigger_on = "invalid"
"#;
let result = ScheduleConfig::parse(config_str);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
}
#[test]
fn test_interaction_defaults_to_interactive() {
let config_str = r#"
[[schedules]]
name = "default-interaction"
cron = "0 * * * *"
prompt = "Test"
"#;
let config = ScheduleConfig::parse(config_str).expect("config should parse");
assert_eq!(
config.schedules[0].interaction,
InteractionMode::Interactive
);
}
#[test]
fn test_interaction_can_be_silent() {
let config_str = r#"
[[schedules]]
name = "silent"
cron = "0 * * * *"
prompt = "Test"
interaction = "silent"
"#;
let config = ScheduleConfig::parse(config_str).expect("config should parse");
assert_eq!(config.schedules[0].interaction, InteractionMode::Silent);
}
fn config_with_notifications(gateway_url: &str, gateway_token: Option<&str>) -> ScheduleConfig {
let config_str = format!(
r##"
[notifications]
gateway_url = "{gateway_url}"
channel = "slack"
chat_id = "#ops"
[[schedules]]
name = "test"
cron = "0 * * * *"
prompt = "test"
"##,
);
let mut config = ScheduleConfig::parse(&config_str).expect("config should parse");
if let Some(ref mut n) = config.notifications {
n.gateway_token = gateway_token.map(String::from);
}
config
}
fn config_without_notifications() -> ScheduleConfig {
let config_str = r##"
[[schedules]]
name = "test"
cron = "0 * * * *"
prompt = "test"
"##;
ScheduleConfig::parse(config_str).expect("config should parse")
}
#[test]
fn test_apply_credentials_injects_token_when_missing() {
let mut config = config_with_notifications("http://127.0.0.1:4096", None);
assert!(
config
.notifications
.as_ref()
.expect("notifications should exist")
.gateway_token
.is_none()
);
config
.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-secret-token-abc");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("runtime-secret-token-abc"),
"runtime token should be injected"
);
}
#[test]
fn test_apply_credentials_overwrites_stale_disk_token() {
let mut config = config_with_notifications(
"http://127.0.0.1:4096",
Some("stale-token-from-previous-run"),
);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "fresh-runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("fresh-runtime-token"),
"stale disk token should be overwritten by runtime token"
);
}
#[test]
fn test_apply_credentials_preserves_custom_gateway_url() {
let custom_url = "https://gateway.example.com";
let mut config = config_with_notifications(custom_url, None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_url, custom_url,
"user-configured external gateway URL should not be overwritten"
);
assert_eq!(
notifications.gateway_token.as_deref(),
Some("runtime-token"),
);
}
#[test]
fn test_apply_credentials_fills_empty_gateway_url() {
let mut config = config_with_notifications("", None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:5555", "runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_url, "http://127.0.0.1:5555",
"empty gateway_url should be filled from runtime"
);
}
#[test]
fn test_apply_credentials_fills_whitespace_only_gateway_url() {
let config_str = r##"
[notifications]
gateway_url = " "
channel = "slack"
chat_id = "#ops"
[[schedules]]
name = "test"
cron = "0 * * * *"
prompt = "test"
"##;
let mut config = ScheduleConfig::parse(config_str).expect("config should parse");
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_url, "http://127.0.0.1:4096",
"whitespace-only gateway_url should be replaced"
);
}
#[test]
fn test_apply_credentials_noop_without_notifications_section() {
let mut config = config_without_notifications();
assert!(config.notifications.is_none());
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
assert!(
config.notifications.is_none(),
"should not fabricate a notifications section"
);
}
#[test]
fn test_apply_credentials_skips_empty_runtime_token() {
let mut config = config_with_notifications("http://127.0.0.1:4096", Some("existing-token"));
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("existing-token"),
"empty runtime token should not overwrite an existing token"
);
}
#[test]
fn test_apply_credentials_skips_whitespace_only_runtime_token() {
let mut config = config_with_notifications("http://127.0.0.1:4096", Some("existing-token"));
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", " ");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("existing-token"),
"whitespace-only runtime token should not overwrite an existing token"
);
}
#[test]
fn test_apply_credentials_trims_runtime_token() {
let mut config = config_with_notifications("http://127.0.0.1:4096", None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", " runtime-token ");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("runtime-token"),
"stored token should be trimmed"
);
}
#[test]
fn test_apply_credentials_skips_empty_runtime_token_when_disk_token_absent() {
let mut config = config_with_notifications("http://127.0.0.1:4096", None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "");
let notifications = config.notifications.expect("notifications should exist");
assert!(
notifications.gateway_token.is_none(),
"empty runtime token should not inject a Some(\"\")"
);
}
#[test]
fn test_apply_credentials_idempotent() {
let mut config = config_with_notifications("http://127.0.0.1:4096", None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(
notifications.gateway_token.as_deref(),
Some("runtime-token"),
);
assert_eq!(notifications.gateway_url, "http://127.0.0.1:4096",);
}
#[test]
fn test_apply_credentials_preserves_notification_fields() {
let mut config = config_with_notifications("http://127.0.0.1:4096", None);
config.apply_runtime_gateway_credentials("http://127.0.0.1:4096", "runtime-token");
let notifications = config.notifications.expect("notifications should exist");
assert_eq!(notifications.channel.as_deref(), Some("slack"));
assert_eq!(notifications.chat_id.as_deref(), Some("#ops"));
assert!(
notifications.notify_on.is_none(),
"unrelated fields should not be mutated"
);
}
}