use std::path::PathBuf;
pub const PROJECT_CONFIG_FILE: &str = ".cflx.jsonc";
pub const GLOBAL_CONFIG_DIR: &str = "cflx";
pub const GLOBAL_CONFIG_FILE: &str = "config.jsonc";
#[allow(dead_code)]
pub const DEFAULT_APPLY_COMMAND: &str = "opencode run '/openspec-apply {change_id}'";
#[allow(dead_code)]
pub const DEFAULT_ARCHIVE_COMMAND: &str = "opencode run '/conflux:archive {change_id}'";
#[allow(dead_code)]
pub const DEFAULT_ACCEPTANCE_COMMAND: &str = "opencode run '/cflx-accept {change_id} {prompt}'";
#[allow(dead_code)]
pub const DEFAULT_RESOLVE_COMMAND: &str = "opencode run {prompt}";
#[allow(dead_code)]
pub const DEFAULT_ANALYZE_COMMAND: &str = "opencode run --format json {prompt}";
pub const DEFAULT_ANALYZE_SKILL: &str = "cflx-analyze";
pub const DEFAULT_APPLY_SKILL: &str = "cflx-apply";
pub const DEFAULT_REJECTING_SKILL: &str = "cflx-rejecting";
pub const DEFAULT_CLEANUP_REVIEW_SKILL: &str = "cflx-cleanup-review";
pub const DEFAULT_ACCEPT_SKILL: &str = "cflx-accept";
pub const DEFAULT_ARCHIVE_SKILL: &str = "cflx-archive";
pub const DEFAULT_RESOLVE_SKILL: &str = "cflx-resolve";
pub const DEFAULT_APPLY_PROMPT: &str = r#"
<system-context>
IMPORTANT: You are running in the repository root directory.
The change you are working on is located at: openspec/changes/{change_id}/
All file paths should be relative to the repository root.
</system-context>
"#;
pub const DEFAULT_ARCHIVE_PROMPT: &str = "";
#[allow(dead_code)]
pub const ACCEPTANCE_SYSTEM_PROMPT: &str = "";
pub const DEFAULT_ACCEPTANCE_PROMPT: &str = "";
pub const DEFAULT_MAX_ITERATIONS: u32 = 50;
pub const DEFAULT_MAX_CONCURRENT_WORKSPACES: usize = 3;
#[allow(dead_code)]
pub const DEFAULT_WORKSPACE_BASE_DIR: &str = "";
pub const DEFAULT_SUPPRESS_REPETITIVE_DEBUG: bool = true;
pub const DEFAULT_LOG_SUMMARY_INTERVAL_SECS: u64 = 60;
pub const DEFAULT_STALL_DETECTION_ENABLED: bool = true;
pub const DEFAULT_STALL_DETECTION_THRESHOLD: u32 = 5;
pub const DEFAULT_APPLY_ESCALATION_AFTER_EMPTY_WIP: Option<u32> = None;
pub const DEFAULT_APPLY_ESCALATION_MAX_USES_PER_STALL: Option<u32> = None;
pub const DEFAULT_ACCEPTANCE_ESCALATION_AFTER_INVALID_RESULTS: u32 = 1;
pub const DEFAULT_ACCEPTANCE_ESCALATION_MAX_USES_PER_SEQUENCE: u32 = 1;
pub const DEFAULT_STAGGER_DELAY_MS: u64 = 2000;
pub const DEFAULT_MAX_RETRIES: u32 = 2;
pub const DEFAULT_RETRY_DELAY_MS: u64 = 5000;
pub const DEFAULT_RETRY_IF_DURATION_UNDER_SECS: u64 = 5;
pub const DEFAULT_COMMAND_INACTIVITY_TIMEOUT_SECS: u64 = 900;
pub const DEFAULT_COMMAND_INACTIVITY_KILL_GRACE_SECS: u64 = 5;
pub const DEFAULT_COMMAND_INACTIVITY_TIMEOUT_MAX_RETRIES: u32 = 3;
pub const DEFAULT_COMMAND_MAX_RUNTIME_SECS: u64 = 10800;
pub const DEFAULT_ACCEPTANCE_MAX_RUNTIME_SECS: u64 = 1800;
pub const MIN_ACCEPTANCE_MAX_RUNTIME_SECS: u64 = 300;
pub const MAX_ACCEPTANCE_MAX_RUNTIME_SECS: u64 = 10800;
pub const DEFAULT_STREAM_JSON_TEXTIFY: bool = true;
pub const DEFAULT_COMMAND_STRICT_PROCESS_CLEANUP: bool = true;
pub fn default_retry_patterns() -> Vec<String> {
vec![
r"Cannot find module".to_string(),
r"ResolveMessage:".to_string(),
r"ENOTFOUND registry\.npmjs\.org".to_string(),
r"ETIMEDOUT.*registry".to_string(),
r"EBADF.*lock".to_string(),
r"Lock acquisition failed".to_string(),
]
}
pub const DEFAULT_ERROR_CIRCUIT_BREAKER_ENABLED: bool = true;
pub const DEFAULT_ERROR_CIRCUIT_BREAKER_THRESHOLD: usize = 5;
pub const DEFAULT_ACCEPTANCE_MAX_CONTINUES: u32 = 10;
pub const DEFAULT_LIFECYCLE_QUEUE_CAPACITY: usize = 64;
pub const DEFAULT_LIFECYCLE_WRITE_TIMEOUT_MS: u64 = 2_000;
pub const DEFAULT_LIFECYCLE_SHUTDOWN_TIMEOUT_MS: u64 = 2_000;
pub fn generate_project_slug(repo_root: &std::path::Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let absolute_path = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
let mut hasher = DefaultHasher::new();
absolute_path.hash(&mut hasher);
let hash = hasher.finish();
let hash_str = format!("{:016x}", hash);
let hash8 = &hash_str[..8];
format!("{}-{}", repo_name, hash8)
}
pub fn default_workspace_base_dir(repo_root: Option<&std::path::Path>) -> PathBuf {
let project_slug = repo_root.map(generate_project_slug);
#[cfg(target_os = "macos")]
{
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
let mut path = PathBuf::from(xdg_data_home).join("cflx").join("worktrees");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
return path;
}
if let Some(home) = dirs::home_dir() {
let mut path = home
.join(".local")
.join("share")
.join("cflx")
.join("worktrees");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
return path;
}
}
#[cfg(target_os = "linux")]
{
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
let mut path = PathBuf::from(xdg_data_home).join("cflx").join("worktrees");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
return path;
}
if let Some(home) = dirs::home_dir() {
let mut path = home
.join(".local")
.join("share")
.join("cflx")
.join("worktrees");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
return path;
}
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
let mut path = appdata.join("cflx").join("worktrees");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
return path;
}
}
let mut path = std::env::temp_dir().join("cflx-workspaces-fallback");
if let Some(slug) = &project_slug {
path = path.join(slug);
}
path
}
pub const STATE_DIR_NAME: &str = "cflx";
#[derive(Debug, thiserror::Error)]
pub enum StateRootError {
#[error(
"state_base_dir must be an absolute path, but '{0}' is relative. \
Set an absolute path in .cflx.jsonc or the global config, or remove \
state_base_dir to use XDG_STATE_HOME."
)]
NotAbsolute(String),
#[error(
"state_base_dir '{path}' is unavailable and could not be created: {source}. \
Mount the volume or grant write permission, or remove state_base_dir to \
use XDG_STATE_HOME."
)]
Uncreatable {
path: PathBuf,
source: std::io::Error,
},
#[error(
"state_base_dir '{path}' is not writable: {source}. \
Grant write permission on that directory, or remove state_base_dir to \
use XDG_STATE_HOME."
)]
NotWritable {
path: PathBuf,
source: std::io::Error,
},
}
pub fn resolve_state_root_from(
configured: Option<&str>,
xdg_state_home: Option<&str>,
home_dir: Option<&std::path::Path>,
) -> std::result::Result<Option<PathBuf>, StateRootError> {
if let Some(configured) = configured.filter(|value| !value.is_empty()) {
let root = PathBuf::from(configured);
if !root.is_absolute() {
return Err(StateRootError::NotAbsolute(configured.to_string()));
}
return Ok(Some(root.join(STATE_DIR_NAME)));
}
if let Some(xdg_state_home) = xdg_state_home.filter(|value| !value.is_empty()) {
return Ok(Some(PathBuf::from(xdg_state_home).join(STATE_DIR_NAME)));
}
Ok(home_dir.map(|home| home.join(".local").join("state").join(STATE_DIR_NAME)))
}
pub fn conflux_state_root(
configured: Option<&str>,
) -> std::result::Result<Option<PathBuf>, StateRootError> {
let xdg_state_home = std::env::var("XDG_STATE_HOME").ok();
resolve_state_root_from(
configured,
xdg_state_home.as_deref(),
dirs::home_dir().as_deref(),
)
}
pub fn log_root_path(configured: Option<&str>) -> std::result::Result<PathBuf, StateRootError> {
Ok(match conflux_state_root(configured)? {
Some(root) => root.join("logs"),
None => std::env::temp_dir().join("cflx-logs-fallback"),
})
}
pub fn ensure_state_root_usable(
configured: Option<&str>,
) -> std::result::Result<Option<PathBuf>, StateRootError> {
let Some(configured) = configured.filter(|value| !value.is_empty()) else {
return Ok(None);
};
let Some(root) = resolve_state_root_from(Some(configured), None, None)? else {
return Ok(None);
};
std::fs::create_dir_all(&root).map_err(|source| StateRootError::Uncreatable {
path: root.clone(),
source,
})?;
let probe = root.join(format!(".cflx-write-probe-{}", std::process::id()));
std::fs::write(&probe, b"").map_err(|source| StateRootError::NotWritable {
path: root.clone(),
source,
})?;
let _ = std::fs::remove_file(&probe);
Ok(Some(root))
}
pub fn get_log_file_path(
state_base_dir: Option<&str>,
repo_root: Option<&std::path::Path>,
) -> std::result::Result<PathBuf, StateRootError> {
use chrono::Local;
let project_slug = repo_root
.map(generate_project_slug)
.unwrap_or_else(|| "unknown".to_string());
let date_str = Local::now().format("%Y-%m-%d").to_string();
let log_filename = format!("{}.log", date_str);
Ok(log_root_path(state_base_dir)?
.join(&project_slug)
.join(&log_filename))
}
pub fn cleanup_old_logs(
state_base_dir: Option<&str>,
repo_root: Option<&std::path::Path>,
retain_days: u32,
) -> std::io::Result<usize> {
use chrono::{Duration, Local};
let project_slug = repo_root
.map(generate_project_slug)
.unwrap_or_else(|| "unknown".to_string());
let log_dir = log_root_path(state_base_dir)
.map_err(std::io::Error::other)?
.join(&project_slug);
if !log_dir.exists() {
return Ok(0);
}
let cutoff_date = Local::now() - Duration::days(i64::from(retain_days - 1));
let cutoff_str = cutoff_date.format("%Y-%m-%d").to_string();
let mut deleted_count = 0;
for entry in std::fs::read_dir(&log_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("log") {
continue;
}
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename < cutoff_str.as_str() {
std::fs::remove_file(&path)?;
deleted_count += 1;
}
}
}
Ok(deleted_count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn set_env_var<K: AsRef<std::ffi::OsStr>, V: AsRef<std::ffi::OsStr>>(key: K, value: V) {
unsafe { std::env::set_var(key, value) }
}
fn remove_env_var<K: AsRef<std::ffi::OsStr>>(key: K) {
unsafe { std::env::remove_var(key) }
}
#[test]
fn test_default_workspace_base_dir_returns_path() {
let path = default_workspace_base_dir(None);
assert!(!path.as_os_str().is_empty());
}
#[test]
fn test_default_workspace_base_dir_contains_cflx() {
let path = default_workspace_base_dir(None);
let path_str = path.to_string_lossy();
let is_cflx_path = path_str.to_lowercase().contains("cflx");
let is_fallback = path_str.contains("cflx-workspaces-fallback");
assert!(
is_cflx_path || is_fallback,
"Path should contain 'cflx' or be fallback: {:?}",
path
);
}
#[test]
fn test_default_workspace_base_dir_with_repo_root() {
let repo_root = PathBuf::from("/Users/alice/projects/conflux");
let path = default_workspace_base_dir(Some(&repo_root));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("conflux-"),
"Path should contain project slug: {:?}",
path
);
}
#[test]
fn test_generate_project_slug() {
let repo_root = PathBuf::from("/Users/alice/projects/my-repo");
let slug = generate_project_slug(&repo_root);
assert!(
slug.starts_with("my-repo-"),
"Slug should start with repo name"
);
assert_eq!(
slug.len(),
"my-repo-".len() + 8,
"Slug should have 8-char hash"
);
}
#[test]
fn test_default_workspace_base_dir_with_xdg_data_home() {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
use std::env;
let original = env::var("XDG_DATA_HOME").ok();
let test_path = "/tmp/test-xdg-data";
set_env_var("XDG_DATA_HOME", test_path);
let repo_root = PathBuf::from("/tmp/test-repo");
let result = default_workspace_base_dir(Some(&repo_root));
assert!(
result.starts_with(test_path),
"Expected path to start with {}, got {:?}",
test_path,
result
);
assert!(
result.to_string_lossy().contains("cflx/worktrees"),
"Expected path to contain cflx/worktrees, got {:?}",
result
);
match original {
Some(val) => set_env_var("XDG_DATA_HOME", val),
None => remove_env_var("XDG_DATA_HOME"),
}
}
}
#[test]
#[cfg(target_os = "macos")]
fn test_default_workspace_base_dir_macos_fallback() {
let repo_root = PathBuf::from("/tmp/test-repo");
let result = default_workspace_base_dir(Some(&repo_root));
let path_str = result.to_string_lossy();
assert!(
path_str.contains("cflx") && path_str.contains("worktrees"),
"Expected path to contain 'cflx' and 'worktrees', got {:?}",
result
);
assert!(
path_str.contains("test-repo-"),
"Expected path to contain project slug 'test-repo-', got {:?}",
result
);
}
#[test]
#[cfg(target_os = "linux")]
fn test_default_workspace_base_dir_linux_fallback() {
let repo_root = PathBuf::from("/tmp/test-repo");
let result = default_workspace_base_dir(Some(&repo_root));
let path_str = result.to_string_lossy();
assert!(
path_str.contains("cflx") && path_str.contains("worktrees"),
"Expected path to contain 'cflx' and 'worktrees', got {:?}",
result
);
assert!(
path_str.contains("test-repo-"),
"Expected path to contain project slug 'test-repo-', got {:?}",
result
);
}
#[test]
#[cfg(target_os = "windows")]
fn test_default_workspace_base_dir_windows() {
let repo_root = PathBuf::from("C:\\Users\\test\\projects\\my-repo");
let result = default_workspace_base_dir(Some(&repo_root));
let path_str = result.to_string_lossy();
assert!(
path_str.contains("cflx") && path_str.contains("worktrees"),
"Expected path to contain 'cflx' and 'worktrees', got {:?}",
result
);
}
#[test]
fn test_acceptance_system_prompt_is_empty() {
assert!(
ACCEPTANCE_SYSTEM_PROMPT.is_empty(),
"ACCEPTANCE_SYSTEM_PROMPT should be empty to keep acceptance prompts context-only"
);
}
#[test]
fn test_get_log_file_path_format() {
let repo_root = PathBuf::from("/tmp/test-repo");
let log_path = get_log_file_path(None, Some(&repo_root)).expect("default root resolves");
let path_str = log_path.to_string_lossy();
assert!(
path_str.contains("cflx") && path_str.contains("logs"),
"Expected path to contain 'cflx/logs', got {:?}",
log_path
);
assert!(
path_str.contains("test-repo-"),
"Expected path to contain project slug 'test-repo-', got {:?}",
log_path
);
assert!(
path_str.ends_with(".log"),
"Expected path to end with '.log', got {:?}",
log_path
);
}
#[test]
fn test_cleanup_old_logs_with_nonexistent_directory() {
let repo_root = PathBuf::from("/tmp/nonexistent-repo-for-test");
let result = cleanup_old_logs(None, Some(&repo_root), 7);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_cleanup_old_logs_retains_exactly_n_days() {
use chrono::Local;
use std::env;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let temp_state_home = temp_dir.path().join("state");
fs::create_dir(&temp_state_home).expect("Failed to create state dir");
let original_state_home = env::var("XDG_STATE_HOME").ok();
set_env_var("XDG_STATE_HOME", &temp_state_home);
let repo_root = PathBuf::from("/tmp/test-retention-repo");
let project_slug = generate_project_slug(&repo_root);
let log_dir = temp_state_home
.join("cflx")
.join("logs")
.join(&project_slug);
fs::create_dir_all(&log_dir).expect("Failed to create log dir");
let today = Local::now();
let mut expected_files = Vec::new();
for days_ago in (0..=9).rev() {
let date = today - chrono::Duration::days(days_ago);
let filename = format!("{}.log", date.format("%Y-%m-%d"));
let file_path = log_dir.join(&filename);
fs::write(&file_path, "test log content").expect("Failed to write log file");
if days_ago < 7 {
expected_files.push(filename);
}
}
let deleted_count =
cleanup_old_logs(None, Some(&repo_root), 7).expect("cleanup_old_logs failed");
assert_eq!(deleted_count, 3, "Expected to delete 3 files");
let remaining: Vec<_> = fs::read_dir(&log_dir)
.expect("Failed to read log dir")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("log"))
.map(|e| e.path().file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(
remaining.len(),
7,
"Expected exactly 7 log files to remain, found: {:?}",
remaining
);
let today_filename = format!("{}.log", today.format("%Y-%m-%d"));
assert!(
remaining.contains(&today_filename),
"Today's log file should be preserved: {}",
today_filename
);
for expected in &expected_files {
assert!(
remaining.contains(expected),
"Expected file {} should be present",
expected
);
}
match original_state_home {
Some(val) => set_env_var("XDG_STATE_HOME", val),
None => remove_env_var("XDG_STATE_HOME"),
}
}
#[test]
fn configured_state_root_wins_over_xdg_state_home() {
let root = resolve_state_root_from(
Some("/volumes/external/cflx-state"),
Some("/home/alice/.local/state"),
Some(Path::new("/home/alice")),
)
.expect("absolute configured root is accepted");
assert_eq!(
root,
Some(PathBuf::from("/volumes/external/cflx-state/cflx"))
);
}
#[test]
fn unset_state_root_falls_back_to_xdg_then_platform_default() {
let from_xdg = resolve_state_root_from(
None,
Some("/home/alice/.local/state"),
Some(Path::new("/home/alice")),
)
.expect("xdg root resolves");
assert_eq!(
from_xdg,
Some(PathBuf::from("/home/alice/.local/state/cflx"))
);
let from_home = resolve_state_root_from(None, None, Some(Path::new("/home/alice")))
.expect("platform default resolves");
assert_eq!(
from_home,
Some(PathBuf::from("/home/alice/.local/state/cflx"))
);
let nothing = resolve_state_root_from(None, None, None).expect("absence is not a failure");
assert_eq!(nothing, None);
}
#[test]
fn empty_state_root_behaves_as_unset() {
let root = resolve_state_root_from(
Some(""),
Some("/home/alice/.local/state"),
Some(Path::new("/home/alice")),
)
.expect("empty configured root is not a failure");
assert_eq!(root, Some(PathBuf::from("/home/alice/.local/state/cflx")));
let root = resolve_state_root_from(Some(""), Some(""), Some(Path::new("/home/alice")))
.expect("empty xdg value is not a failure");
assert_eq!(root, Some(PathBuf::from("/home/alice/.local/state/cflx")));
}
#[test]
fn relative_state_root_is_rejected_with_an_actionable_message() {
let err = resolve_state_root_from(
Some("relative/state"),
Some("/home/alice/.local/state"),
Some(Path::new("/home/alice")),
)
.expect_err("a relative configured root must fail closed");
assert!(matches!(err, StateRootError::NotAbsolute(ref value) if value == "relative/state"));
let message = err.to_string();
assert!(message.contains("absolute"), "message: {message}");
assert!(message.contains("relative/state"), "message: {message}");
assert!(message.contains("state_base_dir"), "message: {message}");
}
#[test]
fn ensure_state_root_usable_is_a_no_op_without_configuration() {
assert!(ensure_state_root_usable(None)
.expect("absent configuration is not a failure")
.is_none());
assert!(ensure_state_root_usable(Some(""))
.expect("empty configuration is not a failure")
.is_none());
}
#[test]
fn ensure_state_root_usable_rejects_a_relative_root_before_touching_the_filesystem() {
let err = ensure_state_root_usable(Some("relative/state"))
.expect_err("a relative configured root must fail closed");
assert!(matches!(err, StateRootError::NotAbsolute(_)));
assert!(
!Path::new("relative/state").exists(),
"rejection must not create the configured directory"
);
}
}