feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

16
crates/domain/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "domain"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA — pure domain layer: entities, value objects, ports (traits), domain events, layout logic. Zero I/O."
[dependencies]
uuid = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

257
crates/domain/src/agent.rs Normal file
View File

@ -0,0 +1,257 @@
//! Agent entity, its origin and the project agent manifest.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::{AgentId, ProfileId, TemplateId};
use crate::template::TemplateVersion;
/// Origin of an agent: created from scratch, or derived from a template.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum AgentOrigin {
/// Created from scratch; no template link.
Scratch,
/// Derived from a template, tracking the last synced version.
#[serde(rename_all = "camelCase")]
FromTemplate {
/// Source template.
template_id: TemplateId,
/// Template version recorded at the last successful sync.
synced_template_version: TemplateVersion,
},
}
impl AgentOrigin {
/// Returns the source template id, if any.
#[must_use]
pub fn template_id(&self) -> Option<TemplateId> {
match self {
Self::Scratch => None,
Self::FromTemplate { template_id, .. } => Some(*template_id),
}
}
/// Returns `true` if this origin is a template.
#[must_use]
pub fn is_from_template(&self) -> bool {
matches!(self, Self::FromTemplate { .. })
}
}
/// A project-scoped agent.
///
/// Invariants enforced here:
/// - `name` non-empty,
/// - `context_path` is a relative, safe path (the `.md` lives under `.ideai/`),
/// - `synchronized == true` ⇒ `origin == FromTemplate { .. }`.
///
/// Note: "`context` must exist at activation" and "`profile_id` must reference a
/// known profile" are *runtime/cross-aggregate* invariants checked by the
/// application layer (they require I/O or the profile registry), not by this
/// pure constructor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Agent {
/// Stable identifier.
pub id: AgentId,
/// Display name.
pub name: String,
/// Relative path of the agent's `.md` within `.ideai/` (e.g. `agents/foo.md`).
pub context_path: String,
/// Runtime profile reference.
pub profile_id: ProfileId,
/// Origin of the agent.
pub origin: AgentOrigin,
/// Whether the agent tracks its template (only valid for template origins).
pub synchronized: bool,
}
impl Agent {
/// Builds a validated agent.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `name` is empty,
/// - [`DomainError::PathNotRelativeSafe`] if `context_path` is absolute or
/// contains `..`,
/// - [`DomainError::SyncRequiresTemplate`] if `synchronized` is `true` while
/// `origin` is [`AgentOrigin::Scratch`].
pub fn new(
id: AgentId,
name: impl Into<String>,
context_path: impl Into<String>,
profile_id: ProfileId,
origin: AgentOrigin,
synchronized: bool,
) -> Result<Self, DomainError> {
let name = name.into();
let context_path = context_path.into();
crate::validation::non_empty(&name, "agent.name")?;
crate::validation::relative_safe(&context_path)?;
if synchronized && !origin.is_from_template() {
return Err(DomainError::SyncRequiresTemplate);
}
Ok(Self {
id,
name,
context_path,
profile_id,
origin,
synchronized,
})
}
}
/// One entry in the project agent manifest (`.ideai/agents.json`).
///
/// This is the **persisted form of an [`Agent`]** (ARCHITECTURE §9.1): the
/// manifest is the source of truth for a project's agents, so each entry carries
/// everything needed to reconstruct the agent — its `name` and `profile_id`
/// included (without them the IDE could not list agents or resolve the profile to
/// launch). The template link is kept flat (`template_id` +
/// `synced_template_version`) for a compact on-disk shape; [`to_agent`] folds it
/// back into an [`AgentOrigin`].
///
/// Invariants:
/// - `name` non-empty,
/// - `md_path` relative and safe,
/// - `synchronized == true` ⇒ `template_id.is_some() && synced_template_version.is_some()`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManifestEntry {
/// The agent this entry describes.
pub agent_id: AgentId,
/// Display name of the agent.
pub name: String,
/// Relative path of the agent's `.md`.
pub md_path: String,
/// Runtime profile reference.
pub profile_id: ProfileId,
/// Source template, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<TemplateId>,
/// Whether the agent tracks its template.
pub synchronized: bool,
/// Template version recorded at the last sync.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub synced_template_version: Option<TemplateVersion>,
}
impl ManifestEntry {
/// Builds a validated manifest entry.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `name` is empty,
/// - [`DomainError::PathNotRelativeSafe`] if `md_path` is absolute or has `..`,
/// - [`DomainError::InconsistentManifest`] if `synchronized` is `true` while
/// `template_id` or `synced_template_version` is missing.
pub fn new(
agent_id: AgentId,
name: impl Into<String>,
md_path: impl Into<String>,
profile_id: ProfileId,
template_id: Option<TemplateId>,
synchronized: bool,
synced_template_version: Option<TemplateVersion>,
) -> Result<Self, DomainError> {
let name = name.into();
let md_path = md_path.into();
crate::validation::non_empty(&name, "manifestEntry.name")?;
crate::validation::relative_safe(&md_path)?;
if synchronized && (template_id.is_none() || synced_template_version.is_none()) {
return Err(DomainError::InconsistentManifest {
reason: "synchronized entry requires templateId and syncedTemplateVersion"
.to_string(),
});
}
Ok(Self {
agent_id,
name,
md_path,
profile_id,
template_id,
synchronized,
synced_template_version,
})
}
/// Projects an [`Agent`] into its manifest entry (flattening the origin).
///
/// Infallible: an [`Agent`] is already validated, and every entry invariant
/// is implied by the agent's invariants.
#[must_use]
pub fn from_agent(agent: &Agent) -> Self {
let (template_id, synced_template_version) = match &agent.origin {
AgentOrigin::Scratch => (None, None),
AgentOrigin::FromTemplate {
template_id,
synced_template_version,
} => (Some(*template_id), Some(*synced_template_version)),
};
Self {
agent_id: agent.id,
name: agent.name.clone(),
md_path: agent.context_path.clone(),
profile_id: agent.profile_id,
template_id,
synchronized: agent.synchronized,
synced_template_version,
}
}
/// Reconstructs the validated [`Agent`] this entry persists, folding the flat
/// template link back into an [`AgentOrigin`].
///
/// # Errors
/// Returns a [`DomainError`] if the persisted fields violate an [`Agent`]
/// invariant (e.g. a synchronized entry whose origin is not a template).
pub fn to_agent(&self) -> Result<Agent, DomainError> {
let origin = match (self.template_id, self.synced_template_version) {
(Some(template_id), Some(synced_template_version)) => AgentOrigin::FromTemplate {
template_id,
synced_template_version,
},
_ => AgentOrigin::Scratch,
};
Agent::new(
self.agent_id,
self.name.clone(),
self.md_path.clone(),
self.profile_id,
origin,
self.synchronized,
)
}
}
/// In-memory image of `.ideai/agents.json`.
///
/// Invariant enforced here: `md_path` values are unique across entries.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentManifest {
/// Schema version of the manifest file.
pub version: u32,
/// Entries (one per project agent).
#[serde(rename = "agents")]
pub entries: Vec<ManifestEntry>,
}
impl AgentManifest {
/// Builds a validated manifest.
///
/// # Errors
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
/// same `md_path`.
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
let mut seen = std::collections::HashSet::new();
for entry in &entries {
if !seen.insert(entry.md_path.as_str()) {
return Err(DomainError::InconsistentManifest {
reason: format!("duplicate md_path `{}`", entry.md_path),
});
}
}
Ok(Self { version, entries })
}
}

View File

@ -0,0 +1,71 @@
//! Domain-level validation errors.
//!
//! These errors are raised by validating constructors (`new`/`try_new`) when an
//! invariant documented in `ARCHITECTURE.md` §3.2 is violated. They are pure
//! data — no I/O, no platform coupling.
use thiserror::Error;
/// Error raised when an entity or value object invariant is violated at
/// construction time.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DomainError {
/// A required string field was empty.
#[error("field `{field}` must not be empty")]
EmptyField {
/// Name of the offending field.
field: &'static str,
},
/// A path that must be absolute was relative.
#[error("path `{path}` must be absolute")]
PathNotAbsolute {
/// The offending path.
path: String,
},
/// A path that must be relative was absolute, or escaped its root via `..`.
#[error("path `{path}` must be relative and must not contain `..`")]
PathNotRelativeSafe {
/// The offending path.
path: String,
},
/// An environment variable name was not a valid identifier.
#[error("`{value}` is not a valid environment variable identifier")]
InvalidEnvVar {
/// The offending value.
value: String,
},
/// An SSH port was outside the valid `1..=65535` range.
#[error("ssh port must be in 1..=65535, got {port}")]
InvalidPort {
/// The offending port.
port: u32,
},
/// A PTY dimension was zero.
#[error("pty size must have rows>0 and cols>0, got rows={rows} cols={cols}")]
InvalidPtySize {
/// Requested rows.
rows: u16,
/// Requested cols.
cols: u16,
},
/// `synchronized == true` requires an agent originating from a template.
#[error("a synchronized agent must originate from a template")]
SyncRequiresTemplate,
/// A manifest entry was inconsistent (e.g. synchronized without template metadata).
#[error("manifest entry inconsistent: {reason}")]
InconsistentManifest {
/// Human-readable reason.
reason: String,
},
/// A generic invariant violation with an explanatory message.
#[error("invariant violated: {0}")]
Invariant(String),
}

View File

@ -0,0 +1,79 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProjectId, SessionId, TemplateId};
use crate::template::TemplateVersion;
/// Events emitted by the domain/application as state changes occur.
///
/// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process
/// concern relayed to IPC by an infrastructure adapter, which owns the wire
/// format. `PtyOutput` in particular is usually short-circuited to a Tauri
/// channel rather than serialised here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomainEvent {
/// A project was created.
ProjectCreated {
/// The new project.
project_id: ProjectId,
},
/// An agent was launched in a terminal.
AgentLaunched {
/// The agent.
agent_id: AgentId,
/// The session it runs in.
session_id: SessionId,
},
/// An agent's process exited.
AgentExited {
/// The agent.
agent_id: AgentId,
/// Exit code.
code: i32,
},
/// A template was updated (content changed, version bumped).
TemplateUpdated {
/// The template.
template_id: TemplateId,
/// New version.
version: TemplateVersion,
},
/// A synchronized agent is behind its template.
AgentDriftDetected {
/// The drifting agent.
agent_id: AgentId,
/// Version the agent is currently at.
from: TemplateVersion,
/// Version available from the template.
to: TemplateVersion,
},
/// A synchronized agent received its template update.
AgentSynced {
/// The agent.
agent_id: AgentId,
/// Version it was brought up to.
to: TemplateVersion,
},
/// A tab's layout changed.
LayoutChanged {
/// The project whose layout changed.
project_id: ProjectId,
},
/// A remote host connection was established.
RemoteConnected {
/// The project on that remote.
project_id: ProjectId,
},
/// Git state for a project changed.
GitStateChanged {
/// The project.
project_id: ProjectId,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.
session_id: SessionId,
/// Output bytes.
bytes: Vec<u8>,
},
}

45
crates/domain/src/git.rs Normal file
View File

@ -0,0 +1,45 @@
//! Git repository entity (domain state image).
//!
//! This is the *entity* describing the derived git state of a project. The
//! git **port** (operations) lives in [`crate::ports`].
use serde::{Deserialize, Serialize};
use crate::ids::ProjectId;
use crate::project::ProjectPath;
/// Derived git state for a project, refreshed via the git port.
///
/// Invariant (operational, not enforced here): `root` contains — or will
/// contain after `init` — a `.git` directory. This is verified by the
/// infrastructure adapter, not by the pure domain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitRepository {
/// Owning project.
pub project_id: ProjectId,
/// Repository root.
pub root: ProjectPath,
/// Current branch name, if the repo is on a branch.
pub current_branch: Option<String>,
/// Whether the working tree has uncommitted changes.
pub is_dirty: bool,
}
impl GitRepository {
/// Builds a git-state snapshot.
#[must_use]
pub fn new(
project_id: ProjectId,
root: ProjectPath,
current_branch: Option<String>,
is_dirty: bool,
) -> Self {
Self {
project_id,
root,
current_branch,
is_dirty,
}
}
}

90
crates/domain/src/ids.rs Normal file
View File

