feat: add main features

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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