cueloop 0.6.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Variable expansion and placeholder validation tests.
//!
//! Purpose:
//! - Variable expansion and placeholder validation tests.
//!
//! Responsibilities: validate environment variable expansion, config variable expansion,
//!
//! Scope:
//! - Limited to this file's owning feature boundary.
//!   and placeholder detection.
//!
//! Not handled: prompt rendering or file loading.
//!
//! Usage:
//! - Used through the crate module tree or integration test harness.
//!
//! Invariants/assumptions: env vars with CUELOOP_TEST_ prefix are safe to manipulate;
//! config paths follow dot notation.

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(())
}