@ -0,0 +1,90 @@
//! Strongly-typed identifiers.
//!
//! Each identifier is a `newtype` around [`uuid::Uuid`]. Using distinct types
//! per concept makes it impossible to pass, say, an [`AgentId`] where a
//! [`ProjectId`] is expected (compile-time safety, SOLID/typing discipline).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
macro_rules! typed_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub Uuid);
impl $name {
/// Wraps an existing [`Uuid`].
#[must_use]
pub const fn from_uuid(id: Uuid) -> Self {
Self(id)
}
/// Generates a fresh random (v4) identifier.
///
/// Prefer injecting an [`crate::ports::IdGenerator`] in application
/// code for determinism; this convenience exists for tests and the
/// composition root.
#[must_use]
pub fn new_random() -> Self {
Self(Uuid::new_v4())
}
/// Returns the inner [`Uuid`].
#[must_use]
pub const fn as_uuid(&self) -> Uuid {
self.0
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for $name {
fn from(id: Uuid) -> Self {
Self(id)
}
}
};
}
typed_id!(
/// Identifies a [`crate::project::Project`].
ProjectId
);
typed_id!(
/// Identifies an [`crate::agent::Agent`].
AgentId
);
typed_id!(
/// Identifies an [`crate::template::AgentTemplate`].
TemplateId
);
typed_id!(
/// Identifies an [`crate::profile::AgentProfile`].
ProfileId
);
typed_id!(
/// Identifies a [`crate::terminal::TerminalSession`].
SessionId
);
typed_id!(
/// Identifies a [`crate::layout::WindowId`]-bearing OS window.
WindowId
);
typed_id!(
/// Identifies a [`crate::layout::Tab`].
TabId
);
typed_id!(
/// Identifies one named terminal layout within a project (L10/#4).
LayoutId
);
typed_id!(
/// Identifies a node in a [`crate::layout::LayoutTree`].
NodeId
);

692
crates/domain/src/layout.rs Normal file
View File

