//! [`ProjectMeta`] — the on-disk shape of `.ideai/project.json` (ARCHITECTURE §9.1). //! //! This is the *project-local* metadata that travels with the code (it lives //! inside the project root, is versionable, and is independent of the machine). //! The known-projects **registry** is a separate, machine-local concern owned by //! the [`domain::ports::ProjectStore`] adapter (ARCHITECTURE §9.2). use serde::{Deserialize, Serialize}; use domain::{Project, ProjectId, ProjectPath, RemoteRef}; use crate::error::AppError; /// The `.ideai/` directory name inside a project root. pub(crate) const IDEAI_DIR: &str = ".ideai"; /// The project-meta file name inside `.ideai/`. pub(crate) const PROJECT_FILE: &str = "project.json"; /// The agent manifest file name inside `.ideai/`. pub(crate) const AGENTS_FILE: &str = "agents.json"; /// Current schema version of `project.json`. pub(crate) const PROJECT_META_VERSION: u32 = 1; /// Serialised contents of `.ideai/project.json`. /// /// Carries the project's identity and the metadata needed to reopen it: its /// stable id, display name, the default agent profile, the remote reference and /// the creation timestamp. The `root` itself is *not* stored here — the file /// already lives at `/.ideai/project.json`, so the root is implied by the /// file's location (and authoritatively held by the registry). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectMeta { /// Schema version of this file. pub version: u32, /// Stable project id (matches the registry entry). pub id: ProjectId, /// Display name. pub name: String, /// Default agent profile id, if one has been chosen (`null` until first-run /// / profile selection in later lots). #[serde(default, skip_serializing_if = "Option::is_none")] pub default_profile_id: Option, /// Where the project physically lives. pub remote: RemoteRef, /// Creation timestamp, epoch milliseconds. pub created_at: i64, } impl ProjectMeta { /// Builds the meta image from a [`Project`]. #[must_use] pub fn from_project(project: &Project, default_profile_id: Option) -> Self { Self { version: PROJECT_META_VERSION, id: project.id, name: project.name.clone(), default_profile_id, remote: project.remote.clone(), created_at: project.created_at, } } /// Reconstructs a validated [`Project`] from this meta and its (registry-known) /// root. /// /// # Errors /// Returns [`AppError::Invalid`] if the stored fields violate a domain /// invariant (e.g. empty name). pub fn into_project(self, root: ProjectPath) -> Result { Project::new(self.id, self.name, root, self.remote, self.created_at) .map_err(|e| AppError::Invalid(e.to_string())) } } /// Serialises a value to pretty JSON bytes, mapping failures to [`AppError`]. pub(crate) fn to_json_bytes(value: &T) -> Result, AppError> { serde_json::to_vec_pretty(value) .map(|mut v| { v.push(b'\n'); v }) .map_err(|e| AppError::Store(format!("serialize failed: {e}"))) } /// Deserialises JSON bytes, mapping failures to [`AppError`]. pub(crate) fn from_json_bytes Deserialize<'de>>(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|e| AppError::Store(format!("deserialize failed: {e}"))) } /// Joins a project root with a relative path segment using a POSIX-style /// separator. Paths inside `.ideai/` are always written with `/`, which is valid /// on every platform we target (Windows `tokio::fs` accepts `/`). pub(crate) fn join_root(root: &ProjectPath, rel: &str) -> String { let base = root.as_str().trim_end_matches(['/', '\\']); format!("{base}/{rel}") }