feat: add main features

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

View File

@ -0,0 +1,19 @@
[package]
name = "infrastructure"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA — infrastructure layer: concrete adapters implementing the domain ports (fs, event bus, clock, id)."
[dependencies]
domain = { workspace = true }
# `process` (additive) powers LocalProcessSpawner; the workspace baseline keeps
# rt/macros/sync/fs/io-util.
tokio = { workspace = true, features = ["process"] }
uuid = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
portable-pty = "0.9"
git2 = { workspace = true }

View File

@ -0,0 +1,27 @@
//! [`SystemClock`] — production [`Clock`] backed by the system wall clock.
use std::time::{SystemTime, UNIX_EPOCH};
use domain::ports::Clock;
/// Real clock returning the current epoch time in milliseconds.
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl SystemClock {
/// Creates a new [`SystemClock`].
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Clock for SystemClock {
fn now_millis(&self) -> i64 {
// Saturating cast is fine: epoch millis fits in i64 until year 292M.
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
}

View File

@ -0,0 +1,86 @@
//! [`TokioBroadcastEventBus`] — in-process [`EventBus`] backed by
//! [`tokio::sync::broadcast`].
//!
//! `publish` fans out to all current subscribers. `subscribe` returns the
//! domain's [`EventStream`] (a `Box<dyn Iterator>`): a **blocking** iterator
//! that pulls events off the broadcast receiver. The Tauri event relay drives
//! it from a dedicated thread / blocking task, so blocking is acceptable and
//! keeps the domain port signature (`-> EventStream`) intact without forcing it
//! to become async.
use domain::events::DomainEvent;
use domain::ports::{EventBus, EventStream};
use tokio::sync::broadcast;
/// Default capacity of the broadcast ring buffer.
const DEFAULT_CAPACITY: usize = 1024;
/// An in-process event bus relaying [`DomainEvent`]s to all subscribers via a
/// Tokio broadcast channel.
#[derive(Clone)]
pub struct TokioBroadcastEventBus {
sender: broadcast::Sender<DomainEvent>,
}
impl TokioBroadcastEventBus {
/// Creates a bus with the default buffer capacity.
#[must_use]
pub fn new() -> Self {
Self::with_capacity(DEFAULT_CAPACITY)
}
/// Creates a bus with an explicit buffer capacity.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
let (sender, _rx) = broadcast::channel(capacity);
Self { sender }
}
/// Returns a raw async receiver, useful for relays that prefer to consume
/// events on the Tokio runtime rather than via the blocking [`EventStream`].
#[must_use]
pub fn raw_receiver(&self) -> broadcast::Receiver<DomainEvent> {
self.sender.subscribe()
}
}
impl Default for TokioBroadcastEventBus {
fn default() -> Self {
Self::new()
}
}
impl EventBus for TokioBroadcastEventBus {
fn publish(&self, event: DomainEvent) {
// A send error only means there are currently no subscribers; that is
// not an error condition for a fire-and-forget bus.
let _ = self.sender.send(event);
}
fn subscribe(&self) -> EventStream {
Box::new(BroadcastIter {
rx: self.sender.subscribe(),
})
}
}
/// Blocking iterator adapter over a broadcast receiver. `next()` blocks until an
/// event arrives; it ends when the channel is closed, and skips past `Lagged`
/// notices (dropping the lagged count and continuing).
struct BroadcastIter {
rx: broadcast::Receiver<DomainEvent>,
}
impl Iterator for BroadcastIter {
type Item = DomainEvent;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.rx.blocking_recv() {
Ok(event) => return Some(event),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}
}

View File

@ -0,0 +1,102 @@
//! [`LocalFileSystem`] — minimal [`FileSystem`] adapter over [`tokio::fs`].
//!
//! Maps [`RemotePath`] (a location-neutral string path) onto the local OS
//! filesystem. Only the operations needed to wire up the composition root and
//! later lots are implemented; richer behaviour (and SSH/WSL siblings) arrives
//! in L2/L9.
use std::io;
use std::path::Path;
use async_trait::async_trait;
use domain::ports::{DirEntry, FileSystem, FsError, RemotePath};
use tokio::fs;
/// Filesystem adapter backed by the local OS via `tokio::fs`.
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalFileSystem;
impl LocalFileSystem {
/// Creates a new [`LocalFileSystem`].
#[must_use]
pub const fn new() -> Self {
Self
}
}
/// Maps a [`std::io::Error`] to the domain's [`FsError`], preserving the
/// not-found / permission distinctions the application layer cares about.
fn map_io(path: &RemotePath, err: &io::Error) -> FsError {
match err.kind() {
io::ErrorKind::NotFound => FsError::NotFound(path.as_str().to_owned()),
io::ErrorKind::PermissionDenied => FsError::PermissionDenied(path.as_str().to_owned()),
_ => FsError::Io(format!("{}: {err}", path.as_str())),
}
}
#[async_trait]
impl FileSystem for LocalFileSystem {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
fs::read(path.as_str())
.await
.map_err(|e| map_io(path, &e))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
fs::write(path.as_str(), data)
.await
.map_err(|e| map_io(path, &e))
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
match fs::metadata(path.as_str()).await {
Ok(_) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(map_io(path, &e)),
}
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
fs::create_dir_all(path.as_str())
.await
.map_err(|e| map_io(path, &e))
}
async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
let mut entries = Vec::new();
let mut read_dir = fs::read_dir(path.as_str())
.await
.map_err(|e| map_io(path, &e))?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? {
let is_dir = entry
.file_type()
.await
.map(|t| t.is_dir())
.unwrap_or(false);
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir,
});
}
Ok(entries)
}
async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError> {
symlink_impl(Path::new(src.as_str()), Path::new(dst.as_str()))
.await
.map_err(|e| map_io(dst, &e))
}
}
#[cfg(unix)]
async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> {
fs::symlink(src, dst).await
}
#[cfg(windows)]
async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> {
// On Windows we default to a file symlink; directory symlinks require a
// different call and are handled when the store layer (L2/L6) needs them.
fs::symlink_file(src, dst).await
}

View File

