use super::source::Source;
use std::collections::HashSet;
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct FileObservation {
pub root_id: i64,
pub rel_path: String,
pub device: u64,
pub inode: u64,
pub size: i64,
pub mtime: i64,
pub partial_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reconciliation {
New,
Unchanged { source_id: i64 },
Modified {
source_id: i64,
old_object_id: Option<i64>,
},
Moved {
source_id: i64,
from_root_id: i64,
from_path: String,
old_object_id: Option<i64>,
},
}
impl Reconciliation {
pub fn needs_partial_hash(&self) -> bool {
matches!(self, Reconciliation::New | Reconciliation::Modified { .. })
}
#[allow(dead_code)]
pub fn source_id(&self) -> Option<i64> {
match self {
Reconciliation::New => None,
Reconciliation::Unchanged { source_id }
| Reconciliation::Modified { source_id, .. }
| Reconciliation::Moved { source_id, .. } => Some(*source_id),
}
}
}
pub fn reconcile(
observation: &FileObservation,
source_at_path: Option<&Source>,
source_by_inode: Option<&Source>,
) -> Reconciliation {
if let Some(existing) = source_at_path {
let inode_tracked = existing.inode != 0;
let same_inode = inode_tracked && existing.inode as u64 == observation.inode;
if same_inode || !inode_tracked {
let fingerprint_matches =
existing.size == observation.size && existing.mtime == observation.mtime;
if fingerprint_matches {
Reconciliation::Unchanged {
source_id: existing.id,
}
} else {
Reconciliation::Modified {
source_id: existing.id,
old_object_id: existing.object_id,
}
}
} else {
Reconciliation::New
}
} else if let Some(existing) = source_by_inode {
Reconciliation::Moved {
source_id: existing.id,
from_root_id: existing.root_id,
from_path: existing.rel_path.clone(),
old_object_id: existing.object_id,
}
} else {
Reconciliation::New
}
}
pub fn find_missing(expected_ids: &HashSet<i64>, seen_ids: &HashSet<i64>) -> Vec<i64> {
expected_ids.difference(seen_ids).copied().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_observation(rel_path: &str) -> FileObservation {
FileObservation {
root_id: 1,
rel_path: rel_path.to_string(),
device: 100,
inode: 1000,
size: 1024,
mtime: 1700000000,
partial_hash: None,
}
}
fn make_source(id: i64, rel_path: &str) -> Source {
Source {
id,
root_id: 1,
root_path: "/test".to_string(),
rel_path: rel_path.to_string(),
object_id: Some(42),
size: 1024,
mtime: 1700000000,
excluded: false,
object_excluded: None,
device: 100,
inode: 1000,
partial_hash: "abc123".to_string(),
basis_rev: 0,
root_role: "source".to_string(),
root_suspended: false,
}
}
#[test]
fn reconcile_new_file() {
let obs = make_observation("new_file.txt");
let result = reconcile(&obs, None, None);
assert_eq!(result, Reconciliation::New);
}
#[test]
fn reconcile_unchanged_file() {
let obs = make_observation("existing.txt");
let existing = make_source(1, "existing.txt");
let result = reconcile(&obs, Some(&existing), Some(&existing));
assert_eq!(result, Reconciliation::Unchanged { source_id: 1 });
}
#[test]
fn reconcile_modified_file_size() {
let mut obs = make_observation("existing.txt");
obs.size = 2048;
let existing = make_source(1, "existing.txt");
let result = reconcile(&obs, Some(&existing), Some(&existing));
assert_eq!(
result,
Reconciliation::Modified {
source_id: 1,
old_object_id: Some(42)
}
);
}
#[test]
fn reconcile_modified_file_mtime() {
let mut obs = make_observation("existing.txt");
obs.mtime = 1800000000;
let existing = make_source(1, "existing.txt");
let result = reconcile(&obs, Some(&existing), Some(&existing));
assert_eq!(
result,
Reconciliation::Modified {
source_id: 1,
old_object_id: Some(42)
}
);
}
#[test]
fn reconcile_moved_file() {
let obs = make_observation("new_location.txt");
let existing = make_source(1, "old_location.txt");
let result = reconcile(&obs, None, Some(&existing));
assert_eq!(
result,
Reconciliation::Moved {
source_id: 1,
from_root_id: 1,
from_path: "old_location.txt".to_string(),
old_object_id: Some(42)
}
);
}
#[test]
fn reconcile_moved_cross_root() {
let obs = make_observation("imported.txt");
let mut existing = make_source(1, "original.txt");
existing.root_id = 2;
let result = reconcile(&obs, None, Some(&existing));
assert_eq!(
result,
Reconciliation::Moved {
source_id: 1,
from_root_id: 2,
from_path: "original.txt".to_string(),
old_object_id: Some(42)
}
);
}
#[test]
fn reconcile_replaced_file() {
let mut obs = make_observation("replaced.txt");
obs.inode = 9999;
let existing = make_source(1, "replaced.txt");
let result = reconcile(&obs, Some(&existing), None);
assert_eq!(result, Reconciliation::New);
}
#[test]
fn reconcile_inode_not_tracked() {
let obs = make_observation("file.txt");
let mut existing = make_source(1, "file.txt");
existing.inode = 0; existing.device = 0;
let result = reconcile(&obs, Some(&existing), None);
assert_eq!(result, Reconciliation::Unchanged { source_id: 1 });
}
#[test]
fn reconcile_inode_not_tracked_modified() {
let mut obs = make_observation("file.txt");
obs.size = 2048;
let mut existing = make_source(1, "file.txt");
existing.inode = 0;
existing.device = 0;
let result = reconcile(&obs, Some(&existing), None);
assert_eq!(
result,
Reconciliation::Modified {
source_id: 1,
old_object_id: Some(42)
}
);
}
#[test]
fn reconcile_device_changed() {
let mut obs = make_observation("file.txt");
obs.device = 200;
let existing = make_source(1, "file.txt");
let result = reconcile(&obs, Some(&existing), Some(&existing));
assert_eq!(result, Reconciliation::Unchanged { source_id: 1 });
}
#[test]
fn reconcile_device_changed_modified() {
let mut obs = make_observation("file.txt");
obs.device = 200; obs.size = 2048;
let existing = make_source(1, "file.txt");
let result = reconcile(&obs, Some(&existing), Some(&existing));
assert_eq!(
result,
Reconciliation::Modified {
source_id: 1,
old_object_id: Some(42)
}
);
}
#[test]
fn needs_partial_hash_new() {
assert!(Reconciliation::New.needs_partial_hash());
}
#[test]
fn needs_partial_hash_modified() {
let r = Reconciliation::Modified {
source_id: 1,
old_object_id: None,
};
assert!(r.needs_partial_hash());
}
#[test]
fn needs_partial_hash_moved() {
let r = Reconciliation::Moved {
source_id: 1,
from_root_id: 1,
from_path: "old.txt".to_string(),
old_object_id: None,
};
assert!(!r.needs_partial_hash());
}
#[test]
fn needs_partial_hash_unchanged() {
let r = Reconciliation::Unchanged { source_id: 1 };
assert!(!r.needs_partial_hash());
}
#[test]
fn source_id_new() {
assert_eq!(Reconciliation::New.source_id(), None);
}
#[test]
fn source_id_unchanged() {
let r = Reconciliation::Unchanged { source_id: 42 };
assert_eq!(r.source_id(), Some(42));
}
#[test]
fn source_id_modified() {
let r = Reconciliation::Modified {
source_id: 42,
old_object_id: None,
};
assert_eq!(r.source_id(), Some(42));
}
#[test]
fn source_id_moved() {
let r = Reconciliation::Moved {
source_id: 42,
from_root_id: 1,
from_path: "old.txt".to_string(),
old_object_id: None,
};
assert_eq!(r.source_id(), Some(42));
}
#[test]
fn find_missing_empty_sets() {
let expected: HashSet<i64> = HashSet::new();
let seen: HashSet<i64> = HashSet::new();
let result = find_missing(&expected, &seen);
assert!(result.is_empty());
}
#[test]
fn find_missing_all_seen() {
let expected: HashSet<i64> = [1, 2, 3].into_iter().collect();
let seen: HashSet<i64> = [1, 2, 3].into_iter().collect();
let result = find_missing(&expected, &seen);
assert!(result.is_empty());
}
#[test]
fn find_missing_none_seen() {
let expected: HashSet<i64> = [1, 2, 3].into_iter().collect();
let seen: HashSet<i64> = HashSet::new();
let mut result = find_missing(&expected, &seen);
result.sort();
assert_eq!(result, vec![1, 2, 3]);
}
#[test]
fn find_missing_partial() {
let expected: HashSet<i64> = [1, 2, 3, 4, 5].into_iter().collect();
let seen: HashSet<i64> = [1, 3, 5].into_iter().collect();
let mut result = find_missing(&expected, &seen);
result.sort();
assert_eq!(result, vec![2, 4]);
}
#[test]
fn find_missing_seen_has_extra() {
let expected: HashSet<i64> = [1, 2].into_iter().collect();
let seen: HashSet<i64> = [1, 2, 99, 100].into_iter().collect();
let result = find_missing(&expected, &seen);
assert!(result.is_empty());
}
}