use std::collections::HashSet;
use std::fs;
use libgrite_core::config::{actor_sled_path, list_actors};
use libgrite_core::integrity::check_store_integrity;
use libgrite_core::{EventId, GriteError, GriteStore};
use libgrite_git::WalManager;
use serde::Serialize;
use crate::cli::Cli;
use crate::commands::daemon::{is_daemon_running, start_daemon, stop_daemon};
use crate::context::{ExecutionMode, GriteContext};
use crate::output::output_success;
#[derive(Serialize)]
struct DoctorOutput {
checks: Vec<CheckResult>,
applied: Vec<String>,
}
#[derive(Serialize)]
struct CheckResult {
id: String,
status: String,
message: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
plan: Vec<String>,
}
impl CheckResult {
fn ok(id: &str, message: &str) -> Self {
Self {
id: id.to_string(),
status: "ok".to_string(),
message: message.to_string(),
plan: vec![],
}
}
fn warn(id: &str, message: &str, plan: Vec<&str>) -> Self {
Self {
id: id.to_string(),
status: "warn".to_string(),
message: message.to_string(),
plan: plan.into_iter().map(String::from).collect(),
}
}
fn error(id: &str, message: &str, plan: Vec<&str>) -> Self {
Self {
id: id.to_string(),
status: "error".to_string(),
message: message.to_string(),
plan: plan.into_iter().map(String::from).collect(),
}
}
}
fn store_held_by_daemon(cli: &Cli) -> bool {
GriteContext::resolve(cli)
.map(|ctx| {
matches!(
ctx.execution_mode(cli.no_daemon),
ExecutionMode::Daemon { .. } | ExecutionMode::Blocked { .. }
)
})
.unwrap_or(false)
}
pub fn run(cli: &Cli, fix: bool) -> Result<(), GriteError> {
let mut checks = Vec::new();
let mut applied = Vec::new();
let daemon_was_running = fix && is_daemon_running(cli);
if daemon_was_running {
if !cli.quiet && !cli.json {
eprintln!("Stopping daemon for repairs...");
}
let _ = stop_daemon(cli);
std::thread::sleep(std::time::Duration::from_millis(100));
}
checks.push(check_git_repo(cli));
let (wal_check, needs_wal_backfill) = check_wal_ref(cli);
checks.push(wal_check);
checks.push(check_actor_config(cli));
let (store_check, needs_rebuild) = check_store(cli);
checks.push(store_check);
checks.push(check_rebuild_threshold(cli));
let (orphan_check, needs_merge) = check_legacy_actor_sleds(cli);
checks.push(orphan_check);
if fix && needs_rebuild {
if let Ok(ctx) = GriteContext::resolve(cli) {
if let Ok(store) = ctx.open_store() {
if store.rebuild().is_ok() {
applied.push("rebuild".to_string());
}
}
}
}
if fix && needs_wal_backfill {
match fix_wal_backfill(cli) {
Ok(count) if count > 0 => {
applied.push(format!("backfilled {} event(s) to WAL", count));
if let Some(c) = checks.iter_mut().find(|c| c.id == "wal_ref") {
*c = CheckResult::ok(
"wal_ref",
&format!("WAL backfilled with {} event(s)", count),
);
}
}
Ok(_) => {}
Err(e) => {
if let Some(c) = checks.iter_mut().find(|c| c.id == "wal_ref") {
*c = CheckResult::error(
"wal_ref",
&format!("WAL backfill failed: {}", e),
vec![],
);
}
}
}
}
if fix && needs_merge {
match fix_legacy_actor_sleds(cli) {
Ok((merged, cleaned)) if merged > 0 || cleaned > 0 => {
if merged > 0 {
applied.push(format!("merged {} legacy event(s)", merged));
}
if cleaned > 0 {
applied.push(format!("cleaned {} legacy sled(s)", cleaned));
}
if let Some(c) = checks.iter_mut().find(|c| c.id == "legacy_actor_sleds") {
let msg = match (merged, cleaned) {
(m, c) if m > 0 && c > 0 => {
format!("merged {} event(s), cleaned {} legacy sled(s)", m, c)
}
(m, _) if m > 0 => {
format!("merged {} legacy event(s) into shared store", m)
}
(_, c) => format!("cleaned {} legacy sled(s)", c),
};
*c = CheckResult::ok("legacy_actor_sleds", &msg);
}
}
Ok(_) => {}
Err(_) => {}
}
}
if daemon_was_running {
if !cli.quiet && !cli.json {
eprintln!("Restarting daemon...");
}
let _ = start_daemon(cli, 300); }
let has_errors = checks.iter().any(|c| c.status == "error");
let did_repair = !applied.is_empty();
if cli.json {
output_success(cli, DoctorOutput { checks, applied });
} else if !cli.quiet {
for check in &checks {
let icon = match check.status.as_str() {
"ok" => "[ok]",
"warn" => "[!!]",
"error" => "[ERR]",
_ => "[?]",
};
println!("{} {}: {}", icon, check.id, check.message);
for plan_item in &check.plan {
println!(" -> {}", plan_item);
}
}
if !applied.is_empty() {
println!("\nApplied fixes: {}", applied.join(", "));
}
}
if has_errors && !did_repair {
return Err(GriteError::Internal("Health checks failed".to_string()));
}
Ok(())
}
fn check_git_repo(cli: &Cli) -> CheckResult {
match GriteContext::resolve(cli) {
Ok(ctx) => {
let git_dir = ctx.repo_root().join(".git");
if git_dir.exists() {
CheckResult::ok("git_repo", "Git repository is valid")
} else {
CheckResult::error("git_repo", "Not a git repository", vec!["Run 'git init'"])
}
}
Err(_) => CheckResult::error(
"git_repo",
"Cannot resolve repository context",
vec!["Ensure you are in a git repository", "Run 'grite init'"],
),
}
}
fn check_wal_ref(cli: &Cli) -> (CheckResult, bool) {
let ctx = match GriteContext::resolve(cli) {
Ok(ctx) => ctx,
Err(_) => {
return (
CheckResult::warn(
"wal_ref",
"Cannot check WAL - no context",
vec!["Fix git_repo first"],
),
false,
)
}
};
let git_dir = ctx.repo_root().join(".git");
match WalManager::open(&git_dir) {
Ok(wal) => match wal.head() {
Ok(Some(_)) => (
CheckResult::ok("wal_ref", "WAL ref exists and is readable"),
false,
),
Ok(None) => {
let sled_events = ctx
.open_store()
.ok()
.and_then(|s| s.get_all_events().ok())
.map(|e| e.len())
.unwrap_or(0);
if sled_events > 0 {
(
CheckResult::warn(
"wal_ref",
&format!("WAL ref empty but sled has {} event(s)", sled_events),
vec!["Run 'grite doctor --fix' to backfill WAL from sled"],
),
true,
)
} else {
(
CheckResult::ok("wal_ref", "WAL ref not yet created (empty)"),
false,
)
}
}
Err(e) => (
CheckResult::error(
"wal_ref",
&format!("WAL ref is corrupted: {}", e),
vec!["Run 'grite doctor --fix' to rebuild"],
),
false,
),
},
Err(e) => (
CheckResult::error(
"wal_ref",
&format!("Cannot open WAL manager: {}", e),
vec!["Check git repository integrity"],
),
false,
),
}
}
fn check_actor_config(cli: &Cli) -> CheckResult {
match GriteContext::resolve(cli) {
Ok(ctx) => {
if ctx.actor_id.is_empty() {
CheckResult::warn(
"actor_config",
"No actor configured",
vec!["Run 'grite actor init'"],
)
} else {
CheckResult::ok(
"actor_config",
&format!(
"Actor configured: {}",
&ctx.actor_id[..8.min(ctx.actor_id.len())]
),
)
}
}
Err(_) => CheckResult::warn(
"actor_config",
"Cannot check actor config - no context",
vec!["Run 'grite init' first"],
),
}
}
fn check_store(cli: &Cli) -> (CheckResult, bool) {
let ctx = match GriteContext::resolve(cli) {
Ok(ctx) => ctx,
Err(_) => {
return (
CheckResult::warn(
"store_integrity",
"Cannot check store - no context",
vec!["Fix git_repo first"],
),
false,
)
}
};
if store_held_by_daemon(cli) {
return (
CheckResult::ok("store_integrity", "Store held by running daemon"),
false,
);
}
let store = match ctx.open_store() {
Ok(store) => store,
Err(e) => {
return (
CheckResult::error(
"store_integrity",
&format!("Cannot open store: {}", e),
vec!["Run 'grite doctor --fix' to rebuild"],
),
true,
)
}
};
match check_store_integrity(&store, false) {
Ok(report) => {
if report.is_healthy() {
(
CheckResult::ok(
"store_integrity",
&format!("{} events verified", report.events_checked),
),
false,
)
} else {
(
CheckResult::error(
"store_integrity",
&format!(
"{} corrupt events found out of {}",
report.corruption_count(),
report.events_checked
),
vec!["Run 'grite doctor --fix' to rebuild from WAL"],
),
true,
)
}
}
Err(e) => (
CheckResult::error(
"store_integrity",
&format!("Integrity check failed: {}", e),
vec!["Run 'grite doctor --fix' to rebuild"],
),
true,
),
}
}
fn check_rebuild_threshold(cli: &Cli) -> CheckResult {
let ctx = match GriteContext::resolve(cli) {
Ok(ctx) => ctx,
Err(_) => {
return CheckResult::warn(
"rebuild_threshold",
"Cannot check rebuild threshold - no context",
vec!["Fix git_repo first"],
)
}
};
if store_held_by_daemon(cli) {
return CheckResult::ok(
"rebuild_threshold",
"Rebuild threshold managed by running daemon",
);
}
let store = match ctx.open_store() {
Ok(store) => store,
Err(_) => {
return CheckResult::warn(
"rebuild_threshold",
"Cannot check rebuild threshold - cannot open store",
vec!["Fix store_integrity first"],
)
}
};
let sled_path = ctx.sled_path();
match store.stats(&sled_path) {
Ok(stats) => {
if stats.rebuild_recommended {
let days_msg = stats
.days_since_rebuild
.map(|d| format!(" ({} days ago)", d))
.unwrap_or_default();
CheckResult::warn(
"rebuild_threshold",
&format!(
"{} events since last rebuild{}",
stats.events_since_rebuild, days_msg
),
vec!["Run 'grite rebuild' to optimize performance"],
)
} else {
let events_msg = if stats.events_since_rebuild > 0 {
format!("{} events since last rebuild", stats.events_since_rebuild)
} else {
"No events since last rebuild".to_string()
};
CheckResult::ok("rebuild_threshold", &events_msg)
}
}
Err(e) => CheckResult::warn(
"rebuild_threshold",
&format!("Cannot check rebuild stats: {}", e),
vec![],
),
}
}
fn check_legacy_actor_sleds(cli: &Cli) -> (CheckResult, bool) {
let git_dir = match GriteContext::find_git_dir() {
Ok(d) => d,
Err(_) => {
return (
CheckResult::warn(
"legacy_actor_sleds",
"Cannot check - no git context",
vec![],
),
false,
)
}
};
let ctx = match GriteContext::resolve(cli) {
Ok(ctx) => ctx,
Err(_) => {
return (
CheckResult::warn(
"legacy_actor_sleds",
"Cannot check - no actor configured",
vec!["Run 'grite init'"],
),
false,
)
}
};
let actors = match list_actors(&git_dir) {
Ok(a) => a,
Err(e) => {
return (
CheckResult::warn(
"legacy_actor_sleds",
&format!("Cannot list actors: {}", e),
vec![],
),
false,
)
}
};
let legacy: Vec<_> = actors
.into_iter()
.filter(|a| actor_sled_path(&git_dir, &a.actor_id).exists())
.collect();
if legacy.is_empty() {
return (
CheckResult::ok("legacy_actor_sleds", "No legacy per-actor sleds"),
false,
);
}
if store_held_by_daemon(cli) {
return (
CheckResult::warn(
"legacy_actor_sleds",
&format!(
"{} legacy actor sled(s) found (store held by daemon - cannot count unmerged events)",
legacy.len()
),
vec!["Stop the daemon and re-run 'grite doctor' to check for unmerged events"],
),
false,
);
}
let current_event_ids: HashSet<EventId> = match ctx.open_store() {
Ok(store) => store
.get_all_events()
.unwrap_or_default()
.into_iter()
.map(|e| e.event_id)
.collect(),
Err(_) => {
return (
CheckResult::warn(
"legacy_actor_sleds",
"Cannot open shared store to check for unmerged events",
vec!["Fix store_integrity first"],
),
false,
)
}
};
let mut accessible = 0usize;
let mut unmerged_events = 0usize;
for actor in &legacy {
let sled_path = actor_sled_path(&git_dir, &actor.actor_id);
if let Ok(store) = GriteStore::open(&sled_path) {
accessible += 1;
let events = store.get_all_events().unwrap_or_default();
unmerged_events += events
.iter()
.filter(|e| !current_event_ids.contains(&e.event_id))
.count();
}
}
if accessible == 0 {
return (
CheckResult::ok("legacy_actor_sleds", "No accessible legacy actor sleds"),
false,
);
}
if unmerged_events == 0 {
(
CheckResult::warn(
"legacy_actor_sleds",
&format!(
"{} legacy actor sled(s), all events already in shared store",
accessible
),
vec!["Run 'grite doctor --fix' to clean up legacy sleds"],
),
true, )
} else {
(
CheckResult::warn(
"legacy_actor_sleds",
&format!(
"{} legacy actor sled(s) with {} unmerged event(s)",
accessible, unmerged_events
),
vec!["Run 'grite doctor --fix' to merge legacy events into the shared store"],
),
true,
)
}
}
fn fix_legacy_actor_sleds(cli: &Cli) -> Result<(usize, usize), GriteError> {
let git_dir = GriteContext::find_git_dir()?;
let ctx = GriteContext::resolve(cli)?;
let actors = list_actors(&git_dir)?;
let current_store = ctx.open_store()?;
let current_event_ids: HashSet<EventId> = current_store
.get_all_events()?
.into_iter()
.map(|e| e.event_id)
.collect();
let mut merged = 0usize;
let mut cleaned = 0usize;
let mut paths_to_clean: Vec<std::path::PathBuf> = Vec::new();
for actor in actors {
let sled_path = actor_sled_path(&git_dir, &actor.actor_id);
if !sled_path.exists() {
continue;
}
let legacy_store: GriteStore = match GriteStore::open(&sled_path) {
Ok(s) => s,
Err(_) => continue,
};
let events = legacy_store.get_all_events().unwrap_or_default();
let all_merged = events
.iter()
.all(|e| current_event_ids.contains(&e.event_id));
for event in &events {
if !current_event_ids.contains(&event.event_id) {
current_store.insert_event(event)?;
merged += 1;
}
}
if all_merged || merged > 0 {
paths_to_clean.push(sled_path);
}
}
if merged > 0 {
current_store.rebuild()?;
}
for path in paths_to_clean {
if path.exists() {
if let Ok(canonical) = path.canonicalize() {
if canonical.to_string_lossy().contains("/.git/grite/actors/")
&& fs::remove_dir_all(&path).is_ok()
{
cleaned += 1;
}
}
}
}
Ok((merged, cleaned))
}
fn fix_wal_backfill(cli: &Cli) -> Result<usize, GriteError> {
let ctx = GriteContext::resolve(cli)?;
let store = ctx.open_store()?;
let mut events = store.get_all_events()?;
if events.is_empty() {
return Ok(0);
}
let git_dir = ctx.repo_root().join(".git");
let wal = WalManager::open(&git_dir)
.map_err(|e| GriteError::Internal(format!("Cannot open WAL: {}", e)))?;
let actor_id: libgrite_core::types::ids::ActorId = hex::decode(&ctx.actor_id)
.map_err(|e| GriteError::Internal(format!("Invalid actor ID: {}", e)))?
.try_into()
.map_err(|_| GriteError::Internal("Actor ID must be 16 bytes".to_string()))?;
events.sort_by_key(|e| e.ts_unix_ms);
wal.append(&actor_id, &events)
.map_err(|e| GriteError::Internal(format!("WAL append failed: {}", e)))?;
Ok(events.len())
}