Jason Tudisco 360ecbdad0 Initial commit: CAN Service + examples (can-sync v1, canfs, filemanager, paste)
CAN Service: content-addressable storage with HTTP API, SQLite metadata,
file-based blob storage, thumbnail generation, and integrity verification.

can-sync v1: P2P sync sidecar using iroh-docs for encrypted peer-to-peer
replication with library/filter-based selective sync. Fully builds but
being superseded by v2 (simplified full-mirror approach).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:32:04 -06:00

260 lines
8.2 KiB
Rust

use std::collections::{HashMap, HashSet};
use crate::api::AssetMeta;
use crate::util;
use chrono::DateTime;
/// Unique identifier for a node in the virtual tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeId(pub usize);
/// A node is either a directory or a file reference.
#[derive(Debug, Clone)]
pub enum NodeKind {
Directory,
/// Points to an index in the flat asset list.
File { asset_index: usize },
}
/// A node in the virtual directory tree.
#[derive(Debug, Clone)]
pub struct VNode {
pub name: String,
pub kind: NodeKind,
pub children: Vec<NodeId>,
#[allow(dead_code)]
pub parent: Option<NodeId>,
}
impl VNode {
pub fn is_directory(&self) -> bool {
matches!(self.kind, NodeKind::Directory)
}
}
/// The complete virtual directory tree built from a list of assets.
pub struct VirtualTree {
nodes: Vec<VNode>,
/// Normalized path -> NodeId lookup.
path_index: HashMap<String, NodeId>,
}
impl VirtualTree {
/// Build the virtual tree from a flat list of assets.
pub fn build(assets: &[AssetMeta]) -> Self {
let mut tree = TreeBuilder::new();
// Create top-level directories
let root = tree.root();
let can_dir = tree.add_dir("CAN", root);
let app_dir = tree.add_dir("APPLICATION", root);
let dates_dir = tree.add_dir("DATES", root);
let tags_dir = tree.add_dir("TAGS", root);
for (i, asset) in assets.iter().enumerate() {
let ext = util::mime_to_ext(&asset.mime_type);
let hash8 = &asset.hash[..asset.hash.len().min(8)];
// 1) CAN/ — always: {timestamp}_{hash8}.ext
let can_name = format!("{}_{}.{}", asset.timestamp, hash8, ext);
let can_name = util::sanitize_filename(&can_name);
tree.add_file(&can_name, can_dir, i);
// Display name for other folders: human_filename if available, else hash8.ext
let display_name = if let Some(ref hf) = asset.human_filename {
util::sanitize_filename(hf)
} else {
format!("{}.{}", hash8, ext)
};
// 2) APPLICATION/{app}/ — if application is set
if let Some(ref app) = asset.application {
let app_name = util::sanitize_filename(app);
let app_sub = tree.ensure_dir(&app_name, app_dir);
tree.add_file(&display_name, app_sub, i);
}
// 3) DATES/{year}/{month:02}/
if let Some(dt) = DateTime::from_timestamp_millis(asset.timestamp) {
let year_str = dt.format("%Y").to_string();
let month_str = dt.format("%m").to_string();
let year_dir = tree.ensure_dir(&year_str, dates_dir);
let month_dir = tree.ensure_dir(&month_str, year_dir);
tree.add_file(&display_name, month_dir, i);
}
// 4) TAGS/{tag}/
for tag in &asset.tags {
let tag_name = util::sanitize_filename(tag);
if tag_name.is_empty() {
continue;
}
let tag_sub = tree.ensure_dir(&tag_name, tags_dir);
tree.add_file(&display_name, tag_sub, i);
}
}
// Sort children and build path index
tree.finalize()
}
/// Look up a node by its normalized path (e.g., `\can\file.txt`).
pub fn lookup(&self, path: &str) -> Option<NodeId> {
let normalized = path.to_lowercase().replace('/', "\\");
let normalized = if normalized.len() > 1 && normalized.ends_with('\\') {
&normalized[..normalized.len() - 1]
} else {
&normalized
};
self.path_index.get(normalized).copied()
}
/// Get a node by ID.
pub fn get(&self, id: NodeId) -> &VNode {
&self.nodes[id.0]
}
/// Get the root node ID.
#[allow(dead_code)]
pub fn root(&self) -> NodeId {
NodeId(0)
}
}
/// Builder helper for constructing the virtual tree.
struct TreeBuilder {
nodes: Vec<VNode>,
/// Track names used per directory to resolve collisions.
dir_names: HashMap<usize, HashSet<String>>,
/// Cache for ensure_dir: (parent_id, name) -> NodeId
dir_cache: HashMap<(usize, String), NodeId>,
}
impl TreeBuilder {
fn new() -> Self {
let root = VNode {
name: String::new(),
kind: NodeKind::Directory,
children: Vec::new(),
parent: None,
};
let mut dir_names = HashMap::new();
dir_names.insert(0, HashSet::new());
Self {
nodes: vec![root],
dir_names,
dir_cache: HashMap::new(),
}
}
fn root(&self) -> NodeId {
NodeId(0)
}
/// Add a directory as a child of `parent`. Returns its NodeId.
fn add_dir(&mut self, name: &str, parent: NodeId) -> NodeId {
let id = NodeId(self.nodes.len());
self.nodes.push(VNode {
name: name.to_string(),
kind: NodeKind::Directory,
children: Vec::new(),
parent: Some(parent),
});
self.nodes[parent.0].children.push(id);
self.dir_names.insert(id.0, HashSet::new());
self.dir_names
.entry(parent.0)
.or_default()
.insert(name.to_lowercase());
self.dir_cache
.insert((parent.0, name.to_lowercase()), id);
id
}
/// Get or create a subdirectory by name under `parent`.
fn ensure_dir(&mut self, name: &str, parent: NodeId) -> NodeId {
let key = (parent.0, name.to_lowercase());
if let Some(&id) = self.dir_cache.get(&key) {
return id;
}
self.add_dir(name, parent)
}
/// Add a file node as a child of `parent`, deduplicating names.
fn add_file(&mut self, name: &str, parent: NodeId, asset_index: usize) {
let used = self.dir_names.entry(parent.0).or_default();
let lower = name.to_lowercase();
let final_name = if !used.contains(&lower) {
used.insert(lower);
name.to_string()
} else {
// Deduplicate: try _2, _3, etc.
let (stem, ext) = if let Some(dot_pos) = name.rfind('.') {
(&name[..dot_pos], &name[dot_pos..])
} else {
(name, "")
};
let mut n = 2;
loop {
let candidate = format!("{}_{}{}", stem, n, ext);
let cand_lower = candidate.to_lowercase();
if !used.contains(&cand_lower) {
used.insert(cand_lower);
break candidate;
}
n += 1;
}
};
let id = NodeId(self.nodes.len());
self.nodes.push(VNode {
name: final_name,
kind: NodeKind::File { asset_index },
children: Vec::new(),
parent: Some(parent),
});
self.nodes[parent.0].children.push(id);
}
/// Sort children and build the path index.
fn finalize(mut self) -> VirtualTree {
// Sort children by name (case-insensitive)
let names: Vec<String> = self.nodes.iter().map(|n| n.name.to_lowercase()).collect();
for node in &mut self.nodes {
node.children.sort_by(|a, b| names[a.0].cmp(&names[b.0]));
}
// Build path index by walking the tree
let mut path_index = HashMap::new();
path_index.insert("\\".to_string(), NodeId(0));
fn walk(
nodes: &[VNode],
id: NodeId,
prefix: &str,
index: &mut HashMap<String, NodeId>,
) {
for &child_id in &nodes[id.0].children {
let child = &nodes[child_id.0];
let path = if prefix == "\\" {
format!("\\{}", child.name.to_lowercase())
} else {
format!("{}\\{}", prefix, child.name.to_lowercase())
};
index.insert(path.clone(), child_id);
if child.is_directory() {
walk(nodes, child_id, &path, index);
}
}
}
walk(&self.nodes, NodeId(0), "\\", &mut path_index);
VirtualTree {
nodes: self.nodes,
path_index,
}
}
}