s3cli 0.1.1

CLI-first S3 storage for developers and AI agents
Documentation
use super::Provider;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default = "default_provider")]
    pub provider: Provider,

    #[serde(default)]
    pub endpoint: Option<String>,

    #[serde(default = "default_region")]
    pub region: String,

    #[serde(default)]
    pub bucket: String,

    #[serde(default)]
    pub access_key: Option<String>,

    #[serde(default)]
    pub secret_key: Option<String>,

    #[serde(default)]
    pub path: Option<String>,

    #[serde(default = "default_expiry")]
    pub default_expiry: String,

    #[serde(default = "default_true")]
    pub verify_ssl: bool,

    #[serde(default)]
    pub color: String,

    #[serde(default = "default_concurrency")]
    pub upload_concurrency: usize,

    #[serde(default = "default_concurrency")]
    pub download_concurrency: usize,

    #[serde(default = "default_part_size")]
    pub part_size: String,
}

fn default_provider() -> Provider {
    Provider::S3
}

fn default_region() -> String {
    "us-east-1".to_string()
}

fn default_expiry() -> String {
    "7d".to_string()
}

fn default_true() -> bool {
    true
}

fn default_concurrency() -> usize {
    4
}

fn default_part_size() -> String {
    "8MB".to_string()
}

impl Default for Config {
    fn default() -> Self {
        Self {
            provider: Provider::S3,
            endpoint: None,
            region: default_region(),
            bucket: String::new(),
            access_key: None,
            secret_key: None,
            path: None,
            default_expiry: default_expiry(),
            verify_ssl: default_true(),
            color: "auto".to_string(),
            upload_concurrency: default_concurrency(),
            download_concurrency: default_concurrency(),
            part_size: default_part_size(),
        }
    }
}

impl Config {
    pub fn config_dir() -> Option<PathBuf> {
        dirs::config_dir().map(|p| p.join("s3cli"))
    }

    pub fn config_file() -> Option<PathBuf> {
        Self::config_dir().map(|p| p.join("config.toml"))
    }

    pub fn load_config_file() -> Result<Option<Config>, ConfigError> {
        let config_path = Self::config_file();

        if let Some(path) = config_path {
            if path.exists() {
                let contents = std::fs::read_to_string(&path)
                    .map_err(|e| ConfigError::IoError(e.to_string()))?;

                let config: Config = toml::from_str(&contents)
                    .map_err(|e| ConfigError::ParseError(e.to_string()))?;

                return Ok(Some(config));
            }
        }

        Ok(None)
    }

    pub fn validate(&self) -> Result<(), ConfigError> {
        if self.bucket.is_empty() {
            return Err(ConfigError::MissingField("bucket".to_string()));
        }

        if self.provider.requires_endpoint() && self.endpoint.is_none() {
            return Err(ConfigError::MissingEndpoint(self.provider));
        }

        if !matches!(self.provider, Provider::Local) {
            if self.access_key.is_none() || self.secret_key.is_none() {
                return Err(ConfigError::MissingCredentials);
            }
        }

        Ok(())
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("Failed to read config file: {0}")]
    IoError(String),

    #[error("Failed to parse config: {0}")]
    ParseError(String),

    #[error("Missing required field: {0}")]
    MissingField(String),

    #[error("Missing credentials")]
    MissingCredentials,

    #[error("Provider {0} requires an endpoint URL")]
    MissingEndpoint(Provider),
}

impl From<std::io::Error> for ConfigError {
    fn from(err: std::io::Error) -> Self {
        ConfigError::IoError(err.to_string())
    }
}

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

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.provider, Provider::S3);
        assert_eq!(config.region, "us-east-1");
    }

    #[test]
    fn test_provider_from_str() {
        assert_eq!("s3".parse::<Provider>().unwrap(), Provider::S3);
        assert_eq!("r2".parse::<Provider>().unwrap(), Provider::R2);
        assert_eq!("minio".parse::<Provider>().unwrap(), Provider::MinIO);
        assert_eq!("local".parse::<Provider>().unwrap(), Provider::Local);
    }

    #[test]
    fn test_config_to_toml() {
        let config = Config::default();
        let toml = toml::to_string(&config).unwrap();
        assert!(toml.contains("provider"));
        assert!(toml.contains("bucket"));
    }

    #[test]
    fn test_config_from_toml() {
        let toml_str = r#"
provider = "r2"
endpoint = "https://test.r2.cloudflarestorage.com"
region = "auto"
bucket = "test-bucket"
access_key = "test-key"
secret_key = "test-secret"
"#;
        let config: Config = toml::from_str(toml_str).unwrap();
        assert_eq!(config.provider, Provider::R2);
        assert_eq!(config.bucket, "test-bucket");
    }

    #[test]
    fn test_config_validate_missing_bucket() {
        let config = Config::default();
        let result = config.validate();
        assert!(result.is_err());
    }

    #[test]
    fn test_config_validate_r2_requires_endpoint() {
        let mut config = Config::default();
        config.provider = Provider::R2;
        config.bucket = "test".to_string();
        let result = config.validate();
        assert!(result.is_err());
    }

    #[test]
    fn test_config_validate_local_no_credentials() {
        let mut config = Config::default();
        config.provider = Provider::Local;
        config.bucket = "test".to_string();
        let result = config.validate();
        assert!(result.is_ok());
    }
}