1use std::collections::HashMap;
4use std::fs::File;
5use std::path::Path;
6
7use crate::manifest::{ArtifactKind, Media};
8use crate::{Error, Result};
9
10pub fn hash_file(path: &Path) -> Result<String> {
12 let file = File::open(path).map_err(|e| Error::io("open", path, e))?;
13 let mut hasher = blake3::Hasher::new();
14 hasher
15 .update_reader(file)
16 .map_err(|e| Error::io("read", path, e))?;
17 Ok(hasher.finalize().to_hex().to_string())
18}
19
20pub fn hash_bytes(bytes: &[u8]) -> String {
22 blake3::hash(bytes).to_hex().to_string()
23}
24
25#[derive(Debug, Clone)]
30pub struct CanonicalArtifact {
31 pub source_path: String,
33 pub text_path: String,
35 pub text_hash: String,
37 pub converter_id: String,
39 pub converter_version: String,
41 pub artifact_kind: Option<ArtifactKind>,
43 pub media: Option<Media>,
47 pub warnings: Vec<String>,
50}
51
52#[derive(Debug, Default)]
57pub struct DedupIndex {
58 by_hash: HashMap<String, CanonicalArtifact>,
59}
60
61impl DedupIndex {
62 pub fn insert(&mut self, source_hash: &str, canonical: CanonicalArtifact) {
64 self.by_hash
65 .entry(source_hash.to_string())
66 .or_insert(canonical);
67 }
68
69 pub fn replace(&mut self, source_hash: &str, canonical: CanonicalArtifact) {
74 self.by_hash.insert(source_hash.to_string(), canonical);
75 }
76
77 pub fn get(&self, source_hash: &str) -> Option<&CanonicalArtifact> {
79 self.by_hash.get(source_hash)
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::io::Write;
87
88 #[test]
89 fn hash_bytes_matches_known_vector() {
90 assert_eq!(
91 hash_bytes(b""),
92 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
93 );
94 }
95
96 #[test]
97 fn hash_file_matches_hash_bytes() {
98 let mut file = tempfile::NamedTempFile::new().unwrap();
99 file.write_all(b"mirror me").unwrap();
100 file.flush().unwrap();
101 assert_eq!(hash_file(file.path()).unwrap(), hash_bytes(b"mirror me"));
102 }
103
104 #[test]
105 fn dedup_index_keeps_first_insert() {
106 let mut index = DedupIndex::default();
107 let first = CanonicalArtifact {
108 source_path: "a.txt".to_string(),
109 text_path: "a.txt.txt".to_string(),
110 text_hash: "aa".to_string(),
111 converter_id: "text-passthrough".to_string(),
112 converter_version: "1.0.0".to_string(),
113 artifact_kind: Some(ArtifactKind::Text),
114 media: None,
115 warnings: Vec::new(),
116 };
117 let mut second = first.clone();
118 second.source_path = "b.txt".to_string();
119 index.insert("h1", first);
120 index.insert("h1", second.clone());
121 assert_eq!(index.get("h1").unwrap().source_path, "a.txt");
122 assert!(index.get("h2").is_none());
123
124 index.replace("h1", second);
125 assert_eq!(index.get("h1").unwrap().source_path, "b.txt");
126 }
127}