1use super::site::{SiteError, SiteLimits};
4use super::{Lifecycle, ServiceRecord};
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum StepStatus {
11 Todo,
12 Doing,
13 Done,
14 Blocked,
15}
16
17#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Step {
21 pub id: usize,
23 pub title: String,
25 pub status: StepStatus,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub note: Option<String>,
30 pub at_ms: u64,
32}
33
34#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct PlanTransition {
38 pub holder: String,
40 pub at_ms: u64,
42 pub revision: u64,
44 pub steps: Vec<Step>,
46}
47
48#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct Plan {
52 pub steps: Vec<Step>,
54 pub current: Option<usize>,
56 pub revision: u64,
58 #[serde(default)]
60 pub transitions: Vec<PlanTransition>,
61}
62
63#[must_use]
65pub fn current_value(plan: &Plan) -> serde_json::Value {
66 serde_json::json!({"steps":plan.steps,"current":plan.current,"revision":plan.revision})
67}
68
69pub(crate) fn refuse_unfinished(plan: &Plan) -> Result<(), SiteError> {
75 let open: Vec<String> = plan
76 .steps
77 .iter()
78 .filter(|step| step.status != StepStatus::Done)
79 .map(|step| step.id.to_string())
80 .collect();
81 if open.is_empty() {
82 Ok(())
83 } else {
84 Err(SiteError::Refused(format!(
85 "plan steps not done: {}; mark them done before completing",
86 open.join(", ")
87 )))
88 }
89}
90
91fn text(value: &str, limits: &SiteLimits) -> Result<(), SiteError> {
92 if value.is_empty() || value.len() > limits.text_bytes.get() || value.contains('\0') {
93 Err(SiteError::Invalid("plan text"))
94 } else {
95 Ok(())
96 }
97}
98
99impl Plan {
100 pub fn validate(&self, limits: &SiteLimits) -> Result<(), SiteError> {
105 if self.transitions.len() > limits.transitions.get() {
106 return Err(SiteError::Bound("plan history"));
107 }
108 check_steps(&self.steps, limits)?;
109 let current = self
110 .steps
111 .iter()
112 .find(|step| step.status == StepStatus::Doing)
113 .map(|step| step.id);
114 if current != self.current
115 || self.revision
116 != u64::try_from(self.transitions.len()).map_err(|_| SiteError::Store)?
117 {
118 return Err(SiteError::Invalid("stored plan revision or current"));
119 }
120 let mut previous: &[Step] = &[];
121 for (index, transition) in self.transitions.iter().enumerate() {
122 if transition.revision != u64::try_from(index + 1).map_err(|_| SiteError::Store)?
123 || transition.holder.is_empty()
124 || transition.holder.len() > limits.text_bytes.get()
125 || transition.holder.chars().any(char::is_control)
126 {
127 return Err(SiteError::Invalid("stored plan transition"));
128 }
129 check_steps(&transition.steps, limits)?;
130 for done in previous
131 .iter()
132 .filter(|step| step.status == StepStatus::Done)
133 {
134 if transition
135 .steps
136 .get(done.id - 1)
137 .is_none_or(|next| next.title != done.title || next.status != StepStatus::Done)
138 {
139 return Err(SiteError::Invalid("stored finished step changed"));
140 }
141 }
142 previous = &transition.steps;
143 }
144 if previous != self.steps.as_slice() {
145 return Err(SiteError::Invalid(
146 "stored plan history differs from current",
147 ));
148 }
149 Ok(())
150 }
151 pub fn set(
156 &mut self,
157 titles: &[String],
158 holder: &str,
159 at_ms: u64,
160 limits: &SiteLimits,
161 ) -> Result<(), SiteError> {
162 if titles.is_empty() || titles.len() > limits.steps.get() {
163 return Err(SiteError::Bound("steps"));
164 }
165 for title in titles {
166 text(title, limits)?;
167 }
168 for old in &self.steps {
169 if old.status == StepStatus::Done && titles.get(old.id - 1) != Some(&old.title) {
170 return Err(SiteError::Refused(format!(
171 "done step {} cannot be removed or renamed",
172 old.id
173 )));
174 }
175 }
176 let steps = titles
177 .iter()
178 .enumerate()
179 .map(|(ordinal, title)| {
180 let id = ordinal + 1;
181 self.steps
182 .get(ordinal)
183 .filter(|old| old.title == *title)
184 .cloned()
185 .unwrap_or_else(|| Step {
186 id,
187 title: title.clone(),
188 status: StepStatus::Todo,
189 note: None,
190 at_ms,
191 })
192 })
193 .collect();
194 self.write(steps, holder, at_ms, limits)
195 }
196
197 pub fn step(
202 &mut self,
203 id: usize,
204 status: StepStatus,
205 note: Option<String>,
206 holder: &str,
207 at_ms: u64,
208 limits: &SiteLimits,
209 ) -> Result<(), SiteError> {
210 if let Some(note) = ¬e {
211 text(note, limits)?;
212 }
213 if status == StepStatus::Doing && self.current.is_some_and(|current| current != id) {
214 return Err(SiteError::Refused(format!(
215 "step {} is already doing",
216 self.current.ok_or(SiteError::Store)?
217 )));
218 }
219 let mut steps = self.steps.clone();
220 let step = steps
221 .iter_mut()
222 .find(|step| step.id == id)
223 .ok_or_else(|| SiteError::Refused(format!("plan has no step {id}")))?;
224 if step.status == StepStatus::Done && status != StepStatus::Done {
225 return Err(SiteError::Refused(format!(
226 "done step {id} cannot change status"
227 )));
228 }
229 step.status = status;
230 step.note = note;
231 step.at_ms = at_ms;
232 self.write(steps, holder, at_ms, limits)
233 }
234
235 fn write(
236 &mut self,
237 steps: Vec<Step>,
238 holder: &str,
239 at_ms: u64,
240 limits: &SiteLimits,
241 ) -> Result<(), SiteError> {
242 if self.transitions.len() >= limits.transitions.get() {
243 return Err(SiteError::Bound("transitions"));
244 }
245 let revision = self
246 .revision
247 .checked_add(1)
248 .ok_or(SiteError::Bound("revision"))?;
249 self.current = steps
250 .iter()
251 .find(|step| step.status == StepStatus::Doing)
252 .map(|step| step.id);
253 self.transitions.push(PlanTransition {
254 holder: holder.into(),
255 at_ms,
256 revision,
257 steps: steps.clone(),
258 });
259 self.steps = steps;
260 self.revision = revision;
261 Ok(())
262 }
263}
264
265fn check_steps(steps: &[Step], limits: &SiteLimits) -> Result<(), SiteError> {
266 if steps.len() > limits.steps.get() {
267 return Err(SiteError::Bound("steps"));
268 }
269 let mut doing = false;
270 for (ordinal, step) in steps.iter().enumerate() {
271 if step.id != ordinal + 1 {
272 return Err(SiteError::Invalid("stored plan ordinal"));
273 }
274 text(&step.title, limits)?;
275 if let Some(note) = &step.note {
276 text(note, limits)?;
277 }
278 if step.status == StepStatus::Doing {
279 if doing {
280 return Err(SiteError::Invalid("stored plan has two doing steps"));
281 }
282 doing = true;
283 }
284 }
285 Ok(())
286}
287
288impl Lifecycle {
289 pub fn check_plan_holder(&self, record: &ServiceRecord, holder: &str) -> Result<(), SiteError> {
294 if record.builder_bound && record.builder.as_deref() == Some(holder) {
295 return Ok(());
296 }
297 Err(SiteError::Refused(format!(
298 "pass holder {holder} is not the builder of {}",
299 record.name
300 )))
301 }
302
303 pub(crate) fn plan_set(
308 &self,
309 service: &str,
310 holder: &str,
311 titles: &[String],
312 at_ms: u64,
313 ) -> Result<Plan, SiteError> {
314 self.write_plan(service, holder, at_ms, |plan, limits| {
315 plan.set(titles, holder, at_ms, limits)
316 })
317 }
318
319 pub(crate) fn plan_step(
324 &self,
325 service: &str,
326 holder: &str,
327 id: usize,
328 status: StepStatus,
329 note: Option<String>,
330 at_ms: u64,
331 ) -> Result<Plan, SiteError> {
332 self.write_plan(service, holder, at_ms, |plan, limits| {
333 plan.step(id, status, note, holder, at_ms, limits)
334 })
335 }
336
337 fn write_plan(
338 &self,
339 service: &str,
340 holder: &str,
341 at_ms: u64,
342 apply: impl FnOnce(&mut Plan, &SiteLimits) -> Result<(), SiteError>,
343 ) -> Result<Plan, SiteError> {
344 let limits = &self.site_bindings()?.limits;
345 let mut registry = self.lock()?;
346 let mut record = registry
347 .records
348 .get(service)
349 .cloned()
350 .ok_or_else(|| SiteError::Refused(format!("unknown_service: {service}")))?;
351 self.check_plan_holder(&record, holder)?;
352 if record.state != super::ServiceState::Building {
353 return Err(SiteError::Invalid("plan requires building"));
354 }
355 if record.transitions.len() >= limits.transitions.get() {
356 return Err(SiteError::Bound("transitions"));
357 }
358 apply(&mut record.plan, limits)?;
359 if serde_json::to_vec(¤t_value(&record.plan))
360 .map_err(|_| SiteError::Store)?
361 .len()
362 > limits.request_bytes.get()
363 {
364 return Err(SiteError::Bound("plan retained bytes"));
365 }
366 Self::site_transition(&mut record, holder, at_ms, "plan written");
367 self.keep(&mut registry, &record)?;
368 self.publish_plan(&record)?;
369 drop(registry);
370 Ok(record.plan)
371 }
372}
373
374#[cfg(test)]
375#[path = "plan_tests.rs"]
376mod tests;