1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use iroh_blobs::Hash;
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::folder::SyncwebFolder;
9use crate::node::iroh_node::IrohNode;
10
11pub async fn fetch_selected_content(node: &IrohNode, folder: &SyncwebFolder, strategy: &FetchStrategy) -> Result<u64> {
23 let mut candidates = Vec::new();
24 for entry in folder.content_entries().await? {
25 let hash = entry.content_hash();
26 let local = folder.has_local(hash).await?;
27 let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
28 candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
29 }
30 let mut selected = strategy.select(&candidates);
31 selected.sort_by(|left, right| {
34 left.size
35 .cmp(&right.size)
36 .then(left.path.cmp(&right.path))
37 .then(left.hash.cmp(&right.hash))
38 });
39 let peers = node
40 .topic_tracker()
41 .find_peers(folder.namespace_id())
42 .await
43 .unwrap_or_default();
44 let mut bytes = 0_u64;
45 for candidate in selected {
46 if candidate.local {
47 continue;
48 }
49 for peer in &peers {
50 if node
51 .blob_store()
52 .force_fetch_from_peer(node.endpoint(), peer, candidate.hash)
53 .await
54 .is_ok()
55 {
56 bytes = bytes.saturating_add(candidate.size);
57 break;
58 }
59 }
60 }
61 Ok(bytes)
62}
63
64pub async fn materialize_selected_content(
75 node: &IrohNode,
76 folder: &SyncwebFolder,
77 strategy: &FetchStrategy,
78 mount_root: &Path,
79) -> Result<usize> {
80 let mut candidates = Vec::new();
81 for entry in folder.content_entries().await? {
82 let hash = entry.content_hash();
83 let local = folder.has_local(hash).await?;
84 let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
85 candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
86 }
87 let selected = strategy.select(&candidates);
88 let root = absolute_base(mount_root)?;
89 let mut count = 0_usize;
90 for candidate in selected {
91 let dest = root.join(&candidate.path);
92 if let Some(parent) = dest.parent() {
93 tokio::fs::create_dir_all(parent)
94 .await
95 .map_err(|error| crate::error::SyncwebError::operation("failed to create download directory", error))?;
96 }
97 node.blob_store().export_to_path(candidate.hash, &dest).await?;
98 count = count.saturating_add(1);
99 }
100 Ok(count)
101}
102
103fn absolute_base(base: &Path) -> Result<PathBuf> {
109 if base.is_absolute() {
110 return Ok(base.to_path_buf());
111 }
112 let cwd = std::env::current_dir()
113 .map_err(|error| crate::error::SyncwebError::operation("failed to resolve current directory", error))?;
114 Ok(cwd.join(base))
115}
116
117pub async fn strategy_selects_only_local(folder: &SyncwebFolder, strategy: &FetchStrategy) -> Result<bool> {
129 let mut candidates = Vec::new();
130 for entry in folder.content_entries().await? {
131 let hash = entry.content_hash();
132 let local = folder.has_local(hash).await?;
133 let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
134 candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
135 }
136 let selected = strategy.select(&candidates);
137 Ok(!selected.is_empty() && selected.iter().all(|candidate| candidate.local))
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
142#[non_exhaustive]
143pub struct FetchCandidate {
144 pub path: PathBuf,
145 pub hash: Hash,
146 pub size: u64,
147 pub peer_count: usize,
148 pub local: bool,
149}
150
151impl FetchCandidate {
152 #[must_use]
153 pub fn new(path: impl Into<PathBuf>, hash: Hash, size: u64, peer_count: usize, local: bool) -> Self {
154 Self {
155 path: path.into(),
156 hash,
157 size,
158 peer_count,
159 local,
160 }
161 }
162}
163
164#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
166#[non_exhaustive]
167pub struct FetchFilter {
168 pub paths: Option<Vec<PathBuf>>,
169 pub min_size: Option<u64>,
170 pub max_size: Option<u64>,
171 pub min_peers: Option<usize>,
172 pub max_peers: Option<usize>,
173 pub min_count: Option<usize>,
174 pub max_count: Option<usize>,
175}
176
177impl FetchFilter {
178 #[must_use]
179 pub const fn new() -> Self {
180 Self {
181 paths: None,
182 min_size: None,
183 max_size: None,
184 min_peers: None,
185 max_peers: None,
186 min_count: None,
187 max_count: None,
188 }
189 }
190
191 #[must_use]
192 pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
193 self.paths = Some(paths);
194 self
195 }
196
197 #[must_use]
198 pub const fn with_min_size(mut self, size: u64) -> Self {
199 self.min_size = Some(size);
200 self
201 }
202
203 #[must_use]
204 pub const fn with_max_size(mut self, size: u64) -> Self {
205 self.max_size = Some(size);
206 self
207 }
208
209 #[must_use]
210 pub const fn with_min_peers(mut self, peers: usize) -> Self {
211 self.min_peers = Some(peers);
212 self
213 }
214
215 #[must_use]
216 pub const fn with_max_peers(mut self, peers: usize) -> Self {
217 self.max_peers = Some(peers);
218 self
219 }
220
221 #[must_use]
222 pub const fn with_min_count(mut self, count: usize) -> Self {
223 self.min_count = Some(count);
224 self
225 }
226
227 #[must_use]
228 pub const fn with_max_count(mut self, count: usize) -> Self {
229 self.max_count = Some(count);
230 self
231 }
232
233 #[must_use]
238 pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
239 let mut selected = candidates
240 .iter()
241 .filter(|candidate| self.matches(candidate))
242 .cloned()
243 .collect::<Vec<_>>();
244 selected.sort_by(|left, right| {
245 left.peer_count
246 .cmp(&right.peer_count)
247 .then(left.path.cmp(&right.path))
248 .then(left.hash.cmp(&right.hash))
249 });
250 if let Some(max_count) = self.max_count {
251 selected.truncate(max_count);
252 }
253 selected
254 }
255
256 #[must_use]
257 pub fn matches(&self, candidate: &FetchCandidate) -> bool {
258 self.paths.as_ref().is_none_or(|paths| {
259 paths
260 .iter()
261 .any(|path| candidate.path == *path || candidate.path.starts_with(path))
262 }) && self.min_size.is_none_or(|size| candidate.size >= size)
263 && self.max_size.is_none_or(|size| candidate.size <= size)
264 && self.min_peers.is_none_or(|peers| candidate.peer_count >= peers)
265 && self.max_peers.is_none_or(|peers| candidate.peer_count <= peers)
266 }
267}
268
269#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
271#[non_exhaustive]
272pub enum FetchStrategy {
273 #[default]
274 All,
275 Filter(FetchFilter),
276}
277
278impl FetchStrategy {
279 #[must_use]
280 pub const fn filter(filter: FetchFilter) -> Self {
281 Self::Filter(filter)
282 }
283
284 #[must_use]
285 pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
286 match self {
287 Self::All => candidates.to_vec(),
288 Self::Filter(filter) => filter.select(candidates),
289 }
290 }
291}
292
293#[derive(Clone, Debug, Eq, PartialEq)]
295#[non_exhaustive]
296pub struct BlobHealth {
297 pub path: PathBuf,
298 pub hash: Hash,
299 pub size: u64,
300 pub peer_count: usize,
301 pub local: bool,
302}
303
304#[derive(Clone, Debug, Eq, PartialEq)]
306#[non_exhaustive]
307pub struct HealthReport {
308 pub total: usize,
309 pub well_seeded: usize,
310 pub under_seeded: usize,
311 pub unseeded: usize,
312 pub least_seeded: Vec<BlobHealth>,
313}
314
315impl HealthReport {
316 #[must_use]
318 pub fn from_candidates(candidates: &[FetchCandidate], well_seeded_threshold: usize) -> Self {
319 let mut least_seeded = candidates
320 .iter()
321 .map(|candidate| BlobHealth {
322 path: candidate.path.clone(),
323 hash: candidate.hash,
324 size: candidate.size,
325 peer_count: candidate.peer_count,
326 local: candidate.local,
327 })
328 .collect::<Vec<_>>();
329 least_seeded.sort_by(|left, right| left.peer_count.cmp(&right.peer_count).then(left.path.cmp(&right.path)));
330 let total = candidates.len();
331 let well_seeded = candidates
332 .iter()
333 .filter(|candidate| candidate.peer_count >= well_seeded_threshold)
334 .count();
335 let unseeded = candidates.iter().filter(|candidate| candidate.peer_count == 0).count();
336 Self {
337 total,
338 well_seeded,
339 under_seeded: total.saturating_sub(well_seeded).saturating_sub(unseeded),
340 unseeded,
341 least_seeded,
342 }
343 }
344
345 #[must_use]
351 pub fn from_candidates_with_peers_per_hash(
352 candidates: &[FetchCandidate],
353 peers_per_hash: &HashMap<Hash, usize>,
354 well_seeded_threshold: usize,
355 ) -> Self {
356 let enriched: Vec<FetchCandidate> = candidates
357 .iter()
358 .map(|c| {
359 let peers = peers_per_hash.get(&c.hash).copied().unwrap_or(c.peer_count);
360 FetchCandidate::new(&c.path, c.hash, c.size, peers, c.local)
361 })
362 .collect();
363 Self::from_candidates(&enriched, well_seeded_threshold)
364 }
365}
366
367#[cfg(test)]
368mod absolute_base_tests {
369 use super::*;
370
371 #[test]
372 fn absolute_paths_are_returned_unchanged() {
373 assert_eq!(
374 absolute_base(Path::new("/srv/folder")).unwrap(),
375 PathBuf::from("/srv/folder")
376 );
377 }
378
379 #[test]
380 fn relative_paths_are_anchored_to_the_current_directory() {
381 let base = Path::new("relative/folder");
382 let resolved = absolute_base(base).unwrap();
383 assert!(
384 resolved.is_absolute(),
385 "blob export rejects relative roots: {resolved:?}"
386 );
387 assert!(resolved.ends_with(base), "{resolved:?} should end with {base:?}");
388 }
389
390 #[test]
391 fn dot_resolves_to_the_current_directory() {
392 let resolved = absolute_base(Path::new(".")).unwrap();
393 assert!(resolved.is_absolute());
394 }
395}