@ -0,0 +1,275 @@
//! [`Git2Repository`] — local Git adapter implementing the [`GitPort`] port via
//! libgit2 (`git2`), ARCHITECTURE §5, L8.
//!
//! Scope is **local** operations (status, stage/unstage, commit, branches,
//! checkout, log, init). Network operations (`pull`/`push`) need remote +
//! credential handling and are deferred to L9 (`RemoteGitRepository` over SSH/WSL
//! and credential callbacks); here they return a clear [`GitError::Operation`].
//!
//! # Async & `Send`
//!
//! [`GitPort`] is `#[async_trait]`, but libgit2 is synchronous and its handles
//! ([`git2::Repository`]) are `!Send`. Each method opens the repository, does all
//! its work, and drops it **within a single poll** — there is no `.await` while a
//! repo/handle is alive — so the returned futures are `Send` and the adapter is
//! safe behind `Arc<dyn GitPort>` on the multi-threaded runtime.
use async_trait::async_trait;
use git2::{BranchType, ErrorCode, Repository, Status, StatusOptions};
use std::collections::HashMap;
use domain::ports::{GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit};
use domain::project::ProjectPath;
/// Local Git adapter backed by libgit2.
#[derive(Clone, Default)]
pub struct Git2Repository;
impl Git2Repository {
/// Builds the adapter (stateless; a repository is opened per call from the
/// project root, so one instance serves every project).
#[must_use]
pub fn new() -> Self {
Self
}
}
/// Maps a libgit2 error to a domain [`GitError`] (its message, not the raw type).
fn op(e: git2::Error) -> GitError {
GitError::Operation(e.message().to_owned())
}
/// Opens the repository at `root`, distinguishing "not a repo" from other errors.
fn open(root: &ProjectPath) -> Result<Repository, GitError> {
Repository::open(root.as_str()).map_err(|e| {
if e.code() == ErrorCode::NotFound {
GitError::NotFound
} else {
op(e)
}
})
}
/// Index-side status flags (a path with any of these has staged changes).
const STAGED: Status = Status::INDEX_NEW
.union(Status::INDEX_MODIFIED)
.union(Status::INDEX_DELETED)
.union(Status::INDEX_RENAMED)
.union(Status::INDEX_TYPECHANGE);
#[async_trait]
impl GitPort for Git2Repository {
async fn init(&self, root: &ProjectPath) -> Result<(), GitError> {
Repository::init(root.as_str()).map_err(op)?;
Ok(())
}
async fn status(&self, root: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError> {
let repo = open(root)?;
let mut opts = StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts)).map_err(op)?;
let mut out = Vec::new();
for entry in statuses.iter() {
let s = entry.status();
if s.is_ignored() {
continue;
}
if let Some(path) = entry.path() {
out.push(GitFileStatus {
path: path.to_owned(),
staged: s.intersects(STAGED),
});
}
}
Ok(out)
}
async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> {
let repo = open(root)?;
let mut index = repo.index().map_err(op)?;
index.add_path(std::path::Path::new(path)).map_err(op)?;
index.write().map_err(op)?;
Ok(())
}
async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> {
let repo = open(root)?;
match repo.head() {
// Reset the path in the index back to its HEAD state.
Ok(head) => {
let obj = head.peel(git2::ObjectType::Commit).map_err(op)?;
repo.reset_default(Some(&obj), [path]).map_err(op)?;
}
// Unborn HEAD (no commit yet): "unstage" means drop it from the index.
Err(_) => {
let mut index = repo.index().map_err(op)?;
index.remove_path(std::path::Path::new(path)).map_err(op)?;
index.write().map_err(op)?;
}
}
Ok(())
}
async fn commit(&self, root: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError> {
let repo = open(root)?;
let mut index = repo.index().map_err(op)?;
let tree_oid = index.write_tree().map_err(op)?;
let tree = repo.find_tree(tree_oid).map_err(op)?;
// Prefer the configured identity; fall back to a stable local one so a
// fresh repo with no user.name/email can still commit.
let sig = repo
.signature()
.or_else(|_| git2::Signature::now("IdeA", "idea@localhost"))
.map_err(op)?;
let parent = match repo.head() {
Ok(head) => Some(head.peel_to_commit().map_err(op)?),
Err(_) => None,
};
let parents: Vec<&git2::Commit> = parent.iter().collect();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
.map_err(op)?;
Ok(GitCommitInfo {
hash: oid.to_string(),
summary: message.lines().next().unwrap_or("").to_owned(),
})
}
async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError> {
let repo = open(root)?;
let mut names = Vec::new();
for branch in repo.branches(Some(BranchType::Local)).map_err(op)? {
let (branch, _) = branch.map_err(op)?;
if let Some(name) = branch.name().map_err(op)? {
names.push(name.to_owned());
}
}
Ok(names)
}
async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, GitError> {
let repo = open(root)?;
let head = match repo.head() {
Ok(head) => head,
// Unborn branch (no commits yet): no current branch to report.
Err(e) if e.code() == ErrorCode::UnbornBranch => return Ok(None),
Err(e) => return Err(op(e)),
};
// A detached HEAD (or a non-branch ref) has no branch name.
Ok(if head.is_branch() {
head.shorthand().map(ToOwned::to_owned)
} else {
None
})
}
async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError> {
let repo = open(root)?;
let obj = repo.revparse_single(branch).map_err(op)?;
repo.checkout_tree(&obj, None).map_err(op)?;
repo.set_head(&format!("refs/heads/{branch}")).map_err(op)?;
Ok(())
}
async fn log(
&self,
root: &ProjectPath,
limit: usize,
) -> Result<Vec<GitCommitInfo>, GitError> {
let repo = open(root)?;
let mut revwalk = repo.revwalk().map_err(op)?;
// No commits yet ⇒ nothing to walk.
if revwalk.push_head().is_err() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for oid in revwalk.take(limit) {
let oid = oid.map_err(op)?;
let commit = repo.find_commit(oid).map_err(op)?;
out.push(GitCommitInfo {
hash: oid.to_string(),
summary: commit.summary().unwrap_or("").to_owned(),
});
}
Ok(out)
}
async fn log_graph(
&self,
root: &ProjectPath,
limit: usize,
) -> Result<Vec<GraphCommit>, GitError> {
let repo = open(root)?;
// Build a map from OID → ref short-names for label attachment.
let mut ref_map: HashMap<git2::Oid, Vec<String>> = HashMap::new();
if let Ok(references) = repo.references() {
for reference in references.flatten() {
// Only handle symbolic and direct refs; skip errors.
let target_oid = reference.resolve().ok().and_then(|r| r.target());
if let Some(oid) = target_oid {
let label = match reference.shorthand() {
Some(name) => {
// Distinguish tags from branches by prefix.
if reference.is_tag() {
format!("tag: {name}")
} else {
name.to_owned()
}
}
None => continue,
};
ref_map.entry(oid).or_default().push(label);
}
}
}
let mut revwalk = repo.revwalk().map_err(op)?;
revwalk
.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
.map_err(op)?;
// Push all local branches; if the repo is empty this produces no OIDs.
let _ = revwalk.push_glob("refs/heads/*");
let mut out = Vec::new();
for oid_result in revwalk.take(limit) {
let oid = oid_result.map_err(op)?;
let commit = repo.find_commit(oid).map_err(op)?;
let parents = commit
.parent_ids()
.map(|p| p.to_string())
.collect::<Vec<_>>();
let author = commit.author().name().unwrap_or("").to_owned();
let timestamp = commit.time().seconds();
let refs = ref_map.get(&oid).cloned().unwrap_or_default();
out.push(GraphCommit {
hash: oid.to_string(),
summary: commit.summary().unwrap_or("").to_owned(),
parents,
refs,
author,
timestamp,
});
}
Ok(out)
}
async fn pull(&self, _root: &ProjectPath) -> Result<(), GitError> {
Err(GitError::Operation(
"pull requires remote/credential configuration (L9)".to_owned(),
))
}
async fn push(&self, _root: &ProjectPath) -> Result<(), GitError> {
Err(GitError::Operation(
"push requires remote/credential configuration (L9)".to_owned(),
))
}
}

View File

@ -0,0 +1,22 @@
//! [`UuidGenerator`] — production [`IdGenerator`] producing random v4 UUIDs.
use domain::ports::IdGenerator;
use uuid::Uuid;
/// Real id generator producing random (v4) UUIDs.
#[derive(Debug, Default, Clone, Copy)]
pub struct UuidGenerator;
impl UuidGenerator {
/// Creates a new [`UuidGenerator`].
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl IdGenerator for UuidGenerator {
fn new_uuid(&self) -> Uuid {
Uuid::new_v4()
}
}

View File

@ -0,0 +1,35 @@
//! # IdeA — Infrastructure layer
//!
//! Concrete **adapters** implementing the domain ports (ARCHITECTURE §5). All
//! real-world I/O (`tokio::fs`, broadcast channels, system clock, UUIDs) lives
//! here, never in `domain` or `application`.
//!
//! L1 shipped the DI/event-relay adapters: [`LocalFileSystem`],
//! [`TokioBroadcastEventBus`], [`SystemClock`], [`UuidGenerator`]. L2 adds the
//! project persistence adapter [`FsProjectStore`]. L3 adds the local PTY adapter
//! [`PortablePtyAdapter`]. Git/remote/template adapters arrive in later lots.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod clock;
pub mod eventbus;
pub mod fs;
pub mod git;
pub mod id;
pub mod process;
pub mod pty;
pub mod remote;
pub mod runtime;
pub mod store;
pub use clock::SystemClock;
pub use eventbus::TokioBroadcastEventBus;
pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime;
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};

View File

@ -0,0 +1,53 @@
//! [`LocalProcessSpawner`] — local [`ProcessSpawner`] over `tokio::process`
//! (ARCHITECTURE §5).
//!
//! Runs a **non-interactive** process to completion and captures
//! stdout/stderr/exit code. Used by `DetectProfiles` (via [`CliAgentRuntime`])
//! and any future short-lived command (git fallbacks, scripts). Interactive
//! processes go through the PTY port instead.
use async_trait::async_trait;
use tokio::process::Command;
use domain::ports::{ExitStatus, Output, ProcessError, ProcessSpawner, SpawnSpec};
/// Process spawner backed by the local OS via `tokio::process::Command`.
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalProcessSpawner;
impl LocalProcessSpawner {
/// Creates a new [`LocalProcessSpawner`].
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait]
impl ProcessSpawner for LocalProcessSpawner {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
let mut cmd = Command::new(&spec.command);
cmd.args(&spec.args);
// `cwd` is "/" for detection probes; only set a real working directory
// when the spec points at a concrete project path.
if spec.cwd.as_str() != "/" {
cmd.current_dir(spec.cwd.as_str());
}
for (key, value) in &spec.env {
cmd.env(key, value);
}
let output = cmd
.output()
.await
.map_err(|e| ProcessError::Spawn(format!("{}: {e}", spec.command)))?;
Ok(Output {
status: ExitStatus {
code: output.status.code(),
},
stdout: output.stdout,
stderr: output.stderr,
})
}
}

View File

@ -0,0 +1,238 @@
//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the
//! `portable-pty` crate (ARCHITECTURE §5, L3).
//!
//! # Design
//!
//! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a
//! `Box<dyn Read>` reader and a `Box<dyn Write>` writer, and the child process is
//! waited on a thread. We bridge that to the domain [`PtyPort`] as follows:
//!
//! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master
//! PTY, writer, child) live in this adapter's registry keyed by that id. The
//! domain never sees an OS handle (ARCHITECTURE §4).
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
//! slave, then start **one reader thread** that pumps bytes from the master
//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output)
//! hands back the receiver wrapped as the domain's blocking [`OutputStream`]
//! iterator; the presentation layer drains it on its own thread and forwards
//! chunks to the per-session Tauri channel (the `PtyBridge`).
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
//! reader thread, and returns the [`ExitStatus`].
//!
//! # Cross-platform note (spike, ARCHITECTURE §13.1)
//!
//! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics
//! differ: a Unix process killed by a signal reports `code: None` here, while
//! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere.
//! Points to validate on Windows: child `kill()` actually tears down ConPTY, and
//! the reader thread observes EOF promptly on exit. The code below avoids any
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Mutex;
use std::thread::JoinHandle;
use async_trait::async_trait;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
use domain::terminal::PtySize;
use domain::SessionId;
/// Size of each read buffer pumped from the master PTY.
const READ_BUF: usize = 8 * 1024;
/// A live PTY owned by the adapter.
struct LivePty {
/// Master side — used for resize.
master: Box<dyn MasterPty + Send>,
/// Writer into the PTY (child stdin).
writer: Box<dyn Write + Send>,
/// The spawned child process.
child: Box<dyn Child + Send + Sync>,
/// Receiver end of the output channel; taken once by `subscribe_output`.
output_rx: Option<Receiver<Vec<u8>>>,
/// Handle of the reader thread, joined on kill.
reader: Option<JoinHandle<()>>,
}
/// Local PTY adapter backed by `portable-pty`'s native PTY system.
///
/// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is
/// cloneable-as-`Arc` and injected as `Arc<dyn PtyPort>` at the composition root.
#[derive(Default)]
pub struct PortablePtyAdapter {
sessions: Mutex<HashMap<SessionId, LivePty>>,
}
impl PortablePtyAdapter {
/// Creates an empty adapter.
#[must_use]
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
}
}
}
/// Maps the domain [`PtySize`] to the `portable-pty` one.
fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
portable_pty::PtySize {
rows: size.rows,
cols: size.cols,
pixel_width: 0,
pixel_height: 0,
}
}
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
let mut cmd = CommandBuilder::new(&spec.command);
cmd.args(&spec.args);
cmd.cwd(spec.cwd.as_str());
for (k, v) in &spec.env {
cmd.env(k, v);
}
cmd
}
#[async_trait]
impl PtyPort for PortablePtyAdapter {
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(to_pty_size(size))
.map_err(|e| PtyError::Spawn(e.to_string()))?;
let cmd = to_command(&spec);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| PtyError::Spawn(e.to_string()))?;
// The slave is held by the child; drop our copy so EOF propagates on exit.
drop(pair.slave);
let writer = pair
.master
.take_writer()
.map_err(|e| PtyError::Io(e.to_string()))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| PtyError::Io(e.to_string()))?;
// One reader thread per PTY pumps bytes into the output channel until EOF.
let (tx, rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel();
let reader_handle = std::thread::spawn(move || {
let mut buf = [0u8; READ_BUF];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
// Receiver gone (session closed) → stop pumping.
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
// The PTY layer owns the handle identity: it mints a fresh session id and
// the caller (the `OpenTerminal` use case) adopts it as the
// `TerminalSession.id`. This keeps the OS handle out of the domain while
// giving everyone a single, agreed-upon id (ARCHITECTURE §4).
let handle = PtyHandle {
session_id: SessionId::new_random(),
};
let live = LivePty {
master: pair.master,
writer,
child,
output_rx: Some(rx),
reader: Some(reader_handle),
};
self.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?
.insert(handle.session_id, live);
Ok(handle)
}
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
let mut map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
live.writer
.write_all(data)
.map_err(|e| PtyError::Io(e.to_string()))?;
live.writer
.flush()
.map_err(|e| PtyError::Io(e.to_string()))
}
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
let map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
live.master
.resize(to_pty_size(size))
.map_err(|e| PtyError::Io(e.to_string()))
}
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
let mut map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
let rx = live
.output_rx
.take()
.ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?;
Ok(Box::new(rx.into_iter()))
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
// Remove from the registry so the writer/master drop and the child is
// fully owned here while we tear it down.
let mut live = {
let mut map = self
.sessions
.lock()
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
map.remove(&handle.session_id).ok_or(PtyError::NotFound)?
};
// Ask the child to terminate, then wait for its real status.
let _ = live.child.kill();
let status = live
.child
.wait()
.map_err(|e| PtyError::Io(e.to_string()))?;
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
drop(live.output_rx.take());
if let Some(reader) = live.reader.take() {
let _ = reader.join();
}
Ok(ExitStatus {
code: exit_code(&status),
})
}
}
/// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes
/// `exit_code(): u32`; `0` is success. We surface it as `i32`.
fn exit_code(status: &portable_pty::ExitStatus) -> Option<i32> {
Some(status.exit_code() as i32)
}