@ -0,0 +1,692 @@
//! Pure, immutable terminal layout model (the "spreadsheet-like" recursive grid)
//! and its operations.
//!
//! See ARCHITECTURE.md §7. The model is a recursive split/grid tree. All
//! mutating operations are **pure functions** `&LayoutTree -> Result<LayoutTree,
//! LayoutError>` returning a new tree, which makes them trivially testable and
//! enables undo/redo at the application layer.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
/// Direction of a [`SplitContainer`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
/// Children laid out left→right (columns).
Row,
/// Children laid out top→bottom (rows).
Column,
}
/// A leaf cell hosting zero or one terminal session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeafCell {
/// Node identifier.
pub id: NodeId,
/// The hosted session, if any (0 or 1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionId>,
/// The agent to launch automatically in this cell, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentId>,
}
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
/// resizable share; the UI normalises it for rendering.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WeightedChild {
/// The child node.
pub node: LayoutNode,
/// Relative weight; invariant: `> 0`.
pub weight: f32,
}
/// A simple n-ary weighted split (rows or columns).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SplitContainer {
/// Node identifier.
pub id: NodeId,
/// Split direction.
pub direction: Direction,
/// Ordered children (left→right / top→bottom).
pub children: Vec<WeightedChild>,
}
/// A grid cell placement with spans (spreadsheet-style merging).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridCell {
/// The hosted node (may itself be a split/grid — recursive).
pub node: LayoutNode,
/// Zero-based row index.
pub row: u16,
/// Zero-based column index.
pub col: u16,
/// Row span; invariant: `>= 1`.
pub row_span: u16,
/// Column span; invariant: `>= 1`.
pub col_span: u16,
}
/// A spreadsheet-like grid with per-column / per-row weights and span-based
/// merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridContainer {
/// Node identifier.
pub id: NodeId,
/// Column widths (relative); length = number of columns.
pub col_weights: Vec<f32>,
/// Row heights (relative); length = number of rows.
pub row_weights: Vec<f32>,
/// Cell placements with spans.
pub cells: Vec<GridCell>,
}
/// A node in the layout tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
pub enum LayoutNode {
/// A terminal-hosting leaf.
Leaf(LeafCell),
/// A weighted split.
Split(SplitContainer),
/// A spreadsheet-style grid.
Grid(GridContainer),
}
/// The root of a layout (one per tab).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutTree {
/// Root node.
pub root: LayoutNode,
}
/// Errors produced by layout validation and operations.
#[derive(Debug, Clone, PartialEq, Error)]
pub enum LayoutError {
/// A weight was not strictly positive.
#[error("weight must be > 0, got {weight}")]
NonPositiveWeight {
/// Offending weight.
weight: f32,
},
/// A split had no children.
#[error("a split container must have at least one child")]
EmptySplit,
/// A grid span was less than one.
#[error("grid span must be >= 1")]
InvalidSpan,
/// A grid cell extends beyond the grid bounds.
#[error("grid cell at ({row},{col}) span ({row_span}x{col_span}) exceeds grid {rows}x{cols}")]
SpanOutOfBounds {
/// Cell row.
row: u16,
/// Cell column.
col: u16,
/// Row span.
row_span: u16,
/// Column span.
col_span: u16,
/// Grid rows.
rows: u16,
/// Grid cols.
cols: u16,
},
/// Two grid cells overlap.
#[error("grid cells overlap at ({row},{col})")]
OverlappingCells {
/// Row of the overlap.
row: u16,
/// Column of the overlap.
col: u16,
},
/// Part of the grid surface is not covered by any cell.
#[error("grid surface not fully covered: cell ({row},{col}) is empty")]
UncoveredCell {
/// Uncovered row.
row: u16,
/// Uncovered column.
col: u16,
},
/// The same session appears in more than one leaf.
#[error("session {0} appears in more than one leaf")]
DuplicateSession(SessionId),
/// A referenced node id was not found in the tree.
#[error("node {0} not found")]
NodeNotFound(NodeId),
/// A merge/move spanned two distinct containers.
#[error("operation cannot span two distinct containers")]
CrossContainer,
/// A referenced tab id was not found in any window.
#[error("tab {0} not found")]
TabNotFound(TabId),
}
impl LayoutTree {
/// Wraps a root node into a tree (without validation).
#[must_use]
pub fn new(root: LayoutNode) -> Self {
Self { root }
}
/// Convenience: a single-leaf tree.
#[must_use]
pub fn single(leaf: LeafCell) -> Self {
Self {
root: LayoutNode::Leaf(leaf),
}
}
/// Validates **all** layout invariants on the whole tree:
/// positive weights, valid/non-overlapping/fully-covering grid spans, and
/// at-most-one-leaf-per-session uniqueness.
///
/// # Errors
/// Returns the first [`LayoutError`] encountered.
pub fn validate(&self) -> Result<(), LayoutError> {
let mut sessions = std::collections::HashSet::new();
validate_node(&self.root, &mut sessions)
}
/// Splits the leaf `target` into a [`SplitContainer`] with the original leaf
/// and a new leaf `new_leaf`, in the given `direction` with equal weights.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
/// - any validation error of the resulting tree.
pub fn split(
&self,
target: NodeId,
direction: Direction,
new_leaf: LeafCell,
container_id: NodeId,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Split(SplitContainer {
id: container_id,
direction,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(leaf.clone()),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(new_leaf.clone()),
weight: 1.0,
},
],
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Merges a [`SplitContainer`] identified by `container` back into a single
/// node, keeping the child at `keep_index` and discarding the rest.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `container` is not a split in the tree,
/// - [`LayoutError::CrossContainer`] if `keep_index` is out of range,
/// - any validation error of the resulting tree.
pub fn merge(&self, container: NodeId, keep_index: usize) -> Result<Self, LayoutError> {
let mut result: Result<(), LayoutError> = Ok(());
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Split(split) = node {
if split.id == container {
match split.children.get(keep_index) {
Some(child) => return child.node.clone(),
None => result = Err(LayoutError::CrossContainer),
}
}
}
node.clone()
});
result?;
if root == self.root {
return Err(LayoutError::NodeNotFound(container));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Resizes the children of a [`SplitContainer`] by assigning new `weights`.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `container` is not a split,
/// - [`LayoutError::CrossContainer`] if `weights.len()` differs from the
/// child count,
/// - [`LayoutError::NonPositiveWeight`] (via validation) if any weight ≤ 0.
pub fn resize(&self, container: NodeId, weights: &[f32]) -> Result<Self, LayoutError> {
let mut outcome: Option<Result<(), LayoutError>> = None;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Split(split) = node {
if split.id == container {
if split.children.len() != weights.len() {
outcome = Some(Err(LayoutError::CrossContainer));
return node.clone();
}
let children = split
.children
.iter()
.zip(weights.iter())
.map(|(child, &w)| WeightedChild {
node: child.node.clone(),
weight: w,
})
.collect();
outcome = Some(Ok(()));
return LayoutNode::Split(SplitContainer {
id: split.id,
direction: split.direction,
children,
});
}
}
node.clone()
});
match outcome {
Some(Ok(())) => {
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
Some(Err(e)) => Err(e),
None => Err(LayoutError::NodeNotFound(container)),
}
}
/// Moves the session currently hosted by leaf `from` to leaf `to`.
///
/// `from` is left empty; `to` must currently be empty. This models dragging
/// a terminal between cells without duplicating it. Pure: returns a new
/// validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if either leaf is missing,
/// - [`LayoutError::CrossContainer`] if `from` has no session or `to` is
/// occupied,
/// - any validation error of the resulting tree.
pub fn move_session(&self, from: NodeId, to: NodeId) -> Result<Self, LayoutError> {
let session = self.session_in_leaf(from)?;
let Some(session) = session else {
return Err(LayoutError::CrossContainer);
};
// Target must exist and be empty.
match self.session_in_leaf(to)? {
None => {}
Some(_) => return Err(LayoutError::CrossContainer),
}
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == from {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: None,
agent: leaf.agent,
});
}
if leaf.id == to {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
});
}
}
node.clone()
});
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Attaches (or, with `None`, detaches) a [`SessionId`] to the leaf `target`.
///
/// This is the bridge between the layout and the terminal layer (L3/L4): when
/// [`crate::terminal::TerminalSession`] is opened for a cell, the application
/// records its id in the hosting leaf with this pure operation.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
/// - [`LayoutError::DuplicateSession`] (via validation) if `session` is already
/// hosted by another leaf.
pub fn set_session(
&self,
target: NodeId,
session: Option<SessionId>,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session,
agent: leaf.agent,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
///
/// Records which agent should be auto-launched in the cell (feature #3).
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_cell_agent(
&self,
target: NodeId,
agent: Option<AgentId>,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
/// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {
fn find(node: &LayoutNode, id: NodeId) -> Option<Option<SessionId>> {
match node {
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(split) => {
split.children.iter().find_map(|c| find(&c.node, id))
}
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
}
}
find(&self.root, id).ok_or(LayoutError::NodeNotFound(id))
}
}
/// Recursively rebuilds a node, applying `f` to every node (post-order: `f` sees
/// already-rebuilt children).
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
let rebuilt = match node {
LayoutNode::Leaf(_) => node.clone(),
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
id: split.id,
direction: split.direction,
children: split
.children
.iter()
.map(|c| WeightedChild {
node: map_node(&c.node, f),
weight: c.weight,
})
.collect(),
}),
LayoutNode::Grid(grid) => LayoutNode::Grid(GridContainer {
id: grid.id,
col_weights: grid.col_weights.clone(),
row_weights: grid.row_weights.clone(),
cells: grid
.cells
.iter()
.map(|c| GridCell {
node: map_node(&c.node, f),
row: c.row,
col: c.col,
row_span: c.row_span,
col_span: c.col_span,
})
.collect(),
}),
};
f(&rebuilt)
}
/// Recursive validation of a single node, accumulating seen sessions to enforce
/// global uniqueness.
fn validate_node(
node: &LayoutNode,
sessions: &mut std::collections::HashSet<SessionId>,
) -> Result<(), LayoutError> {
match node {
LayoutNode::Leaf(leaf) => {
if let Some(session) = leaf.session {
if !sessions.insert(session) {
return Err(LayoutError::DuplicateSession(session));
}
}
Ok(())
}
LayoutNode::Split(split) => {
if split.children.is_empty() {
return Err(LayoutError::EmptySplit);
}
for child in &split.children {
if child.weight <= 0.0 {
return Err(LayoutError::NonPositiveWeight {
weight: child.weight,
});
}
validate_node(&child.node, sessions)?;
}
Ok(())
}
LayoutNode::Grid(grid) => validate_grid(grid, sessions),
}
}
/// Validates a grid: positive weights, in-bounds spans, no overlaps, full
/// coverage, and recursion into cell contents.
fn validate_grid(
grid: &GridContainer,
sessions: &mut std::collections::HashSet<SessionId>,
) -> Result<(), LayoutError> {
for &w in grid.col_weights.iter().chain(grid.row_weights.iter()) {
if w <= 0.0 {
return Err(LayoutError::NonPositiveWeight { weight: w });
}
}
let rows = grid.row_weights.len();
let cols = grid.col_weights.len();
// Occupancy matrix to detect overlaps and gaps.
let mut occupied = vec![false; rows * cols];
for cell in &grid.cells {
if cell.row_span < 1 || cell.col_span < 1 {
return Err(LayoutError::InvalidSpan);
}
let row_end = cell.row as usize + cell.row_span as usize;
let col_end = cell.col as usize + cell.col_span as usize;
if row_end > rows || col_end > cols {
return Err(LayoutError::SpanOutOfBounds {
row: cell.row,
col: cell.col,
row_span: cell.row_span,
col_span: cell.col_span,
rows: rows as u16,
cols: cols as u16,
});
}
for r in cell.row as usize..row_end {
for c in cell.col as usize..col_end {
let idx = r * cols + c;
if occupied[idx] {
return Err(LayoutError::OverlappingCells {
row: r as u16,
col: c as u16,
});
}
occupied[idx] = true;
}
}
validate_node(&cell.node, sessions)?;
}
// Full coverage.
for r in 0..rows {
for c in 0..cols {
if !occupied[r * cols + c] {
return Err(LayoutError::UncoveredCell {
row: r as u16,
col: c as u16,
});
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Window / Tab / Workspace — persisted presentation entities (ARCHITECTURE §3.2)
// ---------------------------------------------------------------------------
/// An open tab, bound 1:1 to a project.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tab {
/// Tab identifier.
pub id: TabId,
/// The project shown in this tab.
pub project_id: crate::ids::ProjectId,
/// The terminal layout of this tab.
pub layout: LayoutTree,
}
/// An OS window holding one or more tabs.
///
/// Invariant: a non-closed window holds at least one tab (see [`Window::new`]).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Window {
/// Window identifier.
pub id: WindowId,
/// Tabs in this window.
pub tabs: Vec<Tab>,
/// Currently active tab.
pub active_tab: TabId,
}
impl Window {
/// Builds a window, enforcing the "≥ 1 tab" invariant and that `active_tab`
/// refers to one of the tabs.
///
/// # Errors
/// Returns [`LayoutError::CrossContainer`] if `tabs` is empty or `active_tab`
/// is not present.
pub fn new(id: WindowId, tabs: Vec<Tab>, active_tab: TabId) -> Result<Self, LayoutError> {
if tabs.is_empty() || !tabs.iter().any(|t| t.id == active_tab) {
return Err(LayoutError::CrossContainer);
}
Ok(Self {
id,
tabs,
active_tab,
})
}
}
/// The set of windows for a user session.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
/// All open OS windows.
pub windows: Vec<Window>,
}
impl Workspace {
/// Detaches a tab into a brand-new window (ARCHITECTURE §10, L10).
///
/// A **pure** transformation returning the next workspace state — the tab is
/// *moved*, never duplicated (the "a project is open in exactly one tab"
/// invariant). If the source window becomes empty it is removed; otherwise, if
/// the detached tab was the active one, the source's active tab falls back to
/// its first remaining tab. The new window holds the tab and makes it active.
///
/// # Errors
/// - [`LayoutError::TabNotFound`] if no window contains `tab_id`,
/// - propagates [`Window::new`] invariants for the created window.
pub fn move_tab_to_new_window(
&self,
tab_id: TabId,
new_window_id: WindowId,
) -> Result<Self, LayoutError> {
let mut windows = self.windows.clone();
// Locate the (window, tab) holding `tab_id`.
let (wi, ti) = windows
.iter()
.enumerate()
.find_map(|(wi, w)| {
w.tabs
.iter()
.position(|t| t.id == tab_id)
.map(|ti| (wi, ti))
})
.ok_or(LayoutError::TabNotFound(tab_id))?;
let tab = windows[wi].tabs.remove(ti);
if windows[wi].tabs.is_empty() {
// The source window is now empty: drop it (windows hold ≥ 1 tab).
windows.remove(wi);
} else if windows[wi].active_tab == tab_id {
// The moved tab was active: fall back to the first remaining tab.
windows[wi].active_tab = windows[wi].tabs[0].id;
}
let detached = Window::new(new_window_id, vec![tab.clone()], tab.id)?;
windows.push(detached);
Ok(Self { windows })
}
}

88
crates/domain/src/lib.rs Normal file
View File

@ -0,0 +1,88 @@
//! # IdeA — Domain layer
//!
//! The **pure** hexagonal core (ARCHITECTURE.md §1.4, §3, §4, §7). It contains:
//!
//! - **Entities & value objects** with invariants enforced by validating
//! constructors (`new`/`try_new` returning `Result`),
//! - the **pure layout logic** (`split`/`merge`/`resize`/`move` as immutable
//! `&LayoutTree -> Result<LayoutTree, LayoutError>` functions),
//! - **ports** (traits) the infrastructure implements,
//! - **domain events** and **errors**.
//!
//! ## Dependency rule
//!
//! This crate depends on **no I/O**: no `tokio`, no `std::fs`, no
//! `std::process`, no `git2`/`portable-pty`/`russh`. The only third-party
//! dependencies are `uuid`, `serde` (allowed solely to derive (de)serialisation
//! of *persisted* domain types — a metier format constraint, not I/O),
//! `thiserror`, and `async-trait`.
//!
//! ## Async strategy for ports
//!
//! I/O-touching ports (`PtyPort`, `FileSystem`, `ProcessSpawner`, `RemoteHost`,
//! the stores, `GitPort`) are `#[async_trait]`. They are injected as
//! `Arc<dyn Port>` trait objects at the composition root, which native
//! `async fn`-in-trait does not yet support dyn-compatibly without boxing;
//! `async_trait` boxes the returned future and keeps the ports object-safe.
//! Non-blocking ports (`Clock`, `IdGenerator`, `EventBus`, `AgentRuntime`)
//! remain plain synchronous traits. See [`ports`] for details.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod agent;
pub mod error;
pub mod events;
pub mod git;
pub mod ids;
pub mod layout;
pub mod markdown;
pub mod ports;
pub mod profile;
pub mod project;
pub mod remote;
pub mod template;
pub mod terminal;
mod validation;
// ---------------------------------------------------------------------------
// Curated re-exports for ergonomic downstream use.
// ---------------------------------------------------------------------------
pub use error::DomainError;
pub use ids::{
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{AgentProfile, ContextInjection};
pub use markdown::MarkdownDoc;
pub use remote::{RemoteKind, RemoteRef, SshAuth};
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
pub use git::GitRepository;
pub use layout::{
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
SplitContainer, Tab, WeightedChild, Window, Workspace,
};
pub use events::DomainEvent;
pub use ports::{
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
SpawnSpec, StoreError, TemplateStore,
};

View File

@ -0,0 +1,44 @@
//! Markdown content value object.
use serde::{Deserialize, Serialize};
/// A Markdown document — the content of an agent context (`.md`) or a template.
///
/// This is a thin newtype: the domain treats Markdown as opaque text and does
/// not parse it. It exists to give the content a meaningful type at API
/// boundaries (vs. a bare `String`).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MarkdownDoc(String);
impl MarkdownDoc {
/// Wraps raw Markdown content (empty content is permitted).
#[must_use]
pub fn new(content: impl Into<String>) -> Self {
Self(content.into())
}
/// Returns the content as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Consumes the document, returning its content.
#[must_use]
pub fn into_string(self) -> String {
self.0
}
/// Returns `true` if the document is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl From<String> for MarkdownDoc {
fn from(s: String) -> Self {
Self(s)
}
}

663
crates/domain/src/ports.rs Normal file
View File

@ -0,0 +1,663 @@
//! Ports (traits) — the boundary the domain defines and the infrastructure
//! implements (the **D** of SOLID, materialised).
//!
//! # Async decision
//!
//! Ports that touch the outside world (PTY, filesystem, process, stores, git,
//! remote connect) are inherently asynchronous in every realistic adapter
//! (tokio fs/process, russh, sftp). We therefore make those traits `async` via
//! [`async_trait`]. The rationale for `#[async_trait]` over native
//! `async fn` in traits / `-> impl Future`:
//!
//! - These ports are consumed as **trait objects** (`Arc<dyn FileSystem>`,
//! injected at the composition root). Native `async fn` in traits is not yet
//! dyn-compatible without boxing, so `async_trait` (which boxes the future)
//! is the pragmatic, stable choice and keeps the call sites object-safe.
//! - It keeps signatures readable and uniform across all adapters.
//!
//! Purely synchronous, non-blocking ports ([`Clock`], [`IdGenerator`],
//! [`EventBus`], the CPU-bound part of [`AgentRuntime`]) stay plain `fn` — no
//! need to pay the boxing cost.
//!
//! Each port is **fine-grained** (Interface Segregation): `FileSystem`,
//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait.
use std::sync::Arc;
use async_trait::async_trait;
use thiserror::Error;
use crate::agent::AgentManifest;
use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId};
use crate::markdown::MarkdownDoc;
use crate::profile::AgentProfile;
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::template::AgentTemplate;
use crate::terminal::PtySize;
// ---------------------------------------------------------------------------
// Support value types shared across ports
// ---------------------------------------------------------------------------
/// How the `.md` context should be delivered to a spawned process, resolved
/// from a profile's [`crate::profile::ContextInjection`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextInjectionPlan {
/// Materialise the context at a (relative) file path before launch.
File {
/// Relative target path inside the cwd.
target: String,
},
/// Append the given already-rendered arguments to the command line.
Args {
/// Extra arguments carrying the context path.
args: Vec<String>,
},
/// Pipe the content on stdin.
Stdin,
/// Provide the content (or its path) via an environment variable.
Env {
/// Variable name.
var: String,
},
}
/// A fully-resolved process invocation: command, args, cwd, environment, and the
/// plan for delivering the agent context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnSpec {
/// Executable to run.
pub command: String,
/// Arguments.
pub args: Vec<String>,
/// Working directory.
pub cwd: ProjectPath,
/// Extra environment variables.
pub env: Vec<(String, String)>,
/// How the context is injected, if any.
pub context_plan: Option<ContextInjectionPlan>,
}
/// The agent context prepared for injection (content + the on-disk path it maps
/// to within the project).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedContext {
/// Rendered Markdown content.
pub content: MarkdownDoc,
/// Relative path of the `.md` inside the project.
pub relative_path: String,
}
/// An opaque handle to a live PTY, owned by the adapter.
///
/// The domain only needs an identity to address the PTY in subsequent calls;
/// the actual OS handle lives in infrastructure.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PtyHandle {
/// The session this PTY backs.
pub session_id: SessionId,
}
/// Exit status of a process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExitStatus {
/// Exit code (`None` if terminated by a signal).
pub code: Option<i32>,
}
/// Captured output of a non-interactive process run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
/// Exit status.
pub status: ExitStatus,
/// Captured stdout.
pub stdout: Vec<u8>,
/// Captured stderr.
pub stderr: Vec<u8>,
}
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemotePath(pub String);
impl RemotePath {
/// Wraps a raw path.
#[must_use]
pub fn new(p: impl Into<String>) -> Self {
Self(p.into())
}
/// Returns the path as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A single directory entry returned by [`FileSystem::list`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
/// Entry name (basename).
pub name: String,
/// Whether the entry is a directory.
pub is_dir: bool,
}
/// An owned, boxed stream of PTY output chunks.
///
/// Concrete adapters decide the underlying transport; the domain only sees a
/// dynamically-dispatched iterator of byte chunks.
pub type OutputStream = Box<dyn Iterator<Item = Vec<u8>> + Send>;
/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;
// ---------------------------------------------------------------------------
// Per-port error types
// ---------------------------------------------------------------------------
/// Errors from [`AgentRuntime`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RuntimeError {
/// The configured command could not be resolved/detected.
#[error("agent runtime: {0}")]
Detection(String),
/// The invocation could not be prepared (bad profile, etc.).
#[error("agent runtime: invalid invocation: {0}")]
Invocation(String),
}
/// Errors from [`PtyPort`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PtyError {
/// Failed to spawn the PTY.
#[error("pty spawn failed: {0}")]
Spawn(String),
/// Read/write failure.
#[error("pty io failed: {0}")]
Io(String),
/// The handle referred to no live PTY.
#[error("pty handle not found")]
NotFound,
}
/// Errors from [`ProcessSpawner`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ProcessError {
/// The process could not be started.
#[error("process spawn failed: {0}")]
Spawn(String),
/// I/O failure while running.
#[error("process io failed: {0}")]
Io(String),
}
/// Errors from [`FileSystem`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum FsError {
/// Path not found.
#[error("path not found: {0}")]
NotFound(String),
/// Permission denied.
#[error("permission denied: {0}")]
PermissionDenied(String),
/// Other I/O error.
#[error("filesystem io failed: {0}")]
Io(String),
}
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
/// [`AgentContextStore`]).
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StoreError {
/// The requested item was not found.
#[error("not found")]
NotFound,
/// (De)serialisation failed.
#[error("serialization failed: {0}")]
Serialization(String),
/// Underlying I/O error.
#[error("store io failed: {0}")]
Io(String),
}
/// Errors from [`RemoteHost`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RemoteError {
/// Connection failed.
#[error("remote connection failed: {0}")]
Connection(String),
/// Authentication failed.
#[error("remote authentication failed: {0}")]
Auth(String),
}
/// Errors from the git [`GitPort`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum GitError {
/// The repository was not found / not initialised.
#[error("git repository not found")]
NotFound,
/// A git operation failed.
#[error("git operation failed: {0}")]
Operation(String),
}
// ---------------------------------------------------------------------------
// Git port support types
// ---------------------------------------------------------------------------
/// Status of a single path in the working tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitFileStatus {
/// Path relative to the repo root.
pub path: String,
/// Whether the change is staged.
pub staged: bool,
}
/// A commit summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitInfo {
/// Commit hash.
pub hash: String,
/// Commit message summary.
pub summary: String,
}
/// A commit enriched for graph display (DAG edges + refs + author + time).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphCommit {
/// Full commit hash (hex string).
pub hash: String,
/// First line of the commit message.
pub summary: String,
/// Parent commit hashes (0 for root, ≥2 for merges).
pub parents: Vec<String>,
/// Human-readable ref labels pointing at this commit
/// (e.g. `"main"`, `"tag: v1.0"`).
pub refs: Vec<String>,
/// Author name (empty string when unavailable).
pub author: String,
/// Author timestamp in Unix seconds.
pub timestamp: i64,
}
// ---------------------------------------------------------------------------
// Ports
// ---------------------------------------------------------------------------
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
/// injection. CPU-bound and synchronous — kept plain `fn`.
pub trait AgentRuntime: Send + Sync {
/// Detects whether the profile's command is available.
///
/// # Errors
/// [`RuntimeError`] on detection failure.
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
/// the agent in `cwd` with the prepared context.
///
/// # Errors
/// [`RuntimeError`] if the invocation cannot be prepared.
fn prepare_invocation(
&self,
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
) -> Result<SpawnSpec, RuntimeError>;
}
/// Open and drive pseudo-terminals.
#[async_trait]
pub trait PtyPort: Send + Sync {
/// Spawns a PTY running `spec` at the given `size`.
///
/// # Errors
/// [`PtyError`] on failure.
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>;
/// Writes bytes to the PTY.
///
/// # Errors
/// [`PtyError`] on failure.
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError>;
/// Resizes the PTY.
///
/// # Errors
/// [`PtyError`] on failure.
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError>;
/// Subscribes to the PTY's byte output stream.
///
/// # Errors
/// [`PtyError`] if the handle is unknown.
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
/// Kills the PTY's process, returning its exit status.
///
/// # Errors
/// [`PtyError`] on failure.
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>;
}
/// Run a non-interactive process and capture its output.
#[async_trait]
pub trait ProcessSpawner: Send + Sync {
/// Runs `spec` to completion.
///
/// # Errors
/// [`ProcessError`] on failure.
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
}
/// Location-neutral filesystem access.
#[async_trait]
pub trait FileSystem: Send + Sync {
/// Reads a file.
///
/// # Errors
/// [`FsError`] on failure.
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError>;
/// Writes a file (creating or truncating).
///
/// # Errors
/// [`FsError`] on failure.
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError>;
/// Returns whether the path exists.
///
/// # Errors
/// [`FsError`] on failure.
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
/// Creates a directory and all missing parents.
///
/// # Errors
/// [`FsError`] on failure.
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError>;
/// Lists the entries of a directory.
///
/// # Errors
/// [`FsError`] on failure.
async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError>;
/// Creates a symbolic link `dst` pointing at `src`.
///
/// # Errors
/// [`FsError`] on failure.
async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>;
}
/// Strategy abstracting *where* execution happens (local / SSH / WSL). Acts as a
/// factory for the location-appropriate fine-grained ports.
#[async_trait]
pub trait RemoteHost: Send + Sync {
/// The kind of this host.
fn kind(&self) -> RemoteKind;
/// Establishes the connection (no-op for local).
///
/// # Errors
/// [`RemoteError`] on failure.
async fn connect(&self) -> Result<(), RemoteError>;
/// Returns the filesystem port for this host.
fn file_system(&self) -> Arc<dyn FileSystem>;
/// Returns the process spawner for this host.
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
/// Returns the PTY port for this host.
fn pty(&self) -> Arc<dyn PtyPort>;
}
/// CRUD + versioning for agent templates in the global IDE store.
#[async_trait]
pub trait TemplateStore: Send + Sync {
/// Lists all templates.
///
/// # Errors
/// [`StoreError`] on failure.
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError>;
/// Gets a template by id.
///
/// # Errors
/// [`StoreError::NotFound`] if absent.
async fn get(&self, id: crate::ids::TemplateId) -> Result<AgentTemplate, StoreError>;
/// Saves (creates or replaces) a template.
///
/// # Errors
/// [`StoreError`] on failure.
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError>;
/// Deletes a template.
///
/// # Errors
/// [`StoreError`] on failure.
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
}
/// Persistence of the known-projects registry and the workspace.
#[async_trait]
pub trait ProjectStore: Send + Sync {
/// Lists all known projects.
///
/// # Errors
/// [`StoreError`] on failure.
async fn list_projects(&self) -> Result<Vec<Project>, StoreError>;
/// Loads a single project.
///
/// # Errors
/// [`StoreError::NotFound`] if absent.
async fn load_project(&self, id: crate::ids::ProjectId) -> Result<Project, StoreError>;
/// Saves (creates or replaces) a project.
///
/// # Errors
/// [`StoreError`] on failure.
async fn save_project(&self, project: &Project) -> Result<(), StoreError>;
/// Saves the whole workspace (windows/tabs/layouts).
///
/// # Errors
/// [`StoreError`] on failure.
async fn save_workspace(&self, workspace: &crate::layout::Workspace) -> Result<(), StoreError>;
/// Loads the persisted workspace.
///
/// # Errors
/// [`StoreError`] on failure.
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
}
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
/// single generic [`AgentRuntime`] adapter (Open/Closed).
#[async_trait]
pub trait ProfileStore: Send + Sync {
/// Lists all configured profiles.
///
/// # Errors
/// [`StoreError`] on failure.
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError>;
/// Saves (creates or replaces by id) a profile.
///
/// # Errors
/// [`StoreError`] on failure.
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError>;
/// Deletes a profile by id.
///
/// # Errors
/// [`StoreError`] on failure.
async fn delete(&self, id: crate::ids::ProfileId) -> Result<(), StoreError>;
/// Whether the profiles store has been initialised yet — used to detect the
/// first run of the IDE (no `profiles.json` ⇒ first run).
///
/// # Errors
/// [`StoreError`] on failure.
async fn is_configured(&self) -> Result<bool, StoreError>;
/// Persists an (possibly empty) profiles store, recording that the first run
/// is complete even when the user kept no profile.
///
/// # Errors
/// [`StoreError`] on failure.
async fn mark_configured(&self) -> Result<(), StoreError>;
}
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
#[async_trait]
pub trait AgentContextStore: Send + Sync {
/// Reads an agent's context.
///
/// # Errors
/// [`StoreError`] on failure.
async fn read_context(
&self,
project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError>;
/// Writes an agent's context.
///
/// # Errors
/// [`StoreError`] on failure.
async fn write_context(
&self,
project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError>;
/// Loads the project manifest.
///
/// # Errors
/// [`StoreError`] on failure.
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;
/// Saves the project manifest.
///
/// # Errors
/// [`StoreError`] on failure.
async fn save_manifest(
&self,
project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError>;
}
/// Git operations for a project. Named `GitPort` to avoid clashing with the
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]
pub trait GitPort: Send + Sync {
/// Initialises a repository at the root.
///
/// # Errors
/// [`GitError`] on failure.
async fn init(&self, root: &ProjectPath) -> Result<(), GitError>;
/// Returns the status of changed paths.
///
/// # Errors
/// [`GitError`] on failure.
async fn status(&self, root: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError>;
/// Stages a path.
///
/// # Errors
/// [`GitError`] on failure.
async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;
/// Unstages a path.
///
/// # Errors
/// [`GitError`] on failure.
async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;
/// Creates a commit with the given message.
///
/// # Errors
/// [`GitError`] on failure.
async fn commit(&self, root: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError>;
/// Lists branches.
///
/// # Errors
/// [`GitError`] on failure.
async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError>;
/// Returns the current branch.
///
/// # Errors
/// [`GitError`] on failure.
async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, GitError>;
/// Checks out a branch.
///
/// # Errors
/// [`GitError`] on failure.
async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError>;
/// Returns the recent commit log.
///
/// # Errors
/// [`GitError`] on failure.
async fn log(&self, root: &ProjectPath, limit: usize)
-> Result<Vec<GitCommitInfo>, GitError>;
/// Returns the commit graph (all local branches, topological + time sort).
///
/// # Errors
/// [`GitError`] on failure.
async fn log_graph(
&self,
root: &ProjectPath,
limit: usize,
) -> Result<Vec<GraphCommit>, GitError>;
/// Pulls from the default remote.
///
/// # Errors
/// [`GitError`] on failure.
async fn pull(&self, root: &ProjectPath) -> Result<(), GitError>;
/// Pushes to the default remote.
///
/// # Errors
/// [`GitError`] on failure.
async fn push(&self, root: &ProjectPath) -> Result<(), GitError>;
}
/// Publish/subscribe domain events. Synchronous, in-process.
pub trait EventBus: Send + Sync {
/// Publishes an event.
fn publish(&self, event: DomainEvent);
/// Subscribes to the event stream.
fn subscribe(&self) -> EventStream;
}
/// Provides the current time (epoch milliseconds), abstracted for determinism.
pub trait Clock: Send + Sync {
/// Returns "now" as epoch milliseconds.
fn now_millis(&self) -> i64;
}
/// Generates fresh UUIDs, abstracted for determinism in tests.
pub trait IdGenerator: Send + Sync {
/// Returns a fresh UUID.
fn new_uuid(&self) -> uuid::Uuid;
}

View File

@ -0,0 +1,133 @@
//! AI runtime profile and its context-injection strategy.
//!
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
//! port is parameterised by an [`AgentProfile`].
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::ProfileId;
/// Strategy for injecting an agent's `.md` context into the launched CLI.
///
/// Invariants:
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
/// - `Env.var` is a valid environment-variable identifier,
/// - `Flag.flag` is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum ContextInjection {
/// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
ConventionFile {
/// Relative target file name.
target: String,
},
/// Pass the context file path through a CLI flag.
Flag {
/// The flag template (e.g. `--context-file {path}` or `-f`).
flag: String,
},
/// Pipe the Markdown content on stdin.
Stdin,
/// Pass the context via an environment variable.
Env {
/// Environment variable name.
var: String,
},
}
impl ContextInjection {
/// Validated `ConventionFile` constructor.
///
/// # Errors
/// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
/// contains `..`.
pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::ConventionFile { target })
}
/// Validated `Flag` constructor.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `flag` is empty.
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
let flag = flag.into();
crate::validation::non_empty(&flag, "contextInjection.flag")?;
Ok(Self::Flag { flag })
}
/// `Stdin` constructor (no validation needed).
#[must_use]
pub const fn stdin() -> Self {
Self::Stdin
}
/// Validated `Env` constructor.
///
/// # Errors
/// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
let var = var.into();
crate::validation::valid_env_var(&var)?;
Ok(Self::Env { var })
}
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
/// - `name` and `command` non-empty,
/// - `context_injection` is itself valid (guaranteed by its constructors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
/// Stable identifier.
pub id: ProfileId,
/// Display name.
pub name: String,
/// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
pub command: String,
/// Static arguments.
pub args: Vec<String>,
/// Context-injection strategy.
pub context_injection: ContextInjection,
/// Optional detection command (e.g. `claude --version`).
pub detect: Option<String>,
/// Working-directory template (e.g. `"{projectRoot}"`).
pub cwd_template: String,
}
impl AgentProfile {
/// Builds a validated profile.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
#[allow(clippy::too_many_arguments)]
pub fn new(
id: ProfileId,
name: impl Into<String>,
command: impl Into<String>,
args: Vec<String>,
context_injection: ContextInjection,
detect: Option<String>,
cwd_template: impl Into<String>,
) -> Result<Self, DomainError> {
let name = name.into();
let command = command.into();
let cwd_template = cwd_template.into();
crate::validation::non_empty(&name, "profile.name")?;
crate::validation::non_empty(&command, "profile.command")?;
Ok(Self {
id,
name,
command,
args,
context_injection,
detect,
cwd_template,
})
}
}

