1use std::{
18 collections::BTreeMap,
19 path::{Path, PathBuf},
20};
21
22use iroh_blobs::Hash;
23use iroh_docs::Entry;
24
25use crate::{
26 error::{Result, SyncwebError},
27 folder::FolderManager,
28 node::iroh_node::IrohNode,
29};
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33#[non_exhaustive]
34pub struct MaterializeSummary {
35 pub materialized: usize,
37 pub conflicts: usize,
39 pub pending: usize,
41}
42
43#[derive(Clone, Debug, Default, Eq, PartialEq)]
46#[non_exhaustive]
47pub struct MaterializeOutcome {
48 pub summary: MaterializeSummary,
50 pub written: Vec<PathBuf>,
52}
53
54#[must_use]
59pub fn select_winner(entries: &[Entry]) -> Option<&Entry> {
60 entries.iter().max_by(|left, right| {
61 left.timestamp()
62 .cmp(&right.timestamp())
63 .then_with(|| left.author().as_bytes().cmp(right.author().as_bytes()))
64 })
65}
66
67#[must_use]
69pub fn group_by_key(entries: Vec<Entry>) -> BTreeMap<Vec<u8>, Vec<Entry>> {
70 let mut grouped: BTreeMap<Vec<u8>, Vec<Entry>> = BTreeMap::new();
71 for entry in entries {
72 if entry.key().starts_with(b"sys/") {
73 continue;
74 }
75 grouped.entry(entry.key().to_vec()).or_default().push(entry);
76 }
77 grouped
78}
79
80#[must_use]
82pub fn conflict_path(path: &Path, hash: Hash) -> PathBuf {
83 let short: String = hash.to_hex().chars().take(8).collect();
84 PathBuf::from(format!("{}.conflict.{short}", path.to_string_lossy()))
85}
86
87async fn export_if_different(node: &IrohNode, hash: Hash, expected_size: u64, destination: &Path) -> Result<bool> {
90 if !node.blob_store().has(hash).await? {
91 return Ok(false);
92 }
93 if let Ok(metadata) = tokio::fs::metadata(destination).await
94 && metadata.is_file()
95 && metadata.len() == expected_size
96 {
97 return Ok(false);
98 }
99 if let Some(parent) = destination.parent() {
100 tokio::fs::create_dir_all(parent)
101 .await
102 .map_err(|error| SyncwebError::operation("failed to create folder materialization directory", error))?;
103 }
104 node.blob_store()
105 .export_to_path(hash, destination)
106 .await
107 .map_err(|error| SyncwebError::operation("failed to materialize folder entry", error))?;
108 Ok(true)
109}
110
111pub async fn materialize_folder_content(
122 node: &IrohNode,
123 manager: &FolderManager,
124 namespace: iroh_docs::NamespaceId,
125 mount_root: &Path,
126) -> Result<MaterializeOutcome> {
127 let folder = manager.get(namespace).await?;
128 let entries = folder.docs_engine().list_all_entries(folder.doc()).await?;
129 let grouped = group_by_key(entries);
130 let mut summary = MaterializeSummary::default();
131 let mut written = Vec::new();
132 for (key, variants) in grouped {
133 let path = PathBuf::from(String::from_utf8_lossy(&key).into_owned());
134 let Some(winner) = select_winner(&variants) else {
135 continue;
136 };
137 let winner_hash = winner.content_hash();
138 let winner_size = winner.content_len();
139 let destination = mount_root.join(&path);
140 match export_if_different(node, winner_hash, winner_size, &destination).await {
141 Ok(true) => {
142 summary.materialized = summary.materialized.saturating_add(1);
143 written.push(destination);
144 }
145 Ok(false) if !node.blob_store().has(winner_hash).await? => {
146 summary.pending = summary.pending.saturating_add(1);
147 }
148 Ok(false) => {}
149 Err(error) => {
150 tracing::warn!(%namespace, path = %path.display(), %error, "failed to materialize winner");
151 }
152 }
153 if !folder.is_writable() {
154 continue;
155 }
156 for loser in variants
157 .iter()
158 .filter(|variant| variant.content_hash() != winner_hash && variant.content_hash() != Hash::EMPTY)
159 {
160 let loser_path = mount_root.join(conflict_path(&path, loser.content_hash()));
161 match export_if_different(node, loser.content_hash(), loser.content_len(), &loser_path).await {
162 Ok(true) => {
163 summary.conflicts = summary.conflicts.saturating_add(1);
164 written.push(loser_path);
165 }
166 Ok(false) => {}
167 Err(error) => {
168 tracing::warn!(%namespace, path = %loser_path.display(), %error, "failed to materialize conflict copy");
169 }
170 }
171 }
172 }
173 Ok(MaterializeOutcome { summary, written })
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use iroh_docs::{AuthorId, NamespaceId, Record};
180
181 fn entry(timestamp: u64, author: u8, hash_byte: u8, key: &[u8]) -> Entry {
182 let mut author_bytes = [0_u8; 32];
183 author_bytes[0] = author;
184 let author_id = AuthorId::from(author_bytes);
185 let mut hash_bytes = [0_u8; 32];
186 hash_bytes[0] = hash_byte;
187 let id = iroh_docs::RecordIdentifier::new(NamespaceId::from([0; 32]), author_id, key);
188 let record = Record::new(Hash::from_bytes(hash_bytes), 16, timestamp);
189 Entry::new(id, record)
190 }
191
192 fn author_id(byte: u8) -> AuthorId {
193 let mut bytes = [0_u8; 32];
194 bytes[0] = byte;
195 AuthorId::from(bytes)
196 }
197
198 #[test]
199 fn winner_is_latest_timestamp() {
200 let worth = entry(5, 2, 2, b"a.txt");
201 let older = entry(3, 1, 1, b"a.txt");
202 let variants = [older, worth];
203 let winner = select_winner(&variants);
204 assert_eq!(winner.expect("winner should be selected").timestamp(), 5);
205 assert_eq!(winner.expect("winner should be selected").author(), author_id(2));
206 }
207
208 #[test]
209 fn winner_tie_breaks_by_author() {
210 let left = entry(10, 3, 1, b"a.txt");
211 let right = entry(10, 7, 2, b"a.txt");
212 let variants = [left, right];
213 let winner = select_winner(&variants);
214 assert_eq!(winner.expect("winner should be selected").author(), author_id(7));
215 }
216
217 #[test]
218 fn missing_variants_yield_none() {
219 assert!(select_winner(&[]).is_none());
220 }
221
222 #[test]
223 fn grouping_skips_system_keys() {
224 let grouped = group_by_key(vec![
225 entry(1, 1, 1, b"a.txt"),
226 entry(2, 2, 2, b"a.txt"),
227 entry(3, 3, 3, b"sys/syncweb/mode"),
228 ]);
229 assert!(grouped.contains_key(b"a.txt".as_slice()));
230 assert!(!grouped.contains_key(b"sys/syncweb/mode".as_slice()));
231 assert_eq!(grouped.get(b"a.txt".as_slice()).map_or(0, Vec::len), 2);
232 }
233
234 #[test]
235 fn conflict_path_embeds_short_hash() {
236 let mut bytes = [0_u8; 32];
237 bytes[0] = 0xab;
238 bytes[31] = 0xcd;
239 let hash = Hash::from_bytes(bytes);
240 let path = conflict_path(Path::new("report.md"), hash);
241 let text = path.to_string_lossy();
242 assert!(text.starts_with("report.md.conflict."));
243 assert_eq!(text.len(), "report.md.conflict.".len() + 8);
244 }
245
246 #[test]
247 fn empty_hash_is_distinct_from_content() {
248 let mut bytes = [0_u8; 32];
249 bytes[0] = 0xab;
250 let content = Hash::from_bytes(bytes);
251 assert_ne!(Hash::EMPTY, content);
252 }
253}