use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::digest::ContentDigest;
use super::error::DomainError;
use super::file_type::FileType;
use super::fingerprint::FileFingerprint;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanMatch {
ByHash,
ByPath,
NoMatch,
}
impl ScanMatch {
pub fn is_match(&self) -> bool {
!matches!(self, Self::NoMatch)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyFile {
id: String,
relative_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
canonical_digest: Option<ContentDigest>,
file_type: FileType,
registered_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deleted_at: Option<DateTime<Utc>>,
}
impl TopologyFile {
pub fn new(relative_path: String, file_type: FileType) -> Result<Self, DomainError> {
if relative_path.is_empty() {
return Err(DomainError::Validation {
field: "relative_path".into(),
reason: "must not be empty".into(),
});
}
Ok(Self {
id: uuid::Uuid::new_v4().to_string(),
relative_path,
canonical_digest: None,
file_type,
registered_at: Utc::now(),
deleted_at: None,
})
}
pub(crate) fn reconstitute(
id: String,
relative_path: String,
canonical_digest: Option<ContentDigest>,
file_type: FileType,
registered_at: DateTime<Utc>,
deleted_at: Option<DateTime<Utc>>,
) -> Self {
Self {
id,
relative_path,
canonical_digest,
file_type,
registered_at,
deleted_at,
}
}
pub fn mark_deleted(&mut self) {
if self.deleted_at.is_none() {
self.deleted_at = Some(Utc::now());
}
}
pub fn unmark_deleted(&mut self) {
self.deleted_at = None;
}
pub fn promote_canonical_digest(&mut self, fingerprint: &FileFingerprint) -> bool {
let candidate = &fingerprint.content_digest;
match (&self.canonical_digest, candidate) {
(_, None) => false,
(None, Some(cd)) => {
self.canonical_digest = Some(cd.clone());
true
}
(Some(old), Some(new)) => {
if old != new {
self.canonical_digest = Some(new.clone());
true
} else {
false
}
}
}
}
pub fn update_path(&mut self, new_path: String) {
self.relative_path = new_path;
}
pub fn id(&self) -> &str {
&self.id
}
pub fn relative_path(&self) -> &str {
&self.relative_path
}
pub fn canonical_digest(&self) -> Option<&ContentDigest> {
self.canonical_digest.as_ref()
}
pub fn canonical_hash(&self) -> Option<&str> {
self.canonical_digest.as_ref().map(|cd| cd.as_str())
}
pub fn file_type(&self) -> FileType {
self.file_type
}
pub fn registered_at(&self) -> DateTime<Utc> {
self.registered_at
}
pub fn deleted_at(&self) -> Option<DateTime<Utc>> {
self.deleted_at
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some()
}
pub fn materialize(
&self,
location_id: super::location::LocationId,
relative_path: String,
fingerprint: FileFingerprint,
embedded_id: Option<String>,
) -> Result<super::location_file::LocationFile, DomainError> {
super::location_file::LocationFile::new(
self.id.clone(),
location_id,
relative_path,
fingerprint,
embedded_id,
)
}
pub fn matches_scan(&self, scan_path: &str, scan_fingerprint: &FileFingerprint) -> ScanMatch {
if let Some(ref canonical) = self.canonical_digest {
if let Some(ref scan_cd) = scan_fingerprint.content_digest {
if canonical == scan_cd {
return ScanMatch::ByHash;
}
}
}
if self.relative_path == scan_path {
return ScanMatch::ByPath;
}
ScanMatch::NoMatch
}
}
impl PartialEq<super::location_file::LocationFile> for TopologyFile {
fn eq(&self, other: &super::location_file::LocationFile) -> bool {
self.id == other.file_id()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::digest::ByteDigest;
fn make_fp(
byte_digest: Option<ByteDigest>,
content_digest: Option<&str>,
size: u64,
) -> FileFingerprint {
FileFingerprint {
byte_digest,
content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
meta_digest: None,
size,
modified_at: None,
}
}
#[test]
fn new_sets_fields() {
let f = TopologyFile::new("output/gen-001.png".into(), FileType::Image)
.expect("valid test data");
assert_eq!(f.relative_path(), "output/gen-001.png");
assert_eq!(f.file_type(), FileType::Image);
assert!(!f.id().is_empty());
assert!(!f.is_deleted());
assert!(f.canonical_digest().is_none());
}
#[test]
fn new_rejects_empty_path() {
let result = TopologyFile::new("".into(), FileType::Image);
assert!(result.is_err());
}
#[test]
fn mark_deleted_is_idempotent() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
assert!(!f.is_deleted());
f.mark_deleted();
let first_deleted_at = f.deleted_at().unwrap();
assert!(f.is_deleted());
f.mark_deleted();
assert_eq!(f.deleted_at().unwrap(), first_deleted_at);
}
#[test]
fn unmark_deleted_clears() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
f.mark_deleted();
assert!(f.is_deleted());
f.unmark_deleted();
assert!(!f.is_deleted());
}
#[test]
fn promote_from_none_to_content_digest() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
assert!(f.canonical_digest().is_none());
let fp = make_fp(
Some(ByteDigest::Djb2("djb2_abc".into())),
Some("pixel_xyz"),
1024,
);
assert!(f.promote_canonical_digest(&fp));
assert_eq!(f.canonical_hash(), Some("pixel_xyz"));
}
#[test]
fn promote_no_content_digest_no_change() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
let fp = make_fp(Some(ByteDigest::Djb2("djb2_abc".into())), None, 1024);
assert!(!f.promote_canonical_digest(&fp));
assert!(f.canonical_digest().is_none());
}
#[test]
fn promote_content_digest_updates() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
let fp1 = make_fp(None, Some("pixel_v1"), 1024);
f.promote_canonical_digest(&fp1);
assert_eq!(f.canonical_hash(), Some("pixel_v1"));
let fp2 = make_fp(None, Some("pixel_v2"), 1024);
assert!(f.promote_canonical_digest(&fp2));
assert_eq!(f.canonical_hash(), Some("pixel_v2"));
}
#[test]
fn promote_same_content_digest_no_change() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
let fp = make_fp(None, Some("pixel_xyz"), 1024);
f.promote_canonical_digest(&fp);
assert!(!f.promote_canonical_digest(&fp));
}
#[test]
fn promote_none_fingerprint_no_change() {
let mut f = TopologyFile::new("a.png".into(), FileType::Image).unwrap();
let fp = FileFingerprint {
byte_digest: None,
content_digest: None,
meta_digest: None,
size: 100,
modified_at: None,
};
assert!(!f.promote_canonical_digest(&fp));
assert!(f.canonical_digest().is_none());
}
#[test]
fn matches_scan_by_hash() {
let mut f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let fp = make_fp(Some(ByteDigest::Djb2("h1".into())), Some("pixel_abc"), 1024);
f.promote_canonical_digest(&fp);
let scan_fp = make_fp(Some(ByteDigest::Djb2("h2".into())), Some("pixel_abc"), 2048);
assert_eq!(
f.matches_scan("output/renamed.png", &scan_fp),
ScanMatch::ByHash
);
}
#[test]
fn matches_scan_by_path() {
let f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let scan_fp = make_fp(Some(ByteDigest::Djb2("h1".into())), None, 1024);
assert_eq!(
f.matches_scan("output/001.png", &scan_fp),
ScanMatch::ByPath
);
}
#[test]
fn matches_scan_by_path_when_hash_differs() {
let mut f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let fp = make_fp(Some(ByteDigest::Djb2("h1".into())), Some("pixel_abc"), 1024);
f.promote_canonical_digest(&fp);
let scan_fp = make_fp(
Some(ByteDigest::Djb2("h2".into())),
Some("pixel_different"),
2048,
);
assert_eq!(
f.matches_scan("output/001.png", &scan_fp),
ScanMatch::ByPath
);
}
#[test]
fn matches_scan_no_match() {
let mut f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let fp = make_fp(Some(ByteDigest::Djb2("h1".into())), Some("pixel_abc"), 1024);
f.promote_canonical_digest(&fp);
let scan_fp = make_fp(Some(ByteDigest::Djb2("h3".into())), Some("pixel_xyz"), 4096);
assert_eq!(
f.matches_scan("output/other.png", &scan_fp),
ScanMatch::NoMatch
);
}
#[test]
fn matches_scan_cloud_no_hash() {
let f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let scan_fp = FileFingerprint {
byte_digest: None,
content_digest: None,
meta_digest: None,
size: 2048,
modified_at: None,
};
assert_eq!(
f.matches_scan("output/001.png", &scan_fp),
ScanMatch::ByPath
);
}
#[test]
fn matches_scan_cloud_no_hash_no_path() {
let f = TopologyFile::new("output/001.png".into(), FileType::Image).unwrap();
let scan_fp = FileFingerprint {
byte_digest: None,
content_digest: None,
meta_digest: None,
size: 2048,
modified_at: None,
};
assert_eq!(
f.matches_scan("output/other.png", &scan_fp),
ScanMatch::NoMatch
);
}
#[test]
fn update_path_changes_relative_path() {
let mut f = TopologyFile::new("old/path.png".into(), FileType::Image).unwrap();
f.update_path("new/path.png".into());
assert_eq!(f.relative_path(), "new/path.png");
}
#[test]
fn reconstitute_preserves_all_fields() {
let now = Utc::now();
let f = TopologyFile::reconstitute(
"id-1".into(),
"path.png".into(),
Some(ContentDigest("pixel_abc".into())),
FileType::Image,
now,
None,
);
assert_eq!(f.id(), "id-1");
assert_eq!(f.relative_path(), "path.png");
assert_eq!(f.canonical_hash(), Some("pixel_abc"));
assert_eq!(f.registered_at(), now);
assert!(!f.is_deleted());
}
#[test]
fn serde_roundtrip() {
let mut f = TopologyFile::new("test.png".into(), FileType::Image).unwrap();
let fp = make_fp(Some(ByteDigest::Djb2("h1".into())), Some("pixel_abc"), 1024);
f.promote_canonical_digest(&fp);
let json = serde_json::to_value(&f).unwrap();
let restored: TopologyFile = serde_json::from_value(json).unwrap();
assert_eq!(restored.id(), f.id());
assert_eq!(restored.relative_path(), f.relative_path());
assert_eq!(restored.canonical_hash(), Some("pixel_abc"));
}
#[test]
fn serde_omits_none_canonical_digest() {
let f = TopologyFile::new("test.png".into(), FileType::Image).unwrap();
let json = serde_json::to_value(&f).unwrap();
assert!(
json.get("canonical_digest").is_none(),
"None canonical_digest must be omitted"
);
}
}