//! Loads `.cui-cui.toml` project configuration.
//!
//! Supports layered config: user-level (`$XDG_CONFIG_HOME/cui-cui/config.toml`),
//! repo-level (`.cui-cui.toml`), and CLI flags (highest priority). Defines
//! runner declarations, default theme, pass-env lists, dispatch-skip patterns,
//! and other settings. See [`Config`] for the full set of fields.

use std::path::{Path, PathBuf};

use indexmap::IndexMap;
use log::warn;
use serde::Deserialize;

/// Theme preference as expressed in config files.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ThemePreference {
    Light,
    Dark,
    Auto,
}

/// Configuration for a custom runtime (`[runner.custom]` section).
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct CustomRuntimeConfig {
    /// The command prefix (e.g. `"vng --run --"`).
    pub command: Option<String>,
    /// Shell commands (or `@file` reference) to run before the job.
    pub prepare: Option<String>,
    /// Shell commands (or `@file` reference) to run after the job (always runs).
    pub cleanup: Option<String>,
}

/// Container-specific tuning (`[runner.container]` section).
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct ContainerSectionConfig {
    /// Kernel flag template for the `container` runtime (e.g.
    /// `"$LIMA_KEXEC_ARTIFACT"`). Expanded against job variables; if the
    /// result is non-empty, passed as `--kernel <value>`.
    pub kernel: Option<String>,
    /// Memory limit (e.g. `"8G"`). Passed as `-m <value>`.
    pub memory: Option<String>,
    /// Number of CPUs for the container.
    pub cpus: Option<u32>,
}

/// Backward-compatible deserialization: `exec-prefix` can be either a bare
/// string (old format) or a table with `command`/`prepare`/`cleanup` fields.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ExecPrefixValue {
    Simple(String),
    Section(CustomRuntimeConfig),
}

/// A remote (or local) runner definition.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
#[derive(Default)]
pub struct RunnerConfig {
    /// Human-readable name for this runner.
    pub name: String,
    /// SSH host. `None` means the runner is local (no SSH).
    pub host: Option<String>,
    /// Tags this runner can handle. A runner matches a job when its tags are a
    /// superset of the job's tags.
    #[serde(default)]
    pub tags: Vec<String>,
    /// If true, this runner also picks up jobs with no tags.
    pub no_tags: Option<bool>,
    /// SSH user (defaults to current user).
    pub user: Option<String>,
    /// SSH port (defaults to 22).
    pub port: Option<u16>,
    /// Remote working directory base (defaults to `~/.cui-cui/worktrees`).
    /// Accepts both `working-dir` (new) and `remote-dir` (old) in TOML.
    #[serde(alias = "remote-dir")]
    pub working_dir: Option<String>,
    /// Path to the cui-cui binary on the remote (for no exec-prefix case).
    pub binary: Option<String>,
    /// Container runtime override (`"podman"`, `"docker"`, `"container"`,
    /// or `"custom"`). When unset, auto-detects (podman → docker).
    pub runtime: Option<String>,

    // --- New subsection fields ---
    /// Custom runtime configuration (`[runner.custom]`).
    pub custom: Option<CustomRuntimeConfig>,
    /// Container tuning (`[runner.container]`).
    pub container: Option<ContainerSectionConfig>,

    // --- Deprecated flat fields (backward compatibility) ---
    #[serde(default)]
    pub(crate) exec_prefix: Option<ExecPrefixValue>,
    #[serde(default)]
    pub(crate) prepare: Option<String>,
    #[serde(default)]
    pub(crate) cleanup: Option<String>,
    #[serde(default)]
    pub(crate) kernel: Option<String>,
    #[serde(default)]
    pub(crate) memory: Option<String>,

    /// Maximum number of jobs to run in parallel on this runner (default: 1).
    pub concurrency: Option<usize>,
}

impl RunnerConfig {
    /// Whether this runner uses a custom runtime (exec-prefix mode).
    pub fn has_custom_runtime(&self) -> bool {
        self.custom_command().is_some()
    }

