pub mod exit_codes;
pub mod lock;
pub mod primitives;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub use exit_codes::{
ALREADY_CURRENT, CONFIG, DAEMON_DOWN, ERROR, LOCKED, NEEDS_MIGRATION, NEEDS_SETUP, NO_WORKERS,
OK, USAGE,
};
pub use lock::ConfigLock;
pub use primitives::{
IdempotentResult, append_line_if_missing, atomic_write, create_backup, create_if_missing,
ensure_directory, ensure_symlink, update_if_changed,
};
fn default_socket_path() -> PathBuf {
PathBuf::from(rch_common::default_socket_path())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RchState {
pub status: InstallStatus,
pub components: ComponentStates,
pub issues: Vec<StateIssue>,
pub detected_at: chrono::DateTime<chrono::Utc>,
pub rch_version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallStatus {
Ready,
NeedsSetup,
NotInstalled,
Degraded,
NeedsMigration,
}
impl std::fmt::Display for InstallStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InstallStatus::Ready => write!(f, "ready"),
InstallStatus::NeedsSetup => write!(f, "needs setup"),
InstallStatus::NotInstalled => write!(f, "not installed"),
InstallStatus::Degraded => write!(f, "degraded"),
InstallStatus::NeedsMigration => write!(f, "needs migration"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentStates {
pub user_config: ConfigState,
pub project_config: ConfigState,
pub workers: WorkersState,
pub daemon: DaemonState,
pub hooks: Vec<AgentHookState>,
pub binaries: BinaryState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigState {
pub path: PathBuf,
pub exists: bool,
pub valid: bool,
pub version: Option<String>,
pub needs_migration: bool,
pub source: ConfigSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSource {
Default,
UserConfig,
ProjectConfig,
Environment,
CommandLine,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkersState {
pub config_path: PathBuf,
pub exists: bool,
pub valid: bool,
pub worker_count: usize,
pub reachable_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonState {
pub running: bool,
pub pid: Option<u32>,
pub socket_path: PathBuf,
pub socket_responsive: bool,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentHookState {
pub agent: String,
pub hook_path: PathBuf,
pub registered: bool,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinaryState {
pub rch_path: Option<PathBuf>,
pub rchd_path: Option<PathBuf>,
pub rch_wkr_path: Option<PathBuf>,
pub version: Option<String>,
pub in_path: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateIssue {
pub severity: IssueSeverity,
pub component: String,
pub message: String,
pub remediation: String,
pub exit_code: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueSeverity {
Critical,
Warning,
Info,
}
pub fn detect_state() -> Result<RchState> {
let mut issues = Vec::new();
let user_config = detect_user_config(&mut issues)?;
let project_config = detect_project_config(&mut issues)?;
let workers = detect_workers_state(&mut issues)?;
let daemon = detect_daemon_state(&mut issues)?;
let hooks = detect_hooks_state(&mut issues)?;
let binaries = detect_binary_state(&mut issues)?;
let components = ComponentStates {
user_config,
project_config,
workers,
daemon,
hooks,
binaries,
};
let status = determine_overall_status(&components, &issues);
Ok(RchState {
status,
components,
issues,
detected_at: chrono::Utc::now(),
rch_version: env!("CARGO_PKG_VERSION").to_string(),
})
}
fn detect_user_config(issues: &mut Vec<StateIssue>) -> Result<ConfigState> {
let config_dir = crate::config::config_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?;
let config_path = config_dir.join("config.toml");
let exists = config_path.exists();
let mut valid = false;
let mut version = None;
let needs_migration = false;
if exists {
match std::fs::read_to_string(&config_path) {
Ok(content) => {
match toml::from_str::<toml::Value>(&content) {
Ok(value) => {
valid = true;
if let Some(v) = value.get("version").and_then(|v| v.as_str()) {
version = Some(v.to_string());
}
}
Err(e) => {
issues.push(StateIssue {
severity: IssueSeverity::Critical,
component: "user_config".to_string(),
message: format!("Invalid TOML in config.toml: {}", e),
remediation: "Fix the syntax error or run: rch config init --force"
.to_string(),
exit_code: exit_codes::CONFIG,
});
}
}
}
Err(e) => {
issues.push(StateIssue {
severity: IssueSeverity::Critical,
component: "user_config".to_string(),
message: format!("Cannot read config.toml: {}", e),
remediation: "Check file permissions".to_string(),
exit_code: exit_codes::CONFIG,
});
}
}
}
Ok(ConfigState {
path: config_path,
exists,
valid,
version,
needs_migration,
source: if exists {
ConfigSource::UserConfig
} else {
ConfigSource::Default
},
})
}
fn detect_project_config(_issues: &mut [StateIssue]) -> Result<ConfigState> {
let cwd = std::env::current_dir().context("Cannot determine current directory")?;
let config_path = cwd.join(".rch/config.toml");
let exists = config_path.exists();
let mut valid = false;
let mut version = None;
if exists
&& let Ok(content) = std::fs::read_to_string(&config_path)
&& let Ok(value) = toml::from_str::<toml::Value>(&content)
{
valid = true;
if let Some(v) = value.get("version").and_then(|v| v.as_str()) {
version = Some(v.to_string());
}
}
Ok(ConfigState {
path: config_path,
exists,
valid,
version,
needs_migration: false,
source: if exists {
ConfigSource::ProjectConfig
} else {
ConfigSource::Default
},
})
}
fn detect_workers_state(issues: &mut Vec<StateIssue>) -> Result<WorkersState> {
let config_dir = crate::config::config_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?;
let config_path = config_dir.join("workers.toml");
let exists = config_path.exists();
let mut valid = false;
let mut worker_count = 0;
if exists {
match std::fs::read_to_string(&config_path) {
Ok(content) => {
match toml::from_str::<toml::Value>(&content) {
Ok(value) => {
valid = true;
if let Some(workers) = value.get("workers").and_then(|w| w.as_array()) {
worker_count = workers.len();
}
}
Err(e) => {
issues.push(StateIssue {
severity: IssueSeverity::Critical,
component: "workers".to_string(),
message: format!("Invalid TOML in workers.toml: {}", e),
remediation: "Fix the syntax error in workers.toml".to_string(),
exit_code: exit_codes::CONFIG,
});
}
}
}
Err(e) => {
issues.push(StateIssue {
severity: IssueSeverity::Critical,
component: "workers".to_string(),
message: format!("Cannot read workers.toml: {}", e),
remediation: "Check file permissions".to_string(),
exit_code: exit_codes::CONFIG,
});
}
}
} else {
issues.push(StateIssue {
severity: IssueSeverity::Warning,
component: "workers".to_string(),
message: "No workers.toml found".to_string(),
remediation: "Create ~/.config/rch/workers.toml or run: rch setup workers".to_string(),
exit_code: exit_codes::NO_WORKERS,
});
}
if exists && valid && worker_count == 0 {
issues.push(StateIssue {
severity: IssueSeverity::Warning,
component: "workers".to_string(),
message: "workers.toml exists but has no workers configured".to_string(),
remediation: "Add worker entries to workers.toml".to_string(),
exit_code: exit_codes::NO_WORKERS,
});
}
Ok(WorkersState {
config_path,
exists,
valid,
worker_count,
reachable_count: None, })
}
fn detect_daemon_state(issues: &mut Vec<StateIssue>) -> Result<DaemonState> {
let socket_path = default_socket_path();
let socket_exists = socket_path.exists();
let socket_responsive = if socket_exists {
#[cfg(unix)]
{
std::os::unix::net::UnixStream::connect(&socket_path).is_ok()
}
#[cfg(not(unix))]
{
false
}
} else {
false
};
let running = socket_responsive;
let pid = None;
if !running {
issues.push(StateIssue {
severity: IssueSeverity::Warning,
component: "daemon".to_string(),
message: "RCH daemon is not running".to_string(),
remediation: "Start the daemon with: rchd or rch daemon start".to_string(),
exit_code: exit_codes::DAEMON_DOWN,
});
}
Ok(DaemonState {
running,
pid,
socket_path,
socket_responsive,
version: None,
})
}
fn detect_hooks_state(_issues: &mut [StateIssue]) -> Result<Vec<AgentHookState>> {
let mut hooks = Vec::new();
let claude_settings = dirs::config_dir().map(|p| p.join("claude-code/settings.json"));
if let Some(path) = claude_settings {
let registered = path.exists();
hooks.push(AgentHookState {
agent: "claude-code".to_string(),
hook_path: path,
registered,
version: None,
});
}
Ok(hooks)
}
fn detect_binary_state(issues: &mut Vec<StateIssue>) -> Result<BinaryState> {
let rch_path = which::which("rch").ok();
let rchd_path = which::which("rchd").ok();
let rch_wkr_path = which::which("rch-wkr").ok();
let in_path = rch_path.is_some();
if rch_path.is_none() {
issues.push(StateIssue {
severity: IssueSeverity::Warning,
component: "binaries".to_string(),
message: "rch binary not found in PATH".to_string(),
remediation: "Add ~/.local/bin to PATH or install with: cargo install --path rch"
.to_string(),
exit_code: exit_codes::NEEDS_SETUP,
});
}
Ok(BinaryState {
rch_path,
rchd_path,
rch_wkr_path,
version: Some(env!("CARGO_PKG_VERSION").to_string()),
in_path,
})
}
fn determine_overall_status(components: &ComponentStates, issues: &[StateIssue]) -> InstallStatus {
let has_critical = issues.iter().any(|i| i.severity == IssueSeverity::Critical);
if has_critical {
return InstallStatus::NotInstalled;
}
if components.user_config.needs_migration {
return InstallStatus::NeedsMigration;
}
let config_ready = components.user_config.exists && components.user_config.valid;
let workers_ready = components.workers.exists
&& components.workers.valid
&& components.workers.worker_count > 0;
if !config_ready || !workers_ready {
return InstallStatus::NeedsSetup;
}
let has_warnings = issues.iter().any(|i| i.severity == IssueSeverity::Warning);
if has_warnings {
return InstallStatus::Degraded;
}
InstallStatus::Ready
}
pub fn state_to_exit_code(state: &RchState) -> i32 {
if let Some(issue) = state
.issues
.iter()
.find(|i| i.severity == IssueSeverity::Critical)
{
return issue.exit_code;
}
match state.status {
InstallStatus::Ready => exit_codes::OK,
InstallStatus::NeedsSetup => exit_codes::NEEDS_SETUP,
InstallStatus::NotInstalled => exit_codes::NEEDS_SETUP,
InstallStatus::Degraded => exit_codes::OK, InstallStatus::NeedsMigration => exit_codes::NEEDS_MIGRATION,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
use tracing::info;
use uuid::Uuid;
fn log_test_start(name: &str) {
info!("TEST START: {}", name);
}
fn log_test_pass(name: &str) {
info!("TEST PASS: {}", name);
}
fn build_test_state(status: InstallStatus) -> RchState {
RchState {
status,
components: ComponentStates {
user_config: ConfigState {
path: PathBuf::from("/test"),
exists: true,
valid: true,
version: Some("1.0".to_string()),
needs_migration: false,
source: ConfigSource::UserConfig,
},
project_config: ConfigState {
path: PathBuf::from("/test/.rch"),
exists: false,
valid: false,
version: None,
needs_migration: false,
source: ConfigSource::Default,
},
workers: WorkersState {
config_path: PathBuf::from("/test/workers.toml"),
exists: true,
valid: true,
worker_count: 2,
reachable_count: None,
},
daemon: DaemonState {
running: true,
pid: Some(12345),
socket_path: default_socket_path(),
socket_responsive: true,
version: Some("0.1.0".to_string()),
},
hooks: vec![],
binaries: BinaryState {
rch_path: Some(PathBuf::from("/usr/local/bin/rch")),
rchd_path: Some(PathBuf::from("/usr/local/bin/rchd")),
rch_wkr_path: None,
version: Some("0.1.0".to_string()),
in_path: true,
},
},
issues: vec![],
detected_at: chrono::Utc::now(),
rch_version: "0.1.0".to_string(),
}
}
#[test]
fn test_install_status_display() {
log_test_start("test_install_status_display");
assert_eq!(format!("{}", InstallStatus::Ready), "ready");
assert_eq!(format!("{}", InstallStatus::NeedsSetup), "needs setup");
assert_eq!(format!("{}", InstallStatus::NotInstalled), "not installed");
assert_eq!(format!("{}", InstallStatus::Degraded), "degraded");
assert_eq!(
format!("{}", InstallStatus::NeedsMigration),
"needs migration"
);
log_test_pass("test_install_status_display");
}
#[test]
fn test_state_serialization() {
log_test_start("test_state_serialization");
let state = build_test_state(InstallStatus::Ready);
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("ready"));
assert!(json.contains("user_config"));
log_test_pass("test_state_serialization");
}
#[test]
fn test_state_serialization_round_trip_file() {
log_test_start("test_state_serialization_round_trip_file");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
let state = build_test_state(InstallStatus::Ready);
let json = serde_json::to_string(&state).unwrap();
atomic_write(&path, json.as_bytes()).unwrap();
let loaded: RchState = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.status, InstallStatus::Ready);
assert_eq!(loaded.components.workers.worker_count, 2);
log_test_pass("test_state_serialization_round_trip_file");
}
#[test]
fn test_state_to_exit_code() {
log_test_start("test_state_to_exit_code");
let ready_state = RchState {
status: InstallStatus::Ready,
components: ComponentStates {
user_config: ConfigState {
path: PathBuf::new(),
exists: true,
valid: true,
version: None,
needs_migration: false,
source: ConfigSource::Default,
},
project_config: ConfigState {
path: PathBuf::new(),
exists: false,
valid: false,
version: None,
needs_migration: false,
source: ConfigSource::Default,
},
workers: WorkersState {
config_path: PathBuf::new(),
exists: true,
valid: true,
worker_count: 1,
reachable_count: None,
},
daemon: DaemonState {
running: true,
pid: None,
socket_path: PathBuf::new(),
socket_responsive: true,
version: None,
},
hooks: vec![],
binaries: BinaryState {
rch_path: None,
rchd_path: None,
rch_wkr_path: None,
version: None,
in_path: false,
},
},
issues: vec![],
detected_at: chrono::Utc::now(),
rch_version: "0.1.0".to_string(),
};
assert_eq!(state_to_exit_code(&ready_state), exit_codes::OK);
log_test_pass("test_state_to_exit_code");
}
#[test]
fn test_state_to_exit_code_prefers_critical_issue() {
log_test_start("test_state_to_exit_code_prefers_critical_issue");
let mut state = build_test_state(InstallStatus::Ready);
state.issues.push(StateIssue {
severity: IssueSeverity::Critical,
component: "daemon".to_string(),
message: "daemon down".to_string(),
remediation: "start daemon".to_string(),
exit_code: exit_codes::DAEMON_DOWN,
});
assert_eq!(state_to_exit_code(&state), exit_codes::DAEMON_DOWN);
log_test_pass("test_state_to_exit_code_prefers_critical_issue");
}
#[test]
#[serial_test::serial]
fn test_concurrent_state_access_with_lock() {
log_test_start("test_concurrent_state_access_with_lock");
let tmp = TempDir::new().unwrap();
let state_path = Arc::new(tmp.path().join("state_counter.txt"));
let lock_name = Arc::new(format!("state-lock-{}", Uuid::new_v4()));
let threads = 4;
let start_barrier = Arc::new(Barrier::new(threads));
let mut handles = Vec::new();
for i in 0..threads {
let state_path = Arc::clone(&state_path);
let lock_name = Arc::clone(&lock_name);
let start = Arc::clone(&start_barrier);
handles.push(thread::spawn(move || {
start.wait();
let _lock = ConfigLock::acquire_with_timeout(
&lock_name,
Duration::from_secs(30),
&format!("thread-{i}"),
)
.expect("lock acquisition failed");
let current = fs::read_to_string(&*state_path)
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let next = current + 1;
atomic_write(&state_path, next.to_string().as_bytes())
.expect("atomic write failed");
}));
}
for handle in handles {
handle.join().expect("thread panicked");
}
let final_value: usize = fs::read_to_string(&*state_path).unwrap().parse().unwrap();
assert_eq!(final_value, threads);
log_test_pass("test_concurrent_state_access_with_lock");
}
}