use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvVarGuard {
name: &'static str,
original: Option<OsString>,
_lock: MutexGuard<'static, ()>,
}
impl EnvVarGuard {
fn remove(name: &'static str) -> Self {
let lock = env_lock().lock().expect("env lock");
let original = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self {
name,
original,
_lock: lock,
}
}
fn set(name: &'static str, value: &str) -> Self {
let lock = env_lock().lock().expect("env lock");
let original = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self {
name,
original,
_lock: lock,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => unsafe { std::env::set_var(self.name, value) },
None => unsafe { std::env::remove_var(self.name) },
}
}
}
#[test]
fn ensure_no_unresolved_placeholders_passes_when_none_remain() -> Result<()> {
let rendered = "Hello world";
assert!(ensure_no_unresolved_placeholders(rendered, "test").is_ok());
Ok(())
}
#[test]
fn ensure_no_unresolved_placeholders_fails_with_placeholder() -> Result<()> {
let rendered = "Hello {{MISSING}} world";
let err = ensure_no_unresolved_placeholders(rendered, "test").unwrap_err();
assert!(err.to_string().contains("MISSING"));
assert!(err.to_string().contains("unresolved placeholders"));
Ok(())
}
#[test]
fn unresolved_placeholders_finds_all_placeholders() {
let rendered = "Test {{ONE}} and {{TWO}} and {{three}}";
let placeholders = unresolved_placeholders(rendered);
assert_eq!(placeholders.len(), 3);
assert!(placeholders.contains(&"ONE".to_string()));
assert!(placeholders.contains(&"TWO".to_string()));
assert!(placeholders.contains(&"THREE".to_string()));
}
#[test]
fn unresolved_placeholders_returns_sorted_unique() {
let rendered = "Test {{Z}} and {{A}} and {{B}} and {{A}}";
let placeholders = unresolved_placeholders(rendered);
assert_eq!(placeholders, vec!["A", "B", "Z"]);
}
#[test]
fn expand_variables_expands_env_var_with_default() -> Result<()> {
let var_name = "CUELOOP_TEST_DEFAULT_VAR";
let _env = EnvVarGuard::remove(var_name);
let template = format!("Value: ${{{}:-default_value}}", var_name);
let config = default_config();
let result = expand_variables(&template, &config)?;
assert_eq!(result, "Value: default_value");
Ok(())
}
#[test]
fn expand_variables_expands_env_var_when_set() -> Result<()> {
let var_name = "CUELOOP_TEST_SET_VAR";
let template = format!("Value: ${{{}:-default}}", var_name);
let config = default_config();
let _env = EnvVarGuard::set(var_name, "actual_value");
let result = expand_variables(&template, &config)?;
assert_eq!(result, "Value: actual_value");
Ok(())
}
#[test]
fn expand_variables_leaves_missing_env_var_literal() -> Result<()> {
let template = "Value: ${MISSING_VAR}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert!(result.contains("${MISSING_VAR}"));
Ok(())
}
#[test]
fn expand_variables_handles_dollar_escape() -> Result<()> {
let template = "Literal: $${ESCAPED}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert_eq!(result, "Literal: ${ESCAPED}");
Ok(())
}
#[test]
fn expand_variables_handles_backslash_escape() -> Result<()> {
let template = "Literal: \\${ESCAPED}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert_eq!(result, "Literal: ${ESCAPED}");
Ok(())
}
#[test]
fn expand_variables_expands_config_runner() -> Result<()> {
let template = "Runner: {{config.agent.runner}}";
let mut config = default_config();
config.agent.runner = Some(crate::contracts::Runner::Claude);
let result = expand_variables(template, &config)?;
assert!(result.contains("Runner: Claude"));
Ok(())
}
#[test]
fn expand_variables_expands_config_model() -> Result<()> {
let template = "Model: {{config.agent.model}}";
let mut config = default_config();
config.agent.model = Some(crate::contracts::Model::Gpt53Codex);
let result = expand_variables(template, &config)?;
assert!(result.contains("gpt-5.3-codex"));
Ok(())
}
#[test]
fn expand_variables_expands_config_queue_id_prefix() -> Result<()> {
let template = "Prefix: {{config.queue.id_prefix}}";
let mut config = default_config();
config.queue.id_prefix = Some("TASK".to_string());
let result = expand_variables(template, &config)?;
assert!(result.contains("Prefix: TASK"));
Ok(())
}
#[test]
fn expand_variables_expands_config_queue_file() -> Result<()> {
let template = "Queue file: {{config.queue.file}}";
let mut config = default_config();
config.queue.file = Some(std::path::PathBuf::from(".cueloop/custom_queue.jsonc"));
let result = expand_variables(template, &config)?;
assert!(result.contains("Queue file: .cueloop/custom_queue.jsonc"));
Ok(())
}
#[test]
fn expand_variables_uses_default_config_queue_file_when_unset() -> Result<()> {
let template = "Queue file: {{config.queue.file}}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert!(result.contains("Queue file: .cueloop/queue.jsonc"));
assert!(!result.contains("{{config.queue.file}}"));
Ok(())
}
#[test]
fn expand_variables_expands_config_done_file() -> Result<()> {
let template = "Done file: {{config.queue.done_file}}";
let mut config = default_config();
config.queue.done_file = Some(std::path::PathBuf::from(".cueloop/custom_done.jsonc"));
let result = expand_variables(template, &config)?;
assert!(result.contains("Done file: .cueloop/custom_done.jsonc"));
Ok(())
}
#[test]
fn expand_variables_uses_default_config_done_file_when_unset() -> Result<()> {
let template = "Done file: {{config.queue.done_file}}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert!(result.contains("Done file: .cueloop/done.jsonc"));
assert!(!result.contains("{{config.queue.done_file}}"));
Ok(())
}
#[test]
fn expand_variables_expands_config_ci_gate_display() -> Result<()> {
let template = "CI: {{config.agent.ci_gate_display}}";
let mut config = default_config();
config.agent.ci_gate = Some(crate::contracts::CiGateConfig {
enabled: Some(true),
argv: Some(vec!["cargo".to_string(), "test".to_string()]),
});
let result = expand_variables(template, &config)?;
assert!(result.contains("CI: cargo test"));
Ok(())
}
#[test]
fn expand_variables_expands_config_ci_gate_enabled() -> Result<()> {
let template = "Enabled: {{config.agent.ci_gate_enabled}}";
let mut config = default_config();
config.agent.ci_gate = Some(crate::contracts::CiGateConfig {
enabled: Some(false),
argv: None,
});
let result = expand_variables(template, &config)?;
assert!(result.contains("Enabled: false"));
Ok(())
}
#[test]
fn expand_variables_expands_git_publish_mode() -> Result<()> {
let template = "Git publish mode: {{config.agent.git_publish_mode}}";
let mut config = default_config();
config.agent.git_publish_mode = Some(crate::contracts::GitPublishMode::Commit);
let result = expand_variables(template, &config)?;
assert!(result.contains("Git publish mode: commit"));
Ok(())
}
#[test]
fn expand_variables_leaves_non_config_placeholders() -> Result<()> {
let template = "Request: {{USER_REQUEST}}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert!(result.contains("{{USER_REQUEST}}"));
Ok(())
}
#[test]
fn expand_variables_mixed_env_and_config() -> Result<()> {
let template = "Model: {{config.agent.model}}, Var: ${TEST:-default}";
let mut config = default_config();
config.agent.model = Some(crate::contracts::Model::Gpt53Codex);
let result = expand_variables(template, &config)?;
assert!(result.contains("gpt-5.3-codex"));
assert!(result.contains("Var: default"));
Ok(())
}
#[test]
fn expand_variables_invalid_config_path_left_literal() -> Result<()> {
let template = "Value: {{config.invalid.path}}";
let config = default_config();
let result = expand_variables(template, &config)?;
assert!(result.contains("{{config.invalid.path}}"));
Ok(())
}