    /// Get the custom runtime command string.
    pub fn custom_command(&self) -> Option<&str> {
        self.custom
            .as_ref()
            .and_then(|c| c.command.as_deref())
            .or(match &self.exec_prefix {
                Some(ExecPrefixValue::Simple(s)) => Some(s),
                Some(ExecPrefixValue::Section(sec)) => sec.command.as_deref(),
                None => None,
            })
    }

    /// Get the prepare script (new section takes precedence over flat field).
    pub fn prepare(&self) -> Option<&str> {
        self.custom
            .as_ref()
            .and_then(|c| c.prepare.as_deref())
            .or(match &self.exec_prefix {
                Some(ExecPrefixValue::Section(sec)) => sec.prepare.as_deref(),
                _ => None,
            })
            .or(self.prepare.as_deref())
    }

    /// Get the cleanup script (new section takes precedence over flat field).
    pub fn cleanup(&self) -> Option<&str> {
        self.custom
            .as_ref()
            .and_then(|c| c.cleanup.as_deref())
            .or(match &self.exec_prefix {
                Some(ExecPrefixValue::Section(sec)) => sec.cleanup.as_deref(),
                _ => None,
            })
            .or(self.cleanup.as_deref())
    }

    /// Get the kernel template (container section takes precedence).
    pub fn kernel(&self) -> Option<&str> {
        self.container
            .as_ref()
            .and_then(|c| c.kernel.as_deref())
            .or(self.kernel.as_deref())
    }

    /// Get the memory limit (container section takes precedence).
    pub fn memory(&self) -> Option<&str> {
        self.container
            .as_ref()
            .and_then(|c| c.memory.as_deref())
            .or(self.memory.as_deref())
    }

    /// Get the CPU count (only available via container section).
    pub fn cpus(&self) -> Option<u32> {
        self.container.as_ref().and_then(|c| c.cpus)
    }

    /// Get the working directory (renamed from remote-dir).
    pub fn working_dir(&self) -> Option<&str> {
        self.working_dir.as_deref()
    }

    /// Maximum parallel jobs on this runner (defaults to 1).
    pub fn concurrency(&self) -> usize {
        self.concurrency.unwrap_or(1).max(1)
    }
}

/// Settings loaded from a TOML config file.
/// All fields are optional so partial configs merge cleanly.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
    pub theme: Option<ThemePreference>,
    pub gitlab_url: Option<String>,
    /// Override the container registry hostname (e.g. `"registry.gitlab.gnome.org"`).
    pub ci_registry: Option<String>,
    pub no_overlay: Option<bool>,
    pub pass_env: Option<Vec<String>>,
    pub ci_config: Option<String>,
    /// Override the CI project path (e.g. "group/project").
    /// Overrides the project path detected from the git remote.
    pub ci_project: Option<String>,
    pub dispatch_skip: Option<Vec<String>>,
    pub allow_unresolved: Option<bool>,
    /// Container image used for git operations (clone, diff apply) in dispatch mode.
    pub git_image: Option<String>,
    /// Extra variables to inject into all jobs. These override job-level
    /// variables but are overridden by `-e` CLI flags.
    pub variables: Option<IndexMap<String, String>>,
    /// Runner definitions for remote or local execution.
    #[serde(rename = "runner")]
    pub runners: Option<Vec<RunnerConfig>>,
}

/// Return the XDG config directory for cui-cui.
fn config_dir() -> Option<PathBuf> {
    let base = std::env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .ok()
        .filter(|p| p.is_absolute())
        .or_else(|| {
            std::env::var("HOME")
                .ok()
                .map(|h| PathBuf::from(h).join(".config"))
        })?;
    Some(base.join("cui-cui"))
}

