//! Project aggregate root and its path value object. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::ProjectId; use crate::remote::RemoteRef; /// A normalized, absolute project path, aware of its target platform. /// /// Invariant: the path is **absolute** for its platform. We accept POSIX /// absolute paths (`/...`), Windows absolute paths (`C:\...` or `\\server\...`) /// and WSL mount paths (`/mnt/c/...`, themselves POSIX-absolute). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ProjectPath(String); impl ProjectPath { /// Builds a validated project path. /// /// # Errors /// Returns [`DomainError::PathNotAbsolute`] if `raw` is not absolute, or /// [`DomainError::EmptyField`] if it is empty. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); crate::validation::non_empty(&raw, "project.root")?; if Self::is_absolute(&raw) { Ok(Self(raw)) } else { Err(DomainError::PathNotAbsolute { path: raw }) } } /// Returns whether `raw` is an absolute path on any supported platform. fn is_absolute(raw: &str) -> bool { let bytes = raw.as_bytes(); // POSIX / WSL absolute. if bytes.first() == Some(&b'/') { return true; } // Windows UNC. if raw.starts_with("\\\\") { return true; } // Windows drive-letter absolute: `C:\` or `C:/`. if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') { return true; } false } /// Returns the raw path string. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for ProjectPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } /// Project aggregate root. /// /// Invariants enforced here: /// - `name` non-empty, /// - `root` is an absolute [`ProjectPath`]. /// /// The cross-aggregate uniqueness invariant — "no two projects share the same /// `(remote, root)`" — is a *repository* concern (it requires knowledge of all /// projects) and is enforced by the `ProjectStore`/use case layer, not here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Project { /// Stable identifier. pub id: ProjectId, /// Display name. pub name: String, /// Absolute project root. pub root: ProjectPath, /// Where the project physically lives. pub remote: RemoteRef, /// Creation timestamp, as epoch milliseconds (supplied via a `Clock` port). pub created_at: i64, } impl Project { /// Builds a validated project. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `name` is empty. pub fn new( id: ProjectId, name: impl Into, root: ProjectPath, remote: RemoteRef, created_at: i64, ) -> Result { let name = name.into(); crate::validation::non_empty(&name, "project.name")?; Ok(Self { id, name, root, remote, created_at, }) } }