use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use super::error::DomainError;
use super::fingerprint::FileFingerprint;
use super::location::LocationId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocationFileState {
Active,
Syncing,
Stale,
Missing,
Archived,
}
impl LocationFileState {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Syncing => "syncing",
Self::Stale => "stale",
Self::Missing => "missing",
Self::Archived => "archived",
}
}
pub fn is_source_eligible(&self) -> bool {
matches!(self, Self::Active)
}
pub fn is_distribute_target(&self) -> bool {
!matches!(self, Self::Archived)
}
pub fn is_present(&self) -> bool {
matches!(self, Self::Active | Self::Stale | Self::Archived)
}
}
impl fmt::Display for LocationFileState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for LocationFileState {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"active" => Ok(Self::Active),
"syncing" => Ok(Self::Syncing),
"stale" => Ok(Self::Stale),
"missing" => Ok(Self::Missing),
"archived" => Ok(Self::Archived),
other => Err(DomainError::Validation {
field: "location_file_state".into(),
reason: format!("unknown state: {other}"),
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationFile {
file_id: String,
location_id: LocationId,
relative_path: String,
fingerprint: FileFingerprint,
state: LocationFileState,
#[serde(default, skip_serializing_if = "Option::is_none")]
embedded_id: Option<String>,
updated_at: DateTime<Utc>,
}
impl LocationFile {
pub fn new(
file_id: String,
location_id: LocationId,
relative_path: String,
fingerprint: FileFingerprint,
embedded_id: Option<String>,
) -> Result<Self, DomainError> {
if file_id.is_empty() {
return Err(DomainError::Validation {
field: "file_id".into(),
reason: "must not be empty".into(),
});
}
if relative_path.is_empty() {
return Err(DomainError::Validation {
field: "relative_path".into(),
reason: "must not be empty".into(),
});
}
Ok(Self {
file_id,
location_id,
relative_path,
fingerprint,
state: LocationFileState::Active,
embedded_id,
updated_at: Utc::now(),
})
}
pub(crate) fn reconstitute(
file_id: String,
location_id: LocationId,
relative_path: String,
fingerprint: FileFingerprint,
state: LocationFileState,
embedded_id: Option<String>,
updated_at: DateTime<Utc>,
) -> Self {
Self {
file_id,
location_id,
relative_path,
fingerprint,
state,
embedded_id,
updated_at,
}
}
pub fn update_fingerprint(
&mut self,
new_fingerprint: FileFingerprint,
new_embedded_id: Option<String>,
) -> bool {
let changed = !self.fingerprint.matches_within_location(&new_fingerprint);
self.fingerprint = new_fingerprint;
self.embedded_id = new_embedded_id;
self.state = LocationFileState::Active;
if changed {
self.updated_at = Utc::now();
}
changed
}
pub fn has_changed(&self, scan_fingerprint: &FileFingerprint) -> bool {
!self.fingerprint.matches_within_location(scan_fingerprint)
}
pub fn mark_stale(&mut self) -> bool {
if self.state == LocationFileState::Archived {
return false;
}
if self.state != LocationFileState::Stale {
self.state = LocationFileState::Stale;
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn mark_syncing(&mut self) -> Result<(), DomainError> {
match self.state {
LocationFileState::Stale | LocationFileState::Missing => {
self.state = LocationFileState::Syncing;
self.updated_at = Utc::now();
Ok(())
}
other => Err(DomainError::InvalidStateTransition {
from: other.as_str().to_string(),
to: "syncing".to_string(),
}),
}
}
pub fn mark_active(&mut self) -> Result<(), DomainError> {
if self.state != LocationFileState::Syncing {
return Err(DomainError::InvalidStateTransition {
from: self.state.as_str().to_string(),
to: "active".to_string(),
});
}
self.state = LocationFileState::Active;
self.updated_at = Utc::now();
Ok(())
}
pub fn mark_missing(&mut self) -> bool {
if self.state == LocationFileState::Archived {
return false;
}
if self.state != LocationFileState::Missing {
self.state = LocationFileState::Missing;
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn archive(&mut self) {
if self.state != LocationFileState::Archived {
self.state = LocationFileState::Archived;
self.updated_at = Utc::now();
}
}
pub fn unarchive(&mut self) -> Result<(), DomainError> {
if self.state != LocationFileState::Archived {
return Err(DomainError::InvalidStateTransition {
from: self.state.as_str().to_string(),
to: "active (unarchive)".to_string(),
});
}
self.state = LocationFileState::Active;
self.updated_at = Utc::now();
Ok(())
}
pub fn file_id(&self) -> &str {
&self.file_id
}
pub fn location_id(&self) -> &LocationId {
&self.location_id
}
pub fn relative_path(&self) -> &str {
&self.relative_path
}
pub fn fingerprint(&self) -> &FileFingerprint {
&self.fingerprint
}
pub fn state(&self) -> LocationFileState {
self.state
}
pub fn embedded_id(&self) -> Option<&str> {
self.embedded_id.as_deref()
}
pub fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
}
pub fn stale_candidates<'a>(
all_lfs: &'a [LocationFile],
origin: &LocationId,
new_fingerprint: &FileFingerprint,
) -> Vec<&'a LocationFile> {
let new_identity = super::digest::CrossLocationIdentity::from_fingerprint(new_fingerprint);
all_lfs
.iter()
.filter(|lf| {
lf.location_id() != origin
&& lf.state() == LocationFileState::Active
&& !new_identity.matches(&super::digest::CrossLocationIdentity::from_fingerprint(
lf.fingerprint(),
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn local_loc() -> LocationId {
LocationId::local()
}
fn pod_loc() -> LocationId {
LocationId::new("pod").unwrap()
}
fn djb2_fp(hash: &str, size: u64) -> FileFingerprint {
use super::super::digest::ByteDigest;
FileFingerprint {
byte_digest: Some(ByteDigest::Djb2(hash.to_string())),
content_digest: None,
meta_digest: None,
size,
modified_at: None,
}
}
fn sha256_fp(hash: &str, size: u64) -> FileFingerprint {
use super::super::digest::ByteDigest;
FileFingerprint {
byte_digest: Some(ByteDigest::Sha256(hash.to_string())),
content_digest: None,
meta_digest: None,
size,
modified_at: None,
}
}
fn cloud_fp(size: u64) -> FileFingerprint {
FileFingerprint {
byte_digest: None,
content_digest: None,
meta_digest: None,
size,
modified_at: None,
}
}
fn make_lf() -> LocationFile {
LocationFile::new(
"f1".into(),
local_loc(),
"a.png".into(),
djb2_fp("abc123", 1024),
None,
)
.unwrap()
}
#[test]
fn new_sets_fields_and_active_state() {
let lf = LocationFile::new(
"file-1".into(),
local_loc(),
"output/gen-001.png".into(),
djb2_fp("abc123", 1024),
Some("gen-001".into()),
)
.unwrap();
assert_eq!(lf.file_id(), "file-1");
assert_eq!(lf.location_id(), &local_loc());
assert_eq!(lf.relative_path(), "output/gen-001.png");
assert_eq!(
lf.fingerprint().byte_digest.as_ref().map(|d| d.as_str()),
Some("abc123")
);
assert_eq!(lf.embedded_id(), Some("gen-001"));
assert_eq!(lf.state(), LocationFileState::Active);
}
#[test]
fn new_rejects_empty_file_id() {
let result = LocationFile::new(
"".into(),
local_loc(),
"a.png".into(),
djb2_fp("abc", 100),
None,
);
assert!(result.is_err());
}
#[test]
fn new_rejects_empty_relative_path() {
let result = LocationFile::new(
"file-1".into(),
local_loc(),
"".into(),
djb2_fp("abc", 100),
None,
);
assert!(result.is_err());
}
#[test]
fn same_hash_not_changed() {
let lf = make_lf();
assert!(!lf.has_changed(&djb2_fp("abc123", 1024)));
}
#[test]
fn different_hash_is_changed() {
let lf = make_lf();
assert!(lf.has_changed(&djb2_fp("def456", 1024)));
}
#[test]
fn cloud_same_size_not_changed() {
let lf = LocationFile::new(
"f1".into(),
LocationId::new("cloud").unwrap(),
"cloud/photo.png".into(),
cloud_fp(2048),
None,
)
.unwrap();
assert!(!lf.has_changed(&cloud_fp(2048)));
}
#[test]
fn cloud_different_size_is_changed() {
let lf = LocationFile::new(
"f1".into(),
LocationId::new("cloud").unwrap(),
"cloud/photo.png".into(),
cloud_fp(2048),
None,
)
.unwrap();
assert!(lf.has_changed(&cloud_fp(4096)));
}
#[test]
fn update_returns_true_on_change() {
let mut lf = make_lf();
let changed = lf.update_fingerprint(djb2_fp("new", 2048), None);
assert!(changed);
assert_eq!(
lf.fingerprint().byte_digest.as_ref().map(|d| d.as_str()),
Some("new")
);
assert_eq!(lf.state(), LocationFileState::Active);
}
#[test]
fn update_returns_false_on_same() {
let mut lf = make_lf();
let changed = lf.update_fingerprint(djb2_fp("abc123", 1024), None);
assert!(!changed);
}
#[test]
fn update_fingerprint_resets_state_to_active() {
let mut lf = make_lf();
lf.mark_stale();
assert_eq!(lf.state(), LocationFileState::Stale);
lf.update_fingerprint(djb2_fp("new", 2048), None);
assert_eq!(lf.state(), LocationFileState::Active);
}
#[test]
fn mark_stale_from_active() {
let mut lf = make_lf();
assert!(lf.mark_stale());
assert_eq!(lf.state(), LocationFileState::Stale);
}
#[test]
fn mark_stale_idempotent() {
let mut lf = make_lf();
lf.mark_stale();
let ts = lf.updated_at();
assert!(!lf.mark_stale());
assert_eq!(lf.updated_at(), ts);
}
#[test]
fn mark_stale_skips_archived() {
let mut lf = make_lf();
lf.archive();
assert!(!lf.mark_stale());
assert_eq!(lf.state(), LocationFileState::Archived);
}
#[test]
fn mark_syncing_from_stale() {
let mut lf = make_lf();
lf.mark_stale();
lf.mark_syncing().unwrap();
assert_eq!(lf.state(), LocationFileState::Syncing);
}
#[test]
fn mark_syncing_from_missing() {
let mut lf = make_lf();
lf.mark_missing();
lf.mark_syncing().unwrap();
assert_eq!(lf.state(), LocationFileState::Syncing);
}
#[test]
fn mark_syncing_from_active_fails() {
let mut lf = make_lf();
assert!(lf.mark_syncing().is_err());
}
#[test]
fn mark_syncing_from_archived_fails() {
let mut lf = make_lf();
lf.archive();
assert!(lf.mark_syncing().is_err());
}
#[test]
fn mark_syncing_from_syncing_fails() {
let mut lf = make_lf();
lf.mark_stale();
lf.mark_syncing().unwrap();
assert!(lf.mark_syncing().is_err());
}
#[test]
fn mark_active_from_syncing() {
let mut lf = make_lf();
lf.mark_stale();
lf.mark_syncing().unwrap();
lf.mark_active().unwrap();
assert_eq!(lf.state(), LocationFileState::Active);
}
#[test]
fn mark_active_from_stale_fails() {
let mut lf = make_lf();
lf.mark_stale();
assert!(lf.mark_active().is_err());
}
#[test]
fn mark_missing_from_active() {
let mut lf = make_lf();
assert!(lf.mark_missing());
assert_eq!(lf.state(), LocationFileState::Missing);
}
#[test]
fn mark_missing_from_stale() {
let mut lf = make_lf();
lf.mark_stale();
assert!(lf.mark_missing());
assert_eq!(lf.state(), LocationFileState::Missing);
}
#[test]
fn mark_missing_idempotent() {
let mut lf = make_lf();
lf.mark_missing();
assert!(!lf.mark_missing());
}
#[test]
fn mark_missing_skips_archived() {
let mut lf = make_lf();
lf.archive();
assert!(!lf.mark_missing());
assert_eq!(lf.state(), LocationFileState::Archived);
}
#[test]
fn archive_from_active() {
let mut lf = make_lf();
lf.archive();
assert_eq!(lf.state(), LocationFileState::Archived);
}
#[test]
fn archive_idempotent() {
let mut lf = make_lf();
lf.archive();
let ts = lf.updated_at();
lf.archive();
assert_eq!(lf.updated_at(), ts);
}
#[test]
fn archive_from_syncing() {
let mut lf = make_lf();
lf.mark_stale();
lf.mark_syncing().unwrap();
lf.archive();
assert_eq!(lf.state(), LocationFileState::Archived);
}
#[test]
fn unarchive_from_archived() {
let mut lf = make_lf();
lf.archive();
lf.unarchive().unwrap();
assert_eq!(lf.state(), LocationFileState::Active);
}
#[test]
fn unarchive_from_active_fails() {
let mut lf = make_lf();
assert!(lf.unarchive().is_err());
}
#[test]
fn active_is_source_eligible() {
assert!(LocationFileState::Active.is_source_eligible());
assert!(!LocationFileState::Stale.is_source_eligible());
assert!(!LocationFileState::Syncing.is_source_eligible());
assert!(!LocationFileState::Missing.is_source_eligible());
assert!(!LocationFileState::Archived.is_source_eligible());
}
#[test]
fn archived_not_distribute_target() {
assert!(LocationFileState::Active.is_distribute_target());
assert!(LocationFileState::Stale.is_distribute_target());
assert!(LocationFileState::Syncing.is_distribute_target());
assert!(LocationFileState::Missing.is_distribute_target());
assert!(!LocationFileState::Archived.is_distribute_target());
}
#[test]
fn is_present_reflects_physical_existence() {
assert!(LocationFileState::Active.is_present());
assert!(LocationFileState::Stale.is_present());
assert!(LocationFileState::Archived.is_present());
assert!(!LocationFileState::Missing.is_present());
assert!(!LocationFileState::Syncing.is_present());
}
#[test]
fn state_str_roundtrip() {
for state in [
LocationFileState::Active,
LocationFileState::Syncing,
LocationFileState::Stale,
LocationFileState::Missing,
LocationFileState::Archived,
] {
let s = state.as_str();
let parsed: LocationFileState = s.parse().unwrap();
assert_eq!(parsed, state);
}
}
#[test]
fn state_display() {
assert_eq!(LocationFileState::Active.to_string(), "active");
assert_eq!(LocationFileState::Archived.to_string(), "archived");
}
#[test]
fn state_invalid_str() {
let result: Result<LocationFileState, _> = "invalid".parse();
assert!(result.is_err());
}
#[test]
fn cross_algo_byte_digest_falls_back_to_size() {
let local_lf = make_lf(); let pod_fp = sha256_fp(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1024,
);
assert!(
!local_lf.has_changed(&pod_fp),
"same size → fallback matches"
);
let pod_fp_diff = sha256_fp(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2048,
);
assert!(
local_lf.has_changed(&pod_fp_diff),
"different size → changed"
);
}
#[test]
fn reconstitute_preserves_all() {
let now = Utc::now();
let lf = LocationFile::reconstitute(
"f-1".into(),
pod_loc(),
"output/file.png".into(),
sha256_fp("deadbeef", 512),
LocationFileState::Stale,
Some("emb".into()),
now,
);
assert_eq!(lf.file_id(), "f-1");
assert_eq!(lf.location_id(), &pod_loc());
assert_eq!(lf.relative_path(), "output/file.png");
assert_eq!(lf.state(), LocationFileState::Stale);
assert_eq!(lf.updated_at(), now);
}
#[test]
fn serde_roundtrip() {
let lf = LocationFile::new(
"f1".into(),
local_loc(),
"output/gen.png".into(),
djb2_fp("hash", 100),
Some("emb".into()),
)
.unwrap();
let json = serde_json::to_value(&lf).unwrap();
let restored: LocationFile = serde_json::from_value(json).unwrap();
assert_eq!(restored.file_id(), lf.file_id());
assert_eq!(restored.location_id(), lf.location_id());
assert_eq!(restored.relative_path(), lf.relative_path());
assert_eq!(
restored
.fingerprint()
.byte_digest
.as_ref()
.map(|d| d.as_str()),
lf.fingerprint().byte_digest.as_ref().map(|d| d.as_str()),
);
assert_eq!(restored.state(), LocationFileState::Active);
}
#[test]
fn serde_roundtrip_archived() {
let mut lf = make_lf();
lf.archive();
let json = serde_json::to_value(&lf).unwrap();
let restored: LocationFile = serde_json::from_value(json).unwrap();
assert_eq!(restored.state(), LocationFileState::Archived);
}
fn cloud_loc() -> LocationId {
LocationId::new("cloud").unwrap()
}
fn fp_with_content(content_hash: &str, size: u64) -> FileFingerprint {
use super::super::digest::{ByteDigest, ContentDigest};
FileFingerprint {
byte_digest: Some(ByteDigest::Djb2("aaa".into())),
content_digest: Some(ContentDigest(content_hash.into())),
meta_digest: None,
size,
modified_at: None,
}
}
fn fp_cloud_no_digest(size: u64) -> FileFingerprint {
FileFingerprint {
byte_digest: None,
content_digest: None,
meta_digest: None,
size,
modified_at: None,
}
}
fn make_lf_at(file_id: &str, loc: LocationId, fp: FileFingerprint) -> LocationFile {
LocationFile::new(file_id.into(), loc, "a.png".into(), fp, None).unwrap()
}
#[test]
fn stale_candidates_skips_same_content_digest() {
let local = make_lf_at("f1", local_loc(), fp_with_content("pixhash1", 1000));
let cloud = make_lf_at("f1", cloud_loc(), fp_with_content("pixhash1", 1000));
let new_fp = fp_with_content("pixhash1", 1000);
let lfs = [local, cloud];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert!(result.is_empty(), "same content_digest → skip");
}
#[test]
fn stale_candidates_marks_different_content_digest() {
let local = make_lf_at("f1", local_loc(), fp_with_content("pixhash_new", 1000));
let cloud = make_lf_at("f1", cloud_loc(), fp_with_content("pixhash_old", 1000));
let new_fp = fp_with_content("pixhash_new", 1000);
let lfs = [local, cloud];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert_eq!(result.len(), 1);
assert_eq!(result[0].location_id(), &cloud_loc());
}
#[test]
fn stale_candidates_size_fallback_same() {
let local = make_lf_at("f1", local_loc(), djb2_fp("aaa", 500));
let cloud = make_lf_at("f1", cloud_loc(), fp_cloud_no_digest(500));
let new_fp = djb2_fp("bbb", 500);
let lfs = [local, cloud];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert!(result.is_empty(), "same size, no content_digest → skip");
}
#[test]
fn stale_candidates_size_fallback_different() {
let local = make_lf_at("f1", local_loc(), djb2_fp("aaa", 600));
let cloud = make_lf_at("f1", cloud_loc(), fp_cloud_no_digest(500));
let new_fp = djb2_fp("bbb", 600);
let lfs = [local, cloud];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert_eq!(result.len(), 1);
assert_eq!(result[0].location_id(), &cloud_loc());
}
#[test]
fn stale_candidates_excludes_origin() {
let local = make_lf_at("f1", local_loc(), fp_with_content("px1", 1000));
let new_fp = fp_with_content("px2", 2000);
let lfs = [local];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert!(result.is_empty());
}
#[test]
fn stale_candidates_excludes_non_active() {
let mut archived = make_lf_at("f1", cloud_loc(), fp_with_content("old", 1000));
archived.archive();
let mut stale = make_lf_at("f1", pod_loc(), fp_with_content("old", 1000));
stale.mark_stale();
let new_fp = fp_with_content("new", 2000);
let lfs = [archived, stale];
let result = stale_candidates(&lfs, &local_loc(), &new_fp);
assert!(result.is_empty(), "Archived/Stale are not candidates");
}
}