use std::path::PathBuf;
use serde::Serialize;
use tracing::{info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsistencyStatus {
Ok,
Drift,
Unknown,
}
impl ConsistencyStatus {
fn as_str(self) -> &'static str {
match self {
ConsistencyStatus::Ok => "ok",
ConsistencyStatus::Drift => "drift",
ConsistencyStatus::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ConsistencyFinding {
pub check: &'static str,
pub status: ConsistencyStatus,
pub reason_code: &'static str,
pub detail: String,
}
impl ConsistencyFinding {
fn ok(check: &'static str, reason_code: &'static str, detail: impl Into<String>) -> Self {
Self {
check,
status: ConsistencyStatus::Ok,
reason_code,
detail: detail.into(),
}
}
fn drift(check: &'static str, reason_code: &'static str, detail: impl Into<String>) -> Self {
Self {
check,
status: ConsistencyStatus::Drift,
reason_code,
detail: detail.into(),
}
}
fn unknown(check: &'static str, reason_code: &'static str, detail: impl Into<String>) -> Self {
Self {
check,
status: ConsistencyStatus::Unknown,
reason_code,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct StartupConsistencyReport {
pub findings: Vec<ConsistencyFinding>,
}
impl StartupConsistencyReport {
pub fn drift_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.status == ConsistencyStatus::Drift)
.count()
}
pub fn has_drift(&self) -> bool {
self.drift_count() > 0
}
}
#[derive(Debug, Clone)]
pub struct ConsistencyInputs {
pub daemon_socket: PathBuf,
pub hook_config_socket: Option<PathBuf>,
pub canonical_socket: PathBuf,
pub installed_hook_command: Option<String>,
pub current_exe: Option<PathBuf>,
pub home_dir: Option<PathBuf>,
}
fn command_program(cmd: &str) -> &str {
cmd.split_whitespace().next().unwrap_or(cmd)
}
pub fn check_startup_consistency(inputs: &ConsistencyInputs) -> StartupConsistencyReport {
let mut findings = Vec::new();
match &inputs.installed_hook_command {
None => {
findings.push(ConsistencyFinding::drift(
"hook_installed",
"hook_not_installed",
"no rch PreToolUse hook found in Claude Code settings; builds will not offload",
));
}
Some(cmd) => {
findings.push(ConsistencyFinding::ok(
"hook_installed",
"hook_present",
format!("rch PreToolUse hook present: {cmd}"),
));
let program = command_program(cmd);
let program_path = PathBuf::from(program);
if program_path.is_absolute() {
match inputs
.current_exe
.as_ref()
.and_then(|exe| exe.parent())
.map(|dir| dir.join("rch"))
{
Some(expected) if expected == program_path => {
findings.push(ConsistencyFinding::ok(
"hook_binary",
"hook_binary_coherent",
format!("hook rch matches daemon sibling: {}", expected.display()),
));
}
Some(expected) => {
findings.push(ConsistencyFinding::drift(
"hook_binary",
"hook_binary_mismatch",
format!(
"hook invokes {} but the running daemon's sibling rch is {}",
program_path.display(),
expected.display()
),
));
}
None => {
findings.push(ConsistencyFinding::unknown(
"hook_binary",
"daemon_exe_unknown",
format!(
"cannot compare hook rch {} — daemon executable path unknown",
program_path.display()
),
));
}
}
} else {
findings.push(ConsistencyFinding::ok(
"hook_binary",
"hook_binary_path_resolved",
format!("hook invokes PATH-resolved rch ({program})"),
));
}
}
}
match &inputs.hook_config_socket {
Some(hook_socket) if *hook_socket == inputs.daemon_socket => {
findings.push(ConsistencyFinding::ok(
"socket_consistency",
"socket_consistent",
format!("daemon and hook agree on socket {}", hook_socket.display()),
));
}
Some(hook_socket) => {
findings.push(ConsistencyFinding::drift(
"socket_consistency",
"socket_mismatch",
format!(
"daemon binds {} but hook/config expects {}; hook cannot reach the daemon",
inputs.daemon_socket.display(),
hook_socket.display()
),
));
}
None => {
if inputs.daemon_socket == inputs.canonical_socket {
findings.push(ConsistencyFinding::ok(
"socket_consistency",
"socket_canonical",
format!(
"daemon binds the canonical socket {}",
inputs.canonical_socket.display()
),
));
} else {
findings.push(ConsistencyFinding::drift(
"socket_consistency",
"socket_noncanonical_no_config",
format!(
"daemon binds {} but no hook socket is configured; the hook will use the \
canonical default {} and miss the daemon",
inputs.daemon_socket.display(),
inputs.canonical_socket.display()
),
));
}
}
}
match &inputs.home_dir {
None => findings.push(ConsistencyFinding::unknown(
"user_path",
"home_unresolved",
"current user's home directory could not be resolved",
)),
Some(home) if !inputs.daemon_socket.is_absolute() => {
findings.push(ConsistencyFinding::drift(
"user_path",
"socket_not_absolute",
format!(
"daemon socket {} is not an absolute path (home={})",
inputs.daemon_socket.display(),
home.display()
),
))
}
Some(home) => findings.push(ConsistencyFinding::ok(
"user_path",
"user_path_coherent",
format!(
"home resolved ({}) and socket path is absolute",
home.display()
),
)),
}
StartupConsistencyReport { findings }
}
pub fn log_startup_consistency(report: &StartupConsistencyReport) {
for f in &report.findings {
info!(
target: "rch::daemon::startup_consistency",
event = "daemon.startup.consistency",
check = f.check,
status = f.status.as_str(),
reason_code = f.reason_code,
detail = %f.detail,
"daemon.startup.consistency",
);
}
if report.has_drift() {
warn!(
target: "rch::daemon::startup_consistency",
event = "daemon.startup.consistency.drift",
drift_count = report.drift_count(),
"daemon startup detected hook/socket/path drift; not rewriting operator-owned config \
(run `rch doctor` to inspect, `--fix` to remediate)",
);
}
}
pub fn gather_and_log(
daemon_socket: PathBuf,
hook_config_socket: Option<PathBuf>,
) -> StartupConsistencyReport {
let inputs = ConsistencyInputs {
daemon_socket,
hook_config_socket,
canonical_socket: PathBuf::from(rch_common::default_socket_path()),
installed_hook_command: rch_common::hooks::installed_rch_hook_command(),
current_exe: std::env::current_exe().ok(),
home_dir: std::env::var_os("HOME").map(PathBuf::from),
};
let report = check_startup_consistency(&inputs);
log_startup_consistency(&report);
report
}
#[cfg(test)]
mod tests {
use super::*;
fn base_inputs() -> ConsistencyInputs {
ConsistencyInputs {
daemon_socket: PathBuf::from("/home/u/.cache/rch/rch.sock"),
hook_config_socket: Some(PathBuf::from("/home/u/.cache/rch/rch.sock")),
canonical_socket: PathBuf::from("/home/u/.cache/rch/rch.sock"),
installed_hook_command: Some("/home/u/.local/bin/rch".to_string()),
current_exe: Some(PathBuf::from("/home/u/.local/bin/rchd")),
home_dir: Some(PathBuf::from("/home/u")),
}
}
fn finding<'a>(report: &'a StartupConsistencyReport, check: &str) -> &'a ConsistencyFinding {
report
.findings
.iter()
.find(|f| f.check == check)
.unwrap_or_else(|| panic!("missing finding for check {check}"))
}
#[test]
fn coherent_setup_has_no_drift() {
let report = check_startup_consistency(&base_inputs());
assert!(
!report.has_drift(),
"coherent setup must not drift: {report:?}"
);
assert_eq!(
finding(&report, "hook_installed").status,
ConsistencyStatus::Ok
);
assert_eq!(
finding(&report, "hook_binary").status,
ConsistencyStatus::Ok
);
assert_eq!(
finding(&report, "socket_consistency").status,
ConsistencyStatus::Ok
);
assert_eq!(finding(&report, "user_path").status, ConsistencyStatus::Ok);
}
#[test]
fn missing_hook_is_drift() {
let mut inputs = base_inputs();
inputs.installed_hook_command = None;
let report = check_startup_consistency(&inputs);
let f = finding(&report, "hook_installed");
assert_eq!(f.status, ConsistencyStatus::Drift);
assert_eq!(f.reason_code, "hook_not_installed");
assert!(report.has_drift());
}
#[test]
fn hook_pointing_at_a_different_rch_install_is_drift() {
let mut inputs = base_inputs();
inputs.installed_hook_command = Some("/opt/other/bin/rch".to_string());
let report = check_startup_consistency(&inputs);
let f = finding(&report, "hook_binary");
assert_eq!(f.status, ConsistencyStatus::Drift);
assert_eq!(f.reason_code, "hook_binary_mismatch");
}
#[test]
fn path_resolved_hook_is_ok_not_drift() {
let mut inputs = base_inputs();
inputs.installed_hook_command = Some("rch".to_string());
let report = check_startup_consistency(&inputs);
let f = finding(&report, "hook_binary");
assert_eq!(f.status, ConsistencyStatus::Ok);
assert_eq!(f.reason_code, "hook_binary_path_resolved");
}
#[test]
fn daemon_and_hook_socket_mismatch_is_drift() {
let mut inputs = base_inputs();
inputs.hook_config_socket = Some(PathBuf::from("/tmp/other.sock"));
let report = check_startup_consistency(&inputs);
let f = finding(&report, "socket_consistency");
assert_eq!(f.status, ConsistencyStatus::Drift);
assert_eq!(f.reason_code, "socket_mismatch");
}
#[test]
fn noncanonical_socket_without_hook_config_is_drift() {
let mut inputs = base_inputs();
inputs.hook_config_socket = None;
inputs.daemon_socket = PathBuf::from("/run/custom/rch.sock");
let report = check_startup_consistency(&inputs);
let f = finding(&report, "socket_consistency");
assert_eq!(f.status, ConsistencyStatus::Drift);
assert_eq!(f.reason_code, "socket_noncanonical_no_config");
}
#[test]
fn canonical_socket_without_hook_config_is_ok() {
let mut inputs = base_inputs();
inputs.hook_config_socket = None;
let report = check_startup_consistency(&inputs);
assert_eq!(
finding(&report, "socket_consistency").reason_code,
"socket_canonical"
);
assert!(!report.has_drift());
}
#[test]
fn unresolved_home_is_unknown_not_drift() {
let mut inputs = base_inputs();
inputs.home_dir = None;
let report = check_startup_consistency(&inputs);
let f = finding(&report, "user_path");
assert_eq!(f.status, ConsistencyStatus::Unknown);
assert!(!report.has_drift());
}
#[test]
fn relative_socket_path_is_drift() {
let mut inputs = base_inputs();
inputs.daemon_socket = PathBuf::from("rch.sock");
inputs.hook_config_socket = Some(PathBuf::from("rch.sock"));
let report = check_startup_consistency(&inputs);
let f = finding(&report, "user_path");
assert_eq!(f.status, ConsistencyStatus::Drift);
assert_eq!(f.reason_code, "socket_not_absolute");
}
#[test]
fn drift_count_aggregates_multiple_findings() {
let mut inputs = base_inputs();
inputs.installed_hook_command = None; inputs.hook_config_socket = Some(PathBuf::from("/tmp/x.sock")); let report = check_startup_consistency(&inputs);
assert!(report.drift_count() >= 2, "expected >=2 drifts: {report:?}");
}
}