use super::*;
fn default_output() -> OutputConfig {
OutputConfig {
max_message_chars: 4000,
file_upload_threshold_bytes: 51200,
chunk_strategy: ChunkStrategy::Natural,
}
}
fn make_config(yaml: &str) -> Result<AppConfig> {
let config: AppConfig = serde_yaml::from_str(yaml).context("malformed YAML")?;
validate(&config)?;
Ok(config)
}
const VALID_YAML: &str = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: telegram
token: "tok"
allowed_users:
- "@user-x"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
#[test]
fn valid_config_parses() {
let cfg = make_config(VALID_YAML).unwrap();
assert_eq!(cfg.workspaces[0].name, "test-ws");
assert_eq!(cfg.output.chunk_strategy, ChunkStrategy::Natural);
}
#[test]
fn unknown_backend_is_rejected() {
let yaml = VALID_YAML.replace("claude-cli", "gpt-cli");
assert!(make_config(&yaml).is_err());
}
#[test]
fn empty_allowed_users_is_rejected() {
let yaml = VALID_YAML.replace(" - \"@user-x\"", "");
assert!(make_config(&yaml).is_err());
}
#[test]
fn unknown_channel_kind_is_rejected() {
let yaml = VALID_YAML.replace("telegram", "discord");
assert!(make_config(&yaml).is_err());
}
#[test]
fn missing_output_section_is_rejected() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: telegram
token: "tok"
allowed_users:
- "@user-x"
"#;
assert!(serde_yaml::from_str::<AppConfig>(yaml).is_err());
}
#[test]
fn numeric_and_handle_allowed_users_parse() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: telegram
token: "tok"
allowed_users:
- "@user-x"
- 987654321
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
let cfg = make_config(yaml).unwrap();
assert_eq!(cfg.workspaces[0].channels[0].allowed_users.len(), 2);
}
#[test]
fn env_var_interpolation_replaces_known_vars() {
std::env::set_var("TEST_TOKEN_RUSTIFYMYCLAW", "secret123");
let raw = "token: ${TEST_TOKEN_RUSTIFYMYCLAW}";
let result = interpolate_env_vars(raw).unwrap();
assert_eq!(result, "token: secret123");
std::env::remove_var("TEST_TOKEN_RUSTIFYMYCLAW");
}
#[test]
fn env_var_interpolation_fails_on_missing_var() {
std::env::remove_var("RUSTIFYMYCLAW_DEFINITELY_NOT_SET");
let raw = "token: ${RUSTIFYMYCLAW_DEFINITELY_NOT_SET}";
assert!(interpolate_env_vars(raw).is_err());
}
#[test]
fn effective_output_uses_channel_override() {
let global = default_output();
let channel = ChannelConfig {
kind: "slack".to_string(),
bot_name: None,
token: "tok".to_string(),
allowed_users: vec![],
max_message_chars: Some(3000),
file_upload_threshold_bytes: None,
webhook_port: None,
phone_number_id: None,
verify_token: None,
app_token: None,
use_threads: None,
};
let effective = effective_output_config(&global, &channel);
assert_eq!(effective.max_message_chars, 3000);
assert_eq!(effective.file_upload_threshold_bytes, 51200);
}
#[test]
fn effective_output_falls_back_to_global() {
let global = default_output();
let channel = ChannelConfig {
kind: "telegram".to_string(),
bot_name: None,
token: "tok".to_string(),
allowed_users: vec![],
max_message_chars: None,
file_upload_threshold_bytes: None,
webhook_port: None,
phone_number_id: None,
verify_token: None,
app_token: None,
use_threads: None,
};
let effective = effective_output_config(&global, &channel);
assert_eq!(effective.max_message_chars, 4000);
assert_eq!(effective.file_upload_threshold_bytes, 51200);
}
#[test]
fn whatsapp_config_fields_parse() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: whatsapp
token: "wa-token"
phone_number_id: "12345"
webhook_port: 8080
verify_token: "secret"
max_message_chars: 2000
allowed_users:
- "+5511999999999"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap();
let ch = &cfg.workspaces[0].channels[0];
assert_eq!(ch.phone_number_id.as_deref(), Some("12345"));
assert_eq!(ch.webhook_port, Some(8080));
assert_eq!(ch.verify_token.as_deref(), Some("secret"));
assert_eq!(ch.max_message_chars, Some(2000));
}
#[test]
fn slack_config_fields_parse() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: slack
token: "xoxb-bot-token"
app_token: "xapp-app-token"
use_threads: true
max_message_chars: 3000
allowed_users:
- "@dev_user"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap();
let ch = &cfg.workspaces[0].channels[0];
assert_eq!(ch.app_token.as_deref(), Some("xapp-app-token"));
assert_eq!(ch.use_threads, Some(true));
assert_eq!(ch.max_message_chars, Some(3000));
}
#[test]
fn telegram_config_still_parses_without_new_fields() {
let cfg = make_config(VALID_YAML).unwrap();
let ch = &cfg.workspaces[0].channels[0];
assert!(ch.max_message_chars.is_none());
assert!(ch.app_token.is_none());
assert!(ch.webhook_port.is_none());
}
#[test]
fn whatsapp_fields_on_telegram_channel_still_parse() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: telegram
token: "tok"
phone_number_id: "oops"
app_token: "also-oops"
allowed_users:
- "@user-x"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
let cfg = make_config(yaml).unwrap();
let ch = &cfg.workspaces[0].channels[0];
assert_eq!(ch.phone_number_id.as_deref(), Some("oops"));
assert_eq!(ch.app_token.as_deref(), Some("also-oops"));
}
#[test]
fn empty_workspaces_is_rejected() {
let yaml = r#"
workspaces: []
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
assert!(make_config(yaml).is_err());
}
#[test]
fn timeout_seconds_absent_defaults_to_none() {
let cfg = make_config(VALID_YAML).unwrap();
assert!(cfg.workspaces[0].timeout_seconds.is_none());
}
#[test]
fn timeout_seconds_parses_when_present() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
timeout_seconds: 120
channels:
- kind: telegram
token: "tok"
allowed_users:
- "@user-x"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.workspaces[0].timeout_seconds, Some(120));
}
#[test]
fn limits_absent_defaults_to_none() {
let cfg = make_config(VALID_YAML).unwrap();
assert!(cfg.limits.is_none());
}
#[test]
fn limits_parses_when_present() {
let yaml = r#"
workspaces:
- name: "test-ws"
directory: "/tmp"
backend: "claude-cli"
channels:
- kind: telegram
token: "tok"
allowed_users:
- "@user-x"
output:
max_message_chars: 4000
file_upload_threshold_bytes: 51200
chunk_strategy: "natural"
limits:
max_requests: 10
window_seconds: 60
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap();
let limits = cfg.limits.unwrap();
assert_eq!(limits.max_requests, 10);
assert_eq!(limits.window_seconds, 60);
}
#[test]
fn load_from_path_returns_error_for_nonexistent_file() {
let result = load_from_path(std::path::Path::new("/nonexistent/path/config.yaml"));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot read config file"),
"unexpected error: {msg}"
);
}
#[test]
fn dirs_path_ends_with_config_yaml() {
let path = dirs_path();
assert!(
path.ends_with("config.yaml"),
"expected path to end with config.yaml, got: {}",
path.display()
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn dirs_path_uses_rustifymyclaw_dir_on_unix() {
let path = dirs_path();
let s = path.to_string_lossy();
assert!(
s.contains(".rustifymyclaw"),
"expected .rustifymyclaw in path, got: {s}"
);
}
#[test]
fn diff_reload_does_not_panic_on_identical_configs() {
let cfg = make_config(VALID_YAML).unwrap();
diff_reload(&cfg, &cfg.clone());
}
#[test]
fn diff_reload_does_not_panic_on_limits_change() {
let old = make_config(VALID_YAML).unwrap();
let mut new = old.clone();
new.limits = Some(LimitsConfig {
max_requests: 5,
window_seconds: 30,
});
diff_reload(&old, &new);
}