View File

@ -0,0 +1,95 @@
//! Remote-host strategy adapters (ARCHITECTURE §5, L9).
//!
//! A [`RemoteHost`] is the **factory** that decides *where* a project's I/O
//! happens — it hands out the location-appropriate [`FileSystem`],
//! [`ProcessSpawner`] and [`PtyPort`]. Routing every use case through the
//! project's host is what makes local and remote execution transparent (Liskov,
//! ARCHITECTURE §1.2).
//!
//! This module ships [`LocalHost`] (the local strategy, fully wired and tested)
//! and the [`remote_host`] selector. The SSH and WSL strategies (`SshHost` via
//! russh/SFTP, `WslHost` via `wsl.exe`) are the remaining L9 work; their
//! integration tests are environment-gated (no SSH server / no WSL here), so they
//! are not yet wired in to avoid shipping unverified adapters. Until then the
//! selector reports them as unsupported rather than silently failing later.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{FileSystem, ProcessSpawner, PtyPort, RemoteError, RemoteHost};
use domain::remote::{RemoteKind, RemoteRef};
use crate::{LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter};
/// The local execution strategy: the project lives on this machine, so the host
/// simply hands out the local adapters.
#[derive(Clone)]
pub struct LocalHost {
fs: Arc<dyn FileSystem>,
spawner: Arc<dyn ProcessSpawner>,
pty: Arc<dyn PtyPort>,
}
impl LocalHost {
/// Builds a local host wired to the local FS / process / PTY adapters.
#[must_use]
pub fn new() -> Self {
Self {
fs: Arc::new(LocalFileSystem::new()),
spawner: Arc::new(LocalProcessSpawner::new()),
pty: Arc::new(PortablePtyAdapter::new()),
}
}
}
impl Default for LocalHost {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RemoteHost for LocalHost {
fn kind(&self) -> RemoteKind {
RemoteKind::Local
}
async fn connect(&self) -> Result<(), RemoteError> {
// Nothing to establish for the local machine.
Ok(())
}
fn file_system(&self) -> Arc<dyn FileSystem> {
Arc::clone(&self.fs)
}
fn process_spawner(&self) -> Arc<dyn ProcessSpawner> {
Arc::clone(&self.spawner)
}
fn pty(&self) -> Arc<dyn PtyPort> {
Arc::clone(&self.pty)
}
}
/// Selects and builds the [`RemoteHost`] for a [`RemoteRef`].
///
/// `Local` yields a [`LocalHost`]. `Ssh`/`Wsl` are not yet wired (their adapters
/// are the remaining, environment-gated L9 work) and return a clear
/// [`RemoteError::Connection`] so callers fail fast with an actionable message
/// instead of a confusing downstream error.
///
/// # Errors
/// [`RemoteError::Connection`] for SSH/WSL remotes until their adapters land.
pub fn remote_host(remote: &RemoteRef) -> Result<Arc<dyn RemoteHost>, RemoteError> {
match remote {
RemoteRef::Local => Ok(Arc::new(LocalHost::new())),
RemoteRef::Ssh { host, .. } => Err(RemoteError::Connection(format!(
"SSH remote ({host}) is not yet supported"
))),
RemoteRef::Wsl { distro } => Err(RemoteError::Connection(format!(
"WSL remote ({distro}) is not yet supported"
))),
}
}

View File

@ -0,0 +1,189 @@
//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by
//! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9).
//!
//! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex,
//! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`]
//! data, never in code. Adding an AI = adding a profile (the **Open/Closed**
//! principle made literal).
//!
//! # Responsibilities
//!
//! - [`detect`](AgentRuntime::detect): run the profile's detection command via
//! the injected [`ProcessSpawner`] and report whether the CLI is installed
//! (exit code 0). When `profile.detect` is `None`, fall back to
//! `<command> --version` (see [`detection_spec`]).
//! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure**
//! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved
//! `cwd` (template substitution), and the context-injection plan derived from
//! the profile's [`ContextInjection`]. This is the testable core of L5.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
#[derive(Clone)]
pub struct CliAgentRuntime {
spawner: Arc<dyn ProcessSpawner>,
}
impl CliAgentRuntime {
/// Builds the runtime from an injected [`ProcessSpawner`] (the composition
/// root passes a [`crate::LocalProcessSpawner`], or a remote one later).
#[must_use]
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
Self { spawner }
}
/// Builds the [`SpawnSpec`] used to *detect* a profile's CLI.
///
/// - If `profile.detect` is set, it is parsed as a whitespace-delimited
/// command line (`"claude --version"` → command `claude`, args
/// `["--version"]`). The first token is the executable.
/// - Otherwise we fall back to `<command> --version`, the near-universal
/// "is it installed?" probe.
///
/// Detection runs in the current working directory and injects nothing.
///
/// This is pure (no I/O) and therefore unit-testable without a spawner.
///
/// # Errors
/// [`RuntimeError::Detection`] if the detection command is blank.
pub fn detection_spec(profile: &AgentProfile) -> Result<SpawnSpec, RuntimeError> {
let line = profile
.detect
.clone()
.unwrap_or_else(|| format!("{} --version", profile.command));
let mut tokens = line.split_whitespace();
let command = tokens
.next()
.ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))?
.to_owned();
let args = tokens.map(str::to_owned).collect();
// Detection runs in a neutral cwd; "." is a safe relative placeholder the
// spawner resolves against the process cwd.
let cwd = ProjectPath::new("/")
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(SpawnSpec {
command,
args,
cwd,
env: Vec::new(),
context_plan: None,
})
}
/// Resolves the profile's `cwd_template` against the project root.
///
/// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty
/// template defaults to the project root itself.
fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
let template = profile.cwd_template.trim();
if template.is_empty() {
return Ok(root.clone());
}
let resolved = template.replace("{projectRoot}", root.as_str());
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
}
/// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and
/// the prepared context. **Pure** — the testable heart of L5.
///
/// | `ContextInjection` | `ContextInjectionPlan` |
/// |---------------------------|----------------------------------------------------------|
/// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there |
/// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path |
/// | `Stdin` | `Stdin` — content piped on stdin |
/// | `Env { var }` | `Env { var }` — content/path delivered via env var |
///
/// For `Flag`, the flag template may contain `{path}` (replaced by the
/// context's relative path). A flag *without* `{path}` is treated as a
/// standalone switch followed by the path as a separate argument
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
/// whitespace after substitution (e.g. `--context-file {path}` →
/// `["--context-file", "<path>"]`).
fn injection_plan(
injection: &ContextInjection,
ctx: &PreparedContext,
) -> ContextInjectionPlan {
match injection {
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
target: target.clone(),
},
ContextInjection::Flag { flag } => {
let path = &ctx.relative_path;
let args = if flag.contains("{path}") {
flag.replace("{path}", path)
.split_whitespace()
.map(str::to_owned)
.collect()
} else {
vec![flag.clone(), path.clone()]
};
ContextInjectionPlan::Args { args }
}
ContextInjection::Stdin => ContextInjectionPlan::Stdin,
ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
}
}
}
#[async_trait]
impl AgentRuntime for CliAgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
let spec = Self::detection_spec(profile)?;
// The port is synchronous but the spawner is async; block on it. The
// adapter is invoked off the Tauri async runtime (detection is a
// short-lived probe), so a transient runtime here is acceptable and keeps
// the `AgentRuntime` port plain-`fn` as the domain declares it.
let spawner = Arc::clone(&self.spawner);
let output = futures_block_on(async move { spawner.run(spec).await })
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(output.status.code == Some(0))
}
fn prepare_invocation(
&self,
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
) -> Result<SpawnSpec, RuntimeError> {
let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
let plan = Self::injection_plan(&profile.context_injection, ctx);
// For the `Flag` strategy the context path travels *on the command line*,
// so fold those args into the spec's args (after the profile's static
// args). The other strategies leave args untouched; the plan tells the
// launcher (L6) what else to do (write file / pipe stdin / set env).
let mut args = profile.args.clone();
if let ContextInjectionPlan::Args { args: extra } = &plan {
args.extend(extra.iter().cloned());
}
Ok(SpawnSpec {
command: profile.command.clone(),
args,
cwd: resolved_cwd,
env: Vec::new(),
context_plan: Some(plan),
})
}
}
/// Minimal block-on helper that drives a future to completion on a fresh
/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose
/// port signature is synchronous while the [`ProcessSpawner`] it calls is async.
fn futures_block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build a current-thread runtime for detection")
.block_on(fut)
}

