use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use crate::lineage::Change;
use crate::lineage::InstalledPackageStatus;
use crate::manifest::Manifest;
use crate::object_hash::ObjectHash;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PullOutcome {
UpToDate,
CleanUpdate,
KeepsLocalChanges {
added: Vec<PathBuf>,
modified: Vec<PathBuf>,
removed: Vec<PathBuf>,
},
Blocked { conflicts: Vec<PathBuf> },
}
#[allow(clippy::large_enum_variant)]
pub(crate) enum RemoteChange {
Modified(ObjectHash),
Removed,
Added(ObjectHash),
}
pub(crate) fn remote_delta(base: &Manifest, latest: &Manifest) -> BTreeMap<PathBuf, RemoteChange> {
let mut delta = BTreeMap::new();
for base_row in &base.rows {
match latest.get_record(&base_row.logical_key) {
Some(latest_row) if latest_row.hash == base_row.hash => {}
Some(latest_row) => {
delta.insert(
base_row.logical_key.clone(),
RemoteChange::Modified(latest_row.hash.clone()),
);
}
None => {
delta.insert(base_row.logical_key.clone(), RemoteChange::Removed);
}
}
}
for latest_row in &latest.rows {
if base.get_record(&latest_row.logical_key).is_none() {
delta.insert(
latest_row.logical_key.clone(),
RemoteChange::Added(latest_row.hash.clone()),
);
}
}
delta
}
fn same_resulting_content(local: &Change, remote: &RemoteChange) -> bool {
match (local, remote) {
(Change::Modified(row), RemoteChange::Modified(hash))
| (Change::Added(row), RemoteChange::Added(hash)) => &row.hash == hash,
(Change::Removed(_), RemoteChange::Removed) => true,
_ => false,
}
}
#[must_use]
pub fn classify_pull(
status: &InstalledPackageStatus,
base: &Manifest,
latest: &Manifest,
) -> PullOutcome {
if base == latest {
return PullOutcome::UpToDate;
}
let delta = remote_delta(base, latest);
if status.changes.is_empty() {
return PullOutcome::CleanUpdate;
}
let mut conflicts = Vec::new();
let mut added = Vec::new();
let mut modified = Vec::new();
let mut removed = Vec::new();
for (path, change) in &status.changes {
if let Some(remote_change) = delta.get(path) {
if !same_resulting_content(change, remote_change) {
conflicts.push(path.clone());
}
continue;
}
match change {
Change::Added(_) => added.push(path.clone()),
Change::Modified(_) => modified.push(path.clone()),
Change::Removed(_) => removed.push(path.clone()),
}
}
if conflicts.is_empty() {
PullOutcome::KeepsLocalChanges {
added,
modified,
removed,
}
} else {
PullOutcome::Blocked { conflicts }
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use multihash::Multihash;
use crate::Res;
use crate::lineage::Change;
use crate::lineage::ChangeSet;
use crate::lineage::InstalledPackageStatus;
use crate::lineage::UpstreamState;
use crate::manifest::Manifest;
use crate::manifest::ManifestRow;
fn row(key: &str, hash_seed: &[u8]) -> ManifestRow {
ManifestRow {
logical_key: PathBuf::from(key),
physical_key: format!("s3://b/{key}"),
hash: Multihash::<256>::wrap(0x12, hash_seed)
.unwrap()
.try_into()
.unwrap(),
size: hash_seed.len() as u64,
meta: None,
}
}
fn manifest_of(rows: Vec<ManifestRow>) -> Manifest {
Manifest {
rows,
..Manifest::default()
}
}
fn behind(changes: ChangeSet) -> InstalledPackageStatus {
InstalledPackageStatus::new(UpstreamState::Behind, changes)
}
#[test(tokio::test)]
async fn clean_tree_is_clean_update() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"2")]); let out = classify_pull(&behind(ChangeSet::default()), &base, &latest);
assert_eq!(out, PullOutcome::CleanUpdate);
Ok(())
}
#[test(tokio::test)]
async fn added_file_is_kept() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"2")]);
let mut changes = ChangeSet::new();
changes.insert(
PathBuf::from("new.txt"),
Change::Added(row("new.txt", b"x")),
);
let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::KeepsLocalChanges {
added: vec![PathBuf::from("new.txt")],
modified: vec![],
removed: vec![],
}
);
Ok(())
}
#[test(tokio::test)]
async fn same_path_different_content_blocks() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"remote")]); let mut changes = ChangeSet::new();
changes.insert(PathBuf::from("a"), Change::Modified(row("a", b"local"))); let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::Blocked {
conflicts: vec![PathBuf::from("a")]
}
);
Ok(())
}
#[test(tokio::test)]
async fn identical_edit_is_not_a_conflict() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"same")]);
let mut changes = ChangeSet::new();
changes.insert(PathBuf::from("a"), Change::Modified(row("a", b"same"))); let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::KeepsLocalChanges {
added: vec![],
modified: vec![],
removed: vec![]
}
);
Ok(())
}
#[test(tokio::test)]
async fn local_remove_vs_remote_modify_blocks() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"2")]); let mut changes = ChangeSet::new();
changes.insert(PathBuf::from("a"), Change::Removed(row("a", b"1"))); let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::Blocked {
conflicts: vec![PathBuf::from("a")]
}
);
Ok(())
}
#[test(tokio::test)]
async fn local_modify_vs_remote_remove_blocks() -> Res {
let base = manifest_of(vec![row("a", b"1"), row("b", b"2")]);
let latest = manifest_of(vec![row("b", b"2")]); let mut changes = ChangeSet::new();
changes.insert(PathBuf::from("a"), Change::Modified(row("a", b"local"))); let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::Blocked {
conflicts: vec![PathBuf::from("a")]
}
);
Ok(())
}
#[test(tokio::test)]
async fn both_removed_is_not_a_conflict() -> Res {
let base = manifest_of(vec![row("a", b"1"), row("b", b"2")]);
let latest = manifest_of(vec![row("b", b"2")]); let mut changes = ChangeSet::new();
changes.insert(PathBuf::from("a"), Change::Removed(row("a", b"1"))); let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::KeepsLocalChanges {
added: vec![],
modified: vec![],
removed: vec![]
}
);
Ok(())
}
#[test(tokio::test)]
async fn both_added_different_content_blocks() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"1"), row("new.txt", b"remote")]);
let mut changes = ChangeSet::new();
changes.insert(
PathBuf::from("new.txt"),
Change::Added(row("new.txt", b"local")),
);
let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::Blocked {
conflicts: vec![PathBuf::from("new.txt")]
}
);
Ok(())
}
#[test(tokio::test)]
async fn both_added_same_content_is_trivially_resolved() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"1"), row("new.txt", b"same")]);
let mut changes = ChangeSet::new();
changes.insert(
PathBuf::from("new.txt"),
Change::Added(row("new.txt", b"same")),
);
let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::KeepsLocalChanges {
added: vec![],
modified: vec![],
removed: vec![],
}
);
Ok(())
}
#[test]
fn the_delta_reports_additions_alongside_modifications_and_removals() {
let base = manifest_of(vec![
row("kept", b"k"),
row("gone", b"g"),
row("edit", b"e1"),
]);
let latest = manifest_of(vec![
row("kept", b"k"),
row("edit", b"e2"),
row("new", b"n"),
]);
let delta = remote_delta(&base, &latest);
assert!(
!delta.contains_key(&PathBuf::from("kept")),
"an unchanged row is not a change"
);
assert!(matches!(
delta.get(&PathBuf::from("gone")),
Some(RemoteChange::Removed)
));
assert!(matches!(
delta.get(&PathBuf::from("edit")),
Some(RemoteChange::Modified(_))
));
assert!(
matches!(delta.get(&PathBuf::from("new")), Some(RemoteChange::Added(h)) if *h == row("new", b"n").hash),
"a latest-only path is an Added carrying its content hash"
);
}
#[test(tokio::test)]
async fn no_remote_change_is_up_to_date() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let latest = manifest_of(vec![row("a", b"1")]); let out = classify_pull(&behind(ChangeSet::default()), &base, &latest);
assert_eq!(out, PullOutcome::UpToDate);
Ok(())
}
#[test(tokio::test)]
async fn metadata_only_change_is_clean_update() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let mut latest = manifest_of(vec![row("a", b"1")]);
latest.header.message = Some("newer revision message".to_string());
let out = classify_pull(&behind(ChangeSet::default()), &base, &latest);
assert_eq!(out, PullOutcome::CleanUpdate);
Ok(())
}
#[test(tokio::test)]
async fn metadata_only_change_keeps_local_changes() -> Res {
let base = manifest_of(vec![row("a", b"1")]);
let mut latest = manifest_of(vec![row("a", b"1")]);
latest.header.message = Some("newer revision message".to_string());
let mut changes = ChangeSet::new();
changes.insert(
PathBuf::from("new.txt"),
Change::Added(row("new.txt", b"x")),
);
let out = classify_pull(&behind(changes), &base, &latest);
assert_eq!(
out,
PullOutcome::KeepsLocalChanges {
added: vec![PathBuf::from("new.txt")],
modified: vec![],
removed: vec![],
}
);
Ok(())
}
}