spin_doctor/manifest/
trigger.rs1use 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#[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 return Ok(vec![]);
24 }
25
26 let mut diags = vec![];
27
28 diags.extend(TriggerDiagnosis::for_app_trigger(manifest.get("trigger")));
30
31 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#[derive(Debug)]
60pub enum TriggerDiagnosis {
61 MissingAppTrigger,
63 InvalidAppTrigger(&'static str),
65 HttpComponentTriggerMissingRoute(String, bool),
67 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 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 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 let trigger_type = trigger.entry("type").or_insert(Item::Value("http".into()));
167 if let Some("http") = trigger_type.as_str() {
168 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 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 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 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}