View File

@ -0,0 +1,172 @@
//! [`IdeaiContextStore`] — file implementation of the [`AgentContextStore`] port
//! (ARCHITECTURE §5, §9.1).
//!
//! Persists, **inside the project root**, the agent contexts and the manifest
//! that together make a project's agents travel with the code:
//!
//! ```text
//! <project_root>/
//! └── .ideai/
//! ├── agents.json # AgentManifest { version, agents: [ManifestEntry, ...] }
//! └── agents/
//! ├── backend-dev.md # an agent's context (md_path = "agents/backend-dev.md")
//! └── ...
//! ```
//!
//! All I/O goes through the [`FileSystem`] port (here the `RemoteHost`'s
//! filesystem — [`crate::LocalFileSystem`] locally, an SFTP/WSL one later), so the
//! store is **location-neutral**: the very same adapter serves a local project or
//! a project hosted over SSH/WSL (Liskov, ARCHITECTURE §1.2).
//!
//! `md_path` values are interpreted **relative to `.ideai/`** (so a manifest
//! `md_path` of `agents/backend-dev.md` maps to
//! `<root>/.ideai/agents/backend-dev.md`), matching the documented schema.
use std::sync::Arc;
use async_trait::async_trait;
use domain::agent::AgentManifest;
use domain::ids::AgentId;
use domain::markdown::MarkdownDoc;
use domain::ports::{AgentContextStore, FileSystem, FsError, RemotePath, StoreError};
use domain::project::{Project, ProjectPath};
/// The `.ideai/` directory name inside a project root.
const IDEAI_DIR: &str = ".ideai";
/// The agent-manifest file name inside `.ideai/`.
const AGENTS_FILE: &str = "agents.json";
/// Current schema version written into a freshly-created manifest.
const MANIFEST_VERSION: u32 = 1;
/// File-backed [`AgentContextStore`], composing a [`FileSystem`] port.
///
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
/// one per resolved `RemoteHost` and shares it across the agent use cases.
#[derive(Clone)]
pub struct IdeaiContextStore {
fs: Arc<dyn FileSystem>,
}
impl IdeaiContextStore {
/// Builds the store from an injected [`FileSystem`] (the project's
/// `RemoteHost` filesystem).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Joins a project root with a relative segment using a POSIX separator
/// (valid on every target; `tokio::fs` accepts `/` on Windows too).
fn join(root: &ProjectPath, rel: &str) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
/// Absolute path of the manifest file for a project.
fn manifest_path(project: &Project) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}")))
}
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)
/// `md_path`.
fn context_path(project: &Project, md_path: &str) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{md_path}")))
}
/// Resolves the `md_path` of an agent from the manifest, or
/// [`StoreError::NotFound`] if the agent is unknown to this project.
async fn md_path_of(&self, project: &Project, agent: &AgentId) -> Result<String, StoreError> {
let manifest = self.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| &e.agent_id == agent)
.map(|e| e.md_path)
.ok_or(StoreError::NotFound)
}
/// Ensures the directory holding `path` exists (creates `.ideai/` and any
/// nested `agents/` parent before a write).
async fn ensure_parent(&self, path: &RemotePath) -> Result<(), StoreError> {
let raw = path.as_str();
let dir = match raw.rfind(['/', '\\']) {
Some(idx) => &raw[..idx],
None => return Ok(()),
};
self.fs
.create_dir_all(&RemotePath::new(dir.to_owned()))
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl AgentContextStore for IdeaiContextStore {
async fn read_context(
&self,
project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
match self.fs.read(&path).await {
Ok(bytes) => {
let content = String::from_utf8(bytes)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
Ok(MarkdownDoc::new(content))
}
Err(FsError::NotFound(_)) => Err(StoreError::NotFound),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn write_context(
&self,
project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
self.ensure_parent(&path).await?;
self.fs
.write(&path, md.as_str().as_bytes())
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError> {
let path = Self::manifest_path(project);
match self.fs.read(&path).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
// No manifest yet: a project simply has no agents — return the empty
// default rather than erroring (mirrors the project-store policy).
Err(FsError::NotFound(_)) => Ok(AgentManifest {
version: MANIFEST_VERSION,
entries: Vec::new(),
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn save_manifest(
&self,
project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
let path = Self::manifest_path(project);
self.ensure_parent(&path).await?;
let mut bytes = serde_json::to_vec_pretty(manifest)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
bytes.push(b'\n');
self.fs
.write(&path, &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}

View File

@ -0,0 +1,15 @@
//! Filesystem-backed persistence stores (ARCHITECTURE §5, §9.2).
//!
//! L2 ships [`FsProjectStore`], implementing the domain [`ProjectStore`] port:
//! the known-projects **registry** and the **workspace** are stored as plain
//! JSON files in the app data directory (machine-local, outside any project).
mod context;
mod profile;
mod project;
mod template;
pub use context::IdeaiContextStore;
pub use profile::FsProfileStore;
pub use project::FsProjectStore;
pub use template::FsTemplateStore;

View File

@ -0,0 +1,149 @@
//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port.
//!
//! Persists the configured [`AgentProfile`]s in the global IDE store
//! (ARCHITECTURE §9.2):
//!
//! ```text
//! <app_data_dir>/
//! └── profiles.json # { version, profiles: [AgentProfile, ...] }
//! ```
//!
//! Each profile item is exactly the declarative profile of CONTEXT §9
//! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The
//! existence of `profiles.json` is what marks the first run as *done* (see
//! [`ProfileStore::is_configured`]).
//!
//! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data
//! directory is resolved by the composition root and injected as a plain path,
//! and all I/O goes through the [`FileSystem`] port.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::AgentProfile;
/// File name of the profiles store inside the app-data dir.
const PROFILES_FILE: &str = "profiles.json";
/// Current schema version of the profiles file.
const PROFILES_VERSION: u32 = 1;
/// On-disk shape of `profiles.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProfilesDoc {
/// Schema version.
version: u32,
/// All configured profiles.
profiles: Vec<AgentProfile>,
}
impl Default for ProfilesDoc {
fn default() -> Self {
Self {
version: PROFILES_VERSION,
profiles: Vec::new(),
}
}
}
/// JSON-file implementation of the [`ProfileStore`] port.
///
/// Cheap to clone (everything is behind `Arc`); built once at the composition
/// root and shared across use cases.
#[derive(Clone)]
pub struct FsProfileStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsProfileStore {
/// Builds the store from an injected [`FileSystem`] and the app-data
/// directory path (resolved by the composition root). The directory is
/// created lazily on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// Joins the app-data dir with the profiles file name (POSIX separator, valid
/// on every target — `tokio::fs` accepts `/` on Windows too).
fn path(&self) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{PROFILES_FILE}"))
}
/// Reads and parses the doc, returning an empty default if the file is absent.
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the doc, ensuring the app-data dir exists first.
async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl ProfileStore for FsProfileStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.read_doc().await?.profiles)
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
let mut doc = self.read_doc().await?;
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
*slot = profile.clone();
} else {
doc.profiles.push(profile.clone());
}
self.write_doc(&doc).await
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
let mut doc = self.read_doc().await?;
let before = doc.profiles.len();
doc.profiles.retain(|p| p.id != id);
if doc.profiles.len() == before {
return Err(StoreError::NotFound);
}
self.write_doc(&doc).await
}
async fn is_configured(&self) -> Result<bool, StoreError> {
self.fs
.exists(&self.path())
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn mark_configured(&self) -> Result<(), StoreError> {
// Write the current doc back (an empty default when nothing exists),
// which materialises `profiles.json` and records first-run completion.
let doc = self.read_doc().await?;
self.write_doc(&doc).await
}
}

View File

@ -0,0 +1,156 @@
//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port.
//!
//! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2):
//!
//! ```text
//! <app_data_dir>/
//! ├── projects.json # the known-projects registry { version, projects: [Project, ...] }
//! └── workspace.json # the persisted Workspace (windows/tabs/layouts)
//! ```
//!
//! The store does **not** know about Tauri: the app-data directory is resolved
//! by the composition root and handed in as a plain path (Dependency Inversion).
//! All I/O goes through the [`FileSystem`] port (here [`LocalFileSystem`]) so the
//! store stays decoupled from `tokio::fs` directly and is reusable as-is.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::ProjectId;
use domain::layout::Workspace;
use domain::ports::{FileSystem, ProjectStore, RemotePath, StoreError};
use domain::project::Project;
/// File name of the known-projects registry inside the app-data dir.
const REGISTRY_FILE: &str = "projects.json";
/// File name of the persisted workspace inside the app-data dir.
const WORKSPACE_FILE: &str = "workspace.json";
/// Current schema version of the registry file.
const REGISTRY_VERSION: u32 = 1;
/// On-disk shape of the registry file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Registry {
/// Schema version.
version: u32,
/// All known projects.
projects: Vec<Project>,
}
/// JSON-file implementation of the [`ProjectStore`] port.
///
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
/// it once and shares it across use cases.
#[derive(Clone)]
pub struct FsProjectStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsProjectStore {
/// Builds the store from an injected [`FileSystem`] and the app-data
/// directory path (resolved by the composition root, e.g. via the Tauri path
/// API). The directory is created lazily on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// Joins the app-data dir with a file name using a POSIX separator (valid on
/// every target — `tokio::fs` accepts `/` on Windows too).
fn path(&self, file: &str) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{file}"))
}
/// Reads and parses the registry, returning an empty one if the file does
/// not exist yet.
async fn read_registry(&self) -> Result<Registry, StoreError> {
let path = self.path(REGISTRY_FILE);
match self.fs.read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| StoreError::Serialization(e.to_string())),
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
version: REGISTRY_VERSION,
projects: Vec::new(),
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the registry, ensuring the app-data dir exists first.
async fn write_registry(&self, registry: &Registry) -> Result<(), StoreError> {
self.ensure_dir().await?;
let bytes = serde_json::to_vec_pretty(registry)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(REGISTRY_FILE), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Creates the app-data directory and all missing parents.
async fn ensure_dir(&self) -> Result<(), StoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl ProjectStore for FsProjectStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.read_registry().await?.projects)
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
self.read_registry()
.await?
.projects
.into_iter()
.find(|p| p.id == id)
.ok_or(StoreError::NotFound)
}
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
let mut registry = self.read_registry().await?;
if let Some(slot) = registry.projects.iter_mut().find(|p| p.id == project.id) {
*slot = project.clone();
} else {
registry.projects.push(project.clone());
}
self.write_registry(&registry).await
}
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
self.ensure_dir().await?;
let bytes = serde_json::to_vec_pretty(workspace)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(WORKSPACE_FILE), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
let path = self.path(WORKSPACE_FILE);
match self.fs.read(&path).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
// No workspace persisted yet: return the empty default.
Err(domain::ports::FsError::NotFound(_)) => Ok(Workspace::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
}

View File

@ -0,0 +1,225 @@
//! [`FsTemplateStore`] — file implementation of the [`TemplateStore`] port
//! (ARCHITECTURE §5, §9.2).
//!
//! Templates live in the **global IDE store** (machine-local app-data dir, *not*
//! inside any project): the Markdown content travels as a diffable `.md`, with a
//! small JSON index carrying the metadata (version, hash) needed to list and
//! version templates without parsing every `.md`:
//!
//! ```text
//! <app_data_dir>/templates/
//! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] }
//! └── md/
//! └── <id>.md # a template's Markdown content
//! ```
//!
//! `contentHash` is a stable digest of the `.md` content, recorded so a future
//! spike can detect **out-of-app edits** (ARCHITECTURE §13.9); it is not part of
//! the [`AgentTemplate`] domain entity. Like the other stores, all I/O goes
//! through the [`FileSystem`] port, so the adapter is Tauri-agnostic.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::{AgentTemplate, TemplateVersion};
/// Directory (under app-data) holding the templates store.
const TEMPLATES_DIR: &str = "templates";
/// Index file name inside the templates dir.
const INDEX_FILE: &str = "index.json";
/// Current schema version of the index file.
const INDEX_VERSION: u32 = 1;
/// One metadata row in `index.json` (the `.md` content lives separately).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexEntry {
id: TemplateId,
name: String,
version: TemplateVersion,
content_hash: String,
default_profile_id: ProfileId,
}
/// On-disk shape of `templates/index.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
templates: Vec<IndexEntry>,
}
impl Default for IndexDoc {
fn default() -> Self {
Self {
version: INDEX_VERSION,
templates: Vec::new(),
}
}
}
/// A stable, dependency-free digest of Markdown content for out-of-app edit
/// detection. `DefaultHasher::new()` uses fixed keys, so this is deterministic
/// across runs and platforms (unlike a `RandomState`-seeded hasher).
fn content_hash(md: &MarkdownDoc) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
md.as_str().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// File-backed [`TemplateStore`], composing a [`FileSystem`] port.
#[derive(Clone)]
pub struct FsTemplateStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsTemplateStore {
/// Builds the store from an injected [`FileSystem`] and the app-data dir
/// (resolved by the composition root). Directories are created on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// `<app>/templates`.
fn dir(&self) -> String {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{TEMPLATES_DIR}")
}
/// `<app>/templates/index.json`.
fn index_path(&self) -> RemotePath {
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir()))
}
/// `<app>/templates/md/<id>.md`.
fn md_path(&self, id: TemplateId) -> RemotePath {
RemotePath::new(format!("{}/md/{id}.md", self.dir()))
}
/// Reads the index, returning an empty default if absent.
async fn read_index(&self) -> Result<IndexDoc, StoreError> {
match self.fs.read(&self.index_path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the index, ensuring `templates/` exists.
async fn write_index(&self, doc: &IndexDoc) -> Result<(), StoreError> {
self.fs
.create_dir_all(&RemotePath::new(self.dir()))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.index_path(), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Reconstructs the [`AgentTemplate`] for an index entry by reading its `.md`.
async fn load(&self, entry: &IndexEntry) -> Result<AgentTemplate, StoreError> {
let bytes = self
.fs
.read(&self.md_path(entry.id))
.await
.map_err(|e| match e {
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
other => StoreError::Io(other.to_string()),
})?;
let content =
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
// The domain entity carries the authoritative version/name from the index;
// we reconstruct it directly (no public mutator needed) since every field
// is known and already validated when it was first saved.
Ok(AgentTemplate {
id: entry.id,
name: entry.name.clone(),
content_md: MarkdownDoc::new(content),
version: entry.version,
default_profile_id: entry.default_profile_id,
})
}
}
#[async_trait]
impl TemplateStore for FsTemplateStore {
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
let index = self.read_index().await?;
let mut out = Vec::with_capacity(index.templates.len());
for entry in &index.templates {
out.push(self.load(entry).await?);
}
Ok(out)
}
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
let index = self.read_index().await?;
let entry = index
.templates
.iter()
.find(|e| e.id == id)
.ok_or(StoreError::NotFound)?;
self.load(entry).await
}
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
// (1) Write the Markdown content.
self.fs
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir())))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
self.fs
.write(
&self.md_path(template.id),
template.content_md.as_str().as_bytes(),
)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// (2) Upsert the index metadata.
let mut index = self.read_index().await?;
let row = IndexEntry {
id: template.id,
name: template.name.clone(),
version: template.version,
content_hash: content_hash(&template.content_md),
default_profile_id: template.default_profile_id,
};
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
*slot = row;
} else {
index.templates.push(row);
}
self.write_index(&index).await
}
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
let mut index = self.read_index().await?;
let before = index.templates.len();
index.templates.retain(|e| e.id != id);
if index.templates.len() == before {
return Err(StoreError::NotFound);
}
// The orphaned `md/<id>.md` is left on disk (the FileSystem port exposes no
// delete); the index no longer references it, so it is effectively gone.
self.write_index(&index).await
}
}