View File

@ -0,0 +1,115 @@
//! 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<String>) -> Result<Self, DomainError> {
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<String>,
root: ProjectPath,
remote: RemoteRef,
created_at: i64,
) -> Result<Self, DomainError> {
let name = name.into();
crate::validation::non_empty(&name, "project.name")?;
Ok(Self {
id,
name,
root,
remote,
created_at,
})
}
}

128
crates/domain/src/remote.rs Normal file
View File

@ -0,0 +1,128 @@
//! Remote-location value objects: the strategy that locates where a project's
//! filesystem and processes actually live (local, SSH, or WSL).
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
/// SSH authentication strategy.
///
/// The domain only models *which* strategy is selected; resolving keys,
/// agents or known-hosts is an infrastructure concern.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SshAuth {
/// Use the running SSH agent.
Agent,
/// Use a private-key file at the given (remote-machine-agnostic) path.
Key {
/// Path to the private key on the local machine.
path: String,
},
/// Interactive / stored password authentication.
Password,
}
/// Strategy describing where a project is physically located and executed.
///
/// Invariants:
/// - `Ssh.port` ∈ `1..=65535`,
/// - `Ssh.host` / `Ssh.user` / `Ssh.remote_root` non-empty,
/// - `Wsl.distro` non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum RemoteRef {
/// The local machine.
Local,
/// A remote host reached over SSH.
#[serde(rename_all = "camelCase")]
Ssh {
/// Hostname or IP.
host: String,
/// TCP port (`1..=65535`).
port: u16,
/// Remote user.
user: String,
/// Authentication strategy.
auth: SshAuth,
/// Absolute root path on the remote machine.
remote_root: String,
},
/// A Windows Subsystem for Linux distribution.
Wsl {
/// Distribution name (e.g. `Ubuntu-22.04`).
distro: String,
},
}
impl RemoteRef {
/// Convenience constructor for the local machine.
#[must_use]
pub const fn local() -> Self {
Self::Local
}
/// Validated constructor for an SSH remote.
///
/// # Errors
/// Returns [`DomainError`] if the port is `0`, or if `host`, `user` or
/// `remote_root` is empty.
pub fn ssh(
host: impl Into<String>,
port: u16,
user: impl Into<String>,
auth: SshAuth,
remote_root: impl Into<String>,
) -> Result<Self, DomainError> {
let host = host.into();
let user = user.into();
let remote_root = remote_root.into();
crate::validation::non_empty(&host, "ssh.host")?;
crate::validation::non_empty(&user, "ssh.user")?;
crate::validation::non_empty(&remote_root, "ssh.remote_root")?;
// u16 already bounds the upper end; only port 0 is invalid.
if port == 0 {
return Err(DomainError::InvalidPort { port: 0 });
}
Ok(Self::Ssh {
host,
port,
user,
auth,
remote_root,
})
}
/// Validated constructor for a WSL remote.
///
/// # Errors
/// Returns [`DomainError`] if `distro` is empty.
pub fn wsl(distro: impl Into<String>) -> Result<Self, DomainError> {
let distro = distro.into();
crate::validation::non_empty(&distro, "wsl.distro")?;
Ok(Self::Wsl { distro })
}
/// Returns the coarse kind of this remote, for adapter selection.
#[must_use]
pub fn kind(&self) -> RemoteKind {
match self {
Self::Local => RemoteKind::Local,
Self::Ssh { .. } => RemoteKind::Ssh,
Self::Wsl { .. } => RemoteKind::Wsl,
}
}
}
/// Coarse discriminant used by the [`crate::ports::RemoteHost`] port to advertise
/// its kind without exposing connection details.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RemoteKind {
/// Local execution.
Local,
/// SSH remote.
Ssh,
/// WSL distribution.
Wsl,
}

