Skip to main content

treeboot_core/
doctor.rs

1use std::path::PathBuf;
2
3use serde::Serialize;
4
5use crate::check::WorktreeSnapshot;
6use crate::config::RuntimeOptionOverrides;
7use crate::context;
8use crate::{ActionPlan, Config, EnvironmentInput, InitScriptDiscovery, WorktreeOptions};
9
10/// Options for diagnosing treeboot discovery and validation.
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct DoctorOptions {
13    /// Directory from which diagnostics start. Defaults to the process cwd.
14    pub cwd: Option<PathBuf>,
15    /// Overrides the root checkout used as the file-operation source.
16    pub root: Option<PathBuf>,
17    /// Explicit environment input used for compatibility discovery and options.
18    pub environment: EnvironmentInput,
19    /// Uses one specific config file and skips init script discovery.
20    pub config: Option<PathBuf>,
21    /// Skips init script discovery and uses declarative config discovery.
22    pub no_init_script: bool,
23}
24
25/// Diagnostic status.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum DiagnosticStatus {
29    /// The check passed.
30    Ok,
31    /// The check found a non-fatal issue.
32    Warning,
33    /// The check found a fatal issue.
34    Error,
35}
36
37/// One doctor diagnostic.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Diagnostic {
40    /// Stable diagnostic name.
41    pub name: &'static str,
42    /// Diagnostic status.
43    pub status: DiagnosticStatus,
44    /// Human-readable diagnostic message.
45    pub message: String,
46}
47
48/// Result summary for a `treeboot doctor` invocation.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct DoctorReport {
51    /// Whether any diagnostic is fatal.
52    pub fatal: bool,
53    /// Discovered worktree context, when available.
54    pub context: Option<WorktreeSnapshot>,
55    /// Ordered diagnostics.
56    pub diagnostics: Vec<Diagnostic>,
57}
58
59impl DoctorReport {
60    /// Returns true when the report contains any fatal diagnostic.
61    #[must_use]
62    pub fn has_fatal(&self) -> bool {
63        self.fatal
64    }
65}
66
67/// Diagnoses treeboot discovery and validation without side effects.
68#[must_use]
69pub fn diagnose(options: DoctorOptions) -> DoctorReport {
70    let mut diagnostics = Vec::new();
71    let mut fatal = false;
72
73    let env_options = match RuntimeOptionOverrides::from_environment(&options.environment) {
74        Ok(options) => {
75            diagnostics.push(ok("environment_options", "environment options are valid"));
76            options
77        }
78        Err(error) => {
79            diagnostics.push(error_diag("environment_options", error.to_string()));
80            return DoctorReport {
81                fatal: true,
82                context: None,
83                diagnostics,
84            };
85        }
86    };
87
88    let context = match context::resolve(&WorktreeOptions {
89        cwd: options.cwd.clone(),
90        root: options.root.clone(),
91        environment: options.environment.clone(),
92    }) {
93        Ok(context) => {
94            diagnostics.push(ok("worktree", "worktree context resolved"));
95            diagnostics.push(ok("root", "root checkout resolved"));
96            if context.default_branch.is_empty() {
97                diagnostics.push(warning("default_branch", "default branch unknown"));
98            } else {
99                diagnostics.push(ok("default_branch", "default branch resolved"));
100            }
101            diagnostics.push(ok("environment", "child environment built"));
102            context
103        }
104        Err(error) => {
105            diagnostics.push(error_diag("worktree", error.to_string()));
106            return DoctorReport {
107                fatal: true,
108                context: None,
109                diagnostics,
110            };
111        }
112    };
113    let context_snapshot = WorktreeSnapshot::from(&context);
114
115    if !options.no_init_script && options.config.is_none() {
116        let scripts = InitScriptDiscovery::discover(&context);
117        if let Some(path) = scripts.executable {
118            diagnostics.push(ok(
119                "init_script",
120                format!("executable init script found: {}", path.display()),
121            ));
122        } else if scripts.ignored.is_empty() {
123            diagnostics.push(warning("init_script", "no executable init script found"));
124        } else {
125            diagnostics.push(warning(
126                "init_script",
127                format!(
128                    "no executable init script found; ignored {} non-executable path(s)",
129                    scripts.ignored.len()
130                ),
131            ));
132        }
133    } else {
134        diagnostics.push(ok("init_script", "init script discovery skipped"));
135    }
136
137    match check_config(&options, &context, env_options) {
138        Ok(diagnostic) => diagnostics.push(diagnostic),
139        Err(diagnostic) => {
140            fatal = true;
141            diagnostics.push(diagnostic);
142        }
143    }
144
145    DoctorReport {
146        fatal,
147        context: Some(context_snapshot),
148        diagnostics,
149    }
150}
151
152fn check_config(
153    options: &DoctorOptions,
154    context: &crate::Worktree,
155    env_options: RuntimeOptionOverrides,
156) -> std::result::Result<Diagnostic, Diagnostic> {
157    let path = Config::discover_path(context, options.config.as_deref())
158        .map_err(|error| error_diag("config", error.to_string()))?;
159
160    let Some(path) = path else {
161        return Ok(warning("config", "no config detected"));
162    };
163
164    let config =
165        Config::load(&path, context).map_err(|error| error_diag("config", error.to_string()))?;
166    let plan_options = env_options.resolve(&config.options, false);
167    ActionPlan::from_manifest(&path, &config, context, plan_options.into())
168        .map_err(|error| error_diag("config_validation", error.to_string()))?;
169
170    Ok(ok("config", format!("config is valid: {}", path.display())))
171}
172
173fn ok(name: &'static str, message: impl Into<String>) -> Diagnostic {
174    Diagnostic {
175        name,
176        status: DiagnosticStatus::Ok,
177        message: message.into(),
178    }
179}
180
181fn warning(name: &'static str, message: impl Into<String>) -> Diagnostic {
182    Diagnostic {
183        name,
184        status: DiagnosticStatus::Warning,
185        message: message.into(),
186    }
187}
188
189fn error_diag(name: &'static str, message: impl Into<String>) -> Diagnostic {
190    Diagnostic {
191        name,
192        status: DiagnosticStatus::Error,
193        message: message.into(),
194    }
195}