View File

@ -0,0 +1,317 @@
//! L5 tests for [`CliAgentRuntime`].
//!
//! Covers:
//! - `prepare_invocation` (the **pure** core): for every [`ContextInjection`]
//! strategy, the produced [`SpawnSpec`] (command, args order, resolved cwd,
//! `context_plan`) is asserted.
//! - `detection_spec` (pure): custom `detect` tokenisation vs `--version`
//! fallback.
//! - `detect` driven by a **mocked** [`ProcessSpawner`]: exit 0 ⇒ `true`,
//! non-zero ⇒ `false`, spawner error ⇒ propagated as `RuntimeError`.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
use domain::ids::ProfileId;
use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(1)),
"Test",
"mycli",
vec!["--static".to_owned(), "arg".to_owned()],
injection,
Some("mycli probe --json".to_owned()),
cwd_template,
)
.unwrap()
}
fn ctx() -> PreparedContext {
PreparedContext {
content: MarkdownDoc::new("# hi"),
relative_path: ".ideai/agent.md".to_owned(),
}
}
/// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec.
struct FixedSpawner(Result<Output, ProcessError>);
#[async_trait]
impl ProcessSpawner for FixedSpawner {
async fn run(&self, _spec: SpawnSpec) -> Result<Output, ProcessError> {
self.0.clone()
}
}
fn runtime_with(outcome: Result<Output, ProcessError>) -> CliAgentRuntime {
CliAgentRuntime::new(Arc::new(FixedSpawner(outcome)))
}
/// A spawner that just records the spec it was handed (for detect-spec assertions).
struct RecordingSpawner(std::sync::Mutex<Option<SpawnSpec>>);
#[async_trait]
impl ProcessSpawner for RecordingSpawner {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
*self.0.lock().unwrap() = Some(spec);
Ok(Output {
status: ExitStatus { code: Some(0) },
stdout: Vec::new(),
stderr: Vec::new(),
})
}
}
fn pure_runtime() -> CliAgentRuntime {
runtime_with(Ok(Output {
status: ExitStatus { code: Some(0) },
stdout: Vec::new(),
stderr: Vec::new(),
}))
}
// ---------------------------------------------------------------------------
// prepare_invocation — ConventionFile
// ---------------------------------------------------------------------------
#[test]
fn prepare_convention_file_keeps_args_and_plans_file() {
let rt = pure_runtime();
let p = profile(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
"{projectRoot}",
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
assert_eq!(
spec.context_plan,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned()
})
);
}
// ---------------------------------------------------------------------------
// prepare_invocation — Flag with {path}
// ---------------------------------------------------------------------------
#[test]
fn prepare_flag_with_path_substitutes_and_splits() {
let rt = pure_runtime();
let p = profile(
ContextInjection::flag("--context-file {path}").unwrap(),
"{projectRoot}",
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
// static args first, then the substituted+split flag args.
assert_eq!(
spec.args,
vec!["--static", "arg", "--context-file", ".ideai/agent.md"]
);
assert_eq!(
spec.context_plan,
Some(ContextInjectionPlan::Args {
args: vec!["--context-file".to_owned(), ".ideai/agent.md".to_owned()]
})
);
}
// ---------------------------------------------------------------------------
// prepare_invocation — Flag without {path} (switch + path)
// ---------------------------------------------------------------------------
#[test]
fn prepare_flag_without_path_is_switch_then_path() {
let rt = pure_runtime();
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
assert_eq!(
spec.context_plan,
Some(ContextInjectionPlan::Args {
args: vec!["-f".to_owned(), ".ideai/agent.md".to_owned()]
})
);
}
// ---------------------------------------------------------------------------
// prepare_invocation — Stdin
// ---------------------------------------------------------------------------
#[test]
fn prepare_stdin_keeps_args_and_plans_stdin() {
let rt = pure_runtime();
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
}
// ---------------------------------------------------------------------------
// prepare_invocation — Env
// ---------------------------------------------------------------------------
#[test]
fn prepare_env_keeps_args_and_plans_env() {
let rt = pure_runtime();
let p = profile(
ContextInjection::env("AGENT_CONTEXT").unwrap(),
"{projectRoot}",
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
assert_eq!(
spec.context_plan,
Some(ContextInjectionPlan::Env {
var: "AGENT_CONTEXT".to_owned()
})
);
}
// ---------------------------------------------------------------------------
// prepare_invocation — cwd template substitution
// ---------------------------------------------------------------------------
#[test]
fn prepare_substitutes_project_root_in_cwd_template() {
let rt = pure_runtime();
let p = profile(
ContextInjection::stdin(),
"{projectRoot}/subdir",
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
}
#[test]
fn prepare_empty_cwd_template_defaults_to_root() {
let rt = pure_runtime();
let p = profile(ContextInjection::stdin(), "");
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
}
// ---------------------------------------------------------------------------
// detection_spec (pure)
// ---------------------------------------------------------------------------
#[test]
fn detection_spec_uses_custom_detect_tokenised() {
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let spec = CliAgentRuntime::detection_spec(&p).unwrap();
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["probe", "--json"]);
assert!(spec.context_plan.is_none());
assert!(spec.env.is_empty());
}
#[test]
fn detection_spec_falls_back_to_command_version() {
let p = AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
"NoDetect",
"somecli",
Vec::new(),
ContextInjection::stdin(),
None,
"{projectRoot}",
)
.unwrap();
let spec = CliAgentRuntime::detection_spec(&p).unwrap();
assert_eq!(spec.command, "somecli");
assert_eq!(spec.args, vec!["--version"]);
}
// ---------------------------------------------------------------------------
// detect (mocked spawner)
// ---------------------------------------------------------------------------
#[test]
fn detect_true_on_exit_zero() {
let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(0) },
stdout: Vec::new(),
stderr: Vec::new(),
}));
let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(rt.detect(&p).unwrap());
}
#[test]
fn detect_false_on_nonzero_exit() {
let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(127) },
stdout: Vec::new(),
stderr: Vec::new(),
}));
let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap());
}
#[test]
fn detect_false_on_signal_terminated() {
let rt = runtime_with(Ok(Output {
status: ExitStatus { code: None },
stdout: Vec::new(),
stderr: Vec::new(),
}));
let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap());
}
#[test]
fn detect_propagates_spawner_error() {
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let err = rt.detect(&p).expect_err("spawner error surfaces");
assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
}
#[test]
fn detect_runs_the_detection_spec_command() {
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
let rt = CliAgentRuntime::new(recorder.clone());
let p = profile(ContextInjection::stdin(), "{projectRoot}");
rt.detect(&p).unwrap();
let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["probe", "--json"]);
}

