use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::Deserialize;
use crate::paths::substrate::ResolvedSubstrate;
use crate::profile::ProfileName;
use crate::repo::marker::validate_locality_id;
pub const CCD_DIR_NAME: &str = ".ccd";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateLayout {
ccd_root: PathBuf,
substrate: ResolvedSubstrate,
profile: ProfileName,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ProfileConfigFile {
pub focus: DispatchConfig,
pub dispatch: DispatchConfig,
pub memory: MemoryConfig,
pub sync: SyncConfig,
pub telemetry: TelemetryConfig,
}
impl ProfileConfigFile {
pub fn resolved_dispatch(&self) -> &DispatchConfig {
if self.dispatch.has_value() {
&self.dispatch
} else {
&self.focus
}
}
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct DispatchConfig {
pub coordination_scope: Option<String>,
pub pod: Option<String>,
}
impl DispatchConfig {
pub fn has_value(&self) -> bool {
self.coordination_scope.is_some() || self.pod.is_some()
}
pub fn resolved_coordination_scope(&self) -> Option<&str> {
self.coordination_scope.as_deref().or(self.pod.as_deref())
}
pub fn resolved_coordination_scope_source(&self) -> Option<CoordinationScopeKey> {
if self.coordination_scope.is_some() {
Some(CoordinationScopeKey::CoordinationScope)
} else if self.pod.is_some() {
Some(CoordinationScopeKey::PodAlias)
} else {
None
}
}
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct SyncConfig {
pub soft_line_limit: Option<usize>,
pub hard_line_limit: Option<usize>,
pub stub_soft_line_limit: Option<usize>,
pub stub_hard_line_limit: Option<usize>,
pub enforce_lf: Option<bool>,
pub claude_header: Option<String>,
pub gemini_header: Option<String>,
pub stub_body: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct RepoOverlayConfigFile {
pub sources: SourcesConfig,
pub doctor: DoctorOverlayConfig,
pub extensions: Vec<ExtensionBlock>,
pub hosts: BTreeMap<String, HostIntegrationConfig>,
pub dispatch: DispatchConfig,
pub focus: DispatchConfig,
pub memory: MemoryConfig,
pub telemetry: TelemetryConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct HostIntegrationConfig {
pub mode: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub authoritative_provider: Option<String>,
pub recall_provider: Option<String>,
pub ingest_provider: Option<String>,
pub start_recall_policy: Option<String>,
pub providers: BTreeMap<String, MemoryProviderConfig>,
pub ingest: BTreeMap<String, MemoryProviderConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct MemoryProviderConfig {
pub kind: Option<String>,
pub mode: Option<String>,
pub command: Option<Vec<String>>,
pub timeout_s: Option<u64>,
pub capabilities: Option<Vec<String>>,
pub allowed_scopes: Option<Vec<String>>,
pub inputs: BTreeMap<String, String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct SourcesConfig {
pub always: Vec<String>,
pub relevant: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct TelemetryConfig {
pub cost: TelemetryCostConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct TelemetryCostConfig {
pub session_warn_usd: Option<f64>,
pub item_warn_usd: Option<f64>,
pub models: Vec<TelemetryCostModelConfig>,
}
impl TelemetryCostConfig {
pub(crate) fn merge(profile: &Self, overlay: Option<&Self>) -> Self {
let mut models = Vec::new();
if let Some(overlay) = overlay {
models.extend(overlay.models.clone());
}
models.extend(profile.models.clone());
Self {
session_warn_usd: overlay
.and_then(|config| config.session_warn_usd)
.or(profile.session_warn_usd),
item_warn_usd: overlay
.and_then(|config| config.item_warn_usd)
.or(profile.item_warn_usd),
models,
}
}
pub(crate) fn is_configured(&self) -> bool {
self.session_warn_usd.is_some() || self.item_warn_usd.is_some() || !self.models.is_empty()
}
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct TelemetryCostModelConfig {
#[serde(rename = "match")]
pub match_name: String,
pub aliases: Vec<String>,
pub input_usd_per_million: Option<f64>,
pub output_usd_per_million: Option<f64>,
pub cache_write_usd_per_million: Option<f64>,
pub cache_read_usd_per_million: Option<f64>,
pub blended_usd_per_million: Option<f64>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DoctorOverlayConfig {
pub handoff: HandoffDoctorOverlayConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandoffDoctorOverlayConfig {
pub missing: Option<String>,
pub sections: Option<String>,
pub sections_empty: Option<String>,
pub narration: Option<String>,
pub freshness: Option<String>,
}
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Debug, Clone, Deserialize)]
pub struct ExtensionBlock {
#[serde(rename = "type")]
pub extension_type: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub default: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CoordinationScopeKey {
CoordinationScope,
PodAlias,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CoordinationScopeSource {
RepoDispatchCoordinationScope,
RepoDispatchPodAlias,
RepoFocusPodAlias,
ProfileDispatchCoordinationScope,
ProfileDispatchPodAlias,
ProfileFocusPodAlias,
}
impl CoordinationScopeSource {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::RepoDispatchCoordinationScope => "repo_dispatch_coordination_scope",
Self::RepoDispatchPodAlias => "repo_dispatch_pod_alias",
Self::RepoFocusPodAlias => "repo_focus_pod_alias",
Self::ProfileDispatchCoordinationScope => "profile_dispatch_coordination_scope",
Self::ProfileDispatchPodAlias => "profile_dispatch_pod_alias",
Self::ProfileFocusPodAlias => "profile_focus_pod_alias",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedCoordinationScope {
pub name: String,
pub source: CoordinationScopeSource,
pub config_path: PathBuf,
pub shared_root: PathBuf,
}
impl StateLayout {
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(ccd_root: PathBuf, git_ccd_root: PathBuf, profile: ProfileName) -> Self {
Self::new_with_substrate(ccd_root, ResolvedSubstrate::git(git_ccd_root), profile)
}
pub(crate) fn new_with_substrate(
ccd_root: PathBuf,
substrate: ResolvedSubstrate,
profile: ProfileName,
) -> Self {
Self {
ccd_root,
substrate,
profile,
}
}
pub fn resolve(repo_root: &Path, profile: ProfileName) -> Result<Self> {
let ccd_root = default_ccd_root()?;
Ok(Self::new_with_substrate(
ccd_root.clone(),
ResolvedSubstrate::resolve(repo_root, &ccd_root)?,
profile,
))
}
pub(crate) fn resolve_for_attach(repo_root: &Path, profile: ProfileName) -> Result<Self> {
let ccd_root = default_ccd_root()?;
Ok(Self::new_with_substrate(
ccd_root.clone(),
ResolvedSubstrate::resolve_for_attach(repo_root, &ccd_root)?,
profile,
))
}
pub fn ccd_root(&self) -> &Path {
&self.ccd_root
}
pub fn workspace_state_root(&self) -> &Path {
self.substrate.workspace_state_root()
}
pub(crate) fn resolved_substrate(&self) -> &ResolvedSubstrate {
&self.substrate
}
pub fn profile(&self) -> &ProfileName {
&self.profile
}
pub fn profile_root(&self) -> PathBuf {
self.ccd_root.join("profiles").join(self.profile.as_str())
}
pub fn profile_config_path(&self) -> PathBuf {
self.profile_root().join("config.toml")
}
pub fn profile_policy_path(&self) -> PathBuf {
self.profile_root().join("policy.md")
}
pub fn profile_memory_path(&self) -> PathBuf {
self.profile_root().join("memory.md")
}
pub fn profile_repos_root(&self) -> PathBuf {
self.profile_root().join("repos")
}
pub fn repo_registry_root(&self) -> PathBuf {
self.ccd_root.join("repos")
}
pub fn repo_registry_dir(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self
.repo_registry_root()
.join(validate_locality_id(locality_id)?))
}
pub fn repo_metadata_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_registry_dir(locality_id)?.join("repo.toml"))
}
pub fn repo_overlay_root(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self
.profile_repos_root()
.join(validate_locality_id(locality_id)?))
}
pub fn repo_manifest_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("manifest.md"))
}
pub fn repo_policy_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("policy.md"))
}
pub fn locality_policy_path(&self, locality_id: &str) -> Result<PathBuf> {
self.repo_policy_path(locality_id)
}
pub fn repo_memory_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("memory.md"))
}
pub fn locality_memory_path(&self, locality_id: &str) -> Result<PathBuf> {
self.repo_memory_path(locality_id)
}
pub fn branch_memory_root(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("branches"))
}
pub fn branch_memory_path(&self, locality_id: &str, branch: &str) -> Result<PathBuf> {
if branch.is_empty() {
bail!("branch name cannot be empty");
}
if branch.contains('\0') {
bail!("branch name cannot contain NUL bytes");
}
Ok(self
.branch_memory_root(locality_id)?
.join(encode_branch_memory_segment(branch))
.join("memory.md"))
}
pub fn profile_runtime_state_path(&self) -> PathBuf {
self.profile_root().join("runtime_state.json")
}
pub fn repo_runtime_state_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self
.repo_overlay_root(locality_id)?
.join("runtime_state.json"))
}
pub fn locality_runtime_state_path(&self, locality_id: &str) -> Result<PathBuf> {
self.repo_runtime_state_path(locality_id)
}
pub fn repo_validation_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("validation.toml"))
}
pub fn repo_overlay_config_path(&self, locality_id: &str) -> Result<PathBuf> {
Ok(self.repo_overlay_root(locality_id)?.join("config.toml"))
}
pub fn load_repo_overlay_config(
&self,
locality_id: &str,
) -> Result<Option<RepoOverlayConfigFile>> {
let path = self.repo_overlay_config_path(locality_id)?;
load_repo_overlay_config(&path)
}
pub fn clone_profile_root(&self) -> PathBuf {
self.workspace_state_root()
.join("profiles")
.join(self.profile.as_str())
}
pub fn state_db_path(&self) -> PathBuf {
self.clone_profile_root().join("state.db")
}
pub fn clone_memory_path(&self) -> PathBuf {
self.clone_profile_root().join("memory.md")
}
pub fn handoff_path(&self) -> PathBuf {
self.clone_profile_root().join("handoff.md")
}
pub fn session_state_path(&self) -> PathBuf {
self.clone_profile_root().join("session_state.json")
}
pub fn clone_working_buffer_path(&self) -> PathBuf {
self.clone_profile_root().join("working_buffer.md")
}
pub fn clone_checkpoint_path(&self) -> PathBuf {
self.clone_profile_root().join("checkpoint.json")
}
pub fn clone_runtime_state_root(&self) -> PathBuf {
self.clone_profile_root().join("runtime_state")
}
pub fn clone_runtime_state_path(&self) -> PathBuf {
self.clone_runtime_state_root().join("state.json")
}
pub fn clone_projection_metadata_path(&self) -> PathBuf {
self.clone_runtime_state_root()
.join("projection_metadata.json")
}
pub fn escalation_state_path(&self) -> PathBuf {
self.clone_runtime_state_root().join("escalation.json")
}
pub fn compiled_state_path(&self) -> PathBuf {
self.clone_profile_root().join("compiled_state.json")
}
pub fn load_profile_config(&self) -> Result<ProfileConfigFile> {
load_profile_config(&self.profile_config_path())
}
pub(crate) fn pod_root(&self, pod: &str) -> Result<PathBuf> {
validate_dispatch_pod_name(pod)?;
Ok(self.ccd_root.join("pods").join(pod))
}
pub(crate) fn pod_machine_manifest_path(&self, pod: &str) -> Result<PathBuf> {
Ok(self.pod_root(pod)?.join("machine.toml"))
}
pub(crate) fn pod_machine_presence_root(&self, pod: &str) -> Result<PathBuf> {
Ok(self.pod_root(pod)?.join("presence"))
}
pub(crate) fn pod_machine_presence_path(&self, pod: &str, machine_id: &str) -> Result<PathBuf> {
validate_machine_id(machine_id)?;
Ok(self
.pod_machine_presence_root(pod)?
.join(format!("{machine_id}.json")))
}
pub(crate) fn pod_repo_overlay_root(&self, pod: &str, locality_id: &str) -> Result<PathBuf> {
Ok(self
.pod_root(pod)?
.join("repos")
.join(validate_locality_id(locality_id)?))
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn pod_extensions_root(&self, pod: &str, locality_id: &str) -> Result<PathBuf> {
Ok(self
.pod_repo_overlay_root(pod, locality_id)?
.join("extensions"))
}
pub(crate) fn pod_memory_path(&self, pod: &str) -> Result<PathBuf> {
Ok(self.pod_root(pod)?.join("memory.md"))
}
pub(crate) fn coordination_scope_name(&self, locality_id: &str) -> Result<Option<String>> {
Ok(self
.resolved_coordination_scope(locality_id)?
.map(|resolved| resolved.name))
}
pub(crate) fn resolved_coordination_scope(
&self,
locality_id: &str,
) -> Result<Option<ResolvedCoordinationScope>> {
let overlay_path = self.repo_overlay_root(locality_id)?.join("config.toml");
if let Some(overlay_config) = load_repo_overlay_config(&overlay_path)? {
if let Some(resolved) = resolved_coordination_scope_from_repo_config(
&overlay_config,
&overlay_path,
locality_id,
self,
)? {
return Ok(Some(resolved));
}
}
let profile_path = self.profile_config_path();
let profile_config = load_profile_config(&profile_path)?;
resolved_coordination_scope_from_profile_config(
&profile_config,
&profile_path,
locality_id,
self,
)
}
pub(crate) fn focus_pod_name(&self, locality_id: &str) -> Result<Option<String>> {
self.coordination_scope_name(locality_id)
}
pub(crate) fn effective_telemetry_cost_config(
&self,
locality_id: &str,
) -> Result<TelemetryCostConfig> {
let profile_config = load_profile_config(&self.profile_config_path())?;
let overlay_path = self.repo_overlay_root(locality_id)?.join("config.toml");
let overlay_config = load_repo_overlay_config(&overlay_path)?;
Ok(TelemetryCostConfig::merge(
&profile_config.telemetry.cost,
overlay_config.as_ref().map(|config| &config.telemetry.cost),
))
}
}
pub fn default_ccd_root() -> Result<PathBuf> {
let home = env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
if home.is_empty() {
bail!("HOME is empty");
}
Ok(PathBuf::from(home).join(CCD_DIR_NAME))
}
fn load_profile_config(path: &Path) -> Result<ProfileConfigFile> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ProfileConfigFile::default());
}
Err(error) => return Err(error.into()),
};
if contents.trim().is_empty() {
return Ok(ProfileConfigFile::default());
}
Ok(toml::from_str::<ProfileConfigFile>(&contents)?)
}
fn load_repo_overlay_config(path: &Path) -> Result<Option<RepoOverlayConfigFile>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if contents.trim().is_empty() {
return Ok(Some(RepoOverlayConfigFile::default()));
}
Ok(Some(toml::from_str::<RepoOverlayConfigFile>(&contents)?))
}
#[derive(Debug)]
pub(crate) struct PodMembership {
pub pod_name: String,
pub profile: String,
pub locality_id: String,
}
pub(crate) fn scan_all_pod_memberships(ccd_root: &Path) -> Result<Vec<PodMembership>> {
let mut memberships = Vec::new();
let profiles_dir = ccd_root.join("profiles");
let Ok(profiles) = fs::read_dir(&profiles_dir) else {
return Ok(memberships);
};
for profile_entry in profiles.flatten() {
let profile_name = profile_entry.file_name().to_string_lossy().to_string();
let repos_dir = profile_entry.path().join("repos");
let profile_config_path = profile_entry.path().join("config.toml");
let profile_pod_fallback = load_profile_config(&profile_config_path)
.ok()
.and_then(|c| {
c.resolved_dispatch()
.resolved_coordination_scope()
.map(str::to_owned)
});
if let Ok(repos) = fs::read_dir(&repos_dir) {
for repo_entry in repos.flatten() {
let locality_id = repo_entry.file_name().to_string_lossy().to_string();
let config_path = repo_entry.path().join("config.toml");
if let Some(config) = load_repo_overlay_config(&config_path)? {
let effective = if config.dispatch.has_value() {
&config.dispatch
} else {
&config.focus
};
if let Some(pod) = effective.resolved_coordination_scope() {
memberships.push(PodMembership {
pod_name: pod.to_owned(),
profile: profile_name.clone(),
locality_id: locality_id.clone(),
});
continue;
}
}
if let Some(ref pod) = profile_pod_fallback {
memberships.push(PodMembership {
pod_name: pod.clone(),
profile: profile_name.clone(),
locality_id: locality_id.clone(),
});
}
}
}
}
Ok(memberships)
}
pub(crate) fn validate_pod_name(value: &str) -> Result<()> {
validate_dispatch_pod_name(value)
}
pub(crate) fn validate_coordination_scope_name(value: &str) -> Result<()> {
validate_dispatch_pod_name(value)
}
pub(crate) fn validate_machine_id(value: &str) -> Result<()> {
validate_dispatch_pod_name(value)
}
fn resolved_coordination_scope_from_repo_config(
config: &RepoOverlayConfigFile,
config_path: &Path,
locality_id: &str,
layout: &StateLayout,
) -> Result<Option<ResolvedCoordinationScope>> {
if let Some(name) = config.dispatch.resolved_coordination_scope() {
validate_coordination_scope_name(name)?;
return Ok(Some(ResolvedCoordinationScope {
name: name.to_owned(),
source: match config.dispatch.resolved_coordination_scope_source() {
Some(CoordinationScopeKey::CoordinationScope) => {
CoordinationScopeSource::RepoDispatchCoordinationScope
}
Some(CoordinationScopeKey::PodAlias) => {
CoordinationScopeSource::RepoDispatchPodAlias
}
None => unreachable!("dispatch config must have a value"),
},
config_path: config_path.to_path_buf(),
shared_root: layout.pod_repo_overlay_root(name, locality_id)?,
}));
}
if let Some(name) = config.focus.resolved_coordination_scope() {
validate_coordination_scope_name(name)?;
return Ok(Some(ResolvedCoordinationScope {
name: name.to_owned(),
source: CoordinationScopeSource::RepoFocusPodAlias,
config_path: config_path.to_path_buf(),
shared_root: layout.pod_repo_overlay_root(name, locality_id)?,
}));
}
Ok(None)
}
fn resolved_coordination_scope_from_profile_config(
config: &ProfileConfigFile,
config_path: &Path,
locality_id: &str,
layout: &StateLayout,
) -> Result<Option<ResolvedCoordinationScope>> {
if let Some(name) = config.dispatch.resolved_coordination_scope() {
validate_coordination_scope_name(name)?;
return Ok(Some(ResolvedCoordinationScope {
name: name.to_owned(),
source: match config.dispatch.resolved_coordination_scope_source() {
Some(CoordinationScopeKey::CoordinationScope) => {
CoordinationScopeSource::ProfileDispatchCoordinationScope
}
Some(CoordinationScopeKey::PodAlias) => {
CoordinationScopeSource::ProfileDispatchPodAlias
}
None => unreachable!("dispatch config must have a value"),
},
config_path: config_path.to_path_buf(),
shared_root: layout.pod_repo_overlay_root(name, locality_id)?,
}));
}
if let Some(name) = config.focus.resolved_coordination_scope() {
validate_coordination_scope_name(name)?;
return Ok(Some(ResolvedCoordinationScope {
name: name.to_owned(),
source: CoordinationScopeSource::ProfileFocusPodAlias,
config_path: config_path.to_path_buf(),
shared_root: layout.pod_repo_overlay_root(name, locality_id)?,
}));
}
Ok(None)
}
fn validate_dispatch_pod_name(value: &str) -> Result<()> {
if value.is_empty() {
bail!("dispatch pod name cannot be empty");
}
if value == "." || value == ".." {
bail!("dispatch pod name cannot be `.` or `..`");
}
if value.contains('/') || value.contains('\\') {
bail!("dispatch pod name cannot contain path separators");
}
if value.as_bytes().contains(&0) {
bail!("dispatch pod name cannot contain NUL bytes");
}
Ok(())
}
fn encode_branch_memory_segment(branch: &str) -> String {
let mut encoded = String::new();
for byte in branch.bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => {
encoded.push(char::from(byte));
}
_ => {
encoded.push('%');
encoded.push(hex_nibble(byte >> 4));
encoded.push(hex_nibble(byte & 0x0f));
}
}
}
encoded
}
fn hex_nibble(value: u8) -> char {
match value {
0..=9 => char::from(b'0' + value),
10..=15 => char::from(b'A' + (value - 10)),
_ => unreachable!("hex nibble must be within 0..=15"),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn builds_canonical_profile_repo_and_clone_paths() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
layout.profile_root(),
PathBuf::from("/tmp/home/.ccd/profiles/main")
);
assert_eq!(
layout.profile_config_path(),
PathBuf::from("/tmp/home/.ccd/profiles/main/config.toml")
);
assert_eq!(
layout
.repo_metadata_path("ccdrepo_123")
.expect("repo metadata path"),
PathBuf::from("/tmp/home/.ccd/repos/ccdrepo_123/repo.toml")
);
assert_eq!(
layout
.repo_manifest_path("ccdrepo_123")
.expect("repo manifest path"),
PathBuf::from("/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/manifest.md")
);
assert_eq!(
layout
.branch_memory_path("ccdrepo_123", "session/feature-1")
.expect("branch memory path"),
PathBuf::from(
"/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/branches/session%2Ffeature-1/memory.md"
)
);
assert_eq!(
layout.profile_runtime_state_path(),
PathBuf::from("/tmp/home/.ccd/profiles/main/runtime_state.json")
);
assert_eq!(
layout
.repo_runtime_state_path("ccdrepo_123")
.expect("repo runtime state path"),
PathBuf::from("/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/runtime_state.json")
);
assert_eq!(
layout
.repo_validation_path("ccdrepo_123")
.expect("repo validation path"),
PathBuf::from("/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/validation.toml")
);
assert_eq!(
layout.session_state_path(),
PathBuf::from("/tmp/repo/.git/ccd/profiles/main/session_state.json")
);
assert_eq!(
layout.clone_working_buffer_path(),
PathBuf::from("/tmp/repo/.git/ccd/profiles/main/working_buffer.md")
);
assert_eq!(
layout.clone_checkpoint_path(),
PathBuf::from("/tmp/repo/.git/ccd/profiles/main/checkpoint.json")
);
assert_eq!(
layout.clone_runtime_state_path(),
PathBuf::from("/tmp/repo/.git/ccd/profiles/main/runtime_state/state.json")
);
assert_eq!(
layout.clone_projection_metadata_path(),
PathBuf::from(
"/tmp/repo/.git/ccd/profiles/main/runtime_state/projection_metadata.json"
)
);
assert_eq!(
layout.compiled_state_path(),
PathBuf::from("/tmp/repo/.git/ccd/profiles/main/compiled_state.json")
);
}
#[test]
fn rejects_invalid_project_ids() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let error = layout
.repo_overlay_root("../bad")
.expect_err("project_id should fail");
assert!(error.to_string().contains("project_id"));
}
#[test]
fn branch_memory_path_rejects_empty_branch_names() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let error = layout
.branch_memory_path("ccdrepo_123", "")
.expect_err("empty branch should fail");
assert!(error.to_string().contains("branch name"));
}
#[test]
fn profile_config_ignores_backlog_sections() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[focus]
pod = "operator"
[sync]
soft_line_limit = 120
[backlog]
default = "github"
[backlog.adapters.github]
kind = "builtin"
provider = "github-issues"
"#,
)
.expect("write config");
let config = load_profile_config(&config_path).expect("parse config");
assert_eq!(config.focus.pod.as_deref(), Some("operator"));
assert_eq!(config.sync.soft_line_limit, Some(120));
}
#[test]
fn dispatch_section_takes_priority_over_focus() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[focus]
pod = "legacy-pod"
[dispatch]
pod = "new-pod"
"#,
)
.expect("write config");
let config = load_profile_config(&config_path).expect("parse config");
assert_eq!(config.resolved_dispatch().pod.as_deref(), Some("new-pod"));
assert_eq!(config.focus.pod.as_deref(), Some("legacy-pod"));
}
#[test]
fn focus_section_still_works_without_dispatch() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(&config_path, "[focus]\npod = \"operator\"\n").expect("write config");
let config = load_profile_config(&config_path).expect("parse config");
assert_eq!(config.resolved_dispatch().pod.as_deref(), Some("operator"));
}
#[test]
fn rejects_invalid_dispatch_pod_names() {
for invalid in ["", ".", "..", "ops/dev", "ops\\dev", "ops\0dev"] {
let error = validate_dispatch_pod_name(invalid).expect_err("invalid pod name");
assert!(!error.to_string().is_empty());
}
}
#[test]
fn default_ccd_root_uses_home_environment() {
let temp = tempdir().expect("tempdir");
let expected = temp.path().join(".ccd");
let actual = default_ccd_root_with(Some(temp.path()));
assert_eq!(actual.expect("ccd root"), expected);
}
#[test]
fn default_ccd_root_requires_home() {
let error = default_ccd_root_with(None).expect_err("home should be required");
assert!(error.to_string().contains("HOME"));
}
fn default_ccd_root_with(home: Option<&Path>) -> Result<PathBuf> {
match home {
Some(path) => Ok(path.join(CCD_DIR_NAME)),
None => bail!("HOME is not set"),
}
}
#[test]
fn repo_overlay_config_path_is_derived_from_overlay_root() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
layout
.repo_overlay_config_path("ccdrepo_123")
.expect("config path"),
PathBuf::from("/tmp/home/.ccd/profiles/main/repos/ccdrepo_123/config.toml")
);
}
#[test]
fn repo_overlay_config_parses_complete_file() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[sources]
always = ["AGENTS.md", "README.md"]
relevant = ["specs/"]
[doctor.handoff]
freshness = "error"
sections = "error"
[[extensions]]
type = "backlog"
name = "github"
repo = "dusk-network/ccd"
base_branch = "main"
"#,
)
.expect("write config");
let config = load_repo_overlay_config(&config_path)
.expect("parse")
.expect("should be Some");
assert_eq!(config.sources.always, vec!["AGENTS.md", "README.md"]);
assert_eq!(config.sources.relevant, vec!["specs/"]);
assert_eq!(config.doctor.handoff.freshness.as_deref(), Some("error"));
assert_eq!(config.doctor.handoff.sections.as_deref(), Some("error"));
assert_eq!(config.extensions.len(), 1);
assert_eq!(config.extensions[0].extension_type, "backlog");
assert_eq!(config.extensions[0].name.as_deref(), Some("github"));
assert_eq!(
config.extensions[0]
.extra
.get("repo")
.and_then(|v| v.as_str()),
Some("dusk-network/ccd")
);
}
#[test]
fn repo_overlay_config_parses_partial_file() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[sources]
always = ["AGENTS.md"]
"#,
)
.expect("write config");
let config = load_repo_overlay_config(&config_path)
.expect("parse")
.expect("should be Some");
assert_eq!(config.sources.always, vec!["AGENTS.md"]);
assert!(config.sources.relevant.is_empty());
assert!(config.extensions.is_empty());
}
#[test]
fn repo_overlay_config_parses_host_expectations() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[hosts.claude]
mode = "native_hook"
[hosts.codex]
mode = "manual_skill"
"#,
)
.expect("write config");
let config = load_repo_overlay_config(&config_path)
.expect("parse")
.expect("should be Some");
assert_eq!(
config
.hosts
.get("claude")
.and_then(|host| host.mode.as_deref()),
Some("native_hook")
);
assert_eq!(
config
.hosts
.get("codex")
.and_then(|host| host.mode.as_deref()),
Some("manual_skill")
);
}
#[test]
fn repo_overlay_config_returns_none_for_missing_file() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
let result = load_repo_overlay_config(&config_path).expect("should not error");
assert!(result.is_none());
}
#[test]
fn repo_overlay_config_parses_empty_file() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(&config_path, "").expect("write empty");
let config = load_repo_overlay_config(&config_path)
.expect("parse")
.expect("should be Some");
assert!(config.sources.always.is_empty());
assert!(config.extensions.is_empty());
}
#[test]
fn repo_overlay_config_parses_multiple_extensions() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[[extensions]]
type = "backlog"
name = "github"
default = true
repo = "dusk-network/ccd"
[[extensions]]
type = "backlog"
name = "local"
kind = "builtin"
provider = "local-markdown"
"#,
)
.expect("write config");
let config = load_repo_overlay_config(&config_path)
.expect("parse")
.expect("should be Some");
assert_eq!(config.extensions.len(), 2);
assert_eq!(config.extensions[0].extension_type, "backlog");
assert!(config.extensions[0].default);
assert_eq!(config.extensions[1].name.as_deref(), Some("local"));
assert!(!config.extensions[1].default);
assert_eq!(
config.extensions[1]
.extra
.get("provider")
.and_then(|v| v.as_str()),
Some("local-markdown")
);
}
#[test]
fn repo_overlay_config_parses_dispatch_pod() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "[dispatch]\npod = \"operator\"\n").unwrap();
let config: super::RepoOverlayConfigFile =
toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(config.dispatch.pod.as_deref(), Some("operator"));
assert_eq!(
config.dispatch.resolved_coordination_scope(),
Some("operator")
);
}
#[test]
fn repo_overlay_config_parses_coordination_scope() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "[dispatch]\ncoordination_scope = \"operator\"\n").unwrap();
let config: super::RepoOverlayConfigFile =
toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
config.dispatch.coordination_scope.as_deref(),
Some("operator")
);
assert_eq!(
config.dispatch.resolved_coordination_scope(),
Some("operator")
);
}
#[test]
fn repo_overlay_config_parses_legacy_focus_pod() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "[focus]\npod = \"legacy-pod\"\n").unwrap();
let config: super::RepoOverlayConfigFile =
toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(config.focus.pod.as_deref(), Some("legacy-pod"));
}
#[test]
fn pod_extensions_root_returns_correct_path() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let path = layout
.pod_extensions_root("mypod", "ccdrepo_abc123")
.unwrap();
assert_eq!(
path,
PathBuf::from("/tmp/home/.ccd/pods/mypod/repos/ccdrepo_abc123/extensions")
);
}
#[test]
fn pod_memory_path_returns_correct_path() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
layout.pod_memory_path("mypod").unwrap(),
PathBuf::from("/tmp/home/.ccd/pods/mypod/memory.md")
);
}
#[test]
fn pod_machine_manifest_path_returns_correct_path() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
layout.pod_machine_manifest_path("mypod").unwrap(),
PathBuf::from("/tmp/home/.ccd/pods/mypod/machine.toml")
);
}
#[test]
fn pod_machine_presence_path_returns_correct_path() {
let layout = StateLayout::new(
PathBuf::from("/tmp/home/.ccd"),
PathBuf::from("/tmp/repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
assert_eq!(
layout
.pod_machine_presence_path("mypod", "laptop-amsterdam")
.unwrap(),
PathBuf::from("/tmp/home/.ccd/pods/mypod/presence/laptop-amsterdam.json")
);
}
#[test]
fn repo_overlay_config_rejects_unknown_doctor_keys() {
let temp = tempdir().expect("tempdir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[doctor.handoff]
freshnes = "error"
"#,
)
.expect("write config");
let result = load_repo_overlay_config(&config_path);
assert!(
result.is_err(),
"expected parse error for unknown doctor key, got: {:?}",
result
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field"),
"error should mention unknown field, got: {err}"
);
}
}