outrig 0.1.0

Run LLM agents with podman-isolated MCP servers (library crate).
Documentation
//! Integration tests for the config schema: round-trip parsing, unknown-key
//! rejection, and MCP shape parity.

use std::collections::BTreeMap;
use std::path::PathBuf;

use outrig::config::{
    CapabilityProfile, Config, EnvValue, LlmProvider, McpServerSpec, MountAccess,
};
use outrig::error::OutrigError;

const FIXTURE: &str = include_str!("fixtures/config-full.toml");

mod config_schema {
    use super::*;

    #[test]
    fn fixture_round_trips_through_serde() {
        let parsed = Config::load_from_str(FIXTURE).expect("fixture parses");
        let reserialized = toml::to_string(&parsed).expect("config serializes");
        let again = Config::load_from_str(&reserialized).expect("reserialized parses");
        assert_eq!(parsed, again);
    }

    #[test]
    fn unknown_top_level_key_rejected() {
        let bad = r#"
default-agent = "coding"
oops          = "this key is not in the schema"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::Config(toml_err) = err else {
            panic!("expected OutrigError::Config, got: {err:?}");
        };
        let msg = toml_err.to_string();
        assert!(
            msg.contains("oops"),
            "error message should point at the offending key, got: {msg}",
        );
    }

    #[test]
    fn dotted_model_name_gets_quoting_hint() {
        let bad = r#"
[models.opus-4.7]
provider   = "anthropic"
identifier = "claude-opus-4-7"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::ConfigDottedKey { source } = &err else {
            panic!("expected OutrigError::ConfigDottedKey, got: {err:?}");
        };
        let inner = source.to_string();
        assert!(
            inner.contains("opus-4"),
            "inner toml error should still point at the offending header, got: {inner}",
        );
        let rendered = err.to_string();
        assert!(
            rendered.contains("help: key names containing `.` must be quoted"),
            "rendered error should carry the quoting hint, got: {rendered}",
        );
    }

    #[test]
    fn dotted_container_name_also_gets_quoting_hint() {
        // Same heuristic must fire for sections other than `[models]` --
        // here, an image whose name was meant to be `my.thing`.
        let bad = r#"
[images.my.thing]
dockerfile = "D"
context    = "ctx"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        assert!(
            matches!(err, OutrigError::ConfigDottedKey { .. }),
            "expected OutrigError::ConfigDottedKey, got: {err:?}",
        );
    }

    #[test]
    fn mcp_short_and_full_normalize_equal() {
        let short_toml = r#"
[images.c]
dockerfile = "D"
context    = "ctx"

[images.c.mcp]
srv = ["bin", "arg1"]
"#;
        let full_toml = r#"
[images.c]
dockerfile = "D"
context    = "ctx"

[images.c.mcp]
srv = { command = ["bin", "arg1"] }
"#;

        let short = Config::load_from_str(short_toml).expect("short form parses");
        let full = Config::load_from_str(full_toml).expect("full form parses");

        let short_spec = short.images["c"].mcp["srv"].clone();
        let full_spec = full.images["c"].mcp["srv"].clone();

        assert!(
            matches!(short_spec, McpServerSpec::Short(_)),
            "array form should parse to Short, got: {short_spec:?}",
        );
        assert!(
            matches!(full_spec, McpServerSpec::Full { .. }),
            "table form should parse to Full, got: {full_spec:?}",
        );

        assert_eq!(short_spec.normalize(), full_spec.normalize());
        assert_eq!(
            short.images["c"].mcp["srv"].normalize(),
            (vec!["bin".to_string(), "arg1".to_string()], BTreeMap::new()),
        );
    }