View File

@ -0,0 +1,147 @@
//! L6 integration tests for [`IdeaiContextStore`] against a real temp directory
//! and a real [`LocalFileSystem`], exercising the full `.ideai/` persistence path
//! (manifest JSON, context `.md` round-trip, tolerant reads, NotFound).
use std::path::PathBuf;
use std::sync::Arc;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::ids::{AgentId, ProfileId};
use domain::markdown::MarkdownDoc;
use domain::ports::{AgentContextStore, FileSystem, RemotePath, StoreError};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use infrastructure::{IdeaiContextStore, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l6-ctx-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn root(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn child(&self, rel: &str) -> RemotePath {
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store() -> IdeaiContextStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
IdeaiContextStore::new(fs)
}
fn project(root: &str) -> Project {
Project::new(
domain::ids::ProjectId::new_random(),
"demo",
ProjectPath::new(root).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn agent(id: AgentId, name: &str, md: &str, profile: ProfileId) -> Agent {
Agent::new(id, name, md, profile, AgentOrigin::Scratch, false).unwrap()
}
#[tokio::test]
async fn missing_manifest_loads_empty() {
let tmp = TempDir::new();
let store = store();
let manifest = store.load_manifest(&project(&tmp.root())).await.unwrap();
assert!(manifest.entries.is_empty());
assert_eq!(manifest.version, 1);
}
#[tokio::test]
async fn manifest_save_then_load_roundtrips() {
let tmp = TempDir::new();
let store = store();
let p = project(&tmp.root());
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
store.save_manifest(&p, &manifest).await.unwrap();
let back = store.load_manifest(&p).await.unwrap();
assert_eq!(back, manifest);
}
#[tokio::test]
async fn context_write_then_read_roundtrips() {
let tmp = TempDir::new();
let store = store();
let p = project(&tmp.root());
// The manifest must know the agent before its context can be addressed.
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
store.save_manifest(&p, &manifest).await.unwrap();
let md = MarkdownDoc::new("# Backend\nYou are the backend agent.");
store.write_context(&p, &a.id, &md).await.unwrap();
let back = store.read_context(&p, &a.id).await.unwrap();
assert_eq!(back, md);
// The `.md` actually landed at `.ideai/agents/backend.md`.
let fs = LocalFileSystem::new();
let bytes = fs
.read(&tmp.child(".ideai/agents/backend.md"))
.await
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), md.as_str());
}
#[tokio::test]
async fn read_context_for_unknown_agent_is_not_found() {
let tmp = TempDir::new();
let store = store();
let p = project(&tmp.root());
let err = store.read_context(&p, &aid(404)).await.unwrap_err();
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn manifest_file_is_camelcase_json_under_ideai() {
let tmp = TempDir::new();
let store = store();
let p = project(&tmp.root());
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
store.save_manifest(&p, &manifest).await.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let agents = json
.get("agents")
.and_then(|v| v.as_array())
.expect("top-level `agents` array");
assert_eq!(agents.len(), 1);
let entry = &agents[0];
assert_eq!(entry.get("mdPath").and_then(|v| v.as_str()), Some("agents/backend.md"));
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
assert!(entry.get("profileId").is_some(), "camelCase profileId present");
assert!(entry.get("md_path").is_none(), "no snake_case leak");
}

View File

@ -0,0 +1,53 @@
//! L1 tests for [`TokioBroadcastEventBus`]: a published [`DomainEvent`] is
//! received both through the blocking `subscribe()` [`EventStream`] and through
//! the async `raw_receiver()` used by the Tauri event relay.
use domain::events::DomainEvent;
use domain::ports::EventBus;
use domain::ProjectId;
use infrastructure::TokioBroadcastEventBus;
use uuid::Uuid;
fn sample_event() -> DomainEvent {
DomainEvent::ProjectCreated {
project_id: ProjectId::from_uuid(Uuid::nil()),
}
}
#[tokio::test]
async fn raw_receiver_gets_published_event() {
let bus = TokioBroadcastEventBus::new();
let mut rx = bus.raw_receiver();
bus.publish(sample_event());
let got = rx.recv().await.expect("event received");
assert_eq!(got, sample_event());
}
#[tokio::test]
async fn raw_receiver_fans_out_to_multiple_subscribers() {
let bus = TokioBroadcastEventBus::new();
let mut rx1 = bus.raw_receiver();
let mut rx2 = bus.raw_receiver();
bus.publish(sample_event());
assert_eq!(rx1.recv().await.unwrap(), sample_event());
assert_eq!(rx2.recv().await.unwrap(), sample_event());
}
#[test]
fn subscribe_blocking_stream_yields_published_event() {
let bus = TokioBroadcastEventBus::new();
let mut stream = bus.subscribe();
bus.publish(sample_event());
assert_eq!(stream.next(), Some(sample_event()));
}
#[tokio::test]
async fn publish_without_subscribers_is_noop() {
let bus = TokioBroadcastEventBus::new();
// No receiver registered: publish must not panic.
bus.publish(sample_event());
}

View File

@ -0,0 +1,108 @@
//! L8 integration tests for [`Git2Repository`] against a real temporary repo,
//! exercising the local flow end to end: init → status → stage → commit →
//! branch/current_branch → log, plus the not-a-repo error path.
use std::path::PathBuf;
use domain::ports::GitPort;
use domain::ports::GitError;
use domain::project::ProjectPath;
use infrastructure::Git2Repository;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l8-git-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn root(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
}
fn write(&self, name: &str, content: &str) {
std::fs::write(self.0.join(name), content).unwrap();
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[tokio::test]
async fn init_status_stage_commit_branch_log_flow() {
let tmp = TempDir::new();
let root = tmp.root();
let git = Git2Repository::new();
git.init(&root).await.expect("init");
// A new untracked file shows up as not-staged.
tmp.write("a.txt", "hello");
let status = git.status(&root).await.unwrap();
let a = status
.iter()
.find(|s| s.path == "a.txt")
.expect("a.txt appears in status");
assert!(!a.staged, "untracked file is not staged");
// Staging flips the staged flag.
git.stage(&root, "a.txt").await.unwrap();
let staged = git.status(&root).await.unwrap();
assert!(
staged.iter().find(|s| s.path == "a.txt").unwrap().staged,
"file is staged after stage()"
);
// Commit the index.
let commit = git.commit(&root, "first commit").await.unwrap();
assert!(!commit.hash.is_empty());
assert_eq!(commit.summary, "first commit");
// After committing, the tree is clean.
assert!(
git.status(&root).await.unwrap().is_empty(),
"no changes after commit"
);
// A current branch exists and is listed among local branches.
let current = git.current_branch(&root).await.unwrap();
let current = current.expect("a current branch after the first commit");
let branches = git.branches(&root).await.unwrap();
assert!(branches.contains(&current), "current branch is listed");
// The log has exactly our commit.
let log = git.log(&root, 10).await.unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].summary, "first commit");
assert_eq!(log[0].hash, commit.hash);
}
#[tokio::test]
async fn status_on_non_repo_is_not_found() {
let tmp = TempDir::new();
let err = Git2Repository::new().status(&tmp.root()).await.unwrap_err();
assert!(matches!(err, GitError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn unstage_after_first_commit_resets_index() {
let tmp = TempDir::new();
let root = tmp.root();
let git = Git2Repository::new();
git.init(&root).await.unwrap();
tmp.write("a.txt", "v1");
git.stage(&root, "a.txt").await.unwrap();
git.commit(&root, "c1").await.unwrap();
// Modify + stage, then unstage → the change is no longer staged.
tmp.write("a.txt", "v2");
git.stage(&root, "a.txt").await.unwrap();
assert!(git.status(&root).await.unwrap().iter().any(|s| s.path == "a.txt" && s.staged));
git.unstage(&root, "a.txt").await.unwrap();
let st = git.status(&root).await.unwrap();
let a = st.iter().find(|s| s.path == "a.txt").unwrap();
assert!(!a.staged, "unstaged change is no longer in the index");
}

View File

@ -0,0 +1,81 @@
//! L1 integration tests for [`LocalFileSystem`] against a real temp directory.
use std::path::PathBuf;
use domain::ports::{FileSystem, FsError, RemotePath};
use infrastructure::LocalFileSystem;
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l1-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
fn path(&self) -> RemotePath {
RemotePath::new(self.0.to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[tokio::test]
async fn write_then_read_roundtrips() {
let tmp = TempDir::new();
let fs = LocalFileSystem::new();
let file = tmp.child("hello.txt");
fs.write(&file, b"bonjour").await.unwrap();
let back = fs.read(&file).await.unwrap();
assert_eq!(back, b"bonjour");
}
#[tokio::test]
async fn exists_reflects_presence() {
let tmp = TempDir::new();
let fs = LocalFileSystem::new();
let file = tmp.child("maybe.txt");
assert!(!fs.exists(&file).await.unwrap());
fs.write(&file, b"x").await.unwrap();
assert!(fs.exists(&file).await.unwrap());
}
#[tokio::test]
async fn create_dir_all_and_list() {
let tmp = TempDir::new();
let fs = LocalFileSystem::new();
let nested = tmp.child("a/b/c");
fs.create_dir_all(&nested).await.unwrap();
assert!(fs.exists(&nested).await.unwrap());
// Put a file and a dir at the top level, then list them.
fs.write(&tmp.child("file.txt"), b"y").await.unwrap();
let entries = fs.list(&tmp.path()).await.unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"file.txt"));
assert!(names.contains(&"a"));
let dir_entry = entries.iter().find(|e| e.name == "a").unwrap();
assert!(dir_entry.is_dir);
let file_entry = entries.iter().find(|e| e.name == "file.txt").unwrap();
assert!(!file_entry.is_dir);
}
#[tokio::test]
async fn read_missing_maps_to_not_found() {
let tmp = TempDir::new();
let fs = LocalFileSystem::new();
let err = fs.read(&tmp.child("nope.txt")).await.unwrap_err();
assert!(matches!(err, FsError::NotFound(_)), "got {err:?}");
}

View File

@ -0,0 +1,169 @@
//! L5 integration tests for [`FsProfileStore`] against a real temp directory,
//! using a real [`LocalFileSystem`] so the full persistence path (camelCase
//! `profiles.json`, upsert, delete, first-run marker) is exercised end-to-end.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::{AgentProfile, ContextInjection};
use infrastructure::{FsProfileStore, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l5-profile-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(tmp: &TempDir) -> FsProfileStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsProfileStore::new(fs, tmp.app_data_dir())
}
fn sample(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(id)),
name,
command,
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(format!("{command} --version")),
"{projectRoot}",
)
.unwrap()
}
#[tokio::test]
async fn save_then_list_roundtrips() {
let tmp = TempDir::new();
let store = store(&tmp);
let p = sample(1, "Claude", "claude");
store.save(&p).await.unwrap();
let listed = store.list().await.unwrap();
assert_eq!(listed, vec![p]);
}
#[tokio::test]
async fn save_upserts_by_id_without_duplicating() {
let tmp = TempDir::new();
let store = store(&tmp);
let first = sample(1, "before", "claude");
store.save(&first).await.unwrap();
let updated = sample(1, "after", "claude-renamed");
store.save(&updated).await.unwrap();
let listed = store.list().await.unwrap();
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
assert_eq!(listed[0], updated);
assert_eq!(listed[0].name, "after");
}
#[tokio::test]
async fn delete_removes_profile() {
let tmp = TempDir::new();
let store = store(&tmp);
let a = sample(1, "A", "a");
let b = sample(2, "B", "b");
store.save(&a).await.unwrap();
store.save(&b).await.unwrap();
store.delete(a.id).await.unwrap();
let listed = store.list().await.unwrap();
assert_eq!(listed, vec![b]);
}
#[tokio::test]
async fn delete_unknown_is_not_found() {
let tmp = TempDir::new();
let store = store(&tmp);
store.save(&sample(1, "A", "a")).await.unwrap();
let err = store
.delete(ProfileId::from_uuid(Uuid::from_u128(999)))
.await
.expect_err("deleting unknown id fails");
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn is_configured_false_before_any_write() {
let tmp = TempDir::new();
let store = store(&tmp);
// First run: no profiles.json yet.
assert!(!store.is_configured().await.unwrap());
assert!(store.list().await.unwrap().is_empty());
}
#[tokio::test]
async fn is_configured_true_after_save() {
let tmp = TempDir::new();
let store = store(&tmp);
store.save(&sample(1, "A", "a")).await.unwrap();
assert!(store.is_configured().await.unwrap());
}
#[tokio::test]
async fn mark_configured_creates_file_with_empty_profiles() {
let tmp = TempDir::new();
let store = store(&tmp);
assert!(!store.is_configured().await.unwrap());
store.mark_configured().await.unwrap();
assert!(store.is_configured().await.unwrap(), "marker materialised");
assert!(
store.list().await.unwrap().is_empty(),
"empty profile list recorded"
);
}
#[tokio::test]
async fn profiles_file_is_camelcase_versioned() {
let tmp = TempDir::new();
let store = store(&tmp);
let p = sample(1, "Claude", "claude");
store.save(&p).await.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("profiles.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["version"], 1);
let profiles = json
.get("profiles")
.and_then(|v| v.as_array())
.expect("top-level `profiles` array");
assert_eq!(profiles.len(), 1);
let entry = &profiles[0];
assert_eq!(entry["name"], "Claude");
assert_eq!(entry["command"], "claude");
// camelCase fields, tagged contextInjection.
assert!(entry.get("cwdTemplate").is_some(), "camelCase cwdTemplate");
assert!(entry.get("cwd_template").is_none(), "no snake_case leak");
assert_eq!(entry["contextInjection"]["strategy"], "conventionFile");
assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md");
}

View File

@ -0,0 +1,139 @@
//! L2 integration tests for [`FsProjectStore`] against a real temp directory,
//! using a real [`LocalFileSystem`] so the full persistence path (JSON layout,
//! tolerant reads, upsert) is exercised end-to-end.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::ProjectId;
use domain::layout::Workspace;
use domain::ports::{FileSystem, ProjectStore, RemotePath};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use infrastructure::{FsProjectStore, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l2-store-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
/// The app-data dir as a plain string, as the composition root would pass it.
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(tmp: &TempDir) -> FsProjectStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsProjectStore::new(fs, tmp.app_data_dir())
}
fn sample_project(id: ProjectId, name: &str, root: &str) -> Project {
Project::new(
id,
name,
ProjectPath::new(root).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
#[tokio::test]
async fn save_then_list_roundtrips() {
let tmp = TempDir::new();
let store = store(&tmp);
let p = sample_project(ProjectId::new_random(), "alpha", "/home/me/alpha");
store.save_project(&p).await.unwrap();
let listed = store.list_projects().await.unwrap();
assert_eq!(listed, vec![p]);
}
#[tokio::test]
async fn save_upserts_by_id_without_duplicating() {
let tmp = TempDir::new();
let store = store(&tmp);
let id = ProjectId::new_random();
let first = sample_project(id, "before", "/home/me/proj");
store.save_project(&first).await.unwrap();
// Same id, changed fields: must update in place, not append.
let updated = sample_project(id, "after", "/home/me/proj-renamed");
store.save_project(&updated).await.unwrap();
let listed = store.list_projects().await.unwrap();
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
assert_eq!(listed[0], updated);
assert_eq!(listed[0].name, "after");
}
#[tokio::test]
async fn missing_registry_lists_empty() {
let tmp = TempDir::new();
let store = store(&tmp);
// No projects.json written yet: tolerant read returns an empty list.
let listed = store.list_projects().await.unwrap();
assert!(listed.is_empty());
}
#[tokio::test]
async fn workspace_save_then_load_roundtrips() {
let tmp = TempDir::new();
let store = store(&tmp);
// Missing workspace returns the default.
let loaded = store.load_workspace().await.unwrap();
assert_eq!(loaded, Workspace::default());
let ws = Workspace::default();
store.save_workspace(&ws).await.unwrap();
let back = store.load_workspace().await.unwrap();
assert_eq!(back, ws);
}
#[tokio::test]
async fn registry_file_is_camelcase_json() {
let tmp = TempDir::new();
let store = store(&tmp);
let p = sample_project(ProjectId::new_random(), "jsoncheck", "/srv/app");
store.save_project(&p).await.unwrap();
// Read the raw bytes the store wrote and assert the camelCase shape.
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("projects.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(json.get("version").is_some(), "top-level `version` present");
let projects = json
.get("projects")
.and_then(|v| v.as_array())
.expect("top-level `projects` array");
assert_eq!(projects.len(), 1);
let entry = &projects[0];
// camelCase serialization of `created_at`.
assert!(
entry.get("createdAt").is_some(),
"project uses camelCase `createdAt`, got {entry}"
);
assert!(entry.get("created_at").is_none(), "no snake_case leak");
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck"));
assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app"));
}

View File

@ -0,0 +1,169 @@
//! L3 integration tests for [`PortablePtyAdapter`] — exercising a **real** OS
//! PTY on Linux. We spawn tiny `/bin/sh` programs whose output is deterministic,
//! drain the blocking output stream on a dedicated thread, and assert on the
//! bytes / exit code.
//!
//! Robustness: every blocking drain runs on its own thread joined with a bounded
//! timeout so a misbehaving PTY can never hang the test suite/CI.
#![cfg(unix)]
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use domain::ports::{PtyPort, SpawnSpec};
use domain::{ProjectPath, PtySize};
use infrastructure::PortablePtyAdapter;
/// Hard ceiling for any single PTY interaction in these tests.
const TIMEOUT: Duration = Duration::from_secs(10);
fn sh_spec(script: &str) -> SpawnSpec {
SpawnSpec {
command: "/bin/sh".to_owned(),
args: vec!["-c".to_owned(), script.to_owned()],
cwd: ProjectPath::new("/").unwrap(),
env: Vec::new(),
context_plan: None,
}
}
fn size() -> PtySize {
PtySize::new(24, 80).unwrap()
}
/// Drains an output stream to a single `Vec<u8>` on a worker thread, returning
/// the collected bytes or panicking if it does not finish within `TIMEOUT`.
fn drain_with_timeout(
stream: domain::ports::OutputStream,
timeout: Duration,
) -> Vec<u8> {
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in stream {
all.extend_from_slice(&chunk);
}
let _ = tx.send(all);
});
let bytes = rx
.recv_timeout(timeout)
.expect("output stream drained within timeout");
worker.join().expect("drain thread joined");
bytes
}
#[tokio::test]
async fn spawn_printf_streams_expected_bytes_and_exits_zero() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("printf hello-pty"), size())
.await
.expect("spawn succeeds");
let stream = pty.subscribe_output(&handle).expect("subscribe once");
let bytes = drain_with_timeout(stream, TIMEOUT);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("hello-pty"),
"expected output to contain 'hello-pty', got {text:?}"
);
// Process already exited; kill collects the status. `sh` exiting cleanly → 0.
let status = pty.kill(&handle).await.expect("kill succeeds");
assert_eq!(status.code, Some(0), "clean exit reports code 0");
}
#[tokio::test]
async fn write_is_echoed_back_through_output_stream() {
// `cat` echoes its stdin back to stdout; we feed it a line then close stdin
// by killing it, and assert we saw the echoed bytes.
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
let stream = pty.subscribe_output(&handle).expect("subscribe once");
// Look for the marker on a worker thread, with a timeout, so we don't block
// forever if `cat` never echoes.
let (found_tx, found_rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
for chunk in stream {
all.extend_from_slice(&chunk);
if String::from_utf8_lossy(&all).contains("marker-123") {
let _ = found_tx.send(true);
// Keep draining until EOF so the thread can exit on kill.
}
}
});
pty.write(&handle, b"marker-123\n").expect("write to cat");
let found = found_rx
.recv_timeout(TIMEOUT)
.expect("echoed marker observed within timeout");
assert!(found, "cat echoed the written bytes back");
pty.kill(&handle).await.expect("kill cat");
worker.join().expect("drain thread joined after kill");
}
#[tokio::test]
async fn subscribe_output_twice_is_an_error() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("sleep 0.2"), size())
.await
.expect("spawn");
let first = pty.subscribe_output(&handle);
assert!(first.is_ok(), "first subscribe succeeds");
let second = pty.subscribe_output(&handle);
assert!(
second.is_err(),
"second subscribe on the same session must error"
);
// Drain the first stream so the reader thread can finish, then tidy up.
let stream = first.unwrap();
drain_with_timeout(stream, TIMEOUT);
let _ = pty.kill(&handle).await;
}
#[tokio::test]
async fn write_resize_kill_on_unknown_handle_are_not_found() {
use domain::ports::{PtyError, PtyHandle};
use domain::SessionId;
let pty = PortablePtyAdapter::new();
let ghost = PtyHandle {
session_id: SessionId::new_random(),
};
assert_eq!(pty.write(&ghost, b"x"), Err(PtyError::NotFound));
assert_eq!(pty.resize(&ghost, size()), Err(PtyError::NotFound));
assert!(pty.subscribe_output(&ghost).is_err());
assert_eq!(pty.kill(&ghost).await, Err(PtyError::NotFound));
}
#[tokio::test]
async fn resize_on_live_pty_succeeds() {
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("sleep 0.2"), size())
.await
.expect("spawn");
pty.resize(&handle, PtySize::new(40, 120).unwrap())
.expect("resize a live pty succeeds");
// Drain + reap so the test leaves no live process/thread behind.
let stream = pty.subscribe_output(&handle).expect("subscribe");
let _ = thread::spawn(move || stream.count());
let _ = pty.kill(&handle).await;
}

