clync 0.4.1

Encrypted sync for Claude Code across machines
pub mod folder;
pub mod git;
#[cfg(feature = "s3")]
pub mod s3;

use anyhow::{Context, Result};
use fs2::FileExt;
use std::path::{Path, PathBuf};

use crate::config::{Config, StorageConfig};

#[allow(dead_code)]
pub trait Store: Send + Sync {
    fn write_file(&self, rel_path: &str, data: &[u8]) -> Result<()>;
    fn read_file(&self, rel_path: &str) -> Result<Vec<u8>>;
    fn exists(&self, rel_path: &str) -> bool;
    fn list_files(&self, prefix: &str) -> Result<Vec<String>>;
    fn file_size(&self, rel_path: &str) -> Result<u64>;
    fn atomic_write(&self, rel_path: &str, data: &[u8]) -> Result<()>;

    fn sync_down(&self) -> Result<()>;
    fn sync_up(&self, message: &str) -> Result<()>;

    fn lock(&self) -> Result<Box<dyn std::any::Any>>;
    fn try_lock(&self) -> Result<Box<dyn std::any::Any>>;

    fn local_path(&self) -> Option<&Path>;
}

pub struct LocalFs {
    root: PathBuf,
}

#[allow(dead_code)]
impl LocalFs {
    pub fn new(root: PathBuf) -> Self {
        Self { root }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn write_file(&self, rel_path: &str, data: &[u8]) -> Result<()> {
        let full_path = self.root.join(rel_path);
        if let Some(parent) = full_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&full_path, data)?;
        Ok(())
    }

    pub fn read_file(&self, rel_path: &str) -> Result<Vec<u8>> {
        let full_path = self.root.join(rel_path);
        std::fs::read(&full_path).with_context(|| format!("could not read {}", full_path.display()))
    }

    pub fn exists(&self, rel_path: &str) -> bool {
        self.root.join(rel_path).exists()
    }

    pub fn list_files(&self, prefix: &str) -> Result<Vec<String>> {
        let dir = self.root.join(prefix);
        if !dir.exists() {
            return Ok(Vec::new());
        }
        let mut files = Vec::new();
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            if entry.file_type()?.is_file()
                && let Some(name) = entry.file_name().to_str()
            {
                files.push(format!("{prefix}/{name}"));
            }
        }
        Ok(files)
    }

    pub fn file_size(&self, rel_path: &str) -> Result<u64> {
        let full_path = self.root.join(rel_path);
        Ok(std::fs::metadata(&full_path)
            .with_context(|| format!("could not stat {}", full_path.display()))?
            .len())
    }

    pub fn atomic_write(&self, rel_path: &str, data: &[u8]) -> Result<()> {
        let full_path = self.root.join(rel_path);
        let tmp_path = self.root.join(format!("{rel_path}.tmp"));
        if let Some(parent) = full_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&tmp_path, data)?;
        std::fs::rename(&tmp_path, &full_path)?;
        Ok(())
    }

    pub fn lock(&self) -> Result<Box<dyn std::any::Any>> {
        self.lock_inner(true)
    }

    pub fn try_lock(&self) -> Result<Box<dyn std::any::Any>> {
        self.lock_inner(false)
    }

    fn lock_inner(&self, blocking: bool) -> Result<Box<dyn std::any::Any>> {
        let lock_path = self.root.join(".clync.lock");
        let file = std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(false)
            .open(&lock_path)
            .context("could not create lock file")?;
        if blocking {
            file.lock_exclusive()
                .context("another clync process is running (locked)")?;
        } else {
            file.try_lock_exclusive()
                .context("sync in progress, try again shortly")?;
        }
        Ok(Box::new(file))
    }
}

pub fn create_store(config: &Config) -> Result<Box<dyn Store>> {
    match &config.sync.storage {
        StorageConfig::Git { path, .. } => Ok(Box::new(git::GitStore::new(path.clone()))),
        StorageConfig::Folder { path } => Ok(Box::new(folder::FolderStore::new(path.clone()))),
        #[cfg(feature = "s3")]
        StorageConfig::S3 {
            bucket,
            prefix,
            region,
            endpoint,
            access_key,
            secret_key,
        } => Ok(Box::new(s3::S3Store::new(
            bucket.clone(),
            prefix.clone(),
            region.clone(),
            endpoint.clone(),
            access_key.clone(),
            secret_key.clone(),
        )?)),
    }
}