feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
19
crates/infrastructure/Cargo.toml
Normal file
19
crates/infrastructure/Cargo.toml
Normal 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 }
|
||||
27
crates/infrastructure/src/clock/mod.rs
Normal file
27
crates/infrastructure/src/clock/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
86
crates/infrastructure/src/eventbus/mod.rs
Normal file
86
crates/infrastructure/src/eventbus/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
crates/infrastructure/src/fs/mod.rs
Normal file
102
crates/infrastructure/src/fs/mod.rs
Normal 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
|
||||
}
|
||||
275
crates/infrastructure/src/git/mod.rs
Normal file
275
crates/infrastructure/src/git/mod.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
22
crates/infrastructure/src/id/mod.rs
Normal file
22
crates/infrastructure/src/id/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
35
crates/infrastructure/src/lib.rs
Normal file
35
crates/infrastructure/src/lib.rs
Normal 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};
|
||||
53
crates/infrastructure/src/process/mod.rs
Normal file
53
crates/infrastructure/src/process/mod.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
238
crates/infrastructure/src/pty/mod.rs
Normal file
238
crates/infrastructure/src/pty/mod.rs
Normal 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)
|
||||
}
|
||||
95
crates/infrastructure/src/remote/mod.rs
Normal file
95
crates/infrastructure/src/remote/mod.rs
Normal 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"
|
||||
))),
|
||||
}
|
||||
}
|
||||
189
crates/infrastructure/src/runtime/mod.rs
Normal file
189
crates/infrastructure/src/runtime/mod.rs
Normal 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)
|
||||
}
|
||||
172
crates/infrastructure/src/store/context.rs
Normal file
172
crates/infrastructure/src/store/context.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
15
crates/infrastructure/src/store/mod.rs
Normal file
15
crates/infrastructure/src/store/mod.rs
Normal 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;
|
||||
149
crates/infrastructure/src/store/profile.rs
Normal file
149
crates/infrastructure/src/store/profile.rs
Normal 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
|
||||
}
|
||||
}
|
||||
156
crates/infrastructure/src/store/project.rs
Normal file
156
crates/infrastructure/src/store/project.rs
Normal 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(®istry).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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
225
crates/infrastructure/src/store/template.rs
Normal file
225
crates/infrastructure/src/store/template.rs
Normal 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
|
||||
}
|
||||
}
|
||||
317
crates/infrastructure/tests/agent_runtime.rs
Normal file
317
crates/infrastructure/tests/agent_runtime.rs
Normal 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"]);
|
||||
}
|
||||
147
crates/infrastructure/tests/context_store.rs
Normal file
147
crates/infrastructure/tests/context_store.rs
Normal 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");
|
||||
}
|
||||
53
crates/infrastructure/tests/eventbus.rs
Normal file
53
crates/infrastructure/tests/eventbus.rs
Normal 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());
|
||||
}
|
||||
108
crates/infrastructure/tests/git_repository.rs
Normal file
108
crates/infrastructure/tests/git_repository.rs
Normal 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(¤t), "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");
|
||||
}
|
||||
81
crates/infrastructure/tests/local_fs.rs
Normal file
81
crates/infrastructure/tests/local_fs.rs
Normal 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:?}");
|
||||
}
|
||||
169
crates/infrastructure/tests/profile_store.rs
Normal file
169
crates/infrastructure/tests/profile_store.rs
Normal 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");
|
||||
}
|
||||
139
crates/infrastructure/tests/project_store.rs
Normal file
139
crates/infrastructure/tests/project_store.rs
Normal 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"));
|
||||
}
|
||||
169
crates/infrastructure/tests/pty_adapter.rs
Normal file
169
crates/infrastructure/tests/pty_adapter.rs
Normal 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;
|
||||
}
|
||||
70
crates/infrastructure/tests/remote_host.rs
Normal file
70
crates/infrastructure/tests/remote_host.rs
Normal 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();
|
||||
}
|
||||
139
crates/infrastructure/tests/template_store.rs
Normal file
139
crates/infrastructure/tests/template_store.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user