View File

@ -0,0 +1,70 @@
//! L9 tests for the local remote-host strategy and the host selector.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ports::{RemoteError, RemoteHost, RemotePath};
use domain::remote::{RemoteKind, RemoteRef, SshAuth};
use infrastructure::{remote_host, LocalHost};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l9-host-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> String {
self.0.to_string_lossy().into_owned()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[tokio::test]
async fn local_host_connects_and_exposes_local_fs() {
let tmp = TempDir::new();
let host = LocalHost::new();
assert_eq!(host.kind(), RemoteKind::Local);
host.connect().await.expect("local connect is a no-op");
let fs = host.file_system();
assert!(fs.exists(&RemotePath::new(tmp.path())).await.unwrap());
assert!(!fs
.exists(&RemotePath::new(format!("{}/nope", tmp.path())))
.await
.unwrap());
}
#[tokio::test]
async fn selector_builds_local_host() {
let host = remote_host(&RemoteRef::local()).expect("local host builds");
assert_eq!(host.kind(), RemoteKind::Local);
}
#[test]
fn selector_rejects_ssh_and_wsl_for_now() {
let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap();
assert!(matches!(
remote_host(&ssh),
Err(RemoteError::Connection(_))
));
let wsl = RemoteRef::wsl("Ubuntu").unwrap();
assert!(matches!(
remote_host(&wsl),
Err(RemoteError::Connection(_))
));
}
/// Local host PTY/spawner handles are cloneable port objects (Arc-backed).
#[test]
fn local_host_hands_out_ports() {
let host: Arc<dyn RemoteHost> = Arc::new(LocalHost::new());
let _fs = host.file_system();
let _sp = host.process_spawner();
let _pty = host.pty();
}

View File

@ -0,0 +1,139 @@
//! L7 integration tests for [`FsTemplateStore`] against a real temp directory and
//! a real [`LocalFileSystem`]: md + `index.json` round-trip, version persistence,
//! upsert, delete, tolerant reads, and the on-disk layout (`templates/md/<id>.md`).
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::AgentTemplate;
use infrastructure::{FsTemplateStore, LocalFileSystem};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l7-tpl-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn child(&self, rel: &str) -> RemotePath {
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(tmp: &TempDir) -> FsTemplateStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsTemplateStore::new(fs, tmp.app_data_dir())
}
fn tid(n: u128) -> TemplateId {
TemplateId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn template(id: TemplateId, name: &str, content: &str) -> AgentTemplate {
AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap()
}
#[tokio::test]
async fn missing_index_lists_empty() {
let tmp = TempDir::new();
assert!(store(&tmp).list().await.unwrap().is_empty());
}
#[tokio::test]
async fn save_then_get_and_list_roundtrip() {
let tmp = TempDir::new();
let store = store(&tmp);
let t = template(tid(1), "Backend", "# Backend template");
store.save(&t).await.unwrap();
assert_eq!(store.get(tid(1)).await.unwrap(), t);
assert_eq!(store.list().await.unwrap(), vec![t.clone()]);
// The Markdown actually landed at templates/md/<id>.md.
let fs = LocalFileSystem::new();
let bytes = fs
.child_read(&tmp, &format!("templates/md/{}.md", tid(1)))
.await;
assert_eq!(String::from_utf8(bytes).unwrap(), t.content_md.as_str());
}
#[tokio::test]
async fn save_upserts_and_persists_bumped_version() {
let tmp = TempDir::new();
let store = store(&tmp);
let t0 = template(tid(1), "Backend", "v1");
store.save(&t0).await.unwrap();
let t1 = t0.with_updated_content(MarkdownDoc::new("v2"));
store.save(&t1).await.unwrap();
let back = store.get(tid(1)).await.unwrap();
assert_eq!(back.version.get(), 2, "bumped version persisted");
assert_eq!(back.content_md.as_str(), "v2");
assert_eq!(store.list().await.unwrap().len(), 1, "upsert, not append");
}
#[tokio::test]
async fn get_unknown_is_not_found() {
let tmp = TempDir::new();
assert!(matches!(
store(&tmp).get(tid(404)).await.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn delete_removes_from_index() {
let tmp = TempDir::new();
let store = store(&tmp);
store.save(&template(tid(1), "T", "x")).await.unwrap();
store.delete(tid(1)).await.unwrap();
assert!(store.list().await.unwrap().is_empty());
assert!(matches!(
store.delete(tid(1)).await.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new();
let store = store(&tmp);
store.save(&template(tid(1), "Backend", "hello")).await.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let entry = &json.get("templates").unwrap().as_array().unwrap()[0];
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
assert!(entry.get("contentHash").is_some(), "camelCase contentHash present");
assert!(entry.get("defaultProfileId").is_some());
assert!(entry.get("content_hash").is_none(), "no snake_case leak");
}
/// Tiny read helper so the md-path assertion stays readable.
trait ChildRead {
async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec<u8>;
}
impl ChildRead for LocalFileSystem {
async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec<u8> {
self.read(&tmp.child(rel)).await.unwrap()
}
}