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_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_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_PROPOSAL_TRANSPORT_COMMAND: &str = "opencode";
pub const DEFAULT_PROPOSAL_TRANSPORT_ARGS: &[&str] = &["acp"];
pub const DEFAULT_PROPOSAL_SESSION_INACTIVITY_TIMEOUT_SECS: u64 = 1800;
pub const DEFAULT_SERVER_BIND: &str = "127.0.0.1";
pub const DEFAULT_SERVER_PORT: u16 = 39876;
pub const DEFAULT_SERVER_MAX_CONCURRENT_TOTAL: usize = 4;
pub const DEFAULT_SERVER_DATA_SUBDIR: &str = "server";
pub fn default_server_data_dir() -> std::path::PathBuf {
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
if !xdg_data_home.is_empty() {
return std::path::PathBuf::from(xdg_data_home)
.join("cflx")
.join(DEFAULT_SERVER_DATA_SUBDIR);
}
}
if let Some(home) = dirs::home_dir() {
return home
.join(".local")
.join("share")
.join("cflx")
.join(DEFAULT_SERVER_DATA_SUBDIR);
}
std::env::temp_dir()
.join("cflx-server-fallback")
.join(DEFAULT_SERVER_DATA_SUBDIR)
}
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
}
#[cfg(target_os = "macos")]
pub fn get_server_log_path() -> PathBuf {
let xdg_state_home = std::env::var("XDG_STATE_HOME").ok();
get_server_log_path_from(xdg_state_home.as_deref(), dirs::home_dir())
}
#[cfg(any(target_os = "macos", test))]
fn get_server_log_path_from(xdg_state_home: Option<&str>, home_dir: Option<PathBuf>) -> PathBuf {
if let Some(xdg_state_home) = xdg_state_home {
if !xdg_state_home.is_empty() {
return PathBuf::from(xdg_state_home)
.join("cflx")
.join("server.log");
}
}
if let Some(home) = home_dir {
return home
.join(".local")
.join("state")
.join("cflx")
.join("server.log");
}
std::env::temp_dir().join("cflx-server.log")
}
pub fn get_log_file_path(repo_root: Option<&std::path::Path>) -> PathBuf {
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);
if let Ok(xdg_state_home) = std::env::var("XDG_STATE_HOME") {
return PathBuf::from(xdg_state_home)
.join("cflx")
.join("logs")
.join(&project_slug)
.join(&log_filename);
}
if let Some(home) = dirs::home_dir() {
return home
.join(".local")
.join("state")
.join("cflx")
.join("logs")
.join(&project_slug)
.join(&log_filename);
}
std::env::temp_dir()
.join("cflx-logs-fallback")
.join(&project_slug)
.join(&log_filename)
}
pub fn cleanup_old_logs(
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 = if let Ok(xdg_state_home) = std::env::var("XDG_STATE_HOME") {
PathBuf::from(xdg_state_home)
.join("cflx")
.join("logs")
.join(&project_slug)
} else if let Some(home) = dirs::home_dir() {
home.join(".local")
.join("state")
.join("cflx")
.join("logs")
.join(&project_slug)
} else {
std::env::temp_dir()
.join("cflx-logs-fallback")
.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::*;
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(Some(&repo_root));
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_get_server_log_path_with_xdg_state_home() {
let result = get_server_log_path_from(
Some("/custom/state"),
Some(PathBuf::from("/home/test-user")),
);
assert_eq!(result, PathBuf::from("/custom/state/cflx/server.log"));
}
#[test]
fn test_get_server_log_path_without_xdg_state_home() {
use std::env;
let original = env::var("XDG_STATE_HOME").ok();
remove_env_var("XDG_STATE_HOME");
let home = PathBuf::from("/home/test-user");
let result = get_server_log_path_from(None, Some(home.clone()));
assert_eq!(result, home.join(".local/state/cflx/server.log"));
match original {
Some(val) => set_env_var("XDG_STATE_HOME", val),
None => remove_env_var("XDG_STATE_HOME"),
}
}
#[test]
fn test_get_server_log_path_without_home_directory() {
use std::env;
let original = env::var("XDG_STATE_HOME").ok();
remove_env_var("XDG_STATE_HOME");
let result = get_server_log_path_from(None, None);
assert_eq!(result, std::env::temp_dir().join("cflx-server.log"));
match original {
Some(val) => set_env_var("XDG_STATE_HOME", val),
None => remove_env_var("XDG_STATE_HOME"),
}
}
#[test]
fn test_cleanup_old_logs_with_nonexistent_directory() {
let repo_root = PathBuf::from("/tmp/nonexistent-repo-for-test");
let result = cleanup_old_logs(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(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"),
}
}
}