clync 0.3.0

Encrypted sync for Claude Code across machines
use anyhow::{Context, Result, bail};
use std::process::Command;

use crate::config::EncryptionConfig;

pub trait SecretProvider: Send + Sync {
    fn read_secret(&self, config: &EncryptionConfig) -> Result<String>;
}

pub struct CliSecretProvider;

impl SecretProvider for CliSecretProvider {
    fn read_secret(&self, config: &EncryptionConfig) -> Result<String> {
        match config {
            EncryptionConfig::KeyFile { path } => std::fs::read_to_string(path)
                .with_context(|| format!("could not read key file: {}", path.display()))
                .map(|s| s.trim().to_string()),
            EncryptionConfig::OnePassword { reference } => {
                run_secret_cmd("op", &["read", reference, "--no-newline"])
            }
            EncryptionConfig::Bitwarden { item_id, field } => {
                run_secret_cmd("bw", &["get", field, item_id])
            }
            EncryptionConfig::Pass { entry } => run_secret_cmd("pass", &["show", entry]),
            EncryptionConfig::Passphrase { .. } | EncryptionConfig::None => {
                bail!("cannot read secret for this encryption mode")
            }
        }
    }
}

fn run_secret_cmd(program: &str, args: &[&str]) -> Result<String> {
    let output = Command::new(program)
        .args(args)
        .output()
        .with_context(|| format!("failed to run `{program}`. Is it installed?"))?;
    if !output.status.success() {
        bail!(
            "{program} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(String::from_utf8(output.stdout)
        .context("command returned non-UTF8 data")?
        .trim()
        .to_string())
}

#[cfg(test)]
pub struct MockSecretProvider {
    secret: String,
}

#[cfg(test)]
impl MockSecretProvider {
    pub fn new(secret: &str) -> Self {
        Self {
            secret: secret.to_string(),
        }
    }
}

#[cfg(test)]
impl SecretProvider for MockSecretProvider {
    fn read_secret(&self, _config: &EncryptionConfig) -> Result<String> {
        Ok(self.secret.clone())
    }
}

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

    #[test]
    fn mock_provider_returns_secret() {
        let provider = MockSecretProvider::new("AGE-SECRET-KEY-TEST");
        let config = EncryptionConfig::KeyFile {
            path: PathBuf::from("/fake"),
        };
        assert_eq!(
            provider.read_secret(&config).unwrap(),
            "AGE-SECRET-KEY-TEST"
        );
    }

    #[test]
    fn cli_provider_key_file_not_found() {
        let provider = CliSecretProvider;
        let config = EncryptionConfig::KeyFile {
            path: PathBuf::from("/nonexistent/key.txt"),
        };
        assert!(provider.read_secret(&config).is_err());
    }

    #[test]
    fn cli_provider_passphrase_errors() {
        let provider = CliSecretProvider;
        let config = EncryptionConfig::Passphrase {
            env_var: "UNUSED".into(),
        };
        assert!(provider.read_secret(&config).is_err());
    }

    #[test]
    fn cli_provider_none_errors() {
        let provider = CliSecretProvider;
        let config = EncryptionConfig::None;
        assert!(provider.read_secret(&config).is_err());
    }

    #[test]
    fn cli_provider_reads_real_key_file() {
        let dir = std::env::temp_dir().join(format!("clync-secret-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let key_path = dir.join("test-key.txt");
        std::fs::write(&key_path, "my-secret-key\n").unwrap();

        let provider = CliSecretProvider;
        let config = EncryptionConfig::KeyFile { path: key_path };
        assert_eq!(provider.read_secret(&config).unwrap(), "my-secret-key");

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