    #[test]
    fn fixture_spot_checks_match_documented_keys() {
        let cfg = Config::load_from_str(FIXTURE).expect("fixture parses");

        assert_eq!(cfg.default_image.as_deref(), Some("coding"));
        assert_eq!(cfg.default_agent.as_deref(), Some("coding"));
        assert_eq!(cfg.default_model.as_deref(), Some("fast"));
        assert_eq!(
            cfg.session_root.as_deref(),
            Some(std::path::Path::new("/var/lib/outrig/sessions")),
        );
        assert_eq!(
            cfg.model_cache_root.as_deref(),
            Some(std::path::Path::new("/var/cache/outrig/models")),
        );
        assert_eq!(cfg.tool_call_max, Some(100));
        assert_eq!(cfg.tool_result_max, Some(524288));

        let LlmProvider::OpenAi {
            base_url,
            api_key,
            request_timeout_secs,
        } = &cfg.providers["openai"]
        else {
            panic!("expected OpenAi variant for [providers.openai]");
        };
        assert_eq!(base_url, "https://api.openai.com/v1");
        assert_eq!(api_key.var_name(), "OPENAI_API_KEY");
        assert_eq!(*request_timeout_secs, Some(90));
        let LlmProvider::OpenAi {
            request_timeout_secs: anthropic_timeout,
            ..
        } = &cfg.providers["anthropic"]
        else {
            panic!("expected OpenAi variant for [providers.anthropic]");
        };
        assert_eq!(*anthropic_timeout, None);

        assert_eq!(cfg.models["fast"].provider, "openai");
        assert_eq!(
            cfg.models["fast"].identifier.as_deref(),
            Some("gpt-4o-mini")
        );

        // mistralrs-side models carry weight fields and no identifier.
        let phi3 = &cfg.models["phi3-fast"];
        assert_eq!(phi3.provider, "local");
        assert_eq!(phi3.identifier, None);
        assert_eq!(
            phi3.model_id.as_deref(),
            Some("microsoft/Phi-3-mini-4k-instruct-gguf")
        );
        assert_eq!(
            phi3.model_file.as_deref(),
            Some(&["Phi-3-mini-4k-instruct-q4.gguf".to_string()][..])
        );
        assert_eq!(phi3.device.as_deref(), Some("cpu"));
        let llama = &cfg.models["llama-local"];
        assert_eq!(llama.provider, "local");
        assert_eq!(llama.identifier, None);
        assert_eq!(
            llama.model_path.as_deref(),
            Some(std::path::Path::new(
                ".agents/outrig/models/llama-3-8b-instruct.q4.gguf"
            )),
        );
        assert_eq!(llama.context_length, Some(4096));
        assert!(matches!(cfg.providers["local"], LlmProvider::Mistralrs));

        let coding = &cfg.agents["coding"];
        assert_eq!(coding.model, None);
        assert_eq!(coding.image.as_deref(), Some("coding"));
        assert_eq!(coding.temperature, Some(0.2));
        assert_eq!(coding.max_tokens, Some(4096));
        assert_eq!(coding.tool_call_max, Some(300));
        assert_eq!(coding.tool_result_max, Some(1048576));
        assert_eq!(cfg.agents["review"].model.as_deref(), Some("smart"));

        assert_eq!(cfg.workspace.host_path, PathBuf::from("."));
        assert_eq!(cfg.workspace.container_path, PathBuf::from("/workspace"));
        assert_eq!(cfg.workspace.mounts.len(), 2);
        assert_eq!(
            cfg.workspace.mounts[0].host_path,
            PathBuf::from(".agents/outrig/resources/docs"),
        );
        assert_eq!(
            cfg.workspace.mounts[0].container_path,
            PathBuf::from("/resources/docs"),
        );
        assert_eq!(cfg.workspace.mounts[0].access, MountAccess::ReadOnly);
        assert_eq!(
            cfg.workspace.mounts[1].host_path,
            PathBuf::from(".agents/outrig/resources/cache"),
        );
        assert_eq!(
            cfg.workspace.mounts[1].container_path,
            PathBuf::from("/resources/cache"),
        );
        assert_eq!(cfg.workspace.mounts[1].access, MountAccess::ReadWrite);

        let coding_ctr = &cfg.images["coding"];
        assert_eq!(
            coding_ctr.dockerfile,
            Some(PathBuf::from(".agents/outrig/images/coding/Dockerfile")),
        );
        assert_eq!(
            coding_ctr.context,
            Some(PathBuf::from(".agents/outrig/images/coding")),
        );
        // Inner map keys (build-args ARG names, mcp env-var names) keep user
        // casing -- they're not subject to the outer `rename_all = kebab-case`.
        assert_eq!(
            coding_ctr.build_args["NODE_VERSION"],
            EnvValue::Literal("20".to_string()),
        );
        assert_eq!(
            coding_ctr.security.capability_profile,
            CapabilityProfile::NoNetRaw,
        );
        assert_eq!(coding_ctr.security.cap_drop, ["MKNOD", "SETFCAP"]);
        assert_eq!(coding_ctr.security.cap_add, ["NET_BIND_SERVICE"]);

        assert!(matches!(coding_ctr.mcp["shell"], McpServerSpec::Short(_)));
        let (fs_cmd, fs_env) = coding_ctr.mcp["fs"].normalize();
        assert_eq!(fs_cmd, vec!["mcp-server-filesystem", "/workspace"]);
        assert!(fs_env.is_empty());
        let (build_cmd, build_env) = coding_ctr.mcp["build"].normalize();
        assert_eq!(build_cmd, vec!["cargo-mcp"]);
        assert_eq!(
            build_env["CARGO_HOME"],
            EnvValue::Literal("/workspace/.cargo".to_string()),
        );
    }

