Menu
Public repository / e20fdc20ba73ed38f462643569dcb30aa8cb2187
AkurAI-Drive / client/src/sync.rs
Latest change 2d36cd6c72fc6a63a7edb87bb54a4020e50f2f8a - desktop: Tauri v2 tray + Google-Drive-style file browser app by olibuijr
//! Sync orchestration: gather the three snapshots, run the pure decision
//! engine, then execute the resulting actions against the backend and disk.
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::api::Api;
use crate::config::{state_db_path, Config};
use crate::engine::{self, Action, LocalEntry, RemoteEntry, StateEntry};
use crate::state::{Record, State};
use crate::util::{self, TMP_PREFIX};
/// A local file as walked from disk.
struct LocalFile {
sha256: String,
size: u64,
mtime: i64,
}
#[derive(Debug, Default, Clone)]
pub struct Summary {
pub uploaded: u32,
pub downloaded: u32,
pub deleted_remote: u32,
pub deleted_local: u32,
pub adopted: u32,
pub conflicts: u32,
pub errors: u32,
}
impl Summary {
pub fn to_line(&self) -> String {
format!(
"uploaded={} downloaded={} deleted_remote={} deleted_local={} adopted={} conflicts={} errors={}",
self.uploaded,
self.downloaded,
self.deleted_remote,
self.deleted_local,
self.adopted,
self.conflicts,
self.errors
)
}
}
/// Run one full bidirectional sync. When `quiet`, suppress the per-file and
/// summary prints (used for the daemon's frequent re-syncs).
pub fn run_sync(cfg: &Config, quiet: bool) -> Result<Summary> {
let folder = require_folder(cfg)?;
let bucket = cfg
.bucket
.clone()
.context("no bucket configured — run `akurai-drive-sync init <folder> --bucket <name>`")?;
let token = cfg
.token
.clone()
.filter(|t| !t.is_empty())
.context("not logged in — run `akurai-drive-sync login`")?;
let api = Api::new(cfg.resolved_api(), token)?;
let state = State::open(&state_db_path()?)?;
let st_map = state.load()?;
// ── Snapshot: remote ───────────────────────────────────────────────────
let remote_files = api.files(&bucket, "")?;
let remote: BTreeMap<String, RemoteEntry> = remote_files
.into_iter()
.map(|f| {
(
f.key,
RemoteEntry {
etag: f.etag,
size: f.size,
},
)
})
.collect();
// ── Snapshot: local (hashing fast-path reuses unchanged state) ─────────
let local = gather_local(&folder, &st_map)?;
// ── Build engine inputs and compute the plan ──────────────────────────
let state_engine: BTreeMap<String, StateEntry> = st_map
.iter()
.map(|(k, r)| {
(
k.clone(),
StateEntry {
sha256: r.sha256.clone(),
etag: r.etag.clone(),
size: r.size,
},
)
})
.collect();
let local_engine: BTreeMap<String, LocalEntry> = local
.iter()
.map(|(k, f)| {
(
k.clone(),
LocalEntry {
sha256: f.sha256.clone(),
size: f.size,
},
)
})
.collect();
let plan = engine::plan_actions(&state_engine, &remote, &local_engine);
tracing::debug!(
bucket = %bucket,
remote = remote.len(),
local = local.len(),
actions = plan.len(),
"sync plan computed"
);
// ── Execute ────────────────────────────────────────────────────────────
let exec = Executor {
api: &api,
state: &state,
folder: &folder,
bucket: &bucket,
remote: &remote,
local: &local,
quiet,
};
let mut summary = Summary::default();
for action in plan {
if let Err(e) = exec.run(&action, &mut summary) {
summary.errors += 1;
let detail = format!("{e:#}");
tracing::error!(key = %action.key(), error = %detail, "sync action failed");
eprintln!(" ! {} failed: {detail}", action.key());
}
}
state.meta_set("last_sync", &summary.to_line())?;
state.meta_set("last_sync_at", &now_rfc3339())?;
tracing::info!(summary = %summary.to_line(), "sync pass complete");
if !quiet {
print_summary(&summary);
}
Ok(summary)
}
/// Walk the sync folder into a key→LocalFile map, skipping temp files. Reuses a
/// stored SHA-256 when size and mtime are unchanged to avoid re-hashing.
fn gather_local(
folder: &Path,
state: &BTreeMap<String, Record>,
) -> Result<BTreeMap<String, LocalFile>> {
let mut map = BTreeMap::new();
for entry in walkdir::WalkDir::new(folder)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy();
if name.starts_with(TMP_PREFIX) {
continue;
}
let path = entry.path();
let rel = match path.strip_prefix(folder) {
Ok(r) => r,
Err(_) => continue,
};
let key = rel
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
if key.is_empty() {
continue;
}
let meta = entry.metadata()?;
let size = meta.len();
let mtime = util::mtime_millis(path)?;
let sha256 = match state.get(&key) {
Some(rec) if rec.size == size && rec.mtime == mtime => rec.sha256.clone(),
_ => util::sha256_file(path)?,
};
map.insert(
key,
LocalFile {
sha256,
size,
mtime,
},
);
}
Ok(map)
}
/// Borrowed bundle of everything an action needs, so executing each action is a
/// single method call rather than a many-argument function.
struct Executor<'a> {
api: &'a Api,
state: &'a State,
folder: &'a Path,
bucket: &'a str,
remote: &'a BTreeMap<String, RemoteEntry>,
local: &'a BTreeMap<String, LocalFile>,
quiet: bool,
}
impl Executor<'_> {
fn run(&self, action: &Action, summary: &mut Summary) -> Result<()> {
match action {
Action::Upload(key) => self.upload(key, summary),
Action::Download(key) => self.download(key, summary),
Action::DeleteRemote(key) => self.delete_remote(key, summary),
Action::DeleteLocal(key) => self.delete_local(key, summary),
Action::Adopt(key) => self.adopt(key, summary),
Action::DropState(key) => {
self.state.remove(key)?;
Ok(())
}
Action::Conflict(key) => self.conflict(key, summary),
}
}
fn upload(&self, key: &str, summary: &mut Summary) -> Result<()> {
let lf = self
.local
.get(key)
.context("missing local file for upload")?;
let ct = util::content_type_for(key);
let presign = self.api.presign(self.bucket, key, "put", Some(ct))?;
let abspath = self.folder.join(key);
let etag = self
.api
.upload_file(&presign, &abspath, Some(ct))?
.unwrap_or_default();
self.state.upsert(
key,
&Record {
sha256: lf.sha256.clone(),
etag,
size: lf.size,
mtime: lf.mtime,
},
)?;
summary.uploaded += 1;
self.log(format!("↑ {key}"));
Ok(())
}
fn download(&self, key: &str, summary: &mut Summary) -> Result<()> {
let re = self
.remote
.get(key)
.context("missing remote entry for download")?;
let abspath = self.folder.join(key);
self.download_atomic(key, &abspath)?;
self.record_from_disk(key, &abspath, &re.etag)?;
summary.downloaded += 1;
self.log(format!("↓ {key}"));
Ok(())
}
fn delete_remote(&self, key: &str, summary: &mut Summary) -> Result<()> {
self.api.delete(self.bucket, key)?;
self.state.remove(key)?;
summary.deleted_remote += 1;
self.log(format!("⊘ remote {key}"));
Ok(())
}
fn delete_local(&self, key: &str, summary: &mut Summary) -> Result<()> {
let abspath = self.folder.join(key);
match std::fs::remove_file(&abspath) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {}", abspath.display())),
}
prune_empty_dirs(self.folder, &abspath);
self.state.remove(key)?;
summary.deleted_local += 1;
self.log(format!("⊘ local {key}"));
Ok(())
}
fn adopt(&self, key: &str, summary: &mut Summary) -> Result<()> {
let lf = self
.local
.get(key)
.context("missing local file for adopt")?;
let re = self
.remote
.get(key)
.context("missing remote entry for adopt")?;
self.state.upsert(
key,
&Record {
sha256: lf.sha256.clone(),
etag: re.etag.clone(),
size: lf.size,
mtime: lf.mtime,
},
)?;
summary.adopted += 1;
self.log(format!("= {key} (already in sync)"));
Ok(())
}
/// Keep-both conflict resolution: move the local copy aside under a
/// `(conflict <id>)` name and upload it, then download the remote version
/// to the original path.
fn conflict(&self, key: &str, summary: &mut Summary) -> Result<()> {
let lf = self
.local
.get(key)
.context("missing local file for conflict")?;
let re = self
.remote
.get(key)
.context("missing remote entry for conflict")?;
let sid = util::short_id(&format!("{key}:{}", re.etag));
let conflict_key = engine::conflict_name(key, &sid);
let orig_abs = self.folder.join(key);
let conflict_abs = self.folder.join(&conflict_key);
// 1. Move the local edit aside.
std::fs::rename(&orig_abs, &conflict_abs).with_context(|| {
format!(
"renaming {} -> {}",
orig_abs.display(),
conflict_abs.display()
)
})?;
// 2. Download the remote version to the original path.
self.download_atomic(key, &orig_abs)?;
self.record_from_disk(key, &orig_abs, &re.etag)?;
// 3. Upload the kept-aside local copy so it propagates everywhere.
let ct = util::content_type_for(&conflict_key);
let presign = self
.api
.presign(self.bucket, &conflict_key, "put", Some(ct))?;
let cetag = self
.api
.upload_file(&presign, &conflict_abs, Some(ct))?
.unwrap_or_default();
self.state.upsert(
&conflict_key,
&Record {
sha256: lf.sha256.clone(),
etag: cetag,
size: lf.size,
mtime: util::mtime_millis(&conflict_abs)?,
},
)?;
summary.conflicts += 1;
// Conflicts are always reported, even in quiet mode.
tracing::warn!(%key, %conflict_key, "conflict: kept remote, saved local copy aside");
eprintln!(" ⚠ conflict on {key}: kept remote; local copy saved as {conflict_key}");
Ok(())
}
/// Download a presigned GET to a temp sibling, then atomically rename.
fn download_atomic(&self, key: &str, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let presign = self.api.presign(self.bucket, key, "get", None)?;
let tmp = temp_sibling(dest);
self.api.download_to(&presign, &tmp)?;
std::fs::rename(&tmp, dest)
.with_context(|| format!("finalizing download of {}", dest.display()))?;
Ok(())
}
/// Record state for a key from the file currently on disk plus a remote etag.
fn record_from_disk(&self, key: &str, abspath: &Path, etag: &str) -> Result<()> {
let sha256 = util::sha256_file(abspath)?;
let size = std::fs::metadata(abspath)?.len();
let mtime = util::mtime_millis(abspath)?;
self.state.upsert(
key,
&Record {
sha256,
etag: etag.to_string(),
size,
mtime,
},
)?;
Ok(())
}
fn log(&self, msg: String) {
// Always record the action in the structured log (key + arrow), even in
// quiet mode — the desktop app drives quiet syncs and diagnoses from logs.
tracing::info!(action = %msg, "sync action");
if !self.quiet {
println!(" {msg}");
}
}
}
/// Temp file alongside the destination (same dir ⇒ same filesystem ⇒ atomic
/// rename). Named with the skip-prefix so the walker ignores it.
fn temp_sibling(dest: &Path) -> PathBuf {
let parent = dest.parent().unwrap_or_else(|| Path::new("."));
let id = util::short_id(&dest.to_string_lossy());
parent.join(format!("{TMP_PREFIX}{id}"))
}
/// Remove now-empty parent directories of a deleted file, up to (not including)
/// the sync-folder root. Best-effort; failures are ignored.
fn prune_empty_dirs(root: &Path, removed: &Path) {
let mut dir = removed.parent();
while let Some(d) = dir {
if d == root || !d.starts_with(root) {
break;
}
if std::fs::remove_dir(d).is_err() {
break; // not empty (or gone) — stop climbing
}
dir = d.parent();
}
}
fn require_folder(cfg: &Config) -> Result<PathBuf> {
let folder = cfg.folder.clone().context(
"no sync folder configured — run `akurai-drive-sync init <folder> --bucket <name>`",
)?;
let path = PathBuf::from(folder);
if !path.is_dir() {
bail!("sync folder {} does not exist", path.display());
}
Ok(path)
}
fn print_summary(s: &Summary) {
println!(
"\nSync complete: {} uploaded, {} downloaded, {} deleted remote, {} deleted local, {} conflicts{}",
s.uploaded,
s.downloaded,
s.deleted_remote,
s.deleted_local,
s.conflicts,
if s.errors > 0 {
format!(", {} errors", s.errors)
} else {
String::new()
}
);
}
/// Minimal RFC3339-ish UTC timestamp without pulling in chrono.
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Civil date from Unix seconds (Howard Hinnant's algorithm).
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (h, mi, se) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z")
}