sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
[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
@@ -0,0 +1,751 @@
//! 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
@@ -0,0 +1,43 @@
//! 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
@@ -0,0 +1,441 @@
//! 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
@@ -0,0 +1,13 @@
//! 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
@@ -0,0 +1,353 @@
//! 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
@@ -0,0 +1,77 @@
//! 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
}
@@ -0,0 +1,62 @@
//! 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
@@ -0,0 +1,44 @@
//! 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
@@ -0,0 +1,127 @@
//! 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
@@ -0,0 +1,118 @@
//! 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());
}
}
@@ -0,0 +1,6 @@
//! Storage layer for file-based project persistence.
//!
//! Follows the archivist pattern: atomic writes, JSON files, directory-per-project.
pub mod io;
pub mod paths;
@@ -0,0 +1,66 @@
//! 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
@@ -0,0 +1,81 @@
//! 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
@@ -0,0 +1,184 @@
//! 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()));
}
@@ -0,0 +1,226 @@
//! 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!({}),
}
}
@@ -0,0 +1,424 @@
//! 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);
}