View File

@ -0,0 +1,92 @@
//! Agent templates (global IDE store) and their monotonic versioning.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::{ProfileId, TemplateId};
use crate::markdown::MarkdownDoc;
/// Monotonically increasing template version.
///
/// Invariant (enforced via [`TemplateVersion::next`]): a version only ever
/// increases by one when the template content changes (ARCHITECTURE §8.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TemplateVersion(pub u64);
impl TemplateVersion {
/// The initial version assigned to a freshly created template.
pub const INITIAL: Self = Self(1);
/// Returns the next version (current + 1).
#[must_use]
pub const fn next(self) -> Self {
Self(self.0 + 1)
}
/// Returns the raw version number.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl Default for TemplateVersion {
fn default() -> Self {
Self::INITIAL
}
}
/// A reusable agent template stored in the global IDE store.
///
/// Invariants:
/// - `name` non-empty,
/// - `version` is monotonic; bumping is done via [`AgentTemplate::with_updated_content`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentTemplate {
/// Stable identifier.
pub id: TemplateId,
/// Display name.
pub name: String,
/// Markdown content.
pub content_md: MarkdownDoc,
/// Current version.
pub version: TemplateVersion,
/// Default runtime profile for agents created from this template.
pub default_profile_id: ProfileId,
}
impl AgentTemplate {
/// Builds a validated template at [`TemplateVersion::INITIAL`].
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `name` is empty.
pub fn new(
id: TemplateId,
name: impl Into<String>,
content_md: MarkdownDoc,
default_profile_id: ProfileId,
) -> Result<Self, DomainError> {
let name = name.into();
crate::validation::non_empty(&name, "template.name")?;
Ok(Self {
id,
name,
content_md,
version: TemplateVersion::INITIAL,
default_profile_id,
})
}
/// Returns a copy of this template with new content and the version bumped
/// by one, preserving the monotonic-version invariant.
#[must_use]
pub fn with_updated_content(&self, content_md: MarkdownDoc) -> Self {
Self {
content_md,
version: self.version.next(),
..self.clone()
}
}
}

View File

@ -0,0 +1,105 @@
//! Terminal session entity and supporting value objects.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::{AgentId, NodeId, SessionId};
use crate::project::ProjectPath;
/// Dimensions of a pseudo-terminal, in character cells.
///
/// Invariant: `rows > 0 && cols > 0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PtySize {
/// Number of rows.
pub rows: u16,
/// Number of columns.
pub cols: u16,
}
impl PtySize {
/// Builds a validated PTY size.
///
/// # Errors
/// Returns [`DomainError::InvalidPtySize`] if either dimension is zero.
pub fn new(rows: u16, cols: u16) -> Result<Self, DomainError> {
if rows == 0 || cols == 0 {
return Err(DomainError::InvalidPtySize { rows, cols });
}
Ok(Self { rows, cols })
}
}
/// What a terminal session is running.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SessionKind {
/// A plain shell terminal.
Plain,
/// A terminal launched for a specific agent.
#[serde(rename_all = "camelCase")]
Agent {
/// The agent driving this session.
agent_id: AgentId,
},
}
/// Lifecycle status of a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum SessionStatus {
/// Spawn requested, not yet confirmed running.
Starting,
/// Running.
Running,
/// Exited with a code.
Exited {
/// Process exit code.
code: i32,
},
}
/// A terminal session hosted by a layout leaf cell.
///
/// Invariants:
/// - `pty_size` is valid (`rows>0 && cols>0`),
/// - "at most one active session per leaf" is a *layout* invariant enforced in
/// [`crate::layout`], not here (a session does not own the leaf).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminalSession {
/// Stable identifier.
pub id: SessionId,
/// Layout leaf hosting this session.
pub node_id: NodeId,
/// Working directory.
pub cwd: ProjectPath,
/// What the session runs.
pub kind: SessionKind,
/// Current terminal size.
pub pty_size: PtySize,
/// Lifecycle status.
pub status: SessionStatus,
}
impl TerminalSession {
/// Builds a terminal session (in `Starting` state).
#[must_use]
pub fn starting(
id: SessionId,
node_id: NodeId,
cwd: ProjectPath,
kind: SessionKind,
pty_size: PtySize,
) -> Self {
Self {
id,
node_id,
cwd,
kind,
pty_size,
status: SessionStatus::Starting,
}
}
}

View File

@ -0,0 +1,59 @@
//! Small pure validation helpers shared across value objects.
use crate::error::DomainError;
/// Returns the trimmed string if non-empty, otherwise [`DomainError::EmptyField`].
pub(crate) fn non_empty(value: &str, field: &'static str) -> Result<(), DomainError> {
if value.trim().is_empty() {
Err(DomainError::EmptyField { field })
} else {
Ok(())
}
}
/// Validates that `var` is a syntactically valid environment-variable identifier:
/// non-empty, starts with a letter or `_`, and contains only ASCII alphanumeric
/// characters or `_`.
pub(crate) fn valid_env_var(var: &str) -> Result<(), DomainError> {
let invalid = || DomainError::InvalidEnvVar {
value: var.to_string(),
};
let mut chars = var.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return Err(invalid()),
}
if chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
Ok(())
} else {
Err(invalid())
}
}
/// Validates that a path is relative and does not escape its root via `..`.
///
/// Used for [`crate::profile::ContextInjection::ConventionFile`] targets and
/// manifest `.md` paths, which must stay inside the project / `.ideai/` tree.
pub(crate) fn relative_safe(path: &str) -> Result<(), DomainError> {
let err = || DomainError::PathNotRelativeSafe {
path: path.to_string(),
};
if path.is_empty() {
return Err(err());
}
// Reject absolute POSIX paths and Windows drive / UNC paths.
let bytes = path.as_bytes();
if bytes[0] == b'/' || bytes[0] == b'\\' {
return Err(err());
}
if path.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
return Err(err());
}
// Reject any `..` traversal component (handle both separators).
for component in path.split(['/', '\\']) {
if component == ".." {
return Err(err());
}
}
Ok(())
}

View File

