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
193impl std::fmt::Display for FingerprintPrecision {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 Self::SizeOnly => f.write_str("size-only"),
197 Self::Metadata => f.write_str("metadata"),
198 Self::ByteLevel => f.write_str("byte-level"),
199 Self::MetaLevel => f.write_str("meta-level"),
200 Self::Semantic => f.write_str("semantic"),
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn hash_fp(
210 byte_digest: ByteDigest,
211 content_digest: Option<&str>,
212 size: u64,
213 ) -> FileFingerprint {
214 FileFingerprint {
215 byte_digest: Some(byte_digest),
216 content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
217 meta_digest: None,
218 size,
219 modified_at: None,
220 }
221 }
222
223 fn hash_fp_with_meta(
224 byte_digest: ByteDigest,
225 content_digest: Option<&str>,
226 meta_digest: Option<&str>,
227 size: u64,
228 ) -> FileFingerprint {
229 FileFingerprint {
230 byte_digest: Some(byte_digest),
231 content_digest: content_digest.map(|s| ContentDigest(s.to_string())),
232 meta_digest: meta_digest.map(|s| MetaDigest(s.to_string())),
233 size,
234 modified_at: None,
235 }
236 }
237
238 fn metadata_fp(size: u64, mtime: Option<DateTime<Utc>>) -> FileFingerprint {
239 FileFingerprint {
240 byte_digest: None,
241 content_digest: None,
242 meta_digest: None,
243 size,
244 modified_at: mtime,
245 }
246 }
247
248 #[test]
253 fn matches_byte_level_trumps_semantic() {
254 let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
255 let b = hash_fp(ByteDigest::Djb2("h1".into()), Some("c2"), 200);
256 assert!(a.matches_within_location(&b));
257 }
258
259 #[test]
260 fn matches_byte_level_different_trumps_semantic() {
261 let a = hash_fp(ByteDigest::Djb2("h1".into()), Some("c1"), 100);
262 let b = hash_fp(ByteDigest::Djb2("h2".into()), Some("c1"), 100);
263 assert!(!a.matches_within_location(&b));
264 }
265
266 #[test]
271 fn matches_semantic_fallback_same() {
272 let a = FileFingerprint {
273 byte_digest: None,
274 content_digest: Some(ContentDigest("c1".into())),
275 meta_digest: None,
276 size: 100,
277 modified_at: None,
278 };
279 let b = FileFingerprint {
280 byte_digest: None,
281 content_digest: Some(ContentDigest("c1".into())),
282 meta_digest: None,
283 size: 200,
284 modified_at: None,
285 };
286 assert!(a.matches_within_location(&b));
287 }
288
289 #[test]
290 fn matches_semantic_fallback_different() {
291 let a = FileFingerprint {
292 byte_digest: None,
293 content_digest: Some(ContentDigest("c1".into())),
294 meta_digest: None,
295 size: 100,
296 modified_at: None,
297 };
298 let b = FileFingerprint {
299 byte_digest: None,
300 content_digest: Some(ContentDigest("c2".into())),
301 meta_digest: None,
302 size: 100,
303 modified_at: None,
304 };
305 assert!(!a.matches_within_location(&b));
306 }
307
308 #[test]
313 fn matches_metadata_same() {
314 let t = Utc::now();
315 let a = metadata_fp(1024, Some(t));
316 let b = metadata_fp(1024, Some(t));
317 assert!(a.matches_within_location(&b));
318 }
319
320 #[test]
321 fn matches_metadata_size_differs() {
322 let t = Utc::now();
323 let a = metadata_fp(1024, Some(t));
324 let b = metadata_fp(2048, Some(t));
325 assert!(!a.matches_within_location(&b));
326 }
327
328 #[test]
329 fn matches_metadata_mtime_differs() {
330 let t1 = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
331 .unwrap()
332 .with_timezone(&Utc);
333 let t2 = DateTime::parse_from_rfc3339("2024-06-01T00:00:00Z")
334 .unwrap()
335 .with_timezone(&Utc);
336 let a = metadata_fp(1024, Some(t1));
337 let b = metadata_fp(1024, Some(t2));
338 assert!(!a.matches_within_location(&b));
339 }
340
341 #[test]
346 fn matches_size_only_same() {
347 let a = metadata_fp(1024, None);
348 let b = metadata_fp(1024, None);
349 assert!(a.matches_within_location(&b));
350 }
351
352 #[test]
353 fn matches_size_only_different() {
354 let a = metadata_fp(1024, None);
355 let b = metadata_fp(2048, None);
356 assert!(!a.matches_within_location(&b));
357 }
358
359 #[test]
364 fn matches_hash_vs_metadata_size_match() {
365 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
366 let b = metadata_fp(1024, None);
367 assert!(a.matches_within_location(&b));
368 }
369
370 #[test]
371 fn matches_hash_vs_metadata_size_differs() {
372 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
373 let b = metadata_fp(2048, None);
374 assert!(!a.matches_within_location(&b));
375 }
376
377 #[test]
382 fn cross_algorithm_falls_back_to_size() {
383 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 1024);
385 let b = hash_fp(ByteDigest::Sha256("h2".into()), None, 1024);
386 assert!(a.matches_within_location(&b));
388 }
389
390 #[test]
395 fn precision_semantic() {
396 let fp = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
397 assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
398 }
399
400 #[test]
401 fn precision_byte_level() {
402 let fp = hash_fp(ByteDigest::Djb2("h".into()), None, 100);
403 assert_eq!(fp.precision(), FingerprintPrecision::ByteLevel);
404 }
405
406 #[test]
407 fn precision_metadata() {
408 let fp = metadata_fp(100, Some(Utc::now()));
409 assert_eq!(fp.precision(), FingerprintPrecision::Metadata);
410 }
411
412 #[test]
413 fn precision_size_only() {
414 let fp = metadata_fp(100, None);
415 assert_eq!(fp.precision(), FingerprintPrecision::SizeOnly);
416 }
417
418 #[test]
423 fn effective_precision_downgrades() {
424 let hash = hash_fp(ByteDigest::Djb2("h".into()), Some("c"), 100);
425 let meta = metadata_fp(100, Some(Utc::now()));
426 assert_eq!(
427 hash.effective_precision(&meta),
428 FingerprintPrecision::Metadata
429 );
430 }
431
432 #[test]
433 fn effective_precision_same_level() {
434 let a = hash_fp(ByteDigest::Djb2("h1".into()), None, 100);
435 let b = hash_fp(ByteDigest::Djb2("h2".into()), None, 200);
436 assert_eq!(a.effective_precision(&b), FingerprintPrecision::ByteLevel);
437 }
438
439 #[test]
444 fn precision_display() {
445 assert_eq!(FingerprintPrecision::Semantic.to_string(), "semantic");
446 assert_eq!(FingerprintPrecision::MetaLevel.to_string(), "meta-level");
447 assert_eq!(FingerprintPrecision::ByteLevel.to_string(), "byte-level");
448 assert_eq!(FingerprintPrecision::Metadata.to_string(), "metadata");
449 assert_eq!(FingerprintPrecision::SizeOnly.to_string(), "size-only");
450 }
451
452 #[test]
457 fn matches_meta_digest_fallback_same() {
458 let a = FileFingerprint {
459 byte_digest: None,
460 content_digest: None,
461 meta_digest: Some(MetaDigest("m1".into())),
462 size: 100,
463 modified_at: None,
464 };
465 let b = FileFingerprint {
466 byte_digest: None,
467 content_digest: None,
468 meta_digest: Some(MetaDigest("m1".into())),
469 size: 200,
470 modified_at: None,
471 };
472 assert!(a.matches_within_location(&b));
473 }
474
475 #[test]
476 fn matches_meta_digest_fallback_different() {
477 let a = FileFingerprint {
478 byte_digest: None,
479 content_digest: None,
480 meta_digest: Some(MetaDigest("m1".into())),
481 size: 100,
482 modified_at: None,
483 };
484 let b = FileFingerprint {
485 byte_digest: None,
486 content_digest: None,
487 meta_digest: Some(MetaDigest("m2".into())),
488 size: 100,
489 modified_at: None,
490 };
491 assert!(!a.matches_within_location(&b));
492 }
493
494 #[test]
495 fn content_same_meta_different_means_meta_only_change() {
496 let a = hash_fp_with_meta(ByteDigest::Djb2("h1".into()), Some("c1"), Some("m1"), 1024);
497 let b = hash_fp_with_meta(ByteDigest::Djb2("h2".into()), Some("c1"), Some("m2"), 1024);
498 assert!(!a.matches_within_location(&b));
500 }
501
502 #[test]
503 fn precision_meta_level() {
504 let fp = FileFingerprint {
505 byte_digest: None,
506 content_digest: None,
507 meta_digest: Some(MetaDigest("m1".into())),
508 size: 100,
509 modified_at: None,
510 };
511 assert_eq!(fp.precision(), FingerprintPrecision::MetaLevel);
512 }
513
514 #[test]
515 fn precision_semantic_trumps_meta() {
516 let fp = FileFingerprint {
517 byte_digest: None,
518 content_digest: Some(ContentDigest("c1".into())),
519 meta_digest: Some(MetaDigest("m1".into())),
520 size: 100,
521 modified_at: None,
522 };
523 assert_eq!(fp.precision(), FingerprintPrecision::Semantic);
524 }
525
526 #[test]
527 fn precision_ordering() {
528 assert!(FingerprintPrecision::SizeOnly < FingerprintPrecision::Metadata);
529 assert!(FingerprintPrecision::Metadata < FingerprintPrecision::ByteLevel);
530 assert!(FingerprintPrecision::ByteLevel < FingerprintPrecision::MetaLevel);
531 assert!(FingerprintPrecision::MetaLevel < FingerprintPrecision::Semantic);
532 }
533
534 #[test]
539 fn entity_model_meta_change_does_not_break_identity() {
540 let before = FileFingerprint {
541 byte_digest: None,
542 content_digest: Some(ContentDigest("pixel_hash_abc".into())),
543 meta_digest: Some(MetaDigest("meta_v1".into())),
544 size: 10240,
545 modified_at: None,
546 };
547 let after = FileFingerprint {
548 byte_digest: None,
549 content_digest: Some(ContentDigest("pixel_hash_abc".into())),
550 meta_digest: Some(MetaDigest("meta_v2".into())),
551 size: 10300,
552 modified_at: None,
553 };
554 assert!(before.matches_within_location(&after));
555 }
556
557 #[test]
558 fn entity_model_content_change_is_detected() {
559 let v1 = FileFingerprint {
560 byte_digest: None,
561 content_digest: Some(ContentDigest("pixel_v1".into())),
562 meta_digest: Some(MetaDigest("meta_v1".into())),
563 size: 10240,
564 modified_at: None,
565 };
566 let v2 = FileFingerprint {
567 byte_digest: None,
568 content_digest: Some(ContentDigest("pixel_v2".into())),
569 meta_digest: Some(MetaDigest("meta_v1".into())),
570 size: 10240,
571 modified_at: None,
572 };
573 assert!(!v1.matches_within_location(&v2));
574 }
575
576 #[test]
577 fn entity_model_reexport_with_ts_in_meta() {
578 let original = hash_fp_with_meta(
579 ByteDigest::Djb2("file_h1".into()),
580 Some("pixel_abc"),
581 Some("meta_ts1"),
582 10240,
583 );
584 let reexport = hash_fp_with_meta(
585 ByteDigest::Djb2("file_h2".into()),
586 Some("pixel_abc"),
587 Some("meta_ts2"),
588 10300,
589 );
590 assert!(!original.matches_within_location(&reexport));
591 assert_eq!(
592 original.content_digest.as_ref().map(|cd| cd.as_str()),
593 reexport.content_digest.as_ref().map(|cd| cd.as_str()),
594 );
595 }
596}