use anyhow::Result;
use rch_common::RchConfig;
use schemars::JsonSchema;
use serde::Serialize;
use std::path::{Path, PathBuf};
use crate::config;
use crate::ui::context::OutputContext;
use super::helpers::{config_dir, load_workers_from_config};
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum DoctorSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct DoctorDiagnostic {
pub severity: DoctorSeverity,
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ConfigDoctorResponse {
pub diagnostics: Vec<DoctorDiagnostic>,
pub error_count: usize,
pub warning_count: usize,
pub info_count: usize,
pub healthy: bool,
}
pub fn config_doctor(ctx: &OutputContext) -> Result<()> {
let mut diagnostics = Vec::new();
let config = match config::load_config() {
Ok(c) => c,
Err(e) => {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "DOC-E001".to_string(),
message: "Failed to load configuration".to_string(),
detail: Some(e.to_string()),
remediation: Some("Run 'rch config init' to create configuration".to_string()),
});
return output_doctor_results(ctx, diagnostics);
}
};
check_socket_path(&config, &mut diagnostics);
check_identity_files(&mut diagnostics);
check_remote_paths(&mut diagnostics);
check_glob_patterns(&config, &mut diagnostics);
check_file_permissions(&mut diagnostics);
check_daemon_status(&config, &mut diagnostics);
check_remediation_config(&config, &mut diagnostics);
output_doctor_results(ctx, diagnostics)
}
fn check_remediation_config(config: &RchConfig, diagnostics: &mut Vec<DoctorDiagnostic>) {
let issues = config.remediation.validate();
if issues.is_empty() {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Info,
code: "DOC-I100".to_string(),
message: "Remediation settings are valid".to_string(),
detail: Some(
"All [remediation] knobs are within safe ranges (defaults apply where unset)."
.to_string(),
),
remediation: None,
});
return;
}
for issue in issues {
let (severity, code) = match issue.severity {
rch_common::remediation_config::IssueSeverity::Error => {
(DoctorSeverity::Error, "DOC-E100")
}
rch_common::remediation_config::IssueSeverity::Warning => {
(DoctorSeverity::Warning, "DOC-W100")
}
};
diagnostics.push(DoctorDiagnostic {
severity,
code: code.to_string(),
message: format!("{}: {}", issue.field, issue.message),
detail: None,
remediation: Some(format!(
"Adjust `{}` under [remediation] (or the matching RCH_REMEDIATION_* env var).",
issue.field
)),
});
}
}
fn configured_socket_path(config: &RchConfig) -> PathBuf {
PathBuf::from(shellexpand::tilde(&config.general.socket_path).into_owned())
}
fn check_socket_path(config: &RchConfig, diagnostics: &mut Vec<DoctorDiagnostic>) {
let socket_path = configured_socket_path(config);
if let Some(parent) = socket_path.parent() {
if !parent.exists() {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "DOC-E010".to_string(),
message: format!("Socket directory does not exist: {}", parent.display()),
detail: None,
remediation: Some(format!("Create directory: mkdir -p {}", parent.display())),
});
} else {
let test_file = parent.join(".rch_write_test");
match std::fs::File::create(&test_file) {
Ok(_) => {
let _ = std::fs::remove_file(&test_file);
}
Err(e) => {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "DOC-E011".to_string(),
message: format!("Socket directory is not writable: {}", parent.display()),
detail: Some(e.to_string()),
remediation: Some(format!(
"Fix permissions: chmod u+w {}",
parent.display()
)),
});
}
}
}
}
if socket_path.exists() {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Info,
code: "DOC-I010".to_string(),
message: format!("Socket file exists: {}", socket_path.display()),
detail: Some("This may be from a running daemon or a stale socket".to_string()),
remediation: Some(
"If daemon is not running, remove with: rm -f <socket_path>".to_string(),
),
});
}
}
fn check_identity_files(diagnostics: &mut Vec<DoctorDiagnostic>) {
let workers = match load_workers_from_config() {
Ok(w) => w,
Err(_) => return, };
let mut by_identity: std::collections::BTreeMap<PathBuf, Vec<String>> =
std::collections::BTreeMap::new();
for worker in &workers {
let identity_path = match shellexpand::full(&worker.identity_file) {
Ok(expanded) => PathBuf::from(expanded.into_owned()),
Err(err) => {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "RCH-E009".to_string(),
message: format!(
"identity_file {:?} for worker '{}' is unresolvable: {}",
worker.identity_file, worker.id, err
),
detail: None,
remediation: Some(
"Set the referenced environment variable, or replace the reference \
with an absolute path / ~-relative path in workers.toml"
.to_string(),
),
});
continue;
}
};
by_identity
.entry(identity_path)
.or_default()
.push(worker.id.as_str().to_string());
}
for (identity_path, worker_ids) in &by_identity {
if !identity_path.exists() {
let referencing = worker_ids.join(", ");
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "RCH-E009".to_string(),
message: format!(
"SSH identity file not found: {} (referenced by workers: {})",
identity_path.display(),
referencing
),
detail: None,
remediation: Some(format!(
"Generate key: ssh-keygen -t ed25519 -f {}",
identity_path.display()
)),
});
continue;
}
check_shared_identity_file_permissions(identity_path, worker_ids, diagnostics);
}
}
fn check_shared_identity_file_permissions(
identity_path: &Path,
worker_ids: &[String],
diagnostics: &mut Vec<DoctorDiagnostic>,
) {
#[cfg(not(unix))]
{
let _ = (identity_path, worker_ids, diagnostics);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = identity_path.metadata() else {
return;
};
let mode = metadata.permissions().mode() & 0o777;
if mode == 0o600 || mode == 0o400 {
return;
}
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Warning,
code: "DOC-W020".to_string(),
message: format!(
"Identity file {} has insecure permissions {:o} (referenced by workers: {})",
identity_path.display(),
mode,
worker_ids.join(", ")
),
detail: Some("SSH may refuse to use keys with permissions too open".to_string()),
remediation: Some(format!(
"Fix permissions: chmod 600 {}",
identity_path.display()
)),
});
}
}
fn check_remote_paths(_diagnostics: &mut Vec<DoctorDiagnostic>) {
}
fn check_glob_patterns(config: &RchConfig, diagnostics: &mut Vec<DoctorDiagnostic>) {
use glob::Pattern;
for (i, pattern) in config.transfer.exclude_patterns.iter().enumerate() {
if let Err(e) = Pattern::new(pattern) {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Error,
code: "DOC-E040".to_string(),
message: format!(
"Invalid glob pattern in exclude_patterns[{}]: {}",
i, pattern
),
detail: Some(e.to_string()),
remediation: Some("Fix the pattern syntax in your configuration".to_string()),
});
}
}
}
fn check_file_permissions(diagnostics: &mut Vec<DoctorDiagnostic>) {
if let Some(config_dir) = config_dir()
&& config_dir.exists()
{
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = config_dir.metadata() {
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o007 != 0 {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Warning,
code: "DOC-W050".to_string(),
message: format!("Config directory is world-accessible: {:o}", mode),
detail: Some(format!(
"Directory {} may contain sensitive configuration",
config_dir.display()
)),
remediation: Some(format!(
"Restrict access: chmod 700 {}",
config_dir.display()
)),
});
}
}
}
}
if let Some(workers_path) = config_dir().map(|d| d.join("workers.toml"))
&& workers_path.exists()
{
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = workers_path.metadata() {
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Info,
code: "DOC-I050".to_string(),
message: format!("workers.toml is group/world accessible: {:o}", mode),
detail: Some(
"Consider restricting if it contains sensitive paths".to_string(),
),
remediation: Some(format!(
"Restrict access: chmod 600 {}",
workers_path.display()
)),
});
}
}
}
}
}
fn check_daemon_status(config: &RchConfig, diagnostics: &mut Vec<DoctorDiagnostic>) {
let socket_path = configured_socket_path(config);
#[cfg(unix)]
{
if socket_path.exists() {
match std::os::unix::net::UnixStream::connect(&socket_path) {
Ok(_) => {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Info,
code: "DOC-I060".to_string(),
message: "Daemon is running and accepting connections".to_string(),
detail: None,
remediation: None,
});
}
Err(e) => {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Warning,
code: "DOC-W060".to_string(),
message: "Socket exists but connection failed".to_string(),
detail: Some(e.to_string()),
remediation: Some("Try: rch daemon restart".to_string()),
});
}
}
} else {
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Warning,
code: "DOC-W061".to_string(),
message: "Daemon does not appear to be running".to_string(),
detail: Some(format!("No socket at {}", socket_path.display())),
remediation: Some("Start daemon with: rch daemon start".to_string()),
});
}
}
#[cfg(not(unix))]
{
let _ = socket_path;
diagnostics.push(DoctorDiagnostic {
severity: DoctorSeverity::Info,
code: "DOC-I062".to_string(),
message: "Daemon status check skipped (Unix sockets not available on this platform)"
.to_string(),
detail: None,
remediation: None,
});
}
}
fn output_doctor_results(ctx: &OutputContext, diagnostics: Vec<DoctorDiagnostic>) -> Result<()> {
let style = ctx.theme();
let error_count = diagnostics
.iter()
.filter(|d| matches!(d.severity, DoctorSeverity::Error))
.count();
let warning_count = diagnostics
.iter()
.filter(|d| matches!(d.severity, DoctorSeverity::Warning))
.count();
let info_count = diagnostics
.iter()
.filter(|d| matches!(d.severity, DoctorSeverity::Info))
.count();
let response = ConfigDoctorResponse {
diagnostics: diagnostics.clone(),
error_count,
warning_count,
info_count,
healthy: error_count == 0,
};
if ctx.is_json() {
ctx.json(&response)?;
if error_count > 0 {
std::process::exit(1);
}
return Ok(());
}
println!();
println!("{}", style.format_header("Configuration Doctor"));
println!();
if diagnostics.is_empty() {
println!(" {} All checks passed!", style.success("✓"));
} else {
for diag in &diagnostics {
let (icon, _severity_style) = match diag.severity {
DoctorSeverity::Error => (style.error("✗"), "error"),
DoctorSeverity::Warning => (style.warning("!"), "warning"),
DoctorSeverity::Info => (style.info("i"), "info"),
};
println!(" {} [{}] {}", icon, diag.code, diag.message);
if let Some(ref detail) = diag.detail {
println!(" {}", style.muted(detail));
}
if let Some(ref remediation) = diag.remediation {
println!(" {}", style.highlight(&format!("→ {}", remediation)));
}
println!();
}
let mut summary_parts = Vec::new();
if error_count > 0 {
summary_parts.push(format!(
"{} error{}",
error_count,
if error_count == 1 { "" } else { "s" }
));
}
if warning_count > 0 {
summary_parts.push(format!(
"{} warning{}",
warning_count,
if warning_count == 1 { "" } else { "s" }
));
}
if info_count > 0 {
summary_parts.push(format!("{} info", info_count));
}
let status = if error_count > 0 {
style.error("unhealthy")
} else if warning_count > 0 {
style.warning("healthy with warnings")
} else {
style.success("healthy")
};
println!(" Status: {} ({})", status, summary_parts.join(", "));
}
if error_count > 0 {
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn doctor_reporter_delivers_formats_and_propagates_real_output_failures() {
use crate::ui::context::{
ColorChoice, OutputConfig, OutputError, OutputFormat, OutputMode,
};
use crate::ui::writer::OutputWriter;
use std::os::unix::net::UnixStream;
let retained = tempfile::tempdir().unwrap().keep();
for format in [OutputFormat::Json, OutputFormat::Toon] {
for closed in [false, true] {
let path = retained.join(format!("{format:?}-{closed}.output"));
let stdout = if closed {
let (socket, peer) = UnixStream::pair().unwrap();
socket
.set_write_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
drop(peer);
OutputWriter::new(socket, false)
} else {
OutputWriter::new(std::fs::File::create_new(&path).unwrap(), false)
};
let ctx = OutputContext::with_writers(
OutputConfig {
force_mode: Some(OutputMode::Json),
color: ColorChoice::Never,
format,
..Default::default()
},
stdout,
OutputWriter::stderr(),
);
let result = output_doctor_results(&ctx, Vec::new());
if closed {
let error = result.unwrap_err();
assert!(matches!(
error.downcast_ref::<OutputError>(),
Some(OutputError::Delivery(cause))
if cause.kind() == std::io::ErrorKind::BrokenPipe
));
assert!(!path.exists());
} else {
result.unwrap();
let bytes = std::fs::read(path).unwrap();
let report = if format == OutputFormat::Toon {
serde_json::from_str::<serde_json::Value>(
&toon_rust::toon_to_json(std::str::from_utf8(&bytes).unwrap()).unwrap(),
)
.unwrap()
} else {
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()
};
assert_eq!(report["healthy"], true);
assert_eq!(report["diagnostics"], serde_json::json!([]));
for count in ["error_count", "warning_count", "info_count"] {
assert_eq!(report[count].as_f64(), Some(0.0), "{format:?} {count}");
}
}
}
}
}
#[test]
fn configured_socket_path_expands_tilde() {
let mut config = RchConfig::default();
config.general.socket_path = "~/rch-config-doctor.sock".to_string();
let socket_path = configured_socket_path(&config);
assert_ne!(socket_path, PathBuf::from("~/rch-config-doctor.sock"));
assert!(socket_path.ends_with("rch-config-doctor.sock"));
}
}