@ -0,0 +1,104 @@
//! Validation of the four `ContextInjection` variants (ARCHITECTURE §3.2).
use domain::{ContextInjection, DomainError};
// --- ConventionFile -------------------------------------------------------
#[test]
fn convention_file_relative_ok() {
let ci = ContextInjection::convention_file("CLAUDE.md").unwrap();
assert!(matches!(ci, ContextInjection::ConventionFile { target } if target == "CLAUDE.md"));
}
#[test]
fn convention_file_nested_relative_ok() {
assert!(ContextInjection::convention_file("docs/AGENTS.md").is_ok());
}
#[test]
fn convention_file_rejects_absolute() {
assert!(matches!(
ContextInjection::convention_file("/etc/CLAUDE.md").unwrap_err(),
DomainError::PathNotRelativeSafe { .. }
));
}
#[test]
fn convention_file_rejects_dotdot() {
assert!(matches!(
ContextInjection::convention_file("../CLAUDE.md").unwrap_err(),
DomainError::PathNotRelativeSafe { .. }
));
}
#[test]
fn convention_file_rejects_windows_drive() {
assert!(matches!(
ContextInjection::convention_file("C:\\CLAUDE.md").unwrap_err(),
DomainError::PathNotRelativeSafe { .. }
));
}
// --- Flag -----------------------------------------------------------------
#[test]
fn flag_non_empty_ok() {
let ci = ContextInjection::flag("--context-file {path}").unwrap();
assert!(matches!(ci, ContextInjection::Flag { flag } if flag == "--context-file {path}"));
}
#[test]
fn flag_rejects_empty() {
assert!(matches!(
ContextInjection::flag("").unwrap_err(),
DomainError::EmptyField { .. }
));
assert!(matches!(
ContextInjection::flag(" ").unwrap_err(),
DomainError::EmptyField { .. }
));
}
// --- Stdin ----------------------------------------------------------------
#[test]
fn stdin_variant() {
assert_eq!(ContextInjection::stdin(), ContextInjection::Stdin);
}
// --- Env ------------------------------------------------------------------
#[test]
fn env_valid_identifier_ok() {
assert!(ContextInjection::env("AGENT_CONTEXT_FILE").is_ok());
assert!(ContextInjection::env("_private").is_ok());
assert!(ContextInjection::env("X1").is_ok());
}
#[test]
fn env_rejects_leading_digit() {
assert!(matches!(
ContextInjection::env("1BAD").unwrap_err(),
DomainError::InvalidEnvVar { .. }
));
}
#[test]
fn env_rejects_invalid_chars() {
assert!(matches!(
ContextInjection::env("BAD-VAR").unwrap_err(),
DomainError::InvalidEnvVar { .. }
));
assert!(matches!(
ContextInjection::env("HAS SPACE").unwrap_err(),
DomainError::InvalidEnvVar { .. }
));
}
#[test]
fn env_rejects_empty() {
assert!(matches!(
ContextInjection::env("").unwrap_err(),
DomainError::InvalidEnvVar { .. }
));
}

View File

