use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use iroh_blobs::Hash;
use iroh_docs::Entry;
use crate::{
error::{Result, SyncwebError},
folder::FolderManager,
node::iroh_node::IrohNode,
};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct MaterializeSummary {
pub materialized: usize,
pub conflicts: usize,
pub pending: usize,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct MaterializeOutcome {
pub summary: MaterializeSummary,
pub written: Vec<PathBuf>,
}
#[must_use]
pub fn select_winner(entries: &[Entry]) -> Option<&Entry> {
entries.iter().max_by(|left, right| {
left.timestamp()
.cmp(&right.timestamp())
.then_with(|| left.author().as_bytes().cmp(right.author().as_bytes()))
})
}
#[must_use]
pub fn group_by_key(entries: Vec<Entry>) -> BTreeMap<Vec<u8>, Vec<Entry>> {
let mut grouped: BTreeMap<Vec<u8>, Vec<Entry>> = BTreeMap::new();
for entry in entries {
if entry.key().starts_with(b"sys/") {
continue;
}
grouped.entry(entry.key().to_vec()).or_default().push(entry);
}
grouped
}
#[must_use]
pub fn conflict_path(path: &Path, hash: Hash) -> PathBuf {
let short: String = hash.to_hex().chars().take(8).collect();
PathBuf::from(format!("{}.conflict.{short}", path.to_string_lossy()))
}
async fn export_if_different(node: &IrohNode, hash: Hash, expected_size: u64, destination: &Path) -> Result<bool> {
if !node.blob_store().has(hash).await? {
return Ok(false);
}
if let Ok(metadata) = tokio::fs::metadata(destination).await
&& metadata.is_file()
&& metadata.len() == expected_size
{
return Ok(false);
}
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|error| SyncwebError::operation("failed to create folder materialization directory", error))?;
}
node.blob_store()
.export_to_path(hash, destination)
.await
.map_err(|error| SyncwebError::operation("failed to materialize folder entry", error))?;
Ok(true)
}
pub async fn materialize_folder_content(
node: &IrohNode,
manager: &FolderManager,
namespace: iroh_docs::NamespaceId,
mount_root: &Path,
) -> Result<MaterializeOutcome> {
let folder = manager.get(namespace).await?;
let entries = folder.docs_engine().list_all_entries(folder.doc()).await?;
let grouped = group_by_key(entries);
let mut summary = MaterializeSummary::default();
let mut written = Vec::new();
for (key, variants) in grouped {
let path = PathBuf::from(String::from_utf8_lossy(&key).into_owned());
let Some(winner) = select_winner(&variants) else {
continue;
};
let winner_hash = winner.content_hash();
let winner_size = winner.content_len();
let destination = mount_root.join(&path);
match export_if_different(node, winner_hash, winner_size, &destination).await {
Ok(true) => {
summary.materialized = summary.materialized.saturating_add(1);
written.push(destination);
}
Ok(false) if !node.blob_store().has(winner_hash).await? => {
summary.pending = summary.pending.saturating_add(1);
}
Ok(false) => {}
Err(error) => {
tracing::warn!(%namespace, path = %path.display(), %error, "failed to materialize winner");
}
}
if !folder.is_writable() {
continue;
}
for loser in variants
.iter()
.filter(|variant| variant.content_hash() != winner_hash && variant.content_hash() != Hash::EMPTY)
{
let loser_path = mount_root.join(conflict_path(&path, loser.content_hash()));
match export_if_different(node, loser.content_hash(), loser.content_len(), &loser_path).await {
Ok(true) => {
summary.conflicts = summary.conflicts.saturating_add(1);
written.push(loser_path);
}
Ok(false) => {}
Err(error) => {
tracing::warn!(%namespace, path = %loser_path.display(), %error, "failed to materialize conflict copy");
}
}
}
}
Ok(MaterializeOutcome { summary, written })
}
#[cfg(test)]
mod tests {
use super::*;
use iroh_docs::{AuthorId, NamespaceId, Record};
fn entry(timestamp: u64, author: u8, hash_byte: u8, key: &[u8]) -> Entry {
let mut author_bytes = [0_u8; 32];
author_bytes[0] = author;
let author_id = AuthorId::from(author_bytes);
let mut hash_bytes = [0_u8; 32];
hash_bytes[0] = hash_byte;
let id = iroh_docs::RecordIdentifier::new(NamespaceId::from([0; 32]), author_id, key);
let record = Record::new(Hash::from_bytes(hash_bytes), 16, timestamp);
Entry::new(id, record)
}
fn author_id(byte: u8) -> AuthorId {
let mut bytes = [0_u8; 32];
bytes[0] = byte;
AuthorId::from(bytes)
}
#[test]
fn winner_is_latest_timestamp() {
let worth = entry(5, 2, 2, b"a.txt");
let older = entry(3, 1, 1, b"a.txt");
let variants = [older, worth];
let winner = select_winner(&variants);
assert_eq!(winner.expect("winner should be selected").timestamp(), 5);
assert_eq!(winner.expect("winner should be selected").author(), author_id(2));
}
#[test]
fn winner_tie_breaks_by_author() {
let left = entry(10, 3, 1, b"a.txt");
let right = entry(10, 7, 2, b"a.txt");
let variants = [left, right];
let winner = select_winner(&variants);
assert_eq!(winner.expect("winner should be selected").author(), author_id(7));
}
#[test]
fn missing_variants_yield_none() {
assert!(select_winner(&[]).is_none());
}
#[test]
fn grouping_skips_system_keys() {
let grouped = group_by_key(vec![
entry(1, 1, 1, b"a.txt"),
entry(2, 2, 2, b"a.txt"),
entry(3, 3, 3, b"sys/syncweb/mode"),
]);
assert!(grouped.contains_key(b"a.txt".as_slice()));
assert!(!grouped.contains_key(b"sys/syncweb/mode".as_slice()));
assert_eq!(grouped.get(b"a.txt".as_slice()).map_or(0, Vec::len), 2);
}
#[test]
fn conflict_path_embeds_short_hash() {
let mut bytes = [0_u8; 32];
bytes[0] = 0xab;
bytes[31] = 0xcd;
let hash = Hash::from_bytes(bytes);
let path = conflict_path(Path::new("report.md"), hash);
let text = path.to_string_lossy();
assert!(text.starts_with("report.md.conflict."));
assert_eq!(text.len(), "report.md.conflict.".len() + 8);
}
#[test]
fn empty_hash_is_distinct_from_content() {
let mut bytes = [0_u8; 32];
bytes[0] = 0xab;
let content = Hash::from_bytes(bytes);
assert_ne!(Hash::EMPTY, content);
}
}