/// Load the user-level config from `$XDG_CONFIG_HOME/cui-cui/config.toml`.
/// Returns `Config::default()` if the file doesn't exist.
pub fn load_user_config() -> Config {
    let Some(path) = config_dir().map(|d| d.join("config.toml")) else {
        return Config::default();
    };
    load_config_file(&path)
}

/// Load the repo-level config from `.cui-cui.toml` in the given directory.
/// If `target` is a file, checks its parent directory.
pub fn load_repo_config(target: &Path) -> Config {
    let dir = if target.is_dir() {
        target
    } else {
        target.parent().unwrap_or(target)
    };
    load_config_file(&dir.join(".cui-cui.toml"))
}

/// Read and parse a config file. Returns `Config::default()` if the file
/// doesn't exist. Logs a warning and returns defaults if parsing fails.
fn load_config_file(path: &Path) -> Config {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Config::default(),
        Err(e) => {
            warn!("Failed to read {}: {}", path.display(), e);
            return Config::default();
        }
    };
    match toml::from_str(&content) {
        Ok(c) => c,
        Err(e) => {
            warn!("Failed to parse {}: {}", path.display(), e);
            Config::default()
        }
    }
}

/// Merge two configs. `overlay` values take precedence over `base`.
pub fn merge(base: Config, overlay: Config) -> Config {
    Config {
        theme: overlay.theme.or(base.theme),
        gitlab_url: overlay.gitlab_url.or(base.gitlab_url),
        ci_registry: overlay.ci_registry.or(base.ci_registry),
        no_overlay: overlay.no_overlay.or(base.no_overlay),
        pass_env: overlay.pass_env.or(base.pass_env),
        ci_config: overlay.ci_config.or(base.ci_config),
        ci_project: overlay.ci_project.or(base.ci_project),
        dispatch_skip: overlay.dispatch_skip.or(base.dispatch_skip),
        allow_unresolved: overlay.allow_unresolved.or(base.allow_unresolved),
        git_image: overlay.git_image.or(base.git_image),
        variables: match (base.variables, overlay.variables) {
            (None, None) => None,
            (Some(b), None) => Some(b),
            (None, Some(o)) => Some(o),
            (Some(mut b), Some(o)) => {
                b.extend(o);
                Some(b)
            }
        },
        runners: overlay.runners.or(base.runners),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_deserialize_full_config() {
        let toml = r#"
theme = "dark"
gitlab-url = "https://gitlab.example.com"
no-overlay = true
pass-env = ["GITHUB_TOKEN", "DOCKER_PASSWORD"]
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.theme, Some(ThemePreference::Dark));
        assert_eq!(
            config.gitlab_url.as_deref(),
            Some("https://gitlab.example.com")
        );
        assert_eq!(config.no_overlay, Some(true));
        assert_eq!(
            config.pass_env.as_deref(),
            Some(&["GITHUB_TOKEN".to_string(), "DOCKER_PASSWORD".to_string()][..])
        );
    }

    #[test]
    fn test_deserialize_partial_config() {
        let toml = r#"
pass-env = ["TOKEN"]
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.theme, None);
        assert_eq!(config.gitlab_url, None);
        assert_eq!(config.no_overlay, None);
        assert_eq!(config.pass_env.as_deref(), Some(&["TOKEN".to_string()][..]));
    }

    #[test]
    fn test_deserialize_empty_config() {
        let config: Config = toml::from_str("").unwrap();
        assert_eq!(config.theme, None);
        assert_eq!(config.no_overlay, None);
        assert_eq!(config.pass_env, None);
    }

    #[test]
    fn test_deserialize_theme_variants() {
        let config: Config = toml::from_str("theme = \"light\"").unwrap();
        assert_eq!(config.theme, Some(ThemePreference::Light));

        let config: Config = toml::from_str("theme = \"auto\"").unwrap();
        assert_eq!(config.theme, Some(ThemePreference::Auto));
    }

    #[test]
    fn test_merge_overlay_wins() {
        let base = Config {
            theme: Some(ThemePreference::Light),
            gitlab_url: Some("https://base.example.com".to_string()),
            ci_registry: None,
            no_overlay: Some(false),
            pass_env: Some(vec!["A".to_string()]),
            ci_config: None,
            ci_project: Some("base/project".to_string()),
            dispatch_skip: None,
            allow_unresolved: None,
            git_image: None,
            variables: None,
            runners: None,
        };
        let overlay = Config {
            theme: Some(ThemePreference::Dark),
            gitlab_url: None,
            ci_registry: None,
            no_overlay: Some(true),
            pass_env: Some(vec!["B".to_string()]),
            ci_config: None,
            ci_project: Some("overlay/project".to_string()),
            dispatch_skip: None,
            allow_unresolved: None,
            git_image: None,
            variables: None,
            runners: None,
        };
        let merged = merge(base, overlay);
        assert_eq!(merged.theme, Some(ThemePreference::Dark));
        assert_eq!(
            merged.gitlab_url.as_deref(),
            Some("https://base.example.com")
        );
        assert_eq!(merged.no_overlay, Some(true));
        assert_eq!(merged.pass_env.as_deref(), Some(&["B".to_string()][..]));
        assert_eq!(merged.ci_project.as_deref(), Some("overlay/project"));
    }

    #[test]
    fn test_merge_base_preserved_when_overlay_none() {
        let base = Config {
            theme: Some(ThemePreference::Light),
            gitlab_url: Some("https://base.example.com".to_string()),
            ci_registry: None,
            no_overlay: Some(true),
            pass_env: Some(vec!["TOKEN".to_string()]),
            ci_config: None,
            ci_project: None,
            dispatch_skip: None,
            allow_unresolved: None,
            git_image: Some("custom/git:latest".to_string()),
            variables: None,
            runners: None,
        };
        let overlay = Config::default();
        let merged = merge(base, overlay);
        assert_eq!(merged.theme, Some(ThemePreference::Light));
        assert_eq!(
            merged.gitlab_url.as_deref(),
            Some("https://base.example.com")
        );
        assert_eq!(merged.no_overlay, Some(true));
        assert_eq!(merged.pass_env.as_deref(), Some(&["TOKEN".to_string()][..]));
        assert_eq!(merged.git_image.as_deref(), Some("custom/git:latest"));
    }

    #[test]
    fn test_deserialize_git_image() {
        let toml = r#"git-image = "my-registry.com/git:v2""#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.git_image.as_deref(), Some("my-registry.com/git:v2"));
    }

    #[test]
    fn test_merge_git_image_overlay_wins() {
        let base = Config {
            git_image: Some("base/git:1".to_string()),
            ..Config::default()
        };
        let overlay = Config {
            git_image: Some("overlay/git:2".to_string()),
            ..Config::default()
        };
        let merged = merge(base, overlay);
        assert_eq!(merged.git_image.as_deref(), Some("overlay/git:2"));
    }

    #[test]
    fn test_deserialize_ci_project() {
        let toml = r#"ci-project = "group/project""#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.ci_project.as_deref(), Some("group/project"));
    }

    #[test]
    fn test_merge_ci_project_overlay_wins() {
        let base = Config {
            ci_project: Some("base/project".to_string()),
            ..Config::default()
        };
        let overlay = Config {
            ci_project: Some("overlay/project".to_string()),
            ..Config::default()
        };
        let merged = merge(base, overlay);
        assert_eq!(merged.ci_project.as_deref(), Some("overlay/project"));
    }

    #[test]
    fn test_merge_ci_project_base_preserved_when_overlay_none() {
        let base = Config {
            ci_project: Some("base/project".to_string()),
            ..Config::default()
        };
        let overlay = Config::default();
        let merged = merge(base, overlay);
        assert_eq!(merged.ci_project.as_deref(), Some("base/project"));
    }

    #[test]
    fn test_load_missing_file_returns_default() {
        let config = load_config_file(Path::new("/nonexistent/path/config.toml"));
        assert_eq!(config.theme, None);
        assert_eq!(config.pass_env, None);
    }

    #[test]
    fn test_deserialize_runners_old_format() {
        let toml = r#"
[[runner]]
name = "linux-builder"
host = "build-server.local"
tags = ["kvm", "x86_64"]
no-tags = true

[[runner]]
name = "vng-tester"
tags = ["kvm"]
exec-prefix = "vng --run --"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runners = config.runners.unwrap();
        assert_eq!(runners.len(), 2);

        assert_eq!(runners[0].name, "linux-builder");
        assert_eq!(runners[0].host.as_deref(), Some("build-server.local"));
        assert_eq!(runners[0].tags, vec!["kvm", "x86_64"]);
        assert_eq!(runners[0].no_tags, Some(true));
        assert!(!runners[0].has_custom_runtime());

        assert_eq!(runners[1].name, "vng-tester");
        assert!(runners[1].host.is_none());
        assert_eq!(runners[1].tags, vec!["kvm"]);
        assert_eq!(runners[1].custom_command(), Some("vng --run --"));
        assert!(runners[1].has_custom_runtime());
    }

    #[test]
    fn test_deserialize_runner_all_flat_fields() {
        let toml = r#"
[[runner]]
name = "mac-tester"
host = "mac-mini.local"
tags = ["macos", "arm64"]
exec-prefix = "limactl shell kernel-test --"
prepare = "@runners/lima-prepare.sh"
cleanup = "limactl stop kernel-test"
user = "ci"
port = 2222
remote-dir = "/opt/ci/worktrees"
binary = "/usr/local/bin/cui-cui"
memory = "8G"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.name, "mac-tester");
        assert_eq!(runner.host.as_deref(), Some("mac-mini.local"));
        assert_eq!(
            runner.custom_command(),
            Some("limactl shell kernel-test --")
        );
        assert_eq!(runner.prepare(), Some("@runners/lima-prepare.sh"));
        assert_eq!(runner.cleanup(), Some("limactl stop kernel-test"));
        assert_eq!(runner.user.as_deref(), Some("ci"));
        assert_eq!(runner.port, Some(2222));
        assert_eq!(runner.working_dir(), Some("/opt/ci/worktrees"));
        assert_eq!(runner.binary.as_deref(), Some("/usr/local/bin/cui-cui"));
        assert_eq!(runner.memory(), Some("8G"));
    }

    #[test]
    fn test_deserialize_runner_new_custom_section() {
        let toml = r#"
[[runner]]
name = "vng-tester"
host = "miniplouf"
tags = ["kvm"]
runtime = "custom"

[runner.custom]
command = "$PODMAN_UNSHARE vng --run --"
prepare = "@examples/vng-runner/vng-prepare.sh"
cleanup = "@examples/vng-runner/vng-cleanup.sh"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.name, "vng-tester");
        assert_eq!(runner.runtime.as_deref(), Some("custom"));
        assert!(runner.has_custom_runtime());
        assert_eq!(
            runner.custom_command(),
            Some("$PODMAN_UNSHARE vng --run --")
        );
        assert_eq!(
            runner.prepare(),
            Some("@examples/vng-runner/vng-prepare.sh")
        );
        assert_eq!(
            runner.cleanup(),
            Some("@examples/vng-runner/vng-cleanup.sh")
        );
    }

    #[test]
    fn test_deserialize_runner_new_container_section() {
        let toml = r#"
[[runner]]
name = "mac-tester"
host = "mac-mini.local"
tags = ["macos"]
runtime = "container"

[runner.container]
kernel = "$LIMA_KEXEC_ARTIFACT"
memory = "8G"
cpus = 4
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.runtime.as_deref(), Some("container"));
        assert_eq!(runner.kernel(), Some("$LIMA_KEXEC_ARTIFACT"));
        assert_eq!(runner.memory(), Some("8G"));
        assert_eq!(runner.cpus(), Some(4));
    }

    #[test]
    fn test_container_section_overrides_flat_fields() {
        let toml = r#"
[[runner]]
name = "test"
memory = "4G"
kernel = "old-kernel"

[runner.container]
memory = "8G"
kernel = "new-kernel"
cpus = 2
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.memory(), Some("8G"));
        assert_eq!(runner.kernel(), Some("new-kernel"));
        assert_eq!(runner.cpus(), Some(2));
    }

    #[test]
    fn test_working_dir_alias_remote_dir() {
        let toml = r#"
[[runner]]
name = "old-style"
remote-dir = "/opt/ci/worktrees"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.working_dir(), Some("/opt/ci/worktrees"));

        let toml = r#"
