use std::{
env, fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{BranchError, BranchResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchConfig {
pub workspace_id: Uuid,
pub branches_dir: PathBuf,
pub registry_db_path: PathBuf,
pub max_branches_per_workspace: usize,
pub max_branch_age_days: Option<u64>,
pub snapshot_compression: bool,
pub gc_interval_secs: u64,
pub gc_orphan_threshold_secs: u64,
pub divergence_threshold: f64,
pub auto_metrics: bool,
pub metrics_refresh_interval_secs: u64,
pub trunk_branch_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct BranchConfigBuilder {
workspace_id: Option<Uuid>,
branches_dir: Option<PathBuf>,
registry_db_path: Option<PathBuf>,
max_branches_per_workspace: Option<usize>,
max_branch_age_days: Option<Option<u64>>,
snapshot_compression: Option<bool>,
gc_interval_secs: Option<u64>,
gc_orphan_threshold_secs: Option<u64>,
divergence_threshold: Option<f64>,
auto_metrics: Option<bool>,
metrics_refresh_interval_secs: Option<u64>,
trunk_branch_name: Option<String>,
}
impl BranchConfig {
pub fn builder() -> BranchConfigBuilder {
BranchConfigBuilder::default()
}
pub fn from_env() -> BranchResult<Self> {
let workspace_id = env::var("CLAW_BRANCH_WORKSPACE_ID")
.ok()
.and_then(|value| Uuid::parse_str(&value).ok())
.ok_or_else(|| {
BranchError::NamingError("missing or invalid CLAW_BRANCH_WORKSPACE_ID".to_string())
})?;
let branches_dir = env::var("CLAW_BRANCH_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./branches"));
let registry_db_path = env::var("CLAW_BRANCH_REGISTRY")
.map(PathBuf::from)
.unwrap_or_else(|_| branches_dir.join("branch_registry.db"));
Self::builder()
.workspace_id(workspace_id)
.branches_dir(branches_dir)
.registry_db_path(registry_db_path)
.max_branches_per_workspace(parse_env_or_default("CLAW_BRANCH_MAX", 50_usize)?)
.max_branch_age_days(parse_optional_env("CLAW_BRANCH_MAX_AGE_DAYS")?)
.snapshot_compression(false)
.gc_interval_secs(parse_env_or_default("CLAW_BRANCH_GC_INTERVAL", 3600_u64)?)
.gc_orphan_threshold_secs(parse_env_or_default(
"CLAW_BRANCH_GC_ORPHAN_THRESHOLD",
86400_u64,
)?)
.divergence_threshold(parse_env_or_default(
"CLAW_BRANCH_DIVERGENCE_THRESHOLD",
0.8_f64,
)?)
.auto_metrics(parse_env_or_default("CLAW_BRANCH_AUTO_METRICS", true)?)
.metrics_refresh_interval_secs(parse_env_or_default(
"CLAW_BRANCH_METRICS_REFRESH_INTERVAL",
300_u64,
)?)
.trunk_branch_name(
env::var("CLAW_BRANCH_TRUNK_NAME").unwrap_or_else(|_| "trunk".to_string()),
)
.build()
}
pub fn default_for_workspace(workspace_id: Uuid, base_dir: &Path) -> Self {
let workspace_root = base_dir.join(workspace_id.to_string());
Self {
workspace_id,
branches_dir: workspace_root.join("branches"),
registry_db_path: workspace_root.join("branch_registry.db"),
max_branches_per_workspace: 50,
max_branch_age_days: None,
snapshot_compression: false,
gc_interval_secs: 3600,
gc_orphan_threshold_secs: 86400,
divergence_threshold: 0.8,
auto_metrics: true,
metrics_refresh_interval_secs: 300,
trunk_branch_name: "trunk".to_string(),
}
}
}
impl BranchConfigBuilder {
pub fn workspace_id(mut self, workspace_id: Uuid) -> Self {
self.workspace_id = Some(workspace_id);
self
}
pub fn branches_dir<P: Into<PathBuf>>(mut self, branches_dir: P) -> Self {
self.branches_dir = Some(branches_dir.into());
self
}
pub fn registry_db_path<P: Into<PathBuf>>(mut self, registry_db_path: P) -> Self {
self.registry_db_path = Some(registry_db_path.into());
self
}
pub fn max_branches_per_workspace(mut self, max_branches_per_workspace: usize) -> Self {
self.max_branches_per_workspace = Some(max_branches_per_workspace);
self
}
pub fn max_branch_age_days(mut self, max_branch_age_days: Option<u64>) -> Self {
self.max_branch_age_days = Some(max_branch_age_days);
self
}
pub fn snapshot_compression(mut self, snapshot_compression: bool) -> Self {
self.snapshot_compression = Some(snapshot_compression);
self
}
pub fn gc_interval_secs(mut self, gc_interval_secs: u64) -> Self {
self.gc_interval_secs = Some(gc_interval_secs);
self
}
pub fn gc_orphan_threshold_secs(mut self, gc_orphan_threshold_secs: u64) -> Self {
self.gc_orphan_threshold_secs = Some(gc_orphan_threshold_secs);
self
}
pub fn divergence_threshold(mut self, divergence_threshold: f64) -> Self {
self.divergence_threshold = Some(divergence_threshold);
self
}
pub fn auto_metrics(mut self, auto_metrics: bool) -> Self {
self.auto_metrics = Some(auto_metrics);
self
}
pub fn metrics_refresh_interval_secs(mut self, metrics_refresh_interval_secs: u64) -> Self {
self.metrics_refresh_interval_secs = Some(metrics_refresh_interval_secs);
self
}
pub fn trunk_branch_name<S: Into<String>>(mut self, trunk_branch_name: S) -> Self {
self.trunk_branch_name = Some(trunk_branch_name.into());
self
}
pub fn build(self) -> BranchResult<BranchConfig> {
let workspace_id = self.workspace_id.unwrap_or_default();
let branches_dir = self
.branches_dir
.unwrap_or_else(|| PathBuf::from("./branches"));
let registry_db_path = self
.registry_db_path
.unwrap_or_else(|| branches_dir.join("branch_registry.db"));
let max_branches_per_workspace = self.max_branches_per_workspace.unwrap_or(50);
let divergence_threshold = self.divergence_threshold.unwrap_or(0.8);
if workspace_id.is_nil() {
return Err(BranchError::NamingError(
"workspace_id must not be nil".to_string(),
));
}
if max_branches_per_workspace < 1 {
return Err(BranchError::NamingError(
"max_branches_per_workspace must be at least 1".to_string(),
));
}
if !(0.0..=1.0).contains(&divergence_threshold) || divergence_threshold == 0.0 {
return Err(BranchError::NamingError(
"divergence_threshold must be within (0.0, 1.0]".to_string(),
));
}
if let Some(parent) = branches_dir.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = registry_db_path.parent() {
fs::create_dir_all(parent)?;
}
Ok(BranchConfig {
workspace_id,
branches_dir,
registry_db_path,
max_branches_per_workspace,
max_branch_age_days: self.max_branch_age_days.unwrap_or(None),
snapshot_compression: self.snapshot_compression.unwrap_or(false),
gc_interval_secs: self.gc_interval_secs.unwrap_or(3600),
gc_orphan_threshold_secs: self.gc_orphan_threshold_secs.unwrap_or(86400),
divergence_threshold,
auto_metrics: self.auto_metrics.unwrap_or(true),
metrics_refresh_interval_secs: self.metrics_refresh_interval_secs.unwrap_or(300),
trunk_branch_name: self
.trunk_branch_name
.unwrap_or_else(|| "trunk".to_string()),
})
}
}
fn parse_env_or_default<T>(key: &str, default: T) -> BranchResult<T>
where
T: std::str::FromStr,
{
match env::var(key) {
Ok(value) => value
.parse::<T>()
.map_err(|_| BranchError::NamingError(format!("invalid value for {key}"))),
Err(_) => Ok(default),
}
}
fn parse_optional_env<T>(key: &str) -> BranchResult<Option<T>>
where
T: std::str::FromStr,
{
match env::var(key) {
Ok(value) => value
.parse::<T>()
.map(Some)
.map_err(|_| BranchError::NamingError(format!("invalid value for {key}"))),
Err(_) => Ok(None),
}
}