clync 0.3.0

Encrypted sync for Claude Code across machines
use anyhow::Result;
use std::path::Path;
use walkdir::WalkDir;

use crate::config::Config;
use crate::crypto::Cipher;

pub fn encrypted_name(name: &str, encrypted: bool) -> String {
    if encrypted {
        format!("{name}.age")
    } else {
        name.to_string()
    }
}

pub fn is_encrypted(config: &Config) -> bool {
    !matches!(config.encryption, crate::config::EncryptionConfig::None)
}

pub fn is_safe_path_component(s: &str) -> bool {
    if s.contains('/') || s.contains('\\') {
        return false;
    }
    !s.split('-').any(|seg| seg == "..")
}

pub fn mtime_secs(path: &Path) -> Result<u64> {
    Ok(std::fs::metadata(path)?
        .modified()?
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0))
}

pub fn sync_file_if_changed(src: &Path, dst: &Path, cipher: &Cipher) -> Result<u32> {
    if !src.exists() {
        return Ok(0);
    }

    let src_mtime = mtime_secs(src)?;
    if dst.exists() && src_mtime <= mtime_secs(dst)? {
        return Ok(0);
    }

    cipher.encrypt_file(src, dst)?;
    Ok(1)
}

pub fn restore_file(src: &Path, dst: &Path, cipher: &Cipher) -> Result<u32> {
    if !src.exists() {
        return Ok(0);
    }

    if dst.exists() && mtime_secs(src)? <= mtime_secs(dst)? {
        return Ok(0);
    }

    let plaintext = match cipher.decrypt_file(src) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("warning: could not decrypt {}: {e}", src.display());
            return Ok(0);
        }
    };
    if let Some(parent) = dst.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(dst, plaintext)?;
    Ok(1)
}

pub fn sync_directory(
    src_dir: &Path,
    dst_dir: &Path,
    cipher: &Cipher,
    encrypted: bool,
) -> Result<u32> {
    if !src_dir.exists() {
        return Ok(0);
    }
    std::fs::create_dir_all(dst_dir)?;
    let mut count = 0u32;
    for entry in WalkDir::new(src_dir).into_iter().filter_map(|e| e.ok()) {
        if !entry.file_type().is_file() {
            continue;
        }
        let rel = entry.path().strip_prefix(src_dir)?;
        let dst = dst_dir.join(encrypted_name(&rel.to_string_lossy(), encrypted));
        count += sync_file_if_changed(entry.path(), &dst, cipher)?;
    }
    Ok(count)
}

pub fn restore_directory(src_dir: &Path, dst_dir: &Path, cipher: &Cipher) -> Result<u32> {
    if !src_dir.exists() {
        return Ok(0);
    }
    std::fs::create_dir_all(dst_dir)?;
    let mut count = 0u32;
    for entry in WalkDir::new(src_dir).into_iter().filter_map(|e| e.ok()) {
        if !entry.file_type().is_file() {
            continue;
        }
        let rel = entry
            .path()
            .strip_prefix(src_dir)?
            .to_string_lossy()
            .to_string();
        let original_name = rel.strip_suffix(".age").unwrap_or(&rel);
        let dst = dst_dir.join(original_name);
        count += restore_file(entry.path(), &dst, cipher)?;
    }
    Ok(count)
}