spin_doctor/
lib.rs

1//! Spin doctor: check and automatically fix problems with Spin apps.
2#![deny(missing_docs)]
3
4use std::{collections::VecDeque, fmt::Debug, fs, path::PathBuf};
5
6use anyhow::{ensure, Context, Result};
7use async_trait::async_trait;
8use spin_common::ui::quoted_path;
9use toml_edit::DocumentMut;
10
11/// Diagnoses for app manifest format problems.
12pub mod manifest;
13/// Diagnose for Rust-specific problems.
14pub mod rustlang;
15/// Test helpers.
16pub(crate) mod test;
17/// Diagnoses for Wasm source problems.
18pub mod wasm;
19
20/// Configuration for an app to be checked for problems.
21pub struct Checkup {
22    patient: PatientApp,
23    diagnostics: VecDeque<Box<dyn BoxingDiagnostic>>,
24    unprocessed_diagnoses: VecDeque<Box<dyn Diagnosis>>,
25}
26
27impl Checkup {
28    /// Return a new checkup for the app manifest at the given path.
29    pub fn new(manifest_path: impl Into<PathBuf>) -> Result<Self> {
30        let patient = PatientApp::new(manifest_path)?;
31        let mut checkup = Self {
32            patient,
33            diagnostics: Default::default(),
34            unprocessed_diagnoses: Default::default(),
35        };
36        checkup
37            .add_diagnostic::<manifest::upgrade::UpgradeDiagnostic>()
38            .add_diagnostic::<manifest::version::VersionDiagnostic>()
39            .add_diagnostic::<manifest::trigger::TriggerDiagnostic>()
40            .add_diagnostic::<rustlang::target::TargetDiagnostic>() // Do toolchain checks _before_ build check
41            .add_diagnostic::<wasm::missing::WasmMissingDiagnostic>();
42        Ok(checkup)
43    }
44
45    /// Returns the [`PatientApp`] being checked.
46    pub fn patient(&self) -> &PatientApp {
47        &self.patient
48    }
49
50    /// Add a detectable problem to this checkup.
51    pub fn add_diagnostic<D: Diagnostic + Default + 'static>(&mut self) -> &mut Self {
52        self.diagnostics.push_back(Box::<D>::default());
53        self
54    }
55
56    /// Returns the next detected problem.
57    pub async fn next_diagnosis(&mut self) -> Result<Option<PatientDiagnosis>> {
58        while self.unprocessed_diagnoses.is_empty() {
59            let Some(diagnostic) = self.diagnostics.pop_front() else {
60                return Ok(None);
61            };
62            self.unprocessed_diagnoses = diagnostic
63                .diagnose_boxed(&self.patient)
64                .await
65                .unwrap_or_else(|err| {
66                    tracing::debug!("Diagnose failed: {err:?}");
67                    vec![]
68                })
69                .into()
70        }
71        Ok(Some(PatientDiagnosis {
72            patient: &mut self.patient,
73            diagnosis: self.unprocessed_diagnoses.pop_front().unwrap(),
74        }))
75    }
76}
77
78/// An app "patient" to be checked for problems.
79#[derive(Clone)]
80pub struct PatientApp {
81    /// Path to an app manifest file.
82    pub manifest_path: PathBuf,
83    /// Parsed app manifest TOML document.
84    pub manifest_doc: DocumentMut,
85}
86
87impl PatientApp {
88    fn new(manifest_path: impl Into<PathBuf>) -> Result<Self> {
89        let path = manifest_path.into();
90        ensure!(
91            path.is_file(),
92            "No Spin app manifest file found at {}",
93            quoted_path(&path),
94        );
95
96        let contents = fs::read_to_string(&path).with_context(|| {
97            format!(
98                "Couldn't read Spin app manifest file at {}",
99                quoted_path(&path)
100            )
101        })?;
102
103        let manifest_doc: DocumentMut = contents.parse().with_context(|| {
104            format!(
105                "Couldn't parse manifest file at {} as valid TOML",
106                quoted_path(&path)
107            )
108        })?;
109
110        Ok(Self {
111            manifest_path: path,
112            manifest_doc,
113        })
114    }
115}
116
117/// A PatientDiagnosis bundles a [`Diagnosis`] with its (borrowed) [`PatientApp`].
118pub struct PatientDiagnosis<'a> {
119    /// The diagnosis
120    pub diagnosis: Box<dyn Diagnosis>,
121    /// A reference to the patient this diagnosis applies to
122    pub patient: &'a mut PatientApp,
123}
124
125/// The Diagnose trait implements the detection of a particular Spin app problem.
126#[async_trait]
127pub trait Diagnostic: Send + Sync {
128    /// A [`Diagnosis`] representing the problem(s) this can detect.
129    type Diagnosis: Diagnosis;
130
131    /// Check the given [`PatientApp`], returning any problem(s) found.
132    ///
133    /// If multiple _independently addressable_ problems are found, this may
134    /// return multiple instances. If two "logically separate" problems would
135    /// have the same fix, they should be represented with the same instance.
136    async fn diagnose(&self, patient: &PatientApp) -> Result<Vec<Self::Diagnosis>>;
137}
138
139/// The Diagnosis trait represents a detected problem with a Spin app.
140pub trait Diagnosis: Debug + Send + Sync + 'static {
141    /// Return a human-friendly description of this problem.
142    fn description(&self) -> String;
143
144    /// Return true if this problem is "critical", i.e. if the app's
145    /// configuration or environment is invalid. Return false for
146    /// "non-critical" problems like deprecations.
147    fn is_critical(&self) -> bool {
148        true
149    }
150
151    /// Return a [`Treatment`] that can (potentially) fix this problem, or
152    /// None if there is no automatic fix.
153    fn treatment(&self) -> Option<&dyn Treatment> {
154        None
155    }
156}
157
158/// The Treatment trait represents a (potential) fix for a detected problem.
159#[async_trait]
160pub trait Treatment: Sync {
161    /// Return a short (single line) description of what this fix will do, as
162    /// an imperative, e.g. "Upgrade the library".
163    fn summary(&self) -> String;
164
165    /// Return a detailed description of what this fix will do, such as a file
166    /// diff or list of commands to be executed.
167    ///
168    /// May return `Err(DryRunNotSupported.into())` if no such description is
169    /// available, which is the default implementation.
170    async fn dry_run(&self, patient: &PatientApp) -> Result<String> {
171        let _ = patient;
172        Err(DryRunNotSupported.into())
173    }
174
175    /// Attempt to fix this problem. Return Ok only if the problem is
176    /// successfully fixed.
177    async fn treat(&self, patient: &mut PatientApp) -> Result<()>;
178}
179
180/// Error returned by [`Treatment::dry_run`] if dry run isn't supported.
181#[derive(Debug)]
182pub struct DryRunNotSupported;
183
184impl std::fmt::Display for DryRunNotSupported {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        write!(f, "dry run not implemented for this treatment")
187    }
188}
189
190impl std::error::Error for DryRunNotSupported {}
191
192#[async_trait]
193trait BoxingDiagnostic {
194    async fn diagnose_boxed(&self, patient: &PatientApp) -> Result<Vec<Box<dyn Diagnosis>>>;
195}
196
197#[async_trait]
198impl<Factory: Diagnostic> BoxingDiagnostic for Factory {
199    async fn diagnose_boxed(&self, patient: &PatientApp) -> Result<Vec<Box<dyn Diagnosis>>> {
200        Ok(self
201            .diagnose(patient)
202            .await?
203            .into_iter()
204            .map(|diag| Box::new(diag) as Box<dyn Diagnosis>)
205            .collect())
206    }
207}
208
209/// Return this as an error from a treatment to stop further diagnoses when
210/// the user needs to intervene before the doctor can proceed.
211#[derive(Debug)]
212pub struct StopDiagnosing {
213    message: String,
214}
215
216impl std::fmt::Display for StopDiagnosing {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(f, "{}", self.message)
219    }
220}
221
222impl StopDiagnosing {
223    /// Creates a new instance.
224    pub fn new(message: impl Into<String>) -> Self {
225        Self {
226            message: message.into(),
227        }
228    }
229
230    /// The message to be displayed to the user indicating what they must do
231    /// before resuming diagnosing.
232    pub fn message(&self) -> &str {
233        &self.message
234    }
235}