rft-cli 0.5.1

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

use serde::Deserialize;

const DEFAULT_HOST: &str = "localhost";

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

impl Default for RftConfig {
    fn default() -> Self {
        Self {
            sync: Vec::new(),
            env_overrides: HashMap::new(),
            port_offset: None,
            main_branch: None,
            host: DEFAULT_HOST.to_string(),
        }
    }
}

#[derive(Deserialize)]
struct ConfigFile {
    sync: Option<Vec<String>>,
    env_overrides: Option<HashMap<String, String>>,
    port_offset: Option<u32>,
    main_branch: Option<String>,
    host: 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,
            host: file.host.unwrap_or_else(|| DEFAULT_HOST.to_string()),
        }
    }
}

pub fn load_config(repo_root: &Path) -> RftConfig {
    let mut config = load_from_files(repo_root);
    apply_local_overrides(&mut config, 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 apply_local_overrides(config: &mut RftConfig, repo_root: &Path) {
    let path = repo_root.join(".rftrc.local.toml");
    let content = match std::fs::read_to_string(&path) {
        Ok(content) => content,
        Err(_) => return,
    };

    let local: ConfigFile = match toml::from_str(&content) {
        Ok(parsed) => parsed,
        Err(error) => {
            eprintln!("warning: failed to parse {}: {error}", path.display());
            return;
        }
    };

    if let Some(sync) = local.sync {
        config.sync = sync;
    }
    if let Some(env_overrides) = local.env_overrides {
        config.env_overrides.extend(env_overrides);
    }
    if let Some(port_offset) = local.port_offset {
        config.port_offset = Some(port_offset);
    }
    if let Some(main_branch) = local.main_branch {
        config.main_branch = Some(main_branch);
    }
    if let Some(host) = local.host {
        config.host = host;
    }
}

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_HOST") {
        config.host = value;
    }

    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 local_toml_overrides_shared() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join(".rftrc.toml"),
            r#"
port_offset = 20000
sync = ["nginx/"]
main_branch = "main"
"#,
        )
        .unwrap();
        fs::write(
            dir.path().join(".rftrc.local.toml"),
            r#"
host = "192.168.1.50"
port_offset = 30000
"#,
        )
        .unwrap();

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

        assert_eq!(config.host, "192.168.1.50");
        assert_eq!(config.port_offset, Some(30000));
        assert_eq!(config.sync, vec!["nginx/"]);
        assert_eq!(config.main_branch, Some("main".to_string()));
    }

    #[test]
    fn local_toml_without_shared_works() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(".rftrc.local.toml"), r#"host = "10.0.0.1""#).unwrap();

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

        assert_eq!(config.host, "10.0.0.1");
        assert_eq!(config.port_offset, None);
    }

    #[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);
    }
}