use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::{
config::{read_project_config, resolve_vault_path, resolve_vault_path_with_passphrase},
fs_util,
git_context::collect_git_context,
logs,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Registry {
#[serde(default)]
pub active_project: Option<String>,
#[serde(default)]
pub projects: BTreeMap<String, RegisteredProject>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisteredProject {
pub path: PathBuf,
pub vault: PathBuf,
pub git_remote: Option<String>,
pub created_at: String,
pub last_used: Option<String>,
#[serde(default)]
pub allowed_worktree_roots: Vec<PathBuf>,
#[serde(default)]
pub known_worktrees: Vec<PathBuf>,
#[serde(default = "default_auto_bind_worktrees")]
pub auto_bind_worktrees: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_repo_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_common_dir: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedProject {
pub name: String,
pub path: PathBuf,
pub vault: PathBuf,
}
pub fn registry_path() -> PathBuf {
logs::ward_home().join("registry.json")
}
pub fn load_registry() -> Result<Registry> {
let path = registry_path();
if !path.exists() {
return Ok(Registry::default());
}
let contents =
fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?;
serde_json::from_str(&contents).context(format!("failed to parse {}", path.display()))
}
pub fn save_registry(registry: &Registry) -> Result<()> {
let path = registry_path();
let contents = serde_json::to_string_pretty(registry).expect("registry should serialize");
fs_util::ensure_private_dir(&logs::ward_home())?;
fs_util::write_private_file(&path, format!("{contents}\n").as_bytes())
}
pub fn register_project(
project: String,
path: PathBuf,
vault: PathBuf,
) -> Result<RegisteredProject> {
let mut registry = load_registry()?;
let git = collect_git_context(&path);
let canonical_repo_path = path.canonicalize().ok();
let registered = RegisteredProject {
path,
vault,
git_remote: git.remote,
created_at: chrono::Utc::now().to_rfc3339(),
last_used: Some(chrono::Utc::now().to_rfc3339()),
allowed_worktree_roots: Vec::new(),
known_worktrees: Vec::new(),
auto_bind_worktrees: true,
canonical_repo_path,
git_common_dir: git.common_dir,
};
registry
.projects
.insert(project.clone(), registered.clone());
registry.active_project = Some(project);
save_registry(®istry)?;
Ok(registered)
}
pub fn update_project_vault(project: &str, path: PathBuf, vault: PathBuf) -> Result<()> {
let mut registry = load_registry()?;
let git = collect_git_context(&path);
let canonical_repo_path = path.canonicalize().ok();
match registry.projects.get_mut(project) {
Some(registered) => {
registered.path = path;
registered.vault = vault;
registered.git_remote = git.remote;
registered.last_used = Some(chrono::Utc::now().to_rfc3339());
registered.canonical_repo_path = canonical_repo_path;
registered.git_common_dir = git.common_dir;
}
None => {
let registered = RegisteredProject {
path,
vault,
git_remote: git.remote,
created_at: chrono::Utc::now().to_rfc3339(),
last_used: Some(chrono::Utc::now().to_rfc3339()),
allowed_worktree_roots: Vec::new(),
known_worktrees: Vec::new(),
auto_bind_worktrees: true,
canonical_repo_path,
git_common_dir: git.common_dir,
};
registry.projects.insert(project.to_string(), registered);
}
}
registry.active_project = Some(project.to_string());
save_registry(®istry)
}
fn default_auto_bind_worktrees() -> bool {
true
}
pub fn list_projects() -> Result<Registry> {
load_registry()
}
pub fn set_active_project(project: &str) -> Result<()> {
let mut registry = load_registry()?;
if !registry.projects.contains_key(project) {
anyhow::bail!("project {project} is not registered");
}
registry.active_project = Some(project.to_string());
save_registry(®istry)
}
pub fn remove_project(project: &str) -> Result<bool> {
let mut registry = load_registry()?;
let removed = registry.projects.remove(project).is_some();
if registry.active_project.as_deref() == Some(project) {
registry.active_project = None;
}
if removed {
save_registry(®istry)?;
}
Ok(removed)
}
pub fn resolve_project(explicit_project: Option<&str>, cwd: &Path) -> Result<ResolvedProject> {
let registry = load_registry()?;
if let Some(project) = explicit_project {
return registered_project(®istry, project)
.context(format!("project {project} is not registered"));
}
let local_config_root =
crate::config::find_project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
if let Ok(config) = read_project_config(&local_config_root) {
if let Some(registered) = registry.projects.get(&config.project) {
return Ok(ResolvedProject {
name: config.project,
path: registered.path.clone(),
vault: registered.vault.clone(),
});
}
return Ok(ResolvedProject {
name: config.project.clone(),
path: local_config_root.clone(),
vault: resolve_vault_path(&local_config_root, &config),
});
}
let git = collect_git_context(cwd);
if let Some(remote) = git.remote.as_deref() {
if let Some((name, registered)) = registry
.projects
.iter()
.find(|(_, registered)| registered.git_remote.as_deref() == Some(remote))
{
return Ok(ResolvedProject {
name: name.clone(),
path: registered.path.clone(),
vault: registered.vault.clone(),
});
}
}
if let Some((name, registered)) = registry
.projects
.iter()
.find(|(_, registered)| cwd.starts_with(®istered.path))
{
return Ok(ResolvedProject {
name: name.clone(),
path: registered.path.clone(),
vault: registered.vault.clone(),
});
}
if let Some(active) = registry.active_project.as_deref() {
return registered_project(®istry, active)
.context(format!("active project {active} is not registered"));
}
anyhow::bail!("could not resolve Ward project; run ward init or ward use <project>")
}
pub fn resolve_project_with_passphrase(
explicit_project: Option<&str>,
cwd: &Path,
passphrase: &str,
) -> Result<ResolvedProject> {
let mut resolved = resolve_project(explicit_project, cwd)?;
if let Ok(config) = read_project_config(&resolved.path) {
if config.project == resolved.name {
resolved.vault =
resolve_vault_path_with_passphrase(&resolved.path, &config, passphrase);
}
}
Ok(resolved)
}
fn registered_project(registry: &Registry, project: &str) -> Option<ResolvedProject> {
let registered = registry.projects.get(project)?;
Some(ResolvedProject {
name: project.to_string(),
path: registered.path.clone(),
vault: registered.vault.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
process::Command,
sync::{Mutex, OnceLock},
};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn set_home(path: &Path) {
std::env::set_var("WARD_HOME", path);
}
fn registered(path: &Path, vault: &Path) -> RegisteredProject {
RegisteredProject {
path: path.to_path_buf(),
vault: vault.to_path_buf(),
git_remote: None,
allowed_worktree_roots: Vec::new(),
known_worktrees: Vec::new(),
auto_bind_worktrees: true,
canonical_repo_path: None,
git_common_dir: None,
created_at: "2026-05-26T00:00:00Z".to_string(),
last_used: None,
}
}
#[test]
#[serial_test::serial]
fn load_registry_handles_missing_and_invalid_registry() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
set_home(home.path());
assert!(load_registry().unwrap().projects.is_empty());
std::fs::create_dir_all(home.path()).unwrap();
std::fs::write(registry_path(), "{bad-json}").unwrap();
assert!(load_registry().is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn load_and_save_registry_report_io_failures() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
set_home(home.path());
std::fs::create_dir(registry_path()).unwrap();
assert!(load_registry().is_err());
std::fs::remove_dir(registry_path()).unwrap();
std::fs::write(home.path().join("registry.json"), "{}").unwrap();
std::fs::write(home.path().join("registry.json.tmp"), "").unwrap();
let blocked_home = tempfile::NamedTempFile::new().unwrap();
set_home(blocked_home.path());
assert!(save_registry(&Registry::default()).is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn registry_callers_propagate_load_and_save_failures() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
set_home(home.path());
std::fs::create_dir(registry_path()).unwrap();
assert!(register_project(
"demo".to_string(),
project.path().to_path_buf(),
project.path().join(".env.vault"),
)
.is_err());
assert!(set_active_project("demo").is_err());
assert!(resolve_project(None, project.path()).is_err());
std::fs::remove_dir(registry_path()).unwrap();
let blocked_home = tempfile::NamedTempFile::new().unwrap();
set_home(blocked_home.path());
assert!(register_project(
"demo".to_string(),
project.path().to_path_buf(),
project.path().join(".env.vault"),
)
.is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn register_set_active_and_resolve_explicit_project() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let vault = project.path().join(".env.vault");
set_home(home.path());
register_project(
"demo".to_string(),
project.path().to_path_buf(),
vault.clone(),
)
.unwrap();
set_active_project("demo").unwrap();
let resolved = resolve_project(Some("demo"), project.path()).unwrap();
assert_eq!(resolved.name, "demo");
assert_eq!(resolved.vault, vault);
assert!(set_active_project("missing").is_err());
assert!(resolve_project(Some("missing"), project.path()).is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn resolve_project_uses_local_config_when_registered_or_unregistered() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
set_home(home.path());
std::fs::write(
project.path().join(".ward.json"),
r#"{"version":1,"project":"demo","vault":".env.vault","presets":[]}"#,
)
.unwrap();
let unregistered = resolve_project(None, project.path()).unwrap();
assert_eq!(unregistered.name, "demo");
assert_eq!(unregistered.path, project.path());
let mut registry = Registry::default();
registry.projects.insert(
"demo".to_string(),
registered(project.path(), &project.path().join("registered.vault")),
);
save_registry(®istry).unwrap();
let registered = resolve_project(None, project.path()).unwrap();
assert_eq!(registered.vault, project.path().join("registered.vault"));
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn resolve_project_uses_git_remote_path_ancestry_and_active_project() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let canonical = tempfile::tempdir().unwrap();
let active_root = tempfile::tempdir().unwrap();
let child = canonical.path().join("child");
let git_repo = tempfile::tempdir().unwrap();
set_home(home.path());
std::fs::create_dir(&child).unwrap();
Command::new("git")
.args(["init"])
.current_dir(git_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["remote", "add", "origin", "https://example.test/demo.git"])
.current_dir(git_repo.path())
.output()
.unwrap();
let mut registry = Registry::default();
registry.active_project = Some("active".to_string());
registry.projects.insert(
"remote".to_string(),
RegisteredProject {
git_remote: Some("https://example.test/demo.git".to_string()),
..registered(canonical.path(), &canonical.path().join("remote.vault"))
},
);
registry.projects.insert(
"path".to_string(),
registered(canonical.path(), &canonical.path().join("path.vault")),
);
registry.projects.insert(
"active".to_string(),
registered(active_root.path(), &active_root.path().join("active.vault")),
);
save_registry(®istry).unwrap();
assert_eq!(
resolve_project(None, git_repo.path()).unwrap().name,
"remote"
);
let unmatched_git_repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(unmatched_git_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["remote", "add", "origin", "https://example.test/other.git"])
.current_dir(unmatched_git_repo.path())
.output()
.unwrap();
assert_eq!(
resolve_project(None, unmatched_git_repo.path())
.unwrap()
.name,
"active"
);
assert_eq!(resolve_project(None, &child).unwrap().name, "path");
let outside = tempfile::tempdir().unwrap();
assert_eq!(
resolve_project(None, outside.path()).unwrap().name,
"active"
);
std::env::remove_var("WARD_HOME");
}
#[test]
fn registered_project_returns_none_for_missing_project() {
assert!(registered_project(&Registry::default(), "missing").is_none());
}
#[test]
#[serial_test::serial]
fn resolve_project_reports_stale_active_project() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
set_home(home.path());
let registry = Registry {
active_project: Some("missing".to_string()),
projects: BTreeMap::new(),
};
save_registry(®istry).unwrap();
assert!(resolve_project(None, outside.path()).is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn resolve_project_reports_unresolved_project_without_active_project() {
let _guard = env_lock();
let home = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
set_home(home.path());
save_registry(&Registry::default()).unwrap();
assert!(resolve_project(None, outside.path()).is_err());
std::env::remove_var("WARD_HOME");
}
}