1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::{OperationId, OperationName, Reversibility, RiskClass, TargetRef};
7
8const MAX_PLAN_TEXT_CHARS: usize = 2_048;
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(transparent)]
14pub struct PlanFingerprint(String);
15
16impl PlanFingerprint {
17 pub fn parse(value: impl Into<String>) -> Result<Self, PlanError> {
19 let value = value.into();
20 if value.len() == 64
21 && value
22 .bytes()
23 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
24 {
25 Ok(Self(value))
26 } else {
27 Err(PlanError::InvalidFingerprint)
28 }
29 }
30
31 #[must_use]
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct PlannedChange {
42 resource: TargetRef,
43 action: String,
44 summary: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 before_digest: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 after_digest: Option<String>,
49}
50
51impl PlannedChange {
52 pub fn new(
54 resource: TargetRef,
55 action: impl Into<String>,
56 summary: impl Into<String>,
57 ) -> Result<Self, PlanError> {
58 let action = action.into();
59 let summary = summary.into();
60 validate_plan_text("change action", &action)?;
61 validate_plan_text("change summary", &summary)?;
62 Ok(Self {
63 resource,
64 action,
65 summary,
66 before_digest: None,
67 after_digest: None,
68 })
69 }
70
71 #[must_use]
73 pub fn with_digests(mut self, before: Option<String>, after: Option<String>) -> Self {
74 self.before_digest = before;
75 self.after_digest = after;
76 self
77 }
78
79 #[must_use]
81 pub fn resource(&self) -> &TargetRef {
82 &self.resource
83 }
84
85 #[must_use]
87 pub fn action(&self) -> &str {
88 &self.action
89 }
90
91 #[must_use]
93 pub fn summary(&self) -> &str {
94 &self.summary
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct PlanStep {
102 sequence: u32,
103 operation: OperationName,
104 target: TargetRef,
105 summary: String,
106}
107
108impl PlanStep {
109 pub fn new(
111 sequence: u32,
112 operation: OperationName,
113 target: TargetRef,
114 summary: impl Into<String>,
115 ) -> Result<Self, PlanError> {
116 if sequence == 0 {
117 return Err(PlanError::InvalidStepSequence);
118 }
119 let summary = summary.into();
120 validate_plan_text("step summary", &summary)?;
121 Ok(Self {
122 sequence,
123 operation,
124 target,
125 summary,
126 })
127 }
128
129 #[must_use]
131 pub const fn sequence(&self) -> u32 {
132 self.sequence
133 }
134
135 #[must_use]
137 pub fn operation(&self) -> &OperationName {
138 &self.operation
139 }
140
141 #[must_use]
143 pub fn target(&self) -> &TargetRef {
144 &self.target
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151pub struct VerificationStrategy {
152 operation: OperationName,
153 description: String,
154}
155
156impl VerificationStrategy {
157 pub fn new(
159 operation: OperationName,
160 description: impl Into<String>,
161 ) -> Result<Self, PlanError> {
162 let description = description.into();
163 validate_plan_text("verification description", &description)?;
164 Ok(Self {
165 operation,
166 description,
167 })
168 }
169
170 #[must_use]
172 pub fn operation(&self) -> &OperationName {
173 &self.operation
174 }
175
176 #[must_use]
178 pub fn description(&self) -> &str {
179 &self.description
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct OperationPlan {
187 operation_id: OperationId,
188 operation: OperationName,
189 target: TargetRef,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 topology_revision: Option<String>,
192 changes: Vec<PlannedChange>,
193 risk: RiskClass,
194 reversibility: Reversibility,
195 prerequisites: Vec<String>,
196 conflicts: Vec<String>,
197 steps: Vec<PlanStep>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 verification: Option<VerificationStrategy>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 rollback_guidance: Option<String>,
202 fingerprint: PlanFingerprint,
203}
204
205impl OperationPlan {
206 pub fn new(
208 operation_id: OperationId,
209 operation: OperationName,
210 target: TargetRef,
211 risk: RiskClass,
212 reversibility: Reversibility,
213 ) -> Result<Self, PlanError> {
214 let mut plan = Self {
215 operation_id,
216 operation,
217 target,
218 topology_revision: None,
219 changes: Vec::new(),
220 risk,
221 reversibility,
222 prerequisites: Vec::new(),
223 conflicts: Vec::new(),
224 steps: Vec::new(),
225 verification: None,
226 rollback_guidance: None,
227 fingerprint: PlanFingerprint(String::new()),
228 };
229 plan.refresh_fingerprint()?;
230 Ok(plan)
231 }
232
233 pub fn with_topology_revision(
235 mut self,
236 revision: impl Into<String>,
237 ) -> Result<Self, PlanError> {
238 let revision = revision.into();
239 validate_plan_text("topology revision", &revision)?;
240 self.topology_revision = Some(revision);
241 self.refresh_fingerprint()?;
242 Ok(self)
243 }
244
245 pub fn with_change(mut self, change: PlannedChange) -> Result<Self, PlanError> {
247 self.changes.push(change);
248 self.refresh_fingerprint()?;
249 Ok(self)
250 }
251
252 pub fn with_prerequisite(mut self, prerequisite: impl Into<String>) -> Result<Self, PlanError> {
254 let prerequisite = prerequisite.into();
255 validate_plan_text("prerequisite", &prerequisite)?;
256 self.prerequisites.push(prerequisite);
257 self.refresh_fingerprint()?;
258 Ok(self)
259 }
260
261 pub fn with_conflict(mut self, conflict: impl Into<String>) -> Result<Self, PlanError> {
263 let conflict = conflict.into();
264 validate_plan_text("conflict", &conflict)?;
265 self.conflicts.push(conflict);
266 self.refresh_fingerprint()?;
267 Ok(self)
268 }
269
270 pub fn with_step(mut self, step: PlanStep) -> Result<Self, PlanError> {
272 self.steps.push(step);
273 validate_step_sequence(&self.steps)?;
274 self.refresh_fingerprint()?;
275 Ok(self)
276 }
277
278 pub fn with_verification(
280 mut self,
281 verification: VerificationStrategy,
282 ) -> Result<Self, PlanError> {
283 self.verification = Some(verification);
284 self.refresh_fingerprint()?;
285 Ok(self)
286 }
287
288 pub fn with_rollback_guidance(
290 mut self,
291 guidance: impl Into<String>,
292 ) -> Result<Self, PlanError> {
293 let guidance = guidance.into();
294 validate_plan_text("rollback guidance", &guidance)?;
295 self.rollback_guidance = Some(guidance);
296 self.refresh_fingerprint()?;
297 Ok(self)
298 }
299
300 pub fn refresh_fingerprint(&mut self) -> Result<(), PlanError> {
302 self.fingerprint = compute_fingerprint(self)?;
303 Ok(())
304 }
305
306 pub fn validate_fingerprint(&self) -> Result<(), PlanError> {
308 if compute_fingerprint(self)? == self.fingerprint {
309 Ok(())
310 } else {
311 Err(PlanError::FingerprintMismatch)
312 }
313 }
314
315 #[must_use]
317 pub fn operation_id(&self) -> &OperationId {
318 &self.operation_id
319 }
320
321 #[must_use]
323 pub fn operation(&self) -> &OperationName {
324 &self.operation
325 }
326
327 #[must_use]
329 pub fn target(&self) -> &TargetRef {
330 &self.target
331 }
332
333 #[must_use]
335 pub fn topology_revision(&self) -> Option<&str> {
336 self.topology_revision.as_deref()
337 }
338
339 #[must_use]
341 pub fn changes(&self) -> &[PlannedChange] {
342 &self.changes
343 }
344
345 #[must_use]
347 pub fn steps(&self) -> &[PlanStep] {
348 &self.steps
349 }
350
351 #[must_use]
353 pub fn verification(&self) -> Option<&VerificationStrategy> {
354 self.verification.as_ref()
355 }
356
357 #[must_use]
359 pub fn fingerprint(&self) -> &PlanFingerprint {
360 &self.fingerprint
361 }
362}
363
364#[derive(Serialize)]
365struct FingerprintMaterial<'a> {
366 operation_id: &'a OperationId,
367 operation: &'a OperationName,
368 target: &'a TargetRef,
369 topology_revision: &'a Option<String>,
370 changes: &'a [PlannedChange],
371 risk: RiskClass,
372 reversibility: Reversibility,
373 prerequisites: &'a [String],
374 conflicts: &'a [String],
375 steps: &'a [PlanStep],
376 verification: &'a Option<VerificationStrategy>,
377 rollback_guidance: &'a Option<String>,
378}
379
380fn compute_fingerprint(plan: &OperationPlan) -> Result<PlanFingerprint, PlanError> {
381 let material = FingerprintMaterial {
382 operation_id: &plan.operation_id,
383 operation: &plan.operation,
384 target: &plan.target,
385 topology_revision: &plan.topology_revision,
386 changes: &plan.changes,
387 risk: plan.risk,
388 reversibility: plan.reversibility,
389 prerequisites: &plan.prerequisites,
390 conflicts: &plan.conflicts,
391 steps: &plan.steps,
392 verification: &plan.verification,
393 rollback_guidance: &plan.rollback_guidance,
394 };
395 let encoded = serde_json::to_vec(&material)
396 .map_err(|error| PlanError::Serialization(error.to_string()))?;
397 let digest = Sha256::digest(encoded);
398 let value: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
399 PlanFingerprint::parse(value)
400}
401
402fn validate_step_sequence(steps: &[PlanStep]) -> Result<(), PlanError> {
403 let mut seen = BTreeSet::new();
404 for (index, step) in steps.iter().enumerate() {
405 let expected = u32::try_from(index + 1).map_err(|_| PlanError::InvalidStepSequence)?;
406 if step.sequence != expected || !seen.insert(step.sequence) {
407 return Err(PlanError::InvalidStepSequence);
408 }
409 }
410 Ok(())
411}
412
413fn validate_plan_text(field: &'static str, value: &str) -> Result<(), PlanError> {
414 let chars = value.chars().count();
415 if chars == 0 || chars > MAX_PLAN_TEXT_CHARS || value.chars().any(char::is_control) {
416 return Err(PlanError::InvalidText {
417 field,
418 max_chars: MAX_PLAN_TEXT_CHARS,
419 });
420 }
421 Ok(())
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
426#[non_exhaustive]
427pub enum PlanError {
428 #[error("invalid plan fingerprint")]
430 InvalidFingerprint,
431 #[error("plan fingerprint does not match plan material")]
433 FingerprintMismatch,
434 #[error("plan steps must use contiguous one-based sequence numbers")]
436 InvalidStepSequence,
437 #[error("invalid {field}: expected 1..={max_chars} non-control characters")]
439 InvalidText {
440 field: &'static str,
442 max_chars: usize,
444 },
445 #[error("could not serialize plan fingerprint material: {0}")]
447 Serialization(String),
448}
449
450#[cfg(test)]
451#[path = "plan_tests.rs"]
452mod tests;