@ -0,0 +1,411 @@
//! Entity & value-object invariant tests: valid construction plus the expected
//! rejections (ARCHITECTURE §3.2).
mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth,
TemplateId, TemplateVersion,
};
use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid;
fn profile_id() -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(42))
}
fn template_id() -> TemplateId {
TemplateId::from_uuid(Uuid::from_u128(7))
}
// ---------------------------------------------------------------------------
// ProjectPath
// ---------------------------------------------------------------------------
#[test]
fn project_path_accepts_posix_absolute() {
assert!(ProjectPath::new("/home/user/proj").is_ok());
}
#[test]
fn project_path_accepts_windows_drive_and_unc() {
assert!(ProjectPath::new("C:\\Users\\x").is_ok());
assert!(ProjectPath::new("C:/Users/x").is_ok());
assert!(ProjectPath::new("\\\\server\\share").is_ok());
}
#[test]
fn project_path_accepts_wsl_mount() {
assert!(ProjectPath::new("/mnt/c/code").is_ok());
}
#[test]
fn project_path_rejects_relative() {
let err = ProjectPath::new("relative/path").unwrap_err();
assert!(matches!(err, DomainError::PathNotAbsolute { .. }));
}
#[test]
fn project_path_rejects_empty() {
let err = ProjectPath::new("").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// Project (also exercises the Clock/IdGenerator port fakes for determinism)
// ---------------------------------------------------------------------------
#[test]
fn project_valid_with_fixed_clock_and_seq_ids() {
use domain::{ports::Clock, ports::IdGenerator, ProjectId};
let clock = FixedClock(1_700_000_000_000);
let ids = AtomicSeqIdGenerator::new();
let id = ProjectId::from_uuid(ids.new_uuid());
let p = Project::new(
id,
"demo",
ProjectPath::new("/srv/demo").unwrap(),
RemoteRef::local(),
clock.now_millis(),
)
.unwrap();
assert_eq!(p.created_at, 1_700_000_000_000);
assert_eq!(p.id.as_uuid(), Uuid::from_u128(1));
}
#[test]
fn project_rejects_empty_name() {
let err = Project::new(
domain::ProjectId::from_uuid(Uuid::nil()),
" ",
ProjectPath::new("/x").unwrap(),
RemoteRef::local(),
0,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// Agent invariants
// ---------------------------------------------------------------------------
#[test]
fn agent_scratch_not_synchronized_is_ok() {
let a = Agent::new(
domain::AgentId::from_uuid(Uuid::from_u128(1)),
"scratch",
"agents/foo.md",
profile_id(),
AgentOrigin::Scratch,
false,
);
assert!(a.is_ok());
}
#[test]
fn agent_from_template_synchronized_is_ok() {
let a = Agent::new(
domain::AgentId::from_uuid(Uuid::from_u128(1)),
"tpl",
"agents/foo.md",
profile_id(),
AgentOrigin::FromTemplate {
template_id: template_id(),
synced_template_version: TemplateVersion::INITIAL,
},
true,
);
assert!(a.is_ok());
}
#[test]
fn agent_synchronized_without_template_is_rejected() {
let err = Agent::new(
domain::AgentId::from_uuid(Uuid::from_u128(1)),
"bad",
"agents/foo.md",
profile_id(),
AgentOrigin::Scratch,
true,
)
.unwrap_err();
assert_eq!(err, DomainError::SyncRequiresTemplate);
}
#[test]
fn agent_rejects_absolute_context_path() {
let err = Agent::new(
domain::AgentId::from_uuid(Uuid::from_u128(1)),
"x",
"/etc/passwd",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
#[test]
fn agent_rejects_dotdot_context_path() {
let err = Agent::new(
domain::AgentId::from_uuid(Uuid::from_u128(1)),
"x",
"agents/../../secret.md",
profile_id(),
AgentOrigin::Scratch,
false,
)
.unwrap_err();
assert!(matches!(err, DomainError::PathNotRelativeSafe { .. }));
}
// ---------------------------------------------------------------------------
// AgentProfile: command non-empty
// ---------------------------------------------------------------------------
fn ci_stdin() -> ContextInjection {
ContextInjection::stdin()
}
#[test]
fn profile_valid() {
let p = AgentProfile::new(
profile_id(),
"Claude",
"claude",
vec!["--yolo".into()],
ci_stdin(),
Some("claude --version".into()),
"{projectRoot}",
);
assert!(p.is_ok());
}
#[test]
fn profile_rejects_empty_command() {
let err = AgentProfile::new(
profile_id(),
"Name",
"",
vec![],
ci_stdin(),
None,
"{projectRoot}",
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command"));
}
#[test]
fn profile_rejects_empty_name() {
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}")
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
}
// ---------------------------------------------------------------------------
// RemoteRef invariants
// ---------------------------------------------------------------------------
#[test]
fn ssh_valid() {
let r = RemoteRef::ssh("host", 22, "me", SshAuth::Agent, "/srv");
assert!(r.is_ok());
assert_eq!(r.unwrap().kind(), domain::RemoteKind::Ssh);
}
#[test]
fn ssh_port_zero_rejected() {
let err = RemoteRef::ssh("host", 0, "me", SshAuth::Agent, "/srv").unwrap_err();
assert!(matches!(err, DomainError::InvalidPort { port: 0 }));
}
#[test]
fn ssh_max_port_accepted() {
// 65535 is the upper bound of the 1..=65535 range; u16 prevents anything higher.
assert!(RemoteRef::ssh("h", 65535, "u", SshAuth::Password, "/r").is_ok());
}
#[test]
fn ssh_rejects_empty_host_user_root() {
assert!(RemoteRef::ssh("", 22, "u", SshAuth::Agent, "/r").is_err());
assert!(RemoteRef::ssh("h", 22, "", SshAuth::Agent, "/r").is_err());
assert!(RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "").is_err());
}
#[test]
fn wsl_valid() {
assert!(RemoteRef::wsl("Ubuntu-22.04").is_ok());
}
#[test]
fn wsl_empty_distro_rejected() {
let err = RemoteRef::wsl("").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// PtySize invariants
// ---------------------------------------------------------------------------
#[test]
fn pty_size_valid() {
assert!(PtySize::new(24, 80).is_ok());
}
#[test]
fn pty_size_zero_rows_rejected() {
assert!(matches!(
PtySize::new(0, 80).unwrap_err(),
DomainError::InvalidPtySize { rows: 0, cols: 80 }
));
}
#[test]
fn pty_size_zero_cols_rejected() {
assert!(matches!(
PtySize::new(24, 0).unwrap_err(),
DomainError::InvalidPtySize { rows: 24, cols: 0 }
));
}
// ---------------------------------------------------------------------------
// AgentTemplate version monotonicity (via with_updated_content / next)
// ---------------------------------------------------------------------------
#[test]
fn template_starts_at_initial() {
let t = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
assert_eq!(t.version, TemplateVersion::INITIAL);
assert_eq!(t.version.get(), 1);
}
#[test]
fn template_update_bumps_version_monotonically() {
let t0 = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap();
let t1 = t0.with_updated_content(MarkdownDoc::new("b"));
let t2 = t1.with_updated_content(MarkdownDoc::new("c"));
assert!(t1.version > t0.version);
assert!(t2.version > t1.version);
assert_eq!(t2.version.get(), 3);
assert_eq!(t2.content_md.as_str(), "c");
// id and profile preserved.
assert_eq!(t2.id, t0.id);
assert_eq!(t2.default_profile_id, t0.default_profile_id);
}
#[test]
fn template_version_next_increments() {
assert_eq!(TemplateVersion(5).next(), TemplateVersion(6));
}
#[test]
fn template_rejects_empty_name() {
let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// ManifestEntry / AgentManifest invariants
// ---------------------------------------------------------------------------
fn agent_id(n: u128) -> domain::AgentId {
domain::AgentId::from_uuid(Uuid::from_u128(n))
}
#[test]
fn manifest_entry_synchronized_requires_template_metadata() {
let err =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None)
.unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
// template id present but version missing → still rejected.
let err = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
Some(template_id()),
true,
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}
#[test]
fn manifest_entry_rejects_empty_name() {
let err =
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
#[test]
fn manifest_entry_synchronized_with_metadata_ok() {
assert!(ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
Some(template_id()),
true,
Some(TemplateVersion::INITIAL)
)
.is_ok());
}
#[test]
fn manifest_entry_rejects_absolute_md_path() {
assert!(matches!(
ManifestEntry::new(agent_id(1), "A", "/abs.md", profile_id(), None, false, None)
.unwrap_err(),
DomainError::PathNotRelativeSafe { .. }
));
}
#[test]
fn manifest_entry_agent_roundtrip() {
// from_agent ∘ to_agent preserves a template-backed, synchronized agent.
let agent = Agent::new(
agent_id(9),
"Backend",
"agents/backend.md",
profile_id(),
AgentOrigin::FromTemplate {
template_id: template_id(),
synced_template_version: TemplateVersion(4),
},
true,
)
.unwrap();
let entry = ManifestEntry::from_agent(&agent);
assert_eq!(entry.to_agent().unwrap(), agent);
}
#[test]
fn manifest_rejects_duplicate_md_path() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}
#[test]
fn manifest_unique_md_paths_ok() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None)
.unwrap();
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
}

View File

@ -0,0 +1,92 @@
//! Shared test helpers: deterministic fakes for the `Clock` / `IdGenerator`
//! ports, plus small id constructors. Kept in `tests/` so they never ship in
//! the crate proper.
#![allow(dead_code)]
use std::cell::Cell;
use domain::ports::{Clock, IdGenerator};
use uuid::Uuid;
/// A clock that always returns the same fixed millisecond value.
pub struct FixedClock(pub i64);
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
/// An id generator producing a deterministic, monotonically increasing sequence
/// of UUIDs (`0000...0001`, `0000...0002`, ...).
pub struct SeqIdGenerator {
next: Cell<u128>,
}
impl SeqIdGenerator {
#[must_use]
pub fn new() -> Self {
Self { next: Cell::new(1) }
}
}
impl Default for SeqIdGenerator {
fn default() -> Self {
Self::new()
}
}
// `IdGenerator` only requires `&self`, so we use a `Cell` for interior
// mutability. The trait demands `Send + Sync`; `Cell` is `!Sync`, so for the
// test fake we wrap calls behind `&self` single-threaded usage only.
// To satisfy the bound we instead expose a plain method used directly in tests.
impl SeqIdGenerator {
/// Returns the next UUID in the deterministic sequence.
pub fn next_uuid(&self) -> Uuid {
let n = self.next.get();
self.next.set(n + 1);
Uuid::from_u128(n)
}
}
/// A `Send + Sync` deterministic id generator suitable for the `IdGenerator`
/// port (uses an atomic counter).
pub struct AtomicSeqIdGenerator {
next: std::sync::atomic::AtomicU64,
}
impl AtomicSeqIdGenerator {
#[must_use]
pub fn new() -> Self {
Self {
next: std::sync::atomic::AtomicU64::new(1),
}
}
}
impl Default for AtomicSeqIdGenerator {
fn default() -> Self {
Self::new()
}
}
impl IdGenerator for AtomicSeqIdGenerator {
fn new_uuid(&self) -> Uuid {
let n = self
.next
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Uuid::from_u128(u128::from(n))
}
}
/// Builds a `NodeId` from a small integer (handy, readable test ids).
#[must_use]
pub fn node(n: u128) -> domain::NodeId {
domain::NodeId::from_uuid(Uuid::from_u128(n))
}
/// Builds a `SessionId` from a small integer.
#[must_use]
pub fn session(n: u128) -> domain::SessionId {
domain::SessionId::from_uuid(Uuid::from_u128(n))
}

View File

@ -0,0 +1,495 @@
//! Pure layout logic: split / merge / resize / move_session, nominal and error
//! paths, grid validation, and immutability of the source tree (ARCHITECTURE §7).
mod helpers;
use domain::{
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
SplitContainer, WeightedChild,
};
use domain::ids::AgentId;
use helpers::{node, session};
fn agent_id(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn leaf(id: u128, sess: Option<u128>) -> LeafCell {
LeafCell {
id: node(id),
session: sess.map(session),
agent: None,
}
}
fn single(id: u128, sess: Option<u128>) -> LayoutTree {
LayoutTree::single(leaf(id, sess))
}
// ---------------------------------------------------------------------------
// split
// ---------------------------------------------------------------------------
#[test]
fn split_nominal_produces_two_children() {
let tree = single(1, Some(100));
let out = tree
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
match out.root {
LayoutNode::Split(s) => {
assert_eq!(s.id, node(9));
assert_eq!(s.direction, Direction::Row);
assert_eq!(s.children.len(), 2);
assert!(s.children.iter().all(|c| c.weight > 0.0));
}
_ => panic!("expected a split at the root"),
}
}
#[test]
fn split_is_immutable_source_unchanged() {
let tree = single(1, Some(100));
let before = tree.clone();
let _ = tree
.split(node(1), Direction::Column, leaf(2, None), node(9))
.unwrap();
assert_eq!(tree, before, "source tree must not be mutated");
}
#[test]
fn split_missing_target_is_node_not_found() {
let tree = single(1, None);
let err = tree
.split(node(404), Direction::Row, leaf(2, None), node(9))
.unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn split_into_a_session_already_present_elsewhere_is_duplicate() {
// root leaf 1 holds session 100; we split it adding a NEW leaf that reuses
// the same session id → duplicate must be rejected by validation.
let tree = single(1, Some(100));
let err = tree
.split(node(1), Direction::Row, leaf(2, Some(100)), node(9))
.unwrap_err();
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
}
// ---------------------------------------------------------------------------
// merge
// ---------------------------------------------------------------------------
#[test]
fn merge_keeps_selected_child() {
let tree = single(1, Some(100))
.split(node(1), Direction::Row, leaf(2, Some(200)), node(9))
.unwrap();
// keep index 1 (the new leaf with session 200).
let merged = tree.merge(node(9), 1).unwrap();
assert_eq!(merged.root, LayoutNode::Leaf(leaf(2, Some(200))));
}
#[test]
fn merge_is_immutable() {
let tree = single(1, Some(100))
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let before = tree.clone();
let _ = tree.merge(node(9), 0).unwrap();
assert_eq!(tree, before);
}
#[test]
fn merge_unknown_container_is_node_not_found() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let err = tree.merge(node(404), 0).unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn merge_index_out_of_range_is_cross_container() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let err = tree.merge(node(9), 5).unwrap_err();
assert_eq!(err, LayoutError::CrossContainer);
}
// ---------------------------------------------------------------------------
// resize
// ---------------------------------------------------------------------------
#[test]
fn resize_nominal_updates_weights() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let out = tree.resize(node(9), &[2.0, 3.0]).unwrap();
match out.root {
LayoutNode::Split(s) => {
assert_eq!(s.children[0].weight, 2.0);
assert_eq!(s.children[1].weight, 3.0);
}
_ => panic!("expected split"),
}
}
#[test]
fn resize_is_immutable() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let before = tree.clone();
let _ = tree.resize(node(9), &[2.0, 3.0]).unwrap();
assert_eq!(tree, before);
}
#[test]
fn resize_nonpositive_weight_rejected() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let err = tree.resize(node(9), &[0.0, 1.0]).unwrap_err();
assert_eq!(err, LayoutError::NonPositiveWeight { weight: 0.0 });
let err = tree.resize(node(9), &[-1.0, 1.0]).unwrap_err();
assert_eq!(err, LayoutError::NonPositiveWeight { weight: -1.0 });
}
#[test]
fn resize_wrong_arity_is_cross_container() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let err = tree.resize(node(9), &[1.0, 2.0, 3.0]).unwrap_err();
assert_eq!(err, LayoutError::CrossContainer);
}
#[test]
fn resize_unknown_container_is_node_not_found() {
let tree = single(1, None)
.split(node(1), Direction::Row, leaf(2, None), node(9))
.unwrap();
let err = tree.resize(node(404), &[1.0, 2.0]).unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
// ---------------------------------------------------------------------------
// move_session
// ---------------------------------------------------------------------------
fn two_leaves(from_sess: Option<u128>, to_sess: Option<u128>) -> LayoutTree {
LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(9),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(leaf(1, from_sess)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(leaf(2, to_sess)),
weight: 1.0,
},
],
}))
}
/// Looks up the session held by the leaf `id` in a tree (test-only helper that
/// walks the public structure, since the domain's lookup is private).
fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option<domain::SessionId> {
fn walk(n: &LayoutNode, id: domain::NodeId) -> Option<Option<domain::SessionId>> {
match n {
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
}
}
walk(&tree.root, id).flatten()
}
#[test]
fn move_session_nominal() {
let tree = two_leaves(Some(100), None);
let out = tree.move_session(node(1), node(2)).unwrap();
assert_eq!(session_for(&out, node(1)), None);
assert_eq!(session_for(&out, node(2)), Some(session(100)));
}
#[test]
fn move_session_is_immutable() {
let tree = two_leaves(Some(100), None);
let before = tree.clone();
let _ = tree.move_session(node(1), node(2)).unwrap();
assert_eq!(tree, before);
}
#[test]
fn move_session_from_empty_rejected() {
let tree = two_leaves(None, None);
let err = tree.move_session(node(1), node(2)).unwrap_err();
assert_eq!(err, LayoutError::CrossContainer);
}
#[test]
fn move_session_to_occupied_rejected() {
let tree = two_leaves(Some(100), Some(200));
let err = tree.move_session(node(1), node(2)).unwrap_err();
assert_eq!(err, LayoutError::CrossContainer);
}
#[test]
fn move_session_missing_leaf_is_node_not_found() {
let tree = two_leaves(Some(100), None);
assert_eq!(
tree.move_session(node(404), node(2)).unwrap_err(),
LayoutError::NodeNotFound(node(404))
);
assert_eq!(
tree.move_session(node(1), node(404)).unwrap_err(),
LayoutError::NodeNotFound(node(404))
);
}
// ---------------------------------------------------------------------------
// validate(): grid invariants & duplicate sessions
// ---------------------------------------------------------------------------
fn grid(col_w: Vec<f32>, row_w: Vec<f32>, cells: Vec<GridCell>) -> LayoutTree {
LayoutTree::new(LayoutNode::Grid(GridContainer {
id: node(50),
col_weights: col_w,
row_weights: row_w,
cells,
}))
}
fn gcell(id: u128, row: u16, col: u16, rs: u16, cs: u16) -> GridCell {
GridCell {
node: LayoutNode::Leaf(leaf(id, None)),
row,
col,
row_span: rs,
col_span: cs,
}
}
#[test]
fn grid_fully_covered_2x2_ok() {
let cells = vec![
gcell(1, 0, 0, 1, 1),
gcell(2, 0, 1, 1, 1),
gcell(3, 1, 0, 1, 1),
gcell(4, 1, 1, 1, 1),
];
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
assert!(t.validate().is_ok());
}
#[test]
fn grid_merged_span_full_coverage_ok() {
// one cell spanning the whole top row + two cells on the bottom row.
let cells = vec![
gcell(1, 0, 0, 1, 2), // top row merged
gcell(2, 1, 0, 1, 1),
gcell(3, 1, 1, 1, 1),
];
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
assert!(t.validate().is_ok());
}
#[test]
fn grid_overlap_rejected() {
let cells = vec![
gcell(1, 0, 0, 1, 2),
gcell(2, 0, 1, 1, 1), // overlaps column 1 of the spanning cell
gcell(3, 1, 0, 1, 1),
gcell(4, 1, 1, 1, 1),
];
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells);
assert!(matches!(
t.validate().unwrap_err(),
LayoutError::OverlappingCells { .. }
));
}
#[test]
fn grid_uncovered_surface_rejected() {
// 2x2 grid but only one cell → three cells uncovered.
let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], vec![gcell(1, 0, 0, 1, 1)]);
assert!(matches!(
t.validate().unwrap_err(),
LayoutError::UncoveredCell { .. }
));
}
#[test]
fn grid_span_out_of_bounds_rejected() {
let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 2, 1)]);
assert!(matches!(
t.validate().unwrap_err(),
LayoutError::SpanOutOfBounds { .. }
));
}
#[test]
fn grid_zero_span_rejected() {
let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 0, 1)]);
assert_eq!(t.validate().unwrap_err(), LayoutError::InvalidSpan);
}
#[test]
fn grid_nonpositive_weight_rejected() {
let t = grid(vec![0.0], vec![1.0], vec![gcell(1, 0, 0, 1, 1)]);
assert_eq!(
t.validate().unwrap_err(),
LayoutError::NonPositiveWeight { weight: 0.0 }
);
}
#[test]
fn duplicate_session_across_leaves_rejected() {
let tree = two_leaves(Some(100), Some(100));
assert_eq!(
tree.validate().unwrap_err(),
LayoutError::DuplicateSession(session(100))
);
}
#[test]
fn empty_split_rejected() {
let t = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(9),
direction: Direction::Row,
children: vec![],
}));
assert_eq!(t.validate().unwrap_err(), LayoutError::EmptySplit);
}
// ---------------------------------------------------------------------------
// set_session (L4: cell ↔ terminal binding bridge)
// ---------------------------------------------------------------------------
#[test]
fn set_session_attaches_to_leaf() {
let tree = single(1, None);
let out = tree.set_session(node(1), Some(session(100))).unwrap();
match out.root {
LayoutNode::Leaf(l) => {
assert_eq!(l.id, node(1));
assert_eq!(l.session, Some(session(100)));
}
_ => panic!("expected a leaf at the root"),
}
}
#[test]
fn set_session_detaches_with_none() {
let tree = single(1, Some(100));
let out = tree.set_session(node(1), None).unwrap();
match out.root {
LayoutNode::Leaf(l) => assert_eq!(l.session, None),
_ => panic!("expected a leaf at the root"),
}
}
#[test]
fn set_session_is_immutable_source_unchanged() {
let tree = single(1, None);
let before = tree.clone();
let _ = tree.set_session(node(1), Some(session(100))).unwrap();
assert_eq!(tree, before, "source tree must not be mutated");
}
#[test]
fn set_session_reaches_nested_leaf() {
// Attach onto the second leaf of a split, leaving the first empty.
let tree = two_leaves(None, None);
let out = tree.set_session(node(2), Some(session(7))).unwrap();
match out.root {
LayoutNode::Split(s) => match &s.children[1].node {
LayoutNode::Leaf(l) => assert_eq!(l.session, Some(session(7))),
_ => panic!("expected a leaf child"),
},
_ => panic!("expected a split root"),
}
}
#[test]
fn set_session_missing_leaf_is_node_not_found() {
let tree = single(1, None);
let err = tree.set_session(node(404), Some(session(100))).unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn set_session_duplicate_across_leaves_rejected() {
// Leaf 1 already hosts session 100; attaching the same session to leaf 2
// must fail validation rather than producing a duplicate.
let tree = two_leaves(Some(100), None);
let err = tree.set_session(node(2), Some(session(100))).unwrap_err();
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
}
// ---------------------------------------------------------------------------
// set_cell_agent (#3: per-cell agent)
// ---------------------------------------------------------------------------
#[test]
fn set_cell_agent_attaches_agent_to_leaf() {
let tree = single(1, None);
let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap();
match out.root {
LayoutNode::Leaf(l) => {
assert_eq!(l.id, node(1));
assert_eq!(l.agent, Some(agent_id(42)));
}
_ => panic!("expected a leaf at root"),
}
}
#[test]
fn set_cell_agent_detaches_with_none() {
// First attach, then detach.
let tree = single(1, None);
let with_agent = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap();
let out = with_agent.set_cell_agent(node(1), None).unwrap();
match out.root {
LayoutNode::Leaf(l) => assert_eq!(l.agent, None),
_ => panic!("expected a leaf at root"),
}
}
#[test]
fn set_cell_agent_is_immutable_source_unchanged() {
let tree = single(1, None);
let before = tree.clone();
let _ = tree.set_cell_agent(node(1), Some(agent_id(99))).unwrap();
assert_eq!(tree, before, "source tree must not be mutated");
}
#[test]
fn set_cell_agent_missing_leaf_is_node_not_found() {
let tree = single(1, None);
let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn set_cell_agent_preserves_session() {
// Session must survive an agent attachment.
let tree = single(1, Some(100));
let out = tree.set_cell_agent(node(1), Some(agent_id(7))).unwrap();
match out.root {
LayoutNode::Leaf(l) => {
assert_eq!(l.session, Some(session(100)));
assert_eq!(l.agent, Some(agent_id(7)));
}
_ => panic!("expected leaf"),
}
}

View File

@ -0,0 +1,295 @@
//! JSON round-trip (serde) of persisted domain types, plus camelCase / tagging
//! checks (ARCHITECTURE §7.3, §9).
mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
SplitContainer, SshAuth, TemplateVersion, WeightedChild,
};
use helpers::{node, session};
use uuid::Uuid;
fn pid(n: u128) -> domain::ProjectId {
domain::ProjectId::from_uuid(Uuid::from_u128(n))
}
fn profid(n: u128) -> domain::ProfileId {
domain::ProfileId::from_uuid(Uuid::from_u128(n))
}
fn tid(n: u128) -> domain::TemplateId {
domain::TemplateId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> domain::AgentId {
domain::AgentId::from_uuid(Uuid::from_u128(n))
}
fn roundtrip<T>(value: &T) -> T
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let json = serde_json::to_string(value).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
// ---------------------------------------------------------------------------
// Project
// ---------------------------------------------------------------------------
#[test]
fn project_roundtrip() {
let p = Project::new(
pid(1),
"demo",
ProjectPath::new("/srv/demo").unwrap(),
RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(),
123,
)
.unwrap();
assert_eq!(roundtrip(&p), p);
}
#[test]
fn project_uses_camel_case_and_tagged_remote() {
let p = Project::new(
pid(1),
"demo",
ProjectPath::new("/srv/demo").unwrap(),
RemoteRef::local(),
123,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"createdAt\":123"), "json was {json}");
// RemoteRef tagged with `kind`, camelCased "local".
assert!(json.contains("\"kind\":\"local\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// RemoteRef variants
// ---------------------------------------------------------------------------
#[test]
fn remote_ssh_roundtrip_and_tags() {
let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap();
assert_eq!(roundtrip(&r), r);
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
// SshAuth tagged with `type`.
assert!(json.contains("\"type\":\"key\""), "json was {json}");
// Enum-variant fields must be camelCased on the wire (ARCHITECTURE §9).
assert!(json.contains("\"remoteRoot\""), "json was {json}");
assert!(!json.contains("\"remote_root\""), "json was {json}");
}
#[test]
fn remote_wsl_roundtrip() {
let r = RemoteRef::wsl("Ubuntu").unwrap();
assert_eq!(roundtrip(&r), r);
}
// ---------------------------------------------------------------------------
// AgentProfile + ContextInjection (tagged with `strategy`)
// ---------------------------------------------------------------------------
#[test]
fn profile_roundtrip_all_injection_variants() {
for ci in [
ContextInjection::convention_file("CLAUDE.md").unwrap(),
ContextInjection::flag("-f {path}").unwrap(),
ContextInjection::stdin(),
ContextInjection::env("CTX").unwrap(),
] {
let p = AgentProfile::new(
profid(1),
"Name",
"claude",
vec!["a".into(), "b".into()],
ci,
Some("claude --version".into()),
"{projectRoot}",
)
.unwrap();
assert_eq!(roundtrip(&p), p);
}
}
#[test]
fn context_injection_strategy_tag_is_camel_case() {
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap())
.unwrap();
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}");
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
}
#[test]
fn profile_cwd_template_is_camel_case() {
let p = AgentProfile::new(
profid(1),
"n",
"c",
vec![],
ContextInjection::stdin(),
None,
"{projectRoot}",
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"cwdTemplate\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// AgentTemplate
// ---------------------------------------------------------------------------
#[test]
fn template_roundtrip() {
let t = AgentTemplate::new(tid(1), "T", MarkdownDoc::new("# hi"), profid(2))
.unwrap()
.with_updated_content(MarkdownDoc::new("# bye"));
assert_eq!(roundtrip(&t), t);
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("\"contentMd\""), "json was {json}");
assert!(json.contains("\"defaultProfileId\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// Agent + manifest
// ---------------------------------------------------------------------------
#[test]
fn agent_roundtrip_from_template() {
let a = Agent::new(
aid(1),
"Backend",
"agents/backend.md",
profid(2),
AgentOrigin::FromTemplate {
template_id: tid(3),
synced_template_version: TemplateVersion(4),
},
true,
)
.unwrap();
assert_eq!(roundtrip(&a), a);
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contextPath\""), "json was {json}");
// AgentOrigin tagged with `type`, camelCased.
assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}");
// Inner fields must be camelCased per ARCHITECTURE §9.1:
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
assert!(json.contains("\"templateId\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}");
assert!(!json.contains("\"template_id\""), "json was {json}");
assert!(!json.contains("\"synced_template_version\""), "json was {json}");
assert!(!json.contains("\"synced_version\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// SessionKind (tagged enum: `type`, camelCased variant fields)
// ---------------------------------------------------------------------------
#[test]
fn session_kind_agent_roundtrip_and_camel_case() {
use domain::SessionKind;
let k = SessionKind::Agent { agent_id: aid(7) };
assert_eq!(roundtrip(&k), k);
let json = serde_json::to_string(&k).unwrap();
assert!(json.contains("\"type\":\"agent\""), "json was {json}");
assert!(json.contains("\"agentId\""), "json was {json}");
assert!(!json.contains("\"agent_id\""), "json was {json}");
// Plain variant carries no fields.
let plain = serde_json::to_string(&SessionKind::Plain).unwrap();
assert!(plain.contains("\"type\":\"plain\""), "json was {plain}");
}
#[test]
fn manifest_roundtrip_and_camel_case() {
let e1 = ManifestEntry::new(
aid(1),
"Alpha",
"agents/a.md",
profid(9),
Some(tid(2)),
true,
Some(TemplateVersion(5)),
)
.unwrap();
let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
assert_eq!(roundtrip(&m), m);
let json = serde_json::to_string(&m).unwrap();
// entries are serialized under "agents".
assert!(json.contains("\"agents\":["), "json was {json}");
assert!(json.contains("\"mdPath\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}");
// Non-synchronized entry omits optional template fields (skip_serializing_if).
assert!(!json.contains("\"templateId\":null"), "json was {json}");
}
// ---------------------------------------------------------------------------
// LayoutTree (tagged enum: type/node)
// ---------------------------------------------------------------------------
#[test]
fn layout_roundtrip() {
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(9),
direction: Direction::Column,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(1),
session: Some(session(100)),
agent: None,
}),
weight: 1.5,
},
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(2),
session: None,
agent: None,
}),
weight: 2.5,
},
],
}));
assert_eq!(roundtrip(&tree), tree);
let json = serde_json::to_string(&tree).unwrap();
// enum adjacently tagged: type + node ; direction camelCase.
assert!(json.contains("\"type\":\"split\""), "json was {json}");
assert!(json.contains("\"type\":\"leaf\""), "json was {json}");
assert!(json.contains("\"direction\":\"column\""), "json was {json}");
// empty session leaf omits the field.
assert!(!json.contains("\"session\":null"), "json was {json}");
}
#[test]
fn leaf_with_agent_roundtrip_and_omits_null() {
use domain::ids::AgentId;
let agent_uuid = Uuid::from_u128(0xABC);
let tree = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(1),
session: None,
agent: Some(AgentId::from_uuid(agent_uuid)),
}));
let rt = roundtrip(&tree);
match rt.root {
LayoutNode::Leaf(l) => assert_eq!(l.agent, Some(AgentId::from_uuid(agent_uuid))),
_ => panic!("expected leaf"),
}
let json = serde_json::to_string(&tree).unwrap();
// agent present when set
assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}");
// null variant omitted
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(2),
session: None,
agent: None,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");
}

