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:
16
crates/domain/Cargo.toml
Normal file
16
crates/domain/Cargo.toml
Normal 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
257
crates/domain/src/agent.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
71
crates/domain/src/error.rs
Normal file
71
crates/domain/src/error.rs
Normal 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),
|
||||
}
|
||||
79
crates/domain/src/events.rs
Normal file
79
crates/domain/src/events.rs
Normal 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
45
crates/domain/src/git.rs
Normal 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
90
crates/domain/src/ids.rs
Normal 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
692
crates/domain/src/layout.rs
Normal 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
88
crates/domain/src/lib.rs
Normal 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,
|
||||
};
|
||||
44
crates/domain/src/markdown.rs
Normal file
44
crates/domain/src/markdown.rs
Normal 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
663
crates/domain/src/ports.rs
Normal 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;
|
||||
}
|
||||
133
crates/domain/src/profile.rs
Normal file
133
crates/domain/src/profile.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
115
crates/domain/src/project.rs
Normal file
115
crates/domain/src/project.rs
Normal 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
128
crates/domain/src/remote.rs
Normal 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,
|
||||
}
|
||||
92
crates/domain/src/template.rs
Normal file
92
crates/domain/src/template.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
105
crates/domain/src/terminal.rs
Normal file
105
crates/domain/src/terminal.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/domain/src/validation.rs
Normal file
59
crates/domain/src/validation.rs
Normal 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(())
|
||||
}
|
||||
104
crates/domain/tests/context_injection.rs
Normal file
104
crates/domain/tests/context_injection.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
411
crates/domain/tests/entities.rs
Normal file
411
crates/domain/tests/entities.rs
Normal 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());
|
||||
}
|
||||
92
crates/domain/tests/helpers/mod.rs
Normal file
92
crates/domain/tests/helpers/mod.rs
Normal 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))
|
||||
}
|
||||
495
crates/domain/tests/layout.rs
Normal file
495
crates/domain/tests/layout.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
295
crates/domain/tests/serde_roundtrip.rs
Normal file
295
crates/domain/tests/serde_roundtrip.rs
Normal 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}");
|
||||
}
|
||||
93
crates/domain/tests/window.rs
Normal file
93
crates/domain/tests/window.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user