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:
19
crates/application/Cargo.toml
Normal file
19
crates/application/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "IdeA — application layer: use cases, DTOs, AppError. Depends only on domain ports."
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
93
crates/application/src/agent/catalogue.rs
Normal file
93
crates/application/src/agent/catalogue.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
|
||||
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
|
||||
//!
|
||||
//! These are **data, not domain code**: the catalogue lives in the application
|
||||
//! layer (a product decision about *which* AIs to suggest), built from the
|
||||
//! domain's validating constructors. Nothing is imposed — the user picks, edits
|
||||
//! the pre-filled commands, and may add custom profiles. The single
|
||||
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
|
||||
//!
|
||||
//! Reference set (CONTEXT §9):
|
||||
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
|
||||
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
|
||||
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
|
||||
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
|
||||
//!
|
||||
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
|
||||
//! so re-deriving the catalogue yields the same id for "claude" every time,
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
||||
|
||||
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
|
||||
#[must_use]
|
||||
fn reference_id(slug: &str) -> ProfileId {
|
||||
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
|
||||
}
|
||||
|
||||
/// Returns the stable id a reference profile slug maps to (exposed for tests and
|
||||
/// callers that need to address a reference profile).
|
||||
#[must_use]
|
||||
pub fn reference_profile_id(slug: &str) -> ProfileId {
|
||||
reference_id(slug)
|
||||
}
|
||||
|
||||
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
|
||||
///
|
||||
/// # Panics
|
||||
/// Never in practice: every literal here satisfies the domain invariants, so the
|
||||
/// constructors cannot fail; the `expect`s document that.
|
||||
#[must_use]
|
||||
pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
vec![
|
||||
AgentProfile::new(
|
||||
reference_id("claude"),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md")
|
||||
.expect("CLAUDE.md is a valid convention target"),
|
||||
Some("claude --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md")
|
||||
.expect("AGENTS.md is a valid convention target"),
|
||||
Some("codex --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
"gemini",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("GEMINI.md")
|
||||
.expect("GEMINI.md is a valid convention target"),
|
||||
Some("gemini --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("gemini reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
reference_id("aider"),
|
||||
"Aider",
|
||||
"aider",
|
||||
Vec::new(),
|
||||
ContextInjection::flag("--message-file {path}")
|
||||
.expect("aider flag template is non-empty"),
|
||||
Some("aider --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.expect("aider reference profile is valid"),
|
||||
]
|
||||
}
|
||||
538
crates/application/src/agent/lifecycle.rs
Normal file
538
crates/application/src/agent/lifecycle.rs
Normal file
@ -0,0 +1,538 @@
|
||||
//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
|
||||
//!
|
||||
//! These own the *project-agent* side (distinct from the profile side in
|
||||
//! [`super::usecases`]): creating agents and their `.md` contexts under
|
||||
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
|
||||
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
|
||||
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
|
||||
//! spawns the CLI.
|
||||
//!
|
||||
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
|
||||
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||
//! a concrete adapter or Tauri.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||
ProfileStore, PtyPort, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::TerminalSessions;
|
||||
|
||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||
const AGENTS_SUBDIR: &str = "agents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromScratch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateAgentFromScratch::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAgentInput {
|
||||
/// The project that owns the agent.
|
||||
pub project: Project,
|
||||
/// Display name of the agent.
|
||||
pub name: String,
|
||||
/// Runtime profile the agent launches with.
|
||||
pub profile_id: ProfileId,
|
||||
/// Initial `.md` content (empty when `None`).
|
||||
pub initial_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`CreateAgentFromScratch::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAgentOutput {
|
||||
/// The freshly-created agent.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
|
||||
/// path, records the manifest entry, then writes the (possibly empty) context.
|
||||
pub struct CreateAgentFromScratch {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn domain::ports::IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateAgentFromScratch {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn domain::ports::IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// Ordering matters: the manifest entry is persisted **before** the context
|
||||
/// is written, because [`AgentContextStore::write_context`] resolves the
|
||||
/// on-disk path from the manifest.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
|
||||
/// inconsistent,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
|
||||
let id = AgentId::from_uuid(self.ids.new_uuid());
|
||||
let md_path = unique_md_path(&input.name, &manifest);
|
||||
|
||||
let agent = Agent::new(
|
||||
id,
|
||||
input.name,
|
||||
md_path,
|
||||
input.profile_id,
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// Append the entry and re-validate the whole manifest (unique md_paths).
|
||||
let mut entries = manifest.entries;
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
|
||||
// Now the path resolves: write the initial context.
|
||||
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
|
||||
self.contexts
|
||||
.write_context(&input.project, &agent.id, &md)
|
||||
.await?;
|
||||
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
|
||||
Ok(CreateAgentOutput { agent })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListAgents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListAgentsInput {
|
||||
/// The project whose agents to list.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output of [`ListAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListAgentsOutput {
|
||||
/// The project's agents (reconstructed from the manifest).
|
||||
pub agents: Vec<Agent>,
|
||||
}
|
||||
|
||||
/// Lists a project's agents by reconstructing them from the manifest entries.
|
||||
pub struct ListAgents {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl ListAgents {
|
||||
/// Builds the use case from the [`AgentContextStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Loads the manifest and folds each entry back into an [`Agent`].
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on persistence failure,
|
||||
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
|
||||
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let agents = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ListAgentsOutput { agents })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadAgentContext / UpdateAgentContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ReadAgentContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadAgentContextInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent whose `.md` to read.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output of [`ReadAgentContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadAgentContextOutput {
|
||||
/// The agent's Markdown context.
|
||||
pub content: MarkdownDoc,
|
||||
}
|
||||
|
||||
/// Reads an agent's `.md` context.
|
||||
pub struct ReadAgentContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl ReadAgentContext {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Reads the context.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadAgentContextInput,
|
||||
) -> Result<ReadAgentContextOutput, AppError> {
|
||||
let content = self
|
||||
.contexts
|
||||
.read_context(&input.project, &input.agent_id)
|
||||
.await?;
|
||||
Ok(ReadAgentContextOutput { content })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateAgentContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentContextInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent whose `.md` to overwrite.
|
||||
pub agent_id: AgentId,
|
||||
/// New Markdown content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Overwrites an agent's `.md` context.
|
||||
pub struct UpdateAgentContext {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentContext {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Writes the new context.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
|
||||
let md = MarkdownDoc::new(input.content);
|
||||
self.contexts
|
||||
.write_context(&input.project, &input.agent_id, &md)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAgentInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent to remove.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Removes an agent from the project manifest.
|
||||
///
|
||||
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
|
||||
/// delete, and keeping the file is the safe default (the user may want to recover
|
||||
/// the context). Re-creating an agent with the same name reuses a fresh path.
|
||||
pub struct DeleteAgent {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteAgent {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { contexts, events }
|
||||
}
|
||||
|
||||
/// Drops the manifest entry for the agent.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let before = manifest.entries.len();
|
||||
let entries: Vec<ManifestEntry> = manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.agent_id != input.agent_id)
|
||||
.collect();
|
||||
if entries.len() == before {
|
||||
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||
}
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaunchAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`LaunchAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LaunchAgentInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent to launch.
|
||||
pub agent_id: AgentId,
|
||||
/// Initial terminal height in rows.
|
||||
pub rows: u16,
|
||||
/// Initial terminal width in columns.
|
||||
pub cols: u16,
|
||||
/// The layout leaf hosting the session (a fresh node when `None`).
|
||||
pub node_id: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// Output of [`LaunchAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LaunchAgentOutput {
|
||||
/// The created agent terminal session.
|
||||
pub session: TerminalSession,
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||
///
|
||||
/// This is the orchestrating use case of L6 and therefore consumes several ports
|
||||
/// — each only for the slice it needs (Interface Segregation): the context store
|
||||
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
|
||||
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
|
||||
/// context), and the PTY (spawn + optional stdin injection).
|
||||
pub struct LaunchAgent {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
profiles,
|
||||
runtime,
|
||||
fs,
|
||||
pty,
|
||||
sessions,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the launch.
|
||||
///
|
||||
/// Step order is contractually significant (and unit-tested): resolve the
|
||||
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
|
||||
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
|
||||
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
|
||||
/// strategy.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
|
||||
/// - [`AppError::Invalid`] for a zero-sized terminal,
|
||||
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
|
||||
/// the respective port failures.
|
||||
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// 1. Resolve the agent from the manifest (name + profile + md_path).
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// 2. Read its context and resolve its profile.
|
||||
let content = self
|
||||
.contexts
|
||||
.read_context(&input.project, &agent.id)
|
||||
.await?;
|
||||
let profile = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
|
||||
})?;
|
||||
|
||||
// 3. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||
let prepared = PreparedContext {
|
||||
content: content.clone(),
|
||||
relative_path: agent.context_path.clone(),
|
||||
};
|
||||
let mut spec = self
|
||||
.runtime
|
||||
.prepare_invocation(&profile, &prepared, &input.project.root)?;
|
||||
|
||||
// 4. Apply the injection plan side effects *before* spawning.
|
||||
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
||||
.await?;
|
||||
|
||||
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||
let session_id = handle.session_id;
|
||||
|
||||
// 6. For the Stdin strategy, pipe the context once the PTY is live.
|
||||
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
||||
self.pty.write(&handle, content.as_str().as_bytes())?;
|
||||
}
|
||||
|
||||
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
|
||||
let mut session = TerminalSession::starting(
|
||||
session_id,
|
||||
node_id,
|
||||
spec.cwd.clone(),
|
||||
SessionKind::Agent {
|
||||
agent_id: agent.id,
|
||||
},
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
self.sessions.insert(handle, session.clone());
|
||||
|
||||
self.events.publish(DomainEvent::AgentLaunched {
|
||||
agent_id: agent.id,
|
||||
session_id,
|
||||
});
|
||||
|
||||
Ok(LaunchAgentOutput { session })
|
||||
}
|
||||
|
||||
/// Applies the context-injection plan that must happen *before* spawn:
|
||||
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
||||
/// or attaching the on-disk context path to an environment variable. `Args` is
|
||||
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
|
||||
async fn apply_injection(
|
||||
&self,
|
||||
project: &Project,
|
||||
context_rel_path: &str,
|
||||
content: &MarkdownDoc,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
match spec.context_plan.clone() {
|
||||
Some(ContextInjectionPlan::File { target }) => {
|
||||
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
|
||||
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
|
||||
// A copy (not a symlink) is the portable choice — Windows symlinks
|
||||
// need privileges and SFTP/WSL symlink semantics differ.
|
||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||
self.fs.write(&path, content.as_str().as_bytes()).await?;
|
||||
}
|
||||
Some(ContextInjectionPlan::Env { var }) => {
|
||||
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
||||
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
|
||||
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
|
||||
spec.env.push((var, abspath));
|
||||
}
|
||||
// Args were folded into spec.args by prepare_invocation; Stdin is
|
||||
// applied after the PTY is live.
|
||||
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||
/// segment using a POSIX separator.
|
||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
let b = base.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{b}/{rel}")
|
||||
}
|
||||
|
||||
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
||||
/// agent, disambiguating against the manifest's existing paths with a numeric
|
||||
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
||||
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
|
||||
let slug = slugify(name);
|
||||
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
|
||||
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
|
||||
let mut n = 2;
|
||||
while manifest.entries.iter().any(|e| e.md_path == candidate) {
|
||||
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Lowercases and slugifies a display name into a safe file stem
|
||||
/// (`[a-z0-9-]`), collapsing runs of separators.
|
||||
fn slugify(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in name.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_owned()
|
||||
}
|
||||
27
crates/application/src/agent/mod.rs
Normal file
27
crates/application/src/agent/mod.rs
Normal file
@ -0,0 +1,27 @@
|
||||
//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5).
|
||||
//!
|
||||
//! This module owns the *profile* side of the AI runtime: detecting which CLIs
|
||||
//! are installed, persisting the chosen/edited/custom profiles, and exposing the
|
||||
//! pre-filled reference catalogue that seeds the first-run wizard. It talks only
|
||||
//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`]
|
||||
//! ports. Launching an agent (PTY + injection) is L6.
|
||||
|
||||
mod catalogue;
|
||||
mod lifecycle;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use lifecycle::{
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
||||
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
323
crates/application/src/agent/usecases.rs
Normal file
323
crates/application/src/agent/usecases.rs
Normal file
@ -0,0 +1,323 @@
|
||||
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
|
||||
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
|
||||
//!
|
||||
//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`]
|
||||
//! and report which CLIs are installed (first-run availability ✓/✗).
|
||||
//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the
|
||||
//! persisted profiles through the [`ProfileStore`].
|
||||
//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles
|
||||
//! (closes the first-run wizard).
|
||||
//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue.
|
||||
//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show
|
||||
//! (no `profiles.json` yet) and hand it the reference catalogue.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentRuntime, ProfileStore};
|
||||
use domain::profile::AgentProfile;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::reference_profiles;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectProfilesInput {
|
||||
/// Profiles whose `detect` command should be run.
|
||||
pub candidates: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Availability of a single candidate after detection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileAvailability {
|
||||
/// The probed profile.
|
||||
pub profile: AgentProfile,
|
||||
/// Whether its CLI was detected as installed (exit code 0).
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Output of [`DetectProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectProfilesOutput {
|
||||
/// One entry per candidate (same order), with its availability.
|
||||
pub results: Vec<ProfileAvailability>,
|
||||
}
|
||||
|
||||
/// Probes candidate profiles' detection commands and reports availability.
|
||||
pub struct DetectProfiles {
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
}
|
||||
|
||||
impl DetectProfiles {
|
||||
/// Builds the use case from the [`AgentRuntime`] port. The runtime itself
|
||||
/// holds the [`domain::ports::ProcessSpawner`] used for detection.
|
||||
#[must_use]
|
||||
pub fn new(runtime: Arc<dyn AgentRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
|
||||
/// Runs detection for each candidate. A detection *error* (e.g. the command
|
||||
/// could not even be launched) is reported as `available: false`, not a
|
||||
/// hard failure — the wizard just shows ✗ and the user can still keep the
|
||||
/// profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// Currently never returns `Err` (failures degrade to `available: false`);
|
||||
/// the `Result` keeps the signature uniform with the other use cases.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: DetectProfilesInput,
|
||||
) -> Result<DetectProfilesOutput, AppError> {
|
||||
let results = input
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
let available = self.runtime.detect(&profile).unwrap_or(false);
|
||||
ProfileAvailability { profile, available }
|
||||
})
|
||||
.collect();
|
||||
Ok(DetectProfilesOutput { results })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ListProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListProfilesOutput {
|
||||
/// All configured profiles.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Lists the configured profiles from the store.
|
||||
pub struct ListProfiles {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ListProfiles {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Lists configured profiles.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
|
||||
Ok(ListProfilesOutput {
|
||||
profiles: self.store.list().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SaveProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SaveProfile::execute`]: the profile to upsert.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveProfileInput {
|
||||
/// The profile to create or replace (by id).
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Output of [`SaveProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveProfileOutput {
|
||||
/// The saved profile (echoed back).
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Persists (creates or replaces) a single profile.
|
||||
pub struct SaveProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl SaveProfile {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Saves the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
|
||||
self.store.save(&input.profile).await?;
|
||||
Ok(SaveProfileOutput {
|
||||
profile: input.profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteProfileInput {
|
||||
/// Id of the profile to delete.
|
||||
pub id: domain::ids::ProfileId,
|
||||
}
|
||||
|
||||
/// Deletes a profile by id.
|
||||
pub struct DeleteProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl DeleteProfile {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Deletes the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
||||
/// persistence failure.
|
||||
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
|
||||
self.store.delete(input.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigureProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigureProfilesInput {
|
||||
/// All profiles the user decided to keep (closes the first run).
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Output of [`ConfigureProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigureProfilesOutput {
|
||||
/// The persisted profiles.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Persists the batch of profiles chosen at the end of the first-run wizard.
|
||||
///
|
||||
/// Saving even an empty list creates `profiles.json`, which marks the first run
|
||||
/// as done (so the wizard does not reappear).
|
||||
pub struct ConfigureProfiles {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ConfigureProfiles {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Persists each chosen profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ConfigureProfilesInput,
|
||||
) -> Result<ConfigureProfilesOutput, AppError> {
|
||||
for profile in &input.profiles {
|
||||
self.store.save(profile).await?;
|
||||
}
|
||||
// Ensure `profiles.json` exists even when the user kept nothing, so the
|
||||
// first run is recorded as complete.
|
||||
if input.profiles.is_empty() {
|
||||
self.store.mark_configured().await?;
|
||||
}
|
||||
Ok(ConfigureProfilesOutput {
|
||||
profiles: input.profiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles (catalogue accessor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ReferenceProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReferenceProfilesOutput {
|
||||
/// The pre-filled, editable reference catalogue.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceProfiles;
|
||||
|
||||
impl ReferenceProfiles {
|
||||
/// Builds the (stateless) use case.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Returns the reference catalogue. Infallible.
|
||||
///
|
||||
/// # Errors
|
||||
/// Never; the `Result` keeps the call site uniform.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||
Ok(ReferenceProfilesOutput {
|
||||
profiles: reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstRunState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirstRunStateOutput {
|
||||
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||
pub is_first_run: bool,
|
||||
/// The pre-filled reference catalogue to seed the wizard.
|
||||
pub reference_profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Reports whether the IDE is on its first run (no profiles configured yet) and
|
||||
/// provides the reference catalogue to seed the wizard.
|
||||
pub struct FirstRunState {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl FirstRunState {
|
||||
/// Builds the use case from the [`ProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Computes the first-run state.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
|
||||
let configured = self.store.is_configured().await?;
|
||||
Ok(FirstRunStateOutput {
|
||||
is_first_run: !configured,
|
||||
reference_profiles: reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
115
crates/application/src/error.rs
Normal file
115
crates/application/src/error.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! [`AppError`] — the single error type returned by every use case.
|
||||
//!
|
||||
//! Per-port errors from the domain ([`domain::ports::FsError`],
|
||||
//! [`domain::ports::StoreError`], …) are mapped into this application-level
|
||||
//! error so that the presentation layer (Tauri commands) only ever has to deal
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
///
|
||||
/// Each variant carries a stable, machine-readable `code` (see [`AppError::code`])
|
||||
/// so the presentation layer can map it to an `ErrorDTO` without string matching.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// A requested resource was not found.
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The input failed a domain/application invariant.
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("filesystem error: {0}")]
|
||||
FileSystem(String),
|
||||
|
||||
/// A persistence (store) operation failed.
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
|
||||
/// A process/PTY/runtime operation failed.
|
||||
#[error("process error: {0}")]
|
||||
Process(String),
|
||||
|
||||
/// A git operation failed.
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
/// A remote (SSH/WSL) operation failed.
|
||||
#[error("remote error: {0}")]
|
||||
Remote(String),
|
||||
|
||||
/// An unexpected internal error.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// A stable, machine-readable code for this error, intended for the
|
||||
/// `ErrorDTO` so the frontend can branch without parsing messages.
|
||||
#[must_use]
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
Self::Git(_) => "GIT",
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::Internal(_) => "INTERNAL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FsError> for AppError {
|
||||
fn from(e: FsError) -> Self {
|
||||
match e {
|
||||
FsError::NotFound(p) => Self::NotFound(p),
|
||||
other => Self::FileSystem(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoreError> for AppError {
|
||||
fn from(e: StoreError) -> Self {
|
||||
match e {
|
||||
StoreError::NotFound => Self::NotFound("store item".to_owned()),
|
||||
other => Self::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PtyError> for AppError {
|
||||
fn from(e: PtyError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProcessError> for AppError {
|
||||
fn from(e: ProcessError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeError> for AppError {
|
||||
fn from(e: RuntimeError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GitError> for AppError {
|
||||
fn from(e: GitError) -> Self {
|
||||
Self::Git(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemoteError> for AppError {
|
||||
fn from(e: RemoteError) -> Self {
|
||||
Self::Remote(e.to_string())
|
||||
}
|
||||
}
|
||||
12
crates/application/src/git/mod.rs
Normal file
12
crates/application/src/git/mod.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! Git use cases (ARCHITECTURE §6, L8). Local Git operations orchestrated over
|
||||
//! the [`domain::ports::GitPort`]: status, staging, commit, branches, checkout
|
||||
//! and log. State-changing operations announce [`domain::DomainEvent::GitStateChanged`].
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
};
|
||||
382
crates/application/src/git/usecases.rs
Normal file
382
crates/application/src/git/usecases.rs
Normal file
@ -0,0 +1,382 @@
|
||||
//! Git use cases (ARCHITECTURE §6, L8). Thin orchestration over the [`GitPort`]:
|
||||
//! validate the root, call the port, and (for state-changing operations) announce
|
||||
//! [`DomainEvent::GitStateChanged`] so the UI can refresh.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, GitCommitInfo, GitFileStatus, GitPort, GraphCommit};
|
||||
use domain::{DomainEvent, ProjectId, ProjectPath};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Parses a raw root string into a validated [`ProjectPath`].
|
||||
fn parse_root(root: &str) -> Result<ProjectPath, AppError> {
|
||||
ProjectPath::new(root).map_err(|e| AppError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitStatus::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitStatusInput {
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Output of [`GitStatus::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitStatusOutput {
|
||||
/// Changed paths (staged flag per path).
|
||||
pub entries: Vec<GitFileStatus>,
|
||||
}
|
||||
|
||||
/// Reports the working-tree status of a repository.
|
||||
pub struct GitStatus {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitStatus {
|
||||
/// Builds the use case from the [`GitPort`].
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Returns the status.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a non-absolute root,
|
||||
/// - [`AppError::Git`] if the repo is missing or the operation fails.
|
||||
pub async fn execute(&self, input: GitStatusInput) -> Result<GitStatusOutput, AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
let entries = self.git.status(&root).await?;
|
||||
Ok(GitStatusOutput { entries })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitStage / GitUnstage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitStage::execute`] / [`GitUnstage::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitStagePathInput {
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
/// Repo-relative path to (un)stage.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Stages a path (adds it to the index).
|
||||
pub struct GitStage {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitStage {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Stages the path.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
self.git.stage(&root, &input.path).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unstages a path (resets it to its HEAD state, or removes it when unborn).
|
||||
pub struct GitUnstage {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitUnstage {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Unstages the path.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
self.git.unstage(&root, &input.path).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitCommit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitCommit::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitCommitInput {
|
||||
/// The project (for the emitted event).
|
||||
pub project_id: ProjectId,
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
/// Commit message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Output of [`GitCommit::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitCommitOutput {
|
||||
/// The created commit.
|
||||
pub commit: GitCommitInfo,
|
||||
}
|
||||
|
||||
/// Commits the staged index, announcing [`DomainEvent::GitStateChanged`].
|
||||
pub struct GitCommit {
|
||||
git: Arc<dyn GitPort>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl GitCommit {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { git, events }
|
||||
}
|
||||
|
||||
/// Creates the commit.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a bad root or an empty message,
|
||||
/// - [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitCommitInput) -> Result<GitCommitOutput, AppError> {
|
||||
if input.message.trim().is_empty() {
|
||||
return Err(AppError::Invalid("commit message is empty".to_owned()));
|
||||
}
|
||||
let root = parse_root(&input.root)?;
|
||||
let commit = self.git.commit(&root, &input.message).await?;
|
||||
self.events.publish(DomainEvent::GitStateChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(GitCommitOutput { commit })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitBranches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitBranches::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitBranchesInput {
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Output of [`GitBranches::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitBranchesOutput {
|
||||
/// All local branches.
|
||||
pub branches: Vec<String>,
|
||||
/// The current branch (`None` when detached or unborn).
|
||||
pub current: Option<String>,
|
||||
}
|
||||
|
||||
/// Lists local branches and the current one.
|
||||
pub struct GitBranches {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitBranches {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Lists branches + current.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitBranchesInput) -> Result<GitBranchesOutput, AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
let branches = self.git.branches(&root).await?;
|
||||
let current = self.git.current_branch(&root).await?;
|
||||
Ok(GitBranchesOutput { branches, current })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitCheckout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitCheckout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitCheckoutInput {
|
||||
/// The project (for the emitted event).
|
||||
pub project_id: ProjectId,
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
/// Branch to check out.
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
/// Checks out a branch, announcing [`DomainEvent::GitStateChanged`].
|
||||
pub struct GitCheckout {
|
||||
git: Arc<dyn GitPort>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl GitCheckout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { git, events }
|
||||
}
|
||||
|
||||
/// Checks out the branch.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitCheckoutInput) -> Result<(), AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
self.git.checkout(&root, &input.branch).await?;
|
||||
self.events.publish(DomainEvent::GitStateChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitLog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitLog::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitLogInput {
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
/// Maximum number of commits to return.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// Output of [`GitLog::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitLogOutput {
|
||||
/// Recent commits, newest first.
|
||||
pub commits: Vec<GitCommitInfo>,
|
||||
}
|
||||
|
||||
/// Returns the recent commit log.
|
||||
pub struct GitLog {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitLog {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Returns the log.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitLogInput) -> Result<GitLogOutput, AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
let commits = self.git.log(&root, input.limit).await?;
|
||||
Ok(GitLogOutput { commits })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitInit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitInit::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitInitInput {
|
||||
/// The project (for the emitted event).
|
||||
pub project_id: ProjectId,
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Initialises a repository at the project root, announcing
|
||||
/// [`DomainEvent::GitStateChanged`].
|
||||
pub struct GitInit {
|
||||
git: Arc<dyn GitPort>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl GitInit {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { git, events }
|
||||
}
|
||||
|
||||
/// Initialises the repository.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitInitInput) -> Result<(), AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
self.git.init(&root).await?;
|
||||
self.events.publish(DomainEvent::GitStateChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitGraph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GitGraph::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitGraphInput {
|
||||
/// Absolute repository root.
|
||||
pub root: String,
|
||||
/// Maximum number of commits to return.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// Output of [`GitGraph::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitGraphOutput {
|
||||
/// Graph commits (topological + time order), newest first.
|
||||
pub commits: Vec<GraphCommit>,
|
||||
}
|
||||
|
||||
/// Returns the full commit graph for all local branches.
|
||||
pub struct GitGraph {
|
||||
git: Arc<dyn GitPort>,
|
||||
}
|
||||
|
||||
impl GitGraph {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(git: Arc<dyn GitPort>) -> Self {
|
||||
Self { git }
|
||||
}
|
||||
|
||||
/// Returns the graph.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
|
||||
pub async fn execute(&self, input: GitGraphInput) -> Result<GitGraphOutput, AppError> {
|
||||
let root = parse_root(&input.root)?;
|
||||
let commits = self.git.log_graph(&root, input.limit).await?;
|
||||
Ok(GitGraphOutput { commits })
|
||||
}
|
||||
}
|
||||
84
crates/application/src/health.rs
Normal file
84
crates/application/src/health.rs
Normal file
@ -0,0 +1,84 @@
|
||||
//! [`HealthUseCase`] — a trivial, in-memory use case used to validate the
|
||||
//! end-to-end wiring (composition root → Tauri command → use case → ports →
|
||||
//! frontend gateway). It carries its ports as `Arc<dyn Port>`, exactly like
|
||||
//! every real use case will (ARCHITECTURE §6).
|
||||
//!
|
||||
//! It depends only on the utility ports [`Clock`] and [`IdGenerator`], so it
|
||||
//! exercises dependency injection without needing any I/O adapter.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Clock, EventBus, IdGenerator};
|
||||
use domain::DomainEvent;
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`HealthUseCase::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct HealthInput {
|
||||
/// Optional caller-supplied note echoed back in the report (used by tests
|
||||
/// and the frontend smoke check).
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`HealthUseCase::execute`]: a small health report.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HealthReport {
|
||||
/// Application/crate version (`CARGO_PKG_VERSION`).
|
||||
pub version: String,
|
||||
/// Liveness flag — always `true` if the use case ran.
|
||||
pub alive: bool,
|
||||
/// Server "now" in epoch milliseconds (from the injected [`Clock`]).
|
||||
pub time_millis: i64,
|
||||
/// A fresh correlation id (from the injected [`IdGenerator`]).
|
||||
pub correlation_id: String,
|
||||
/// Echoed caller note, if any.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Trivial health/ping use case validating the DI + IPC pipeline.
|
||||
pub struct HealthUseCase {
|
||||
clock: Arc<dyn Clock>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl HealthUseCase {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the health check.
|
||||
///
|
||||
/// As a side effect it publishes a [`DomainEvent`] on the [`EventBus`] so
|
||||
/// the event-relay path is exercised end to end (the relay forwards it to a
|
||||
/// Tauri event). A `ProjectCreated`-shaped event is reused here purely as a
|
||||
/// no-op smoke signal; no project is actually created.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError`] — never, in this trivial implementation, but the
|
||||
/// signature matches the real use-case contract.
|
||||
pub fn execute(&self, input: HealthInput) -> Result<HealthReport, AppError> {
|
||||
let correlation = self.ids.new_uuid();
|
||||
|
||||
// Exercise the event-bus relay path with a harmless smoke event.
|
||||
self.events.publish(DomainEvent::ProjectCreated {
|
||||
project_id: ProjectId::from_uuid(correlation),
|
||||
});
|
||||
|
||||
Ok(HealthReport {
|
||||
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
alive: true,
|
||||
time_millis: self.clock.now_millis(),
|
||||
correlation_id: correlation.to_string(),
|
||||
note: input.note,
|
||||
})
|
||||
}
|
||||
}
|
||||
336
crates/application/src/layout/management.rs
Normal file
336
crates/application/src/layout/management.rs
Normal file
@ -0,0 +1,336 @@
|
||||
//! Named-layout management use cases (#4): list, create, rename, delete and set
|
||||
//! the active layout. Each loads the project's layouts store (see
|
||||
//! [`super::store`]), applies the change and persists it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore};
|
||||
use domain::{DomainEvent, LayoutId, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::store::{default_tree, persist_doc, resolve_doc, LayoutKind, NamedLayout};
|
||||
|
||||
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LayoutInfo {
|
||||
/// Stable identifier.
|
||||
pub id: LayoutId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Kind of this layout.
|
||||
pub kind: LayoutKind,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListLayouts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListLayoutsInput {
|
||||
/// Project whose layouts to list.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`ListLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListLayoutsOutput {
|
||||
/// All named layouts (id + name), in order.
|
||||
pub layouts: Vec<LayoutInfo>,
|
||||
/// The active layout.
|
||||
pub active_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Lists a project's named layouts and the active one.
|
||||
pub struct ListLayouts {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl ListLayouts {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { store, fs }
|
||||
}
|
||||
|
||||
/// Lists the layouts.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] for an unknown project, [`AppError::FileSystem`] /
|
||||
/// [`AppError::Store`] on I/O failure.
|
||||
pub async fn execute(&self, input: ListLayoutsInput) -> Result<ListLayoutsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
Ok(ListLayoutsOutput {
|
||||
layouts: doc
|
||||
.layouts
|
||||
.iter()
|
||||
.map(|l| LayoutInfo {
|
||||
id: l.id,
|
||||
name: l.name.clone(),
|
||||
kind: l.kind,
|
||||
})
|
||||
.collect(),
|
||||
active_id: doc.active_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Display name for the new layout.
|
||||
pub name: String,
|
||||
/// Kind of the new layout (defaults to Terminal).
|
||||
pub kind: LayoutKind,
|
||||
}
|
||||
|
||||
/// Output of [`CreateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateLayoutOutput {
|
||||
/// The id minted for the new (now active) layout.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Creates a new empty named layout and makes it active.
|
||||
pub struct CreateLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fs,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] for an
|
||||
/// unknown project, I/O errors otherwise.
|
||||
pub async fn execute(&self, input: CreateLayoutInput) -> Result<CreateLayoutOutput, AppError> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Invalid("layout name is empty".to_owned()));
|
||||
}
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let id = LayoutId::from_uuid(self.ids.new_uuid());
|
||||
doc.layouts.push(NamedLayout {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: input.kind,
|
||||
tree: default_tree(),
|
||||
});
|
||||
doc.active_id = id; // a freshly-created layout becomes active.
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(CreateLayoutOutput { layout_id: id })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenameLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`RenameLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenameLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to rename.
|
||||
pub layout_id: LayoutId,
|
||||
/// New display name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Renames a named layout.
|
||||
pub struct RenameLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl RenameLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Renames the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] if the
|
||||
/// project or layout is unknown.
|
||||
pub async fn execute(&self, input: RenameLayoutInput) -> Result<(), AppError> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Invalid("layout name is empty".to_owned()));
|
||||
}
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let named = doc
|
||||
.find_mut(input.layout_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("layout {}", input.layout_id)))?;
|
||||
named.name = name.to_owned();
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to delete.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Output of [`DeleteLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteLayoutOutput {
|
||||
/// The active layout after the deletion.
|
||||
pub active_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Deletes a named layout. The last remaining layout cannot be deleted; if the
|
||||
/// active layout is removed, the first remaining one becomes active.
|
||||
pub struct DeleteLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Deletes the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] if it is the last layout, [`AppError::NotFound`] if
|
||||
/// the project or layout is unknown.
|
||||
pub async fn execute(&self, input: DeleteLayoutInput) -> Result<DeleteLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
if doc.layouts.len() <= 1 {
|
||||
return Err(AppError::Invalid(
|
||||
"cannot delete the last layout".to_owned(),
|
||||
));
|
||||
}
|
||||
if doc.find(input.layout_id).is_none() {
|
||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
||||
}
|
||||
doc.layouts.retain(|l| l.id != input.layout_id);
|
||||
if doc.active_id == input.layout_id {
|
||||
doc.active_id = doc.layouts[0].id;
|
||||
}
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(DeleteLayoutOutput {
|
||||
active_id: doc.active_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetActiveLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SetActiveLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetActiveLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to make active.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Switches the active layout of a project.
|
||||
pub struct SetActiveLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl SetActiveLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Sets the active layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the project or layout is unknown.
|
||||
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
if doc.find(input.layout_id).is_none() {
|
||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
||||
}
|
||||
doc.active_id = input.layout_id;
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
36
crates/application/src/layout/mod.rs
Normal file
36
crates/application/src/layout/mod.rs
Normal file
@ -0,0 +1,36 @@
|
||||
//! Layout use cases (ARCHITECTURE §6, §7, L4).
|
||||
//!
|
||||
//! The terminal layout of a tab is a pure [`domain::LayoutTree`] (a recursive
|
||||
//! spreadsheet-like grid). Its mutating operations (`split`/`merge`/`resize`/
|
||||
//! `move`/`set_session`) are **pure functions** that live in the domain; this
|
||||
//! module is the thin application orchestration that:
|
||||
//!
|
||||
//! - resolves the project root (via the [`domain::ports::ProjectStore`]),
|
||||
//! - reads/writes the tree from/to `.ideai/layout.json` (via the
|
||||
//! [`domain::ports::FileSystem`] port — so the layout *travels with the
|
||||
//! project*, including on remote hosts, ARCHITECTURE §7.3, §9.1),
|
||||
//! - publishes [`domain::DomainEvent::LayoutChanged`] on every mutation.
|
||||
//!
|
||||
//! ## Layout ↔ terminal session binding (L3 ↔ L4)
|
||||
//!
|
||||
//! A [`domain::LeafCell`] carries `Option<SessionId>`. When the UI opens a
|
||||
//! terminal in a cell, it calls `OpenTerminal` (L3) — which mints the
|
||||
//! `SessionId` — then records that id in the hosting leaf through the
|
||||
//! `SetSession` layout operation here. The layout is the single source of truth
|
||||
//! for *which cell hosts which session*; the live PTY handle stays in L3's
|
||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||
|
||||
mod management;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
};
|
||||
182
crates/application/src/layout/store.rs
Normal file
182
crates/application/src/layout/store.rs
Normal file
@ -0,0 +1,182 @@
|
||||
//! Persistence of a project's **named terminal layouts** (#4).
|
||||
//!
|
||||
//! A project no longer has a single layout: `.ideai/layouts.json` holds a
|
||||
//! collection of named [`LayoutTree`]s plus which one is active:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] }
|
||||
//! ```
|
||||
//!
|
||||
//! Migration: a project that still has the legacy single `.ideai/layout.json`
|
||||
//! (pre-#4) is upgraded transparently — its tree becomes the first "Default"
|
||||
//! layout. A missing/corrupt store self-heals to one default layout (same
|
||||
//! self-healing contract as the original L4 single-file resolver).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{FileSystem, RemotePath};
|
||||
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
|
||||
|
||||
/// File name of the named-layouts store inside a project's `.ideai/`.
|
||||
pub const LAYOUTS_FILE: &str = "layouts.json";
|
||||
|
||||
/// Legacy single-layout file (pre-#4), migrated on first read if present.
|
||||
const LEGACY_LAYOUT_FILE: &str = "layout.json";
|
||||
|
||||
/// Current schema version of `layouts.json`.
|
||||
const LAYOUTS_VERSION: u32 = 1;
|
||||
|
||||
/// Name given to the layout created by default / migrated from the legacy file.
|
||||
const DEFAULT_LAYOUT_NAME: &str = "Default";
|
||||
|
||||
/// Discriminates the kind of content a named layout holds.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LayoutKind {
|
||||
/// A terminal-grid layout (the original kind).
|
||||
#[default]
|
||||
Terminal,
|
||||
/// A Git-graph visualisation layout.
|
||||
GitGraph,
|
||||
}
|
||||
|
||||
/// One named layout: a stable id, a display name and its terminal grid tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamedLayout {
|
||||
/// Stable identifier.
|
||||
pub id: LayoutId,
|
||||
/// Display name (shown in the layouts tab bar).
|
||||
pub name: String,
|
||||
/// The kind of this layout (terminal grid or git-graph).
|
||||
#[serde(default)]
|
||||
pub kind: LayoutKind,
|
||||
/// The terminal grid for this layout (present but ignored for GitGraph).
|
||||
pub tree: LayoutTree,
|
||||
}
|
||||
|
||||
/// On-disk shape of `.ideai/layouts.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LayoutsDoc {
|
||||
/// Schema version.
|
||||
pub version: u32,
|
||||
/// The currently active layout (always one of `layouts`).
|
||||
pub active_id: LayoutId,
|
||||
/// All named layouts (at least one).
|
||||
pub layouts: Vec<NamedLayout>,
|
||||
}
|
||||
|
||||
impl LayoutsDoc {
|
||||
/// The active layout id, or the explicit one when provided.
|
||||
#[must_use]
|
||||
pub fn resolve_id(&self, id: Option<LayoutId>) -> LayoutId {
|
||||
id.unwrap_or(self.active_id)
|
||||
}
|
||||
|
||||
/// Finds a layout by id.
|
||||
#[must_use]
|
||||
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
|
||||
self.layouts.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
/// Mutable access to a layout by id.
|
||||
pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> {
|
||||
self.layouts.iter_mut().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
/// Structural validity: non-empty, `active_id` present, every tree valid.
|
||||
fn is_valid(&self) -> bool {
|
||||
!self.layouts.is_empty()
|
||||
&& self.find(self.active_id).is_some()
|
||||
&& self.layouts.iter().all(|l| l.tree.validate().is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The default single-cell layout tree (one empty leaf).
|
||||
#[must_use]
|
||||
pub fn default_tree() -> LayoutTree {
|
||||
LayoutTree::single(LeafCell {
|
||||
id: NodeId::new_random(),
|
||||
session: None,
|
||||
agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
|
||||
LayoutsDoc {
|
||||
version: LAYOUTS_VERSION,
|
||||
active_id: id,
|
||||
layouts: vec![NamedLayout {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
tree,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn layouts_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(join_root(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{LAYOUTS_FILE}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn legacy_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(join_root(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reads the legacy single layout tree if present and valid (for migration).
|
||||
async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option<LayoutTree> {
|
||||
let bytes = fs.read(&legacy_path(project)).await.ok()?;
|
||||
let tree = from_json_bytes::<LayoutTree>(&bytes).ok()?;
|
||||
tree.validate().is_ok().then_some(tree)
|
||||
}
|
||||
|
||||
/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed).
|
||||
pub async fn persist_doc(
|
||||
fs: &dyn FileSystem,
|
||||
project: &Project,
|
||||
doc: &LayoutsDoc,
|
||||
) -> Result<(), AppError> {
|
||||
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
||||
fs.create_dir_all(&ideai_dir).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the project's layouts doc, with idempotent initialisation /
|
||||
/// migration / self-healing (mirrors the L4 single-file resolver contract).
|
||||
///
|
||||
/// - **Present & valid**: returned as-is (no write).
|
||||
/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded
|
||||
/// with one empty "Default" layout — and **persisted** (write-through, so ids
|
||||
/// are stable from the first load).
|
||||
/// - **Present but corrupt/invalid**: overwritten with a fresh default.
|
||||
pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<LayoutsDoc, AppError> {
|
||||
if let Ok(bytes) = fs.read(&layouts_path(project)).await {
|
||||
if let Ok(doc) = from_json_bytes::<LayoutsDoc>(&bytes) {
|
||||
if doc.is_valid() {
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
// Present-but-invalid: fall through and re-seed.
|
||||
}
|
||||
|
||||
// First run for this project (or self-heal): migrate the legacy tree if any.
|
||||
let tree = match read_legacy_tree(fs, project).await {
|
||||
Some(tree) => tree,
|
||||
None => default_tree(),
|
||||
};
|
||||
let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree);
|
||||
persist_doc(fs, project, &doc).await?;
|
||||
Ok(doc)
|
||||
}
|
||||
232
crates/application/src/layout/usecases.rs
Normal file
232
crates/application/src/layout/usecases.rs
Normal file
@ -0,0 +1,232 @@
|
||||
//! [`LoadLayout`] and [`MutateLayout`] (ARCHITECTURE §6, §7; L4 + #4).
|
||||
//!
|
||||
//! A project owns **several named layouts** (see [`super::store`]). These two use
|
||||
//! cases operate on **one** layout — the one identified by `layout_id`, or the
|
||||
//! active layout when `layout_id` is `None`. They stay thin orchestrators: the
|
||||
//! mutating operations are the domain's pure `LayoutTree` functions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
||||
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::store::{persist_doc, resolve_doc};
|
||||
|
||||
/// Maps a [`LayoutError`] to the application error type.
|
||||
fn map_layout_err(e: LayoutError) -> AppError {
|
||||
match e {
|
||||
LayoutError::NodeNotFound(id) => AppError::NotFound(format!("layout node {id}")),
|
||||
other => AppError::Invalid(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A layout mutation expressed in terms of the pure domain operations.
|
||||
///
|
||||
/// Each variant maps 1:1 to a pure `LayoutTree` method
|
||||
/// (`split`/`merge`/`resize`/`move`/`set_session`). Decoupling the *operation*
|
||||
/// from the *tree* keeps the use case a thin orchestrator and lets the
|
||||
/// presentation layer (and undo/redo) speak in intentions.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LayoutOperation {
|
||||
/// Split a leaf into a two-child split (original + a new empty leaf).
|
||||
Split {
|
||||
/// The leaf to split.
|
||||
target: NodeId,
|
||||
/// Row (columns) or Column (rows).
|
||||
direction: Direction,
|
||||
/// Id for the new sibling leaf.
|
||||
new_leaf: NodeId,
|
||||
/// Id for the wrapping split container.
|
||||
container: NodeId,
|
||||
},
|
||||
/// Collapse a split container back to one of its children.
|
||||
Merge {
|
||||
/// The split container to collapse.
|
||||
container: NodeId,
|
||||
/// Index of the child to keep.
|
||||
keep_index: usize,
|
||||
},
|
||||
/// Reassign the relative weights of a split's children.
|
||||
Resize {
|
||||
/// The split container to resize.
|
||||
container: NodeId,
|
||||
/// New weights (one per child, all `> 0`).
|
||||
weights: Vec<f32>,
|
||||
},
|
||||
/// Move the session hosted by one leaf to another (empty) leaf.
|
||||
Move {
|
||||
/// Source leaf (left empty).
|
||||
from: NodeId,
|
||||
/// Target leaf (must be empty).
|
||||
to: NodeId,
|
||||
},
|
||||
/// Attach or detach a session to/from a leaf (cell ↔ terminal binding).
|
||||
SetSession {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Session to host, or `None` to clear.
|
||||
session: Option<SessionId>,
|
||||
},
|
||||
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
||||
SetCellAgent {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Agent to associate, or `None` to clear.
|
||||
agent: Option<AgentId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperation {
|
||||
/// Applies this operation to `tree`, returning the new validated tree.
|
||||
fn apply(&self, tree: &LayoutTree) -> Result<LayoutTree, AppError> {
|
||||
let result = match self {
|
||||
Self::Split {
|
||||
target,
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell {
|
||||
id: *new_leaf,
|
||||
session: None,
|
||||
agent: None,
|
||||
},
|
||||
*container,
|
||||
),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
} => tree.merge(*container, *keep_index),
|
||||
Self::Resize { container, weights } => tree.resize(*container, weights),
|
||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||
};
|
||||
result.map_err(map_layout_err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`LoadLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadLayoutInput {
|
||||
/// Project whose layout to load.
|
||||
pub project_id: ProjectId,
|
||||
/// Which named layout to load; `None` loads the active one.
|
||||
pub layout_id: Option<LayoutId>,
|
||||
}
|
||||
|
||||
/// Output of [`LoadLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadLayoutOutput {
|
||||
/// The id of the layout that was loaded (resolved from active when omitted).
|
||||
pub layout_id: LayoutId,
|
||||
/// The loaded layout tree.
|
||||
pub layout: LayoutTree,
|
||||
}
|
||||
|
||||
/// Loads one named layout (the active one by default), self-healing / migrating
|
||||
/// the layouts store as needed (see [`super::store::resolve_doc`]).
|
||||
pub struct LoadLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl LoadLayout {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { store, fs }
|
||||
}
|
||||
|
||||
/// Executes the load.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the project or the requested layout is unknown,
|
||||
/// - [`AppError::FileSystem`] if seeding the default layouts fails to persist,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let id = doc.resolve_id(input.layout_id);
|
||||
let named = doc
|
||||
.find(id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
||||
Ok(LoadLayoutOutput {
|
||||
layout_id: id,
|
||||
layout: named.tree.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`MutateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MutateLayoutInput {
|
||||
/// Project whose layout to mutate.
|
||||
pub project_id: ProjectId,
|
||||
/// Which named layout to mutate; `None` mutates the active one.
|
||||
pub layout_id: Option<LayoutId>,
|
||||
/// The operation to apply.
|
||||
pub operation: LayoutOperation,
|
||||
}
|
||||
|
||||
/// Output of [`MutateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MutateLayoutOutput {
|
||||
/// The id of the mutated layout.
|
||||
pub layout_id: LayoutId,
|
||||
/// The new, persisted layout tree.
|
||||
pub layout: LayoutTree,
|
||||
}
|
||||
|
||||
/// Applies a pure layout operation to one named layout, persists the whole
|
||||
/// layouts store and publishes [`DomainEvent::LayoutChanged`].
|
||||
pub struct MutateLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl MutateLayout {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the project, layout or a referenced node is unknown,
|
||||
/// - [`AppError::Invalid`] if the operation violates a layout invariant,
|
||||
/// - [`AppError::FileSystem`] on persistence failure,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let id = doc.resolve_id(input.layout_id);
|
||||
|
||||
let named = doc
|
||||
.find_mut(id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
||||
let next = input.operation.apply(&named.tree)?;
|
||||
named.tree = next.clone();
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
|
||||
Ok(MutateLayoutOutput {
|
||||
layout_id: id,
|
||||
layout: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
69
crates/application/src/lib.rs
Normal file
69
crates/application/src/lib.rs
Normal file
@ -0,0 +1,69 @@
|
||||
//! # IdeA — Application layer
|
||||
//!
|
||||
//! Orchestrates **use cases** by talking **only to the domain ports** (traits),
|
||||
//! never to concrete adapters (ARCHITECTURE §1.1, §6). Every use case is a
|
||||
//! struct carrying its ports as `Arc<dyn Port>` and exposing
|
||||
//! `execute(input) -> Result<output, AppError>`.
|
||||
//!
|
||||
//! This crate depends on `domain` only. The composition root (`app-tauri`) is
|
||||
//! the single place that constructs concrete adapters and injects them here.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod health;
|
||||
pub mod layout;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
pub use error::AppError;
|
||||
pub use git::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
};
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta,
|
||||
};
|
||||
pub use template::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
||||
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
|
||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||
UpdateTemplateOutput,
|
||||
};
|
||||
pub use terminal::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||
WriteToTerminalInput,
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
97
crates/application/src/project/close.rs
Normal file
97
crates/application/src/project/close.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! [`CloseProject`] / [`CloseTab`] (ARCHITECTURE §6).
|
||||
//!
|
||||
//! In L2 there are no PTYs to release yet, so closing is essentially *persisting
|
||||
//! the current state*. The use case takes the workspace image to persist (the
|
||||
//! UI owns the windows/tabs arrangement) and saves it through the
|
||||
//! [`ProjectStore`]. It is written so the L3 "release PTYs" step slots in here
|
||||
//! without changing the call sites.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ProjectStore;
|
||||
use domain::{ProjectId, Workspace};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`CloseProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CloseProjectInput {
|
||||
/// The project being closed.
|
||||
pub project_id: ProjectId,
|
||||
/// The workspace state to persist (windows/tabs/layouts). `None` skips
|
||||
/// persistence (e.g. the UI has nothing to save).
|
||||
pub workspace: Option<Workspace>,
|
||||
}
|
||||
|
||||
/// Output of [`CloseProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloseProjectOutput {
|
||||
/// The project that was closed.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Closes a project: persists the workspace state and releases resources.
|
||||
pub struct CloseProject {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
impl CloseProject {
|
||||
/// Builds the use case from its injected port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the close.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] if persisting the workspace fails.
|
||||
pub async fn execute(&self, input: CloseProjectInput) -> Result<CloseProjectOutput, AppError> {
|
||||
if let Some(workspace) = &input.workspace {
|
||||
self.store.save_workspace(workspace).await?;
|
||||
}
|
||||
// L3 will release the project's PTYs here.
|
||||
Ok(CloseProjectOutput {
|
||||
project_id: input.project_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`CloseTab::execute`] — closing one tab (a single open project).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CloseTabInput {
|
||||
/// The project shown in the tab being closed.
|
||||
pub project_id: ProjectId,
|
||||
/// The workspace state to persist after the tab is removed.
|
||||
pub workspace: Option<Workspace>,
|
||||
}
|
||||
|
||||
/// Closes a single tab. In L2 this delegates to the same persistence path as
|
||||
/// [`CloseProject`]; it exists as a distinct intention so the multi-window lot
|
||||
/// (L10) can give it tab-specific behaviour without touching callers.
|
||||
pub struct CloseTab {
|
||||
inner: CloseProject,
|
||||
}
|
||||
|
||||
impl CloseTab {
|
||||
/// Builds the use case from its injected port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
|
||||
Self {
|
||||
inner: CloseProject::new(store),
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the tab close.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] if persisting the workspace fails.
|
||||
pub async fn execute(&self, input: CloseTabInput) -> Result<CloseProjectOutput, AppError> {
|
||||
self.inner
|
||||
.execute(CloseProjectInput {
|
||||
project_id: input.project_id,
|
||||
workspace: input.workspace,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
120
crates/application/src/project/create.rs
Normal file
120
crates/application/src/project/create.rs
Normal file
@ -0,0 +1,120 @@
|
||||
//! [`CreateProject`] — create a project from a project root (ARCHITECTURE §6).
|
||||
//!
|
||||
//! Responsibilities (and *only* these):
|
||||
//! 1. validate the root (absolute path — enforced by [`ProjectPath`]) and name,
|
||||
//! 2. enforce the cross-aggregate uniqueness invariant `(remote, root)` — this
|
||||
//! lives here, not in the domain, because it requires knowledge of *all*
|
||||
//! projects (a repository concern, ARCHITECTURE §3.2),
|
||||
//! 3. create the `.ideai/` directory and write `project.json`,
|
||||
//! 4. register the project via the [`ProjectStore`],
|
||||
//! 5. publish [`DomainEvent::ProjectCreated`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Clock, EventBus, FileSystem, IdGenerator, ProjectStore, RemotePath};
|
||||
use domain::{DomainEvent, Project, ProjectId, ProjectPath, RemoteRef};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::meta::{join_root, to_json_bytes, ProjectMeta, IDEAI_DIR, PROJECT_FILE};
|
||||
|
||||
/// Input for [`CreateProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateProjectInput {
|
||||
/// Display name of the project.
|
||||
pub name: String,
|
||||
/// Absolute project root (validated into a [`ProjectPath`]).
|
||||
pub root: String,
|
||||
/// Where the project lives. Defaults to [`RemoteRef::Local`] when `None`.
|
||||
pub remote: Option<RemoteRef>,
|
||||
/// Default agent profile id, if already chosen.
|
||||
pub default_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`CreateProject::execute`]: the freshly-created project.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateProjectOutput {
|
||||
/// The created project.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Creates a project: inits `.ideai/`, writes `project.json`, registers it.
|
||||
pub struct CreateProject {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateProject {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fs,
|
||||
ids,
|
||||
clock,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes project creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the name is empty or the root is not absolute,
|
||||
/// - [`AppError::Invalid`] if a project already exists for the same
|
||||
/// `(remote, root)` (uniqueness invariant),
|
||||
/// - [`AppError::FileSystem`] / [`AppError::Store`] on I/O failures.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CreateProjectInput,
|
||||
) -> Result<CreateProjectOutput, AppError> {
|
||||
let root = ProjectPath::new(input.root).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let remote = input.remote.unwrap_or(RemoteRef::Local);
|
||||
|
||||
// (1+2) Uniqueness invariant on (remote, root) — repository-level.
|
||||
let existing = self.store.list_projects().await?;
|
||||
if existing
|
||||
.iter()
|
||||
.any(|p| p.remote == remote && p.root == root)
|
||||
{
|
||||
return Err(AppError::Invalid(format!(
|
||||
"a project already exists at {} for this remote",
|
||||
root.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
// Build the validated aggregate (enforces non-empty name).
|
||||
let id = ProjectId::from_uuid(self.ids.new_uuid());
|
||||
let created_at = self.clock.now_millis();
|
||||
let project = Project::new(id, input.name, root.clone(), remote, created_at)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// (3) Materialise `.ideai/` and write `project.json`.
|
||||
let ideai_dir = join_root(&root, IDEAI_DIR);
|
||||
self.fs.create_dir_all(&RemotePath::new(ideai_dir)).await?;
|
||||
|
||||
let meta = ProjectMeta::from_project(&project, input.default_profile_id);
|
||||
let meta_path = join_root(&root, &format!("{IDEAI_DIR}/{PROJECT_FILE}"));
|
||||
self.fs
|
||||
.write(&RemotePath::new(meta_path), &to_json_bytes(&meta)?)
|
||||
.await?;
|
||||
|
||||
// (4) Register in the known-projects registry.
|
||||
self.store.save_project(&project).await?;
|
||||
|
||||
// (5) Announce.
|
||||
self.events
|
||||
.publish(DomainEvent::ProjectCreated { project_id: id });
|
||||
|
||||
Ok(CreateProjectOutput { project })
|
||||
}
|
||||
}
|
||||
99
crates/application/src/project/meta.rs
Normal file
99
crates/application/src/project/meta.rs
Normal file
@ -0,0 +1,99 @@
|
||||
//! [`ProjectMeta`] — the on-disk shape of `.ideai/project.json` (ARCHITECTURE §9.1).
|
||||
//!
|
||||
//! This is the *project-local* metadata that travels with the code (it lives
|
||||
//! inside the project root, is versionable, and is independent of the machine).
|
||||
//! The known-projects **registry** is a separate, machine-local concern owned by
|
||||
//! the [`domain::ports::ProjectStore`] adapter (ARCHITECTURE §9.2).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::{Project, ProjectId, ProjectPath, RemoteRef};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// The `.ideai/` directory name inside a project root.
|
||||
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
/// The project-meta file name inside `.ideai/`.
|
||||
pub(crate) const PROJECT_FILE: &str = "project.json";
|
||||
|
||||
/// The agent manifest file name inside `.ideai/`.
|
||||
pub(crate) const AGENTS_FILE: &str = "agents.json";
|
||||
|
||||
/// Current schema version of `project.json`.
|
||||
pub(crate) const PROJECT_META_VERSION: u32 = 1;
|
||||
|
||||
/// Serialised contents of `.ideai/project.json`.
|
||||
///
|
||||
/// Carries the project's identity and the metadata needed to reopen it: its
|
||||
/// stable id, display name, the default agent profile, the remote reference and
|
||||
/// the creation timestamp. The `root` itself is *not* stored here — the file
|
||||
/// already lives at `<root>/.ideai/project.json`, so the root is implied by the
|
||||
/// file's location (and authoritatively held by the registry).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectMeta {
|
||||
/// Schema version of this file.
|
||||
pub version: u32,
|
||||
/// Stable project id (matches the registry entry).
|
||||
pub id: ProjectId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Default agent profile id, if one has been chosen (`null` until first-run
|
||||
/// / profile selection in later lots).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_profile_id: Option<String>,
|
||||
/// Where the project physically lives.
|
||||
pub remote: RemoteRef,
|
||||
/// Creation timestamp, epoch milliseconds.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl ProjectMeta {
|
||||
/// Builds the meta image from a [`Project`].
|
||||
#[must_use]
|
||||
pub fn from_project(project: &Project, default_profile_id: Option<String>) -> Self {
|
||||
Self {
|
||||
version: PROJECT_META_VERSION,
|
||||
id: project.id,
|
||||
name: project.name.clone(),
|
||||
default_profile_id,
|
||||
remote: project.remote.clone(),
|
||||
created_at: project.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a validated [`Project`] from this meta and its (registry-known)
|
||||
/// root.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError::Invalid`] if the stored fields violate a domain
|
||||
/// invariant (e.g. empty name).
|
||||
pub fn into_project(self, root: ProjectPath) -> Result<Project, AppError> {
|
||||
Project::new(self.id, self.name, root, self.remote, self.created_at)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises a value to pretty JSON bytes, mapping failures to [`AppError`].
|
||||
pub(crate) fn to_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, AppError> {
|
||||
serde_json::to_vec_pretty(value)
|
||||
.map(|mut v| {
|
||||
v.push(b'\n');
|
||||
v
|
||||
})
|
||||
.map_err(|e| AppError::Store(format!("serialize failed: {e}")))
|
||||
}
|
||||
|
||||
/// Deserialises JSON bytes, mapping failures to [`AppError`].
|
||||
pub(crate) fn from_json_bytes<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, AppError> {
|
||||
serde_json::from_slice(bytes).map_err(|e| AppError::Store(format!("deserialize failed: {e}")))
|
||||
}
|
||||
|
||||
/// Joins a project root with a relative path segment using a POSIX-style
|
||||
/// separator. Paths inside `.ideai/` are always written with `/`, which is valid
|
||||
/// on every platform we target (Windows `tokio::fs` accepts `/`).
|
||||
pub(crate) fn join_root(root: &ProjectPath, rel: &str) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
24
crates/application/src/project/mod.rs
Normal file
24
crates/application/src/project/mod.rs
Normal file
@ -0,0 +1,24 @@
|
||||
//! Project life-cycle use cases (ARCHITECTURE §6, L2).
|
||||
//!
|
||||
//! Each use case is a struct carrying its ports as `Arc<dyn Port>` and exposing
|
||||
//! a single `execute(input) -> Result<output, AppError>` method
|
||||
//! (**Single Responsibility**). They talk **only** to the domain ports, never to
|
||||
//! concrete adapters; the composition root injects the implementations.
|
||||
//!
|
||||
//! - [`CreateProject`] — validate the root, create `.ideai/project.json`,
|
||||
//! register the project, publish [`domain::DomainEvent::ProjectCreated`].
|
||||
//! - [`OpenProject`] — load a project and its `.ideai/project.json` meta (plus,
|
||||
//! tolerantly, the agent manifest if present).
|
||||
//! - [`CloseProject`] / [`CloseTab`] — persist state and release resources
|
||||
//! (no PTYs yet in L2).
|
||||
//! - [`ListProjects`] — list known projects from the registry.
|
||||
|
||||
mod close;
|
||||
mod create;
|
||||
pub(crate) mod meta;
|
||||
mod open;
|
||||
|
||||
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
||||
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
|
||||
pub use meta::ProjectMeta;
|
||||
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput};
|
||||
115
crates/application/src/project/open.rs
Normal file
115
crates/application/src/project/open.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! [`OpenProject`] and [`ListProjects`] (ARCHITECTURE §6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, ProjectStore, RemotePath};
|
||||
use domain::{AgentManifest, Project, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::meta::{
|
||||
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
|
||||
};
|
||||
|
||||
/// Input for [`OpenProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenProjectInput {
|
||||
/// Id of the project to open (as known by the registry).
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`OpenProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenProjectOutput {
|
||||
/// The opened project (registry source of truth for id/name/root/remote).
|
||||
pub project: Project,
|
||||
/// The project-local meta read from `.ideai/project.json`, if present and
|
||||
/// readable. Read tolerantly: a missing/corrupt file does not fail the open.
|
||||
pub meta: Option<ProjectMeta>,
|
||||
/// The agent manifest read from `.ideai/agents.json`, if present. Tolerant:
|
||||
/// absent in a brand-new project.
|
||||
pub manifest: Option<AgentManifest>,
|
||||
}
|
||||
|
||||
/// Loads a project, its `.ideai/project.json` meta and (tolerantly) its manifest.
|
||||
pub struct OpenProject {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl OpenProject {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { store, fs }
|
||||
}
|
||||
|
||||
/// Executes the open.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the registry has no such project,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
///
|
||||
/// Reading the `.ideai/` files is **tolerant**: their absence or a parse
|
||||
/// failure yields `None` rather than an error, so a project whose `.ideai/`
|
||||
/// was deleted can still be opened.
|
||||
pub async fn execute(&self, input: OpenProjectInput) -> Result<OpenProjectOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
|
||||
let meta = self
|
||||
.read_optional_json::<ProjectMeta>(&project, PROJECT_FILE)
|
||||
.await;
|
||||
let manifest = self
|
||||
.read_optional_json::<AgentManifest>(&project, AGENTS_FILE)
|
||||
.await;
|
||||
|
||||
Ok(OpenProjectOutput {
|
||||
project,
|
||||
meta,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads and parses a JSON file inside the project's `.ideai/`, tolerating
|
||||
/// any failure (missing file, I/O error, parse error) by returning `None`.
|
||||
async fn read_optional_json<T: for<'de> serde::Deserialize<'de>>(
|
||||
&self,
|
||||
project: &Project,
|
||||
file: &str,
|
||||
) -> Option<T> {
|
||||
let path = RemotePath::new(join_root(&project.root, &format!("{IDEAI_DIR}/{file}")));
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => from_json_bytes::<T>(&bytes).ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of [`ListProjects::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListProjectsOutput {
|
||||
/// All projects known to the registry.
|
||||
pub projects: Vec<Project>,
|
||||
}
|
||||
|
||||
/// Lists the projects known to the registry.
|
||||
pub struct ListProjects {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
impl ListProjects {
|
||||
/// Builds the use case from its injected port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the listing.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(&self) -> Result<ListProjectsOutput, AppError> {
|
||||
let projects = self.store.list_projects().await?;
|
||||
Ok(ListProjectsOutput { projects })
|
||||
}
|
||||
}
|
||||
6
crates/application/src/remote/mod.rs
Normal file
6
crates/application/src/remote/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! Remote use cases (ARCHITECTURE §6, L9). Connecting a project's
|
||||
//! [`domain::ports::RemoteHost`] (local / SSH / WSL) and validating its root.
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
75
crates/application/src/remote/usecases.rs
Normal file
75
crates/application/src/remote/usecases.rs
Normal file
@ -0,0 +1,75 @@
|
||||
//! Remote-connection use case (ARCHITECTURE §6, L9).
|
||||
//!
|
||||
//! [`ConnectRemote`] establishes a project's [`RemoteHost`] and validates that
|
||||
//! its root is reachable on the host's filesystem. It speaks only to the
|
||||
//! [`RemoteHost`] port, so it behaves identically for a local, SSH or WSL host
|
||||
//! (Liskov) and is fully testable with a mock host.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, RemoteHost, RemotePath};
|
||||
use domain::{DomainEvent, ProjectId, RemoteKind};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`ConnectRemote::execute`].
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectRemoteInput {
|
||||
/// The host strategy to connect through (built from the project's `RemoteRef`).
|
||||
pub host: Arc<dyn RemoteHost>,
|
||||
/// The project being connected (for the emitted event).
|
||||
pub project_id: ProjectId,
|
||||
/// Absolute root path to validate on the host.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Output of [`ConnectRemote::execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ConnectRemoteOutput {
|
||||
/// The kind of host that was connected.
|
||||
pub kind: RemoteKind,
|
||||
}
|
||||
|
||||
/// Connects a project's remote host and checks its root is reachable.
|
||||
pub struct ConnectRemote {
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl ConnectRemote {
|
||||
/// Builds the use case from the [`EventBus`].
|
||||
#[must_use]
|
||||
pub fn new(events: Arc<dyn EventBus>) -> Self {
|
||||
Self { events }
|
||||
}
|
||||
|
||||
/// Connects the host and validates the root exists, announcing
|
||||
/// [`DomainEvent::RemoteConnected`] on success.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Remote`] if the connection fails,
|
||||
/// - [`AppError::NotFound`] if the root does not exist on the host,
|
||||
/// - [`AppError::FileSystem`] on an I/O failure while probing the root.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ConnectRemoteInput,
|
||||
) -> Result<ConnectRemoteOutput, AppError> {
|
||||
input.host.connect().await?;
|
||||
|
||||
let fs = input.host.file_system();
|
||||
let exists = fs.exists(&RemotePath::new(input.root.clone())).await?;
|
||||
if !exists {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"remote root {} is not reachable",
|
||||
input.root
|
||||
)));
|
||||
}
|
||||
|
||||
self.events.publish(DomainEvent::RemoteConnected {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
|
||||
Ok(ConnectRemoteOutput {
|
||||
kind: input.host.kind(),
|
||||
})
|
||||
}
|
||||
}
|
||||
17
crates/application/src/template/mod.rs
Normal file
17
crates/application/src/template/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7).
|
||||
//!
|
||||
//! Templates are reusable agent contexts stored in the global IDE store, with a
|
||||
//! monotonic version. This module owns their CRUD, the template→agent
|
||||
//! instantiation, and the drift-detection / synchronisation flow that keeps
|
||||
//! `synchronized` agents in step with their template.
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
||||
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
|
||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||
UpdateTemplateOutput,
|
||||
};
|
||||
495
crates/application/src/template/usecases.rs
Normal file
495
crates/application/src/template/usecases.rs
Normal file
@ -0,0 +1,495 @@
|
||||
//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7).
|
||||
//!
|
||||
//! Two concerns live here:
|
||||
//! - **Templates** (global IDE store): CRUD + monotonic versioning
|
||||
//! ([`CreateTemplate`], [`UpdateTemplate`], [`ListTemplates`], [`DeleteTemplate`]).
|
||||
//! - **Template → agent link**: instantiating an agent from a template
|
||||
//! ([`CreateAgentFromTemplate`]), detecting when a synchronized agent is behind
|
||||
//! its template ([`DetectAgentDrift`]), and applying the update
|
||||
//! ([`SyncAgentWithTemplate`]).
|
||||
//!
|
||||
//! Every use case talks only to ports ([`TemplateStore`], [`AgentContextStore`],
|
||||
//! [`IdGenerator`], [`EventBus`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry,
|
||||
MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion,
|
||||
};
|
||||
|
||||
use crate::agent::unique_md_path;
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateTemplateInput {
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Initial Markdown content.
|
||||
pub content: String,
|
||||
/// Default runtime profile for agents created from this template.
|
||||
pub default_profile_id: ProfileId,
|
||||
}
|
||||
|
||||
/// Output of [`CreateTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateTemplateOutput {
|
||||
/// The created template (at [`TemplateVersion::INITIAL`]).
|
||||
pub template: AgentTemplate,
|
||||
}
|
||||
|
||||
/// Creates a template in the global store at the initial version.
|
||||
pub struct CreateTemplate {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CreateTemplate {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(templates: Arc<dyn TemplateStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { templates, ids }
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the name is empty,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CreateTemplateInput,
|
||||
) -> Result<CreateTemplateOutput, AppError> {
|
||||
let id = TemplateId::from_uuid(self.ids.new_uuid());
|
||||
let template = AgentTemplate::new(
|
||||
id,
|
||||
input.name,
|
||||
MarkdownDoc::new(input.content),
|
||||
input.default_profile_id,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.templates.save(&template).await?;
|
||||
Ok(CreateTemplateOutput { template })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateTemplateInput {
|
||||
/// Template to update.
|
||||
pub template_id: TemplateId,
|
||||
/// New Markdown content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateTemplateOutput {
|
||||
/// The updated template (version bumped by one).
|
||||
pub template: AgentTemplate,
|
||||
}
|
||||
|
||||
/// Updates a template's content and **bumps its version** (monotonic, §8.1),
|
||||
/// announcing [`DomainEvent::TemplateUpdated`] so drift can be re-evaluated.
|
||||
pub struct UpdateTemplate {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateTemplate {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(templates: Arc<dyn TemplateStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { templates, events }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the template is unknown,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateTemplateInput,
|
||||
) -> Result<UpdateTemplateOutput, AppError> {
|
||||
let current = self.templates.get(input.template_id).await?;
|
||||
let updated = current.with_updated_content(MarkdownDoc::new(input.content));
|
||||
self.templates.save(&updated).await?;
|
||||
self.events.publish(DomainEvent::TemplateUpdated {
|
||||
template_id: updated.id,
|
||||
version: updated.version,
|
||||
});
|
||||
Ok(UpdateTemplateOutput { template: updated })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListTemplates / DeleteTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ListTemplates::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListTemplatesOutput {
|
||||
/// All templates in the global store.
|
||||
pub templates: Vec<AgentTemplate>,
|
||||
}
|
||||
|
||||
/// Lists the templates in the global store.
|
||||
pub struct ListTemplates {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
}
|
||||
|
||||
impl ListTemplates {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
||||
Self { templates }
|
||||
}
|
||||
|
||||
/// Lists templates.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<ListTemplatesOutput, AppError> {
|
||||
Ok(ListTemplatesOutput {
|
||||
templates: self.templates.list().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteTemplateInput {
|
||||
/// Template to delete.
|
||||
pub template_id: TemplateId,
|
||||
}
|
||||
|
||||
/// Deletes a template from the global store.
|
||||
///
|
||||
/// Agents previously created from it keep their `.md` (their `origin` still
|
||||
/// references the now-absent template; drift detection simply finds nothing to
|
||||
/// compare against).
|
||||
pub struct DeleteTemplate {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
}
|
||||
|
||||
impl DeleteTemplate {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
||||
Self { templates }
|
||||
}
|
||||
|
||||
/// Deletes the template.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the template is unknown,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteTemplateInput) -> Result<(), AppError> {
|
||||
self.templates.delete(input.template_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateAgentFromTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAgentFromTemplateInput {
|
||||
/// The project that owns the agent.
|
||||
pub project: Project,
|
||||
/// Source template.
|
||||
pub template_id: TemplateId,
|
||||
/// Optional agent name; defaults to the template's name.
|
||||
pub name: Option<String>,
|
||||
/// Whether the agent tracks the template (`true` ⇒ future syncs apply).
|
||||
pub synchronized: bool,
|
||||
}
|
||||
|
||||
/// Output of [`CreateAgentFromTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAgentFromTemplateOutput {
|
||||
/// The created agent.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
/// Instantiates a project agent from a template: copies the template's
|
||||
/// `content_md` into the agent's `.md`, links the origin to the template at its
|
||||
/// current version, and records the manifest entry.
|
||||
pub struct CreateAgentFromTemplate {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateAgentFromTemplate {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
templates,
|
||||
contexts,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes creation from a template.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the template is unknown,
|
||||
/// - [`AppError::Invalid`] if the resulting agent/manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CreateAgentFromTemplateInput,
|
||||
) -> Result<CreateAgentFromTemplateOutput, AppError> {
|
||||
let template = self.templates.get(input.template_id).await?;
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
|
||||
let name = input.name.unwrap_or_else(|| template.name.clone());
|
||||
let id = AgentId::from_uuid(self.ids.new_uuid());
|
||||
let md_path = unique_md_path(&name, &manifest);
|
||||
let origin = AgentOrigin::FromTemplate {
|
||||
template_id: template.id,
|
||||
synced_template_version: template.version,
|
||||
};
|
||||
let agent = Agent::new(
|
||||
id,
|
||||
name,
|
||||
md_path,
|
||||
template.default_profile_id,
|
||||
origin,
|
||||
input.synchronized,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let mut entries = manifest.entries;
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
|
||||
// Seed the agent context with the template content.
|
||||
self.contexts
|
||||
.write_context(&input.project, &agent.id, &template.content_md)
|
||||
.await?;
|
||||
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
|
||||
Ok(CreateAgentFromTemplateOutput { agent })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectAgentDrift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One drifting agent: its template moved ahead of the agent's synced version.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentDrift {
|
||||
/// The drifting agent.
|
||||
pub agent_id: AgentId,
|
||||
/// Version the agent is currently synced to.
|
||||
pub from: TemplateVersion,
|
||||
/// Version available from the template.
|
||||
pub to: TemplateVersion,
|
||||
}
|
||||
|
||||
/// Input for [`DetectAgentDrift::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectAgentDriftInput {
|
||||
/// The project whose agents to check.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output of [`DetectAgentDrift::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectAgentDriftOutput {
|
||||
/// Agents whose template has a newer version (only `synchronized` ones).
|
||||
pub drifts: Vec<AgentDrift>,
|
||||
}
|
||||
|
||||
/// Detects which synchronized agents are behind their template
|
||||
/// (`template.version > synced_template_version`, §8.2), announcing
|
||||
/// [`DomainEvent::AgentDriftDetected`] for each.
|
||||
pub struct DetectAgentDrift {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DetectAgentDrift {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
templates,
|
||||
contexts,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the drift set.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure (a *deleted* template is not an
|
||||
/// error — that agent simply has nothing to drift against).
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: DetectAgentDriftInput,
|
||||
) -> Result<DetectAgentDriftOutput, AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let mut drifts = Vec::new();
|
||||
for entry in &manifest.entries {
|
||||
// Only synchronized, template-backed agents can drift.
|
||||
if !entry.synchronized {
|
||||
continue;
|
||||
}
|
||||
let (Some(template_id), Some(synced)) =
|
||||
(entry.template_id, entry.synced_template_version)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let template = match self.templates.get(template_id).await {
|
||||
Ok(t) => t,
|
||||
Err(StoreError::NotFound) => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if template.version > synced {
|
||||
let drift = AgentDrift {
|
||||
agent_id: entry.agent_id,
|
||||
from: synced,
|
||||
to: template.version,
|
||||
};
|
||||
self.events.publish(DomainEvent::AgentDriftDetected {
|
||||
agent_id: drift.agent_id,
|
||||
from: drift.from,
|
||||
to: drift.to,
|
||||
});
|
||||
drifts.push(drift);
|
||||
}
|
||||
}
|
||||
Ok(DetectAgentDriftOutput { drifts })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SyncAgentWithTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SyncAgentWithTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SyncAgentWithTemplateInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent to bring up to date.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output of [`SyncAgentWithTemplate::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SyncAgentWithTemplateOutput {
|
||||
/// Whether an update was applied (`false` for a non-synchronized or
|
||||
/// scratch agent — those are intentionally left untouched, §8.4).
|
||||
pub synced: bool,
|
||||
/// The version the agent is now at, when a sync happened.
|
||||
pub version: Option<TemplateVersion>,
|
||||
}
|
||||
|
||||
/// Applies a template update to a synchronized agent: **replaces** the agent's
|
||||
/// `.md` with the template content (the context of a synchronized agent is
|
||||
/// "owned" by the template, §8.3) and records the new synced version. Agents
|
||||
/// that are not `synchronized` (or are `scratch`) are left untouched.
|
||||
pub struct SyncAgentWithTemplate {
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl SyncAgentWithTemplate {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
templates: Arc<dyn TemplateStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
templates,
|
||||
contexts,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the sync for one agent.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent or its template is unknown,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SyncAgentWithTemplateInput,
|
||||
) -> Result<SyncAgentWithTemplateOutput, AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let idx = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let entry = &manifest.entries[idx];
|
||||
|
||||
// Non-synchronized / scratch agents are never auto-updated (§8.4).
|
||||
let Some(template_id) = entry.template_id.filter(|_| entry.synchronized) else {
|
||||
return Ok(SyncAgentWithTemplateOutput {
|
||||
synced: false,
|
||||
version: None,
|
||||
});
|
||||
};
|
||||
|
||||
let template = self.templates.get(template_id).await?;
|
||||
manifest.entries[idx].synced_template_version = Some(template.version);
|
||||
|
||||
// Persist the manifest (revalidated) and overwrite the agent context.
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.write_context(&input.project, &input.agent_id, &template.content_md)
|
||||
.await?;
|
||||
|
||||
self.events.publish(DomainEvent::AgentSynced {
|
||||
agent_id: input.agent_id,
|
||||
to: template.version,
|
||||
});
|
||||
|
||||
Ok(SyncAgentWithTemplateOutput {
|
||||
synced: true,
|
||||
version: Some(template.version),
|
||||
})
|
||||
}
|
||||
}
|
||||
35
crates/application/src/terminal/mod.rs
Normal file
35
crates/application/src/terminal/mod.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Terminal use cases (ARCHITECTURE §6, L3).
|
||||
//!
|
||||
//! Each use case is a struct carrying its ports as `Arc<dyn Port>` and exposing a
|
||||
//! single `execute(input) -> Result<output, AppError>` method (**Single
|
||||
//! Responsibility**). They talk **only** to the [`domain::ports::PtyPort`] (plus
|
||||
//! the [`domain::ports::EventBus`] for `OpenTerminal`); the composition root
|
||||
//! injects the concrete [`infrastructure::PortablePtyAdapter`].
|
||||
//!
|
||||
//! - [`OpenTerminal`] — resolve the cwd, spawn a PTY, create a
|
||||
//! [`domain::TerminalSession`], register the live handle, publish an event.
|
||||
//! - [`WriteToTerminal`] — forward bytes (keystrokes) to a PTY.
|
||||
//! - [`ResizeTerminal`] — resize a PTY.
|
||||
//! - [`CloseTerminal`] — kill a PTY and forget its handle.
|
||||
//!
|
||||
//! # Where the active-session registry lives, and why
|
||||
//!
|
||||
//! The domain [`PtyPort`] is *handle-oriented*: `spawn` returns a
|
||||
//! [`domain::ports::PtyHandle`] that every later call (`write`/`resize`/`kill`)
|
||||
//! must reference. Something has to remember, per [`SessionId`], the live
|
||||
//! `PtyHandle` (and the `TerminalSession` snapshot) between IPC calls. That state
|
||||
//! is **not domain state** (it is the in-flight wiring of an I/O resource), and
|
||||
//! it must not live in the adapter alone (other use cases need to address a
|
||||
//! session by id). It therefore lives in [`TerminalSessions`], an **application
|
||||
//! service** injected into the terminal use cases (and held in the composition
|
||||
//! root behind an `Arc`). This keeps the domain pure and the registry shared,
|
||||
//! testable, and transport-agnostic.
|
||||
|
||||
mod registry;
|
||||
mod usecases;
|
||||
|
||||
pub use registry::TerminalSessions;
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
81
crates/application/src/terminal/registry.rs
Normal file
81
crates/application/src/terminal/registry.rs
Normal file
@ -0,0 +1,81 @@
|
||||
//! [`TerminalSessions`] — the active-terminal registry (application service).
|
||||
//!
|
||||
//! Maps a [`SessionId`] to the live [`PtyHandle`] and the [`TerminalSession`]
|
||||
//! snapshot. Thread-safe (behind a [`Mutex`]); a single instance is shared
|
||||
//! (as `Arc`) by all terminal use cases via the composition root. See the module
|
||||
//! docs in `terminal/mod.rs` for the rationale of keeping this in the
|
||||
//! application layer rather than the domain or the adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::{SessionId, TerminalSession};
|
||||
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
handle: PtyHandle,
|
||||
session: TerminalSession,
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalSessions {
|
||||
entries: Mutex<HashMap<SessionId, Entry>>,
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
/// Creates an empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a freshly-opened session.
|
||||
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.insert(session.id, Entry { handle, session });
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`] for a session, if registered.
|
||||
#[must_use]
|
||||
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| e.handle.clone()))
|
||||
}
|
||||
|
||||
/// Returns the [`TerminalSession`] snapshot for a session, if registered.
|
||||
#[must_use]
|
||||
pub fn session(&self, id: &SessionId) -> Option<TerminalSession> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
||||
}
|
||||
|
||||
/// Removes a session from the registry, returning its handle if present.
|
||||
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(id).map(|e| e.handle))
|
||||
}
|
||||
|
||||
/// Number of currently-registered sessions.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
250
crates/application/src/terminal/usecases.rs
Normal file
250
crates/application/src/terminal/usecases.rs
Normal file
@ -0,0 +1,250 @@
|
||||
//! The four terminal use cases (ARCHITECTURE §6, L3).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, PtyPort, SpawnSpec};
|
||||
use domain::{
|
||||
DomainEvent, NodeId, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus,
|
||||
TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::registry::TerminalSessions;
|
||||
|
||||
/// Input for [`OpenTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenTerminalInput {
|
||||
/// Working directory for the shell (absolute path; defaults applied by the
|
||||
/// caller — typically the project root).
|
||||
pub cwd: String,
|
||||
/// Initial terminal height in rows.
|
||||
pub rows: u16,
|
||||
/// Initial terminal width in columns.
|
||||
pub cols: u16,
|
||||
/// Command to run. `None` ⇒ the platform default login shell.
|
||||
pub command: Option<String>,
|
||||
/// Arguments for the command.
|
||||
pub args: Vec<String>,
|
||||
/// The layout leaf hosting this session. `None` ⇒ a fresh node id (L4 will
|
||||
/// thread the real layout node through here).
|
||||
pub node_id: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// Output of [`OpenTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenTerminalOutput {
|
||||
/// The created terminal session (its `id` is the [`SessionId`] minted by the
|
||||
/// PTY layer and reused everywhere — write/resize/close, the output channel).
|
||||
pub session: TerminalSession,
|
||||
}
|
||||
|
||||
/// Opens a PTY in a cwd, creates a [`TerminalSession`], registers the handle.
|
||||
pub struct OpenTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl OpenTerminal {
|
||||
/// Builds the use case from its injected ports/services.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pty,
|
||||
sessions,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the open: validate cwd + size, spawn the PTY, snapshot the
|
||||
/// session, register the live handle, publish [`DomainEvent::LayoutChanged`].
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
|
||||
/// - [`AppError::Process`] if the PTY fails to spawn.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: OpenTerminalInput,
|
||||
) -> Result<OpenTerminalOutput, AppError> {
|
||||
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let command = input.command.unwrap_or_else(default_shell);
|
||||
let spec = SpawnSpec {
|
||||
command,
|
||||
args: input.args,
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
};
|
||||
|
||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||
// id as the `TerminalSession.id` (single source of truth, ARCHITECTURE §4).
|
||||
let handle = self.pty.spawn(spec, size).await?;
|
||||
let session_id = handle.session_id;
|
||||
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
|
||||
|
||||
let mut session =
|
||||
TerminalSession::starting(session_id, node_id, cwd, SessionKind::Plain, size);
|
||||
session.status = SessionStatus::Running;
|
||||
|
||||
self.sessions.insert(handle, session.clone());
|
||||
|
||||
// Output streaming + per-session Channel wiring happens in the presentation
|
||||
// layer (it owns the transport). Announce so the UI can react.
|
||||
self.events.publish(DomainEvent::PtyOutput {
|
||||
session_id,
|
||||
bytes: Vec::new(),
|
||||
});
|
||||
|
||||
Ok(OpenTerminalOutput { session })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`WriteToTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WriteToTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
/// Bytes to write (typically keystrokes from xterm.js).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Forwards bytes (keystrokes) to a live PTY.
|
||||
pub struct WriteToTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl WriteToTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Writes to the session's PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] on PTY I/O failure.
|
||||
pub fn execute(&self, input: WriteToTerminalInput) -> Result<(), AppError> {
|
||||
let handle = self
|
||||
.sessions
|
||||
.handle(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
self.pty.write(&handle, &input.data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResizeTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResizeTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
/// New height in rows.
|
||||
pub rows: u16,
|
||||
/// New width in columns.
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Resizes a live PTY.
|
||||
pub struct ResizeTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl ResizeTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Resizes the session's PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a zero-sized terminal,
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] on PTY failure.
|
||||
pub fn execute(&self, input: ResizeTerminalInput) -> Result<(), AppError> {
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let handle = self
|
||||
.sessions
|
||||
.handle(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
self.pty.resize(&handle, size)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`CloseTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloseTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
/// Output of [`CloseTerminal::execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CloseTerminalOutput {
|
||||
/// Exit code reported by the killed process (`None` if signalled).
|
||||
pub code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Kills a live PTY and forgets its handle.
|
||||
pub struct CloseTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl CloseTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Kills the session's PTY and removes it from the registry. Idempotent on
|
||||
/// the registry side (removing an unknown session is a no-op error).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] if the kill fails.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloseTerminalInput,
|
||||
) -> Result<CloseTerminalOutput, AppError> {
|
||||
let handle = self
|
||||
.sessions
|
||||
.remove(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
let status = self.pty.kill(&handle).await?;
|
||||
Ok(CloseTerminalOutput { code: status.code })
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform default interactive shell.
|
||||
///
|
||||
/// Resolution policy lives in the application layer (a metier default), not the
|
||||
/// adapter, so it is uniform and testable. On Unix we honour `$SHELL`, falling
|
||||
/// back to `/bin/sh`; on Windows we use `cmd.exe` (a ConPTY spike point — PowerShell
|
||||
/// could become the default, ARCHITECTURE §13.1).
|
||||
fn default_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_owned())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())
|
||||
}
|
||||
}
|
||||
6
crates/application/src/window/mod.rs
Normal file
6
crates/application/src/window/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! Window/tab use cases (ARCHITECTURE §6, §10; L10). Detaching a tab into a new
|
||||
//! OS window, persisting the workspace.
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
73
crates/application/src/window/usecases.rs
Normal file
73
crates/application/src/window/usecases.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Window/tab use cases (ARCHITECTURE §6, §10; L10).
|
||||
//!
|
||||
//! [`MoveTabToNewWindow`] detaches a tab into a new OS window. The topology
|
||||
//! change is the pure [`Workspace::move_tab_to_new_window`] domain operation; the
|
||||
//! use case only loads/persists the workspace and mints the new window id.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::{TabId, WindowId};
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{IdGenerator, ProjectStore};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`MoveTabToNewWindow::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MoveTabToNewWindowInput {
|
||||
/// The tab to detach into its own new window.
|
||||
pub tab_id: TabId,
|
||||
}
|
||||
|
||||
/// Output of [`MoveTabToNewWindow::execute`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MoveTabToNewWindowOutput {
|
||||
/// The id minted for the new window.
|
||||
pub new_window_id: WindowId,
|
||||
/// The resulting workspace (already persisted).
|
||||
pub workspace: Workspace,
|
||||
}
|
||||
|
||||
/// Detaches a tab into a freshly-created window and persists the workspace.
|
||||
pub struct MoveTabToNewWindow {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl MoveTabToNewWindow {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { store, ids }
|
||||
}
|
||||
|
||||
/// Executes the detach.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the tab is not in the workspace,
|
||||
/// - [`AppError::Invalid`] if the resulting window is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: MoveTabToNewWindowInput,
|
||||
) -> Result<MoveTabToNewWindowOutput, AppError> {
|
||||
let workspace = self.store.load_workspace().await?;
|
||||
let new_window_id = WindowId::from_uuid(self.ids.new_uuid());
|
||||
|
||||
let workspace = workspace
|
||||
.move_tab_to_new_window(input.tab_id, new_window_id)
|
||||
.map_err(|e| match e {
|
||||
domain::layout::LayoutError::TabNotFound(t) => {
|
||||
AppError::NotFound(format!("tab {t}"))
|
||||
}
|
||||
other => AppError::Invalid(other.to_string()),
|
||||
})?;
|
||||
|
||||
self.store.save_workspace(&workspace).await?;
|
||||
|
||||
Ok(MoveTabToNewWindowOutput {
|
||||
new_window_id,
|
||||
workspace,
|
||||
})
|
||||
}
|
||||
}
|
||||
681
crates/application/tests/agent_lifecycle.rs
Normal file
681
crates/application/tests/agent_lifecycle.rs
Normal file
@ -0,0 +1,681 @@
|
||||
//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`,
|
||||
//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`,
|
||||
//! `LaunchAgent`).
|
||||
//!
|
||||
//! Every port is faked in-memory so the use cases run without real I/O:
|
||||
//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a
|
||||
//! `md_path → content` map,
|
||||
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
||||
//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the
|
||||
//! call into a shared **trace** and returns a configurable injection plan,
|
||||
//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace,
|
||||
//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace,
|
||||
//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator.
|
||||
//!
|
||||
//! The shared trace lets us assert the **call ordering** contract of
|
||||
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PtySize, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
|
||||
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared trace (ordering)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Trace = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the
|
||||
/// target (a path for the fs, a [`SessionId`] for the pty).
|
||||
type WriteLog<K> = Arc<Mutex<Vec<(K, Vec<u8>)>>>;
|
||||
|
||||
fn trace() -> Trace {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContextsInner {
|
||||
manifest: AgentManifest,
|
||||
contents: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
||||
|
||||
impl FakeContexts {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(ContextsInner {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
}
|
||||
fn with_agent(agent: &Agent, content: &str) -> Self {
|
||||
let me = Self::new();
|
||||
{
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.contents
|
||||
.insert(agent.context_path.clone(), content.to_owned());
|
||||
}
|
||||
me
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
self.0.lock().unwrap().manifest.clone()
|
||||
}
|
||||
fn content(&self, md_path: &str) -> Option<String> {
|
||||
self.0.lock().unwrap().contents.get(md_path).cloned()
|
||||
}
|
||||
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| &e.agent_id == agent)
|
||||
.map(|e| e.md_path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.content(&md_path)
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contents
|
||||
.insert(md_path, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().manifest = manifest.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeProfiles (ProfileStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
||||
|
||||
impl FakeProfiles {
|
||||
fn new(profiles: Vec<AgentProfile>) -> Self {
|
||||
Self(Arc::new(profiles))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FakeRuntime {
|
||||
trace: Trace,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
}
|
||||
|
||||
impl FakeRuntime {
|
||||
fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> Self {
|
||||
Self { trace, plan }
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentRuntime for FakeRuntime {
|
||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
self.trace.lock().unwrap().push("prepare".to_owned());
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
args: profile.args.clone(),
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: self.plan.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeFs (FileSystem) — records writes into the trace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeFs {
|
||||
trace: Trace,
|
||||
writes: WriteLog<String>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
fn new(trace: Trace) -> Self {
|
||||
Self {
|
||||
trace,
|
||||
writes: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.trace.lock().unwrap().push("fs.write".to_owned());
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakePty (PtyPort) — records spawn into the trace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePty {
|
||||
trace: Trace,
|
||||
next_id: SessionId,
|
||||
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
|
||||
writes: WriteLog<SessionId>,
|
||||
}
|
||||
|
||||
impl FakePty {
|
||||
fn new(trace: Trace, next_id: SessionId) -> Self {
|
||||
Self {
|
||||
trace,
|
||||
next_id,
|
||||
spawns: Arc::new(Mutex::new(Vec::new())),
|
||||
writes: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
fn spawns(&self) -> Vec<SpawnSpec> {
|
||||
self.spawns.lock().unwrap().clone()
|
||||
}
|
||||
fn writes(&self) -> Vec<(SessionId, Vec<u8>)> {
|
||||
self.writes.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PtyPort for FakePty {
|
||||
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||
self.trace.lock().unwrap().push("spawn".to_owned());
|
||||
self.spawns.lock().unwrap().push(spec);
|
||||
Ok(PtyHandle {
|
||||
session_id: self.next_id,
|
||||
})
|
||||
}
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((handle.session_id, data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
Ok(())
|
||||
}
|
||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
Ok(ExitStatus { code: Some(0) })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpyBus + SeqIds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
Self(Mutex::new(1))
|
||||
}
|
||||
}
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*n);
|
||||
*n += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
injection,
|
||||
Some("claude --version".to_owned()),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent {
|
||||
Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromScratch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_persists_manifest_entry_and_initial_context() {
|
||||
let contexts = FakeContexts::new();
|
||||
let bus = SpyBus::default();
|
||||
let create = CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
|
||||
let out = create
|
||||
.execute(CreateAgentInput {
|
||||
project: project(),
|
||||
name: "Backend Dev".to_owned(),
|
||||
profile_id: pid(9),
|
||||
initial_content: Some("# Backend".to_owned()),
|
||||
})
|
||||
.await
|
||||
.expect("create succeeds");
|
||||
|
||||
// md_path is slugified from the name.
|
||||
assert_eq!(out.agent.context_path, "agents/backend-dev.md");
|
||||
assert_eq!(out.agent.profile_id, pid(9));
|
||||
assert!(matches!(out.agent.origin, AgentOrigin::Scratch));
|
||||
assert!(!out.agent.synchronized);
|
||||
|
||||
// Manifest has exactly one entry for this agent; context stored under md_path.
|
||||
let manifest = contexts.manifest();
|
||||
assert_eq!(manifest.entries.len(), 1);
|
||||
assert_eq!(manifest.entries[0].agent_id, out.agent.id);
|
||||
assert_eq!(
|
||||
contexts.content("agents/backend-dev.md").as_deref(),
|
||||
Some("# Backend")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_disambiguates_md_path_on_name_collision() {
|
||||
// Seed a project that already has `agents/backend.md`.
|
||||
let existing = scratch_agent(aid(50), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&existing, "old");
|
||||
let create = CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
|
||||
let out = create
|
||||
.execute(CreateAgentInput {
|
||||
project: project(),
|
||||
name: "Backend".to_owned(),
|
||||
profile_id: pid(9),
|
||||
initial_content: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent.context_path, "agents/backend-2.md");
|
||||
assert_eq!(contexts.manifest().entries.len(), 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListAgents / Read / Update / Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_reconstructs_agents_from_manifest() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.agents, vec![a]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "original");
|
||||
let read = ReadAgentContext::new(Arc::new(contexts.clone()));
|
||||
let update = UpdateAgentContext::new(Arc::new(contexts.clone()));
|
||||
|
||||
let before = read
|
||||
.execute(ReadAgentContextInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before.content.as_str(), "original");
|
||||
|
||||
update
|
||||
.execute(UpdateAgentContextInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
content: "edited".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(SpyBus::default()));
|
||||
|
||||
delete
|
||||
.execute(DeleteAgentInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(contexts.manifest().entries.is_empty());
|
||||
|
||||
// Second delete: the agent is gone → NotFound.
|
||||
let err = delete
|
||||
.execute(DeleteAgentInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaunchAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything a launch test needs to drive `LaunchAgent` and assert over the
|
||||
/// fakes: the use case, the seeded agent, the recording fs/pty, the event spy,
|
||||
/// the session registry and the shared ordering trace.
|
||||
type LaunchFixture = (
|
||||
LaunchAgent,
|
||||
Agent,
|
||||
FakeFs,
|
||||
FakePty,
|
||||
SpyBus,
|
||||
Arc<TerminalSessions>,
|
||||
Trace,
|
||||
);
|
||||
|
||||
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
|
||||
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
||||
let tr = trace();
|
||||
let runtime = FakeRuntime::new(Arc::clone(&tr), plan);
|
||||
let fs = FakeFs::new(Arc::clone(&tr));
|
||||
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(runtime),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
(launch, agent, fs, pty, bus, sessions, tr)
|
||||
}
|
||||
|
||||
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
||||
LaunchAgentInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
node_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
// conventionFile strategy → an fs.write must happen between prepare and spawn.
|
||||
let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
// Ordering contract.
|
||||
assert_eq!(
|
||||
*tr.lock().unwrap(),
|
||||
vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()],
|
||||
"prepare → injection → spawn"
|
||||
);
|
||||
|
||||
// The conventionFile was written to <cwd>/CLAUDE.md with the context body.
|
||||
let writes = fs.writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md");
|
||||
assert_eq!(writes[0].1, b"# ctx body");
|
||||
|
||||
// Spawn happened at the resolved cwd with the profile command.
|
||||
let spawns = pty.spawns();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert_eq!(spawns[0].command, "claude");
|
||||
assert_eq!(spawns[0].cwd.as_str(), "/home/me/proj");
|
||||
|
||||
// The session adopts the PTY id, is Running, and is registered as an agent.
|
||||
assert_eq!(out.session.id, sid(777));
|
||||
assert!(matches!(
|
||||
out.session.kind,
|
||||
domain::SessionKind::Agent { agent_id } if agent_id == agent.id
|
||||
));
|
||||
assert!(sessions.session(&sid(777)).is_some());
|
||||
|
||||
// AgentLaunched announced.
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::AgentLaunched {
|
||||
agent_id: agent.id,
|
||||
session_id: sid(777),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
||||
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
// No file written for stdin; content is piped to the PTY post-spawn.
|
||||
assert!(fs.writes().is_empty(), "stdin must not write a file");
|
||||
assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]);
|
||||
let writes = pty.writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].0, sid(777));
|
||||
assert_eq!(writes[0].1, b"# ctx body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_unknown_agent_is_not_found() {
|
||||
let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture(
|
||||
ContextInjection::stdin(),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
let err = launch.execute(launch_input(aid(404))).await.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.spawns().is_empty(), "no spawn for unknown agent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_unknown_profile_is_not_found() {
|
||||
// The agent references pid(9) but the store only knows pid(1).
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&agent, "ctx");
|
||||
let profiles = FakeProfiles::new(vec![profile(pid(1), ContextInjection::stdin())]);
|
||||
let tr = trace();
|
||||
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||
let launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
||||
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
|
||||
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
|
||||
}
|
||||
57
crates/application/tests/error_codes.rs
Normal file
57
crates/application/tests/error_codes.rs
Normal file
@ -0,0 +1,57 @@
|
||||
//! L1 tests pinning the stable [`AppError::code`] strings the IPC `ErrorDto`
|
||||
//! relies on, and the per-port `From` mappings.
|
||||
|
||||
use application::AppError;
|
||||
use domain::ports::{FsError, GitError, ProcessError, PtyError, RemoteError, StoreError};
|
||||
|
||||
#[test]
|
||||
fn codes_are_stable() {
|
||||
assert_eq!(AppError::NotFound("x".into()).code(), "NOT_FOUND");
|
||||
assert_eq!(AppError::Invalid("x".into()).code(), "INVALID");
|
||||
assert_eq!(AppError::FileSystem("x".into()).code(), "FILESYSTEM");
|
||||
assert_eq!(AppError::Store("x".into()).code(), "STORE");
|
||||
assert_eq!(AppError::Process("x".into()).code(), "PROCESS");
|
||||
assert_eq!(AppError::Git("x".into()).code(), "GIT");
|
||||
assert_eq!(AppError::Remote("x".into()).code(), "REMOTE");
|
||||
assert_eq!(AppError::Internal("x".into()).code(), "INTERNAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_not_found_maps_to_not_found_other_to_filesystem() {
|
||||
assert_eq!(
|
||||
AppError::from(FsError::NotFound("/tmp/x".into())).code(),
|
||||
"NOT_FOUND"
|
||||
);
|
||||
assert_eq!(
|
||||
AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(),
|
||||
"FILESYSTEM"
|
||||
);
|
||||
assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_not_found_maps_to_not_found_other_to_store() {
|
||||
assert_eq!(AppError::from(StoreError::NotFound).code(), "NOT_FOUND");
|
||||
assert_eq!(
|
||||
AppError::from(StoreError::Serialization("bad".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pty_runtime_map_to_process() {
|
||||
assert_eq!(AppError::from(PtyError::NotFound).code(), "PROCESS");
|
||||
assert_eq!(
|
||||
AppError::from(ProcessError::Spawn("x".into())).code(),
|
||||
"PROCESS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_and_remote_map_through() {
|
||||
assert_eq!(AppError::from(GitError::NotFound).code(), "GIT");
|
||||
assert_eq!(
|
||||
AppError::from(RemoteError::Auth("nope".into())).code(),
|
||||
"REMOTE"
|
||||
);
|
||||
}
|
||||
240
crates/application/tests/git_usecases.rs
Normal file
240
crates/application/tests/git_usecases.rs
Normal file
@ -0,0 +1,240 @@
|
||||
//! L8 tests for the Git use cases with a faked [`GitPort`] (no real repo):
|
||||
//! pass-through to the port, event emission on state changes, and input
|
||||
//! validation (empty message, non-absolute root).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{
|
||||
EventBus, EventStream, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
};
|
||||
use domain::{ProjectId, ProjectPath};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
GitBranches, GitBranchesInput, GitCheckout, GitCheckoutInput, GitCommit, GitCommitInput,
|
||||
GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
};
|
||||
|
||||
/// A recording [`GitPort`] with canned return values.
|
||||
#[derive(Default)]
|
||||
struct FakeGitInner {
|
||||
calls: Vec<String>,
|
||||
status: Vec<GitFileStatus>,
|
||||
branches: Vec<String>,
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeGit(Arc<Mutex<FakeGitInner>>);
|
||||
impl FakeGit {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.0.lock().unwrap().calls.clone()
|
||||
}
|
||||
fn set_status(&self, s: Vec<GitFileStatus>) {
|
||||
self.0.lock().unwrap().status = s;
|
||||
}
|
||||
fn set_branches(&self, b: Vec<String>, current: Option<String>) {
|
||||
let mut i = self.0.lock().unwrap();
|
||||
i.branches = b;
|
||||
i.current = current;
|
||||
}
|
||||
fn record(&self, c: &str) {
|
||||
self.0.lock().unwrap().calls.push(c.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitPort for FakeGit {
|
||||
async fn init(&self, _r: &ProjectPath) -> Result<(), GitError> {
|
||||
self.record("init");
|
||||
Ok(())
|
||||
}
|
||||
async fn status(&self, _r: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError> {
|
||||
self.record("status");
|
||||
Ok(self.0.lock().unwrap().status.clone())
|
||||
}
|
||||
async fn stage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> {
|
||||
self.record(&format!("stage:{path}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn unstage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> {
|
||||
self.record(&format!("unstage:{path}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn commit(&self, _r: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError> {
|
||||
self.record(&format!("commit:{message}"));
|
||||
Ok(GitCommitInfo {
|
||||
hash: "abc123".to_owned(),
|
||||
summary: message.to_owned(),
|
||||
})
|
||||
}
|
||||
async fn branches(&self, _r: &ProjectPath) -> Result<Vec<String>, GitError> {
|
||||
self.record("branches");
|
||||
Ok(self.0.lock().unwrap().branches.clone())
|
||||
}
|
||||
async fn current_branch(&self, _r: &ProjectPath) -> Result<Option<String>, GitError> {
|
||||
self.record("current_branch");
|
||||
Ok(self.0.lock().unwrap().current.clone())
|
||||
}
|
||||
async fn checkout(&self, _r: &ProjectPath, branch: &str) -> Result<(), GitError> {
|
||||
self.record(&format!("checkout:{branch}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn log(
|
||||
&self,
|
||||
_r: &ProjectPath,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<GitCommitInfo>, GitError> {
|
||||
self.record("log");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn log_graph(
|
||||
&self,
|
||||
_r: &ProjectPath,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<GraphCommit>, GitError> {
|
||||
self.record("log_graph");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn pull(&self, _r: &ProjectPath) -> Result<(), GitError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn push(&self, _r: &ProjectPath) -> Result<(), GitError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn pid() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
const ROOT: &str = "/home/me/repo";
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_passes_through_to_port() {
|
||||
let git = FakeGit::default();
|
||||
git.set_status(vec![GitFileStatus {
|
||||
path: "a.txt".to_owned(),
|
||||
staged: true,
|
||||
}]);
|
||||
let out = GitStatus::new(Arc::new(git.clone()))
|
||||
.execute(GitStatusInput { root: ROOT.to_owned() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.entries.len(), 1);
|
||||
assert_eq!(out.entries[0].path, "a.txt");
|
||||
assert_eq!(git.calls(), vec!["status"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_rejects_non_absolute_root() {
|
||||
let git = FakeGit::default();
|
||||
let err = GitStatus::new(Arc::new(git.clone()))
|
||||
.execute(GitStatusInput {
|
||||
root: "relative/path".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
assert!(git.calls().is_empty(), "no port call on invalid root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_calls_port_with_path() {
|
||||
let git = FakeGit::default();
|
||||
GitStage::new(Arc::new(git.clone()))
|
||||
.execute(GitStagePathInput {
|
||||
root: ROOT.to_owned(),
|
||||
path: "src/x.rs".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(git.calls(), vec!["stage:src/x.rs"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_returns_commit_and_publishes_event() {
|
||||
let git = FakeGit::default();
|
||||
let bus = SpyBus::default();
|
||||
let out = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone()))
|
||||
.execute(GitCommitInput {
|
||||
project_id: pid(),
|
||||
root: ROOT.to_owned(),
|
||||
message: "feat: x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.commit.hash, "abc123");
|
||||
assert_eq!(out.commit.summary, "feat: x");
|
||||
assert_eq!(git.calls(), vec!["commit:feat: x"]);
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::GitStateChanged { project_id: pid() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_rejects_empty_message_without_touching_port() {
|
||||
let git = FakeGit::default();
|
||||
let bus = SpyBus::default();
|
||||
let err = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone()))
|
||||
.execute(GitCommitInput {
|
||||
project_id: pid(),
|
||||
root: ROOT.to_owned(),
|
||||
message: " ".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
assert!(git.calls().is_empty(), "no commit attempted");
|
||||
assert!(bus.events().is_empty(), "no event on rejected commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_publishes_event() {
|
||||
let git = FakeGit::default();
|
||||
let bus = SpyBus::default();
|
||||
GitCheckout::new(Arc::new(git.clone()), Arc::new(bus.clone()))
|
||||
.execute(GitCheckoutInput {
|
||||
project_id: pid(),
|
||||
root: ROOT.to_owned(),
|
||||
branch: "dev".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(git.calls(), vec!["checkout:dev"]);
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::GitStateChanged { project_id: pid() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn branches_returns_list_and_current() {
|
||||
let git = FakeGit::default();
|
||||
git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned()));
|
||||
let out = GitBranches::new(Arc::new(git.clone()))
|
||||
.execute(GitBranchesInput { root: ROOT.to_owned() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.branches, vec!["main", "dev"]);
|
||||
assert_eq!(out.current.as_deref(), Some("main"));
|
||||
assert_eq!(git.calls(), vec!["branches", "current_branch"]);
|
||||
}
|
||||
96
crates/application/tests/health.rs
Normal file
96
crates/application/tests/health.rs
Normal file
@ -0,0 +1,96 @@
|
||||
//! L1 tests for [`HealthUseCase`] driven entirely through **mocked/fake ports**
|
||||
//! (`FixedClock`, `SeqIdGenerator`, a spy `EventBus`), exercising DI without any
|
||||
//! real I/O (README "Domaine/application testés sans I/O").
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{HealthInput, HealthUseCase};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{Clock, EventBus, EventStream, IdGenerator};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A clock that always returns the same configured instant.
|
||||
struct FixedClock(i64);
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// An id generator yielding a deterministic, predefined UUID.
|
||||
struct SeqIdGenerator(Uuid);
|
||||
impl IdGenerator for SeqIdGenerator {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A spy event bus capturing every published event for assertions.
|
||||
#[derive(Default)]
|
||||
struct SpyEventBus {
|
||||
published: Mutex<Vec<DomainEvent>>,
|
||||
}
|
||||
impl EventBus for SpyEventBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.published.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_uuid() -> Uuid {
|
||||
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_report_reflects_injected_ports_and_input() {
|
||||
let clock = Arc::new(FixedClock(1_700_000_000_123));
|
||||
let ids = Arc::new(SeqIdGenerator(fixed_uuid()));
|
||||
let bus = Arc::new(SpyEventBus::default());
|
||||
|
||||
let uc = HealthUseCase::new(clock, ids, Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
|
||||
let report = uc
|
||||
.execute(HealthInput {
|
||||
note: Some("ping".to_owned()),
|
||||
})
|
||||
.expect("health never errs");
|
||||
|
||||
assert!(report.alive);
|
||||
assert_eq!(report.time_millis, 1_700_000_000_123);
|
||||
assert_eq!(report.correlation_id, fixed_uuid().to_string());
|
||||
assert_eq!(report.note.as_deref(), Some("ping"));
|
||||
assert_eq!(report.version, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_publishes_exactly_one_domain_event() {
|
||||
let clock = Arc::new(FixedClock(0));
|
||||
let ids = Arc::new(SeqIdGenerator(fixed_uuid()));
|
||||
let bus = Arc::new(SpyEventBus::default());
|
||||
|
||||
let uc = HealthUseCase::new(clock, ids, Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
uc.execute(HealthInput::default()).unwrap();
|
||||
|
||||
let published = bus.published.lock().unwrap();
|
||||
assert_eq!(published.len(), 1, "exactly one smoke event is published");
|
||||
match &published[0] {
|
||||
DomainEvent::ProjectCreated { project_id } => {
|
||||
// The smoke event reuses the correlation id as the (fake) project id.
|
||||
assert_eq!(project_id.as_uuid(), fixed_uuid());
|
||||
}
|
||||
other => panic!("expected ProjectCreated smoke event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_note_defaults_to_none() {
|
||||
let uc = HealthUseCase::new(
|
||||
Arc::new(FixedClock(42)),
|
||||
Arc::new(SeqIdGenerator(fixed_uuid())),
|
||||
Arc::new(SpyEventBus::default()),
|
||||
);
|
||||
let report = uc.execute(HealthInput::default()).unwrap();
|
||||
assert_eq!(report.note, None);
|
||||
}
|
||||
814
crates/application/tests/layout_usecases.rs
Normal file
814
crates/application/tests/layout_usecases.rs
Normal file
@ -0,0 +1,814 @@
|
||||
//! L4 + #4 tests for the layout use cases (`LoadLayout`, `MutateLayout`) and the
|
||||
//! named-layout management (`ListLayouts`, `CreateLayout`, `RenameLayout`,
|
||||
//! `DeleteLayout`, `SetActiveLayout`).
|
||||
//!
|
||||
//! Every port is faked in-memory so the use cases run without any real I/O.
|
||||
//! Layouts now persist to `.ideai/layouts.json` (a collection); a legacy
|
||||
//! `.ideai/layout.json` is migrated transparently.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
||||
ProjectPath, RemoteRef, SessionId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
|
||||
ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
|
||||
RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFsInner {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.lock().unwrap().files.get(path).cloned()
|
||||
}
|
||||
fn has_dir(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap().dirs.contains(path)
|
||||
}
|
||||
fn put(&self, path: &str, data: &[u8]) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.to_owned(), data.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
let inner = self.0.lock().unwrap();
|
||||
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().projects.push(project.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new(start: u128) -> Self {
|
||||
Self(Mutex::new(start))
|
||||
}
|
||||
}
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*n);
|
||||
*n += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROOT: &str = "/home/me/proj";
|
||||
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
|
||||
const LEGACY_PATH: &str = "/home/me/proj/.ideai/layout.json";
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
|
||||
let project =
|
||||
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn single_leaf(node_id: NodeId) -> LayoutTree {
|
||||
LayoutTree::single(LeafCell {
|
||||
id: node_id,
|
||||
session: None,
|
||||
agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Seeds a valid `layouts.json` with a single active layout holding `tree`.
|
||||
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
||||
let doc = serde_json::json!({
|
||||
"version": 1,
|
||||
"activeId": id.to_string(),
|
||||
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
|
||||
});
|
||||
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
|
||||
}
|
||||
|
||||
fn doc_json(fs: &FakeFs) -> serde_json::Value {
|
||||
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json written")).unwrap()
|
||||
}
|
||||
|
||||
/// The JSON of the active layout's tree.
|
||||
fn active_tree_json(fs: &FakeFs) -> serde_json::Value {
|
||||
let doc = doc_json(fs);
|
||||
let active = doc["activeId"].clone();
|
||||
doc["layouts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|l| l["id"] == active)
|
||||
.expect("active layout present")["tree"]
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn read_active_tree(fs: &FakeFs) -> LayoutTree {
|
||||
serde_json::from_value(active_tree_json(fs)).expect("active tree parseable")
|
||||
}
|
||||
|
||||
fn root_leaf_id(tree: &LayoutTree) -> NodeId {
|
||||
match &tree.root {
|
||||
LayoutNode::Leaf(l) => l.id,
|
||||
_ => panic!("expected a single-leaf root"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoadLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_returns_persisted_active_layout() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(1)).await;
|
||||
seed_layouts(&fs, lid(1), &single_leaf(nid(42)));
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
||||
let out = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("load succeeds");
|
||||
|
||||
assert_eq!(out.layout_id, lid(1));
|
||||
assert_eq!(root_leaf_id(&out.layout), nid(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_migrates_a_legacy_layout_json() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(2)).await;
|
||||
// Only the legacy single-layout file exists.
|
||||
fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap());
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
|
||||
let out = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("legacy layout migrates");
|
||||
|
||||
assert_eq!(root_leaf_id(&out.layout), nid(7), "legacy tree preserved");
|
||||
// A layouts.json was written with that tree as the active layout.
|
||||
assert_eq!(root_leaf_id(&read_active_tree(&fs)), nid(7));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_defaults_to_single_empty_leaf_when_absent() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(3)).await;
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
|
||||
let out = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("absent layout does not fail");
|
||||
|
||||
match &out.layout.root {
|
||||
LayoutNode::Leaf(l) => assert!(l.session.is_none()),
|
||||
_ => panic!("expected a single default leaf"),
|
||||
}
|
||||
// Default was written through; two loads are deterministic.
|
||||
assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout));
|
||||
assert!(fs.has_dir("/home/me/proj/.ideai"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_tolerates_corrupt_json_with_default() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(4)).await;
|
||||
fs.put(LAYOUTS_PATH, b"{ not json ]");
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
||||
let out = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("corrupt JSON falls back to default");
|
||||
|
||||
assert!(matches!(out.layout.root, LayoutNode::Leaf(_)));
|
||||
assert!(out.layout.validate().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_unknown_layout_id_is_not_found() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(5)).await;
|
||||
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
||||
let err = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: Some(lid(999)),
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown layout id rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_unknown_project_is_not_found() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
||||
let err = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: pid(999),
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown project rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MutateLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MutEnv {
|
||||
fs: FakeFs,
|
||||
bus: SpyBus,
|
||||
mutate: MutateLayout,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
async fn mut_env(project_id: ProjectId) -> MutEnv {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let bus = SpyBus::default();
|
||||
register_project(&store, project_id).await;
|
||||
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
|
||||
|
||||
let mutate = MutateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
MutEnv {
|
||||
fs,
|
||||
bus,
|
||||
mutate,
|
||||
project_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_split_persists_camelcase_layout_and_announces() {
|
||||
let env = mut_env(pid(10)).await;
|
||||
let out = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::Split {
|
||||
target: nid(1),
|
||||
direction: Direction::Row,
|
||||
new_leaf: nid(2),
|
||||
container: nid(9),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("split succeeds");
|
||||
|
||||
match &out.layout.root {
|
||||
LayoutNode::Split(s) => assert_eq!(s.children.len(), 2),
|
||||
_ => panic!("expected a split root"),
|
||||
}
|
||||
|
||||
assert!(env.fs.has_dir("/home/me/proj/.ideai"));
|
||||
let tree = active_tree_json(&env.fs);
|
||||
assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
|
||||
assert_eq!(tree["root"]["node"]["direction"], "row");
|
||||
assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2);
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![DomainEvent::LayoutChanged {
|
||||
project_id: env.project_id
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_resize_writes_new_weights() {
|
||||
let env = mut_env(pid(11)).await;
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::Split {
|
||||
target: nid(1),
|
||||
direction: Direction::Row,
|
||||
new_leaf: nid(2),
|
||||
container: nid(9),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::Resize {
|
||||
container: nid(9),
|
||||
weights: vec![3.0, 1.0],
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("resize succeeds");
|
||||
|
||||
let tree = active_tree_json(&env.fs);
|
||||
let children = tree["root"]["node"]["children"].as_array().unwrap();
|
||||
assert_eq!(children[0]["weight"], 3.0);
|
||||
assert_eq!(children[1]["weight"], 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_session_attaches_and_clears() {
|
||||
let env = mut_env(pid(12)).await;
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetSession {
|
||||
target: nid(1),
|
||||
session: Some(sid(77)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("attach");
|
||||
assert_eq!(
|
||||
active_tree_json(&env.fs)["root"]["node"]["session"],
|
||||
sid(77).to_string()
|
||||
);
|
||||
|
||||
let out = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetSession {
|
||||
target: nid(1),
|
||||
session: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("detach");
|
||||
match &out.layout.root {
|
||||
LayoutNode::Leaf(l) => assert!(l.session.is_none()),
|
||||
_ => panic!("expected leaf root"),
|
||||
}
|
||||
assert!(active_tree_json(&env.fs)["root"]["node"]
|
||||
.get("session")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_invalid_op_errors_and_does_not_persist() {
|
||||
let env = mut_env(pid(14)).await;
|
||||
// Force the layouts.json to exist first (a clean load) so we have a baseline.
|
||||
let before = doc_json(&env.fs);
|
||||
|
||||
let err = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetSession {
|
||||
target: nid(404),
|
||||
session: Some(sid(1)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect_err("set_session on unknown node fails");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
|
||||
assert_eq!(before, doc_json(&env.fs), "failed op must not overwrite");
|
||||
assert!(env.bus.events().is_empty(), "no event on failed mutation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_then_set_session_on_returned_id_succeeds() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let bus = SpyBus::default();
|
||||
let id = register_project(&store, pid(22)).await;
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()));
|
||||
let mutate = MutateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
|
||||
let loaded = load
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("load");
|
||||
let leaf = root_leaf_id(&loaded.layout);
|
||||
|
||||
mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetSession {
|
||||
target: leaf,
|
||||
session: Some(sid(7)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set_session on the just-loaded leaf id must succeed");
|
||||
|
||||
match &read_active_tree(&fs).root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.session, Some(sid(7))),
|
||||
_ => panic!("expected persisted leaf root"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetCellAgent (#3 — per-cell agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_cell_agent_persists_agent_on_leaf() {
|
||||
let env = mut_env(pid(50)).await;
|
||||
// Attach an agent to the single root leaf (nid(1)).
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellAgent {
|
||||
target: nid(1),
|
||||
agent: Some(aid(0xAA)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set_cell_agent attaches");
|
||||
|
||||
// Verify persisted JSON has the agent field.
|
||||
let tree_json = active_tree_json(&env.fs);
|
||||
assert_eq!(
|
||||
tree_json["root"]["node"]["agent"],
|
||||
aid(0xAA).to_string(),
|
||||
"agent must be persisted on the leaf"
|
||||
);
|
||||
|
||||
// Now clear it.
|
||||
let out = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellAgent {
|
||||
target: nid(1),
|
||||
agent: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set_cell_agent clears");
|
||||
|
||||
match &out.layout.root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.agent, None, "agent must be cleared"),
|
||||
_ => panic!("expected leaf root"),
|
||||
}
|
||||
assert!(
|
||||
active_tree_json(&env.fs)["root"]["node"]
|
||||
.get("agent")
|
||||
.is_none(),
|
||||
"cleared agent must not be serialised"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_cell_agent_missing_leaf_is_not_found() {
|
||||
let env = mut_env(pid(51)).await;
|
||||
let err = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellAgent {
|
||||
target: nid(404),
|
||||
agent: Some(aid(1)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown node rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named-layout management (#4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds a project + fs + bus with a seeded single "Default" layout (id 1).
|
||||
async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let bus = SpyBus::default();
|
||||
register_project(&store, project_id).await;
|
||||
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
|
||||
(store, fs, bus)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_layout_appends_and_activates_it() {
|
||||
let (store, fs, bus) = mgmt_env(pid(30)).await;
|
||||
let create = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xABC)),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
let out = create
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(30),
|
||||
name: "Backend".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(30) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts.len(), 2, "Default + Backend");
|
||||
assert_eq!(list.active_id, out.layout_id, "new layout is active");
|
||||
assert!(list.layouts.iter().any(|l| l.name == "Backend"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_layout_rejects_empty_name() {
|
||||
let (store, fs, bus) = mgmt_env(pid(31)).await;
|
||||
let err = CreateLayout::new(
|
||||
Arc::new(store),
|
||||
Arc::new(fs),
|
||||
Arc::new(SeqIds::new(1)),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(31),
|
||||
name: " ".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_layout_changes_the_name() {
|
||||
let (store, fs, bus) = mgmt_env(pid(32)).await;
|
||||
RenameLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(RenameLayoutInput {
|
||||
project_id: pid(32),
|
||||
layout_id: lid(1),
|
||||
name: "Main".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(32) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts[0].name, "Main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_layout_rejects_the_last_one() {
|
||||
let (store, fs, bus) = mgmt_env(pid(33)).await;
|
||||
let err = DeleteLayout::new(Arc::new(store), Arc::new(fs), Arc::new(bus))
|
||||
.execute(DeleteLayoutInput {
|
||||
project_id: pid(33),
|
||||
layout_id: lid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "cannot delete the last layout");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_active_layout_reassigns_active() {
|
||||
let (store, fs, bus) = mgmt_env(pid(34)).await;
|
||||
// Add a second layout (becomes active), then delete it → active falls back.
|
||||
let created = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xD)),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(34),
|
||||
name: "Second".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let out = DeleteLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(DeleteLayoutInput {
|
||||
project_id: pid(34),
|
||||
layout_id: created.layout_id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.active_id, lid(1), "active fell back to the Default layout");
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(34) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_active_layout_switches_and_load_follows() {
|
||||
let (store, fs, bus) = mgmt_env(pid(35)).await;
|
||||
let created = CreateLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(SeqIds::new(0xE)),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(CreateLayoutInput {
|
||||
project_id: pid(35),
|
||||
name: "Second".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Switch back to the Default layout.
|
||||
SetActiveLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(SetActiveLayoutInput {
|
||||
project_id: pid(35),
|
||||
layout_id: lid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(LoadLayoutInput {
|
||||
project_id: pid(35),
|
||||
layout_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(loaded.layout_id, lid(1));
|
||||
assert_ne!(loaded.layout_id, created.layout_id);
|
||||
}
|
||||
320
crates/application/tests/profile_usecases.rs
Normal file
320
crates/application/tests/profile_usecases.rs
Normal file
@ -0,0 +1,320 @@
|
||||
//! L5 tests for the profile/first-run use cases and the reference catalogue.
|
||||
//!
|
||||
//! Ports are faked in-memory so the use cases run without any I/O:
|
||||
//! - [`FakeProfileStore`] — an in-memory [`ProfileStore`] tracking a `configured`
|
||||
//! flag (mirrors `profiles.json` existence),
|
||||
//! - [`StubRuntime`] — an [`AgentRuntime`] whose `detect` is driven by a map from
|
||||
//! command → result (including an error case to prove graceful degradation).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{
|
||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
profiles: Vec<AgentProfile>,
|
||||
configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeProfileStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfileStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().profiles.clone())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.configured = true;
|
||||
if let Some(slot) = inner.profiles.iter_mut().find(|p| p.id == profile.id) {
|
||||
*slot = profile.clone();
|
||||
} else {
|
||||
inner.profiles.push(profile.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
let before = inner.profiles.len();
|
||||
inner.profiles.retain(|p| p.id != id);
|
||||
if inner.profiles.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(self.0.lock().unwrap().configured)
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().configured = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Detection outcomes keyed by command. Missing keys ⇒ `false`.
|
||||
#[derive(Clone)]
|
||||
enum DetectResult {
|
||||
Available,
|
||||
Missing,
|
||||
Error,
|
||||
}
|
||||
|
||||
struct StubRuntime {
|
||||
by_command: HashMap<String, DetectResult>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for StubRuntime {
|
||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
match self.by_command.get(&profile.command) {
|
||||
Some(DetectResult::Available) => Ok(true),
|
||||
Some(DetectResult::Missing) | None => Ok(false),
|
||||
Some(DetectResult::Error) => {
|
||||
Err(RuntimeError::Detection("boom".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
unreachable!("not used in these tests")
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
name,
|
||||
command,
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_maps_candidates_to_availability_in_order() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("claude".to_owned(), DetectResult::Available);
|
||||
map.insert("codex".to_owned(), DetectResult::Missing);
|
||||
let runtime: Arc<dyn AgentRuntime> = Arc::new(StubRuntime { by_command: map });
|
||||
|
||||
let detect = DetectProfiles::new(runtime);
|
||||
let out = detect
|
||||
.execute(DetectProfilesInput {
|
||||
candidates: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.results.len(), 2);
|
||||
assert_eq!(out.results[0].profile.command, "claude");
|
||||
assert!(out.results[0].available);
|
||||
assert_eq!(out.results[1].profile.command, "codex");
|
||||
assert!(!out.results[1].available);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_error_degrades_to_unavailable_not_hard_failure() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("aider".to_owned(), DetectResult::Error);
|
||||
let runtime: Arc<dyn AgentRuntime> = Arc::new(StubRuntime { by_command: map });
|
||||
|
||||
let detect = DetectProfiles::new(runtime);
|
||||
let out = detect
|
||||
.execute(DetectProfilesInput {
|
||||
candidates: vec![profile(1, "Aider", "aider")],
|
||||
})
|
||||
.await
|
||||
.expect("detection error must not fail the use case");
|
||||
|
||||
assert!(!out.results[0].available, "errored detection ⇒ available:false");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigureProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_persists_chosen_profiles_and_closes_first_run() {
|
||||
let store = FakeProfileStore::default();
|
||||
let configure = ConfigureProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
let out = configure
|
||||
.execute(ConfigureProfilesInput {
|
||||
profiles: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.profiles.len(), 2);
|
||||
assert!(store.is_configured().await.unwrap());
|
||||
assert_eq!(store.list().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_empty_list_still_marks_configured() {
|
||||
let store = FakeProfileStore::default();
|
||||
let configure = ConfigureProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
configure
|
||||
.execute(ConfigureProfilesInput { profiles: vec![] })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
store.is_configured().await.unwrap(),
|
||||
"empty configure closes the first run"
|
||||
);
|
||||
assert!(store.list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstRunState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
let store = FakeProfileStore::default();
|
||||
let uc = FirstRunState::new(Arc::new(store));
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
assert!(out.is_first_run);
|
||||
assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_run_false_after_configuration() {
|
||||
let store = FakeProfileStore::default();
|
||||
store.mark_configured().await.unwrap();
|
||||
let uc = FirstRunState::new(Arc::new(store));
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
assert!(!out.is_first_run);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListProfiles / SaveProfile / DeleteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_list_then_delete() {
|
||||
let store = FakeProfileStore::default();
|
||||
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||
let list = ListProfiles::new(Arc::new(store.clone()));
|
||||
let delete = DeleteProfile::new(Arc::new(store.clone()));
|
||||
|
||||
let p = profile(1, "Claude", "claude");
|
||||
let saved = save
|
||||
.execute(SaveProfileInput { profile: p.clone() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.profile, p);
|
||||
|
||||
assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]);
|
||||
|
||||
delete.execute(DeleteProfileInput { id: p.id }).await.unwrap();
|
||||
assert!(list.execute().await.unwrap().profiles.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_is_not_found_error() {
|
||||
let store = FakeProfileStore::default();
|
||||
let delete = DeleteProfile::new(Arc::new(store));
|
||||
let err = delete
|
||||
.execute(DeleteProfileInput {
|
||||
id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)),
|
||||
})
|
||||
.await
|
||||
.expect_err("deleting unknown id errors");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles / catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn reference_profiles_use_case_returns_four() {
|
||||
let out = ReferenceProfiles::new().execute().await.unwrap();
|
||||
assert_eq!(out.profiles.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_has_expected_commands_and_injection() {
|
||||
let profiles = reference_profiles();
|
||||
let by_command: HashMap<&str, &AgentProfile> =
|
||||
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
|
||||
|
||||
let claude = by_command["claude"];
|
||||
assert_eq!(
|
||||
claude.context_injection,
|
||||
ContextInjection::ConventionFile {
|
||||
target: "CLAUDE.md".to_owned()
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
by_command["codex"].context_injection,
|
||||
ContextInjection::ConventionFile {
|
||||
target: "AGENTS.md".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["gemini"].context_injection,
|
||||
ContextInjection::ConventionFile {
|
||||
target: "GEMINI.md".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
by_command["aider"].context_injection,
|
||||
ContextInjection::Flag {
|
||||
flag: "--message-file {path}".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_ids_are_stable_across_calls() {
|
||||
let first = reference_profiles();
|
||||
let second = reference_profiles();
|
||||
let ids_a: Vec<_> = first.iter().map(|p| p.id).collect();
|
||||
let ids_b: Vec<_> = second.iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids_a, ids_b, "reference ids are deterministic");
|
||||
|
||||
// And match the slug-derived id helper.
|
||||
assert_eq!(first[0].id, reference_profile_id("claude"));
|
||||
}
|
||||
565
crates/application/tests/project_usecases.rs
Normal file
565
crates/application/tests/project_usecases.rs
Normal file
@ -0,0 +1,565 @@
|
||||
//! L2 tests for the project life-cycle use cases (`CreateProject`,
|
||||
//! `OpenProject`, `ListProjects`, `CloseProject`/`CloseTab`).
|
||||
//!
|
||||
//! Every port is faked in-memory so the use cases are exercised without any I/O:
|
||||
//! - [`FakeFs`] — a `Mutex<HashMap<String, Vec<u8>>>` filesystem that records
|
||||
//! directories and file contents,
|
||||
//! - [`FakeStore`] — an in-memory `ProjectStore` (registry + workspace),
|
||||
//! - [`SpyBus`] — records published [`DomainEvent`]s,
|
||||
//! - [`SeqIds`] / [`FixedClock`] — deterministic id/time.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{
|
||||
Clock, DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore,
|
||||
RemotePath, StoreError,
|
||||
};
|
||||
use domain::{Project, ProjectId, ProjectPath, RemoteRef};
|
||||
|
||||
use application::{
|
||||
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
|
||||
ListProjects, OpenProject, OpenProjectInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFsInner {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
/// An in-memory [`FileSystem`] recording writes and created directories.
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.lock().unwrap().files.get(path).cloned()
|
||||
}
|
||||
fn has_dir(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap().dirs.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
let inner = self.0.lock().unwrap();
|
||||
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.dirs
|
||||
.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
workspace: Option<Workspace>,
|
||||
}
|
||||
|
||||
/// An in-memory [`ProjectStore`].
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
impl FakeStore {
|
||||
fn saved_workspace(&self) -> Option<Workspace> {
|
||||
self.0.lock().unwrap().workspace.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
if let Some(slot) = inner.projects.iter_mut().find(|p| p.id == project.id) {
|
||||
*slot = project.clone();
|
||||
} else {
|
||||
inner.projects.push(project.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().workspace = Some(workspace.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(self.0.lock().unwrap().workspace.clone().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`ProjectStore`] whose registry read always fails — used to assert the
|
||||
/// `Store` error code propagates.
|
||||
#[derive(Default, Clone)]
|
||||
struct BrokenStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for BrokenStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Records published events.
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic ids: nil-based UUIDs derived from a counter.
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
Self(Mutex::new(1))
|
||||
}
|
||||
}
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> uuid::Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let v = *n;
|
||||
*n += 1;
|
||||
uuid::Uuid::from_u128(v)
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock(i64);
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wiring helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Env {
|
||||
store: FakeStore,
|
||||
fs: FakeFs,
|
||||
bus: SpyBus,
|
||||
create: CreateProject,
|
||||
open: OpenProject,
|
||||
}
|
||||
|
||||
fn env() -> Env {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let bus = SpyBus::default();
|
||||
let ids: Arc<dyn IdGenerator> = Arc::new(SeqIds::new());
|
||||
let clock: Arc<dyn Clock> = Arc::new(FixedClock(1_700_000_000_000));
|
||||
|
||||
let create = CreateProject::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
ids,
|
||||
clock,
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone()));
|
||||
|
||||
Env {
|
||||
store,
|
||||
fs,
|
||||
bus,
|
||||
create,
|
||||
open,
|
||||
}
|
||||
}
|
||||
|
||||
fn input(name: &str, root: &str) -> CreateProjectInput {
|
||||
CreateProjectInput {
|
||||
name: name.to_owned(),
|
||||
root: root.to_owned(),
|
||||
remote: None,
|
||||
default_profile_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateProject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_inits_ideai_dir_and_writes_camelcase_project_json() {
|
||||
let env = env();
|
||||
let out = env
|
||||
.create
|
||||
.execute(input("Demo", "/home/me/proj"))
|
||||
.await
|
||||
.expect("creation succeeds");
|
||||
|
||||
// .ideai/ created.
|
||||
assert!(env.fs.has_dir("/home/me/proj/.ideai"), "dir created");
|
||||
|
||||
// project.json written with camelCase fields and no `root`.
|
||||
let bytes = env
|
||||
.fs
|
||||
.read_file("/home/me/proj/.ideai/project.json")
|
||||
.expect("project.json written");
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["version"], 1);
|
||||
assert_eq!(json["name"], "Demo");
|
||||
assert_eq!(json["id"], out.project.id.to_string());
|
||||
assert_eq!(json["createdAt"], 1_700_000_000_000i64);
|
||||
assert_eq!(json["remote"]["kind"], "local");
|
||||
assert!(json.get("root").is_none(), "root must NOT be stored");
|
||||
// default_profile_id omitted when None (skip_serializing_if).
|
||||
assert!(json.get("defaultProfileId").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_registers_project_in_store() {
|
||||
let env = env();
|
||||
let out = env.create.execute(input("Demo", "/p")).await.unwrap();
|
||||
|
||||
let stored = env.store.list_projects().await.unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, out.project.id);
|
||||
assert_eq!(stored[0].name, "Demo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_publishes_project_created_event() {
|
||||
let env = env();
|
||||
let out = env.create.execute(input("Demo", "/p")).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
vec![DomainEvent::ProjectCreated {
|
||||
project_id: out.project.id
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_duplicate_remote_root() {
|
||||
let env = env();
|
||||
env.create.execute(input("A", "/same")).await.unwrap();
|
||||
|
||||
let err = env
|
||||
.create
|
||||
.execute(input("B", "/same"))
|
||||
.await
|
||||
.expect_err("duplicate (remote, root) rejected");
|
||||
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
// Only the first project remains registered.
|
||||
assert_eq!(env.store.list_projects().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_allows_same_root_on_different_remote() {
|
||||
let env = env();
|
||||
env.create.execute(input("Local", "/shared")).await.unwrap();
|
||||
|
||||
let remote_input = CreateProjectInput {
|
||||
remote: Some(RemoteRef::Wsl {
|
||||
distro: "Ubuntu".to_owned(),
|
||||
}),
|
||||
..input("Wsl", "/shared")
|
||||
};
|
||||
env.create
|
||||
.execute(remote_input)
|
||||
.await
|
||||
.expect("same root, different remote is allowed");
|
||||
|
||||
assert_eq!(env.store.list_projects().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_non_absolute_root() {
|
||||
let env = env();
|
||||
let err = env
|
||||
.create
|
||||
.execute(input("X", "relative/path"))
|
||||
.await
|
||||
.expect_err("non-absolute root rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_empty_name() {
|
||||
let env = env();
|
||||
let err = env
|
||||
.create
|
||||
.execute(input("", "/abs"))
|
||||
.await
|
||||
.expect_err("empty name rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_propagates_store_error_code() {
|
||||
let ids: Arc<dyn IdGenerator> = Arc::new(SeqIds::new());
|
||||
let clock: Arc<dyn Clock> = Arc::new(FixedClock(0));
|
||||
let create = CreateProject::new(
|
||||
Arc::new(BrokenStore),
|
||||
Arc::new(FakeFs::default()),
|
||||
ids,
|
||||
clock,
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
let err = create
|
||||
.execute(input("X", "/abs"))
|
||||
.await
|
||||
.expect_err("store failure surfaces");
|
||||
assert_eq!(err.code(), "STORE", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenProject — tolerant reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_loads_project_and_meta() {
|
||||
let env = env();
|
||||
let created = env.create.execute(input("Demo", "/o/proj")).await.unwrap();
|
||||
|
||||
let out = env
|
||||
.open
|
||||
.execute(OpenProjectInput {
|
||||
project_id: created.project.id,
|
||||
})
|
||||
.await
|
||||
.expect("open succeeds");
|
||||
|
||||
assert_eq!(out.project.id, created.project.id);
|
||||
assert_eq!(out.project.root, created.project.root);
|
||||
let meta = out.meta.expect("meta present (project.json was written)");
|
||||
assert_eq!(meta.id, created.project.id);
|
||||
assert_eq!(meta.name, "Demo");
|
||||
// No agents.json was written → manifest tolerantly None.
|
||||
assert!(out.manifest.is_none(), "agents.json absent → None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_unknown_project_is_not_found() {
|
||||
let env = env();
|
||||
let err = env
|
||||
.open
|
||||
.execute(OpenProjectInput {
|
||||
project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(999)),
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown id");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_tolerates_missing_meta_file() {
|
||||
// Register a project in the store WITHOUT writing any .ideai/ files.
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = ProjectId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let project = Project::new(
|
||||
id,
|
||||
"Orphan",
|
||||
ProjectPath::new("/no/ideai").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
|
||||
let open = OpenProject::new(Arc::new(store), Arc::new(fs));
|
||||
let out = open
|
||||
.execute(OpenProjectInput { project_id: id })
|
||||
.await
|
||||
.expect("open does not fail on missing meta");
|
||||
assert!(out.meta.is_none(), "missing project.json → None");
|
||||
assert!(out.manifest.is_none(), "missing agents.json → None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_tolerates_corrupt_json() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let id = ProjectId::from_uuid(uuid::Uuid::from_u128(8));
|
||||
let project = Project::new(
|
||||
id,
|
||||
"Corrupt",
|
||||
ProjectPath::new("/c/proj").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
// Write garbage at both .ideai/ paths.
|
||||
fs.write(
|
||||
&RemotePath::new("/c/proj/.ideai/project.json"),
|
||||
b"{ not json ]",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(
|
||||
&RemotePath::new("/c/proj/.ideai/agents.json"),
|
||||
b"<<<broken>>>",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let open = OpenProject::new(Arc::new(store), Arc::new(fs));
|
||||
let out = open
|
||||
.execute(OpenProjectInput { project_id: id })
|
||||
.await
|
||||
.expect("corrupt JSON does not fail the open");
|
||||
assert!(out.meta.is_none(), "corrupt project.json → None");
|
||||
assert!(out.manifest.is_none(), "corrupt agents.json → None");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListProjects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_projects_returns_registered() {
|
||||
let env = env();
|
||||
env.create.execute(input("A", "/a")).await.unwrap();
|
||||
env.create.execute(input("B", "/b")).await.unwrap();
|
||||
|
||||
let list = ListProjects::new(Arc::new(env.store.clone()));
|
||||
let out = list.execute().await.unwrap();
|
||||
let names: Vec<&str> = out.projects.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(out.projects.len(), 2);
|
||||
assert!(names.contains(&"A") && names.contains(&"B"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloseProject / CloseTab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_persists_workspace() {
|
||||
let store = FakeStore::default();
|
||||
let close = CloseProject::new(Arc::new(store.clone()));
|
||||
let id = ProjectId::from_uuid(uuid::Uuid::from_u128(3));
|
||||
|
||||
let out = close
|
||||
.execute(CloseProjectInput {
|
||||
project_id: id,
|
||||
workspace: Some(Workspace::default()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.project_id, id);
|
||||
assert!(store.saved_workspace().is_some(), "workspace persisted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_without_workspace_skips_persistence() {
|
||||
let store = FakeStore::default();
|
||||
let close = CloseProject::new(Arc::new(store.clone()));
|
||||
let id = ProjectId::from_uuid(uuid::Uuid::from_u128(4));
|
||||
|
||||
close
|
||||
.execute(CloseProjectInput {
|
||||
project_id: id,
|
||||
workspace: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.saved_workspace().is_none(), "no persistence when None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_tab_delegates_to_persistence() {
|
||||
let store = FakeStore::default();
|
||||
let close_tab = CloseTab::new(Arc::new(store.clone()));
|
||||
let id = ProjectId::from_uuid(uuid::Uuid::from_u128(5));
|
||||
|
||||
let out = close_tab
|
||||
.execute(CloseTabInput {
|
||||
project_id: id,
|
||||
workspace: Some(Workspace::default()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.project_id, id);
|
||||
assert!(store.saved_workspace().is_some(), "tab close persists too");
|
||||
}
|
||||
158
crates/application/tests/remote_usecases.rs
Normal file
158
crates/application/tests/remote_usecases.rs
Normal file
@ -0,0 +1,158 @@
|
||||
//! L9 tests for [`ConnectRemote`] with a mock [`RemoteHost`]. The same use case
|
||||
//! must behave identically whatever the host kind (Liskov), so we drive it with a
|
||||
//! fake host parameterised by kind + root reachability.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ProcessSpawner, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath,
|
||||
};
|
||||
use domain::{ProjectId, RemoteKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{ConnectRemote, ConnectRemoteInput};
|
||||
|
||||
// --- Fake filesystem (only `exists` matters here) -------------------------
|
||||
|
||||
struct FakeFs {
|
||||
existing_root: Option<String>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
Err(FsError::NotFound(p.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, p: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.existing_root.as_deref() == Some(p.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fake remote host -----------------------------------------------------
|
||||
|
||||
struct FakeHost {
|
||||
kind: RemoteKind,
|
||||
connect_ok: bool,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
impl FakeHost {
|
||||
fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc<dyn RemoteHost> {
|
||||
Arc::new(Self {
|
||||
kind,
|
||||
connect_ok,
|
||||
fs: Arc::new(FakeFs {
|
||||
existing_root: existing_root.map(ToOwned::to_owned),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl RemoteHost for FakeHost {
|
||||
fn kind(&self) -> RemoteKind {
|
||||
self.kind
|
||||
}
|
||||
async fn connect(&self) -> Result<(), RemoteError> {
|
||||
if self.connect_ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RemoteError::Connection("refused".to_owned()))
|
||||
}
|
||||
}
|
||||
fn file_system(&self) -> Arc<dyn FileSystem> {
|
||||
Arc::clone(&self.fs)
|
||||
}
|
||||
fn process_spawner(&self) -> Arc<dyn ProcessSpawner> {
|
||||
unreachable!("ConnectRemote does not use the spawner")
|
||||
}
|
||||
fn pty(&self) -> Arc<dyn PtyPort> {
|
||||
unreachable!("ConnectRemote does not use the pty")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, e: DomainEvent) {
|
||||
self.0.lock().unwrap().push(e);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn pid() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_succeeds_and_emits_event_for_any_host_kind() {
|
||||
// Liskov: identical behaviour for Local, Ssh and Wsl hosts.
|
||||
for kind in [RemoteKind::Local, RemoteKind::Ssh, RemoteKind::Wsl] {
|
||||
let host = FakeHost::make(kind, true, Some("/srv/app"));
|
||||
let bus = SpyBus::default();
|
||||
let out = ConnectRemote::new(Arc::new(bus.clone()))
|
||||
.execute(ConnectRemoteInput {
|
||||
host,
|
||||
project_id: pid(),
|
||||
root: "/srv/app".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, kind);
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::RemoteConnected { project_id: pid() }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_propagates_connection_failure() {
|
||||
let host = FakeHost::make(RemoteKind::Ssh, false, Some("/srv/app"));
|
||||
let bus = SpyBus::default();
|
||||
let err = ConnectRemote::new(Arc::new(bus.clone()))
|
||||
.execute(ConnectRemoteInput {
|
||||
host,
|
||||
project_id: pid(),
|
||||
root: "/srv/app".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "REMOTE", "got {err:?}");
|
||||
assert!(bus.events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_fails_when_root_unreachable() {
|
||||
let host = FakeHost::make(RemoteKind::Local, true, Some("/other"));
|
||||
let bus = SpyBus::default();
|
||||
let err = ConnectRemote::new(Arc::new(bus.clone()))
|
||||
.execute(ConnectRemoteInput {
|
||||
host,
|
||||
project_id: pid(),
|
||||
root: "/srv/app".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(bus.events().is_empty(), "no event when root is missing");
|
||||
}
|
||||
421
crates/application/tests/template_usecases.rs
Normal file
421
crates/application/tests/template_usecases.rs
Normal file
@ -0,0 +1,421 @@
|
||||
//! L7 tests for the template & synchronisation use cases, with in-memory port
|
||||
//! fakes (no real store/FS): `CreateTemplate`, `UpdateTemplate`,
|
||||
//! `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore,
|
||||
};
|
||||
use domain::template::{AgentTemplate, TemplateVersion};
|
||||
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput,
|
||||
DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
|
||||
UpdateTemplate, UpdateTemplateInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeTemplates(Arc<Mutex<Vec<AgentTemplate>>>);
|
||||
impl FakeTemplates {
|
||||
fn with(templates: Vec<AgentTemplate>) -> Self {
|
||||
Self(Arc::new(Mutex::new(templates)))
|
||||
}
|
||||
fn get_sync(&self, id: TemplateId) -> Option<AgentTemplate> {
|
||||
self.0.lock().unwrap().iter().find(|t| t.id == id).cloned()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl TemplateStore for FakeTemplates {
|
||||
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
|
||||
self.get_sync(id).ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
if let Some(slot) = v.iter_mut().find(|t| t.id == template.id) {
|
||||
*slot = template.clone();
|
||||
} else {
|
||||
v.push(template.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
let before = v.len();
|
||||
v.retain(|t| t.id != id);
|
||||
if v.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
|
||||
impl FakeContexts {
|
||||
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||
Self(Arc::new(Mutex::new((
|
||||
AgentManifest { version: 1, entries },
|
||||
HashMap::new(),
|
||||
))))
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
self.0.lock().unwrap().0.clone()
|
||||
}
|
||||
fn content(&self, md_path: &str) -> Option<String> {
|
||||
self.0.lock().unwrap().1.get(md_path).cloned()
|
||||
}
|
||||
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.0
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| &e.agent_id == agent)
|
||||
.map(|e| e.md_path.clone())
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_p: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_p: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest())
|
||||
}
|
||||
async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().0 = m.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
Self(Mutex::new(1))
|
||||
}
|
||||
}
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*n);
|
||||
*n += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn tid(n: u128) -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn v(n: u64) -> TemplateVersion {
|
||||
TemplateVersion(n)
|
||||
}
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/demo").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTemplate {
|
||||
let mut t = AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap();
|
||||
// Bump to the requested version by re-applying content updates.
|
||||
while t.version.get() < version {
|
||||
t = t.with_updated_content(MarkdownDoc::new(content));
|
||||
}
|
||||
t
|
||||
}
|
||||
/// A synchronized, template-backed manifest entry synced at `synced`.
|
||||
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
|
||||
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateTemplate / UpdateTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_template_starts_at_initial_version() {
|
||||
let store = FakeTemplates::default();
|
||||
let out = CreateTemplate::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
|
||||
.execute(CreateTemplateInput {
|
||||
name: "Backend".to_owned(),
|
||||
content: "# ctx".to_owned(),
|
||||
default_profile_id: pid(7),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.template.version, TemplateVersion::INITIAL);
|
||||
assert_eq!(out.template.default_profile_id, pid(7));
|
||||
assert_eq!(store.list().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_template_bumps_version_and_publishes_event() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);
|
||||
let bus = SpyBus::default();
|
||||
let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(1),
|
||||
content: "v2".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.template.version.get(), 2);
|
||||
assert_eq!(store.get_sync(tid(1)).unwrap().content_md.as_str(), "v2");
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::TemplateUpdated {
|
||||
template_id: tid(1),
|
||||
version: v(2),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_unknown_template_is_not_found() {
|
||||
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(404),
|
||||
content: "x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_agent_from_template_links_origin_and_seeds_context() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "Backend", "# body", 4)]);
|
||||
let contexts = FakeContexts::new(vec![]);
|
||||
let out = CreateAgentFromTemplate::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(CreateAgentFromTemplateInput {
|
||||
project: project(),
|
||||
template_id: tid(1),
|
||||
name: None,
|
||||
synchronized: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Name defaults to the template name; profile = template default.
|
||||
assert_eq!(out.agent.name, "Backend");
|
||||
assert_eq!(out.agent.profile_id, pid(1));
|
||||
assert!(out.agent.synchronized);
|
||||
assert_eq!(
|
||||
out.agent.origin,
|
||||
domain::AgentOrigin::FromTemplate {
|
||||
template_id: tid(1),
|
||||
synced_template_version: v(4),
|
||||
}
|
||||
);
|
||||
// Context seeded with the template content under the agent's md path.
|
||||
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
|
||||
assert_eq!(contexts.manifest().entries.len(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectAgentDrift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_drift_flags_only_synchronized_agents_behind() {
|
||||
// Template at v3.
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "v3", 3)]);
|
||||
// a1: synchronized, synced at v1 → drift (1→3).
|
||||
// a2: synchronized, synced at v3 → up to date, no drift.
|
||||
// a3: from template but NOT synchronized → ignored.
|
||||
// a4: scratch (no template) → ignored.
|
||||
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
|
||||
let contexts = FakeContexts::new(vec![
|
||||
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
|
||||
synced_entry(aid(2), "agents/a2.md", tid(1), 3),
|
||||
a3,
|
||||
a4,
|
||||
]);
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = DetectAgentDrift::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
|
||||
assert_eq!(out.drifts[0].agent_id, aid(1));
|
||||
assert_eq!(out.drifts[0].from, v(1));
|
||||
assert_eq!(out.drifts[0].to, v(3));
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::AgentDriftDetected {
|
||||
agent_id: aid(1),
|
||||
from: v(1),
|
||||
to: v(3),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_drift_ignores_deleted_template() {
|
||||
// No templates in the store, but an agent references tid(1): not an error.
|
||||
let store = FakeTemplates::default();
|
||||
let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]);
|
||||
let out = DetectAgentDrift::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.drifts.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SyncAgentWithTemplate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "newest body", 3)]);
|
||||
let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]);
|
||||
// Seed an old context so we can see the replacement.
|
||||
contexts
|
||||
.write_context(&project(), &aid(1), &MarkdownDoc::new("old"))
|
||||
.await
|
||||
.unwrap();
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = SyncAgentWithTemplate::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(SyncAgentWithTemplateInput {
|
||||
project: project(),
|
||||
agent_id: aid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.synced);
|
||||
assert_eq!(out.version, Some(v(3)));
|
||||
// Context replaced by the template content.
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
|
||||
// Manifest synced version advanced to 3.
|
||||
let entry = &contexts.manifest().entries[0];
|
||||
assert_eq!(entry.synced_template_version, Some(v(3)));
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::AgentSynced {
|
||||
agent_id: aid(1),
|
||||
to: v(3),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_ignores_non_synchronized_agent() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
|
||||
// Non-synchronized agent from a template.
|
||||
let entry =
|
||||
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let contexts = FakeContexts::new(vec![entry]);
|
||||
contexts
|
||||
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
|
||||
.await
|
||||
.unwrap();
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = SyncAgentWithTemplate::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(SyncAgentWithTemplateInput {
|
||||
project: project(),
|
||||
agent_id: aid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!out.synced, "non-synchronized agent is left untouched");
|
||||
assert_eq!(out.version, None);
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
|
||||
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
|
||||
}
|
||||
535
crates/application/tests/terminal_usecases.rs
Normal file
535
crates/application/tests/terminal_usecases.rs
Normal file
@ -0,0 +1,535 @@
|
||||
//! L3 tests for the terminal use cases (`OpenTerminal`, `WriteToTerminal`,
|
||||
//! `ResizeTerminal`, `CloseTerminal`) and the [`TerminalSessions`] registry.
|
||||
//!
|
||||
//! Every port is faked in-memory so the use cases run without any real PTY:
|
||||
//! - [`FakePty`] — a recording [`PtyPort`] that mints a deterministic
|
||||
//! [`SessionId`] on `spawn` and records every `write`/`resize`/`kill`,
|
||||
//! - [`SpyBus`] — records published [`DomainEvent`]s.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{
|
||||
EventBus, EventStream, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec,
|
||||
};
|
||||
use domain::{PtySize, SessionId};
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CloseTerminalInput, OpenTerminal, OpenTerminalInput, ResizeTerminal,
|
||||
ResizeTerminalInput, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One recorded PTY call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Call {
|
||||
Spawn { spec: SpawnSpec, size: PtySize },
|
||||
Write { id: SessionId, data: Vec<u8> },
|
||||
Resize { id: SessionId, size: PtySize },
|
||||
Kill { id: SessionId },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePtyInner {
|
||||
calls: Vec<Call>,
|
||||
/// SessionId the next `spawn` will mint (defaults to random).
|
||||
next_id: Option<SessionId>,
|
||||
/// Exit code the next `kill` will report.
|
||||
kill_code: Option<i32>,
|
||||
/// When set, `write`/`resize` fail to exercise error propagation.
|
||||
fail_io: bool,
|
||||
}
|
||||
|
||||
/// A recording [`PtyPort`]: no real OS PTY, just bookkeeping.
|
||||
#[derive(Default, Clone)]
|
||||
struct FakePty(Arc<Mutex<FakePtyInner>>);
|
||||
|
||||
impl FakePty {
|
||||
fn with_next_id(id: SessionId) -> Self {
|
||||
let pty = Self::default();
|
||||
pty.0.lock().unwrap().next_id = Some(id);
|
||||
pty
|
||||
}
|
||||
fn calls(&self) -> Vec<Call> {
|
||||
self.0.lock().unwrap().calls.clone()
|
||||
}
|
||||
fn set_kill_code(&self, code: Option<i32>) {
|
||||
self.0.lock().unwrap().kill_code = code;
|
||||
}
|
||||
fn set_fail_io(&self, fail: bool) {
|
||||
self.0.lock().unwrap().fail_io = fail;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PtyPort for FakePty {
|
||||
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.calls.push(Call::Spawn { spec, size });
|
||||
let session_id = inner.next_id.unwrap_or_else(SessionId::new_random);
|
||||
Ok(PtyHandle { session_id })
|
||||
}
|
||||
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
if inner.fail_io {
|
||||
return Err(PtyError::Io("boom".to_owned()));
|
||||
}
|
||||
inner.calls.push(Call::Write {
|
||||
id: handle.session_id,
|
||||
data: data.to_vec(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
if inner.fail_io {
|
||||
return Err(PtyError::Io("boom".to_owned()));
|
||||
}
|
||||
inner.calls.push(Call::Resize {
|
||||
id: handle.session_id,
|
||||
size,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.calls.push(Call::Kill {
|
||||
id: handle.session_id,
|
||||
});
|
||||
Ok(ExitStatus {
|
||||
code: inner.kill_code,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Records published events.
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn open_input(cwd: &str) -> OpenTerminalInput {
|
||||
OpenTerminalInput {
|
||||
cwd: cwd.to_owned(),
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
node_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenTerminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_spawns_with_resolved_spec_and_size() {
|
||||
let pty = FakePty::with_next_id(sid(42));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
|
||||
let input = OpenTerminalInput {
|
||||
command: Some("/bin/zsh".to_owned()),
|
||||
args: vec!["-l".to_owned()],
|
||||
..open_input("/home/me/proj")
|
||||
};
|
||||
let out = open.execute(input).await.expect("open succeeds");
|
||||
|
||||
// The session adopts the PTY-minted id.
|
||||
assert_eq!(out.session.id, sid(42));
|
||||
|
||||
let calls = pty.calls();
|
||||
assert_eq!(calls.len(), 1, "exactly one spawn");
|
||||
match &calls[0] {
|
||||
Call::Spawn { spec, size } => {
|
||||
assert_eq!(spec.command, "/bin/zsh");
|
||||
assert_eq!(spec.args, vec!["-l".to_owned()]);
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
||||
assert_eq!(*size, PtySize::new(24, 80).unwrap());
|
||||
}
|
||||
other => panic!("expected spawn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_defaults_command_when_none() {
|
||||
let pty = FakePty::default();
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
open.execute(open_input("/p")).await.unwrap();
|
||||
|
||||
match &pty.calls()[0] {
|
||||
Call::Spawn { spec, .. } => assert!(
|
||||
!spec.command.is_empty(),
|
||||
"a default shell command is filled in"
|
||||
),
|
||||
other => panic!("expected spawn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_registers_session_in_registry() {
|
||||
let pty = FakePty::with_next_id(sid(7));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
|
||||
assert!(sessions.is_empty());
|
||||
open.execute(open_input("/p")).await.unwrap();
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert!(sessions.handle(&sid(7)).is_some(), "handle registered");
|
||||
assert!(sessions.session(&sid(7)).is_some(), "snapshot registered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_publishes_pty_output_open_event() {
|
||||
let pty = FakePty::with_next_id(sid(9));
|
||||
let bus = SpyBus::default();
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
open.execute(open_input("/p")).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::PtyOutput {
|
||||
session_id: sid(9),
|
||||
bytes: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_rejects_non_absolute_cwd() {
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(FakePty::default()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
let err = open
|
||||
.execute(open_input("relative/path"))
|
||||
.await
|
||||
.expect_err("non-absolute cwd rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_rejects_zero_sized_terminal() {
|
||||
let pty = FakePty::default();
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
let err = open
|
||||
.execute(OpenTerminalInput {
|
||||
rows: 0,
|
||||
..open_input("/p")
|
||||
})
|
||||
.await
|
||||
.expect_err("zero-sized terminal rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
assert!(pty.calls().is_empty(), "must not spawn on invalid size");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WriteToTerminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_routes_bytes_to_the_right_session() {
|
||||
let pty = FakePty::with_next_id(sid(1));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let open = OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
);
|
||||
open.execute(open_input("/p")).await.unwrap();
|
||||
|
||||
let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
write
|
||||
.execute(WriteToTerminalInput {
|
||||
session_id: sid(1),
|
||||
data: b"ls\n".to_vec(),
|
||||
})
|
||||
.expect("write succeeds");
|
||||
|
||||
let writes: Vec<_> = pty
|
||||
.calls()
|
||||
.into_iter()
|
||||
.filter(|c| matches!(c, Call::Write { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
writes,
|
||||
vec![Call::Write {
|
||||
id: sid(1),
|
||||
data: b"ls\n".to_vec(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_to_unknown_session_is_not_found() {
|
||||
let pty = FakePty::default();
|
||||
let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new()));
|
||||
let err = write
|
||||
.execute(WriteToTerminalInput {
|
||||
session_id: sid(404),
|
||||
data: b"x".to_vec(),
|
||||
})
|
||||
.expect_err("unknown session rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.calls().is_empty(), "no PTY call for unknown session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_propagates_pty_io_error() {
|
||||
let pty = FakePty::with_next_id(sid(2));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(open_input("/p"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pty.set_fail_io(true);
|
||||
let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
let err = write
|
||||
.execute(WriteToTerminalInput {
|
||||
session_id: sid(2),
|
||||
data: b"x".to_vec(),
|
||||
})
|
||||
.expect_err("io failure surfaces");
|
||||
assert_eq!(err.code(), "PROCESS", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResizeTerminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_calls_pty_with_new_size() {
|
||||
let pty = FakePty::with_next_id(sid(3));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(open_input("/p"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resize = ResizeTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
resize
|
||||
.execute(ResizeTerminalInput {
|
||||
session_id: sid(3),
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
})
|
||||
.expect("resize succeeds");
|
||||
|
||||
let resizes: Vec<_> = pty
|
||||
.calls()
|
||||
.into_iter()
|
||||
.filter(|c| matches!(c, Call::Resize { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
resizes,
|
||||
vec![Call::Resize {
|
||||
id: sid(3),
|
||||
size: PtySize::new(40, 120).unwrap(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_unknown_session_is_not_found() {
|
||||
let resize = ResizeTerminal::new(
|
||||
Arc::new(FakePty::default()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
);
|
||||
let err = resize
|
||||
.execute(ResizeTerminalInput {
|
||||
session_id: sid(404),
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
})
|
||||
.expect_err("unknown session rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_rejects_zero_size() {
|
||||
let resize = ResizeTerminal::new(
|
||||
Arc::new(FakePty::default()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
);
|
||||
let err = resize
|
||||
.execute(ResizeTerminalInput {
|
||||
session_id: sid(1),
|
||||
rows: 0,
|
||||
cols: 80,
|
||||
})
|
||||
.expect_err("zero size rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloseTerminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_kills_pty_removes_session_and_returns_code() {
|
||||
let pty = FakePty::with_next_id(sid(5));
|
||||
pty.set_kill_code(Some(0));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(open_input("/p"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
|
||||
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
let out = close
|
||||
.execute(CloseTerminalInput {
|
||||
session_id: sid(5),
|
||||
})
|
||||
.await
|
||||
.expect("close succeeds");
|
||||
|
||||
assert_eq!(out.code, Some(0));
|
||||
assert!(sessions.is_empty(), "session removed from registry");
|
||||
assert!(
|
||||
pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
|
||||
"kill called for the session"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_surfaces_signal_exit_as_none_code() {
|
||||
let pty = FakePty::with_next_id(sid(6));
|
||||
pty.set_kill_code(None);
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
OpenTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(open_input("/p"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
let out = close
|
||||
.execute(CloseTerminalInput {
|
||||
session_id: sid(6),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.code, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_unknown_session_is_not_found() {
|
||||
let pty = FakePty::default();
|
||||
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new()));
|
||||
let err = close
|
||||
.execute(CloseTerminalInput {
|
||||
session_id: sid(404),
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown session rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.calls().is_empty(), "no kill for unknown session");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TerminalSessions registry (unit-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn registry_insert_handle_session_remove_len() {
|
||||
use domain::{NodeId, ProjectPath, SessionKind, TerminalSession};
|
||||
|
||||
let registry = TerminalSessions::new();
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.len(), 0);
|
||||
|
||||
let id = sid(100);
|
||||
let handle = PtyHandle { session_id: id };
|
||||
let session = TerminalSession::starting(
|
||||
id,
|
||||
NodeId::new_random(),
|
||||
ProjectPath::new("/p").unwrap(),
|
||||
SessionKind::Plain,
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
registry.insert(handle.clone(), session);
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert!(!registry.is_empty());
|
||||
assert_eq!(registry.handle(&id), Some(handle.clone()));
|
||||
assert!(registry.session(&id).is_some());
|
||||
assert_eq!(registry.session(&id).unwrap().id, id);
|
||||
|
||||
// Unknown id resolves to None.
|
||||
assert!(registry.handle(&sid(999)).is_none());
|
||||
assert!(registry.session(&sid(999)).is_none());
|
||||
|
||||
// Remove returns the handle and empties the registry.
|
||||
assert_eq!(registry.remove(&id), Some(handle));
|
||||
assert!(registry.is_empty());
|
||||
assert!(registry.remove(&id).is_none(), "second remove is a no-op");
|
||||
}
|
||||
111
crates/application/tests/window_usecases.rs
Normal file
111
crates/application/tests/window_usecases.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is
|
||||
//! detached and the workspace is persisted (load returns the new state).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ids::{ProjectId, TabId, WindowId};
|
||||
use domain::layout::{LayoutNode, LayoutTree, LeafCell, Tab, Window, Workspace};
|
||||
use domain::ports::{IdGenerator, ProjectStore, StoreError};
|
||||
use domain::project::Project;
|
||||
use domain::NodeId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{MoveTabToNewWindow, MoveTabToNewWindowInput};
|
||||
|
||||
/// A `ProjectStore` fake that only implements the workspace persistence the use
|
||||
/// case needs (the project methods are never called here).
|
||||
#[derive(Clone)]
|
||||
struct FakeStore(Arc<Mutex<Workspace>>);
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> {
|
||||
*self.0.lock().unwrap() = ws.clone();
|
||||
Ok(())
|
||||
}
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut n = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*n);
|
||||
*n += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
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 tab(n: u128) -> Tab {
|
||||
Tab {
|
||||
id: tid(n),
|
||||
project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
|
||||
layout: LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
||||
id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
|
||||
session: None,
|
||||
agent: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn seeded() -> FakeStore {
|
||||
let ws = Workspace {
|
||||
windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()],
|
||||
};
|
||||
FakeStore(Arc::new(Mutex::new(ws)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detaches_tab_and_persists_workspace() {
|
||||
let store = seeded();
|
||||
// The id generator's first uuid (from_u128(7)) becomes the new window id.
|
||||
let ids = Arc::new(SeqIds(Mutex::new(7)));
|
||||
let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids);
|
||||
|
||||
let out = uc
|
||||
.execute(MoveTabToNewWindowInput { tab_id: tid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7)));
|
||||
assert_eq!(out.workspace.windows.len(), 2);
|
||||
|
||||
// Persisted: reloading the store yields the detached layout.
|
||||
let reloaded = store.load_workspace().await.unwrap();
|
||||
assert_eq!(reloaded, out.workspace);
|
||||
let detached = reloaded
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.id == out.new_window_id)
|
||||
.unwrap();
|
||||
assert_eq!(detached.tabs.len(), 1);
|
||||
assert_eq!(detached.tabs[0].id, tid(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tab_is_not_found() {
|
||||
let store = seeded();
|
||||
let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7))));
|
||||
let err = uc
|
||||
.execute(MoveTabToNewWindowInput { tab_id: tid(404) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user