1use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37use super::digest::{ByteDigest, ContentDigest, MetaDigest};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileFingerprint {
62 #[serde(default, skip_serializing_if = "Option::is_none", rename = "file_hash")]
67 pub byte_digest: Option<ByteDigest>,
68 #[serde(
71 default,
72 skip_serializing_if = "Option::is_none",
73 rename = "content_hash"
74 )]
75 pub content_digest: Option<ContentDigest>,
76 #[serde(default, skip_serializing_if = "Option::is_none", rename = "meta_hash")]
81 pub meta_digest: Option<MetaDigest>,
82 pub size: u64,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub modified_at: Option<DateTime<Utc>>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum FingerprintPrecision {
99 SizeOnly = 0,
101 Metadata = 1,
103 ByteLevel = 2,
105 MetaLevel = 3,
107 Semantic = 4,
109}
110
111impl FileFingerprint {
112 pub fn matches_within_location(&self, other: &FileFingerprint) -> bool {
134 if let (Some(a), Some(b)) = (&self.byte_digest, &other.byte_digest) {
136 match a.matches_same_algo(b) {
139 Ok(result) => return result,
140 Err(_) => { }
141 }
142 }
143 if let (Some(a), Some(b)) = (&self.content_digest, &other.content_digest) {
147 return a == b;
148 }
149 if let (Some(a), Some(b)) = (&self.meta_digest, &other.meta_digest) {
152 return a == b;
153 }
154 if self.size != other.size {
157 return false;
158 }
159 if let (Some(a), Some(b)) = (&self.modified_at, &other.modified_at) {
161 return a == b;
162 }
163 true
165 }
166
167 pub fn precision(&self) -> FingerprintPrecision {
172 if self.content_digest.is_some() {
173 FingerprintPrecision::Semantic
174 } else if self.meta_digest.is_some() {
175 FingerprintPrecision::MetaLevel
176 } else if self.byte_digest.is_some() {
177 FingerprintPrecision::ByteLevel
178 } else if self.modified_at.is_some() {
179 FingerprintPrecision::Metadata
180 } else {
181 FingerprintPrecision::SizeOnly
182 }
183 }
184
185 pub fn effective_precision(&self, other: &FileFingerprint) -> FingerprintPrecision {
189 std::cmp::min(self.precision(), other.precision())
190 }
191
192 pub fn from_local_hash(
204 file_hash: String,
205 content_hash: Option<String>,
206 size: u64,
207 modified_at: Option<DateTime<Utc>>,
208 ) -> Self {
209 Self {
210 byte_digest: Some(ByteDigest::Djb2(file_hash)),
211 content_digest: content_hash.map(ContentDigest),
212 meta_digest: None,
213 size,
214 modified_at,
215 }
216 }
217}
218
219impl std::fmt::Display for FingerprintPrecision {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 match self {
222 Self::SizeOnly => f.write_str("size-only"),
223 Self::Metadata => f.write_str("metadata"),
224 Self::ByteLevel => f.write_str("byte-level"),
225 Self::MetaLevel => f.write_str("meta-level"),
226 Self::Semantic => f.write_str("semantic"),
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn hash_fp(
236 byte_digest: ByteDigest,
237 content_digest: Option<&str>,
238 size: u64,
239 ) -> FileFingerprint {
240 FileFingerprint {
241 byte_digest: Some(byte_digest),
242 content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
243 meta_digest: None,
244 size,
245 modified_at: None,
246 }
247 }
248
249 fn hash_fp_with_meta(
250 byte_digest: ByteDigest,
251 content_digest: Option<&str>,
252 meta_digest: Option<&str>,
253 size: u64,
254 ) -> FileFingerprint {
255 FileFingerprint {
256 byte_digest: Some(byte_digest),
257 content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
258 meta_digest: meta_digest.map(|s| MetaDigest(s.to_string())),
259 size,
260 modified_at: None,
261 }
262 }
263
264 fn metadata_fp(size: u64, mtime: Option<DateTime<Utc>>) -> FileFingerprint {
265 FileFingerprint {
266 byte_digest: None,
267 content_digest: None,
268 meta_digest: None,
269 size,
270 modified_at: mtime,
271 }
272 }
273
274 #[test]
279 fn matches_byte_level_trumps_semantic() {
280 let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
281 let b = hash_fp(ByteDigest::Djb2("h1".into()), Some("c2"), 200);
282 assert!(a.matches_within_location(&b));
283 }
284
285 #[test]
286 fn matches_byte_level_different_trumps_semantic() {
287 let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
288 let b = hash_fp(ByteDigest::Djb2("h2".into()), Some("c1"), 100);
289 assert!(!a.matches_within_location(&b));
290 }
291
292 #[test]
297 fn matches_semantic_fallback_same() {
298 let a = FileFingerprint {
299 byte_digest: None,
300 content_digest: Some(ContentDigest("c1".into())),
301 meta_digest: None,
302 size: 100,
303 modified_at: None,
304 };
305 let b = FileFingerprint {
306 byte_digest: None,
307 content_digest: Some(ContentDigest("c1".into())),
308 meta_digest: None,
309 size: 200,
310 modified_at: None,
311 };
312 assert!(a.matches_within_location(&b));
313 }
314
315 #[test]
316 fn matches_semantic_fallback_different() {
317 let a = FileFingerprint {
318 byte_digest: None,
319 content_digest: Some(ContentDigest("c1".into())),
320 meta_digest: None,
321 size: 100,
322 modified_at: None,
323 };
324 let b = FileFingerprint {
325 byte_digest: None,
326 content_digest: Some(ContentDigest("c2".into())),
327 meta_digest: None,
328 size: 100,
329 modified_at: None,
330 };
331 assert!(!a.matches_within_location(&b));
332 }
333
334 #[test]
339 fn matches_metadata_same() {
340 let t = Utc::now();
341 let a = metadata_fp(1024, Some(t));
342 let b = metadata_fp(1024, Some(t));
343 assert!(a.matches_within_location(&b));
344 }
345
346 #[test]
347 fn matches_metadata_size_differs() {
348 let t = Utc::now();
349 let a = metadata_fp(1024, Some(t));
350 let b = metadata_fp(2048, Some(t));
351 assert!(!a.matches_within_location(&b));
352 }
353
354 #[test]
355 fn matches_metadata_mtime_differs() {
356 let t1 = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
357 .unwrap()
358 .with_timezone(&Utc);
359 let t2 = DateTime::parse_from_rfc3339("2024-06-01T00:00:00Z")
360 .unwrap()
361 .with_timezone(&Utc);
362 let a = metadata_fp(1024, Some(t1));
363 let b = metadata_fp(1024, Some(t2));
364 assert!(!a.matches_within_location(&b));
365 }
366
367 #[test]
372 fn matches_size_only_same() {
373 let a = metadata_fp(1024, None);
374 let b = metadata_fp(1024, None);
375 assert!(a.matches_within_location(&b));
376 }
377
378 #[test]
379 fn matches_size_only_different() {
380 let a = metadata_fp(1024, None);
381 let b = metadata_fp(2048, None);
382 assert!(!a.matches_within_location(&b));
383 }
384
385 #[test]
390 fn matches_hash_vs_metadata_size_match() {
391 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
392 let b = metadata_fp(1024, None);
393 assert!(a.matches_within_location(&b));
394 }
395
396 #[test]
397 fn matches_hash_vs_metadata_size_differs() {
398 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
399 let b = metadata_fp(2048, None);
400 assert!(!a.matches_within_location(&b));
401 }
402
403 #[test]
408 fn cross_algorithm_falls_back_to_size() {
409 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
411 let b = hash_fp(ByteDigest::Sha256("h2".into()), None, 1024);
412 assert!(a.matches_within_location(&b));
414 }
415
416 #[test]
421 fn precision_semantic() {
422 let fp = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
423 assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
424 }
425
426 #[test]
427 fn precision_byte_level() {
428 let fp = hash_fp(ByteDigest::Djb2("h".into()), None, 100);
429 assert_eq!(fp.precision(), FingerprintPrecision::ByteLevel);
430 }
431
432 #[test]
433 fn precision_metadata() {
434 let fp = metadata_fp(100, Some(Utc::now()));
435 assert_eq!(fp.precision(), FingerprintPrecision::Metadata);
436 }
437
438 #[test]
439 fn precision_size_only() {
440 let fp = metadata_fp(100, None);
441 assert_eq!(fp.precision(), FingerprintPrecision::SizeOnly);
442 }
443
444 #[test]
449 fn effective_precision_downgrades() {
450 let hash = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
451 let meta = metadata_fp(100, Some(Utc::now()));
452 assert_eq!(
453 hash.effective_precision(&meta),
454 FingerprintPrecision::Metadata
455 );
456 }
457
458 #[test]
459 fn effective_precision_same_level() {
460 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 100);
461 let b = hash_fp(ByteDigest::Djb2("h2".into()), None, 200);
462 assert_eq!(a.effective_precision(&b), FingerprintPrecision::ByteLevel);
463 }
464
465 #[test]
470 fn precision_display() {
471 assert_eq!(FingerprintPrecision::Semantic.to_string(), "semantic");
472 assert_eq!(FingerprintPrecision::MetaLevel.to_string(), "meta-level");
473 assert_eq!(FingerprintPrecision::ByteLevel.to_string(), "byte-level");
474 assert_eq!(FingerprintPrecision::Metadata.to_string(), "metadata");
475 assert_eq!(FingerprintPrecision::SizeOnly.to_string(), "size-only");
476 }
477
478 #[test]
483 fn matches_meta_digest_fallback_same() {
484 let a = FileFingerprint {
485 byte_digest: None,
486 content_digest: None,
487 meta_digest: Some(MetaDigest("m1".into())),
488 size: 100,
489 modified_at: None,
490 };
491 let b = FileFingerprint {
492 byte_digest: None,
493 content_digest: None,
494 meta_digest: Some(MetaDigest("m1".into())),
495 size: 200,
496 modified_at: None,
497 };
498 assert!(a.matches_within_location(&b));
499 }
500
501 #[test]
502 fn matches_meta_digest_fallback_different() {
503 let a = FileFingerprint {
504 byte_digest: None,
505 content_digest: None,
506 meta_digest: Some(MetaDigest("m1".into())),
507 size: 100,
508 modified_at: None,
509 };
510 let b = FileFingerprint {
511 byte_digest: None,
512 content_digest: None,
513 meta_digest: Some(MetaDigest("m2".into())),
514 size: 100,
515 modified_at: None,
516 };
517 assert!(!a.matches_within_location(&b));
518 }
519
520 #[test]
521 fn content_same_meta_different_means_meta_only_change() {
522 let a = hash_fp_with_meta(ByteDigest::Djb2("h1".into()), Some("c1"), Some("m1"), 1024);
523 let b = hash_fp_with_meta(ByteDigest::Djb2("h2".into()), Some("c1"), Some("m2"), 1024);
524 assert!(!a.matches_within_location(&b));
526 }
527
528 #[test]
529 fn precision_meta_level() {
530 let fp = FileFingerprint {
531 byte_digest: None,
532 content_digest: None,
533 meta_digest: Some(MetaDigest("m1".into())),
534 size: 100,
535 modified_at: None,
536 };
537 assert_eq!(fp.precision(), FingerprintPrecision::MetaLevel);
538 }
539
540 #[test]
541 fn precision_semantic_trumps_meta() {
542 let fp = FileFingerprint {
543 byte_digest: None,
544 content_digest: Some(ContentDigest("c1".into())),
545 meta_digest: Some(MetaDigest("m1".into())),
546 size: 100,
547 modified_at: None,
548 };
549 assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
550 }
551
552 #[test]
553 fn precision_ordering() {
554 assert!(FingerprintPrecision::SizeOnly < FingerprintPrecision::Metadata);
555 assert!(FingerprintPrecision::Metadata < FingerprintPrecision::ByteLevel);
556 assert!(FingerprintPrecision::ByteLevel < FingerprintPrecision::MetaLevel);
557 assert!(FingerprintPrecision::MetaLevel < FingerprintPrecision::Semantic);
558 }
559
560 #[test]
565 fn entity_model_meta_change_does_not_break_identity() {
566 let before = FileFingerprint {
567 byte_digest: None,
568 content_digest: Some(ContentDigest("pixel_hash_abc".into())),
569 meta_digest: Some(MetaDigest("meta_v1".into())),
570 size: 10240,
571 modified_at: None,
572 };
573 let after = FileFingerprint {
574 byte_digest: None,
575 content_digest: Some(ContentDigest("pixel_hash_abc".into())),
576 meta_digest: Some(MetaDigest("meta_v2".into())),
577 size: 10300,
578 modified_at: None,
579 };
580 assert!(before.matches_within_location(&after));
581 }
582
583 #[test]
584 fn entity_model_content_change_is_detected() {
585 let v1 = FileFingerprint {
586 byte_digest: None,
587 content_digest: Some(ContentDigest("pixel_v1".into())),
588 meta_digest: Some(MetaDigest("meta_v1".into())),
589 size: 10240,
590 modified_at: None,
591 };
592 let v2 = FileFingerprint {
593 byte_digest: None,
594 content_digest: Some(ContentDigest("pixel_v2".into())),
595 meta_digest: Some(MetaDigest("meta_v1".into())),
596 size: 10240,
597 modified_at: None,
598 };
599 assert!(!v1.matches_within_location(&v2));
600 }
601
602 #[test]
603 fn entity_model_reexport_with_ts_in_meta() {
604 let original = hash_fp_with_meta(
605 ByteDigest::Djb2("file_h1".into()),
606 Some("pixel_abc"),
607 Some("meta_ts1"),
608 10240,
609 );
610 let reexport = hash_fp_with_meta(
611 ByteDigest::Djb2("file_h2".into()),
612 Some("pixel_abc"),
613 Some("meta_ts2"),
614 10300,
615 );
616 assert!(!original.matches_within_location(&reexport));
617 assert_eq!(
618 original.content_digest.as_ref().map(|cd| cd.as_str()),
619 reexport.content_digest.as_ref().map(|cd| cd.as_str()),
620 );
621 }
622}