View File

@ -0,0 +1,93 @@
//! L10 tests for the pure `Workspace::move_tab_to_new_window` operation
//! (ARCHITECTURE §10): a tab is *moved*, never duplicated; an emptied source
//! window is dropped; an active moved tab hands activity back to a sibling.
use domain::{
LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window,
WindowId, Workspace,
};
use uuid::Uuid;
fn tid(n: u128) -> TabId {
TabId::from_uuid(Uuid::from_u128(n))
}
fn wid(n: u128) -> WindowId {
WindowId::from_uuid(Uuid::from_u128(n))
}
fn leaf_tree() -> LayoutTree {
LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: NodeId::from_uuid(Uuid::from_u128(900)),
session: None,
agent: None,
}))
}
fn tab(n: u128) -> Tab {
Tab {
id: tid(n),
project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
layout: leaf_tree(),
}
}
/// Count how many windows contain a tab with the given id.
fn occurrences(ws: &Workspace, tab: TabId) -> usize {
ws.windows
.iter()
.filter(|w| w.tabs.iter().any(|t| t.id == tab))
.count()
}
#[test]
fn move_tab_from_multi_tab_window_keeps_source_and_creates_new() {
let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap();
let ws = Workspace { windows: vec![src] };
let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap();
assert_eq!(next.windows.len(), 2, "source kept + new window");
// The moved tab appears exactly once (moved, not duplicated).
assert_eq!(occurrences(&next, tid(1)), 1);
// Source window kept tab 2 and fell back its active tab to it.
let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap();
assert_eq!(source.tabs.len(), 1);
assert_eq!(source.active_tab, tid(2));
// New window holds the moved tab, active.
let detached = next.windows.iter().find(|w| w.id == wid(99)).unwrap();
assert_eq!(detached.tabs.len(), 1);
assert_eq!(detached.active_tab, tid(1));
}
#[test]
fn move_only_tab_removes_the_emptied_source_window() {
let src = Window::new(wid(1), vec![tab(1)], tid(1)).unwrap();
let ws = Workspace { windows: vec![src] };
let next = ws.move_tab_to_new_window(tid(1), wid(99)).unwrap();
assert_eq!(next.windows.len(), 1, "emptied source dropped");
assert_eq!(next.windows[0].id, wid(99));
assert_eq!(occurrences(&next, tid(1)), 1);
}
#[test]
fn move_unknown_tab_is_rejected() {
let ws = Workspace {
windows: vec![Window::new(wid(1), vec![tab(1)], tid(1)).unwrap()],
};
assert!(matches!(
ws.move_tab_to_new_window(tid(404), wid(99)).unwrap_err(),
LayoutError::TabNotFound(t) if t == tid(404)
));
}
#[test]
fn move_non_active_tab_leaves_source_active_unchanged() {
let src = Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap();
let ws = Workspace { windows: vec![src] };
let next = ws.move_tab_to_new_window(tid(2), wid(99)).unwrap();
let source = next.windows.iter().find(|w| w.id == wid(1)).unwrap();
assert_eq!(source.active_tab, tid(1), "active tab unchanged");
assert_eq!(source.tabs.len(), 1);
}