[[runner]]
name = "new-style"
working-dir = "/opt/ci/worktrees"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.working_dir(), Some("/opt/ci/worktrees"));
    }

    #[test]
    fn test_deserialize_no_runners() {
        let config: Config = toml::from_str("").unwrap();
        assert!(config.runners.is_none());
    }

    #[test]
    fn test_merge_runners_overlay_replaces() {
        let base_runner = RunnerConfig {
            name: "base-runner".to_string(),
            host: Some("base.local".to_string()),
            tags: vec!["base".to_string()],
            ..Default::default()
        };
        let overlay_runner = RunnerConfig {
            name: "overlay-runner".to_string(),
            host: Some("overlay.local".to_string()),
            tags: vec!["overlay".to_string()],
            ..Default::default()
        };
        let base = Config {
            runners: Some(vec![base_runner]),
            ..Config::default()
        };
        let overlay = Config {
            runners: Some(vec![overlay_runner]),
            ..Config::default()
        };
        let merged = merge(base, overlay);
        let runners = merged.runners.unwrap();
        assert_eq!(runners.len(), 1);
        assert_eq!(runners[0].name, "overlay-runner");
    }

    #[test]
    fn test_deserialize_runner_concurrency() {
        let toml = r#"
[[runner]]
name = "parallel-builder"
host = "build.local"
concurrency = 4
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let runner = &config.runners.unwrap()[0];
        assert_eq!(runner.concurrency(), 4);
    }

    #[test]
    fn test_runner_concurrency_defaults_to_one() {
        let runner = RunnerConfig::default();
        assert_eq!(runner.concurrency(), 1);
    }

    #[test]
    fn test_merge_runners_base_preserved_when_overlay_none() {
        let base_runner = RunnerConfig {
            name: "base-runner".to_string(),
            host: Some("base.local".to_string()),
            tags: vec!["kvm".to_string()],
            ..Default::default()
        };
        let base = Config {
            runners: Some(vec![base_runner]),
            ..Config::default()
        };
        let overlay = Config::default();
        let merged = merge(base, overlay);
        let runners = merged.runners.unwrap();
        assert_eq!(runners.len(), 1);
        assert_eq!(runners[0].name, "base-runner");
    }

    #[test]
    fn test_deserialize_variables() {
        let toml = r#"
[variables]
TEST_TARGET = "vim2m vimc"
TIMEOUT = "300"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let vars = config.variables.unwrap();
        assert_eq!(vars.get("TEST_TARGET").unwrap(), "vim2m vimc");
        assert_eq!(vars.get("TIMEOUT").unwrap(), "300");
    }

    #[test]
    fn test_merge_variables() {
        let base = Config {
            variables: Some(IndexMap::from([
                ("A".to_string(), "1".to_string()),
                ("B".to_string(), "2".to_string()),
            ])),
            ..Config::default()
        };
        let overlay = Config {
            variables: Some(IndexMap::from([(
                "B".to_string(),
                "overridden".to_string(),
            )])),
            ..Config::default()
        };
        let merged = merge(base, overlay);
        let vars = merged.variables.unwrap();
        assert_eq!(vars.get("A").unwrap(), "1");
        assert_eq!(vars.get("B").unwrap(), "overridden");
    }
}
