sync from monorepo @ 2452e92e
This commit is contained in:
@@ -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"] }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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(¶ms.project_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let repo = ProjectRepository {
|
||||
id: Uuid::now_v7(),
|
||||
project_id: params.project_id,
|
||||
path: params.path,
|
||||
is_primary: params.is_primary,
|
||||
label: params.label,
|
||||
access: AccessMode::ReadWrite,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Read existing repos, append, write back
|
||||
let repos_path = self.paths.repositories_json(¶ms.project_id);
|
||||
let mut repos: Vec<ProjectRepository> = read_json_or_default(&repos_path).await?;
|
||||
|
||||
// If this is primary, unset others
|
||||
if repo.is_primary {
|
||||
for r in repos.iter_mut() {
|
||||
r.is_primary = false;
|
||||
}
|
||||
}
|
||||
|
||||
repos.push(repo.clone());
|
||||
write_json(&repos_path, &repos).await?;
|
||||
|
||||
info!(repo_id = %repo.id, project_id = %params.project_id, "Added repository");
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
async fn remove_repository(&self, id: &Uuid) -> Result<()> {
|
||||
// Scan all projects to find the repo
|
||||
let project_ids = self.scan_project_ids().await?;
|
||||
for project_id in project_ids {
|
||||
let repos_path = self.paths.repositories_json(&project_id);
|
||||
let mut repos: Vec<ProjectRepository> = read_json_or_default(&repos_path).await?;
|
||||
let original_len = repos.len();
|
||||
repos.retain(|r| r.id != *id);
|
||||
if repos.len() < original_len {
|
||||
write_json(&repos_path, &repos).await?;
|
||||
info!(repo_id = %id, "Removed repository");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ProjectError::RepositoryNotFound(*id))
|
||||
}
|
||||
|
||||
async fn set_primary_repository(&self, project_id: &Uuid, repo_id: &Uuid) -> Result<()> {
|
||||
let repos_path = self.paths.repositories_json(project_id);
|
||||
let mut repos: Vec<ProjectRepository> = read_json_or_default(&repos_path).await?;
|
||||
|
||||
let mut found = false;
|
||||
for r in repos.iter_mut() {
|
||||
r.is_primary = r.id == *repo_id;
|
||||
if r.id == *repo_id {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Err(ProjectError::RepositoryNotFound(*repo_id));
|
||||
}
|
||||
|
||||
write_json(&repos_path, &repos).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_repositories(&self, project_id: &Uuid) -> Result<Vec<ProjectRepository>> {
|
||||
let repos_path = self.paths.repositories_json(project_id);
|
||||
Ok(read_json_or_default(&repos_path).await?)
|
||||
}
|
||||
|
||||
// --- Worktrees (Phase 4) ---
|
||||
|
||||
async fn add_worktree(&self, params: AddWorktreeParams) -> Result<Worktree> {
|
||||
// Find which project owns this repository
|
||||
let project_id = self.find_project_for_repo(¶ms.repository_id).await?;
|
||||
|
||||
let worktree = Worktree {
|
||||
id: Uuid::now_v7(),
|
||||
repository_id: params.repository_id,
|
||||
path: params.path,
|
||||
branch: params.branch,
|
||||
work_branch: params.work_branch,
|
||||
naming_strategy: params.naming_strategy,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let wt_path = self.paths.worktrees_json(&project_id);
|
||||
let mut worktrees: Vec<Worktree> = read_json_or_default(&wt_path).await?;
|
||||
worktrees.push(worktree.clone());
|
||||
write_json(&wt_path, &worktrees).await?;
|
||||
|
||||
info!(worktree_id = %worktree.id, repo_id = %params.repository_id, "Added worktree");
|
||||
Ok(worktree)
|
||||
}
|
||||
|
||||
async fn remove_worktree(&self, worktree_id: &Uuid) -> Result<()> {
|
||||
let project_ids = self.scan_project_ids().await?;
|
||||
for project_id in project_ids {
|
||||
let wt_path = self.paths.worktrees_json(&project_id);
|
||||
let mut worktrees: Vec<Worktree> = read_json_or_default(&wt_path).await?;
|
||||
let original_len = worktrees.len();
|
||||
worktrees.retain(|w| w.id != *worktree_id);
|
||||
if worktrees.len() < original_len {
|
||||
write_json(&wt_path, &worktrees).await?;
|
||||
info!(worktree_id = %worktree_id, "Removed worktree");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ProjectError::WorktreeNotFound(*worktree_id))
|
||||
}
|
||||
|
||||
async fn list_worktrees(&self, repository_id: &Uuid) -> Result<Vec<Worktree>> {
|
||||
let project_id = self.find_project_for_repo(repository_id).await?;
|
||||
let wt_path = self.paths.worktrees_json(&project_id);
|
||||
let all: Vec<Worktree> = read_json_or_default(&wt_path).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|w| w.repository_id == *repository_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn update_worktree(
|
||||
&self,
|
||||
worktree_id: &Uuid,
|
||||
update: WorktreeUpdate,
|
||||
) -> Result<Worktree> {
|
||||
let project_ids = self.scan_project_ids().await?;
|
||||
for project_id in &project_ids {
|
||||
let wt_path = self.paths.worktrees_json(project_id);
|
||||
let mut worktrees: Vec<Worktree> = read_json_or_default(&wt_path).await?;
|
||||
if let Some(wt) = worktrees.iter_mut().find(|w| w.id == *worktree_id) {
|
||||
if let Some(branch) = update.branch {
|
||||
wt.branch = branch;
|
||||
}
|
||||
if let Some(work_branch) = update.work_branch {
|
||||
wt.work_branch = work_branch;
|
||||
}
|
||||
let updated = wt.clone();
|
||||
write_json(&wt_path, &worktrees).await?;
|
||||
return Ok(updated);
|
||||
}
|
||||
}
|
||||
Err(ProjectError::WorktreeNotFound(*worktree_id))
|
||||
}
|
||||
|
||||
// --- Bindings (Phase 5 - scaffolded) ---
|
||||
|
||||
async fn bind(&self, params: BindParams) -> Result<ProjectBinding> {
|
||||
let _ = self.get_project(¶ms.project_id).await?;
|
||||
|
||||
let binding = ProjectBinding {
|
||||
id: Uuid::now_v7(),
|
||||
project_id: params.project_id,
|
||||
connector_id: params.connector_id,
|
||||
session_id: params.session_id,
|
||||
working_dir: params.working_dir,
|
||||
};
|
||||
|
||||
let bindings_path = self.paths.bindings_json(¶ms.project_id);
|
||||
let mut bindings: Vec<ProjectBinding> = read_json_or_default(&bindings_path).await?;
|
||||
bindings.push(binding.clone());
|
||||
write_json(&bindings_path, &bindings).await?;
|
||||
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
async fn unbind(&self, binding_id: &Uuid) -> Result<()> {
|
||||
let project_ids = self.scan_project_ids().await?;
|
||||
for project_id in project_ids {
|
||||
let bindings_path = self.paths.bindings_json(&project_id);
|
||||
let mut bindings: Vec<ProjectBinding> = read_json_or_default(&bindings_path).await?;
|
||||
let original_len = bindings.len();
|
||||
bindings.retain(|b| b.id != *binding_id);
|
||||
if bindings.len() < original_len {
|
||||
write_json(&bindings_path, &bindings).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ProjectError::BindingNotFound(*binding_id))
|
||||
}
|
||||
|
||||
async fn list_bindings(&self, project_id: &Uuid) -> Result<Vec<ProjectBinding>> {
|
||||
let bindings_path = self.paths.bindings_json(project_id);
|
||||
Ok(read_json_or_default(&bindings_path).await?)
|
||||
}
|
||||
|
||||
// --- Resolution (Phase 2 - scaffolded) ---
|
||||
|
||||
async fn resolve_working_dir(
|
||||
&self,
|
||||
project_id: &Uuid,
|
||||
repo_id: Option<&Uuid>,
|
||||
) -> Result<PathBuf> {
|
||||
// Verify the project exists before falling back to default_working_dir.
|
||||
// Without this, a missing project directory yields an empty repo list
|
||||
// (via read_json_or_default) and silently falls through to the default.
|
||||
self.get_project(project_id).await?;
|
||||
|
||||
let repos = self.list_repositories(project_id).await?;
|
||||
|
||||
// If repo_id specified, use that
|
||||
if let Some(rid) = repo_id {
|
||||
if let Some(repo) = repos.iter().find(|r| r.id == *rid) {
|
||||
return Ok(repo.path.clone());
|
||||
}
|
||||
return Err(ProjectError::RepositoryNotFound(*rid));
|
||||
}
|
||||
|
||||
// Use primary repo, or first repo
|
||||
if let Some(repo) = repos.iter().find(|r| r.is_primary).or(repos.first()) {
|
||||
return Ok(repo.path.clone());
|
||||
}
|
||||
|
||||
Err(ProjectError::Validation(format!(
|
||||
"project {} has no repositories configured",
|
||||
project_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user