use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::cli::commands::doctor::{print_diagnostic_results, run_all_checks, DoctorReport};
use crate::cli::commands::{doctor_restore, lifecycle};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::DoctorArgs;
use crate::config;
use crate::error::OlError;
use crate::hooks;
use crate::telemetry::{self, Event};
pub(crate) const JOURNAL_FILENAME: &str = "fix-journal.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FixKind {
ConfigRewrite,
TokenRegenerate,
AgentIdInsert,
TelemetryReset,
PidStaleRemove,
HookReinstall,
DaemonRestart,
BinaryCopy,
SupervisionInstall,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixAction {
pub ol_code: String,
pub kind: FixKind,
pub file: PathBuf,
pub backup: Option<PathBuf>,
pub reversible: bool,
pub applied_at: DateTime<Utc>,
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Journal {
pub run_id: String,
pub started_at: DateTime<Utc>,
pub actions: Vec<FixAction>,
}
impl Journal {
pub fn new() -> Self {
Self {
run_id: uuid::Uuid::new_v4().simple().to_string(),
started_at: Utc::now(),
actions: Vec::new(),
}
}
pub fn save(&self, ol_dir: &Path) -> Result<PathBuf, OlError> {
let path = ol_dir.join(JOURNAL_FILENAME);
let raw = serde_json::to_string_pretty(self).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot serialize fix journal: {e}"),
)
})?;
std::fs::write(&path, raw).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot write fix journal '{}': {e}", path.display()),
)
})?;
Ok(path)
}
pub fn load(ol_dir: &Path) -> Result<Self, OlError> {
let path = ol_dir.join(JOURNAL_FILENAME);
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(OlError::new(
crate::error::ERR_DOCTOR_RESTORE_NO_JOURNAL,
format!("no prior --fix run found at '{}'", path.display()),
)
.with_suggestion(
"Run `openlatch doctor --fix` first; --restore reverses the most recent run.",
));
}
Err(e) => {
return Err(OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot read fix journal '{}': {e}", path.display()),
))
}
};
serde_json::from_str(&raw).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("fix journal at '{}' is malformed: {e}", path.display()),
)
.with_suggestion(
"Delete `fix-journal.json` and re-run `openlatch init` to reset state.",
)
})
}
}
impl Default for Journal {
fn default() -> Self {
Self::new()
}
}
pub fn run(_args: &DoctorArgs, output: &OutputConfig) -> Result<(), OlError> {
let started = std::time::Instant::now();
let ol_dir = config::openlatch_dir();
std::fs::create_dir_all(&ol_dir).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("cannot create openlatch dir '{}': {e}", ol_dir.display()),
)
})?;
crate::cli::header::print(output, &["doctor", "--fix"]);
if output.format == OutputFormat::Human && !output.quiet {
eprintln!();
}
let before = run_all_checks(output).ok();
let pre_fix = capture_pre_fix_state(&ol_dir);
let mut journal = Journal::new();
if pre_fix.was_running {
stop_daemon_for_fix(&pre_fix, &ol_dir);
}
journal.actions.extend(heal_state(&ol_dir));
journal.actions.extend(heal_binaries(&ol_dir));
journal.actions.extend(heal_hooks(&ol_dir));
journal.actions.extend(heal_supervision(&ol_dir));
let mut auto_rollback_triggered = false;
let (daemon_actions, restart_failed) = heal_daemon(&ol_dir, &pre_fix, &journal.actions);
journal.actions.extend(daemon_actions);
if restart_failed {
auto_rollback_triggered = true;
tracing::error!("doctor --fix: daemon restart failed — rolling back this run's actions");
let _ = doctor_restore::restore_actions(&journal.actions, &ol_dir);
if pre_fix.was_running {
let token = std::fs::read_to_string(ol_dir.join("daemon.token"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !token.is_empty() {
let _ = lifecycle::spawn_daemon_background(pre_fix.port, &token);
}
}
}
let journal_path = journal.save(&ol_dir)?;
let after = run_all_checks(output)?;
let categories: Vec<&str> = {
let mut cats: Vec<&str> = Vec::new();
for a in &journal.actions {
let cat = match a.kind {
FixKind::ConfigRewrite
| FixKind::TokenRegenerate
| FixKind::AgentIdInsert
| FixKind::TelemetryReset
| FixKind::PidStaleRemove => "state",
FixKind::HookReinstall => "hooks",
FixKind::DaemonRestart => "daemon",
FixKind::BinaryCopy => "binary",
FixKind::SupervisionInstall => "supervision",
};
if !cats.contains(&cat) {
cats.push(cat);
}
}
cats
};
let unfixable: Vec<&str> = after
.checks
.iter()
.filter(|c| !c.pass)
.map(|c| c.message.as_str())
.collect();
let (before_pass, before_fail) = before
.as_ref()
.map(|r| (r.checks.iter().filter(|c| c.pass).count(), r.fail_count()))
.unwrap_or((0, 0));
telemetry::capture_global(Event::doctor_fix_run(
categories,
before_pass,
before_fail,
after.checks.iter().filter(|c| c.pass).count(),
after.fail_count(),
unfixable,
started.elapsed().as_millis() as u64,
auto_rollback_triggered,
));
print_fix_results(
&journal,
&journal_path,
before.as_ref(),
&after,
auto_rollback_triggered,
output,
);
if after.all_pass() && !auto_rollback_triggered {
Ok(())
} else {
std::process::exit(1);
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct PreFixState {
pub was_running: bool,
pub was_healthy: bool,
pub port: u16,
pub pid: Option<u32>,
}
pub(crate) fn capture_pre_fix_state(_ol_dir: &Path) -> PreFixState {
let port = config::Config::load(None, None, false)
.map(|c| c.port)
.unwrap_or(config::PORT_RANGE_START);
let pid = lifecycle::read_pid_file();
let was_running = pid.map(lifecycle::is_process_alive).unwrap_or(false);
let was_healthy = if was_running {
lifecycle::check_health(port)
} else {
false
};
PreFixState {
was_running,
was_healthy,
port,
pid,
}
}
fn stop_daemon_for_fix(state: &PreFixState, ol_dir: &Path) {
let Some(pid) = state.pid else {
return;
};
let token = std::fs::read_to_string(ol_dir.join("daemon.token"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !token.is_empty() {
let _ = lifecycle::send_shutdown_request(state.port, &token);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline && lifecycle::is_process_alive(pid) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
if lifecycle::is_process_alive(pid) {
lifecycle::force_kill(pid);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while std::time::Instant::now() < deadline && lifecycle::is_process_alive(pid) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
let _ = std::fs::remove_file(ol_dir.join("daemon.pid"));
}
pub(crate) fn heal_daemon(
ol_dir: &Path,
pre_fix: &PreFixState,
prior_actions: &[FixAction],
) -> (Vec<FixAction>, bool) {
let mut actions = Vec::new();
let touched_critical = prior_actions.iter().any(|a| {
matches!(
a.kind,
FixKind::ConfigRewrite
| FixKind::TokenRegenerate
| FixKind::AgentIdInsert
| FixKind::HookReinstall
)
});
let should_restart = pre_fix.was_running || touched_critical;
if !should_restart {
return (actions, false);
}
let port = config::probe_free_port(pre_fix.port, config::PORT_RANGE_END)
.or_else(|_| config::probe_free_port(config::PORT_RANGE_START, config::PORT_RANGE_END))
.unwrap_or(pre_fix.port);
if port != pre_fix.port {
let _ = config::write_port_file(port);
}
let token = match std::fs::read_to_string(ol_dir.join("daemon.token")) {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
tracing::error!(
"doctor --fix: cannot restart daemon — daemon.token missing/empty after heal_state"
);
return (actions, true);
}
};
let pid = match lifecycle::spawn_daemon_background(port, &token) {
Ok(pid) => pid,
Err(e) => {
tracing::error!(error = %e.message, code = e.code, "doctor --fix: daemon spawn failed");
return (actions, true);
}
};
if !lifecycle::wait_for_health(port, 3) {
tracing::error!(
pid = pid,
port = port,
"doctor --fix: daemon spawned but /health did not return 200 within 3s"
);
return (actions, true);
}
actions.push(FixAction {
ol_code: crate::error::ERR_DAEMON_START_FAILED.to_string(),
kind: FixKind::DaemonRestart,
file: PathBuf::new(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!("daemon restarted on port {port} (PID {pid}, /health=200)"),
});
(actions, false)
}
pub(crate) fn heal_state(ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
let config_path = ol_dir.join("config.toml");
let config_needs_rewrite = match std::fs::read_to_string(&config_path) {
Ok(raw) => toml::from_str::<toml::Value>(&raw).is_err(),
Err(_) => true,
};
if config_needs_rewrite {
let backup = if config_path.exists() {
backup_file(&config_path).ok()
} else {
None
};
let content = config::generate_default_config_toml(config::PORT_RANGE_START);
if let Err(e) = std::fs::write(&config_path, content) {
tracing::warn!(error = %e, path = %config_path.display(), "doctor --fix: config rewrite failed");
} else {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::ConfigRewrite,
file: config_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("regenerated {} from defaults", config_path.display()),
});
}
}
let token_path = ol_dir.join("daemon.token");
let token_needs_regen = match std::fs::read_to_string(&token_path) {
Ok(raw) => raw.trim().is_empty(),
Err(_) => true,
};
if token_needs_regen {
let backup = if token_path.exists() {
backup_file(&token_path).ok()
} else {
None
};
if token_path.exists() {
let _ = std::fs::remove_file(&token_path);
}
match config::ensure_token(ol_dir) {
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::TokenRegenerate,
file: token_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("regenerated {} (mode 0600 on Unix)", token_path.display()),
});
}
Err(e) => {
tracing::warn!(error = %e.message, code = e.code, "doctor --fix: token regenerate failed");
}
}
}
if config_path.exists() {
let needs_insert = std::fs::read_to_string(&config_path)
.map(|raw| !raw.contains("agent_id"))
.unwrap_or(false);
if needs_insert {
let backup = backup_file(&config_path).ok();
match config::ensure_agent_id(&config_path) {
Ok(id) => {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::AgentIdInsert,
file: config_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("inserted agent_id={id} into [daemon] section"),
});
}
Err(e) => {
tracing::warn!(error = %e.message, code = e.code, "doctor --fix: agent_id insert failed");
}
}
}
}
let telem_path = ol_dir.join("telemetry.json");
if telem_path.exists() {
let valid = std::fs::read_to_string(&telem_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.is_some();
if !valid {
let backup = backup_file(&telem_path).ok();
let reset = serde_json::json!({
"enabled": false,
"schema_version": 1,
"notice_shown_at": null,
});
match serde_json::to_string_pretty(&reset)
.map_err(std::io::Error::other)
.and_then(|s| std::fs::write(&telem_path, s))
{
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_TELEMETRY_CONFIG_CORRUPT.to_string(),
kind: FixKind::TelemetryReset,
file: telem_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!(
"reset {} to enabled=false (re-consent required via init)",
telem_path.display()
),
});
}
Err(e) => {
tracing::warn!(error = %e, "doctor --fix: telemetry reset failed");
}
}
}
}
let pid_path = ol_dir.join("daemon.pid");
if pid_path.exists() {
let pid_alive = std::fs::read_to_string(&pid_path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.map(lifecycle::is_process_alive)
.unwrap_or(false);
if !pid_alive {
if let Err(e) = std::fs::remove_file(&pid_path) {
tracing::warn!(error = %e, path = %pid_path.display(), "doctor --fix: stale PID removal failed");
} else {
actions.push(FixAction {
ol_code: crate::error::ERR_ALREADY_RUNNING.to_string(),
kind: FixKind::PidStaleRemove,
file: pid_path.clone(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!("removed stale {} (process not alive)", pid_path.display()),
});
}
}
}
actions
}
pub(crate) fn heal_hooks(ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
let agent = match hooks::detect_agent() {
Ok(a) => a,
Err(_) => return actions, };
let token_path = ol_dir.join("daemon.token");
let token = match std::fs::read_to_string(&token_path) {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
tracing::warn!(
path = %token_path.display(),
"doctor --fix: skipping hook reinstall — daemon.token missing/empty after heal_state"
);
return actions;
}
};
let port = config::Config::load(None, None, false)
.map(|c| c.port)
.unwrap_or(config::PORT_RANGE_START);
let settings_path = match &agent {
hooks::DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
};
let needs_reinstall = match std::fs::read_to_string(&settings_path) {
Ok(content) => {
!content.contains("_openlatch")
|| !content.contains("PreToolUse")
|| !content.contains("UserPromptSubmit")
|| !content.contains("Stop")
}
Err(_) => true, };
if !needs_reinstall {
return actions;
}
let backup = if settings_path.exists() {
backup_file(&settings_path).ok()
} else {
None
};
match hooks::install_hooks(&agent, port, &token) {
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_HOOK_WRITE_FAILED.to_string(),
kind: FixKind::HookReinstall,
file: settings_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("reinstalled hooks in {}", settings_path.display()),
});
}
Err(e) => {
tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: hook reinstall failed"
);
}
}
actions
}
pub(crate) fn heal_binaries(ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
let target_dir = ol_dir.join("bin");
let target = target_dir.join(bin_name);
if target.exists() {
return actions;
}
let source = locate_hook_source_for_staging(bin_name);
let Some(source) = source else {
tracing::warn!(
target = %target.display(),
"doctor --fix: cannot stage hook binary — no source found (env, exe-sibling)"
);
return actions;
};
if let Err(e) = std::fs::create_dir_all(&target_dir) {
tracing::warn!(error = %e, dir = %target_dir.display(), "doctor --fix: cannot create bin dir");
return actions;
}
if let Err(e) = std::fs::copy(&source, &target) {
tracing::warn!(
error = %e,
source = %source.display(),
target = %target.display(),
"doctor --fix: hook binary copy failed"
);
return actions;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) {
tracing::warn!(error = %e, path = %target.display(), "doctor --fix: chmod +x failed");
}
}
actions.push(FixAction {
ol_code: crate::error::ERR_HOOK_WRITE_FAILED.to_string(),
kind: FixKind::BinaryCopy,
file: target.clone(),
backup: None, reversible: true,
applied_at: Utc::now(),
note: format!("staged {} from {}", target.display(), source.display()),
});
actions
}
fn locate_hook_source_for_staging(bin_name: &str) -> Option<PathBuf> {
if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
if !override_path.is_empty() {
let p = PathBuf::from(override_path);
if p.exists() {
return Some(p);
}
}
}
if let Ok(current_exe) = std::env::current_exe() {
if let Some(dir) = current_exe.parent() {
let candidate = dir.join(bin_name);
if candidate.exists() {
return Some(candidate);
}
}
}
None
}
pub(crate) fn heal_supervision(ol_dir: &Path) -> Vec<FixAction> {
use crate::supervision::{select_supervisor, SupervisionMode};
let mut actions = Vec::new();
let cfg = match config::Config::load(None, None, false) {
Ok(c) => c,
Err(_) => return actions,
};
if !matches!(cfg.supervision.mode, SupervisionMode::Active) {
return actions;
}
let Some(supervisor) = select_supervisor() else {
return actions;
};
let status_ok = supervisor.status().map(|s| s.installed).unwrap_or(false);
if status_ok {
return actions;
}
let exe_path =
std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("openlatch"));
if let Err(e) = supervisor.install(&exe_path) {
tracing::warn!(error = %e.message, code = %e.code, "doctor --fix: supervisor reinstall failed");
return actions;
}
let config_path = ol_dir.join("config.toml");
let _ = config::persist_supervision_state(
&config_path,
&SupervisionMode::Active,
&supervisor.kind(),
None,
);
actions.push(FixAction {
ol_code: crate::supervision::ERR_SUPERVISION_INSTALL_FAILED.to_string(),
kind: FixKind::SupervisionInstall,
file: std::path::PathBuf::new(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: "Reinstalled missing OS supervisor (config said active)".to_string(),
});
actions
}
pub(crate) fn backup_file(path: &Path) -> Result<PathBuf, OlError> {
let bak = bak_path_for(path);
std::fs::copy(path, &bak).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!(
"cannot create backup '{}' for '{}': {e}",
bak.display(),
path.display()
),
)
})?;
Ok(bak)
}
pub(crate) fn bak_path_for(path: &Path) -> PathBuf {
let mut bak = path.to_path_buf();
let new_name = match path.file_name() {
Some(name) => format!("{}.bak", name.to_string_lossy()),
None => "unknown.bak".to_string(),
};
bak.set_file_name(new_name);
bak
}
fn print_fix_results(
journal: &Journal,
journal_path: &Path,
before: Option<&DoctorReport>,
after: &DoctorReport,
auto_rollback_triggered: bool,
output: &OutputConfig,
) {
let unfixable: Vec<&str> = after
.checks
.iter()
.filter(|c| !c.pass)
.map(|c| c.message.as_str())
.collect();
if output.format == OutputFormat::Json {
let actions_json: Vec<serde_json::Value> = journal
.actions
.iter()
.map(|a| {
serde_json::json!({
"ol_code": a.ol_code,
"kind": a.kind,
"file": a.file.display().to_string(),
"backup": a.backup.as_ref().map(|p| p.display().to_string()),
"reversible": a.reversible,
"applied_at": a.applied_at,
"note": a.note,
})
})
.collect();
let backups: Vec<String> = journal
.actions
.iter()
.filter_map(|a| a.backup.as_ref().map(|p| p.display().to_string()))
.collect();
let exit_code = if after.all_pass() && !auto_rollback_triggered {
0
} else {
1
};
output.print_json(&serde_json::json!({
"command": "doctor_fix",
"run_id": journal.run_id,
"started_at": journal.started_at,
"journal_path": journal_path.display().to_string(),
"checks_before": before.map(|r| serde_json::json!({
"pass": r.checks.iter().filter(|c| c.pass).count(),
"fail": r.fail_count(),
})),
"checks_after": serde_json::json!({
"pass": after.checks.iter().filter(|c| c.pass).count(),
"fail": after.fail_count(),
}),
"fixes_applied": actions_json,
"fixes_count": journal.actions.len(),
"unfixable": unfixable,
"backups": backups,
"auto_rollback_triggered": auto_rollback_triggered,
"exit_code": exit_code,
}));
return;
}
if output.quiet {
return;
}
print_diagnostic_results(after, output);
if auto_rollback_triggered {
eprintln!();
eprintln!("Fix attempted but daemon failed to restart — rolled back to pre-fix state.");
eprintln!(" Run `openlatch doctor --rescue` to file a bug with diagnostics.");
return;
}
if !journal.actions.is_empty() {
eprintln!();
eprintln!("Fixes applied ({}):", journal.actions.len());
for action in &journal.actions {
eprintln!(" • {} [{}]", action.note, action.ol_code);
}
let backups: Vec<String> = journal
.actions
.iter()
.filter_map(|a| a.backup.as_ref().map(|p| p.display().to_string()))
.collect();
if !backups.is_empty() {
eprintln!();
eprintln!("Backups created: {}", backups.join(", "));
eprintln!("Roll back with: openlatch doctor --restore");
}
}
eprintln!();
if after.all_pass() {
eprintln!(
"Summary: {} fix{} applied, 0 issues remaining.",
journal.actions.len(),
if journal.actions.len() == 1 { "" } else { "es" }
);
} else {
eprintln!(
"Summary: {} fix{} applied, {} issue{} remaining.",
journal.actions.len(),
if journal.actions.len() == 1 { "" } else { "es" },
after.fail_count(),
if after.fail_count() == 1 { "" } else { "s" }
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn empty_dir() -> TempDir {
TempDir::new().expect("tempdir must be created")
}
#[test]
fn test_bak_path_for_appends_bak_suffix() {
let p = Path::new("/tmp/config.toml");
assert_eq!(bak_path_for(p), Path::new("/tmp/config.toml.bak"));
}
#[test]
fn test_bak_path_for_handles_no_extension() {
let p = Path::new("/tmp/daemon.token");
assert_eq!(bak_path_for(p), Path::new("/tmp/daemon.token.bak"));
}
#[test]
fn test_backup_file_round_trip() {
let tmp = empty_dir();
let src = tmp.path().join("config.toml");
std::fs::write(&src, "port = 7443\n").unwrap();
let bak = backup_file(&src).expect("backup must succeed");
assert_eq!(bak, src.with_file_name("config.toml.bak"));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "port = 7443\n");
}
#[test]
fn test_heal_state_creates_config_when_missing() {
let tmp = empty_dir();
let actions = heal_state(tmp.path());
let config_path = tmp.path().join("config.toml");
assert!(config_path.exists(), "config.toml must be created");
assert!(
actions
.iter()
.any(|a| a.kind == FixKind::ConfigRewrite && a.backup.is_none()),
"expected ConfigRewrite action with no backup (no prior file)"
);
}
#[test]
fn test_heal_state_rewrites_corrupt_config_with_backup() {
let tmp = empty_dir();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "this is not valid TOML {{{").unwrap();
let actions = heal_state(tmp.path());
let bak = config_path.with_file_name("config.toml.bak");
assert!(bak.exists(), ".bak must be created for corrupt config");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(toml::from_str::<toml::Value>(&raw).is_ok());
assert!(actions
.iter()
.any(|a| a.kind == FixKind::ConfigRewrite && a.backup.is_some()));
}
#[test]
fn test_heal_state_regenerates_missing_token() {
let tmp = empty_dir();
let token_path = tmp.path().join("daemon.token");
let actions = heal_state(tmp.path());
assert!(token_path.exists(), "token must be regenerated");
let token = std::fs::read_to_string(&token_path).unwrap();
assert_eq!(token.trim().len(), 64, "token must be 64 hex chars");
assert!(actions.iter().any(|a| a.kind == FixKind::TokenRegenerate));
}
#[test]
fn test_heal_state_regenerates_empty_token_with_backup() {
let tmp = empty_dir();
let token_path = tmp.path().join("daemon.token");
std::fs::write(&token_path, "").unwrap();
let actions = heal_state(tmp.path());
let bak = token_path.with_file_name("daemon.token.bak");
assert!(bak.exists(), "empty token must be backed up");
let token = std::fs::read_to_string(&token_path).unwrap();
assert_eq!(token.trim().len(), 64);
assert!(actions.iter().any(|a| a.kind == FixKind::TokenRegenerate));
}
#[test]
fn test_heal_state_inserts_agent_id_into_existing_config() {
let tmp = empty_dir();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let actions = heal_state(tmp.path());
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(raw.contains("agent_id"), "agent_id must be inserted");
assert!(actions.iter().any(|a| a.kind == FixKind::AgentIdInsert));
}
#[test]
fn test_heal_state_resets_corrupt_telemetry_json() {
let tmp = empty_dir();
let telem_path = tmp.path().join("telemetry.json");
std::fs::write(&telem_path, "{ broken json").unwrap();
let actions = heal_state(tmp.path());
let raw = std::fs::read_to_string(&telem_path).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&raw).expect("telemetry must be valid JSON");
assert_eq!(parsed.get("enabled"), Some(&serde_json::json!(false)));
assert!(actions.iter().any(|a| a.kind == FixKind::TelemetryReset));
}
#[test]
fn test_heal_state_removes_stale_pid_file() {
let tmp = empty_dir();
let pid_path = tmp.path().join("daemon.pid");
std::fs::write(&pid_path, "0").unwrap();
let actions = heal_state(tmp.path());
assert!(!pid_path.exists(), "stale PID file must be removed");
assert!(actions.iter().any(|a| a.kind == FixKind::PidStaleRemove));
}
#[test]
fn test_heal_state_idempotent_on_clean_install() {
let tmp = empty_dir();
let first = heal_state(tmp.path());
assert!(!first.is_empty(), "first run must apply at least one fix");
let second = heal_state(tmp.path());
assert!(
second.is_empty(),
"second run on a healthy install must apply zero fixes (got {second:?})"
);
}
#[test]
fn test_heal_binaries_noop_when_target_exists() {
let tmp = empty_dir();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
std::fs::write(bin_dir.join(bin_name), b"existing").unwrap();
let actions = heal_binaries(tmp.path());
assert!(actions.is_empty(), "no action when target already exists");
}
#[test]
fn test_heal_binaries_copies_from_env_override() {
let tmp = empty_dir();
let src_dir = empty_dir();
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
let src = src_dir.path().join(bin_name);
std::fs::write(&src, b"hook bytes").unwrap();
let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
std::env::set_var("OPENLATCH_HOOK_BIN", &src);
let actions = heal_binaries(tmp.path());
if let Some(p) = prev {
std::env::set_var("OPENLATCH_HOOK_BIN", p);
} else {
std::env::remove_var("OPENLATCH_HOOK_BIN");
}
let target = tmp.path().join("bin").join(bin_name);
assert!(target.exists(), "binary must be staged");
assert_eq!(std::fs::read(&target).unwrap(), b"hook bytes");
assert!(actions.iter().any(|a| a.kind == FixKind::BinaryCopy));
}
#[test]
fn test_heal_binaries_no_action_when_no_source_locatable() {
let tmp = empty_dir();
let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
std::env::set_var("OPENLATCH_HOOK_BIN", "");
let actions = heal_binaries(tmp.path());
if let Some(p) = prev {
std::env::set_var("OPENLATCH_HOOK_BIN", p);
} else {
std::env::remove_var("OPENLATCH_HOOK_BIN");
}
for a in &actions {
assert!(a.file.starts_with(tmp.path()));
}
}
#[test]
fn test_journal_save_and_load_round_trip() {
let tmp = empty_dir();
let mut journal = Journal::new();
journal.actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::ConfigRewrite,
file: tmp.path().join("config.toml"),
backup: Some(tmp.path().join("config.toml.bak")),
reversible: true,
applied_at: Utc::now(),
note: "test".to_string(),
});
journal.save(tmp.path()).expect("save must succeed");
let loaded = Journal::load(tmp.path()).expect("load must succeed");
assert_eq!(loaded.run_id, journal.run_id);
assert_eq!(loaded.actions.len(), 1);
assert_eq!(loaded.actions[0].kind, FixKind::ConfigRewrite);
}
#[test]
fn test_journal_load_returns_no_journal_error_when_absent() {
let tmp = empty_dir();
let err = Journal::load(tmp.path()).expect_err("load must fail when absent");
assert_eq!(err.code, crate::error::ERR_DOCTOR_RESTORE_NO_JOURNAL);
}
#[test]
fn test_journal_load_returns_corrupt_error_when_unparsable() {
let tmp = empty_dir();
std::fs::write(tmp.path().join(JOURNAL_FILENAME), "{ broken").unwrap();
let err = Journal::load(tmp.path()).expect_err("load must fail on bad JSON");
assert_eq!(err.code, crate::error::ERR_DOCTOR_JOURNAL_CORRUPT);
}
}