sync from monorepo @ b6628270

This commit is contained in:
2026-05-09 15:21:46 +02:00
parent 4aa0147223
commit e20542a40e
33 changed files with 1 additions and 4206 deletions
-72
View File
@@ -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.
-23
View File
@@ -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"] }
-204
View File
@@ -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;
}
}
}
-48
View File
@@ -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>)
}
}
-13
View File
@@ -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;
-173
View File
@@ -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);
}
}
-33
View File
@@ -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"] }
-751
View File
@@ -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),
}
}
}
-43
View File
@@ -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>;
-441
View File
@@ -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(&params.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(&params.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(&params.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(&params.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(&params.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
)))
}
}
-13
View File
@@ -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};
-353
View File
@@ -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);
}
}
-77
View File
@@ -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)
}
}
-44
View File
@@ -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,
};
-127
View File
@@ -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>,
}
-118
View File
@@ -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")
);
}
}
-81
View File
@@ -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>;
}
-184
View File
@@ -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);
}
-62
View File
@@ -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
-14
View File
@@ -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
}
]
}
-46
View File
@@ -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,
},
}
-8
View File
@@ -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};
-88
View File
@@ -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);
}
}