AkurAI Build
Menu

Public repository / master

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)"
        );
    }
}