use std::path::Path;
use chrono::{DateTime, Utc};
use crate::store::{FileEntry, Store};
use crate::sync;
#[derive(Debug, PartialEq, Eq)]
pub enum PullApply {
Applied,
DriftRefused,
}
pub fn apply_pull(
store: &mut Store,
root: &Path,
rel_path: &str,
expected_local_sha256: &str,
content: &str,
entry: FileEntry,
) -> std::io::Result<PullApply> {
let path = root.join(rel_path);
let on_disk = std::fs::read_to_string(&path).unwrap_or_default();
if sync::sha256_hex(&on_disk) != expected_local_sha256 {
return Ok(PullApply::DriftRefused);
}
std::fs::write(&path, content)?;
store.insert(root, rel_path.to_string(), entry);
Ok(PullApply::Applied)
}
pub fn record_divergence(
store: &mut Store,
root: &Path,
rel_path: &str,
remote_sha256: String,
remote_updated_at: DateTime<Utc>,
) -> bool {
let Some(entry) = store.get(root, rel_path).cloned() else {
return false;
};
let mut updated = entry;
updated.remote_sha256 = remote_sha256;
updated.remote_updated_at = Some(remote_updated_at);
store.insert(root, rel_path.to_string(), updated);
true
}
pub fn record_observation(
store: &mut Store,
root: &Path,
rel_path: &str,
started: DateTime<Utc>,
remote_sha256: String,
remote_updated_at: DateTime<Utc>,
) -> bool {
if store
.get(root, rel_path)
.filter(|e| e.last_synced <= started)
.is_none()
{
return false;
}
record_divergence(store, root, rel_path, remote_sha256, remote_updated_at)
}
pub fn apply_remote_updates(
store: &mut Store,
root: &Path,
started: DateTime<Utc>,
updated: Vec<(String, FileEntry)>,
) -> Vec<String> {
let mut applied = Vec::new();
for (rel, refreshed) in updated {
if crate::remote::should_apply_update(store.get(root, &rel), &refreshed, started) {
store.insert(root, rel.clone(), refreshed);
applied.push(rel);
}
}
applied
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn entry(remote_id: &str, synced_at: i64) -> FileEntry {
FileEntry {
backend: crate::store::GIST_BACKEND.into(),
remote_id: remote_id.into(),
url: format!("https://gist.github.com/u/{remote_id}"),
local_sha256: "l".into(),
remote_sha256: "r".into(),
last_synced: Utc.timestamp_opt(synced_at, 0).unwrap(),
remote_updated_at: None,
}
}
fn at(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(secs, 0).unwrap()
}
#[test]
fn pull_writes_file_and_records_entry() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.md"), "local").unwrap();
let mut store = Store::default();
let out = apply_pull(
&mut store,
root,
"a.md",
&sync::sha256_hex("local"),
"remote content",
entry("g1", 10),
)
.unwrap();
assert_eq!(out, PullApply::Applied);
assert_eq!(
std::fs::read_to_string(root.join("a.md")).unwrap(),
"remote content"
);
assert_eq!(store.get(root, "a.md").unwrap().remote_id, "g1");
}
#[test]
fn pull_refuses_when_file_changed_mid_flight() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.md"), "edited mid-pull").unwrap();
let mut store = Store::default();
let out = apply_pull(
&mut store,
root,
"a.md",
&sync::sha256_hex("local"),
"remote content",
entry("g1", 10),
)
.unwrap();
assert_eq!(out, PullApply::DriftRefused);
assert_eq!(
std::fs::read_to_string(root.join("a.md")).unwrap(),
"edited mid-pull"
);
assert!(store.get(root, "a.md").is_none());
}
#[test]
fn divergence_updates_remote_fields() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut store = Store::default();
store.insert(root, "a.md".into(), entry("g1", 10));
assert!(record_divergence(
&mut store,
root,
"a.md",
"new-remote-sha".into(),
at(50),
));
let e = store.get(root, "a.md").unwrap();
assert_eq!(e.remote_sha256, "new-remote-sha");
assert_eq!(e.remote_updated_at, Some(at(50)));
assert_eq!(e.local_sha256, "l");
}
#[test]
fn divergence_noop_when_mapping_gone() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::default();
assert!(!record_divergence(
&mut store,
dir.path(),
"a.md",
"x".into(),
at(50),
));
}
#[test]
fn observation_skipped_when_entry_synced_after_check_started() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut store = Store::default();
store.insert(root, "a.md".into(), entry("g1", 200));
assert!(!record_observation(
&mut store,
root,
"a.md",
at(100),
"stale-sha".into(),
at(50),
));
assert_eq!(store.get(root, "a.md").unwrap().remote_sha256, "r");
}
#[test]
fn observation_applied_when_entry_older_than_check() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut store = Store::default();
store.insert(root, "a.md".into(), entry("g1", 10));
assert!(record_observation(
&mut store,
root,
"a.md",
at(100),
"observed-sha".into(),
at(50),
));
assert_eq!(
store.get(root, "a.md").unwrap().remote_sha256,
"observed-sha"
);
}
#[test]
fn remote_updates_apply_selectively() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut store = Store::default();
store.insert(root, "old.md".into(), entry("g1", 10));
store.insert(root, "fresh.md".into(), entry("g2", 200));
let mut refreshed_old = entry("g1", 10);
refreshed_old.remote_sha256 = "new1".into();
let mut refreshed_fresh = entry("g2", 200);
refreshed_fresh.remote_sha256 = "new2".into();
let applied = apply_remote_updates(
&mut store,
root,
at(100),
vec![
("old.md".into(), refreshed_old),
("fresh.md".into(), refreshed_fresh),
],
);
assert_eq!(applied, vec!["old.md".to_string()]);
assert_eq!(store.get(root, "old.md").unwrap().remote_sha256, "new1");
assert_eq!(store.get(root, "fresh.md").unwrap().remote_sha256, "r");
}
}