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, #[allow(dead_code)] pub parent: Option, } 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, /// Normalized path -> NodeId lookup. path_index: HashMap, } 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 { 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, /// Track names used per directory to resolve collisions. dir_names: HashMap>, /// 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 = 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, ) { 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, } } }