Skip to main content

treeboot_core/
doctor.rs

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