rft-cli 0.2.0

Zero-config Docker Compose isolation for git worktrees
use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;

#[derive(Debug, Clone, Default)]
pub struct RftConfig {
    pub sync: Vec<String>,
    pub env_overrides: HashMap<String, String>,
    pub port_offset: Option<u32>,
    pub main_branch: Option<String>,
}

#[derive(Deserialize)]
struct ConfigFile {
    sync: Option<Vec<String>>,
    env_overrides: Option<HashMap<String, String>>,
    port_offset: Option<u32>,
    main_branch: Option<String>,
}

#[derive(Deserialize)]
struct PackageJson {
    rft: Option<ConfigFile>,
}

impl From<ConfigFile> for RftConfig {
    fn from(file: ConfigFile) -> Self {
        Self {
            sync: file.sync.unwrap_or_default(),
            env_overrides: file.env_overrides.unwrap_or_default(),
            port_offset: file.port_offset,
            main_branch: file.main_branch,
        }
    }
}

pub fn load_config(repo_root: &Path) -> RftConfig {
    let mut config = load_from_files(repo_root);
    apply_env_overrides(&mut config, |key| std::env::var(key));
    config
}

fn load_from_files(repo_root: &Path) -> RftConfig {
    if let Some(config) = load_toml(repo_root) {
        return config;
    }
    if let Some(config) = load_json(repo_root) {
        return config;
    }
    if let Some(config) = load_package_json(repo_root) {
        return config;
    }
    RftConfig::default()
}

fn load_toml(repo_root: &Path) -> Option<RftConfig> {
    let path = repo_root.join(".rftrc.toml");
    let content = std::fs::read_to_string(&path).ok()?;
    match toml::from_str::<ConfigFile>(&content) {
        Ok(parsed) => Some(parsed.into()),
        Err(error) => {
            eprintln!("warning: failed to parse {}: {error}", path.display());
            None
        }
    }
}

fn load_json(repo_root: &Path) -> Option<RftConfig> {
    let path = repo_root.join(".rftrc.json");
    let content = std::fs::read_to_string(&path).ok()?;
    match serde_json::from_str::<ConfigFile>(&content) {
        Ok(parsed) => Some(parsed.into()),
        Err(error) => {
            eprintln!("warning: failed to parse {}: {error}", path.display());
            None
        }
    }
}

fn load_package_json(repo_root: &Path) -> Option<RftConfig> {
    let path = repo_root.join("package.json");
    let content = std::fs::read_to_string(&path).ok()?;
    match serde_json::from_str::<PackageJson>(&content) {
        Ok(parsed) => parsed.rft.map(Into::into),
        Err(error) => {
            eprintln!("warning: failed to parse {}: {error}", path.display());
            None
        }
    }
}

fn apply_env_overrides(
    config: &mut RftConfig,
    env_var: impl Fn(&str) -> std::result::Result<String, std::env::VarError>,
) {
    if let Ok(value) = env_var("RFT_PORT_OFFSET") {
        match value.parse::<u32>() {
            Ok(offset) => config.port_offset = Some(offset),
            Err(error) => {
                eprintln!("warning: invalid RFT_PORT_OFFSET value '{value}': {error}");
            }
        }
    }

    if let Ok(value) = env_var("RFT_SYNC") {
        config.sync = value
            .split(',')
            .map(|segment| segment.trim().to_string())
            .filter(|segment| !segment.is_empty())
            .collect();
    }
}

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

    #[test]
    fn loads_toml_config() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join(".rftrc.toml"),
            r#"
sync = ["docker-compose.yml", ".env"]
port_offset = 100

[env_overrides]
DATABASE_URL = "postgres://localhost/test"
"#,
        )
        .unwrap();

        let config = load_from_files(dir.path());

        assert_eq!(config.sync, vec!["docker-compose.yml", ".env"]);
        assert_eq!(config.port_offset, Some(100));
        assert_eq!(
            config.env_overrides.get("DATABASE_URL").unwrap(),
            "postgres://localhost/test"
        );
    }

    #[test]
    fn loads_json_config() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join(".rftrc.json"),
            r#"{
                "sync": ["Makefile"],
                "port_offset": 200
            }"#,
        )
        .unwrap();

        let config = load_from_files(dir.path());

        assert_eq!(config.sync, vec!["Makefile"]);
        assert_eq!(config.port_offset, Some(200));
    }

    #[test]
    fn loads_from_package_json() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("package.json"),
            r#"{
                "name": "my-app",
                "rft": {
                    "sync": ["tsconfig.json"],
                    "port_offset": 50
                }
            }"#,
        )
        .unwrap();

        let config = load_from_files(dir.path());

        assert_eq!(config.sync, vec!["tsconfig.json"]);
        assert_eq!(config.port_offset, Some(50));
    }

    #[test]
    fn toml_takes_priority_over_json() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(".rftrc.toml"), r#"port_offset = 10"#).unwrap();
        fs::write(dir.path().join(".rftrc.json"), r#"{"port_offset": 20}"#).unwrap();

        let config = load_from_files(dir.path());

        assert_eq!(config.port_offset, Some(10));
    }

    #[test]
    fn returns_default_when_no_config_found() {
        let dir = tempfile::tempdir().unwrap();
        let config = load_from_files(dir.path());

        assert!(config.sync.is_empty());
        assert!(config.env_overrides.is_empty());
        assert_eq!(config.port_offset, None);
    }

    #[test]
    fn returns_default_on_invalid_toml() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(".rftrc.toml"), "{{invalid toml").unwrap();

        let config = load_from_files(dir.path());

        assert!(config.sync.is_empty());
        assert_eq!(config.port_offset, None);
    }

    fn fake_env(
        overrides: &[(&str, &str)],
    ) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> {
        let map: HashMap<String, String> = overrides
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        move |key: &str| map.get(key).cloned().ok_or(std::env::VarError::NotPresent)
    }

    #[test]
    fn env_var_overrides_port_offset() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(".rftrc.toml"), r#"port_offset = 10"#).unwrap();

        let mut config = load_from_files(dir.path());
        apply_env_overrides(&mut config, fake_env(&[("RFT_PORT_OFFSET", "999")]));

        assert_eq!(config.port_offset, Some(999));
    }

    #[test]
    fn env_var_overrides_sync() {
        let mut config = RftConfig::default();
        apply_env_overrides(&mut config, fake_env(&[("RFT_SYNC", "a.yml, b.yml")]));

        assert_eq!(config.sync, vec!["a.yml", "b.yml"]);
    }

    #[test]
    fn package_json_without_rft_field_returns_default() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("package.json"),
            r#"{"name": "my-app", "version": "1.0.0"}"#,
        )
        .unwrap();

        let config = load_from_files(dir.path());

        assert!(config.sync.is_empty());
        assert_eq!(config.port_offset, None);
    }
}