msy 0.4.2

Modern musl rsync alternative - Fast, parallel file synchronization
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

#[derive(Debug, Default, Deserialize)]
pub struct Config {
    #[serde(default)]
    #[allow(dead_code)] // Config infrastructure for future use
    pub defaults: Defaults,
    #[serde(default)]
    pub profiles: HashMap<String, Profile>,
}

#[derive(Debug, Default, Deserialize)]
pub struct Defaults {
    #[allow(dead_code)] // Global default for future use
    pub parallel: Option<usize>,
    #[allow(dead_code)] // Global default for future use
    pub exclude: Option<Vec<String>>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Profile {
    pub source: Option<String>,
    pub destination: Option<String>,
    pub delete: Option<bool>,
    pub exclude: Option<Vec<String>>,
    pub bwlimit: Option<String>,
    pub resume: Option<bool>,
    pub min_size: Option<String>,
    pub max_size: Option<String>,
    pub parallel: Option<usize>,
    pub dry_run: Option<bool>,
    pub quiet: Option<bool>,
    pub verbose: Option<u8>,
}

impl Config {
    /// Load config from ~/.config/sy/config.toml
    pub fn load() -> Result<Self> {
        let config_path = Self::config_path()?;

        if !config_path.exists() {
            return Ok(Self::default());
        }

        let contents = std::fs::read_to_string(&config_path)
            .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;

        toml::from_str(&contents)
            .with_context(|| format!("Failed to parse config file: {}", config_path.display()))
    }

    /// Get the config file path
    pub fn config_path() -> Result<PathBuf> {
        let config_dir = dirs::config_dir()
            .context("Cannot find config directory (XDG_CONFIG_HOME or ~/.config)")?;

        Ok(config_dir.join("sy").join("config.toml"))
    }

    /// Get a profile by name
    pub fn get_profile(&self, name: &str) -> Option<&Profile> {
        self.profiles.get(name)
    }

    /// List all available profile names
    pub fn list_profiles(&self) -> Vec<&String> {
        let mut names: Vec<&String> = self.profiles.keys().collect();
        names.sort();
        names
    }

    /// Show profile details in human-readable format
    pub fn show_profile(&self, name: &str) -> Option<String> {
        self.get_profile(name).map(|profile| {
            let toml = toml::to_string_pretty(profile)
                .unwrap_or_else(|_| "Error serializing profile".to_string());
            format!("[profiles.{}]\n{}", name, toml)
        })
    }
}

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

    #[test]
    fn test_load_empty_config() {
        let config = Config::default();
        assert_eq!(config.profiles.len(), 0);
        assert!(config.defaults.parallel.is_none());
    }

    #[test]
    fn test_parse_config() {
        let toml = r#"
[defaults]
parallel = 20
exclude = ["*.tmp", ".DS_Store"]

[profiles.test-profile]
source = "~/src"
destination = "~/dst"
delete = true
exclude = ["*.log"]
bwlimit = "10MB"
resume = true
        "#;

        let config: Config = toml::from_str(toml).unwrap();

        assert_eq!(config.defaults.parallel, Some(20));
        assert_eq!(
            config.defaults.exclude,
            Some(vec!["*.tmp".to_string(), ".DS_Store".to_string()])
        );

        let profile = config.get_profile("test-profile").unwrap();
        assert_eq!(profile.source, Some("~/src".to_string()));
        assert_eq!(profile.destination, Some("~/dst".to_string()));
        assert_eq!(profile.delete, Some(true));
        assert_eq!(profile.bwlimit, Some("10MB".to_string()));
        assert_eq!(profile.resume, Some(true));
    }

    #[test]
    fn test_list_profiles() {
        let toml = r#"
[profiles.profile-a]
source = "~/a"

[profiles.profile-b]
source = "~/b"

[profiles.profile-c]
source = "~/c"
        "#;

        let config: Config = toml::from_str(toml).unwrap();
        let profiles = config.list_profiles();

        assert_eq!(profiles.len(), 3);
        assert_eq!(profiles, vec!["profile-a", "profile-b", "profile-c"]);
    }

    #[test]
    fn test_get_profile_missing() {
        let config = Config::default();
        assert!(config.get_profile("nonexistent").is_none());
    }

    #[test]
    fn test_show_profile() {
        let toml = r#"
[profiles.test]
source = "~/src"
destination = "~/dst"
        "#;

        let config: Config = toml::from_str(toml).unwrap();
        let output = config.show_profile("test").unwrap();

        assert!(output.contains("[profiles.test]"));
        assert!(output.contains("source = \"~/src\""));
        assert!(output.contains("destination = \"~/dst\""));
    }

    #[test]
    fn test_config_path() {
        let path = Config::config_path().unwrap();
        // Check path components instead of string representation (cross-platform)
        assert!(path.parent().unwrap().ends_with("sy"));
        assert_eq!(path.file_name().unwrap(), "config.toml");
    }

    #[test]
    fn test_load_nonexistent_config() {
        // Should return default config if file doesn't exist
        // (We can't actually test this without mocking the config dir)
        let config = Config::default();
        assert_eq!(config.profiles.len(), 0);
    }

    #[test]
    fn test_parse_minimal_profile() {
        let toml = r#"
[profiles.minimal]
source = "~/src"
        "#;

        let config: Config = toml::from_str(toml).unwrap();
        let profile = config.get_profile("minimal").unwrap();

        assert_eq!(profile.source, Some("~/src".to_string()));
        assert!(profile.destination.is_none());
        assert!(profile.delete.is_none());
    }

    #[test]
    fn test_parse_all_profile_fields() {
        let toml = r#"
[profiles.complete]
source = "~/src"
destination = "~/dst"
delete = true
exclude = ["*.log", "*.tmp"]
bwlimit = "10MB"
resume = false
min_size = "1KB"
max_size = "100MB"
parallel = 20
dry_run = true
quiet = true
verbose = 2
        "#;

        let config: Config = toml::from_str(toml).unwrap();
        let profile = config.get_profile("complete").unwrap();

        assert_eq!(profile.source, Some("~/src".to_string()));
        assert_eq!(profile.destination, Some("~/dst".to_string()));
        assert_eq!(profile.delete, Some(true));
        assert_eq!(
            profile.exclude,
            Some(vec!["*.log".to_string(), "*.tmp".to_string()])
        );
        assert_eq!(profile.bwlimit, Some("10MB".to_string()));
        assert_eq!(profile.resume, Some(false));
        assert_eq!(profile.min_size, Some("1KB".to_string()));
        assert_eq!(profile.max_size, Some("100MB".to_string()));
        assert_eq!(profile.parallel, Some(20));
        assert_eq!(profile.dry_run, Some(true));
        assert_eq!(profile.quiet, Some(true));
        assert_eq!(profile.verbose, Some(2));
    }
}