clync 0.4.1

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 claude_dir: PathBuf,
    #[serde(default)]
    pub include_companion_dirs: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub clone_base: Option<PathBuf>,
    pub storage: StorageConfig,
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum StorageConfig {
    #[serde(rename = "git")]
    Git {
        path: PathBuf,
        #[serde(default = "default_true")]
        auto_push: bool,
        #[serde(default = "default_lfs_threshold")]
        lfs_threshold: u64,
    },
    #[serde(rename = "folder")]
    Folder { path: PathBuf },
    #[cfg(feature = "s3")]
    #[serde(rename = "s3")]
    S3 {
        bucket: String,
        #[serde(default)]
        prefix: String,
        region: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        endpoint: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        access_key: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        secret_key: Option<String>,
    },
}

impl std::fmt::Debug for StorageConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StorageConfig::Git {
                path,
                auto_push,
                lfs_threshold,
            } => f
                .debug_struct("Git")
                .field("path", path)
                .field("auto_push", auto_push)
                .field("lfs_threshold", lfs_threshold)
                .finish(),
            StorageConfig::Folder { path } => f.debug_struct("Folder").field("path", path).finish(),
            #[cfg(feature = "s3")]
            StorageConfig::S3 {
                bucket,
                prefix,
                region,
                endpoint,
                ..
            } => f
                .debug_struct("S3")
                .field("bucket", bucket)
                .field("prefix", prefix)
                .field("region", region)
                .field("endpoint", endpoint)
                .field("access_key", &"[REDACTED]")
                .field("secret_key", &"[REDACTED]")
                .finish(),
        }
    }
}

impl StorageConfig {
    pub fn local_path(&self) -> Option<&Path> {
        match self {
            StorageConfig::Git { path, .. } => Some(path),
            StorageConfig::Folder { path } => Some(path),
            #[cfg(feature = "s3")]
            StorageConfig::S3 { .. } => None,
        }
    }

    pub fn is_git(&self) -> bool {
        matches!(self, StorageConfig::Git { .. })
    }

    pub fn auto_push(&self) -> bool {
        match self {
            StorageConfig::Git { auto_push, .. } => *auto_push,
            _ => false,
        }
    }

    pub fn lfs_threshold(&self) -> u64 {
        match self {
            StorageConfig::Git { lfs_threshold, .. } => *lfs_threshold,
            _ => 0,
        }
    }
}

pub fn default_lfs_threshold() -> u64 {
    99 * 1024 * 1024
}

#[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> {
        let home = home_dir().context("could not determine home directory")?;
        let primary = home.join(".clync");

        if primary.join("config.toml").exists() {
            return Ok(primary);
        }

        let xdg_explicit = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from);
        let legacy = xdg_explicit
            .as_ref()
            .map(|xdg| xdg.join("clync"))
            .unwrap_or_else(|| home.join(".config").join("clync"));

        if legacy.join("config.toml").exists() {
            return Ok(legacy);
        }

        // New installs: honor explicit XDG_CONFIG_HOME, otherwise use ~/.clync/
        if xdg_explicit.is_some() {
            Ok(legacy)
        } else {
            Ok(primary)
        }
    }

    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")?;

        match &mut config.sync.storage {
            StorageConfig::Git { path, .. } | StorageConfig::Folder { path } => {
                *path = expand_path(path);
            }
            #[cfg(feature = "s3")]
            StorageConfig::S3 { .. } => {}
        }
        config.sync.claude_dir = expand_path(&config.sync.claude_dir);
        if let Some(ref mut base) = config.sync.clone_base {
            *base = expand_path(base);
        }
        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 storage_path(&self) -> Option<&Path> {
        self.sync.storage.local_path()
    }
}

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 {
                claude_dir: PathBuf::from("/home/user/.claude"),
                include_companion_dirs: false,
                clone_base: None,
                storage: StorageConfig::Git {
                    path: PathBuf::from("/tmp/repo"),
                    auto_push: true,
                    lfs_threshold: default_lfs_threshold(),
                },
            },
            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);
        assert!(loaded.sync.storage.is_git());

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

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

        let config = Config {
            sync: SyncConfig {
                claude_dir: PathBuf::from("/home/user/.claude"),
                include_companion_dirs: false,
                clone_base: None,
                storage: StorageConfig::Folder {
                    path: PathBuf::from("/mnt/nas/clync"),
                },
            },
            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.sync.storage, StorageConfig::Folder { .. }));
        assert!(!loaded.sync.storage.is_git());
        assert!(!loaded.sync.storage.auto_push());

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