Menu
Public repository / d63e826eb482cb963477774b04fe8a84f23f0e45
AkurAI-Drive / client/src/engine.rs
Latest change feb7ac86b5e60d55b429eacb7146cf0dc32c6628 - Add native Linux sync client (akurai-drive-sync) by olibuijr
//! Pure sync-decision engine.
//!
//! Given three snapshots — the last-synced **state**, the current **remote**
//! listing, and the current **local** disk — this module computes the set of
//! actions needed to reconcile the two sides. It performs no I/O, so the whole
//! reconciliation policy is unit-testable without a network or a filesystem.
//!
//! Keys are bucket-relative paths using forward slashes (S3 key style); the
//! same string identifies the local file (relative to the sync folder) and the
//! remote object.
use std::collections::{BTreeMap, BTreeSet};
/// What we recorded for a key the last time it was successfully synced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateEntry {
/// SHA-256 of the local bytes at last sync (local-identity).
pub sha256: String,
/// Remote object ETag at last sync (remote-identity).
pub etag: String,
pub size: u64,
}
/// A key as it currently appears in the remote bucket listing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteEntry {
pub etag: String,
pub size: u64,
}
/// A key as it currently appears on local disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalEntry {
pub sha256: String,
pub size: u64,
}
/// A single reconciliation action for one key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Local file is new/changed → PUT it to the bucket.
Upload(String),
/// Remote object is new/changed → GET it to disk.
Download(String),
/// Local file was deleted → delete the remote object.
DeleteRemote(String),
/// Remote object was deleted → delete the local file.
DeleteLocal(String),
/// Key appeared on both sides with matching size and no prior state →
/// adopt it into state without transferring anything.
Adopt(String),
/// Key vanished from both sides → drop the stale state row.
DropState(String),
/// Both sides changed since last sync → keep both (rename local copy).
Conflict(String),
}
impl Action {
/// The key this action operates on.
pub fn key(&self) -> &str {
match self {
Action::Upload(k)
| Action::Download(k)
| Action::DeleteRemote(k)
| Action::DeleteLocal(k)
| Action::Adopt(k)
| Action::DropState(k)
| Action::Conflict(k) => k,
}
}
}
/// S3 ETags are quoted (`"abc..."`); normalize before comparing.
fn norm_etag(e: &str) -> &str {
e.trim_matches('"')
}
/// Compute the reconciliation plan. Noop keys (in sync) are omitted from the
/// result; everything returned represents real work or a state mutation.
pub fn plan_actions(
state: &BTreeMap<String, StateEntry>,
remote: &BTreeMap<String, RemoteEntry>,
local: &BTreeMap<String, LocalEntry>,
) -> Vec<Action> {
let mut keys: BTreeSet<&String> = BTreeSet::new();
keys.extend(state.keys());
keys.extend(remote.keys());
keys.extend(local.keys());
keys.into_iter()
.filter_map(|key| decide(key, state.get(key), remote.get(key), local.get(key)))
.collect()
}
/// Decide the action for one key from its three-way presence and identity.
fn decide(
key: &str,
s: Option<&StateEntry>,
r: Option<&RemoteEntry>,
l: Option<&LocalEntry>,
) -> Option<Action> {
let k = || key.to_string();
match (s, r, l) {
// ── Never tracked before ───────────────────────────────────────────
(None, None, Some(_)) => Some(Action::Upload(k())), // brand-new local
(None, Some(_), None) => Some(Action::Download(k())), // brand-new remote
(None, Some(r), Some(l)) => {
// Appeared independently on both sides (e.g. re-init of an existing
// folder). Equal size ⇒ assume identical, adopt without transfer;
// otherwise it is a genuine conflict.
if r.size == l.size {
Some(Action::Adopt(k()))
} else {
Some(Action::Conflict(k()))
}
}
(None, None, None) => None, // unreachable: key came from some map
// ── Tracked before ─────────────────────────────────────────────────
(Some(_), None, None) => Some(Action::DropState(k())), // gone both sides
(Some(s), Some(r), None) => {
// Local deleted. If the remote is untouched, propagate the delete;
// if the remote changed too, the remote edit wins — resurrect local.
if norm_etag(&s.etag) == norm_etag(&r.etag) {
Some(Action::DeleteRemote(k()))
} else {
Some(Action::Download(k()))
}
}
(Some(s), None, Some(l)) => {
// Remote deleted. If the local is untouched, propagate the delete;
// if the local changed too, the local edit wins — resurrect remote.
if s.sha256 == l.sha256 {
Some(Action::DeleteLocal(k()))
} else {
Some(Action::Upload(k()))
}
}
(Some(s), Some(r), Some(l)) => {
let local_changed = s.sha256 != l.sha256;
let remote_changed = norm_etag(&s.etag) != norm_etag(&r.etag);
match (local_changed, remote_changed) {
(false, false) => None, // in sync
(true, false) => Some(Action::Upload(k())),
(false, true) => Some(Action::Download(k())),
(true, true) => Some(Action::Conflict(k())),
}
}
}
}
/// Build the keep-both filename for a conflicted key:
/// `docs/report.txt` + `a1b2c3` → `docs/report (conflict a1b2c3).txt`.
/// The directory prefix and extension are preserved; dotfiles keep their dot.
pub fn conflict_name(rel: &str, short_id: &str) -> String {
let (dir, base) = match rel.rfind('/') {
Some(i) => (&rel[..=i], &rel[i + 1..]),
None => ("", rel),
};
let (stem, ext) = split_ext(base);
format!("{dir}{stem} (conflict {short_id}){ext}")
}
/// Split a filename into (stem, extension). A leading dot is part of the stem
/// (so `.bashrc` has no extension), matching common dotfile expectations.
fn split_ext(base: &str) -> (&str, &str) {
match base.rfind('.') {
Some(i) if i > 0 => (&base[..i], &base[i..]),
_ => (base, ""),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn se(sha: &str, etag: &str, size: u64) -> StateEntry {
StateEntry {
sha256: sha.into(),
etag: etag.into(),
size,
}
}
fn re(etag: &str, size: u64) -> RemoteEntry {
RemoteEntry {
etag: etag.into(),
size,
}
}
fn le(sha: &str, size: u64) -> LocalEntry {
LocalEntry {
sha256: sha.into(),
size,
}
}
/// Helper: run the engine over single-key maps and return the lone action.
fn one(s: Option<StateEntry>, r: Option<RemoteEntry>, l: Option<LocalEntry>) -> Option<Action> {
let mut sm = BTreeMap::new();
let mut rm = BTreeMap::new();
let mut lm = BTreeMap::new();
if let Some(s) = s {
sm.insert("k".to_string(), s);
}
if let Some(r) = r {
rm.insert("k".to_string(), r);
}
if let Some(l) = l {
lm.insert("k".to_string(), l);
}
plan_actions(&sm, &rm, &lm).into_iter().next()
}
#[test]
fn new_local_uploads() {
assert_eq!(
one(None, None, Some(le("a", 1))),
Some(Action::Upload("k".into()))
);
}
#[test]
fn new_remote_downloads() {
assert_eq!(
one(None, Some(re("e", 1)), None),
Some(Action::Download("k".into()))
);
}
#[test]
fn both_appear_same_size_adopts() {
assert_eq!(
one(None, Some(re("e", 5)), Some(le("a", 5))),
Some(Action::Adopt("k".into()))
);
}
#[test]
fn both_appear_diff_size_conflicts() {
assert_eq!(
one(None, Some(re("e", 9)), Some(le("a", 5))),
Some(Action::Conflict("k".into()))
);
}
#[test]
fn local_modified_uploads() {
// state sha=a, remote unchanged (etag=e), local now sha=b
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e", 1)), Some(le("b", 1))),
Some(Action::Upload("k".into()))
);
}
#[test]
fn remote_modified_downloads() {
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e2", 1)), Some(le("a", 1))),
Some(Action::Download("k".into()))
);
}
#[test]
fn both_modified_conflicts() {
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e2", 1)), Some(le("b", 1))),
Some(Action::Conflict("k".into()))
);
}
#[test]
fn in_sync_is_noop() {
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e", 1)), Some(le("a", 1))),
None
);
}
#[test]
fn etag_quotes_ignored() {
// remote etag quoted, state etag bare — still "in sync".
assert_eq!(
one(
Some(se("a", "e", 1)),
Some(re("\"e\"", 1)),
Some(le("a", 1))
),
None
);
}
#[test]
fn local_deleted_remote_unchanged_deletes_remote() {
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e", 1)), None),
Some(Action::DeleteRemote("k".into()))
);
}
#[test]
fn local_deleted_remote_changed_resurrects_local() {
assert_eq!(
one(Some(se("a", "e", 1)), Some(re("e2", 1)), None),
Some(Action::Download("k".into()))
);
}
#[test]
fn remote_deleted_local_unchanged_deletes_local() {
assert_eq!(
one(Some(se("a", "e", 1)), None, Some(le("a", 1))),
Some(Action::DeleteLocal("k".into()))
);
}
#[test]
fn remote_deleted_local_changed_resurrects_remote() {
assert_eq!(
one(Some(se("a", "e", 1)), None, Some(le("b", 1))),
Some(Action::Upload("k".into()))
);
}
#[test]
fn gone_both_sides_drops_state() {
assert_eq!(
one(Some(se("a", "e", 1)), None, None),
Some(Action::DropState("k".into()))
);
}
#[test]
fn plan_is_deterministically_ordered() {
let mut sm = BTreeMap::new();
let mut rm = BTreeMap::new();
let mut lm = BTreeMap::new();
lm.insert("z.txt".to_string(), le("a", 1)); // new local → upload
rm.insert("a.txt".to_string(), re("e", 1)); // new remote → download
let plan = plan_actions(&sm, &rm, &lm);
// sorted by key: a.txt before z.txt
assert_eq!(
plan,
vec![
Action::Download("a.txt".into()),
Action::Upload("z.txt".into())
]
);
sm.clear();
}
#[test]
fn conflict_name_simple() {
assert_eq!(
conflict_name("report.txt", "a1b2c3"),
"report (conflict a1b2c3).txt"
);
}
#[test]
fn conflict_name_in_subdir() {
assert_eq!(
conflict_name("docs/notes/report.txt", "ff00aa"),
"docs/notes/report (conflict ff00aa).txt"
);
}
#[test]
fn conflict_name_double_ext() {
assert_eq!(
conflict_name("archive.tar.gz", "abc123"),
"archive.tar (conflict abc123).gz"
);
}
#[test]
fn conflict_name_no_ext() {
assert_eq!(
conflict_name("Makefile", "abc123"),
"Makefile (conflict abc123)"
);
}
#[test]
fn conflict_name_dotfile() {
assert_eq!(
conflict_name(".bashrc", "abc123"),
".bashrc (conflict abc123)"
);
}
#[test]
fn conflict_name_dotfile_in_dir() {
assert_eq!(
conflict_name("home/.env", "abc123"),
"home/.env (conflict abc123)"
);
}
}