sync from monorepo @ b6628270
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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"] }
|
||||
@@ -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<Self, LangfuseError> {
|
||||
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<IngestItem>) -> 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<chrono::Utc>,
|
||||
#[cfg(feature = "server")]
|
||||
client: Arc<LangfuseClient>,
|
||||
#[cfg(feature = "server")]
|
||||
buffer: Arc<Mutex<Vec<IngestItem>>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
const FLUSH_ITEMS: usize = 32;
|
||||
|
||||
impl LangfuseStream {
|
||||
#[cfg(feature = "server")]
|
||||
pub fn new(
|
||||
name: String,
|
||||
config: LangfuseConfig,
|
||||
scope: StreamScope,
|
||||
) -> Result<Arc<Self>, 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<Self> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<dyn SessionStream>, 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<dyn SessionStream>)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Utc>,
|
||||
#[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<IngestItem> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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<DetectedProject>,
|
||||
/// Hints about git worktree relationships.
|
||||
pub worktree_hints: Vec<WorktreeHint>,
|
||||
/// Hints about paths that share a common parent.
|
||||
pub multi_path_hints: Vec<MultiPathHint>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional icon.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// Tags for the new project.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Programming languages.
|
||||
#[serde(default)]
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
dirs::home_dir().map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn try_strip_mingw(s: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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<WorktreeHint> {
|
||||
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/<name>`.
|
||||
// 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<MultiPathHint> {
|
||||
let mut by_parent: HashMap<String, Vec<String>> = 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<ProjectRepository>)],
|
||||
) -> ProjectDetectionResult {
|
||||
// Pre-build a lookup from normalized repo paths -> (project, repo).
|
||||
let mut path_index: HashMap<String, (&Project, &ProjectRepository)> = HashMap::new();
|
||||
let mut canonical_index: HashMap<String, (&Project, &ProjectRepository)> = HashMap::new();
|
||||
let mut name_index: HashMap<String, &Project> = 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<String> = discovered.iter().map(|d| d.path.clone()).collect();
|
||||
let worktree_hints: Vec<WorktreeHint> = 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<ImportProjectCreationRequest>,
|
||||
owner: Uuid,
|
||||
) -> Vec<Result<ImportProjectCreationResult>> {
|
||||
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<ImportProjectCreationResult> {
|
||||
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<ProjectRepository>)> = 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, ProjectError>;
|
||||
@@ -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<PathBuf>) -> std::io::Result<Self> {
|
||||
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<Uuid> {
|
||||
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<ProjectRepository> = 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<Vec<Uuid>> {
|
||||
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<Project> {
|
||||
// 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<Project> {
|
||||
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<Vec<Project>> {
|
||||
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<Project> {
|
||||
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<ProjectRepository> {
|
||||
// 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<ProjectRepository> = 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<ProjectRepository> = 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<ProjectRepository> = 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<Vec<ProjectRepository>> {
|
||||
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<Worktree> {
|
||||
// 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<Worktree> = 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<Worktree> = 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<Vec<Worktree>> {
|
||||
let project_id = self.find_project_for_repo(repository_id).await?;
|
||||
let wt_path = self.paths.worktrees_json(&project_id);
|
||||
let all: Vec<Worktree> = 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<Worktree> {
|
||||
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<Worktree> = 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<ProjectBinding> {
|
||||
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<ProjectBinding> = 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<ProjectBinding> = 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<Vec<ProjectBinding>> {
|
||||
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<PathBuf> {
|
||||
// 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
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<String>,
|
||||
/// 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<PathBuf>) -> 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<String> {
|
||||
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<GitStatus> {
|
||||
let output = self.git(&["status", "--porcelain=v2", "--branch"]).await?;
|
||||
parse_status(&output)
|
||||
}
|
||||
|
||||
/// List remote names.
|
||||
pub async fn remotes(&self) -> Result<Vec<String>> {
|
||||
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<Vec<WorktreeEntry>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<GitStatus> {
|
||||
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<WorktreeEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut current_path: Option<PathBuf> = None;
|
||||
let mut current_branch: Option<String> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Option<String>> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<String>,
|
||||
/// Owner user ID
|
||||
pub owner: Uuid,
|
||||
/// Initial tags
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Initial languages
|
||||
#[serde(default)]
|
||||
pub languages: Vec<String>,
|
||||
/// 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<Uuid>,
|
||||
/// Filter by tag (project must have all specified tags)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Filter by name substring (case-insensitive)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name_contains: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// New description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// New icon
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<Option<String>>,
|
||||
/// New tags (replaces all)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// New languages (replaces all)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub languages: Option<Vec<String>>,
|
||||
/// New metadata (replaces all)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional naming strategy
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub naming_strategy: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// New work branch
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub work_branch: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional session ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<Uuid>,
|
||||
/// Optional working directory override
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub working_dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -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<T: Serialize>(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<T: for<'de> Deserialize<'de>>(path: &Path) -> std::io::Result<T> {
|
||||
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<T: for<'de> Deserialize<'de> + Default>(
|
||||
path: &Path,
|
||||
) -> std::io::Result<T> {
|
||||
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<TestData> = 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<String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<PathBuf>) -> 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Project>;
|
||||
|
||||
/// Get a project by ID.
|
||||
async fn get_project(&self, id: &Uuid) -> Result<Project>;
|
||||
|
||||
/// List projects matching a filter.
|
||||
async fn list_projects(&self, filter: ProjectFilter) -> Result<Vec<Project>>;
|
||||
|
||||
/// Update a project's fields.
|
||||
async fn update_project(&self, id: &Uuid, update: ProjectUpdate) -> Result<Project>;
|
||||
|
||||
/// 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<ProjectRepository>;
|
||||
|
||||
/// 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<Vec<ProjectRepository>>;
|
||||
|
||||
// --- Worktrees (Phase 4) ---
|
||||
|
||||
/// Add a worktree record to a repository.
|
||||
async fn add_worktree(&self, params: AddWorktreeParams) -> Result<Worktree>;
|
||||
|
||||
/// 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<Vec<Worktree>>;
|
||||
|
||||
/// Update a worktree record.
|
||||
async fn update_worktree(&self, worktree_id: &Uuid, update: WorktreeUpdate)
|
||||
-> Result<Worktree>;
|
||||
|
||||
// --- Bindings (Phase 5) ---
|
||||
|
||||
/// Bind a project to a connector/session.
|
||||
async fn bind(&self, params: BindParams) -> Result<ProjectBinding>;
|
||||
|
||||
/// 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<Vec<ProjectBinding>>;
|
||||
|
||||
// --- Resolution (Phase 2) ---
|
||||
|
||||
/// Resolve the working directory for a project.
|
||||
async fn resolve_working_dir(
|
||||
&self,
|
||||
project_id: &Uuid,
|
||||
repo_id: Option<&Uuid>,
|
||||
) -> Result<PathBuf>;
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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!({}),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"] }
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "<optional custom summarization instructions>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<AcpReplay, FixtureError> {
|
||||
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<String> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// 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<ReplayMessage>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user