use std::path::PathBuf;
use serde::Serialize;
use crate::check::WorktreeSnapshot;
use crate::context;
use crate::{
ActionPlan, Config, EnvironmentInput, InitScriptDiscovery, RuntimePolicy, WorktreeOptions,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DoctorOptions {
pub cwd: Option<PathBuf>,
pub root: Option<PathBuf>,
pub environment: EnvironmentInput,
pub config: Option<PathBuf>,
pub no_init_script: bool,
pub strict: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticStatus {
Ok,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
pub name: &'static str,
pub status: DiagnosticStatus,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DoctorReport {
pub fatal: bool,
pub context: Option<WorktreeSnapshot>,
pub diagnostics: Vec<Diagnostic>,
}
impl DoctorReport {
#[must_use]
pub fn has_fatal(&self) -> bool {
self.fatal
}
}
#[must_use]
pub fn diagnose(options: DoctorOptions) -> DoctorReport {
let mut diagnostics = Vec::new();
let mut fatal = false;
let runtime_policy = match RuntimePolicy::from_environment(&options.environment, options.strict)
{
Ok(policy) => {
diagnostics.push(ok("environment_options", "environment options are valid"));
policy
}
Err(error) => {
diagnostics.push(error_diag("environment_options", error.to_string()));
return DoctorReport {
fatal: true,
context: None,
diagnostics,
};
}
};
let context = match context::resolve(&WorktreeOptions {
cwd: options.cwd.clone(),
root: options.root.clone(),
environment: options.environment.clone(),
}) {
Ok(context) => {
diagnostics.push(ok("worktree", "worktree context resolved"));
diagnostics.push(ok("root", "root checkout resolved"));
if context.default_branch.is_empty() {
diagnostics.push(warning("default_branch", "default branch unknown"));
} else {
diagnostics.push(ok("default_branch", "default branch resolved"));
}
diagnostics.push(ok("environment", "child environment built"));
context
}
Err(error) => {
diagnostics.push(error_diag("worktree", error.to_string()));
return DoctorReport {
fatal: true,
context: None,
diagnostics,
};
}
};
let context_snapshot = WorktreeSnapshot::from(&context);
if context.root_path == context.worktree_path && runtime_policy.pre_config_strict() {
fatal = true;
diagnostics.push(error_diag(
"root_worktree",
"root checkout is not a worktree under strict mode",
));
}
let config_selected = if !options.no_init_script && options.config.is_none() {
let scripts = InitScriptDiscovery::discover(&context);
if let Some(path) = scripts.executable {
diagnostics.push(ok(
"init_script",
format!("executable init script found: {}", path.display()),
));
false
} else if scripts.ignored.is_empty() {
diagnostics.push(warning("init_script", "no executable init script found"));
true
} else {
diagnostics.push(warning(
"init_script",
format!(
"no executable init script found; ignored {} non-executable path(s)",
scripts.ignored.len()
),
));
true
}
} else {
diagnostics.push(ok("init_script", "init script discovery skipped"));
true
};
if config_selected {
match check_config(&options, &context, &runtime_policy) {
Ok(diagnostic) => diagnostics.push(diagnostic),
Err(diagnostic) => {
fatal = true;
diagnostics.push(diagnostic);
}
}
} else {
diagnostics.push(ok(
"config",
"config discovery skipped because an init script takes precedence",
));
}
DoctorReport {
fatal,
context: Some(context_snapshot),
diagnostics,
}
}
fn check_config(
options: &DoctorOptions,
context: &crate::Worktree,
runtime_policy: &RuntimePolicy,
) -> std::result::Result<Diagnostic, Diagnostic> {
let path = Config::discover_path(context, options.config.as_deref())
.map_err(|error| error_diag("config", error.to_string()))?;
let Some(path) = path else {
if runtime_policy.pre_config_strict() {
return Err(error_diag("config", "no config detected under strict mode"));
}
return Ok(warning("config", "no config detected"));
};
let config =
Config::load(&path, context).map_err(|error| error_diag("config", error.to_string()))?;
let plan_options = runtime_policy.resolve(&config.options);
ActionPlan::from_manifest(
&path,
&config,
context,
plan_options.into_action_plan_options(),
)
.map_err(|error| error_diag("config_validation", error.to_string()))?;
Ok(ok("config", format!("config is valid: {}", path.display())))
}
fn ok(name: &'static str, message: impl Into<String>) -> Diagnostic {
Diagnostic {
name,
status: DiagnosticStatus::Ok,
message: message.into(),
}
}
fn warning(name: &'static str, message: impl Into<String>) -> Diagnostic {
Diagnostic {
name,
status: DiagnosticStatus::Warning,
message: message.into(),
}
}
fn error_diag(name: &'static str, message: impl Into<String>) -> Diagnostic {
Diagnostic {
name,
status: DiagnosticStatus::Error,
message: message.into(),
}
}