use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::paths::git as git_paths;
use crate::repo::marker::{self as repo_marker, MarkerSubstrate};
const DIRECTORY_WORKSPACE_BINDING_FILE: &str = "workspace.toml";
const DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubstrateKind {
Git,
Directory,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct DirectoryWorkspaceBinding {
#[serde(default = "default_directory_binding_version")]
version: u32,
substrate: MarkerSubstrate,
canonical_root: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedSubstrate {
kind: SubstrateKind,
workspace_state_root: PathBuf,
workspace_binding_path: Option<PathBuf>,
canonical_root: Option<PathBuf>,
}
impl ResolvedSubstrate {
pub(crate) fn resolve(repo_root: &Path, ccd_root: &Path) -> Result<Self> {
let marker = repo_marker::load(repo_root)?;
match marker.as_ref().map(|marker| marker.substrate()) {
Some(MarkerSubstrate::Directory) => Self::resolve_directory(repo_root, ccd_root, true),
_ => Ok(Self::git(git_paths::ccd_dir(repo_root)?)),
}
}
pub(crate) fn resolve_for_attach(repo_root: &Path, ccd_root: &Path) -> Result<Self> {
let marker = repo_marker::load(repo_root)?;
match marker.as_ref().map(|marker| marker.substrate()) {
Some(MarkerSubstrate::Directory) => Self::resolve_directory(repo_root, ccd_root, false),
Some(MarkerSubstrate::Git) => Ok(Self::git(git_paths::ccd_dir(repo_root)?)),
None => match git_paths::ccd_dir(repo_root) {
Ok(path) => Ok(Self::git(path)),
Err(_) => Self::resolve_directory(repo_root, ccd_root, false),
},
}
}
pub(crate) fn git(workspace_state_root: PathBuf) -> Self {
Self {
kind: SubstrateKind::Git,
workspace_state_root,
workspace_binding_path: None,
canonical_root: None,
}
}
pub(crate) fn workspace_state_root(&self) -> &Path {
&self.workspace_state_root
}
pub(crate) fn kind(&self) -> SubstrateKind {
self.kind
}
pub(crate) fn is_git(&self) -> bool {
self.kind == SubstrateKind::Git
}
pub(crate) fn workspace_binding_path(&self) -> Option<&Path> {
self.workspace_binding_path.as_deref()
}
pub(crate) fn workspace_binding_exists(&self) -> bool {
self.workspace_binding_path
.as_ref()
.map(|path| path.exists())
.unwrap_or(false)
}
pub(crate) fn ensure_workspace_binding(&self) -> Result<()> {
if self.kind != SubstrateKind::Directory {
return Ok(());
}
self.validate_directory_state_root()?;
let Some(binding_path) = self.workspace_binding_path.as_ref() else {
return Ok(());
};
let Some(canonical_root) = self.canonical_root.as_ref() else {
return Ok(());
};
fs::create_dir_all(&self.workspace_state_root).with_context(|| {
format!(
"failed to create directory {}",
self.workspace_state_root.display()
)
})?;
let binding = DirectoryWorkspaceBinding {
version: DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION,
substrate: MarkerSubstrate::Directory,
canonical_root: canonical_root.display().to_string(),
};
let contents = toml::to_string(&binding).context("failed to serialize workspace TOML")?;
fs::write(binding_path, contents)
.with_context(|| format!("failed to write {}", binding_path.display()))?;
Ok(())
}
fn resolve_directory(repo_root: &Path, ccd_root: &Path, require_binding: bool) -> Result<Self> {
let metadata = fs::symlink_metadata(repo_root)
.with_context(|| format!("failed to inspect {}", repo_root.display()))?;
if metadata.file_type().is_symlink() {
bail!("directory substrate roots cannot be symlinks; use the canonical directory path");
}
let canonical_root = repo_root
.canonicalize()
.with_context(|| format!("failed to canonicalize {}", repo_root.display()))?;
let workspace_state_root = ccd_root
.join("workspaces")
.join(directory_workspace_id(&canonical_root));
let substrate = Self {
kind: SubstrateKind::Directory,
workspace_binding_path: Some(
workspace_state_root.join(DIRECTORY_WORKSPACE_BINDING_FILE),
),
workspace_state_root,
canonical_root: Some(canonical_root),
};
substrate.validate_directory_state_root()?;
if substrate.workspace_binding_exists() {
substrate.validate_directory_binding()?;
} else if require_binding {
let binding_path = substrate
.workspace_binding_path
.as_ref()
.expect("directory binding path");
bail!(
"directory workspace binding is missing at {}; run `ccd attach` to bootstrap or repair this directory workspace",
binding_path.display()
);
}
Ok(substrate)
}
fn validate_directory_state_root(&self) -> Result<()> {
let Some(canonical_root) = self.canonical_root.as_ref() else {
return Ok(());
};
let normalized_workspace_root = normalize_path_for_comparison(&self.workspace_state_root);
if normalized_workspace_root.starts_with(canonical_root) {
bail!(
"directory workspace state root {} is inside project root {}; configure HOME or CCD state outside the project to avoid committing runtime state",
self.workspace_state_root.display(),
canonical_root.display()
);
}
let visible_ccd_root = canonical_root.join(".ccd");
if visible_ccd_root.exists() {
let metadata = fs::symlink_metadata(&visible_ccd_root)
.with_context(|| format!("failed to inspect {}", visible_ccd_root.display()))?;
if metadata.file_type().is_symlink() {
bail!(
"repo-root {} cannot be a symlink for directory substrate projects",
visible_ccd_root.display()
);
}
if metadata.is_dir() {
let unsafe_entries = fs::read_dir(&visible_ccd_root)
.with_context(|| {
format!("failed to read directory {}", visible_ccd_root.display())
})?
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name != "commands")
.collect::<Vec<_>>();
if !unsafe_entries.is_empty() {
bail!(
"repo-root {} contains visible runtime-state entries ({}); remove them before using the directory substrate",
visible_ccd_root.display(),
unsafe_entries.join(", ")
);
}
}
}
if self.workspace_state_root.exists() {
let metadata = fs::symlink_metadata(&self.workspace_state_root).with_context(|| {
format!("failed to inspect {}", self.workspace_state_root.display())
})?;
if metadata.file_type().is_symlink() {
bail!(
"directory workspace state root {} cannot be a symlink",
self.workspace_state_root.display()
);
}
}
Ok(())
}
fn validate_directory_binding(&self) -> Result<()> {
let Some(binding_path) = self.workspace_binding_path.as_ref() else {
return Ok(());
};
let Some(canonical_root) = self.canonical_root.as_ref() else {
return Ok(());
};
let metadata = fs::symlink_metadata(binding_path)
.with_context(|| format!("failed to inspect {}", binding_path.display()))?;
if metadata.file_type().is_symlink() {
bail!(
"directory workspace binding {} cannot be a symlink",
binding_path.display()
);
}
let contents = fs::read_to_string(binding_path)
.with_context(|| format!("failed to read {}", binding_path.display()))?;
let binding: DirectoryWorkspaceBinding =
toml::from_str(&contents).context("failed to parse workspace TOML")?;
if binding.substrate != MarkerSubstrate::Directory {
bail!(
"directory workspace binding {} must declare substrate = \"directory\"",
binding_path.display()
);
}
let bound_root = PathBuf::from(&binding.canonical_root);
if bound_root != *canonical_root {
bail!(
"directory workspace binding {} points to {} instead of {}; run `ccd attach` to refresh local workspace state",
binding_path.display(),
bound_root.display(),
canonical_root.display()
);
}
Ok(())
}
}
fn default_directory_binding_version() -> u32 {
DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION
}
fn directory_workspace_id(canonical_root: &Path) -> String {
let digest = Sha256::digest(canonical_root.display().to_string().as_bytes());
format!("dir_{digest:x}")
}
fn normalize_path_for_comparison(path: &Path) -> PathBuf {
let mut suffix = Vec::new();
let mut current = path;
loop {
match current.canonicalize() {
Ok(canonical) => {
let mut normalized = canonical;
for component in suffix.iter().rev() {
normalized.push(component);
}
return normalized;
}
Err(_) => {
let Some(file_name) = current.file_name() else {
return path.to_path_buf();
};
suffix.push(file_name.to_os_string());
let Some(parent) = current.parent() else {
return path.to_path_buf();
};
current = parent;
}
}
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::tempdir;
use super::*;
use crate::repo::marker::RepoMarker;
#[test]
fn resolve_detects_git_substrate_and_workspace_state_root() {
let temp = tempdir().expect("tempdir");
let home = tempdir().expect("home");
init_git_repo(temp.path());
let substrate =
ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd")).expect("resolved");
assert_eq!(substrate.kind, SubstrateKind::Git);
assert_eq!(
substrate.workspace_state_root(),
temp.path().join(".git/ccd")
);
}
#[test]
fn resolve_fails_closed_outside_git_repo() {
let temp = tempdir().expect("tempdir");
let home = tempdir().expect("home");
let error = ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd"))
.expect_err("git path should fail");
assert!(error.to_string().contains("git rev-parse --git-path ccd"));
}
#[test]
fn resolve_for_attach_bootstraps_directory_substrate_outside_git_repo() {
let temp = tempdir().expect("tempdir");
let home = tempdir().expect("home");
let substrate =
ResolvedSubstrate::resolve_for_attach(temp.path(), &home.path().join(".ccd"))
.expect("resolved substrate");
assert_eq!(substrate.kind, SubstrateKind::Directory);
assert!(substrate
.workspace_state_root()
.starts_with(home.path().join(".ccd/workspaces")));
}
#[test]
fn resolve_for_attach_rejects_directory_state_inside_project_root() {
let temp = tempdir().expect("tempdir");
let error = ResolvedSubstrate::resolve_for_attach(temp.path(), &temp.path().join(".ccd"))
.expect_err("unsafe ccd root should fail");
assert!(error.to_string().contains("inside project root"));
}
#[test]
fn resolve_directory_requires_binding_once_marker_exists() {
let temp = tempdir().expect("tempdir");
let home = tempdir().expect("home");
let marker = RepoMarker::new_directory("ccdrepo_123", None).expect("marker");
repo_marker::write(temp.path(), &marker).expect("marker written");
let error = ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd"))
.expect_err("missing binding should fail");
assert!(error
.to_string()
.contains("directory workspace binding is missing"));
}
fn init_git_repo(path: &Path) {
let status = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(path)
.status()
.expect("failed to execute `git init`");
assert!(
status.success(),
"git init failed with exit status: {status}"
);
}
}