mino 1.6.0

Secure AI agent sandbox using rootless containers
Documentation
//! Configuration schema for Mino
//!
//! Configuration is stored at `~/.config/mino/config.toml`

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Root configuration structure
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// General settings
    pub general: GeneralConfig,

    /// OrbStack VM settings
    pub vm: VmConfig,

    /// Container settings
    pub container: ContainerConfig,

    /// Cloud credential settings
    pub credentials: CredentialsConfig,

    /// Session defaults
    pub session: SessionConfig,

    /// Cache settings
    pub cache: CacheConfig,

    /// Home volume settings
    pub home: HomeConfig,
}

/// General application settings
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
    /// Enable verbose logging
    pub verbose: bool,

    /// Log format: "text" or "json"
    pub log_format: String,

    /// Enable audit logging (security events written to state dir)
    pub audit_log: bool,

    /// Enable periodic update checks (default: true)
    pub update_check: bool,
}

impl Default for GeneralConfig {
    fn default() -> Self {
        Self {
            verbose: false,
            log_format: "text".to_string(),
            audit_log: true,
            update_check: true,
        }
    }
}

/// OrbStack VM configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VmConfig {
    /// VM name to use
    pub name: String,

    /// VM distribution
    pub distro: String,
}

impl Default for VmConfig {
    fn default() -> Self {
        Self {
            name: "mino".to_string(),
            distro: "fedora".to_string(),
        }
    }
}

/// Container configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContainerConfig {
    /// Base image to use
    pub image: String,

    /// Environment variables to set
    pub env: HashMap<String, String>,

    /// Additional volume mounts (host:container)
    pub volumes: Vec<String>,

    /// Network mode
    pub network: String,

    /// Working directory inside container
    pub workdir: String,

    /// Allowlisted network destinations (host:port format)
    #[serde(default)]
    pub network_allow: Vec<String>,

    /// Network preset name (e.g., "dev", "registries")
    #[serde(default)]
    pub network_preset: Option<String>,

    /// Composable layers (overrides image when non-empty)
    #[serde(default)]
    pub layers: Vec<String>,

    /// Mount root filesystem as read-only (default: false)
    #[serde(default)]
    pub read_only: bool,
}

impl Default for ContainerConfig {
    fn default() -> Self {
        Self {
            image: "fedora:43".to_string(),
            env: HashMap::new(),
            volumes: vec![],
            network: "bridge".to_string(),
            workdir: "/workspace".to_string(),
            network_allow: vec![],
            network_preset: None,
            layers: vec![],
            read_only: false,
        }
    }
}

/// Cloud credentials configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CredentialsConfig {
    /// AWS settings
    pub aws: AwsConfig,

    /// GCP settings
    pub gcp: GcpConfig,

    /// Azure settings
    pub azure: AzureConfig,

    /// GitHub settings
    pub github: GithubConfig,
}

/// AWS credential settings
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AwsConfig {
    /// Enable AWS credentials via config (equivalent to --aws flag)
    pub enabled: bool,

    /// Session token duration in seconds (default: 1 hour)
    pub session_duration_secs: u32,

    /// IAM role to assume (optional)
    pub role_arn: Option<String>,

    /// External ID for role assumption (optional)
    pub external_id: Option<String>,

    /// AWS profile to use
    pub profile: Option<String>,

    /// AWS region
    pub region: Option<String>,
}

impl Default for AwsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            session_duration_secs: 3600,
            role_arn: None,
            external_id: None,
            profile: None,
            region: None,
        }
    }
}

/// GCP credential settings
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GcpConfig {
    /// Enable GCP credentials via config (equivalent to --gcp flag)
    pub enabled: bool,

    /// GCP project ID
    pub project: Option<String>,

    /// Service account to impersonate
    pub service_account: Option<String>,
}

/// Azure credential settings
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AzureConfig {
    /// Enable Azure credentials via config (equivalent to --azure flag)
    pub enabled: bool,

    /// Azure subscription ID
    pub subscription: Option<String>,

    /// Azure tenant ID
    pub tenant: Option<String>,
}

/// GitHub credential settings
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GithubConfig {
    /// GitHub host (for GitHub Enterprise)
    pub host: String,
}

impl Default for GithubConfig {
    fn default() -> Self {
        Self {
            host: "github.com".to_string(),
        }
    }
}

/// Session configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
    /// Default shell inside container
    pub shell: String,

    /// Auto-cleanup stopped/failed sessions older than N hours (0 = disabled)
    pub auto_cleanup_hours: u32,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            shell: "/bin/bash".to_string(),
            auto_cleanup_hours: 720,
        }
    }
}

/// Home volume configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HomeConfig {
    /// Enable persistent per-project home volumes (default: true)
    pub enabled: bool,
}

impl Default for HomeConfig {
    fn default() -> Self {
        Self { enabled: true }
    }
}

/// Cache configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
    /// Enable dependency caching (default: true)
    pub enabled: bool,

    /// Auto-remove caches older than N days (0 = disabled)
    pub gc_days: u32,

    /// Maximum total cache size in GB before triggering gc
    pub max_total_gb: u32,
}

impl Default for CacheConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            gc_days: 30,
            max_total_gb: 50,
        }
    }
}

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

    #[test]
    fn default_config_serializes() {
        let config = Config::default();
        let toml = toml::to_string_pretty(&config).unwrap();
        assert!(toml.contains("[general]"));
        assert!(toml.contains("[vm]"));
    }

    #[test]
    fn config_deserializes_empty() {
        let config: Config = toml::from_str("").unwrap();
        assert_eq!(config.vm.name, "mino");
    }

    #[test]
    fn config_deserializes_read_only() {
        let toml = r#"
            [container]
            read_only = true
        "#;
        let config: Config = toml::from_str(toml).unwrap();
        assert!(config.container.read_only);
    }

    #[test]
    fn config_read_only_defaults_false() {
        let config: Config = toml::from_str("").unwrap();
        assert!(!config.container.read_only);
    }

    #[test]
    fn config_deserializes_update_check() {
        let toml = r#"
            [general]
            update_check = false
        "#;
        let config: Config = toml::from_str(toml).unwrap();
        assert!(!config.general.update_check);
    }

    #[test]
    fn config_update_check_defaults_true() {
        let config: Config = toml::from_str("").unwrap();
        assert!(config.general.update_check);
    }

    #[test]
    fn config_home_enabled_defaults_true() {
        let config: Config = toml::from_str("").unwrap();
        assert!(config.home.enabled);
    }

    #[test]
    fn config_deserializes_home_disabled() {
        let toml = r#"
            [home]
            enabled = false
        "#;
        let config: Config = toml::from_str(toml).unwrap();
        assert!(!config.home.enabled);
    }

    #[test]
    fn config_deserializes_partial() {
        let toml = r#"
            [vm]
            name = "custom-vm"
        "#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.vm.name, "custom-vm");
        assert_eq!(config.container.image, "fedora:43"); // default preserved
    }
}