clync 0.2.0

Encrypted sync for Claude Code across machines
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub sync: SyncConfig,
    pub encryption: EncryptionConfig,
    #[serde(default)]
    pub targets: SyncTargets,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SyncConfig {
    pub repo: PathBuf,
    pub claude_dir: PathBuf,
    #[serde(default)]
    pub include_companion_dirs: bool,
    #[serde(default = "default_true")]
    pub auto_git: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SyncTargets {
    #[serde(default = "default_true")]
    pub sessions: bool,
    #[serde(default = "default_true")]
    pub memories: bool,
    #[serde(default = "default_true")]
    pub settings: bool,
    #[serde(default = "default_true")]
    pub commands: bool,
    #[serde(default = "default_true")]
    pub skills: bool,
    #[serde(default = "default_true")]
    pub global_claude_md: bool,
}

impl Default for SyncTargets {
    fn default() -> Self {
        Self {
            sessions: true,
            memories: true,
            settings: true,
            commands: true,
            skills: true,
            global_claude_md: true,
        }
    }
}

fn default_true() -> bool {
    true
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum EncryptionConfig {
    #[serde(rename = "key_file")]
    KeyFile { path: PathBuf },
    #[serde(rename = "passphrase")]
    Passphrase { env_var: String },
    #[serde(rename = "onepassword")]
    OnePassword { reference: String },
    #[serde(rename = "bitwarden")]
    Bitwarden { item_id: String, field: String },
    #[serde(rename = "pass")]
    Pass { entry: String },
    #[serde(rename = "none")]
    None,
}

impl Config {
    pub fn config_dir() -> Result<PathBuf> {
        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
            return Ok(PathBuf::from(xdg).join("clync"));
        }
        let home = home_dir().context("could not determine home directory")?;
        Ok(home.join(".config").join("clync"))
    }

    pub fn config_path() -> Result<PathBuf> {
        Ok(Self::config_dir()?.join("config.toml"))
    }

    pub fn load() -> Result<Self> {
        let path = Self::config_path()?;
        let contents = std::fs::read_to_string(&path)
            .with_context(|| format!("could not read config at {}", path.display()))?;
        let mut config: Config = toml::from_str(&contents).context("invalid config format")?;

        config.sync.repo = expand_path(&config.sync.repo);
        config.sync.claude_dir = expand_path(&config.sync.claude_dir);
        if let EncryptionConfig::KeyFile { ref mut path } = config.encryption {
            *path = expand_path(path);
        }

        Ok(config)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let contents = toml::to_string_pretty(self).context("failed to serialize config")?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, contents)?;
        Ok(())
    }

    pub fn claude_projects_dir(&self) -> PathBuf {
        self.sync.claude_dir.join("projects")
    }
}

pub fn home_dir() -> Option<PathBuf> {
    std::env::var("HOME")
        .ok()
        .map(PathBuf::from)
        .or_else(dirs::home_dir)
}

pub fn expand_path(p: &Path) -> PathBuf {
    let s = p.to_string_lossy();
    if s.starts_with("~/")
        && let Some(home) = dirs::home_dir()
    {
        return home.join(&s[2..]);
    }
    p.to_path_buf()
}

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

    #[test]
    fn expand_path_no_tilde() {
        let p = PathBuf::from("/usr/local/bin");
        assert_eq!(expand_path(&p), p);
    }

    #[test]
    fn expand_path_with_tilde() {
        let result = expand_path(&PathBuf::from("~/test/path"));
        assert!(!result.to_string_lossy().starts_with("~/"));
        assert!(result.to_string_lossy().ends_with("test/path"));
    }

    #[test]
    fn expand_path_relative() {
        let p = PathBuf::from("relative/path");
        assert_eq!(expand_path(&p), p);
    }

    #[test]
    fn home_dir_returns_some() {
        assert!(home_dir().is_some());
    }

    #[test]
    fn sync_targets_default_all_true() {
        let targets = SyncTargets::default();
        assert!(targets.sessions);
        assert!(targets.memories);
        assert!(targets.settings);
        assert!(targets.commands);
        assert!(targets.skills);
        assert!(targets.global_claude_md);
    }

    #[test]
    fn config_roundtrip() {
        let dir = std::env::temp_dir().join(format!("clync-config-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");

        let config = Config {
            sync: SyncConfig {
                repo: PathBuf::from("/tmp/repo"),
                claude_dir: PathBuf::from("/home/user/.claude"),
                include_companion_dirs: false,
                auto_git: true,
            },
            encryption: EncryptionConfig::None,
            targets: SyncTargets::default(),
        };

        config.save(&path).unwrap();
        let contents = std::fs::read_to_string(&path).unwrap();
        let loaded: Config = toml::from_str(&contents).unwrap();
        assert!(matches!(loaded.encryption, EncryptionConfig::None));
        assert!(loaded.targets.sessions);

        std::fs::remove_dir_all(&dir).ok();
    }
}