use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info};
use crate::error::{OrchestratorError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProjectStatus {
#[default]
Idle,
Running,
Stopped,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OrchestrationStatus {
#[default]
Idle,
Running,
Stopped,
}
impl OrchestrationStatus {
pub fn as_str(&self) -> &'static str {
match self {
OrchestrationStatus::Idle => "idle",
OrchestrationStatus::Running => "running",
OrchestrationStatus::Stopped => "stopped",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProjectSyncState {
UpToDate,
Ahead,
Behind,
Diverged,
#[default]
Unknown,
}
impl ProjectSyncState {
pub fn as_str(&self) -> &'static str {
match self {
ProjectSyncState::UpToDate => "up_to_date",
ProjectSyncState::Ahead => "ahead",
ProjectSyncState::Behind => "behind",
ProjectSyncState::Diverged => "diverged",
ProjectSyncState::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectSyncMetadata {
#[serde(default)]
pub sync_state: ProjectSyncState,
#[serde(default)]
pub ahead_count: u32,
#[serde(default)]
pub behind_count: u32,
#[serde(default)]
pub sync_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_check_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_check_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub id: String,
pub remote_url: String,
pub branch: String,
#[serde(default)]
pub status: ProjectStatus,
#[serde(default)]
pub sync_metadata: ProjectSyncMetadata,
pub created_at: String,
}
impl ProjectEntry {
pub fn new(remote_url: String, branch: String) -> Self {
let id = generate_project_id(&remote_url, &branch);
let created_at = chrono::Utc::now().to_rfc3339();
Self {
id,
remote_url,
branch,
status: ProjectStatus::default(),
sync_metadata: ProjectSyncMetadata::default(),
created_at,
}
}
}
pub fn generate_project_id(remote_url: &str, branch: &str) -> String {
let input = format!("{}\n{}", remote_url, branch);
let digest = md5::compute(input.as_bytes());
let hex = format!("{:x}", digest);
hex[..16].to_string()
}
pub fn server_worktree_branch(project_id: &str, base_branch: &str) -> String {
format!("server-wt/{}/{}", project_id, base_branch)
}
const REGISTRY_FILE: &str = "projects.json";
pub struct ProjectRegistry {
data_dir: PathBuf,
projects: HashMap<String, ProjectEntry>,
project_locks: HashMap<String, Arc<Mutex<()>>>,
global_semaphore: Arc<tokio::sync::Semaphore>,
change_selections: HashMap<String, HashMap<String, bool>>,
error_changes: HashMap<String, HashMap<String, String>>,
}
impl ProjectRegistry {
pub fn load(data_dir: &Path, max_concurrent_total: usize) -> Result<Self> {
std::fs::create_dir_all(data_dir).map_err(|e| {
OrchestratorError::Io(std::io::Error::other(format!(
"Failed to create server data dir '{}': {}",
data_dir.display(),
e
)))
})?;
let registry_path = data_dir.join(REGISTRY_FILE);
let projects = if registry_path.exists() {
let content = std::fs::read_to_string(®istry_path).map_err(|e| {
OrchestratorError::Io(std::io::Error::other(format!(
"Failed to read registry '{}': {}",
registry_path.display(),
e
)))
})?;
serde_json::from_str::<HashMap<String, ProjectEntry>>(&content).map_err(|e| {
OrchestratorError::ConfigLoad(format!(
"Failed to parse registry '{}': {}",
registry_path.display(),
e
))
})?
} else {
HashMap::new()
};
info!(
"Loaded project registry from {:?} ({} projects)",
registry_path,
projects.len()
);
let mut project_locks = HashMap::new();
for id in projects.keys() {
project_locks.insert(id.clone(), Arc::new(Mutex::new(())));
}
Ok(Self {
data_dir: data_dir.to_path_buf(),
projects,
project_locks,
global_semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent_total)),
change_selections: HashMap::new(),
error_changes: HashMap::new(),
})
}
fn save(&self) -> Result<()> {
let registry_path = self.data_dir.join(REGISTRY_FILE);
let content = serde_json::to_string_pretty(&self.projects).map_err(|e| {
OrchestratorError::ConfigLoad(format!("Failed to serialize registry: {}", e))
})?;
std::fs::write(®istry_path, content).map_err(|e| {
OrchestratorError::Io(std::io::Error::other(format!(
"Failed to write registry '{}': {}",
registry_path.display(),
e
)))
})?;
debug!("Saved project registry to {:?}", registry_path);
Ok(())
}
pub fn list(&self) -> Vec<ProjectEntry> {
let mut entries: Vec<ProjectEntry> = self.projects.values().cloned().collect();
entries.sort_by(|a, b| a.created_at.cmp(&b.created_at));
entries
}
pub fn add(&mut self, remote_url: String, branch: String) -> Result<ProjectEntry> {
let id = generate_project_id(&remote_url, &branch);
if self.projects.contains_key(&id) {
return Err(OrchestratorError::ConfigLoad(format!(
"Project already exists: id={} remote_url={} branch={}",
id, remote_url, branch
)));
}
let entry = ProjectEntry::new(remote_url, branch);
self.project_locks
.insert(entry.id.clone(), Arc::new(Mutex::new(())));
self.projects.insert(entry.id.clone(), entry.clone());
self.save()?;
info!("Added project id={}", entry.id);
Ok(entry)
}
pub fn remove(&mut self, id: &str) -> Result<ProjectEntry> {
let entry = self.projects.remove(id).ok_or_else(|| {
OrchestratorError::ConfigLoad(format!("Project not found: id={}", id))
})?;
self.project_locks.remove(id);
self.save()?;
info!("Removed project id={}", id);
Ok(entry)
}
pub fn get(&self, id: &str) -> Option<&ProjectEntry> {
self.projects.get(id)
}
pub fn set_status(&mut self, id: &str, status: ProjectStatus) -> Result<()> {
let entry = self.projects.get_mut(id).ok_or_else(|| {
OrchestratorError::ConfigLoad(format!("Project not found: id={}", id))
})?;
entry.status = status;
self.save()
}
pub fn set_sync_metadata(
&mut self,
id: &str,
sync_metadata: ProjectSyncMetadata,
) -> Result<()> {
let entry = self.projects.get_mut(id).ok_or_else(|| {
OrchestratorError::ConfigLoad(format!("Project not found: id={}", id))
})?;
entry.sync_metadata = sync_metadata;
self.save()
}
pub fn project_lock(&self, id: &str) -> Option<Arc<Mutex<()>>> {
self.project_locks.get(id).cloned()
}
pub fn global_semaphore(&self) -> Arc<tokio::sync::Semaphore> {
self.global_semaphore.clone()
}
pub fn data_dir(&self) -> &std::path::Path {
&self.data_dir
}
#[allow(dead_code)]
pub fn is_change_selected(&self, project_id: &str, change_id: &str) -> bool {
self.change_selections
.get(project_id)
.and_then(|m| m.get(change_id))
.copied()
.unwrap_or(true)
}
#[allow(dead_code)]
pub fn ensure_change_selected(&mut self, project_id: &str, change_id: &str) {
self.change_selections
.entry(project_id.to_string())
.or_default()
.entry(change_id.to_string())
.or_insert(true);
}
pub fn toggle_change_selected(&mut self, project_id: &str, change_id: &str) -> bool {
let is_error = self.is_change_error(project_id, change_id);
let default_selected = !is_error;
let entry = self
.change_selections
.entry(project_id.to_string())
.or_default()
.entry(change_id.to_string())
.or_insert(default_selected);
*entry = !*entry;
debug!(
project_id,
change_id,
selected = *entry,
is_error,
"Toggled change selection"
);
*entry
}
pub fn toggle_all_changes(&mut self, project_id: &str, known_change_ids: &[String]) -> bool {
let default_selections: Vec<(String, bool)> = known_change_ids
.iter()
.map(|cid| (cid.clone(), !self.is_change_error(project_id, cid)))
.collect();
let selections = self
.change_selections
.entry(project_id.to_string())
.or_default();
for (cid, default_selected) in default_selections {
selections.entry(cid).or_insert(default_selected);
}
let any_unselected = known_change_ids
.iter()
.any(|cid| !selections.get(cid).copied().unwrap_or(true));
let new_value = any_unselected;
for cid in known_change_ids {
if let Some(val) = selections.get_mut(cid) {
*val = new_value;
}
}
debug!(
project_id,
new_selected = new_value,
count = known_change_ids.len(),
"Toggled all change selections"
);
new_value
}
pub fn change_selections_for_project(
&self,
project_id: &str,
) -> Option<&HashMap<String, bool>> {
self.change_selections.get(project_id)
}
pub fn set_change_state(
&mut self,
project_id: &str,
change_id: &str,
selected: bool,
error_message: Option<String>,
) {
self.change_selections
.entry(project_id.to_string())
.or_default()
.insert(change_id.to_string(), selected);
match error_message {
Some(message) => {
self.error_changes
.entry(project_id.to_string())
.or_default()
.insert(change_id.to_string(), message);
}
None => {
if let Some(project_errors) = self.error_changes.get_mut(project_id) {
project_errors.remove(change_id);
if project_errors.is_empty() {
self.error_changes.remove(project_id);
}
}
}
}
}
pub fn mark_change_error(&mut self, project_id: &str, change_id: &str, error: String) {
self.error_changes
.entry(project_id.to_string())
.or_default()
.insert(change_id.to_string(), error);
self.change_selections
.entry(project_id.to_string())
.or_default()
.insert(change_id.to_string(), false);
debug!(
project_id,
change_id, "Marked change as error and cleared selection"
);
}
pub fn error_changes_for_project(&self, project_id: &str) -> Option<&HashMap<String, String>> {
self.error_changes.get(project_id)
}
#[allow(dead_code)]
pub fn clear_change_error(&mut self, project_id: &str, change_id: &str) {
if let Some(project_errors) = self.error_changes.get_mut(project_id) {
project_errors.remove(change_id);
if project_errors.is_empty() {
self.error_changes.remove(project_id);
}
}
debug!(project_id, change_id, "Cleared change error state");
}
pub fn is_change_error(&self, project_id: &str, change_id: &str) -> bool {
self.error_changes
.get(project_id)
.and_then(|m| m.get(change_id))
.is_some()
}
}
pub type SharedRegistry = Arc<RwLock<ProjectRegistry>>;
pub fn create_shared_registry(
data_dir: &Path,
max_concurrent_total: usize,
) -> Result<SharedRegistry> {
let registry = ProjectRegistry::load(data_dir, max_concurrent_total)?;
Ok(Arc::new(RwLock::new(registry)))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_server_worktree_branch_format() {
let branch = server_worktree_branch("abc123def456789a", "main");
assert_eq!(
branch, "server-wt/abc123def456789a/main",
"Branch name must follow server-wt/<project_id>/<base_branch> format"
);
}
#[test]
fn test_server_worktree_branch_different_base_branches() {
let branch_main = server_worktree_branch("abc123", "main");
let branch_develop = server_worktree_branch("abc123", "develop");
assert_ne!(
branch_main, branch_develop,
"Different base branches must produce different server worktree branch names"
);
}
#[test]
fn test_server_worktree_branch_different_project_ids() {
let branch1 = server_worktree_branch("abc123", "main");
let branch2 = server_worktree_branch("xyz789", "main");
assert_ne!(
branch1, branch2,
"Different project IDs must produce different server worktree branch names"
);
}
#[test]
fn test_server_worktree_branch_is_not_base_branch() {
let project_id = "abc123def456789a";
let base_branch = "main";
let server_branch = server_worktree_branch(project_id, base_branch);
assert_ne!(
server_branch, base_branch,
"Server worktree branch must differ from the base branch"
);
}
#[test]
fn test_server_worktree_branch_starts_with_server_wt() {
let branch = server_worktree_branch("abc123", "main");
assert!(
branch.starts_with("server-wt/"),
"Server worktree branch must start with 'server-wt/'"
);
}
#[test]
fn test_generate_project_id_deterministic() {
let id1 = generate_project_id("https://github.com/foo/bar", "main");
let id2 = generate_project_id("https://github.com/foo/bar", "main");
assert_eq!(id1, id2, "Same input must produce same project_id");
}
#[test]
fn test_generate_project_id_length() {
let id = generate_project_id("https://github.com/foo/bar", "main");
assert_eq!(id.len(), 16, "project_id must be 16 hex chars");
}
#[test]
fn test_generate_project_id_different_inputs() {
let id1 = generate_project_id("https://github.com/foo/bar", "main");
let id2 = generate_project_id("https://github.com/foo/bar", "develop");
assert_ne!(
id1, id2,
"Different branch must produce different project_id"
);
let id3 = generate_project_id("https://github.com/foo/baz", "main");
assert_ne!(
id1, id3,
"Different remote_url must produce different project_id"
);
}
#[test]
fn test_generate_project_id_known_value() {
let id = generate_project_id("https://github.com/foo/bar", "main");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(id.len(), 16);
}
#[tokio::test]
async fn test_registry_add_and_list() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let projects = registry.list();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].remote_url, "https://github.com/foo/bar");
assert_eq!(projects[0].branch, "main");
}
#[tokio::test]
async fn test_registry_add_duplicate_fails() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let result = registry.add("https://github.com/foo/bar".to_string(), "main".to_string());
assert!(result.is_err(), "Duplicate add should fail");
}
#[tokio::test]
async fn test_registry_remove() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
let entry = registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let id = entry.id.clone();
registry.remove(&id).unwrap();
assert!(registry.get(&id).is_none());
}
#[tokio::test]
async fn test_registry_persistence() {
let temp_dir = TempDir::new().unwrap();
{
let mut registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
}
let registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
let projects = registry.list();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].remote_url, "https://github.com/foo/bar");
}
#[tokio::test]
async fn test_project_lock() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 4).unwrap();
let entry = registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let lock = registry.project_lock(&entry.id);
assert!(lock.is_some(), "Per-project lock must exist after add");
}
#[tokio::test]
async fn test_global_semaphore_limits_concurrency() {
let temp_dir = TempDir::new().unwrap();
let registry = ProjectRegistry::load(temp_dir.path(), 2).unwrap();
let sem = registry.global_semaphore();
assert_eq!(sem.available_permits(), 2);
let _p1 = sem.acquire().await.unwrap();
let _p2 = sem.acquire().await.unwrap();
assert_eq!(sem.available_permits(), 0, "Semaphore should be exhausted");
}
#[test]
fn test_toggle_change_selected_tracks_explicit_false() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 2).unwrap();
let entry = registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
let first = registry.toggle_change_selected(&entry.id, "change-a");
let second = registry.toggle_change_selected(&entry.id, "change-a");
assert!(!first, "first toggle should clear default selection");
assert!(second, "second toggle should restore explicit selection");
assert!(registry.is_change_selected(&entry.id, "change-a"));
}
#[test]
fn test_mark_change_error_clears_selection_until_explicit_remark() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 2).unwrap();
let entry = registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
registry.mark_change_error(&entry.id, "change-a", "boom".to_string());
assert!(registry.is_change_error(&entry.id, "change-a"));
assert!(!registry.is_change_selected(&entry.id, "change-a"));
let remarked = registry.toggle_change_selected(&entry.id, "change-a");
assert!(remarked, "error changes should remark from false to true");
registry.clear_change_error(&entry.id, "change-a");
assert!(!registry.is_change_error(&entry.id, "change-a"));
assert!(registry.is_change_selected(&entry.id, "change-a"));
}
#[test]
fn test_toggle_all_changes_treats_error_changes_as_unselected_by_default() {
let temp_dir = TempDir::new().unwrap();
let mut registry = ProjectRegistry::load(temp_dir.path(), 2).unwrap();
let entry = registry
.add("https://github.com/foo/bar".to_string(), "main".to_string())
.unwrap();
registry.mark_change_error(&entry.id, "change-a", "boom".to_string());
let new_selected = registry
.toggle_all_changes(&entry.id, &["change-a".to_string(), "change-b".to_string()]);
assert!(
new_selected,
"bulk toggle should mark all when any row is unselected"
);
assert!(registry.is_change_selected(&entry.id, "change-a"));
assert!(registry.is_change_selected(&entry.id, "change-b"));
assert!(
registry.is_change_error(&entry.id, "change-a"),
"bulk remark should not clear the tracked error state"
);
}
}