spin_doctor/manifest/
trigger.rs

1use anyhow::{bail, ensure, Context, Result};
2use async_trait::async_trait;
3use toml::Value;
4use toml_edit::{DocumentMut, InlineTable, Item, Table};
5
6use crate::{Diagnosis, Diagnostic, PatientApp, Treatment};
7
8use super::ManifestTreatment;
9
10/// TriggerDiagnostic detects problems with app trigger config.
11#[derive(Default)]
12pub struct TriggerDiagnostic;
13
14#[async_trait]
15impl Diagnostic for TriggerDiagnostic {
16    type Diagnosis = TriggerDiagnosis;
17
18    async fn diagnose(&self, patient: &PatientApp) -> Result<Vec<Self::Diagnosis>> {
19        let manifest: toml::Value = toml_edit::de::from_document(patient.manifest_doc.clone())?;
20
21        if manifest.get("spin_manifest_version") == Some(&Value::Integer(2)) {
22            // Not applicable to manifest V2
23            return Ok(vec![]);
24        }
25
26        let mut diags = vec![];
27
28        // Top-level trigger config
29        diags.extend(TriggerDiagnosis::for_app_trigger(manifest.get("trigger")));
30
31        // Component-level HTTP trigger config
32        let trigger_type = manifest
33            .get("trigger")
34            .and_then(|item| item.get("type"))
35            .and_then(|item| item.as_str());
36        if let Some("http") = trigger_type {
37            if let Some(Value::Array(components)) = manifest.get("component") {
38                let single_component = components.len() == 1;
39                for component in components {
40                    let id = component
41                        .get("id")
42                        .and_then(|value| value.as_str())
43                        .unwrap_or("<missing ID>")
44                        .to_string();
45                    diags.extend(TriggerDiagnosis::for_http_component_trigger(
46                        id,
47                        component.get("trigger"),
48                        single_component,
49                    ));
50                }
51            }
52        }
53
54        Ok(diags)
55    }
56}
57
58/// TriggerDiagnosis represents a problem with app trigger config.
59#[derive(Debug)]
60pub enum TriggerDiagnosis {
61    /// Missing app trigger section
62    MissingAppTrigger,
63    /// Invalid app trigger config
64    InvalidAppTrigger(&'static str),
65    /// HTTP component trigger missing route field
66    HttpComponentTriggerMissingRoute(String, bool),
67    /// Invalid HTTP component trigger config
68    InvalidHttpComponentTrigger(String, &'static str),
69}
70
71impl TriggerDiagnosis {
72    fn for_app_trigger(trigger: Option<&Value>) -> Option<Self> {
73        let Some(trigger) = trigger else {
74            return Some(Self::MissingAppTrigger);
75        };
76        let Some(trigger) = trigger.as_table() else {
77            return Some(Self::InvalidAppTrigger("not a table"));
78        };
79        let Some(trigger_type) = trigger.get("type") else {
80            return Some(Self::InvalidAppTrigger("trigger table missing type"));
81        };
82        let Some(_) = trigger_type.as_str() else {
83            return Some(Self::InvalidAppTrigger("type must be a string"));
84        };
85        None
86    }
87
88    fn for_http_component_trigger(
89        id: String,
90        trigger: Option<&Value>,
91        single_component: bool,
92    ) -> Option<Self> {
93        let Some(trigger) = trigger else {
94            return Some(Self::HttpComponentTriggerMissingRoute(id, single_component));
95        };
96        let Some(trigger) = trigger.as_table() else {
97            return Some(Self::InvalidHttpComponentTrigger(id, "not a table"));
98        };
99        let Some(route) = trigger.get("route") else {
100            return Some(Self::HttpComponentTriggerMissingRoute(id, single_component));
101        };
102        if route.as_str().is_none() {
103            return Some(Self::InvalidHttpComponentTrigger(
104                id,
105                "route is not a string",
106            ));
107        }
108        None
109    }
110}
111
112impl Diagnosis for TriggerDiagnosis {
113    fn description(&self) -> String {
114        match self {
115            Self::MissingAppTrigger => "missing top-level trigger config".into(),
116            Self::InvalidAppTrigger(msg) => {
117                format!("Invalid app trigger config: {msg}")
118            }
119            Self::HttpComponentTriggerMissingRoute(id, _) => {
120                format!("HTTP component {id:?} missing trigger.route")
121            }
122            Self::InvalidHttpComponentTrigger(id, msg) => {
123                format!("Invalid trigger config for http component {id:?}: {msg}")
124            }
125        }
126    }
127
128    fn treatment(&self) -> Option<&dyn Treatment> {
129        match self {
130            Self::MissingAppTrigger => Some(self),
131            // We can reasonably fill in default "route" iff there is only one component
132            Self::HttpComponentTriggerMissingRoute(_, single_component) if *single_component => {
133                Some(self)
134            }
135            _ => None,
136        }
137    }
138}
139
140#[async_trait]
141impl ManifestTreatment for TriggerDiagnosis {
142    fn summary(&self) -> String {
143        match self {
144            TriggerDiagnosis::MissingAppTrigger => "Add default HTTP trigger config".into(),
145            TriggerDiagnosis::HttpComponentTriggerMissingRoute(id, _) => {
146                format!("Set trigger.route '/...' for component {id:?}")
147            }
148            _ => "[invalid treatment]".into(),
149        }
150    }
151
152    async fn treat_manifest(&self, doc: &mut DocumentMut) -> anyhow::Result<()> {
153        match self {
154            Self::MissingAppTrigger => {
155                // Get or insert missing trigger config
156                if doc.get("trigger").is_none() {
157                    doc.insert("trigger", Item::Value(InlineTable::new().into()));
158                }
159                let trigger = doc
160                    .get_mut("trigger")
161                    .unwrap()
162                    .as_table_like_mut()
163                    .context("existing trigger value is not a table")?;
164
165                // Get trigger type or insert default "http"
166                let trigger_type = trigger.entry("type").or_insert(Item::Value("http".into()));
167                if let Some("http") = trigger_type.as_str() {
168                    // Strip "type" trailing space
169                    if let Some(decor) = trigger_type.as_value_mut().map(|v| v.decor_mut()) {
170                        if let Some(suffix) = decor.suffix().and_then(|s| s.as_str()) {
171                            decor.set_suffix(suffix.to_string().trim());
172                        }
173                    }
174                }
175            }
176            Self::HttpComponentTriggerMissingRoute(_, true) => {
177                // Get the only component
178                let components = doc
179                    .get_mut("component")
180                    .context("missing components")?
181                    .as_array_of_tables_mut()
182                    .context("component sections aren't an 'array of tables'")?;
183                ensure!(
184                    components.len() == 1,
185                    "can only set default trigger route if there is exactly one component; found {}",
186                    components.len()
187                );
188                let component = components.get_mut(0).unwrap();
189
190                // Get or insert missing trigger config
191                if component.get("trigger").is_none() {
192                    component.insert("trigger", Item::Table(Table::new()));
193                }
194                let trigger = component
195                    .get_mut("trigger")
196                    .unwrap()
197                    .as_table_like_mut()
198                    .context("existing trigger value is not a table")?;
199
200                // Set missing "route"
201                trigger.entry("route").or_insert(Item::Value("/...".into()));
202            }
203            _ => bail!("cannot be fixed"),
204        }
205        Ok(())
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use crate::test::{run_broken_test, run_correct_test};
212
213    use super::*;
214
215    #[tokio::test]
216    async fn test_correct() {
217        run_correct_test::<TriggerDiagnostic>("manifest_trigger").await;
218    }
219
220    #[tokio::test]
221    async fn test_missing_app_trigger() {
222        let diag =
223            run_broken_test::<TriggerDiagnostic>("manifest_trigger", "missing_app_trigger").await;
224        assert!(matches!(diag, TriggerDiagnosis::MissingAppTrigger));
225    }
226
227    #[tokio::test]
228    async fn test_http_component_trigger_missing_route() {
229        let diag = run_broken_test::<TriggerDiagnostic>(
230            "manifest_trigger",
231            "http_component_trigger_missing_route",
232        )
233        .await;
234        assert!(matches!(
235            diag,
236            TriggerDiagnosis::HttpComponentTriggerMissingRoute(_, _)
237        ));
238    }
239}