From e20542a40e7bb572ea8e8512a9038b72b0e48ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Sat, 9 May 2026 15:21:46 +0200 Subject: [PATCH] sync from monorepo @ b6628270 --- Cargo.toml | 6 - README.md | 5 +- crates/dirigent_langfuse/CLAUDE.md | 72 -- crates/dirigent_langfuse/Cargo.toml | 23 - crates/dirigent_langfuse/src/client.rs | 204 ----- crates/dirigent_langfuse/src/factory.rs | 48 -- crates/dirigent_langfuse/src/lib.rs | 13 - crates/dirigent_langfuse/src/mapping.rs | 173 ---- crates/dirigent_projects/Cargo.toml | 33 - crates/dirigent_projects/src/detection.rs | 751 ------------------ crates/dirigent_projects/src/error.rs | 43 - crates/dirigent_projects/src/file_store.rs | 441 ---------- crates/dirigent_projects/src/git/mod.rs | 13 - crates/dirigent_projects/src/git/runner.rs | 353 -------- crates/dirigent_projects/src/git/state.rs | 77 -- crates/dirigent_projects/src/git/worktree.rs | 62 -- crates/dirigent_projects/src/lib.rs | 44 - crates/dirigent_projects/src/params.rs | 127 --- crates/dirigent_projects/src/storage/io.rs | 118 --- crates/dirigent_projects/src/storage/mod.rs | 6 - crates/dirigent_projects/src/storage/paths.rs | 66 -- crates/dirigent_projects/src/traits.rs | 81 -- crates/dirigent_projects/tests/git_tests.rs | 184 ----- .../tests/project_lifecycle.rs | 226 ------ .../tests/repository_tests.rs | 424 ---------- crates/dirigent_testing/CLAUDE.md | 62 -- crates/dirigent_testing/Cargo.toml | 14 - .../fixtures/minimal_init.json | 31 - .../fixtures/zed_claude_session.json | 227 ------ crates/dirigent_testing/src/fixtures.rs | 46 -- crates/dirigent_testing/src/lib.rs | 8 - crates/dirigent_testing/src/replay.rs | 88 -- .../tests/zed_claude_replay.rs | 138 ---- 33 files changed, 1 insertion(+), 4206 deletions(-) delete mode 100644 crates/dirigent_langfuse/CLAUDE.md delete mode 100644 crates/dirigent_langfuse/Cargo.toml delete mode 100644 crates/dirigent_langfuse/src/client.rs delete mode 100644 crates/dirigent_langfuse/src/factory.rs delete mode 100644 crates/dirigent_langfuse/src/lib.rs delete mode 100644 crates/dirigent_langfuse/src/mapping.rs delete mode 100644 crates/dirigent_projects/Cargo.toml delete mode 100644 crates/dirigent_projects/src/detection.rs delete mode 100644 crates/dirigent_projects/src/error.rs delete mode 100644 crates/dirigent_projects/src/file_store.rs delete mode 100644 crates/dirigent_projects/src/git/mod.rs delete mode 100644 crates/dirigent_projects/src/git/runner.rs delete mode 100644 crates/dirigent_projects/src/git/state.rs delete mode 100644 crates/dirigent_projects/src/git/worktree.rs delete mode 100644 crates/dirigent_projects/src/lib.rs delete mode 100644 crates/dirigent_projects/src/params.rs delete mode 100644 crates/dirigent_projects/src/storage/io.rs delete mode 100644 crates/dirigent_projects/src/storage/mod.rs delete mode 100644 crates/dirigent_projects/src/storage/paths.rs delete mode 100644 crates/dirigent_projects/src/traits.rs delete mode 100644 crates/dirigent_projects/tests/git_tests.rs delete mode 100644 crates/dirigent_projects/tests/project_lifecycle.rs delete mode 100644 crates/dirigent_projects/tests/repository_tests.rs delete mode 100644 crates/dirigent_testing/CLAUDE.md delete mode 100644 crates/dirigent_testing/Cargo.toml delete mode 100644 crates/dirigent_testing/fixtures/minimal_init.json delete mode 100644 crates/dirigent_testing/fixtures/zed_claude_session.json delete mode 100644 crates/dirigent_testing/src/fixtures.rs delete mode 100644 crates/dirigent_testing/src/lib.rs delete mode 100644 crates/dirigent_testing/src/replay.rs delete mode 100644 crates/dirigent_testing/tests/zed_claude_replay.rs diff --git a/Cargo.toml b/Cargo.toml index dae6540..d38fbe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,13 +13,10 @@ members = [ "crates/dirigent_taskrunner", "crates/dirigent_anth", "crates/dirigent_inspector", - "crates/dirigent_projects", "crates/dirigent_matrix", "crates/dirigent_zed", - "crates/dirigent_langfuse", "crates/dirigent_chatgpt", "crates/dirigent_codex", - "crates/dirigent_testing", "crates/opencode_client", ] @@ -43,11 +40,8 @@ dirigent_process = { path = "crates/dirigent_process" } dirigent_taskrunner = { path = "crates/dirigent_taskrunner" } dirigent_anth = { path = "crates/dirigent_anth" } dirigent_inspector = { path = "crates/dirigent_inspector" } -dirigent_projects = { path = "crates/dirigent_projects" } dirigent_matrix = { path = "crates/dirigent_matrix", default-features = true } dirigent_zed = { path = "crates/dirigent_zed" } -dirigent_langfuse = { path = "crates/dirigent_langfuse" } dirigent_chatgpt = { path = "crates/dirigent_chatgpt" } dirigent_codex = { path = "crates/dirigent_codex" } -dirigent_testing = { path = "crates/dirigent_testing" } opencode_client = { path = "crates/opencode_client" } diff --git a/README.md b/README.md index 3731507..b4eb225 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ These tools are developed in this monorepo but distributed as independent reposi - **Standalone Tools** — installable from their own repositories; depend on foundation crates - **Orchestration** — multi-connector runtime, ACP server, task management, archival - **Foundation** — protocol types, tool sandbox, configuration, auth -- **Integrations** — Matrix, Langfuse, Zed, and other external system connectors +- **Integrations** — Matrix, Zed, and other external system connectors - **Parsers** — readers for third-party session formats (OpenCode, ChatGPT, Codex) --- @@ -53,13 +53,10 @@ These tools are developed in this monorepo but distributed as independent reposi | `dirigent_taskrunner` | beta | Background task runner | | `dirigent_anth` | production | Claude Code JSONL session parser | | `dirigent_inspector` | concept | Session inspection tools | -| `dirigent_projects` | concept | Project management primitives | | `dirigent_matrix` | concept | Matrix integration for session sharing | | `dirigent_zed` | concept | Zed editor integration | -| `dirigent_langfuse` | concept | Langfuse observability integration | | `dirigent_chatgpt` | beta | ChatGPT `conversations.json` parser | | `dirigent_codex` | beta | OpenAI Codex session parser | -| `dirigent_testing` | beta | Test utilities | | `opencode_client` | beta | OpenCode.ai HTTP client | --- diff --git a/crates/dirigent_langfuse/CLAUDE.md b/crates/dirigent_langfuse/CLAUDE.md deleted file mode 100644 index d2dcc10..0000000 --- a/crates/dirigent_langfuse/CLAUDE.md +++ /dev/null @@ -1,72 +0,0 @@ -# Package: dirigent_langfuse - -Phase 4 stream backend that mirrors BusEvents to a Langfuse ingestion -endpoint. - -## Scope - -- `LangfuseFactory` registered as `kind = "langfuse"` in the - `StreamFactoryRegistry`. -- `LangfuseStream` implements `SessionStream`: - - Maps each `BusEvent` via `mapping::bus_event_to_items`. - - Buffers up to 32 items per flush; flushes eagerly when full and on - shutdown. - - POSTs `{host}/api/public/ingestion` with basic-auth - `(public_key, secret_key)`. - -## File map - -- `src/lib.rs` — public API: `LangfuseStream`, `LangfuseConfig`, - `LangfuseFactory`. -- `src/client.rs` — `LangfuseClient` (reqwest wrapper with retry) and - the `LangfuseStream` implementation. -- `src/mapping.rs` — `bus_event_to_items` mapping. -- `src/factory.rs` — `StreamFactory` impl. - -## Event → ingestion mapping - -| BusEvent variant | Langfuse item | -|------------------|---------------| -| `SessionCreated` | `trace-create` (id = `scroll_id`) | -| `MessageStarted` | `generation-create` | -| `MessageCompleted` | `generation-update` with output | -| `SessionUpdate` (non-tool) | skipped | -| All others | skipped | - -Events without a bound `scroll_id` (no late-bind hit) are dropped — the -implementation does NOT buffer pending events keyed by connector_id / -native_session_id in Phase 4. If buffering is needed later, extend -`LangfuseStream::on_event`. - -## Failure modes - -- Transport error → `StreamOutcome::Failed(StreamError::Transport)`. - Health drift applies; the stream goes Degraded after one failure and - Unavailable after five consecutive failures. -- 5xx response → retried up to 3 times with exponential backoff - (100ms → 200 → 400 → 800, capped at 1s). -- 4xx response → returned as `LangfuseError::Status(code)`; no retry. -- Empty scroll_id → `StreamOutcome::Skipped` (not a failure). - -## Configuration - -```toml -[[streams]] -name = "langfuse-prod" -type = "langfuse" -enabled = true -[streams.scope] -kind = "connector" -connector_uid = "01985d00-..." -[streams.params] -host = "https://langfuse.example.com" -public_key = "pk-lf-..." -secret_key = "sk-lf-..." -``` - -## Deferred - -- Tool-call → span mapping (`SpanCreate`/`SpanUpdate`): scaffolded but - not yet populated. -- Buffering pending events keyed by `(connector_id, native_session_id)` - for late-bind scenarios. diff --git a/crates/dirigent_langfuse/Cargo.toml b/crates/dirigent_langfuse/Cargo.toml deleted file mode 100644 index 3c97f02..0000000 --- a/crates/dirigent_langfuse/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "dirigent_langfuse" -version = "0.1.0" -edition = "2021" - -[features] -default = [] -server = ["dep:reqwest", "dep:tokio", "dep:dirigent_core", "dirigent_core/server"] - -[dependencies] -async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -dirigent_core = { path = "../dirigent_core", optional = true } -dirigent_protocol = { path = "../dirigent_protocol" } -reqwest = { version = "0.12", optional = true, features = ["json"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "2.0" -tokio = { version = "1", optional = true, features = ["rt", "sync", "macros"] } -toml = "0.8" -tracing = "0.1" -url = "2" -uuid = { version = "1", features = ["v4", "v7"] } diff --git a/crates/dirigent_langfuse/src/client.rs b/crates/dirigent_langfuse/src/client.rs deleted file mode 100644 index 131df3d..0000000 --- a/crates/dirigent_langfuse/src/client.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Langfuse ingestion client. Phase 4 feature-gated on `server`. - -use std::sync::Arc; -#[cfg(feature = "server")] -use std::time::Duration; - -use async_trait::async_trait; -use chrono::Utc; -use thiserror::Error; -#[cfg(feature = "server")] -use tokio::sync::Mutex; -#[cfg(feature = "server")] -use tracing::warn; - -use dirigent_protocol::streaming::{ - BusEvent, SessionStream, StreamKind, StreamOutcome, StreamScope, StreamSummary, -}; -#[cfg(feature = "server")] -use dirigent_protocol::streaming::StreamError; - -#[cfg(feature = "server")] -use crate::mapping::{bus_event_to_items, IngestItem}; - -/// Langfuse stream configuration (credentials + host). -#[derive(Debug, Clone)] -pub struct LangfuseConfig { - pub host: String, - pub public_key: String, - pub secret_key: String, -} - -#[derive(Debug, Error)] -#[cfg_attr(not(feature = "server"), allow(dead_code))] -pub enum LangfuseError { - #[error("transport: {0}")] - Transport(String), - #[error("unexpected status: {0}")] - Status(u16), - #[error("serialisation: {0}")] - Serialisation(String), -} - -/// Thin wrapper around `reqwest::Client` that POSTs batches to -/// `{host}/api/public/ingestion` with HTTP basic auth. -#[cfg(feature = "server")] -pub(crate) struct LangfuseClient { - http: reqwest::Client, - host: String, - auth: (String, String), -} - -#[cfg(feature = "server")] -impl LangfuseClient { - pub fn new(config: LangfuseConfig) -> Result { - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| LangfuseError::Transport(e.to_string()))?; - Ok(Self { - http, - host: config.host, - auth: (config.public_key, config.secret_key), - }) - } - - pub async fn ingest_batch(&self, batch: Vec) -> Result<(), LangfuseError> { - if batch.is_empty() { - return Ok(()); - } - let url = format!("{}/api/public/ingestion", self.host.trim_end_matches('/')); - let payload = serde_json::json!({ "batch": batch }); - - let mut attempt = 0u32; - let mut delay_ms = 100u64; - loop { - let resp = self - .http - .post(&url) - .basic_auth(&self.auth.0, Some(&self.auth.1)) - .json(&payload) - .send() - .await; - - match resp { - Ok(r) if r.status().is_success() => return Ok(()), - Ok(r) if r.status().is_server_error() && attempt < 3 => { - warn!(status = %r.status(), attempt, "langfuse ingestion 5xx; retrying"); - } - Ok(r) => return Err(LangfuseError::Status(r.status().as_u16())), - Err(e) if attempt < 3 => { - warn!(error = %e, attempt, "langfuse transport error; retrying"); - } - Err(e) => return Err(LangfuseError::Transport(e.to_string())), - } - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - attempt += 1; - delay_ms = (delay_ms * 2).min(1000); - } - } -} - -/// A live Langfuse stream. Buffers items in-memory and flushes every N ms -/// or M items, whichever is first. -pub struct LangfuseStream { - pub config: LangfuseConfig, - pub scope: StreamScope, - pub name: String, - pub active_since: chrono::DateTime, - #[cfg(feature = "server")] - client: Arc, - #[cfg(feature = "server")] - buffer: Arc>>, -} - -#[cfg(feature = "server")] -const FLUSH_ITEMS: usize = 32; - -impl LangfuseStream { - #[cfg(feature = "server")] - pub fn new( - name: String, - config: LangfuseConfig, - scope: StreamScope, - ) -> Result, LangfuseError> { - let client = Arc::new(LangfuseClient::new(config.clone())?); - Ok(Arc::new(Self { - config, - scope, - name, - active_since: Utc::now(), - client, - buffer: Arc::new(Mutex::new(Vec::new())), - })) - } - - #[cfg(not(feature = "server"))] - pub fn new(name: String, config: LangfuseConfig, scope: StreamScope) -> Arc { - Arc::new(Self { - config, - scope, - name, - active_since: Utc::now(), - }) - } - - #[cfg(feature = "server")] - async fn flush(&self) -> Result<(), LangfuseError> { - let mut buf = self.buffer.lock().await; - if buf.is_empty() { - return Ok(()); - } - let batch: Vec<_> = buf.drain(..).collect(); - drop(buf); - self.client.ingest_batch(batch).await - } -} - -#[async_trait] -impl SessionStream for LangfuseStream { - fn summary(&self) -> StreamSummary { - StreamSummary { - name: self.name.clone(), - kind: StreamKind::Langfuse, - target: format!("langfuse: {}", self.config.host), - active_since: self.active_since, - } - } - fn scope(&self) -> StreamScope { - self.scope.clone() - } - - #[cfg(feature = "server")] - async fn on_event(&self, event: &BusEvent) -> StreamOutcome { - let items = bus_event_to_items(event); - if items.is_empty() { - return StreamOutcome::Skipped; - } - - let mut buf = self.buffer.lock().await; - buf.extend(items); - if buf.len() >= FLUSH_ITEMS { - let batch: Vec<_> = buf.drain(..).collect(); - drop(buf); - match self.client.ingest_batch(batch).await { - Ok(()) => StreamOutcome::Ok, - Err(e) => StreamOutcome::Failed(StreamError::Transport(e.to_string())), - } - } else { - StreamOutcome::Ok - } - } - - #[cfg(not(feature = "server"))] - async fn on_event(&self, _event: &BusEvent) -> StreamOutcome { - StreamOutcome::Ok - } - - async fn shutdown(&self) { - #[cfg(feature = "server")] - { - let _ = self.flush().await; - } - } -} diff --git a/crates/dirigent_langfuse/src/factory.rs b/crates/dirigent_langfuse/src/factory.rs deleted file mode 100644 index 8abea8c..0000000 --- a/crates/dirigent_langfuse/src/factory.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Phase 4: factory that builds a stub `LangfuseStream`. Task 22 upgrades -//! it to read credentials from params and construct a real client. - -use std::sync::Arc; - -use async_trait::async_trait; - -use dirigent_core::sharing::{StreamBuildError, StreamConfig, StreamFactory}; -use dirigent_protocol::streaming::SessionStream; - -use crate::client::{LangfuseConfig, LangfuseStream}; - -pub struct LangfuseFactory; - -#[async_trait] -impl StreamFactory for LangfuseFactory { - fn kind(&self) -> &'static str { "langfuse" } - - async fn build(&self, cfg: &StreamConfig) -> Result, StreamBuildError> { - // Parse params. Required fields: - // host: String (URL) - // public_key: String - // secret_key: String - // - // Phase 4 stub: parse-or-fail, then construct LangfuseStream with - // the parsed config. Task 22 uses the host to build a reqwest client. - - let host = cfg.params - .get("host").and_then(|v| v.as_str()) - .ok_or_else(|| StreamBuildError::Config("missing `host` (url string)".into()))?; - let public_key = cfg.params - .get("public_key").and_then(|v| v.as_str()) - .ok_or_else(|| StreamBuildError::Config("missing `public_key`".into()))?; - let secret_key = cfg.params - .get("secret_key").and_then(|v| v.as_str()) - .ok_or_else(|| StreamBuildError::Config("missing `secret_key`".into()))?; - - let lf_cfg = LangfuseConfig { - host: host.to_string(), - public_key: public_key.to_string(), - secret_key: secret_key.to_string(), - }; - - let stream = LangfuseStream::new(cfg.name.clone(), lf_cfg, cfg.scope.clone()) - .map_err(|e| StreamBuildError::Transport(e.to_string()))?; - Ok(stream as Arc) - } -} diff --git a/crates/dirigent_langfuse/src/lib.rs b/crates/dirigent_langfuse/src/lib.rs deleted file mode 100644 index 50d9f14..0000000 --- a/crates/dirigent_langfuse/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Langfuse SessionStream implementation. -//! -//! Phase 4 scope: stub implementation. Task 22 adds the real HTTP -//! client + event-to-ingestion mapping. - -mod client; -#[cfg(feature = "server")] -mod factory; -mod mapping; - -pub use client::{LangfuseConfig, LangfuseStream}; -#[cfg(feature = "server")] -pub use factory::LangfuseFactory; diff --git a/crates/dirigent_langfuse/src/mapping.rs b/crates/dirigent_langfuse/src/mapping.rs deleted file mode 100644 index 5660945..0000000 --- a/crates/dirigent_langfuse/src/mapping.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! BusEvent → Langfuse ingestion mapping. -//! -//! Maps the common BusEvent kinds to Langfuse ingestion items (traces, -//! generations, spans). Events without a `scroll_id` are dropped — -//! Langfuse requires a trace id up-front. - -// The items below are only wired into the stream when the `server` -// feature is on; the default-feature build keeps them for symmetry but -// does not reference them, so allow dead-code warnings there. -#![cfg_attr(not(feature = "server"), allow(dead_code))] - -use chrono::{DateTime, Utc}; -use serde::Serialize; -use uuid::Uuid; - -use dirigent_protocol::{streaming::BusEvent, Event}; - -/// A single Langfuse ingestion item. -/// -/// Batched into `{ "batch": [...] }` in `LangfuseClient::ingest_batch`. -#[derive(Debug, Clone, Serialize)] -pub struct IngestItem { - pub id: String, // UUIDv7 - pub timestamp: DateTime, - #[serde(rename = "type")] - pub kind: IngestKind, - pub body: serde_json::Value, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -#[allow(dead_code)] // SpanCreate/SpanUpdate reserved for future tool-call mapping -pub enum IngestKind { - TraceCreate, - GenerationCreate, - GenerationUpdate, - SpanCreate, - SpanUpdate, -} - -pub fn bus_event_to_items(bus_event: &BusEvent) -> Vec { - let Some(scroll_id) = bus_event.routing.scroll_id else { - // No scroll_id binding yet — drop. Upstream callers may choose to - // buffer pending events keyed by (connector_id, native_id) until - // SessionRegistered arrives; Phase 4 scope: drop and log. - return Vec::new(); - }; - - let trace_id = scroll_id.to_string(); - let now = Utc::now(); - - match &*bus_event.event { - Event::SessionCreated { session, .. } => { - // `session.title` is a `String`; fall back to the id if empty. - let name = if session.title.is_empty() { - session.id.clone() - } else { - session.title.clone() - }; - vec![IngestItem { - id: Uuid::now_v7().to_string(), - timestamp: now, - kind: IngestKind::TraceCreate, - body: serde_json::json!({ - "id": trace_id, - "name": name, - }), - }] - } - Event::MessageStarted { message, .. } => { - vec![IngestItem { - id: Uuid::now_v7().to_string(), - timestamp: now, - kind: IngestKind::GenerationCreate, - body: serde_json::json!({ - "id": message.id, - "traceId": trace_id, - "name": format!("{:?}", message.role), - "startTime": message.created_at, - }), - }] - } - Event::MessageCompleted { message, .. } => { - vec![IngestItem { - id: Uuid::now_v7().to_string(), - timestamp: now, - kind: IngestKind::GenerationUpdate, - body: serde_json::json!({ - "id": message.id, - "traceId": trace_id, - "endTime": now, - "output": serialize_content(&message.content), - }), - }] - } - Event::TurnComplete { .. } => Vec::new(), // captured by MessageCompleted - // SessionUpdate::ToolCall* — would need a case-by-case mapping; out of - // Phase 4 scope. Return empty for now. - _ => Vec::new(), - } -} - -fn serialize_content(parts: &[dirigent_protocol::MessagePart]) -> serde_json::Value { - serde_json::to_value(parts).unwrap_or(serde_json::Value::Null) -} - -#[cfg(test)] -mod tests { - use super::*; - use dirigent_protocol::streaming::{BusEvent, EventKind, EventOrigin, EventRouting}; - use dirigent_protocol::{Event, Message, MessageRole, MessageStatus}; - use std::sync::Arc; - - fn make_bus_event_with_scroll(event: Event, scroll_id: Uuid) -> BusEvent { - BusEvent { - routing: EventRouting { - scroll_id: Some(scroll_id), - connector_uid: Some(Uuid::new_v4()), - connector_id: Some("c".into()), - native_session_id: Some("s".into()), - kind: EventKind::Message, - }, - origin: EventOrigin::Runtime, - event: Arc::new(event), - } - } - - #[test] - fn message_started_produces_generation_create() { - let scroll_id = Uuid::new_v4(); - let msg = Message { - id: "m1".into(), - session_id: "s".into(), - role: MessageRole::Assistant, - created_at: chrono::Utc::now(), - content: vec![], - status: MessageStatus::Streaming, - metadata: None, - }; - let bus_event = make_bus_event_with_scroll( - Event::MessageStarted { - connector_id: "c".into(), - message: msg, - }, - scroll_id, - ); - let items = bus_event_to_items(&bus_event); - assert_eq!(items.len(), 1); - assert!(matches!(items[0].kind, IngestKind::GenerationCreate)); - } - - #[test] - fn no_scroll_id_drops_event() { - let event = Event::Connected; - let bus_event = BusEvent { - routing: EventRouting::default(), - origin: EventOrigin::Runtime, - event: Arc::new(event), - }; - let items = bus_event_to_items(&bus_event); - assert_eq!(items.len(), 0); - } - - #[test] - fn unmapped_event_returns_empty() { - // `Connected` is not one of our mapped variants even when a scroll_id - // is bound → expect 0 items. - let scroll_id = Uuid::new_v4(); - let bus_event = make_bus_event_with_scroll(Event::Connected, scroll_id); - let items = bus_event_to_items(&bus_event); - assert_eq!(items.len(), 0); - } -} diff --git a/crates/dirigent_projects/Cargo.toml b/crates/dirigent_projects/Cargo.toml deleted file mode 100644 index bb6b6a0..0000000 --- a/crates/dirigent_projects/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "dirigent_projects" -version = "0.1.0" -edition = "2021" - -[lib] -path = "src/lib.rs" - -[dependencies] -# Async traits -async-trait = "0.1" -# Date/time handling -chrono = { version = "0.4", features = ["serde"] } -dirigent_auth = { path = "../dirigent_auth" } -# Home directory resolution -dirs = "6" -# Protocol types (WASM-compatible project types) -dirigent_protocol = { path = "../dirigent_protocol" } -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -# Error handling -thiserror = "2.0" -# Async runtime and file operations -tokio = { version = "1", features = ["fs", "io-util", "process", "sync"] } -# Logging -tracing = "0.1" -# UUID support with v7 and serde -uuid = { version = "1.0", features = ["serde", "v7"] } - -[dev-dependencies] -tempfile = "3.0" -tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "sync"] } diff --git a/crates/dirigent_projects/src/detection.rs b/crates/dirigent_projects/src/detection.rs deleted file mode 100644 index 6ad36cb..0000000 --- a/crates/dirigent_projects/src/detection.rs +++ /dev/null @@ -1,751 +0,0 @@ -//! Project detection and import support. -//! -//! Provides path normalization, worktree detection, multi-path grouping, -//! and matching logic to link discovered import paths to existing projects. - -use std::collections::HashMap; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use dirigent_protocol::project::{Project, ProjectRepository}; - -use crate::error::{ProjectError, Result}; -use crate::params::{AddRepositoryParams, CreateProjectParams}; -use crate::traits::ProjectStore; - -// --------------------------------------------------------------------------- -// DTOs -// --------------------------------------------------------------------------- - -/// A project discovered during import, before resolution against existing projects. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DetectedProject { - /// Filesystem path as discovered (pre-normalization may have been applied). - pub discovered_path: String, - /// Suggested name derived from the path (e.g. last directory component). - pub suggested_name: String, - /// Number of sessions associated with this discovered path. - pub session_count: usize, - /// How this detection was resolved against existing projects. - pub resolution: ProjectResolution, -} - -/// How a detected project path was resolved against the existing project store. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ProjectResolution { - /// Matched an existing project and repository. - Linked { - project_id: Uuid, - project_name: String, - matched_repository_id: Uuid, - }, - /// No match found — suggests creating a new project. - CreateNew { name: String }, - /// The user chose to skip this detection. - Skip, -} - -/// Full result of running project detection over a set of import discoveries. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectDetectionResult { - /// One entry per discovered path. - pub detections: Vec, - /// Hints about git worktree relationships. - pub worktree_hints: Vec, - /// Hints about paths that share a common parent. - pub multi_path_hints: Vec, -} - -/// Hint that a path is (or may be) a git worktree. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorktreeHint { - /// The worktree path itself. - pub worktree_path: String, - /// The main repository path (parsed from `.git` file), if resolved. - pub main_repo_path: Option, -} - -/// Hint that multiple discovered paths share a common immediate parent. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MultiPathHint { - /// The shared parent directory. - pub shared_parent: String, - /// The child paths that share this parent. - pub paths: Vec, -} - -/// Request to create a project from an import detection. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportProjectCreationRequest { - /// Project name. - pub name: String, - /// Primary repository path. - pub primary_path: String, - /// Additional repository paths. - #[serde(default)] - pub additional_paths: Vec, - /// Optional icon. - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, - /// Tags for the new project. - #[serde(default)] - pub tags: Vec, - /// Programming languages. - #[serde(default)] - pub languages: Vec, -} - -/// Result of creating a project from an import request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportProjectCreationResult { - /// The created project's ID. - pub project_id: Uuid, - /// The created project's name. - pub project_name: String, - /// How many repositories were created (primary + additional). - pub repositories_created: usize, -} - -/// Lightweight input describing a project discovered during import. -/// -/// This mirrors the shape used by import discovery (name + path + session count). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiscoveredImportProject { - /// Project name (typically the directory basename or user-facing label). - pub name: String, - /// Filesystem path associated with this project. - pub path: String, - /// Number of sessions discovered under this path. - pub session_count: usize, -} - -// --------------------------------------------------------------------------- -// Path normalization -// --------------------------------------------------------------------------- - -/// Normalize a filesystem path for consistent cross-platform comparison. -/// -/// Steps (in order): -/// 1. Try `std::fs::canonicalize()` — if it succeeds, use that (resolves symlinks, -/// `..`, etc.) and convert to forward slashes. -/// 2. On failure, apply textual normalization: -/// - Backslash -> forward slash -/// - MinGW `/c/Users/...` -> `C:/Users/...` -/// - WSL `/mnt/c/Users/...` -> `C:/Users/...` -/// - UNC `\\server\share` -> `//server/share` -/// - Tilde `~/foo` -> expanded home + `/foo` -/// - Collapse `//` -> `/` (except leading UNC) -/// - Resolve `.` and `..` segments -/// - Strip trailing `/` -/// 3. On Windows, lowercase the entire result for case-insensitive comparison. -pub fn normalize_project_path(path: &str) -> String { - // Try canonical resolution first. - if let Ok(canonical) = std::fs::canonicalize(path) { - let mut s = canonical.to_string_lossy().replace('\\', "/"); - // Strip trailing slash unless it's a root like "C:/" - if s.len() > 1 && s.ends_with('/') && !s.ends_with(":/") { - s.pop(); - } - return platform_case_normalize(s); - } - - // Textual fallback. - let mut s = path.replace('\\', "/"); - - // Tilde expansion. - if s.starts_with("~/") || s == "~" { - if let Some(home) = home_dir_string() { - if s == "~" { - s = home; - } else { - s = format!("{}/{}", home.trim_end_matches('/'), &s[2..]); - } - } - } - - // MinGW: /c/Users/... -> C:/Users/... - if let Some(rest) = try_strip_mingw(&s) { - s = rest; - } - - // WSL: /mnt/c/Users/... -> C:/Users/... - if let Some(rest) = try_strip_wsl(&s) { - s = rest; - } - - // UNC already converted by backslash replacement: //server/share is fine. - - // Collapse double slashes (preserve leading // for UNC). - s = collapse_slashes(&s); - - // Resolve `.` and `..` segments textually. - s = resolve_dots(&s); - - // Strip trailing slash (unless root). - if s.len() > 1 && s.ends_with('/') && !s.ends_with(":/") { - s.pop(); - } - - platform_case_normalize(s) -} - -fn home_dir_string() -> Option { - dirs::home_dir().map(|p| p.to_string_lossy().replace('\\', "/")) -} - -fn try_strip_mingw(s: &str) -> Option { - let bytes = s.as_bytes(); - // Pattern: /X/... where X is a single ASCII letter - if bytes.len() >= 3 - && bytes[0] == b'/' - && bytes[1].is_ascii_alphabetic() - && bytes[2] == b'/' - { - let drive = (bytes[1] as char).to_ascii_uppercase(); - Some(format!("{}:/{}", drive, &s[3..])) - } else { - None - } -} - -fn try_strip_wsl(s: &str) -> Option { - if let Some(rest) = s.strip_prefix("/mnt/") { - let bytes = rest.as_bytes(); - if !bytes.is_empty() && bytes[0].is_ascii_alphabetic() { - let drive = (bytes[0] as char).to_ascii_uppercase(); - let remainder = if bytes.len() > 1 && bytes[1] == b'/' { - &rest[2..] - } else if bytes.len() == 1 { - "" - } else { - return None; // e.g. /mnt/cdrom — not a drive letter - }; - return Some(format!("{}:/{}", drive, remainder)); - } - } - None -} - -fn collapse_slashes(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - - // Preserve leading double slash for UNC. - if s.starts_with("//") { - result.push('/'); - result.push('/'); - chars.next(); - chars.next(); - // Skip any additional leading slashes beyond the two. - while chars.peek() == Some(&'/') { - chars.next(); - } - } - - let mut prev_slash = false; - for c in chars { - if c == '/' { - if !prev_slash { - result.push(c); - } - prev_slash = true; - } else { - result.push(c); - prev_slash = false; - } - } - result -} - -fn resolve_dots(s: &str) -> String { - // Split on '/', resolve `.` and `..` textually. - let mut parts: Vec<&str> = Vec::new(); - let prefix = if s.starts_with("//") { - "//" - } else if s.starts_with('/') { - "/" - } else { - "" - }; - - for segment in s.split('/') { - match segment { - "" | "." => {} - ".." => { - // Don't pop past the root. - if !parts.is_empty() && *parts.last().unwrap() != ".." { - parts.pop(); - } - } - other => parts.push(other), - } - } - - let joined = parts.join("/"); - if prefix.is_empty() { - joined - } else { - format!("{}{}", prefix, joined) - } -} - -#[cfg(target_os = "windows")] -fn platform_case_normalize(s: String) -> String { - s.to_lowercase() -} - -#[cfg(not(target_os = "windows"))] -fn platform_case_normalize(s: String) -> String { - s -} - -// --------------------------------------------------------------------------- -// Worktree detection -// --------------------------------------------------------------------------- - -/// Check whether the given path is a git worktree (`.git` is a file, not a directory). -/// -/// If it is, parses the `gitdir:` pointer to determine the main repository path. -pub fn detect_worktree(path: &str) -> Option { - let dot_git = PathBuf::from(path).join(".git"); - - // Only interested if .git is a *file* (worktree pointer), not a directory. - let meta = std::fs::symlink_metadata(&dot_git).ok()?; - if !meta.is_file() { - return None; - } - - let content = std::fs::read_to_string(&dot_git).ok()?; - let gitdir_line = content - .lines() - .find(|l| l.starts_with("gitdir:"))?; - - let gitdir_raw = gitdir_line["gitdir:".len()..].trim(); - - // The gitdir path typically looks like `/path/to/main-repo/.git/worktrees/`. - // Walk up to find the main repo root. - let gitdir_path = if PathBuf::from(gitdir_raw).is_absolute() { - PathBuf::from(gitdir_raw) - } else { - PathBuf::from(path).join(gitdir_raw) - }; - - // Try to resolve: .../main-repo/.git/worktrees/xxx -> .../main-repo - let main_repo = gitdir_path - .ancestors() - .find(|ancestor| { - // Check if this ancestor has `.git` as a child (actual git dir, not worktree file). - let git_child = ancestor.join(".git"); - git_child.is_dir() - }) - .map(|p| normalize_project_path(&p.to_string_lossy())); - - Some(WorktreeHint { - worktree_path: normalize_project_path(path), - main_repo_path: main_repo, - }) -} - -// --------------------------------------------------------------------------- -// Multi-path grouping -// --------------------------------------------------------------------------- - -/// Group paths that share a common immediate parent directory. -/// -/// Only produces hints for groups of 2+ paths. -pub fn find_multi_path_groups(paths: &[String]) -> Vec { - let mut by_parent: HashMap> = HashMap::new(); - - for path in paths { - let normalized = normalize_project_path(path); - // Find immediate parent by stripping last component. - if let Some(parent) = PathBuf::from(&normalized).parent() { - let parent_str = parent.to_string_lossy().replace('\\', "/"); - by_parent - .entry(parent_str) - .or_default() - .push(normalized); - } - } - - by_parent - .into_iter() - .filter(|(_, children)| children.len() >= 2) - .map(|(parent, mut children)| { - children.sort(); - MultiPathHint { - shared_parent: parent, - paths: children, - } - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Detection logic -// --------------------------------------------------------------------------- - -/// Match discovered import projects against existing projects. -/// -/// For each discovered path, attempts to find a match in the existing project -/// store using (in priority order): -/// 1. Exact normalized path match against any repository -/// 2. Canonical (fs::canonicalize) path match -/// 3. Name-based hint (project name == suggested name) -/// -/// Unmatched paths get `ProjectResolution::CreateNew`. -pub fn detect_projects( - discovered: &[DiscoveredImportProject], - existing_projects: &[(Project, Vec)], -) -> ProjectDetectionResult { - // Pre-build a lookup from normalized repo paths -> (project, repo). - let mut path_index: HashMap = HashMap::new(); - let mut canonical_index: HashMap = HashMap::new(); - let mut name_index: HashMap = HashMap::new(); - - for (project, repos) in existing_projects { - name_index.insert(project.name.to_lowercase(), project); - for repo in repos { - let repo_path_str = repo.path.to_string_lossy().to_string(); - let normalized = normalize_project_path(&repo_path_str); - path_index.insert(normalized.clone(), (project, repo)); - - // Also try canonical path of the repo. - if let Ok(canonical) = std::fs::canonicalize(&repo.path) { - let canon_norm = normalize_project_path(&canonical.to_string_lossy()); - canonical_index.insert(canon_norm, (project, repo)); - } - } - } - - let mut detections = Vec::with_capacity(discovered.len()); - let discovered_paths: Vec = discovered.iter().map(|d| d.path.clone()).collect(); - let worktree_hints: Vec = discovered_paths - .iter() - .filter_map(|p| detect_worktree(p)) - .collect(); - - for disc in discovered { - let normalized = normalize_project_path(&disc.path); - - // 1. Exact normalized path match. - if let Some((project, repo)) = path_index.get(&normalized) { - detections.push(DetectedProject { - discovered_path: disc.path.clone(), - suggested_name: disc.name.clone(), - session_count: disc.session_count, - resolution: ProjectResolution::Linked { - project_id: project.id, - project_name: project.name.clone(), - matched_repository_id: repo.id, - }, - }); - continue; - } - - // 2. Canonical path match. - let canon_norm = std::fs::canonicalize(&disc.path) - .map(|c| normalize_project_path(&c.to_string_lossy())) - .unwrap_or_default(); - if !canon_norm.is_empty() { - if let Some((project, repo)) = canonical_index.get(&canon_norm) { - detections.push(DetectedProject { - discovered_path: disc.path.clone(), - suggested_name: disc.name.clone(), - session_count: disc.session_count, - resolution: ProjectResolution::Linked { - project_id: project.id, - project_name: project.name.clone(), - matched_repository_id: repo.id, - }, - }); - continue; - } - } - - // 3. Name hint match. - let suggested_lower = derive_suggested_name(&disc.path).to_lowercase(); - if let Some(project) = name_index.get(&suggested_lower) { - // Find the primary repo or any repo to satisfy the linked variant. - let existing_repos = existing_projects - .iter() - .find(|(p, _)| p.id == project.id) - .map(|(_, repos)| repos); - if let Some(repos) = existing_repos { - if let Some(repo) = repos.iter().find(|r| r.is_primary).or(repos.first()) { - detections.push(DetectedProject { - discovered_path: disc.path.clone(), - suggested_name: disc.name.clone(), - session_count: disc.session_count, - resolution: ProjectResolution::Linked { - project_id: project.id, - project_name: project.name.clone(), - matched_repository_id: repo.id, - }, - }); - continue; - } - } - } - - // 4. No match — suggest creating. - let name = derive_suggested_name(&disc.path); - detections.push(DetectedProject { - discovered_path: disc.path.clone(), - suggested_name: disc.name.clone(), - session_count: disc.session_count, - resolution: ProjectResolution::CreateNew { name }, - }); - } - - let multi_path_hints = find_multi_path_groups(&discovered_paths); - - ProjectDetectionResult { - detections, - worktree_hints, - multi_path_hints, - } -} - -/// Derive a suggested project name from a path (last non-empty component). -fn derive_suggested_name(path: &str) -> String { - let normalized = path.replace('\\', "/"); - let trimmed = normalized.trim_end_matches('/'); - trimmed - .rsplit('/') - .next() - .unwrap_or(trimmed) - .to_string() -} - -// --------------------------------------------------------------------------- -// Project creation from import -// --------------------------------------------------------------------------- - -/// Create projects from a batch of import creation requests. -/// -/// For each request: creates the project, adds the primary repository, and -/// adds any additional repositories. Returns one result per request. -pub async fn create_projects_from_import( - store: &dyn ProjectStore, - requests: Vec, - owner: Uuid, -) -> Vec> { - let mut results = Vec::with_capacity(requests.len()); - - for req in requests { - results.push(create_single_project(store, req, owner).await); - } - - results -} - -async fn create_single_project( - store: &dyn ProjectStore, - req: ImportProjectCreationRequest, - owner: Uuid, -) -> Result { - let project = store - .create_project(CreateProjectParams { - name: req.name.clone(), - description: String::new(), - icon: req.icon, - owner, - tags: req.tags, - languages: req.languages, - metadata: serde_json::Value::Object(serde_json::Map::new()), - }) - .await?; - - let mut repos_created: usize = 0; - - // Primary repository. - store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from(&req.primary_path), - is_primary: true, - label: None, - }) - .await?; - repos_created += 1; - - // Additional repositories. - for additional in &req.additional_paths { - match store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from(additional), - is_primary: false, - label: None, - }) - .await - { - Ok(_) => repos_created += 1, - Err(e) => { - tracing::warn!( - project_id = %project.id, - path = %additional, - error = %e, - "Failed to add additional repository during import" - ); - } - } - } - - Ok(ImportProjectCreationResult { - project_id: project.id, - project_name: project.name, - repositories_created: repos_created, - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_backslashes() { - let result = normalize_project_path("C:\\Users\\alice\\project"); - assert!(result.contains('/')); - assert!(!result.contains('\\')); - } - - #[test] - fn normalize_mingw_path() { - let result = normalize_project_path("/c/Users/alice/project"); - assert!( - result.starts_with("C:/") || result.starts_with("c:/"), - "Expected drive letter prefix, got: {}", - result - ); - } - - #[test] - fn normalize_wsl_path() { - let result = normalize_project_path("/mnt/c/Users/alice/project"); - assert!( - result.starts_with("C:/") || result.starts_with("c:/"), - "Expected drive letter prefix, got: {}", - result - ); - } - - #[test] - fn normalize_strips_trailing_slash() { - let result = normalize_project_path("/home/alice/project/"); - assert!(!result.ends_with('/')); - } - - #[test] - fn normalize_resolves_dots() { - // Textual fallback since this path won't exist on disk. - let result = normalize_project_path("/home/alice/./project/../project/src"); - assert!(result.contains("/home/alice/project/src") || result.ends_with("project/src")); - } - - #[test] - fn normalize_collapses_double_slashes() { - let result = normalize_project_path("/home//alice///project"); - assert!(!result.contains("//") || result.starts_with("//")); - } - - #[test] - fn derive_suggested_name_basic() { - assert_eq!(derive_suggested_name("/home/alice/my-project"), "my-project"); - assert_eq!(derive_suggested_name("C:\\Users\\bob\\work"), "work"); - assert_eq!(derive_suggested_name("/home/alice/my-project/"), "my-project"); - } - - #[test] - fn multi_path_groups_basic() { - let paths = vec![ - "/home/alice/projects/foo".to_string(), - "/home/alice/projects/bar".to_string(), - "/home/alice/work/baz".to_string(), - ]; - let groups = find_multi_path_groups(&paths); - // foo and bar share /home/alice/projects, baz is alone under /home/alice/work - let multi = groups - .iter() - .find(|g| g.paths.len() == 2); - assert!(multi.is_some(), "Expected a group with 2 paths"); - } - - #[test] - fn detect_projects_creates_new_for_unmatched() { - let discovered = vec![DiscoveredImportProject { - name: "my-project".to_string(), - path: "/nonexistent/path/my-project".to_string(), - session_count: 5, - }]; - let existing: Vec<(Project, Vec)> = vec![]; - let result = detect_projects(&discovered, &existing); - assert_eq!(result.detections.len(), 1); - match &result.detections[0].resolution { - ProjectResolution::CreateNew { name } => { - assert_eq!(name, "my-project"); - } - other => panic!("Expected CreateNew, got {:?}", other), - } - } - - #[test] - fn detect_projects_links_by_name() { - use chrono::Utc; - let project_id = Uuid::now_v7(); - let repo_id = Uuid::now_v7(); - let now = Utc::now(); - - let project = Project { - id: project_id, - name: "dirigent".to_string(), - description: String::new(), - icon: None, - owner: Uuid::nil(), - members: vec![], - tags: vec![], - languages: vec![], - linked_projects: vec![], - metadata: serde_json::json!({}), - created_at: now, - updated_at: now, - }; - let repo = ProjectRepository { - id: repo_id, - project_id, - path: PathBuf::from("/other/path/dirigent"), - is_primary: true, - label: None, - access: dirigent_protocol::project::AccessMode::ReadWrite, - created_at: now, - updated_at: now, - }; - - let discovered = vec![DiscoveredImportProject { - name: "dirigent".to_string(), - path: "/somewhere/else/dirigent".to_string(), - session_count: 3, - }]; - let result = detect_projects(&discovered, &[(project, vec![repo])]); - assert_eq!(result.detections.len(), 1); - match &result.detections[0].resolution { - ProjectResolution::Linked { - project_id: pid, - matched_repository_id: rid, - .. - } => { - assert_eq!(*pid, project_id); - assert_eq!(*rid, repo_id); - } - other => panic!("Expected Linked, got {:?}", other), - } - } -} diff --git a/crates/dirigent_projects/src/error.rs b/crates/dirigent_projects/src/error.rs deleted file mode 100644 index 1842c5d..0000000 --- a/crates/dirigent_projects/src/error.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Error types for the Projects module. - -use thiserror::Error; -use uuid::Uuid; - -/// Errors that can occur in project operations. -#[derive(Debug, Error)] -pub enum ProjectError { - /// Project not found - #[error("project not found: {0}")] - NotFound(Uuid), - - /// Project already exists - #[error("project already exists: {0}")] - AlreadyExists(Uuid), - - /// Repository not found - #[error("repository not found: {0}")] - RepositoryNotFound(Uuid), - - /// Worktree not found - #[error("worktree not found: {0}")] - WorktreeNotFound(Uuid), - - /// Binding not found - #[error("binding not found: {0}")] - BindingNotFound(Uuid), - - /// Validation error - #[error("validation error: {0}")] - Validation(String), - - /// Storage I/O error - #[error("storage error: {0}")] - Storage(#[from] std::io::Error), - - /// Serialization error - #[error("serialization error: {0}")] - Serialization(#[from] serde_json::Error), -} - -/// Result type alias for project operations. -pub type Result = std::result::Result; diff --git a/crates/dirigent_projects/src/file_store.rs b/crates/dirigent_projects/src/file_store.rs deleted file mode 100644 index e6fe4c5..0000000 --- a/crates/dirigent_projects/src/file_store.rs +++ /dev/null @@ -1,441 +0,0 @@ -//! File-based ProjectStore implementation. -//! -//! Uses one directory per project under a configurable root. -//! Follows the archivist pattern with atomic JSON writes. - -use crate::error::{ProjectError, Result}; -use crate::params::*; -use crate::storage::io::{read_json, read_json_or_default, write_json}; -use crate::storage::paths::ProjectPaths; -use crate::traits::ProjectStore; -use chrono::Utc; -use dirigent_protocol::project::{ - AccessMode, Project, ProjectBinding, ProjectRepository, Worktree, -}; -use std::path::PathBuf; -use tracing::{debug, info}; -use uuid::Uuid; - -/// File-based project store. -/// -/// Each project gets its own directory under the root: -/// ```text -/// root/ -/// {project_uuid}/ -/// project.json -/// repositories.json (Phase 2) -/// bindings.json (Phase 5) -/// worktrees.json (Phase 4) -/// ``` -pub struct FileBasedProjectStore { - paths: ProjectPaths, -} - -impl FileBasedProjectStore { - /// Create a new file-based store at the given root directory. - /// - /// The root directory will be created if it doesn't exist. - pub async fn new(root: impl Into) -> std::io::Result { - let root = root.into(); - tokio::fs::create_dir_all(&root).await?; - Ok(Self { - paths: ProjectPaths::new(root), - }) - } - - /// Find which project owns a given repository ID. - async fn find_project_for_repo(&self, repo_id: &Uuid) -> Result { - let project_ids = self.scan_project_ids().await?; - for project_id in &project_ids { - let repos_path = self.paths.repositories_json(project_id); - let repos: Vec = read_json_or_default(&repos_path).await?; - if repos.iter().any(|r| r.id == *repo_id) { - return Ok(*project_id); - } - } - Err(ProjectError::RepositoryNotFound(*repo_id)) - } - - /// Scan the root directory for project UUIDs. - async fn scan_project_ids(&self) -> Result> { - let mut ids = Vec::new(); - let mut entries = tokio::fs::read_dir(self.paths.root()).await?; - - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await?.is_dir() { - if let Some(name) = entry.file_name().to_str() { - if let Ok(uuid) = Uuid::parse_str(name) { - ids.push(uuid); - } - } - } - } - - Ok(ids) - } -} - -#[async_trait::async_trait] -impl ProjectStore for FileBasedProjectStore { - async fn create_project(&self, params: CreateProjectParams) -> Result { - // Validate - if params.name.trim().is_empty() { - return Err(ProjectError::Validation( - "project name cannot be empty".to_string(), - )); - } - - let now = Utc::now(); - let project = Project { - id: Uuid::now_v7(), - name: params.name, - description: params.description, - icon: params.icon, - owner: params.owner, - members: vec![], - tags: params.tags, - languages: params.languages, - linked_projects: vec![], - metadata: if params.metadata.is_null() { - serde_json::json!({}) - } else { - params.metadata - }, - created_at: now, - updated_at: now, - }; - - // Create project directory - let project_dir = self.paths.project_dir(&project.id); - tokio::fs::create_dir_all(&project_dir).await?; - - // Write project.json - let path = self.paths.project_json(&project.id); - write_json(&path, &project).await?; - - info!(project_id = %project.id, name = %project.name, "Created project"); - Ok(project) - } - - async fn get_project(&self, id: &Uuid) -> Result { - let path = self.paths.project_json(id); - read_json(&path).await.map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => ProjectError::NotFound(*id), - _ => ProjectError::Storage(e), - }) - } - - async fn list_projects(&self, filter: ProjectFilter) -> Result> { - let ids = self.scan_project_ids().await?; - let mut projects = Vec::new(); - - for id in ids { - match self.get_project(&id).await { - Ok(project) => { - // Apply filters - if let Some(ref owner) = filter.owner { - if project.owner != *owner { - continue; - } - } - if let Some(ref name_contains) = filter.name_contains { - if !project - .name - .to_lowercase() - .contains(&name_contains.to_lowercase()) - { - continue; - } - } - if !filter.tags.is_empty() - && !filter.tags.iter().all(|t| project.tags.contains(t)) - { - continue; - } - projects.push(project); - } - Err(e) => { - debug!(project_id = %id, error = %e, "Skipping unreadable project"); - } - } - } - - // Sort by name for consistent ordering - projects.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(projects) - } - - async fn update_project(&self, id: &Uuid, update: ProjectUpdate) -> Result { - let mut project = self.get_project(id).await?; - - if let Some(name) = update.name { - if name.trim().is_empty() { - return Err(ProjectError::Validation( - "project name cannot be empty".to_string(), - )); - } - project.name = name; - } - if let Some(description) = update.description { - project.description = description; - } - if let Some(icon) = update.icon { - project.icon = icon; - } - if let Some(tags) = update.tags { - project.tags = tags; - } - if let Some(languages) = update.languages { - project.languages = languages; - } - if let Some(metadata) = update.metadata { - project.metadata = metadata; - } - - project.updated_at = Utc::now(); - - let path = self.paths.project_json(id); - write_json(&path, &project).await?; - - info!(project_id = %id, "Updated project"); - Ok(project) - } - - async fn delete_project(&self, id: &Uuid) -> Result<()> { - let project_dir = self.paths.project_dir(id); - if !project_dir.exists() { - return Err(ProjectError::NotFound(*id)); - } - - tokio::fs::remove_dir_all(&project_dir).await?; - info!(project_id = %id, "Deleted project"); - Ok(()) - } - - // --- Repository management (Phase 2 - scaffolded) --- - - async fn add_repository(&self, params: AddRepositoryParams) -> Result { - // Ensure project exists - let _ = self.get_project(¶ms.project_id).await?; - - let now = Utc::now(); - let repo = ProjectRepository { - id: Uuid::now_v7(), - project_id: params.project_id, - path: params.path, - is_primary: params.is_primary, - label: params.label, - access: AccessMode::ReadWrite, - created_at: now, - updated_at: now, - }; - - // Read existing repos, append, write back - let repos_path = self.paths.repositories_json(¶ms.project_id); - let mut repos: Vec = read_json_or_default(&repos_path).await?; - - // If this is primary, unset others - if repo.is_primary { - for r in repos.iter_mut() { - r.is_primary = false; - } - } - - repos.push(repo.clone()); - write_json(&repos_path, &repos).await?; - - info!(repo_id = %repo.id, project_id = %params.project_id, "Added repository"); - Ok(repo) - } - - async fn remove_repository(&self, id: &Uuid) -> Result<()> { - // Scan all projects to find the repo - let project_ids = self.scan_project_ids().await?; - for project_id in project_ids { - let repos_path = self.paths.repositories_json(&project_id); - let mut repos: Vec = read_json_or_default(&repos_path).await?; - let original_len = repos.len(); - repos.retain(|r| r.id != *id); - if repos.len() < original_len { - write_json(&repos_path, &repos).await?; - info!(repo_id = %id, "Removed repository"); - return Ok(()); - } - } - Err(ProjectError::RepositoryNotFound(*id)) - } - - async fn set_primary_repository(&self, project_id: &Uuid, repo_id: &Uuid) -> Result<()> { - let repos_path = self.paths.repositories_json(project_id); - let mut repos: Vec = read_json_or_default(&repos_path).await?; - - let mut found = false; - for r in repos.iter_mut() { - r.is_primary = r.id == *repo_id; - if r.id == *repo_id { - found = true; - } - } - - if !found { - return Err(ProjectError::RepositoryNotFound(*repo_id)); - } - - write_json(&repos_path, &repos).await?; - Ok(()) - } - - async fn list_repositories(&self, project_id: &Uuid) -> Result> { - let repos_path = self.paths.repositories_json(project_id); - Ok(read_json_or_default(&repos_path).await?) - } - - // --- Worktrees (Phase 4) --- - - async fn add_worktree(&self, params: AddWorktreeParams) -> Result { - // Find which project owns this repository - let project_id = self.find_project_for_repo(¶ms.repository_id).await?; - - let worktree = Worktree { - id: Uuid::now_v7(), - repository_id: params.repository_id, - path: params.path, - branch: params.branch, - work_branch: params.work_branch, - naming_strategy: params.naming_strategy, - created_at: Utc::now(), - }; - - let wt_path = self.paths.worktrees_json(&project_id); - let mut worktrees: Vec = read_json_or_default(&wt_path).await?; - worktrees.push(worktree.clone()); - write_json(&wt_path, &worktrees).await?; - - info!(worktree_id = %worktree.id, repo_id = %params.repository_id, "Added worktree"); - Ok(worktree) - } - - async fn remove_worktree(&self, worktree_id: &Uuid) -> Result<()> { - let project_ids = self.scan_project_ids().await?; - for project_id in project_ids { - let wt_path = self.paths.worktrees_json(&project_id); - let mut worktrees: Vec = read_json_or_default(&wt_path).await?; - let original_len = worktrees.len(); - worktrees.retain(|w| w.id != *worktree_id); - if worktrees.len() < original_len { - write_json(&wt_path, &worktrees).await?; - info!(worktree_id = %worktree_id, "Removed worktree"); - return Ok(()); - } - } - Err(ProjectError::WorktreeNotFound(*worktree_id)) - } - - async fn list_worktrees(&self, repository_id: &Uuid) -> Result> { - let project_id = self.find_project_for_repo(repository_id).await?; - let wt_path = self.paths.worktrees_json(&project_id); - let all: Vec = read_json_or_default(&wt_path).await?; - Ok(all - .into_iter() - .filter(|w| w.repository_id == *repository_id) - .collect()) - } - - async fn update_worktree( - &self, - worktree_id: &Uuid, - update: WorktreeUpdate, - ) -> Result { - let project_ids = self.scan_project_ids().await?; - for project_id in &project_ids { - let wt_path = self.paths.worktrees_json(project_id); - let mut worktrees: Vec = read_json_or_default(&wt_path).await?; - if let Some(wt) = worktrees.iter_mut().find(|w| w.id == *worktree_id) { - if let Some(branch) = update.branch { - wt.branch = branch; - } - if let Some(work_branch) = update.work_branch { - wt.work_branch = work_branch; - } - let updated = wt.clone(); - write_json(&wt_path, &worktrees).await?; - return Ok(updated); - } - } - Err(ProjectError::WorktreeNotFound(*worktree_id)) - } - - // --- Bindings (Phase 5 - scaffolded) --- - - async fn bind(&self, params: BindParams) -> Result { - let _ = self.get_project(¶ms.project_id).await?; - - let binding = ProjectBinding { - id: Uuid::now_v7(), - project_id: params.project_id, - connector_id: params.connector_id, - session_id: params.session_id, - working_dir: params.working_dir, - }; - - let bindings_path = self.paths.bindings_json(¶ms.project_id); - let mut bindings: Vec = read_json_or_default(&bindings_path).await?; - bindings.push(binding.clone()); - write_json(&bindings_path, &bindings).await?; - - Ok(binding) - } - - async fn unbind(&self, binding_id: &Uuid) -> Result<()> { - let project_ids = self.scan_project_ids().await?; - for project_id in project_ids { - let bindings_path = self.paths.bindings_json(&project_id); - let mut bindings: Vec = read_json_or_default(&bindings_path).await?; - let original_len = bindings.len(); - bindings.retain(|b| b.id != *binding_id); - if bindings.len() < original_len { - write_json(&bindings_path, &bindings).await?; - return Ok(()); - } - } - Err(ProjectError::BindingNotFound(*binding_id)) - } - - async fn list_bindings(&self, project_id: &Uuid) -> Result> { - let bindings_path = self.paths.bindings_json(project_id); - Ok(read_json_or_default(&bindings_path).await?) - } - - // --- Resolution (Phase 2 - scaffolded) --- - - async fn resolve_working_dir( - &self, - project_id: &Uuid, - repo_id: Option<&Uuid>, - ) -> Result { - // Verify the project exists before falling back to default_working_dir. - // Without this, a missing project directory yields an empty repo list - // (via read_json_or_default) and silently falls through to the default. - self.get_project(project_id).await?; - - let repos = self.list_repositories(project_id).await?; - - // If repo_id specified, use that - if let Some(rid) = repo_id { - if let Some(repo) = repos.iter().find(|r| r.id == *rid) { - return Ok(repo.path.clone()); - } - return Err(ProjectError::RepositoryNotFound(*rid)); - } - - // Use primary repo, or first repo - if let Some(repo) = repos.iter().find(|r| r.is_primary).or(repos.first()) { - return Ok(repo.path.clone()); - } - - Err(ProjectError::Validation(format!( - "project {} has no repositories configured", - project_id - ))) - } -} diff --git a/crates/dirigent_projects/src/git/mod.rs b/crates/dirigent_projects/src/git/mod.rs deleted file mode 100644 index 6217ef0..0000000 --- a/crates/dirigent_projects/src/git/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Git integration. -//! -//! - `GitRunner` executes git commands against a local repository -//! - `compute_git_state()` aggregates runner output into a `GitState` -//! - Worktree workflows (follow/take) for branch management - -pub mod runner; -pub mod state; -pub mod worktree; - -pub use runner::GitRunner; -pub use state::compute_git_state; -pub use worktree::{follow, take}; diff --git a/crates/dirigent_projects/src/git/runner.rs b/crates/dirigent_projects/src/git/runner.rs deleted file mode 100644 index dc63abb..0000000 --- a/crates/dirigent_projects/src/git/runner.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Git command runner. -//! -//! Wraps `tokio::process::Command` to execute git operations on a local -//! repository path. All methods return structured results with proper -//! error handling for git-not-installed, not-a-repo, etc. - -use crate::error::{ProjectError, Result}; -use std::path::{Path, PathBuf}; -use tokio::process::Command; - -/// Executes git commands against a local repository. -#[derive(Clone, Debug)] -pub struct GitRunner { - repo_path: PathBuf, -} - -/// Parsed output of `git status --porcelain=v2 --branch`. -#[derive(Clone, Debug, Default)] -pub struct GitStatus { - /// Current branch (empty if detached HEAD) - pub branch: String, - /// Whether there are uncommitted changes - pub is_dirty: bool, - /// Commits ahead of upstream - pub ahead: u32, - /// Commits behind upstream - pub behind: u32, -} - -/// Parsed output of `git worktree list --porcelain`. -#[derive(Clone, Debug)] -pub struct WorktreeEntry { - /// Worktree filesystem path - pub path: PathBuf, - /// Branch checked out (None if detached) - pub branch: Option, - /// Whether HEAD is detached - pub is_detached: bool, - /// Whether this is a bare repository worktree - pub is_bare: bool, -} - -impl GitRunner { - /// Create a new runner for the given repository path. - pub fn new(repo_path: impl Into) -> Self { - Self { - repo_path: repo_path.into(), - } - } - - /// Path this runner operates on. - pub fn repo_path(&self) -> &Path { - &self.repo_path - } - - /// Get the current branch name. - /// - /// Returns empty string if HEAD is detached. - pub async fn current_branch(&self) -> Result { - let output = self.git(&["rev-parse", "--abbrev-ref", "HEAD"]).await?; - let branch = output.trim().to_string(); - // rev-parse returns "HEAD" when detached - if branch == "HEAD" { - Ok(String::new()) - } else { - Ok(branch) - } - } - - /// Get the current status (branch, dirty, ahead/behind). - pub async fn status(&self) -> Result { - let output = self.git(&["status", "--porcelain=v2", "--branch"]).await?; - parse_status(&output) - } - - /// List remote names. - pub async fn remotes(&self) -> Result> { - let output = self.git(&["remote"]).await?; - Ok(output - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect()) - } - - /// Fetch from a remote (defaults to "origin"). - pub async fn fetch(&self, remote: Option<&str>) -> Result<()> { - let remote = remote.unwrap_or("origin"); - self.git(&["fetch", remote, "--quiet"]).await?; - Ok(()) - } - - /// List worktrees via `git worktree list --porcelain`. - pub async fn worktree_list(&self) -> Result> { - let output = self.git(&["worktree", "list", "--porcelain"]).await?; - Ok(parse_worktree_list(&output)) - } - - /// Add a worktree at the given path for the given branch. - pub async fn worktree_add(&self, path: &Path, branch: &str) -> Result<()> { - self.git(&["worktree", "add", &path.to_string_lossy(), branch]) - .await?; - Ok(()) - } - - /// Remove a worktree at the given path. - pub async fn worktree_remove(&self, path: &Path, force: bool) -> Result<()> { - let path_str = path.to_string_lossy(); - let mut args = vec!["worktree", "remove", &*path_str]; - if force { - args.push("--force"); - } - self.git(&args).await?; - Ok(()) - } - - /// Checkout a branch. - pub async fn checkout(&self, branch: &str) -> Result<()> { - self.git(&["checkout", branch]).await?; - Ok(()) - } - - /// Commit staged changes with the given message. Returns the commit hash. - pub async fn commit(&self, message: &str) -> Result { - self.git(&["commit", "-m", message]).await?; - let hash = self.git(&["rev-parse", "HEAD"]).await?; - Ok(hash.trim().to_string()) - } - - /// Squash-merge from a source branch. - pub async fn merge_squash(&self, source_branch: &str) -> Result<()> { - self.git(&["merge", "--squash", source_branch]).await?; - Ok(()) - } - - /// Hard-reset to a target ref. - pub async fn reset_hard(&self, target: &str) -> Result<()> { - self.git(&["reset", "--hard", target]).await?; - Ok(()) - } - - // ======================================================================== - // Internal helpers - // ======================================================================== - - /// Execute a git command and return stdout on success. - async fn git(&self, args: &[&str]) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(&self.repo_path) - .output() - .await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - ProjectError::Validation("git is not installed or not in PATH".into()) - } else { - ProjectError::Storage(e) - } - })?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Err(ProjectError::Validation(format!( - "git {} failed: {}", - args.first().unwrap_or(&""), - stderr.trim() - ))) - } - } -} - -// ============================================================================ -// Parsers -// ============================================================================ - -/// Parse `git status --porcelain=v2 --branch` output. -fn parse_status(output: &str) -> Result { - let mut status = GitStatus::default(); - - for line in output.lines() { - if let Some(rest) = line.strip_prefix("# branch.head ") { - status.branch = rest.trim().to_string(); - if status.branch == "(detached)" { - status.branch = String::new(); - } - } else if let Some(rest) = line.strip_prefix("# branch.ab ") { - // Format: "+N -M" - for part in rest.split_whitespace() { - if let Some(ahead) = part.strip_prefix('+') { - status.ahead = ahead.parse().unwrap_or(0); - } else if let Some(behind) = part.strip_prefix('-') { - status.behind = behind.parse().unwrap_or(0); - } - } - } else if line.starts_with('1') - || line.starts_with('2') - || line.starts_with('u') - || line.starts_with('?') - { - // Any tracked/untracked change means dirty - status.is_dirty = true; - } - } - - Ok(status) -} - -/// Parse `git worktree list --porcelain` output. -/// -/// Porcelain format outputs blocks separated by blank lines: -/// ```text -/// worktree /path/to/main -/// HEAD abc123 -/// branch refs/heads/main -/// -/// worktree /path/to/feature -/// HEAD def456 -/// branch refs/heads/feature -/// ``` -fn parse_worktree_list(output: &str) -> Vec { - let mut entries = Vec::new(); - let mut current_path: Option = None; - let mut current_branch: Option = None; - let mut is_detached = false; - let mut is_bare = false; - - for line in output.lines() { - if line.is_empty() { - // End of block — flush - if let Some(path) = current_path.take() { - entries.push(WorktreeEntry { - path, - branch: current_branch.take(), - is_detached, - is_bare, - }); - } - is_detached = false; - is_bare = false; - } else if let Some(rest) = line.strip_prefix("worktree ") { - current_path = Some(PathBuf::from(rest)); - } else if let Some(rest) = line.strip_prefix("branch ") { - // Strip refs/heads/ prefix - current_branch = Some(rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string()); - } else if line == "detached" { - is_detached = true; - } else if line == "bare" { - is_bare = true; - } - } - - // Flush last block (output may not end with blank line) - if let Some(path) = current_path.take() { - entries.push(WorktreeEntry { - path, - branch: current_branch.take(), - is_detached, - is_bare, - }); - } - - entries -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_status_clean() { - let output = "# branch.head main\n# branch.ab +0 -0\n"; - let status = parse_status(output).unwrap(); - assert_eq!(status.branch, "main"); - assert!(!status.is_dirty); - assert_eq!(status.ahead, 0); - assert_eq!(status.behind, 0); - } - - #[test] - fn test_parse_status_dirty_ahead_behind() { - let output = "\ -# branch.head feature -# branch.ab +3 -1 -1 .M N... 100644 100644 100644 abc123 def456 src/main.rs -? new_file.txt -"; - let status = parse_status(output).unwrap(); - assert_eq!(status.branch, "feature"); - assert!(status.is_dirty); - assert_eq!(status.ahead, 3); - assert_eq!(status.behind, 1); - } - - #[test] - fn test_parse_status_detached() { - let output = "# branch.head (detached)\n"; - let status = parse_status(output).unwrap(); - assert_eq!(status.branch, ""); - } - - #[test] - fn test_parse_worktree_list() { - let output = "\ -worktree /home/user/project -HEAD abc123def456 -branch refs/heads/main - -worktree /home/user/project-feature -HEAD 789012345678 -branch refs/heads/feature - -worktree /home/user/project-detached -HEAD aabbccdd -detached - -"; - let entries = parse_worktree_list(output); - assert_eq!(entries.len(), 3); - - assert_eq!(entries[0].path, PathBuf::from("/home/user/project")); - assert_eq!(entries[0].branch, Some("main".to_string())); - assert!(!entries[0].is_detached); - - assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature")); - assert_eq!(entries[1].branch, Some("feature".to_string())); - assert!(!entries[1].is_detached); - - assert_eq!( - entries[2].path, - PathBuf::from("/home/user/project-detached") - ); - assert!(entries[2].branch.is_none()); - assert!(entries[2].is_detached); - } - - #[test] - fn test_parse_worktree_list_no_trailing_newline() { - let output = "worktree /repo\nHEAD abc\nbranch refs/heads/main"; - let entries = parse_worktree_list(output); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].branch, Some("main".to_string())); - } - - #[test] - fn test_parse_worktree_list_bare() { - let output = "worktree /repo.git\nHEAD abc\nbare\n\n"; - let entries = parse_worktree_list(output); - assert_eq!(entries.len(), 1); - assert!(entries[0].is_bare); - } -} diff --git a/crates/dirigent_projects/src/git/state.rs b/crates/dirigent_projects/src/git/state.rs deleted file mode 100644 index 61b8dd9..0000000 --- a/crates/dirigent_projects/src/git/state.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! GitState computation from GitRunner output. -//! -//! Aggregates branch, status, remotes, and worktrees into a single -//! `GitState` struct with graceful degradation via `GitWarning`. - -use crate::git::runner::GitRunner; -use dirigent_protocol::project::{GitState, GitWarning, WorktreeInfo}; - -/// Compute the full git state for a repository. -/// -/// Calls branch, status, remotes, and worktree_list. Any individual -/// failure is captured as a `GitWarning` rather than failing the whole -/// computation. -pub async fn compute_git_state(runner: &GitRunner) -> GitState { - let mut state = GitState::default(); - let mut warnings = Vec::new(); - - // Status (includes branch + dirty + ahead/behind) - match runner.status().await { - Ok(status) => { - state.branch = status.branch; - state.is_dirty = status.is_dirty; - state.ahead = status.ahead; - state.behind = status.behind; - } - Err(e) => { - warnings.push(GitWarning { - code: "status_failed".to_string(), - message: format!("Failed to get git status: {e}"), - }); - // Try branch separately as fallback - match runner.current_branch().await { - Ok(branch) => state.branch = branch, - Err(e) => { - warnings.push(GitWarning { - code: "branch_failed".to_string(), - message: format!("Failed to get current branch: {e}"), - }); - } - } - } - } - - // Remotes - match runner.remotes().await { - Ok(remotes) => state.remotes = remotes, - Err(e) => { - warnings.push(GitWarning { - code: "remotes_failed".to_string(), - message: format!("Failed to list remotes: {e}"), - }); - } - } - - // Worktrees - match runner.worktree_list().await { - Ok(entries) => { - state.worktrees = entries - .into_iter() - .map(|e| WorktreeInfo { - path: e.path, - branch: e.branch, - is_detached: e.is_detached, - }) - .collect(); - } - Err(e) => { - warnings.push(GitWarning { - code: "worktrees_failed".to_string(), - message: format!("Failed to list worktrees: {e}"), - }); - } - } - - state.unexpected = warnings; - state -} diff --git a/crates/dirigent_projects/src/git/worktree.rs b/crates/dirigent_projects/src/git/worktree.rs deleted file mode 100644 index 9cc1ab2..0000000 --- a/crates/dirigent_projects/src/git/worktree.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Worktree workflow implementations. -//! -//! - **follow**: Hard-reset a work branch to track a target branch (e.g. main) -//! - **take**: Squash-merge changes from a worktree branch into a target branch - -use crate::error::{ProjectError, Result}; -use crate::git::runner::GitRunner; - -/// Follow workflow: hard-reset work_branch to match target_branch. -/// -/// This is used when a worktree's work branch needs to catch up with -/// the main branch. After this operation, work_branch HEAD will be -/// identical to target_branch HEAD. -/// -/// **Destructive**: Discards any uncommitted changes on work_branch. -pub async fn follow(runner: &GitRunner, work_branch: &str, target_branch: &str) -> Result<()> { - // Ensure we're on the work branch - let current = runner.current_branch().await?; - if current != work_branch { - runner.checkout(work_branch).await?; - } - - runner.reset_hard(target_branch).await?; - Ok(()) -} - -/// Take workflow: squash-merge changes from source_branch into target_branch. -/// -/// This brings all the work from source_branch into target_branch as a -/// single commit. If `auto_commit` is true, the squash is committed -/// automatically with a generated message. -/// -/// Returns the commit hash if auto_commit is true, None otherwise -/// (leaving the changes staged for manual commit). -pub async fn take( - runner: &GitRunner, - source_branch: &str, - target_branch: &str, - auto_commit: bool, -) -> Result> { - // Switch to target branch - let current = runner.current_branch().await?; - if current != target_branch { - runner.checkout(target_branch).await?; - } - - // Squash-merge - runner.merge_squash(source_branch).await.map_err(|e| { - ProjectError::Validation(format!( - "squash-merge from '{}' into '{}' failed: {}", - source_branch, target_branch, e - )) - })?; - - if auto_commit { - let message = format!("Squash merge from {}", source_branch); - let hash = runner.commit(&message).await?; - Ok(Some(hash)) - } else { - Ok(None) - } -} diff --git a/crates/dirigent_projects/src/lib.rs b/crates/dirigent_projects/src/lib.rs deleted file mode 100644 index b47d98e..0000000 --- a/crates/dirigent_projects/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Dirigent Projects -//! -//! Project management crate for the Dirigent system. Trait-based, -//! file-backed, async-first (following the archivist pattern). -//! -//! # Architecture -//! -//! - `ProjectStore` trait defines the storage interface -//! - `FileBasedProjectStore` implements file-backed persistence -//! - Storage uses one directory per project with atomic JSON writes -//! - Protocol types from `dirigent_protocol::project` are shared with WASM -//! -//! # Phases -//! -//! - Phase 1: Project CRUD (implemented) -//! - Phase 2: Repository management, working dir resolution (scaffolded) -//! - Phase 3: Git integration (scaffolded) -//! - Phase 4: Worktree support (scaffolded) -//! - Phase 5: Bindings (scaffolded) - -pub mod detection; -pub mod error; -pub mod file_store; -pub mod git; -pub mod params; -pub mod storage; -pub mod traits; - -// Re-export commonly used types -pub use error::{ProjectError, Result}; -pub use file_store::FileBasedProjectStore; -pub use params::{ - AddRepositoryParams, AddWorktreeParams, BindParams, CreateProjectParams, ProjectFilter, - ProjectUpdate, WorktreeUpdate, -}; -pub use traits::ProjectStore; - -// Re-export detection types -pub use detection::{ - create_projects_from_import, detect_projects, detect_worktree, find_multi_path_groups, - normalize_project_path, DetectedProject, DiscoveredImportProject, - ImportProjectCreationRequest, ImportProjectCreationResult, MultiPathHint, - ProjectDetectionResult, ProjectResolution, WorktreeHint, -}; diff --git a/crates/dirigent_projects/src/params.rs b/crates/dirigent_projects/src/params.rs deleted file mode 100644 index fe7a483..0000000 --- a/crates/dirigent_projects/src/params.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Parameter types for project store operations. - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use uuid::Uuid; - -/// Parameters for creating a new project. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CreateProjectParams { - /// Human-readable project name - pub name: String, - /// Project description - #[serde(default)] - pub description: String, - /// Optional icon (emoji or abbreviation) - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, - /// Owner user ID - pub owner: Uuid, - /// Initial tags - #[serde(default)] - pub tags: Vec, - /// Initial languages - #[serde(default)] - pub languages: Vec, - /// Arbitrary metadata - #[serde(default)] - pub metadata: serde_json::Value, -} - -/// Filter for listing projects. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct ProjectFilter { - /// Filter by owner - #[serde(skip_serializing_if = "Option::is_none")] - pub owner: Option, - /// Filter by tag (project must have all specified tags) - #[serde(default)] - pub tags: Vec, - /// Filter by name substring (case-insensitive) - #[serde(skip_serializing_if = "Option::is_none")] - pub name_contains: Option, -} - -/// Fields to update on a project. -/// -/// Only `Some` fields are applied; `None` fields are left unchanged. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct ProjectUpdate { - /// New name - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// New description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// New icon - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option>, - /// New tags (replaces all) - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, - /// New languages (replaces all) - #[serde(skip_serializing_if = "Option::is_none")] - pub languages: Option>, - /// New metadata (replaces all) - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -/// Parameters for adding a repository to a project. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct AddRepositoryParams { - /// Project to add the repository to - pub project_id: Uuid, - /// Local filesystem path - pub path: PathBuf, - /// Whether this is the primary repository - #[serde(default)] - pub is_primary: bool, - /// Optional human-readable label - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option, -} - -/// Parameters for adding a worktree to a repository. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct AddWorktreeParams { - /// Repository this worktree belongs to - pub repository_id: Uuid, - /// Local filesystem path for the worktree - pub path: PathBuf, - /// Branch name - pub branch: String, - /// Optional work branch name - #[serde(skip_serializing_if = "Option::is_none")] - pub work_branch: Option, - /// Optional naming strategy - #[serde(skip_serializing_if = "Option::is_none")] - pub naming_strategy: Option, -} - -/// Fields to update on a worktree. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct WorktreeUpdate { - /// New branch - #[serde(skip_serializing_if = "Option::is_none")] - pub branch: Option, - /// New work branch - #[serde(skip_serializing_if = "Option::is_none")] - pub work_branch: Option>, -} - -/// Parameters for creating a project binding. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BindParams { - /// Project to bind - pub project_id: Uuid, - /// Optional connector ID - #[serde(skip_serializing_if = "Option::is_none")] - pub connector_id: Option, - /// Optional session ID - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - /// Optional working directory override - #[serde(skip_serializing_if = "Option::is_none")] - pub working_dir: Option, -} diff --git a/crates/dirigent_projects/src/storage/io.rs b/crates/dirigent_projects/src/storage/io.rs deleted file mode 100644 index 143a951..0000000 --- a/crates/dirigent_projects/src/storage/io.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! JSON read/write helpers with atomic writes. -//! -//! Follows the archivist pattern: write to .tmp, then rename. - -use serde::{Deserialize, Serialize}; -use std::path::Path; -use tokio::io::AsyncWriteExt; - -/// Write a value to a JSON file atomically. -/// -/// 1. Serializes to pretty-printed JSON -/// 2. Writes to `{path}.tmp` -/// 3. Renames temp file to target (atomic on most filesystems) -pub async fn write_json(path: &Path, value: &T) -> std::io::Result<()> { - let json = serde_json::to_string_pretty(value) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - let temp_path = path.with_extension("tmp"); - - let mut file = tokio::fs::File::create(&temp_path).await?; - file.write_all(json.as_bytes()).await?; - file.sync_all().await?; - drop(file); - - tokio::fs::rename(&temp_path, path).await?; - - Ok(()) -} - -/// Read a value from a JSON file. -/// -/// Returns `NotFound` if the file doesn't exist. -pub async fn read_json Deserialize<'de>>(path: &Path) -> std::io::Result { - let content = tokio::fs::read_to_string(path).await?; - let value: T = serde_json::from_str(&content) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - Ok(value) -} - -/// Read a value from a JSON file, returning a default if the file doesn't exist. -pub async fn read_json_or_default Deserialize<'de> + Default>( - path: &Path, -) -> std::io::Result { - match read_json(path).await { - Ok(value) => Ok(value), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()), - Err(e) => Err(e), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::{Deserialize, Serialize}; - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - struct TestData { - id: String, - value: i32, - } - - #[tokio::test] - async fn test_write_and_read_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.json"); - - let data = TestData { - id: "test".to_string(), - value: 42, - }; - - write_json(&path, &data).await.unwrap(); - let read: TestData = read_json(&path).await.unwrap(); - assert_eq!(read, data); - } - - #[tokio::test] - async fn test_read_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("missing.json"); - - let result: std::io::Result = read_json(&path).await; - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); - } - - #[tokio::test] - async fn test_read_json_or_default() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("missing.json"); - - let result: Vec = read_json_or_default(&path).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn test_atomic_overwrite() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.json"); - - let data1 = TestData { - id: "first".to_string(), - value: 1, - }; - let data2 = TestData { - id: "second".to_string(), - value: 2, - }; - - write_json(&path, &data1).await.unwrap(); - write_json(&path, &data2).await.unwrap(); - - let read: TestData = read_json(&path).await.unwrap(); - assert_eq!(read, data2); - - // Temp file should not remain - assert!(!path.with_extension("tmp").exists()); - } -} diff --git a/crates/dirigent_projects/src/storage/mod.rs b/crates/dirigent_projects/src/storage/mod.rs deleted file mode 100644 index 726de22..0000000 --- a/crates/dirigent_projects/src/storage/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Storage layer for file-based project persistence. -//! -//! Follows the archivist pattern: atomic writes, JSON files, directory-per-project. - -pub mod io; -pub mod paths; diff --git a/crates/dirigent_projects/src/storage/paths.rs b/crates/dirigent_projects/src/storage/paths.rs deleted file mode 100644 index 1c91786..0000000 --- a/crates/dirigent_projects/src/storage/paths.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Path conventions for project storage. - -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -/// Path helper for the projects storage root. -pub struct ProjectPaths { - root: PathBuf, -} - -impl ProjectPaths { - /// Create a new path helper. - pub fn new(root: impl Into) -> Self { - Self { root: root.into() } - } - - /// Root directory for all projects. - pub fn root(&self) -> &Path { - &self.root - } - - /// Directory for a specific project. - pub fn project_dir(&self, project_id: &Uuid) -> PathBuf { - self.root.join(project_id.to_string()) - } - - /// Path to the project metadata JSON file. - pub fn project_json(&self, project_id: &Uuid) -> PathBuf { - self.project_dir(project_id).join("project.json") - } - - /// Path to the repositories JSON file (Phase 2). - pub fn repositories_json(&self, project_id: &Uuid) -> PathBuf { - self.project_dir(project_id).join("repositories.json") - } - - /// Path to the bindings JSON file (Phase 5). - pub fn bindings_json(&self, project_id: &Uuid) -> PathBuf { - self.project_dir(project_id).join("bindings.json") - } - - /// Path to the worktrees JSON file (Phase 4). - pub fn worktrees_json(&self, project_id: &Uuid) -> PathBuf { - self.project_dir(project_id).join("worktrees.json") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_project_paths() { - let paths = ProjectPaths::new("/data/projects"); - let id = Uuid::nil(); - - assert_eq!( - paths.project_dir(&id), - PathBuf::from("/data/projects/00000000-0000-0000-0000-000000000000") - ); - assert_eq!( - paths.project_json(&id), - PathBuf::from("/data/projects/00000000-0000-0000-0000-000000000000/project.json") - ); - } -} diff --git a/crates/dirigent_projects/src/traits.rs b/crates/dirigent_projects/src/traits.rs deleted file mode 100644 index 089cfab..0000000 --- a/crates/dirigent_projects/src/traits.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! ProjectStore trait definition. - -use crate::error::Result; -use crate::params::*; -use dirigent_protocol::project::{Project, ProjectBinding, ProjectRepository, Worktree}; -use std::path::PathBuf; -use uuid::Uuid; - -/// Trait for project storage backends. -/// -/// Async-first, trait-object safe. Implementations must be Send + Sync. -/// Phase 1 implements project CRUD. Later phases add repository management, -/// bindings, and resolution. -#[async_trait::async_trait] -pub trait ProjectStore: Send + Sync { - // --- Project CRUD (Phase 1) --- - - /// Create a new project. - async fn create_project(&self, params: CreateProjectParams) -> Result; - - /// Get a project by ID. - async fn get_project(&self, id: &Uuid) -> Result; - - /// List projects matching a filter. - async fn list_projects(&self, filter: ProjectFilter) -> Result>; - - /// Update a project's fields. - async fn update_project(&self, id: &Uuid, update: ProjectUpdate) -> Result; - - /// Delete a project and all associated data. - async fn delete_project(&self, id: &Uuid) -> Result<()>; - - // --- Repository management (Phase 2) --- - - /// Add a repository to a project. - async fn add_repository(&self, params: AddRepositoryParams) -> Result; - - /// Remove a repository. - async fn remove_repository(&self, id: &Uuid) -> Result<()>; - - /// Set a repository as the primary for its project. - async fn set_primary_repository(&self, project_id: &Uuid, repo_id: &Uuid) -> Result<()>; - - /// List repositories for a project. - async fn list_repositories(&self, project_id: &Uuid) -> Result>; - - // --- Worktrees (Phase 4) --- - - /// Add a worktree record to a repository. - async fn add_worktree(&self, params: AddWorktreeParams) -> Result; - - /// Remove a worktree record. - async fn remove_worktree(&self, worktree_id: &Uuid) -> Result<()>; - - /// List worktree records for a repository. - async fn list_worktrees(&self, repository_id: &Uuid) -> Result>; - - /// Update a worktree record. - async fn update_worktree(&self, worktree_id: &Uuid, update: WorktreeUpdate) - -> Result; - - // --- Bindings (Phase 5) --- - - /// Bind a project to a connector/session. - async fn bind(&self, params: BindParams) -> Result; - - /// Remove a binding. - async fn unbind(&self, binding_id: &Uuid) -> Result<()>; - - /// List bindings for a project. - async fn list_bindings(&self, project_id: &Uuid) -> Result>; - - // --- Resolution (Phase 2) --- - - /// Resolve the working directory for a project. - async fn resolve_working_dir( - &self, - project_id: &Uuid, - repo_id: Option<&Uuid>, - ) -> Result; -} diff --git a/crates/dirigent_projects/tests/git_tests.rs b/crates/dirigent_projects/tests/git_tests.rs deleted file mode 100644 index 65942ff..0000000 --- a/crates/dirigent_projects/tests/git_tests.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Integration tests for the git module. -//! -//! These tests create real temporary git repos and exercise GitRunner -//! and compute_git_state against them. Marked `#[ignore]` by default -//! since they require `git` to be installed. - -use dirigent_projects::git::{compute_git_state, GitRunner}; -use std::path::Path; -use tokio::process::Command; - -/// Helper: initialize a git repo in the given directory with an initial commit. -async fn init_repo(dir: &Path) { - run(dir, &["git", "init"]).await; - run(dir, &["git", "config", "user.email", "test@test.com"]).await; - run(dir, &["git", "config", "user.name", "Test"]).await; - // Create an initial commit so HEAD exists - let file = dir.join("README.md"); - tokio::fs::write(&file, "# Test\n").await.unwrap(); - run(dir, &["git", "add", "."]).await; - run(dir, &["git", "commit", "-m", "Initial commit"]).await; -} - -async fn run(dir: &Path, args: &[&str]) { - let status = Command::new(args[0]) - .args(&args[1..]) - .current_dir(dir) - .output() - .await - .unwrap_or_else(|e| panic!("Failed to run {:?}: {e}", args)); - assert!( - status.status.success(), - "{:?} failed: {}", - args, - String::from_utf8_lossy(&status.stderr) - ); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_current_branch() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let runner = GitRunner::new(dir.path()); - let branch = runner.current_branch().await.unwrap(); - // Default branch may be "main" or "master" depending on git config - assert!( - branch == "main" || branch == "master", - "unexpected branch: {branch}" - ); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_status_clean() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let runner = GitRunner::new(dir.path()); - let status = runner.status().await.unwrap(); - assert!(!status.is_dirty); - assert_eq!(status.ahead, 0); - assert_eq!(status.behind, 0); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_status_dirty() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - // Create an untracked file - tokio::fs::write(dir.path().join("dirty.txt"), "dirty") - .await - .unwrap(); - - let runner = GitRunner::new(dir.path()); - let status = runner.status().await.unwrap(); - assert!(status.is_dirty); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_remotes_empty() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let runner = GitRunner::new(dir.path()); - let remotes = runner.remotes().await.unwrap(); - assert!(remotes.is_empty()); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_worktree_list_single() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let runner = GitRunner::new(dir.path()); - let worktrees = runner.worktree_list().await.unwrap(); - // A non-bare repo always has at least the main worktree - assert_eq!(worktrees.len(), 1); - assert!(worktrees[0].branch.is_some()); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_worktree_add_and_list() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let wt_path = dir.path().join("wt-feature"); - // Create a branch first - run(dir.path(), &["git", "branch", "feature"]).await; - - let runner = GitRunner::new(dir.path()); - runner.worktree_add(&wt_path, "feature").await.unwrap(); - - let worktrees = runner.worktree_list().await.unwrap(); - assert_eq!(worktrees.len(), 2); - - // Find the feature worktree by branch name (paths may differ due to symlink canonicalization) - let feature_wt = worktrees - .iter() - .find(|w| w.branch.as_deref() == Some("feature")) - .expect("should find worktree with branch 'feature'"); - assert!(!feature_wt.is_detached); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_compute_git_state() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - // Make it dirty - tokio::fs::write(dir.path().join("new.txt"), "content") - .await - .unwrap(); - - let runner = GitRunner::new(dir.path()); - let state = compute_git_state(&runner).await; - - assert!(!state.branch.is_empty()); - assert!(state.is_dirty); - assert!( - state.unexpected.is_empty(), - "unexpected warnings: {:?}", - state.unexpected - ); - // Should have at least the main worktree - assert!(!state.worktrees.is_empty()); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_graceful_degradation_not_a_repo() { - let dir = tempfile::tempdir().unwrap(); - // Don't init — not a git repo - - let runner = GitRunner::new(dir.path()); - let state = compute_git_state(&runner).await; - - // Should have warnings, not panic - assert!(!state.unexpected.is_empty()); -} - -#[tokio::test] -#[ignore = "requires git"] -async fn test_commit_returns_hash() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()).await; - - let file = dir.path().join("commit_test.txt"); - tokio::fs::write(&file, "data").await.unwrap(); - run(dir.path(), &["git", "add", "."]).await; - - let runner = GitRunner::new(dir.path()); - let hash = runner.commit("test commit").await.unwrap(); - - // SHA-1 hash is 40 hex chars - assert_eq!(hash.len(), 40, "unexpected hash: {hash}"); - assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); -} diff --git a/crates/dirigent_projects/tests/project_lifecycle.rs b/crates/dirigent_projects/tests/project_lifecycle.rs deleted file mode 100644 index c1c6f0b..0000000 --- a/crates/dirigent_projects/tests/project_lifecycle.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Integration tests for project CRUD lifecycle. - -use dirigent_projects::{ - CreateProjectParams, FileBasedProjectStore, ProjectFilter, ProjectStore, ProjectUpdate, -}; -use uuid::Uuid; - -async fn make_store() -> FileBasedProjectStore { - let dir = tempfile::tempdir().unwrap(); - FileBasedProjectStore::new(dir.into_path()).await.unwrap() -} - -#[tokio::test] -async fn test_create_and_get_project() { - let store = make_store().await; - let owner = Uuid::now_v7(); - - let project = store - .create_project(CreateProjectParams { - name: "Test Project".to_string(), - description: "A test".to_string(), - icon: Some("🚀".to_string()), - owner, - tags: vec!["rust".to_string()], - languages: vec!["Rust".to_string()], - metadata: serde_json::json!({}), - }) - .await - .unwrap(); - - assert_eq!(project.name, "Test Project"); - assert_eq!(project.owner, owner); - - let fetched = store.get_project(&project.id).await.unwrap(); - assert_eq!(fetched.id, project.id); - assert_eq!(fetched.name, "Test Project"); - assert_eq!(fetched.icon, Some("🚀".to_string())); -} - -#[tokio::test] -async fn test_list_projects_empty() { - let store = make_store().await; - let projects = store.list_projects(ProjectFilter::default()).await.unwrap(); - assert!(projects.is_empty()); -} - -#[tokio::test] -async fn test_list_projects_with_filter() { - let store = make_store().await; - let owner1 = Uuid::now_v7(); - let owner2 = Uuid::now_v7(); - - store - .create_project(CreateProjectParams { - name: "Alpha".to_string(), - owner: owner1, - tags: vec!["web".to_string()], - ..default_params() - }) - .await - .unwrap(); - - store - .create_project(CreateProjectParams { - name: "Beta".to_string(), - owner: owner2, - tags: vec!["cli".to_string()], - ..default_params() - }) - .await - .unwrap(); - - // Filter by owner - let filtered = store - .list_projects(ProjectFilter { - owner: Some(owner1), - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].name, "Alpha"); - - // Filter by name - let filtered = store - .list_projects(ProjectFilter { - name_contains: Some("bet".to_string()), - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].name, "Beta"); - - // Filter by tag - let filtered = store - .list_projects(ProjectFilter { - tags: vec!["web".to_string()], - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].name, "Alpha"); - - // No filter returns all, sorted by name - let all = store.list_projects(ProjectFilter::default()).await.unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all[0].name, "Alpha"); - assert_eq!(all[1].name, "Beta"); -} - -#[tokio::test] -async fn test_update_project() { - let store = make_store().await; - - let project = store - .create_project(CreateProjectParams { - name: "Original".to_string(), - ..default_params() - }) - .await - .unwrap(); - - let updated = store - .update_project( - &project.id, - ProjectUpdate { - name: Some("Renamed".to_string()), - description: Some("New description".to_string()), - tags: Some(vec!["new-tag".to_string()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(updated.name, "Renamed"); - assert_eq!(updated.description, "New description"); - assert_eq!(updated.tags, vec!["new-tag"]); - assert!(updated.updated_at > project.created_at); - - // Verify persistence - let fetched = store.get_project(&project.id).await.unwrap(); - assert_eq!(fetched.name, "Renamed"); -} - -#[tokio::test] -async fn test_delete_project() { - let store = make_store().await; - - let project = store - .create_project(CreateProjectParams { - name: "ToDelete".to_string(), - ..default_params() - }) - .await - .unwrap(); - - store.delete_project(&project.id).await.unwrap(); - - let err = store.get_project(&project.id).await.unwrap_err(); - assert!(matches!(err, dirigent_projects::ProjectError::NotFound(_))); -} - -#[tokio::test] -async fn test_get_nonexistent_project() { - let store = make_store().await; - let err = store.get_project(&Uuid::now_v7()).await.unwrap_err(); - assert!(matches!(err, dirigent_projects::ProjectError::NotFound(_))); -} - -#[tokio::test] -async fn test_create_empty_name_fails() { - let store = make_store().await; - let err = store - .create_project(CreateProjectParams { - name: " ".to_string(), - ..default_params() - }) - .await - .unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::Validation(_) - )); -} - -#[tokio::test] -async fn test_update_empty_name_fails() { - let store = make_store().await; - let project = store - .create_project(CreateProjectParams { - name: "Valid".to_string(), - ..default_params() - }) - .await - .unwrap(); - - let err = store - .update_project( - &project.id, - ProjectUpdate { - name: Some("".to_string()), - ..Default::default() - }, - ) - .await - .unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::Validation(_) - )); -} - -fn default_params() -> CreateProjectParams { - CreateProjectParams { - name: String::new(), - description: String::new(), - icon: None, - owner: Uuid::now_v7(), - tags: vec![], - languages: vec![], - metadata: serde_json::json!({}), - } -} diff --git a/crates/dirigent_projects/tests/repository_tests.rs b/crates/dirigent_projects/tests/repository_tests.rs deleted file mode 100644 index dbb0f5f..0000000 --- a/crates/dirigent_projects/tests/repository_tests.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! Integration tests for repository and binding CRUD, plus working directory resolution. - -use dirigent_projects::{ - AddRepositoryParams, BindParams, CreateProjectParams, FileBasedProjectStore, ProjectStore, -}; -use std::path::PathBuf; -use uuid::Uuid; - -async fn make_store() -> FileBasedProjectStore { - let dir = tempfile::tempdir().unwrap(); - FileBasedProjectStore::new(dir.into_path()).await.unwrap() -} - -fn default_params() -> CreateProjectParams { - CreateProjectParams { - name: "Test Project".to_string(), - description: String::new(), - icon: None, - owner: Uuid::now_v7(), - tags: vec![], - languages: vec![], - metadata: serde_json::json!({}), - } -} - -// ============================================================================ -// Repository Tests -// ============================================================================ - -#[tokio::test] -async fn test_add_and_list_repositories() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let repo = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/home/user/project"), - is_primary: false, - label: Some("main".to_string()), - }) - .await - .unwrap(); - - assert_eq!(repo.project_id, project.id); - assert_eq!(repo.path, PathBuf::from("/home/user/project")); - assert!(!repo.is_primary); - assert_eq!(repo.label, Some("main".to_string())); - - let repos = store.list_repositories(&project.id).await.unwrap(); - assert_eq!(repos.len(), 1); - assert_eq!(repos[0].id, repo.id); -} - -#[tokio::test] -async fn test_add_primary_repository_unsets_others() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let repo1 = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo1"), - is_primary: true, - label: None, - }) - .await - .unwrap(); - assert!(repo1.is_primary); - - // Adding a second primary should unset the first - let repo2 = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo2"), - is_primary: true, - label: None, - }) - .await - .unwrap(); - assert!(repo2.is_primary); - - let repos = store.list_repositories(&project.id).await.unwrap(); - assert_eq!(repos.len(), 2); - - let first = repos.iter().find(|r| r.id == repo1.id).unwrap(); - let second = repos.iter().find(|r| r.id == repo2.id).unwrap(); - assert!(!first.is_primary); - assert!(second.is_primary); -} - -#[tokio::test] -async fn test_remove_repository() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let repo = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo"), - is_primary: false, - label: None, - }) - .await - .unwrap(); - - store.remove_repository(&repo.id).await.unwrap(); - let repos = store.list_repositories(&project.id).await.unwrap(); - assert!(repos.is_empty()); -} - -#[tokio::test] -async fn test_remove_nonexistent_repository() { - let store = make_store().await; - let err = store.remove_repository(&Uuid::now_v7()).await.unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::RepositoryNotFound(_) - )); -} - -#[tokio::test] -async fn test_set_primary_repository() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let repo1 = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo1"), - is_primary: true, - label: None, - }) - .await - .unwrap(); - - let repo2 = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo2"), - is_primary: false, - label: None, - }) - .await - .unwrap(); - - // Switch primary to repo2 - store - .set_primary_repository(&project.id, &repo2.id) - .await - .unwrap(); - - let repos = store.list_repositories(&project.id).await.unwrap(); - let first = repos.iter().find(|r| r.id == repo1.id).unwrap(); - let second = repos.iter().find(|r| r.id == repo2.id).unwrap(); - assert!(!first.is_primary); - assert!(second.is_primary); -} - -#[tokio::test] -async fn test_set_primary_nonexistent_repo() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let err = store - .set_primary_repository(&project.id, &Uuid::now_v7()) - .await - .unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::RepositoryNotFound(_) - )); -} - -#[tokio::test] -async fn test_add_repo_to_nonexistent_project() { - let store = make_store().await; - let err = store - .add_repository(AddRepositoryParams { - project_id: Uuid::now_v7(), - path: PathBuf::from("/repo"), - is_primary: false, - label: None, - }) - .await - .unwrap_err(); - assert!(matches!(err, dirigent_projects::ProjectError::NotFound(_))); -} - -// ============================================================================ -// Working Directory Resolution Tests -// ============================================================================ - -#[tokio::test] -async fn test_resolve_working_dir_specific_repo() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let repo = store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/specific/repo"), - is_primary: false, - label: None, - }) - .await - .unwrap(); - - let resolved = store - .resolve_working_dir(&project.id, Some(&repo.id)) - .await - .unwrap(); - assert_eq!(resolved, PathBuf::from("/specific/repo")); -} - -#[tokio::test] -async fn test_resolve_working_dir_primary_repo() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/secondary"), - is_primary: false, - label: None, - }) - .await - .unwrap(); - - store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/primary"), - is_primary: true, - label: None, - }) - .await - .unwrap(); - - let resolved = store.resolve_working_dir(&project.id, None).await.unwrap(); - assert_eq!(resolved, PathBuf::from("/primary")); -} - -#[tokio::test] -async fn test_resolve_working_dir_first_repo_fallback() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/only-repo"), - is_primary: false, - label: None, - }) - .await - .unwrap(); - - let resolved = store.resolve_working_dir(&project.id, None).await.unwrap(); - assert_eq!(resolved, PathBuf::from("/only-repo")); -} - -#[tokio::test] -async fn test_resolve_working_dir_no_repos_errors() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let err = store - .resolve_working_dir(&project.id, None) - .await - .unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::Validation(_) - )); -} - -#[tokio::test] -async fn test_resolve_working_dir_nonexistent_repo_id() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - store - .add_repository(AddRepositoryParams { - project_id: project.id, - path: PathBuf::from("/repo"), - is_primary: true, - label: None, - }) - .await - .unwrap(); - - let err = store - .resolve_working_dir(&project.id, Some(&Uuid::now_v7())) - .await - .unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::RepositoryNotFound(_) - )); -} - -// ============================================================================ -// Binding Tests -// ============================================================================ - -#[tokio::test] -async fn test_bind_and_list_bindings() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let binding = store - .bind(BindParams { - project_id: project.id, - connector_id: Some("opencode-1".to_string()), - session_id: None, - working_dir: Some(PathBuf::from("/custom/dir")), - }) - .await - .unwrap(); - - assert_eq!(binding.project_id, project.id); - assert_eq!(binding.connector_id, Some("opencode-1".to_string())); - assert!(binding.session_id.is_none()); - assert_eq!(binding.working_dir, Some(PathBuf::from("/custom/dir"))); - - let bindings = store.list_bindings(&project.id).await.unwrap(); - assert_eq!(bindings.len(), 1); - assert_eq!(bindings[0].id, binding.id); -} - -#[tokio::test] -async fn test_unbind() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - let binding = store - .bind(BindParams { - project_id: project.id, - connector_id: Some("conn-1".to_string()), - session_id: None, - working_dir: None, - }) - .await - .unwrap(); - - store.unbind(&binding.id).await.unwrap(); - let bindings = store.list_bindings(&project.id).await.unwrap(); - assert!(bindings.is_empty()); -} - -#[tokio::test] -async fn test_unbind_nonexistent() { - let store = make_store().await; - let err = store.unbind(&Uuid::now_v7()).await.unwrap_err(); - assert!(matches!( - err, - dirigent_projects::ProjectError::BindingNotFound(_) - )); -} - -#[tokio::test] -async fn test_bind_to_nonexistent_project() { - let store = make_store().await; - let err = store - .bind(BindParams { - project_id: Uuid::now_v7(), - connector_id: Some("conn".to_string()), - session_id: None, - working_dir: None, - }) - .await - .unwrap_err(); - assert!(matches!(err, dirigent_projects::ProjectError::NotFound(_))); -} - -#[tokio::test] -async fn test_bind_with_session_id() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - let session_id = Uuid::now_v7(); - - let binding = store - .bind(BindParams { - project_id: project.id, - connector_id: Some("conn-1".to_string()), - session_id: Some(session_id), - working_dir: None, - }) - .await - .unwrap(); - - assert_eq!(binding.session_id, Some(session_id)); -} - -#[tokio::test] -async fn test_multiple_bindings_per_project() { - let store = make_store().await; - let project = store.create_project(default_params()).await.unwrap(); - - store - .bind(BindParams { - project_id: project.id, - connector_id: Some("conn-1".to_string()), - session_id: None, - working_dir: None, - }) - .await - .unwrap(); - - store - .bind(BindParams { - project_id: project.id, - connector_id: Some("conn-2".to_string()), - session_id: None, - working_dir: None, - }) - .await - .unwrap(); - - let bindings = store.list_bindings(&project.id).await.unwrap(); - assert_eq!(bindings.len(), 2); -} diff --git a/crates/dirigent_testing/CLAUDE.md b/crates/dirigent_testing/CLAUDE.md deleted file mode 100644 index e80b397..0000000 --- a/crates/dirigent_testing/CLAUDE.md +++ /dev/null @@ -1,62 +0,0 @@ -# Package: dirigent_testing - -Testing utilities for Dirigent with replay-based e2e test support. - -## Quick Facts -- **Type**: Library (dev/test utility) -- **Main Entry**: src/lib.rs -- **Dependencies**: serde, serde_json, thiserror, uuid -- **Status**: Initial — replay framework only - -## Purpose - -Provides testing infrastructure for Dirigent, starting with replay-based end-to-end tests that use recorded ACP (Agent-Client Protocol) interactions. Fixtures are stored as JSON files and can be loaded, filtered, and round-tripped through serde. - -## Module Organization - -- **`lib.rs`**: Public API surface and re-exports -- **`replay.rs`**: Core replay types — `AcpReplay`, `ReplayMessage`, `Direction`, `ReplaySource` -- **`fixtures.rs`**: Fixture loading utilities — `load_fixture`, `fixture_path`, `list_fixtures` - -## Fixtures - -Fixture files live in `fixtures/` and are JSON files conforming to the `AcpReplay` schema. Each fixture contains: -- `name`: Human-readable identifier -- `source`: Origin system (`zed`, `claude`, or custom) -- `messages`: Ordered sequence of `ReplayMessage` with direction, payload, and optional delay - -### Available Fixtures -- `minimal_init.json` — Minimal MCP/ACP initialize handshake (client request + server response) -- `zed_claude_session.json` — Real Zed-Claude ACP session adapted from recorded traffic (9 messages: initialize, session/load with updates, session/list) - -## Usage - -```rust -use dirigent_testing::{load_fixture, AcpReplay, Direction}; - -let replay = load_fixture("minimal_init.json").unwrap(); -assert_eq!(replay.client_messages().len(), 1); -assert_eq!(replay.agent_messages().len(), 1); -``` - -## Testing - -```bash -cargo test -p dirigent_testing -``` - -## Related Packages - -- **dirigent_acp_api**: ACP server that these replays exercise -- **dirigent_core**: Runtime under test in integration scenarios -- **dirigent_protocol**: Shared protocol types - -## Integration Tests - -- `tests/zed_claude_replay.rs` — Tests for the Zed-Claude session fixture: loading, message counts, direction filtering, protocol structure validation, serde roundtrip - -## Future Enhancements - -- Replay runner that drives an ACP server with recorded traffic -- Assertion helpers for validating ACP response sequences -- Timing simulation with `delay_ms` support diff --git a/crates/dirigent_testing/Cargo.toml b/crates/dirigent_testing/Cargo.toml deleted file mode 100644 index 702d5e3..0000000 --- a/crates/dirigent_testing/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "dirigent_testing" -version = "0.1.0" -edition = "2021" -description = "Testing utilities for Dirigent — replay-based e2e tests from recorded ACP traffic" - -[lib] -path = "src/lib.rs" - -[dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "2.0" -uuid = { version = "1.0", features = ["v4", "v7"] } diff --git a/crates/dirigent_testing/fixtures/minimal_init.json b/crates/dirigent_testing/fixtures/minimal_init.json deleted file mode 100644 index 90aeeb7..0000000 --- a/crates/dirigent_testing/fixtures/minimal_init.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "minimal_init", - "source": "zed", - "messages": [ - { - "direction": "client_to_agent", - "payload": { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-01-01", - "capabilities": {}, - "clientInfo": { "name": "test", "version": "0.1.0" } - } - } - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "id": 1, - "result": { - "protocolVersion": "2025-01-01", - "capabilities": {}, - "serverInfo": { "name": "test-agent", "version": "0.1.0" } - } - } - } - ] -} diff --git a/crates/dirigent_testing/fixtures/zed_claude_session.json b/crates/dirigent_testing/fixtures/zed_claude_session.json deleted file mode 100644 index 5d7fdcf..0000000 --- a/crates/dirigent_testing/fixtures/zed_claude_session.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "name": "zed_claude_session", - "source": "zed", - "messages": [ - { - "direction": "client_to_agent", - "payload": { - "jsonrpc": "2.0", - "id": 0, - "method": "initialize", - "params": { - "protocolVersion": 1, - "clientCapabilities": { - "fs": { - "readTextFile": true, - "writeTextFile": true - }, - "terminal": true, - "_meta": { - "terminal_output": true, - "terminal-auth": true - } - }, - "clientInfo": { - "name": "zed", - "title": "Zed", - "version": "0.225.12" - } - } - } - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "id": 0, - "method": "initialize", - "params": { - "protocolVersion": 1, - "agentCapabilities": { - "promptCapabilities": { - "image": true, - "embeddedContext": true - }, - "mcpCapabilities": { - "http": true, - "sse": true - }, - "loadSession": true, - "sessionCapabilities": { - "fork": {}, - "list": {}, - "resume": {} - } - }, - "agentInfo": { - "name": "@zed-industries/claude-agent-acp", - "title": "Claude Agent", - "version": "0.19.2" - }, - "authMethods": [ - { - "description": "Run `claude /login` in the terminal", - "name": "Log in with Claude", - "id": "claude-login" - } - ] - } - }, - "delay_ms": 120 - }, - { - "direction": "client_to_agent", - "payload": { - "jsonrpc": "2.0", - "id": 1, - "method": "session/load", - "params": { - "mcpServers": [], - "cwd": "/dev/projects/dirigent", - "sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786" - } - }, - "delay_ms": 50 - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786", - "update": { - "sessionUpdate": "user_message_chunk", - "content": { - "type": "text", - "text": "hi" - } - } - } - }, - "delay_ms": 200 - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786", - "update": { - "sessionUpdate": "agent_message_chunk", - "content": { - "type": "text", - "text": "Hi! I'm here to help you with the Dirigent project. What would you like to work on today?" - } - } - } - }, - "delay_ms": 800 - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "id": 1, - "method": "session/load", - "params": { - "modes": { - "currentModeId": "default", - "availableModes": [ - { - "id": "default", - "name": "Default", - "description": "Standard behavior, prompts for dangerous operations" - }, - { - "id": "plan", - "name": "Plan Mode", - "description": "Planning mode, no actual tool execution" - } - ] - }, - "models": { - "availableModels": [ - { - "modelId": "default", - "name": "Default (recommended)", - "description": "Opus 4.6" - }, - { - "modelId": "sonnet", - "name": "Sonnet", - "description": "Sonnet 4.6" - } - ], - "currentModelId": "default" - } - } - }, - "delay_ms": 300 - }, - { - "direction": "client_to_agent", - "payload": { - "jsonrpc": "2.0", - "id": 2, - "method": "session/list", - "params": {} - }, - "delay_ms": 100 - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786", - "update": { - "sessionUpdate": "available_commands_update", - "availableCommands": [ - { - "name": "compact", - "description": "Clear conversation history but keep a summary in context.", - "input": { - "hint": "" - } - }, - { - "name": "context", - "description": "Show current context usage", - "input": null - } - ] - } - } - }, - "delay_ms": 150 - }, - { - "direction": "agent_to_client", - "payload": { - "jsonrpc": "2.0", - "id": 2, - "method": "session/list", - "params": { - "sessions": [ - { - "sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786", - "cwd": "/dev/projects/dirigent", - "title": "hi", - "updatedAt": "2026-03-03T14:03:24.740Z" - }, - { - "sessionId": "838b10b2-2f58-4dad-8652-9df81c880a96", - "cwd": "/dev/projects/dirigent", - "title": "Session list investigation", - "updatedAt": "2026-03-03T13:56:15.751Z" - } - ] - } - }, - "delay_ms": 250 - } - ] -} diff --git a/crates/dirigent_testing/src/fixtures.rs b/crates/dirigent_testing/src/fixtures.rs deleted file mode 100644 index 0f9de02..0000000 --- a/crates/dirigent_testing/src/fixtures.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::replay::AcpReplay; -use std::path::{Path, PathBuf}; - -/// Load an ACP replay fixture by filename from the `fixtures/` directory. -pub fn load_fixture(name: &str) -> Result { - let path = fixture_path(name); - let content = std::fs::read_to_string(&path).map_err(|e| FixtureError::ReadError { - path: path.clone(), - source: e, - })?; - serde_json::from_str(&content).map_err(|e| FixtureError::ParseError { path, source: e }) -} - -/// Return the absolute path to a fixture file by name. -pub fn fixture_path(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join(name) -} - -/// List all `.json` fixture filenames in the `fixtures/` directory. -pub fn list_fixtures() -> Vec { - let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures"); - std::fs::read_dir(fixtures_dir) - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) - .filter_map(|e| e.file_name().into_string().ok()) - .collect() -} - -/// Errors that can occur when loading fixture files. -#[derive(Debug, thiserror::Error)] -pub enum FixtureError { - #[error("Failed to read fixture at {path}: {source}")] - ReadError { - path: PathBuf, - source: std::io::Error, - }, - #[error("Failed to parse fixture at {path}: {source}")] - ParseError { - path: PathBuf, - source: serde_json::Error, - }, -} diff --git a/crates/dirigent_testing/src/lib.rs b/crates/dirigent_testing/src/lib.rs deleted file mode 100644 index bd7bc3a..0000000 --- a/crates/dirigent_testing/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Testing utilities for Dirigent. -//! Provides replay-based e2e test support using recorded ACP interactions. - -pub mod fixtures; -pub mod replay; - -pub use fixtures::{load_fixture, list_fixtures}; -pub use replay::{AcpReplay, Direction, ReplayMessage, ReplaySource}; diff --git a/crates/dirigent_testing/src/replay.rs b/crates/dirigent_testing/src/replay.rs deleted file mode 100644 index a3273ba..0000000 --- a/crates/dirigent_testing/src/replay.rs +++ /dev/null @@ -1,88 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Direction of a message in an ACP interaction replay. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Direction { - ClientToAgent, - AgentToClient, -} - -/// Source system that the replay was recorded from. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ReplaySource { - Zed, - Claude, - Custom(String), -} - -/// A single message in an ACP replay sequence. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReplayMessage { - pub direction: Direction, - pub payload: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delay_ms: Option, -} - -/// A complete ACP interaction replay containing a named sequence of messages. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AcpReplay { - pub name: String, - pub source: ReplaySource, - pub messages: Vec, -} - -impl AcpReplay { - /// Returns only the messages sent from client to agent. - pub fn client_messages(&self) -> Vec<&ReplayMessage> { - self.messages - .iter() - .filter(|m| m.direction == Direction::ClientToAgent) - .collect() - } - - /// Returns only the messages sent from agent to client. - pub fn agent_messages(&self) -> Vec<&ReplayMessage> { - self.messages - .iter() - .filter(|m| m.direction == Direction::AgentToClient) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::fixtures; - - #[test] - fn test_load_minimal_fixture() { - let replay = fixtures::load_fixture("minimal_init.json").unwrap(); - assert_eq!(replay.name, "minimal_init"); - assert_eq!(replay.messages.len(), 2); - } - - #[test] - fn test_filter_by_direction() { - let replay = fixtures::load_fixture("minimal_init.json").unwrap(); - assert_eq!(replay.client_messages().len(), 1); - assert_eq!(replay.agent_messages().len(), 1); - } - - #[test] - fn test_list_fixtures() { - let fixtures = fixtures::list_fixtures(); - assert!(fixtures.contains(&"minimal_init.json".to_string())); - } - - #[test] - fn test_serde_roundtrip() { - let replay = fixtures::load_fixture("minimal_init.json").unwrap(); - let json = serde_json::to_string_pretty(&replay).unwrap(); - let parsed: AcpReplay = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.name, replay.name); - assert_eq!(parsed.messages.len(), replay.messages.len()); - } -} diff --git a/crates/dirigent_testing/tests/zed_claude_replay.rs b/crates/dirigent_testing/tests/zed_claude_replay.rs deleted file mode 100644 index db4643e..0000000 --- a/crates/dirigent_testing/tests/zed_claude_replay.rs +++ /dev/null @@ -1,138 +0,0 @@ -use dirigent_testing::{load_fixture, Direction}; - -#[test] -fn test_zed_claude_fixture_loads() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - assert!(!replay.messages.is_empty()); - assert!(!replay.client_messages().is_empty()); - assert!(!replay.agent_messages().is_empty()); -} - -#[test] -fn test_zed_claude_starts_with_initialize() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - let first = &replay.messages[0]; - assert_eq!(first.direction, Direction::ClientToAgent); - let method = first.payload.get("method").and_then(|m| m.as_str()); - assert_eq!(method, Some("initialize")); -} - -#[test] -fn test_zed_claude_message_counts() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - // 3 client messages: initialize, session/load, session/list - assert_eq!(replay.client_messages().len(), 3); - // 6 agent messages: initialize response, 2x session/update notifications, - // session/load response, available_commands_update, session/list response - assert_eq!(replay.agent_messages().len(), 6); - // Total: 9 messages - assert_eq!(replay.messages.len(), 9); -} - -#[test] -fn test_zed_claude_initialize_has_client_info() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - let init = &replay.messages[0].payload; - let client_info = init - .pointer("/params/clientInfo/name") - .and_then(|v| v.as_str()); - assert_eq!(client_info, Some("zed")); -} - -#[test] -fn test_zed_claude_initialize_response_has_agent_info() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - let init_resp = &replay.messages[1].payload; - assert_eq!(init_resp.get("id").and_then(|v| v.as_u64()), Some(0)); - let agent_name = init_resp - .pointer("/params/agentInfo/name") - .and_then(|v| v.as_str()); - assert_eq!(agent_name, Some("@zed-industries/claude-agent-acp")); -} - -#[test] -fn test_zed_claude_session_load_flow() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - // Message at index 2 is the session/load request - let session_load = &replay.messages[2]; - assert_eq!(session_load.direction, Direction::ClientToAgent); - let method = session_load - .payload - .get("method") - .and_then(|m| m.as_str()); - assert_eq!(method, Some("session/load")); - - let session_id = session_load - .payload - .pointer("/params/sessionId") - .and_then(|v| v.as_str()); - assert_eq!( - session_id, - Some("cb878ad6-d72b-43c9-93e0-8228f309a786") - ); -} - -#[test] -fn test_zed_claude_contains_session_list() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - let list_request = replay - .messages - .iter() - .find(|m| { - m.direction == Direction::ClientToAgent - && m.payload.get("method").and_then(|v| v.as_str()) == Some("session/list") - }) - .expect("should contain a session/list request"); - - assert_eq!(list_request.payload.get("id").and_then(|v| v.as_u64()), Some(2)); - - // Find the matching response - let list_response = replay - .messages - .iter() - .find(|m| { - m.direction == Direction::AgentToClient - && m.payload.get("method").and_then(|v| v.as_str()) == Some("session/list") - && m.payload.get("id").and_then(|v| v.as_u64()) == Some(2) - }) - .expect("should contain a session/list response"); - - let sessions = list_response - .payload - .pointer("/params/sessions") - .and_then(|v| v.as_array()) - .expect("should have sessions array"); - assert_eq!(sessions.len(), 2); -} - -#[test] -fn test_zed_claude_has_delay_timings() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - // First message (initialize request from client) has no delay - assert!(replay.messages[0].delay_ms.is_none()); - // Most subsequent messages should have delay_ms set - let messages_with_delay = replay - .messages - .iter() - .filter(|m| m.delay_ms.is_some()) - .count(); - assert!( - messages_with_delay > 0, - "at least some messages should have delay timing" - ); -} - -#[test] -fn test_zed_claude_serde_roundtrip() { - let replay = load_fixture("zed_claude_session.json").unwrap(); - let json = serde_json::to_string_pretty(&replay).unwrap(); - let parsed: dirigent_testing::AcpReplay = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.name, replay.name); - assert_eq!(parsed.messages.len(), replay.messages.len()); - - for (original, roundtripped) in replay.messages.iter().zip(parsed.messages.iter()) { - assert_eq!(original.direction, roundtripped.direction); - assert_eq!(original.payload, roundtripped.payload); - assert_eq!(original.delay_ms, roundtripped.delay_ms); - } -}