roboticus-core 0.11.1

Shared types, config parsing, personality system, and error types for the Roboticus agent runtime
Documentation
    #[test]
    fn validate_empty_agent_name_fails() {
        let toml = r#"
[agent]
name = ""
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("agent.name"));
    }
    #[test]
    fn validate_empty_agent_id_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = ""

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("agent.id"));
    }
    #[test]
    fn validate_empty_model_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = ""
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("models.primary"));
    }

    #[test]
    fn validate_invalid_bind_address_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999
bind = "not-an-ip"

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("not a valid IP"));
    }

    #[test]
    fn validate_localhost_bind_ok() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999
bind = "localhost"

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"
"#;
        RoboticusConfig::from_str(toml).unwrap();
    }

    #[test]
    fn validate_invalid_session_scope_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"

[session]
scope_mode = "invalid"
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("scope_mode"));
    }

    #[test]
    fn validate_group_scope_ok() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"

[session]
scope_mode = "group"
"#;
        RoboticusConfig::from_str(toml).unwrap();
    }

    #[test]
    fn validate_negative_minimum_reserve_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"

[treasury]
minimum_reserve = -1.0
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("minimum_reserve"));
    }

    #[test]
    fn validate_zero_payment_cap_fails() {
        let toml = r#"
[agent]
name = "TestBot"
id = "test"

[server]
port = 9999

[database]
path = "/tmp/test.db"

[models]
primary = "ollama/qwen3:8b"

[treasury]
per_payment_cap = 0.0
"#;
        let err = RoboticusConfig::from_str(toml).unwrap_err();
        assert!(err.to_string().contains("per_payment_cap"));
    }

    // ── startup_announcement_channels coverage ──────────────────────────

    #[test]
    fn startup_announcements_none_returns_empty() {
        let cfg = ChannelsConfig::default();
        assert!(cfg.startup_announcement_channels().is_empty());
    }

    #[test]
    fn startup_announcements_flag_returns_empty() {
        let cfg = ChannelsConfig {
            startup_announcements: Some(StartupAnnouncementsConfig::Flag(true)),
            ..ChannelsConfig::default()
        };
        assert!(cfg.startup_announcement_channels().is_empty());
    }

    #[test]
    fn startup_announcements_text_returns_normalized() {
        let cfg = ChannelsConfig {
            startup_announcements: Some(StartupAnnouncementsConfig::Text("Telegram".into())),
            ..ChannelsConfig::default()
        };
        assert_eq!(cfg.startup_announcement_channels(), vec!["telegram"]);
    }

    #[test]
    fn startup_announcements_text_none_variant() {
        let cfg = ChannelsConfig {
            startup_announcements: Some(StartupAnnouncementsConfig::Text("none".into())),
            ..ChannelsConfig::default()
        };
        assert!(cfg.startup_announcement_channels().is_empty());
    }

    #[test]
    fn startup_announcements_channels_dedup_and_sort() {
        let cfg = ChannelsConfig {
            startup_announcements: Some(StartupAnnouncementsConfig::Channels(vec![
                "whatsapp".into(),
                "telegram".into(),
                "TELEGRAM".into(),
                "none".into(),
            ])),
            ..ChannelsConfig::default()
        };
        let ch = cfg.startup_announcement_channels();
        assert_eq!(ch, vec!["telegram", "whatsapp"]);
    }

    // ── expand_tilde coverage ───────────────────────────────────────────

    #[test]
    fn expand_tilde_no_tilde() {
        let p = PathBuf::from("/absolute/path");
        assert_eq!(expand_tilde(&p), p);
    }

    #[test]
    fn expand_tilde_with_tilde() {
        let p = PathBuf::from("~/Documents/vault");
        let expanded = expand_tilde(&p);
        assert!(!expanded.to_str().unwrap().starts_with("~"));
        assert!(expanded.to_str().unwrap().contains("Documents/vault"));
    }

    // ── ProviderConfig::new ─────────────────────────────────────────────

    #[test]
    fn provider_config_new() {
        let pc = ProviderConfig::new("http://localhost:11434", "T1");
        assert_eq!(pc.url, "http://localhost:11434");
        assert_eq!(pc.tier, "T1");
        assert!(pc.format.is_none());
        assert!(pc.api_key_env.is_none());
        assert!(pc.is_local.is_none());
        assert!(pc.tpm_limit.is_none());
        assert!(pc.rpm_limit.is_none());
    }

    // ── MCP transport default ───────────────────────────────────────────

    #[test]
    fn mcp_transport_default_is_stdio() {
        let t = McpTransport::default();
        assert!(matches!(t, McpTransport::Stdio));
    }

    // ── McpServerConfig deserialization ─────────────────────────────────

    #[test]
    fn mcp_server_config_stdio_deserializes() {
        let toml = r#"
            name = "test-server"
            enabled = true
            [spec]
            type = "stdio"
            command = "npx"
            args = ["-y", "@modelcontextprotocol/server-test"]
        "#;
        let cfg: McpServerConfig = toml::from_str(toml).unwrap();
        assert_eq!(cfg.name, "test-server");
        assert!(cfg.enabled);
        assert!(matches!(cfg.spec, McpServerSpec::Stdio { .. }));
        if let McpServerSpec::Stdio { command, args, .. } = &cfg.spec {
            assert_eq!(command, "npx");
            assert_eq!(args, &["-y", "@modelcontextprotocol/server-test"]);
        }
    }

    #[test]
    fn mcp_server_config_sse_deserializes() {
        let toml = r#"
            name = "remote-server"
            enabled = true
            [spec]
            type = "sse"
            url = "http://localhost:3001/sse"
        "#;
        let cfg: McpServerConfig = toml::from_str(toml).unwrap();
        assert_eq!(cfg.name, "remote-server");
        assert!(cfg.enabled);
        assert!(matches!(cfg.spec, McpServerSpec::Sse { .. }));
        if let McpServerSpec::Sse { url } = &cfg.spec {
            assert_eq!(url, "http://localhost:3001/sse");
        }
    }

    #[test]
    fn mcp_config_servers_field_deserializes() {
        let toml = r#"
            server_enabled = false
            server_port = 3001

            [[servers]]
            name = "github"
            enabled = true
            [servers.spec]
            type = "stdio"
            command = "gh"
            args = ["mcp", "serve"]
        "#;
        let cfg: McpConfig = toml::from_str(toml).unwrap();
        assert_eq!(cfg.servers.len(), 1);
        assert_eq!(cfg.servers[0].name, "github");
        assert!(cfg.clients.is_empty());
    }

    #[test]
    fn mcp_config_legacy_clients_still_deserializes() {
        let toml = r#"
            server_enabled = false
            server_port = 3001

            [[clients]]
            name = "legacy-server"
            url = "http://localhost:3001/sse"
        "#;
        let cfg: McpConfig = toml::from_str(toml).unwrap();
        assert_eq!(cfg.clients.len(), 1);
        assert_eq!(cfg.clients[0].name, "legacy-server");
        assert!(cfg.servers.is_empty());
    }

    // ── home_dir and dirs_next helpers ───────────────────────────────────

    #[test]
    fn home_dir_returns_valid_path() {
        let h = home_dir();
        assert!(h.is_absolute() || h == std::path::Path::new("/tmp"));
    }

    #[test]
    fn dirs_next_appends_roboticus() {
        let d = dirs_next();
        assert!(d.to_str().unwrap().contains(".roboticus"));
    }