use uuid::Uuid; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; // Re-export entity errors from the centralized module pub use super::entity_errors::{FolderError, FolderResult}; /// Represents a folder entity in the domain #[derive(Debug, Clone, PartialEq, Eq)] pub struct Folder { /// Unique identifier for the folder id: String, /// Name of the folder name: String, /// Path to the folder in the domain model storage_path: StoragePath, /// String representation of the path (for API compatibility) path_string: String, /// Parent folder ID (None if it's a root folder) parent_id: Option, /// Owner user ID — scopes folder visibility per user. /// `None` only for legacy/stub folders; real folders always have an owner. owner_id: Option, /// Creation timestamp created_at: u64, /// Last modification timestamp modified_at: u64, } // We no longer need this module, now we use a String directly impl Default for Folder { fn default() -> Self { Self { id: "stub-id".to_string(), name: "stub-folder".to_string(), storage_path: StoragePath::from_string("/"), path_string: "/".to_string(), parent_id: None, owner_id: None, created_at: 0, modified_at: 0, } } } impl Folder { /// Creates a new folder with validation pub fn new( id: String, name: String, storage_path: StoragePath, parent_id: Option, ) -> FolderResult { Self::new_with_owner(id, name, storage_path, parent_id, None) } /// Creates a new folder with validation and an explicit owner. pub fn new_with_owner( id: String, name: String, storage_path: StoragePath, parent_id: Option, owner_id: Option, ) -> FolderResult { // Validate folder name if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); // Store the path string for serialization compatibility let path_string = storage_path.to_string(); Ok(Self { id, name, storage_path, path_string, parent_id, owner_id, created_at: now, modified_at: now, }) } /// Creates a folder with specific timestamps (for reconstruction) pub fn with_timestamps( id: String, name: String, storage_path: StoragePath, parent_id: Option, created_at: u64, modified_at: u64, ) -> FolderResult { Self::with_timestamps_and_owner( id, name, storage_path, parent_id, None, created_at, modified_at, ) } /// Creates a folder with specific timestamps and owner (for DB reconstruction) pub fn with_timestamps_and_owner( id: String, name: String, storage_path: StoragePath, parent_id: Option, owner_id: Option, created_at: u64, modified_at: u64, ) -> FolderResult { // Validate folder name if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } // Store the path string for serialization compatibility let path_string = storage_path.to_string(); Ok(Self { id, name, storage_path, path_string, parent_id, owner_id, created_at, modified_at, }) } // Getters pub fn id(&self) -> &str { &self.id } pub fn name(&self) -> &str { &self.name } pub fn storage_path(&self) -> &StoragePath { &self.storage_path } pub fn path_string(&self) -> &str { &self.path_string } pub fn parent_id(&self) -> Option<&str> { self.parent_id.as_deref() } pub fn created_at(&self) -> u64 { self.created_at } pub fn modified_at(&self) -> u64 { self.modified_at } pub fn owner_id(&self) -> Option { self.owner_id } /// Opaque ETag string (raw, NOT HTTP-quoted). Handlers wrap in /// `"…"` themselves at the HTTP boundary. /// /// **Current formula**: the folder's UUID — stable for the life of /// the row, does NOT change when descendants are added/modified/ /// deleted. This matches today's behaviour in every existing /// folder ETag emission site and is the de-facto v1 contract. /// /// **Known limitation**: NextCloud's sync engine relies on a /// collection's ETag changing whenever any descendant changes — /// that's the signal it uses to decide "recurse into this folder /// to find what's new". A constant ETag breaks NC's incremental /// sync (forces periodic deep recrawl). /// /// A follow-up PR will introduce `storage.folders.tree_modified_at` /// (bumped by trigger on any descendant write) and switch this /// method to `format!("{}-{}", id_short, tree_modified_at)`. That /// PR will be ETag-breaking — all clients re-walk once — so it's /// kept separate from this refactor. pub fn etag(&self) -> &str { &self.id } /// Creates a new Folder instance from a DTO /// This function is primarily for conversions in batch handlers pub fn from_dto( id: String, name: String, path: String, parent_id: Option, created_at: u64, modified_at: u64, ) -> Self { // Create storage_path from the string let storage_path = StoragePath::from_string(&path); // Create directly without validation to avoid errors in DTO conversions Self { id, name, storage_path, path_string: path, parent_id, owner_id: None, created_at, modified_at, } } // Methods to create new versions of the folder (immutable) /// Creates a new version of the folder with updated name pub fn with_name(&self, new_name: String) -> FolderResult { if let Err(reason) = validate_storage_name(&new_name) { return Err(FolderError::InvalidFolderName(format!( "{new_name}: {reason}" ))); } // Update path based on the name let parent_path = self.storage_path.parent(); let new_storage_path = match parent_path { Some(parent) => parent.join(&new_name), None => StoragePath::from_string(&new_name), }; // Update string representation let new_path_string = new_storage_path.to_string(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); Ok(Self { id: self.id.clone(), name: new_name, storage_path: new_storage_path, path_string: new_path_string, parent_id: self.parent_id.clone(), owner_id: self.owner_id, created_at: self.created_at, modified_at: now, }) } /// Creates a new version of the folder with updated parent pub fn with_parent( &self, parent_id: Option, parent_path: Option, ) -> FolderResult { // We need a folder path to update the path let new_storage_path = match parent_path { Some(path) => path.join(&self.name), None => StoragePath::from_string(&self.name), // Root }; // Update string representation let new_path_string = new_storage_path.to_string(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); Ok(Self { id: self.id.clone(), name: self.name.clone(), storage_path: new_storage_path, path_string: new_path_string, parent_id, owner_id: self.owner_id, created_at: self.created_at, modified_at: now, }) } /// Returns an absolute path for this folder pub fn get_absolute_path>(&self, root_path: P) -> std::path::PathBuf { let mut result = std::path::PathBuf::from(root_path.as_ref()); // Skip leading '/' from path_string to avoid creating absolute path incorrectly let relative_path = if self.path_string.starts_with('/') { &self.path_string[1..] } else { &self.path_string }; if !relative_path.is_empty() { result.push(relative_path); } result } } #[cfg(test)] mod tests { use super::*; #[test] fn test_folder_creation_with_valid_name() { let storage_path = StoragePath::from_string("/test/folder"); let folder = Folder::new( "123".to_string(), "my_folder".to_string(), storage_path, None, ); assert!(folder.is_ok()); } #[test] fn test_folder_creation_with_invalid_name() { let storage_path = StoragePath::from_string("/test/invalid/folder"); let folder = Folder::new( "123".to_string(), "folder/with/slash".to_string(), // Invalid name storage_path, None, ); assert!(folder.is_err()); match folder { Err(FolderError::InvalidFolderName(_)) => (), _ => panic!("Expected InvalidFolderName error"), } } #[test] fn test_folder_with_name() { let storage_path = StoragePath::from_string("/test/folder"); let folder = Folder::new( "123".to_string(), "old_name".to_string(), storage_path, None, ) .unwrap(); let renamed = folder.with_name("new_name".to_string()); assert!(renamed.is_ok()); let renamed = renamed.unwrap(); assert_eq!(renamed.name(), "new_name"); assert_eq!(renamed.id(), "123"); // The ID doesn't change } }