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