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 const DEFAULT_JUDGE_TIMEOUT_MS: u64 = 30_000;
pub const MIN_JUDGE_TIMEOUT_MS: u64 = 1;
pub const MAX_JUDGE_TIMEOUT_MS: u64 = 300_000;
pub const DEFAULT_JUDGE_MAX_INPUT_BYTES: usize = 1_048_576;
pub const DEFAULT_JUDGE_MAX_OUTPUT_BYTES: usize = 1_048_576;
pub const MIN_JUDGE_BYTE_LIMIT: usize = 1;
pub const MAX_JUDGE_BYTE_LIMIT: usize = 16_777_216;
pub const DEFAULT_JUDGE_YES_THRESHOLD: f64 = 0.85;
pub const MIN_JUDGE_YES_THRESHOLD: f64 = 0.5;
pub const MAX_JUDGE_YES_THRESHOLD: f64 = 1.0;
pub const JUDGE_MODE_SHADOW: &str = "shadow";
pub const MAX_JUDGE_EVALUATION_RETENTION_DAYS: u32 = 3_650;
pub const MAX_JUDGE_EVALUATION_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024;
pub const EVALUATION_DIR_NAME: &str = "evaluations";
pub const EVALUATION_PURPOSE_PARALLEL_DEPENDENCY: &str = "parallel_dependency";
pub const EVALUATION_SALT_FILE_NAME: &str = ".pair-id-salt";
pub fn evaluation_root_path(
configured: Option<&str>,
) -> std::result::Result<PathBuf, StateRootError> {
Ok(match conflux_state_root(configured)? {
Some(root) => root.join(EVALUATION_DIR_NAME),
None => std::env::temp_dir().join("cflx-evaluations-fallback"),
})
}
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 const ACCEPTANCE_DIR_NAME: &str = "acceptance";
#[derive(Debug, thiserror::Error)]
pub enum AcceptanceStoreError {
#[error("the Conflux state root could not be resolved: {0}")]
StateRoot(#[from] StateRootError),
#[error(
"'{0}' is not usable as a single Acceptance store path component; \
a change or verification identifier may not be empty, '.', '..', longer \
than 128 characters, or contain anything outside [A-Za-z0-9._-]"
)]
UnsafeComponent(String),
#[error(
"the resolved Acceptance store '{path}' is inside the target tree '{target}'. \
Conflux-owned evidence must live outside the target repository and every \
managed worktree; point state_base_dir at a directory outside the project."
)]
InsideTarget { path: PathBuf, target: PathBuf },
#[error("the Acceptance store '{path}' could not be created: {source}")]
Uncreatable {
path: PathBuf,
source: std::io::Error,
},
#[error("the Acceptance store '{path}' is not writable: {source}")]
NotWritable {
path: PathBuf,
source: std::io::Error,
},
}
impl AcceptanceStoreError {
pub fn code(&self) -> &'static str {
match self {
Self::StateRoot(_) => "acceptance_state_root_unresolvable",
Self::UnsafeComponent(_) => "acceptance_store_unsafe_component",
Self::InsideTarget { .. } => "acceptance_store_inside_target",
Self::Uncreatable { .. } => "acceptance_store_uncreatable",
Self::NotWritable { .. } => "acceptance_store_not_writable",
}
}
}
pub fn is_safe_path_component(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value != "."
&& value != ".."
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
pub fn acceptance_root_path(
configured: Option<&str>,
) -> std::result::Result<PathBuf, StateRootError> {
Ok(match conflux_state_root(configured)? {
Some(root) => root.join(ACCEPTANCE_DIR_NAME),
None => std::env::temp_dir().join("cflx-acceptance-fallback"),
})
}
fn canonical_or_lexical(path: &std::path::Path) -> PathBuf {
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
let mut cursor = path.to_path_buf();
loop {
if let Ok(canonical) = cursor.canonicalize() {
let mut resolved = canonical;
for component in suffix.iter().rev() {
resolved.push(component);
}
return resolved;
}
let Some(name) = cursor.file_name().map(std::ffi::OsStr::to_os_string) else {
return path.to_path_buf();
};
suffix.push(name);
if !cursor.pop() {
return path.to_path_buf();
}
}
}
fn is_inside(path: &std::path::Path, target: &std::path::Path) -> bool {
canonical_or_lexical(path).starts_with(canonical_or_lexical(target)) || path.starts_with(target)
}
pub fn acceptance_store_path_in(
acceptance_root: &std::path::Path,
project_root: &std::path::Path,
workspace: &std::path::Path,
change_id: &str,
) -> std::result::Result<PathBuf, AcceptanceStoreError> {
if !is_safe_path_component(change_id) {
return Err(AcceptanceStoreError::UnsafeComponent(change_id.to_string()));
}
let path = acceptance_root
.join(generate_project_slug(project_root))
.join(generate_project_slug(workspace))
.join(change_id);
for target in [project_root, workspace] {
if is_inside(&path, target) {
return Err(AcceptanceStoreError::InsideTarget {
path,
target: target.to_path_buf(),
});
}
}
Ok(path)
}
pub fn ensure_acceptance_store_usable(
path: &std::path::Path,
) -> std::result::Result<(), AcceptanceStoreError> {
std::fs::create_dir_all(path).map_err(|source| AcceptanceStoreError::Uncreatable {
path: path.to_path_buf(),
source,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}
let probe = path.join(format!(".cflx-write-probe-{}", std::process::id()));
std::fs::write(&probe, b"").map_err(|source| AcceptanceStoreError::NotWritable {
path: path.to_path_buf(),
source,
})?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
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"
);
}
#[test]
fn acceptance_store_isolates_project_workspace_and_change() {
let root = Path::new("/var/state/cflx/acceptance");
let project = Path::new("/home/alice/projects/conflux");
let workspace = Path::new("/home/alice/.local/share/cflx/worktrees/p/add-a-thing");
let path = acceptance_store_path_in(root, project, workspace, "add-a-thing")
.expect("an external root resolves");
let components: Vec<_> = path
.strip_prefix(root)
.expect("the store lives under the acceptance root")
.iter()
.map(|component| component.to_string_lossy().to_string())
.collect();
assert_eq!(
components.len(),
3,
"project / workspace / change: {path:?}"
);
assert_eq!(components[0], generate_project_slug(project));
assert_eq!(components[1], generate_project_slug(workspace));
assert_eq!(components[2], "add-a-thing");
assert_eq!(
path,
acceptance_store_path_in(root, project, workspace, "add-a-thing").unwrap()
);
let sibling = Path::new("/home/alice/.local/share/cflx/worktrees/p/other-thing");
assert_ne!(
path,
acceptance_store_path_in(root, project, sibling, "add-a-thing").unwrap()
);
}
#[test]
fn acceptance_store_refuses_an_unsafe_change_component() {
let root = Path::new("/var/state/cflx/acceptance");
let project = Path::new("/home/alice/project");
let workspace = Path::new("/home/alice/workspace");
for hostile in ["", ".", "..", "../escape", "a/b", "with space"] {
let error = acceptance_store_path_in(root, project, workspace, hostile)
.expect_err("an unsafe component must fail closed");
assert!(
matches!(error, AcceptanceStoreError::UnsafeComponent(_)),
"'{hostile}' produced {error:?}"
);
}
assert!(is_safe_path_component("add-a-thing"));
assert!(is_safe_path_component("focused-gate.log"));
assert!(!is_safe_path_component(&"x".repeat(129)));
}
#[test]
fn acceptance_store_refuses_a_root_inside_the_target() {
let project = Path::new("/home/alice/projects/conflux");
let workspace = Path::new("/home/alice/projects/conflux/.worktrees/add-a-thing");
let inside_repo = acceptance_store_path_in(
&project.join(".cflx/state/acceptance"),
project,
workspace,
"add-a-thing",
)
.expect_err("a root inside the repository must fail closed");
assert!(
matches!(
&inside_repo,
AcceptanceStoreError::InsideTarget { target, .. } if target == project
),
"{inside_repo:?}"
);
assert_eq!(inside_repo.code(), "acceptance_store_inside_target");
let inside_worktree = acceptance_store_path_in(
&workspace.join("acceptance"),
Path::new("/home/alice/elsewhere"),
workspace,
"add-a-thing",
)
.expect_err("a root inside the managed worktree must fail closed");
assert!(
matches!(
&inside_worktree,
AcceptanceStoreError::InsideTarget { target, .. } if target == workspace
),
"{inside_worktree:?}"
);
}
#[test]
fn acceptance_store_containment_follows_symlinked_ancestors() {
let real = tempfile::TempDir::new().expect("tempdir");
let project = real.path().join("project");
std::fs::create_dir_all(project.join(".cflx")).expect("create project");
let link_parent = tempfile::TempDir::new().expect("tempdir");
let linked_project = link_parent.path().join("linked-project");
#[cfg(unix)]
std::os::unix::fs::symlink(&project, &linked_project).expect("symlink");
#[cfg(not(unix))]
let linked_project = project.clone();
let error = acceptance_store_path_in(
&project.join(".cflx/acceptance"),
&linked_project,
&project.join("worktree"),
"add-a-thing",
)
.expect_err("a store under the symlinked target must still be refused");
assert!(matches!(error, AcceptanceStoreError::InsideTarget { .. }));
}
#[test]
fn acceptance_root_falls_back_to_an_external_temporary_root() {
let root = acceptance_root_path(Some("/volumes/external/cflx-state"))
.expect("an absolute configured root resolves");
assert_eq!(
root,
PathBuf::from("/volumes/external/cflx-state/cflx/acceptance")
);
let fallback = std::env::temp_dir().join("cflx-acceptance-fallback");
assert!(fallback.is_absolute());
assert_eq!(
fallback.file_name().and_then(|n| n.to_str()),
Some("cflx-acceptance-fallback")
);
}
#[test]
fn ensure_acceptance_store_usable_creates_and_proves_the_directory() {
let temp = tempfile::TempDir::new().expect("tempdir");
let store = temp.path().join("acceptance/project/workspace/change");
ensure_acceptance_store_usable(&store).expect("a fresh store is creatable and writable");
assert!(store.is_dir());
assert_eq!(
std::fs::read_dir(&store)
.expect("read store")
.filter_map(Result::ok)
.count(),
0,
"the write probe must be removed again"
);
}
}