//! Persistence of a project's **named terminal layouts** (#4). //! //! A project no longer has a single layout: `.ideai/layouts.json` holds a //! collection of named [`LayoutTree`]s plus which one is active: //! //! ```json //! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] } //! ``` //! //! Migration: a project that still has the legacy single `.ideai/layout.json` //! (pre-#4) is upgraded transparently — its tree becomes the first "Default" //! layout. A missing/corrupt store self-heals to one default layout (same //! self-healing contract as the original L4 single-file resolver). use serde::{Deserialize, Serialize}; use domain::ports::{FileSystem, RemotePath}; use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project}; use crate::error::AppError; use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR}; /// File name of the named-layouts store inside a project's `.ideai/`. pub const LAYOUTS_FILE: &str = "layouts.json"; /// Legacy single-layout file (pre-#4), migrated on first read if present. const LEGACY_LAYOUT_FILE: &str = "layout.json"; /// Current schema version of `layouts.json`. const LAYOUTS_VERSION: u32 = 1; /// Name given to the layout created by default / migrated from the legacy file. const DEFAULT_LAYOUT_NAME: &str = "Default"; /// Discriminates the kind of content a named layout holds. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum LayoutKind { /// A terminal-grid layout (the original kind). #[default] Terminal, /// A Git-graph visualisation layout. GitGraph, } /// One named layout: a stable id, a display name and its terminal grid tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NamedLayout { /// Stable identifier. pub id: LayoutId, /// Display name (shown in the layouts tab bar). pub name: String, /// The kind of this layout (terminal grid or git-graph). #[serde(default)] pub kind: LayoutKind, /// The terminal grid for this layout (present but ignored for GitGraph). pub tree: LayoutTree, } /// On-disk shape of `.ideai/layouts.json`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LayoutsDoc { /// Schema version. pub version: u32, /// The currently active layout (always one of `layouts`). pub active_id: LayoutId, /// All named layouts (at least one). pub layouts: Vec, } impl LayoutsDoc { /// The active layout id, or the explicit one when provided. #[must_use] pub fn resolve_id(&self, id: Option) -> LayoutId { id.unwrap_or(self.active_id) } /// Finds a layout by id. #[must_use] pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> { self.layouts.iter().find(|l| l.id == id) } /// Mutable access to a layout by id. pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> { self.layouts.iter_mut().find(|l| l.id == id) } /// Structural validity: non-empty, `active_id` present, every tree valid. fn is_valid(&self) -> bool { !self.layouts.is_empty() && self.find(self.active_id).is_some() && self.layouts.iter().all(|l| l.tree.validate().is_ok()) } } /// The default single-cell layout tree (one empty leaf). #[must_use] pub fn default_tree() -> LayoutTree { LayoutTree::single(LeafCell::new(NodeId::new_random())) } /// Builds a fresh doc holding one layout (`tree`) made active. fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc { LayoutsDoc { version: LAYOUTS_VERSION, active_id: id, layouts: vec![NamedLayout { id, name: name.to_owned(), kind: LayoutKind::Terminal, tree, }], } } fn layouts_path(project: &Project) -> RemotePath { RemotePath::new(join_root( &project.root, &format!("{IDEAI_DIR}/{LAYOUTS_FILE}"), )) } fn legacy_path(project: &Project) -> RemotePath { RemotePath::new(join_root( &project.root, &format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"), )) } /// Reads the legacy single layout tree if present and valid (for migration). async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option { let bytes = fs.read(&legacy_path(project)).await.ok()?; let tree = from_json_bytes::(&bytes).ok()?; tree.validate().is_ok().then_some(tree) } /// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed). pub async fn persist_doc( fs: &dyn FileSystem, project: &Project, doc: &LayoutsDoc, ) -> Result<(), AppError> { let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR)); fs.create_dir_all(&ideai_dir).await?; fs.write(&layouts_path(project), &to_json_bytes(doc)?) .await?; Ok(()) } /// Resolves the project's layouts doc, with idempotent initialisation / /// migration / self-healing (mirrors the L4 single-file resolver contract). /// /// - **Present & valid**: returned as-is (no write). /// - **Absent**: migrated from the legacy `layout.json` if present, else seeded /// with one empty "Default" layout — and **persisted** (write-through, so ids /// are stable from the first load). /// - **Present but corrupt/invalid**: overwritten with a fresh default. pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result { if let Ok(bytes) = fs.read(&layouts_path(project)).await { if let Ok(doc) = from_json_bytes::(&bytes) { if doc.is_valid() { return Ok(doc); } } // Present-but-invalid: fall through and re-seed. } // First run for this project (or self-heal): migrate the legacy tree if any. let tree = match read_legacy_tree(fs, project).await { Some(tree) => tree, None => default_tree(), }; let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree); persist_doc(fs, project, &doc).await?; Ok(doc) }