    #[test]
    fn workspace_table_absent_yields_documented_defaults() {
        let cfg = Config::load_from_str("").expect("empty config parses");
        assert_eq!(cfg.workspace.host_path, PathBuf::from("."));
        assert_eq!(cfg.workspace.container_path, PathBuf::from("/workspace"));
        assert!(cfg.workspace.mounts.is_empty());
    }

    #[test]
    fn container_security_absent_yields_documented_defaults() {
        let cfg = Config::load_from_str(
            r#"
[images.coding]
dockerfile = "D"
context    = "ctx"
"#,
        )
        .expect("config parses");
        let security = &cfg.images["coding"].security;
        assert_eq!(security.capability_profile, CapabilityProfile::Default);
        assert!(security.cap_drop.is_empty());
        assert!(security.cap_add.is_empty());
    }

    #[test]
    fn capability_profile_values_are_validated_by_schema() {
        let bad = r#"
[images.coding]
dockerfile = "D"
context    = "ctx"

[images.coding.security]
capability-profile = "wide-open"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::Config(toml_err) = err else {
            panic!("expected OutrigError::Config, got: {err:?}");
        };
        let msg = toml_err.to_string();
        assert!(
            msg.contains("wide-open") || msg.contains("unknown variant"),
            "error should explain invalid capability profile, got: {msg}",
        );
    }

    #[test]
    fn workspace_mount_access_values_are_validated_by_schema() {
        let bad = r#"
[[workspace.mounts]]
host-path      = "docs"
container-path = "/resources/docs"
access         = "write-mostly"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::Config(toml_err) = err else {
            panic!("expected OutrigError::Config, got: {err:?}");
        };
        let msg = toml_err.to_string();
        assert!(
            msg.contains("write-mostly") || msg.contains("unknown variant"),
            "error should explain invalid mount access, got: {msg}",
        );
    }

    #[test]
    fn network_action_values_are_validated_by_schema() {
        let bad = r#"
[network]
default = "ask"
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::Config(toml_err) = err else {
            panic!("expected OutrigError::Config, got: {err:?}");
        };
        let msg = toml_err.to_string();
        assert!(
            msg.contains("ask") || msg.contains("unknown variant"),
            "error should explain invalid network action, got: {msg}",
        );
    }

    #[test]
    fn network_entry_unknown_fields_are_rejected() {
        let bad = r#"
[network]
allow = [{ host = "example.com", method = "GET" }]
"#;
        let err = Config::load_from_str(bad).unwrap_err();
        let OutrigError::Config(toml_err) = err else {
            panic!("expected OutrigError::Config, got: {err:?}");
        };
        let msg = toml_err.to_string();
        assert!(
            msg.contains("method")
                || msg.contains("unknown field")
                || msg.contains("did not match any variant"),
            "error should reject unknown network entry field, got: {msg}",
        );
    }
}