1use crate::{
5 lint::{
6 LintProducerReceipt, LintProfile, LintScope, LintSnapshotReceipts,
7 LINT_CHECK_CATALOG_VERSION, LINT_REPORT_SCHEMA_VERSION,
8 },
9 repair::{RepairDigest, RepairLintScope, RepairManifest},
10};
11use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
12use std::{collections::BTreeSet, fmt};
13
14pub const REPAIR_PLAN_SCHEMA_VERSION: u16 = 1;
15pub const REPAIR_PLAN_PAGE_MAX_ENTRIES: usize = 100;
16pub const REPAIR_PLAN_PAGE_MAX_BYTES: usize = 48 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RepairPlanContractError {
22 UnsupportedSchema,
23 InvalidPlanId,
24 InvalidReportReceipt,
25 InvalidEntry,
26 DuplicateOccurrence,
27 DuplicateReadyManifest,
28 InvalidReviewItem,
29 InvalidSystemAction,
30 InvalidBlockedResolution,
31 InvalidTotals,
32 InvalidDigest,
33}
34
35impl fmt::Display for RepairPlanContractError {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(match self {
38 Self::UnsupportedSchema => "unsupported repair plan schema",
39 Self::InvalidPlanId => "invalid repair plan id",
40 Self::InvalidReportReceipt => "invalid repair plan report receipt",
41 Self::InvalidEntry => "invalid repair plan entry",
42 Self::DuplicateOccurrence => "duplicate repair plan occurrence",
43 Self::DuplicateReadyManifest => "duplicate ready repair manifest",
44 Self::InvalidReviewItem => "invalid repair review item",
45 Self::InvalidSystemAction => "invalid repair system action",
46 Self::InvalidBlockedResolution => "invalid blocked repair resolution",
47 Self::InvalidTotals => "invalid repair plan totals",
48 Self::InvalidDigest => "invalid repair plan digest",
49 })
50 }
51}
52
53impl std::error::Error for RepairPlanContractError {}
54
55fn valid_nonempty(value: &str) -> bool {
56 !value.is_empty() && value.trim() == value
57}
58
59fn valid_plan_id(value: &str) -> bool {
60 let Some(uuid) = value.strip_prefix("repair_plan_") else {
61 return false;
62 };
63 uuid.len() == 36
64 && uuid.bytes().enumerate().all(|(index, byte)| match index {
65 8 | 13 | 18 | 23 => byte == b'-',
66 _ => byte.is_ascii_digit() || (byte.is_ascii_lowercase() && byte.is_ascii_hexdigit()),
67 })
68}
69
70fn valid_review_id(value: &str) -> bool {
71 value
72 .strip_prefix("lint_review_")
73 .is_some_and(|digest| RepairDigest::parse(digest).is_ok())
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum RepairFindingKind {
79 Deterministic,
80 Semantic,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum RepairAffectedRecordKind {
86 Memory,
87 Page,
88 Entity,
89 Relation,
90 Tag,
91 PageLink,
92 Schema,
93 Route,
94 Retrieval,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
98pub struct RepairAffectedRecord {
99 kind: RepairAffectedRecordKind,
100 durable_id: String,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct RepairAffectedRecordWire {
106 kind: RepairAffectedRecordKind,
107 durable_id: String,
108}
109
110impl RepairAffectedRecord {
111 pub fn try_new(
112 kind: RepairAffectedRecordKind,
113 durable_id: String,
114 ) -> Result<Self, RepairPlanContractError> {
115 if !valid_nonempty(&durable_id) {
116 return Err(RepairPlanContractError::InvalidEntry);
117 }
118 Ok(Self { kind, durable_id })
119 }
120
121 pub const fn kind(&self) -> RepairAffectedRecordKind {
122 self.kind
123 }
124
125 pub fn durable_id(&self) -> &str {
126 &self.durable_id
127 }
128}
129
130impl<'de> Deserialize<'de> for RepairAffectedRecord {
131 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
132 where
133 D: Deserializer<'de>,
134 {
135 let wire = RepairAffectedRecordWire::deserialize(deserializer)?;
136 Self::try_new(wire.kind, wire.durable_id).map_err(D::Error::custom)
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141pub struct RepairReviewItem {
142 review_id: String,
143 check_id: String,
144 issue: String,
145 choices: Vec<String>,
146 suggested_research_queries: Vec<String>,
147}
148
149#[derive(Deserialize)]
150#[serde(deny_unknown_fields)]
151struct RepairReviewItemWire {
152 review_id: String,
153 check_id: String,
154 issue: String,
155 choices: Vec<String>,
156 #[serde(default)]
157 suggested_research_queries: Vec<String>,
158}
159
160impl RepairReviewItem {
161 pub fn try_new(
162 review_id: String,
163 check_id: String,
164 issue: String,
165 choices: Vec<String>,
166 suggested_research_queries: Vec<String>,
167 ) -> Result<Self, RepairPlanContractError> {
168 if !valid_review_id(&review_id)
169 || !valid_nonempty(&check_id)
170 || !valid_nonempty(&issue)
171 || choices.is_empty()
172 || choices.iter().any(|choice| !valid_nonempty(choice))
173 || suggested_research_queries
174 .iter()
175 .any(|query| !valid_nonempty(query))
176 {
177 return Err(RepairPlanContractError::InvalidReviewItem);
178 }
179 Ok(Self {
180 review_id,
181 check_id,
182 issue,
183 choices,
184 suggested_research_queries,
185 })
186 }
187
188 pub fn review_id(&self) -> &str {
189 &self.review_id
190 }
191
192 pub fn check_id(&self) -> &str {
193 &self.check_id
194 }
195
196 pub fn issue(&self) -> &str {
197 &self.issue
198 }
199
200 pub fn choices(&self) -> &[String] {
201 &self.choices
202 }
203
204 pub fn suggested_research_queries(&self) -> &[String] {
205 &self.suggested_research_queries
206 }
207}
208
209impl<'de> Deserialize<'de> for RepairReviewItem {
210 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
211 where
212 D: Deserializer<'de>,
213 {
214 let wire = RepairReviewItemWire::deserialize(deserializer)?;
215 Self::try_new(
216 wire.review_id,
217 wire.check_id,
218 wire.issue,
219 wire.choices,
220 wire.suggested_research_queries,
221 )
222 .map_err(D::Error::custom)
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum RepairSystemActionKind {
229 RunSchemaMigration,
230 RebuildSearchIndex,
231 UpdateDaemon,
232 RestartDaemon,
233 CorrectRouteScopeContract,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
237pub struct RepairSystemAction {
238 kind: RepairSystemActionKind,
239 summary: String,
240 evidence: Vec<String>,
241}
242
243#[derive(Deserialize)]
244#[serde(deny_unknown_fields)]
245struct RepairSystemActionWire {
246 kind: RepairSystemActionKind,
247 summary: String,
248 evidence: Vec<String>,
249}
250
251impl RepairSystemAction {
252 pub fn try_new(
253 kind: RepairSystemActionKind,
254 summary: String,
255 evidence: Vec<String>,
256 ) -> Result<Self, RepairPlanContractError> {
257 if !valid_nonempty(&summary)
258 || evidence.is_empty()
259 || evidence.iter().any(|item| !valid_nonempty(item))
260 {
261 return Err(RepairPlanContractError::InvalidSystemAction);
262 }
263 Ok(Self {
264 kind,
265 summary,
266 evidence,
267 })
268 }
269
270 pub const fn kind(&self) -> RepairSystemActionKind {
271 self.kind
272 }
273
274 pub fn summary(&self) -> &str {
275 &self.summary
276 }
277
278 pub fn evidence(&self) -> &[String] {
279 &self.evidence
280 }
281}
282
283impl<'de> Deserialize<'de> for RepairSystemAction {
284 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
285 where
286 D: Deserializer<'de>,
287 {
288 let wire = RepairSystemActionWire::deserialize(deserializer)?;
289 Self::try_new(wire.kind, wire.summary, wire.evidence).map_err(D::Error::custom)
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum RepairBlockedReasonCode {
296 UnsupportedDeterministicWriter,
297 SourceIncomplete,
298 SourceStale,
299 AmbiguousRepairTarget,
300 ConflictingRepairProposals,
301 UnknownSchemaShape,
302 MissingPrerequisite,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct RepairBlocked {
307 reason_code: RepairBlockedReasonCode,
308 detail: String,
309 next_action: String,
310}
311
312#[derive(Deserialize)]
313#[serde(deny_unknown_fields)]
314struct RepairBlockedWire {
315 reason_code: RepairBlockedReasonCode,
316 detail: String,
317 next_action: String,
318}
319
320impl RepairBlocked {
321 pub fn try_new(
322 reason_code: RepairBlockedReasonCode,
323 detail: String,
324 next_action: String,
325 ) -> Result<Self, RepairPlanContractError> {
326 if !valid_nonempty(&detail) || !valid_nonempty(&next_action) {
327 return Err(RepairPlanContractError::InvalidBlockedResolution);
328 }
329 Ok(Self {
330 reason_code,
331 detail,
332 next_action,
333 })
334 }
335
336 pub const fn reason_code(&self) -> RepairBlockedReasonCode {
337 self.reason_code
338 }
339
340 pub fn detail(&self) -> &str {
341 &self.detail
342 }
343
344 pub fn next_action(&self) -> &str {
345 &self.next_action
346 }
347}
348
349impl<'de> Deserialize<'de> for RepairBlocked {
350 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351 where
352 D: Deserializer<'de>,
353 {
354 let wire = RepairBlockedWire::deserialize(deserializer)?;
355 Self::try_new(wire.reason_code, wire.detail, wire.next_action).map_err(D::Error::custom)
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(tag = "disposition", rename_all = "snake_case", deny_unknown_fields)]
361pub enum RepairResolution {
362 Ready { manifest: Box<RepairManifest> },
363 Review { review_item: RepairReviewItem },
364 SystemAction { system_action: RepairSystemAction },
365 Blocked { blocked: RepairBlocked },
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
369pub struct RepairPlanEntry {
370 finding_kind: RepairFindingKind,
371 check_id: String,
372 occurrence_digest: RepairDigest,
373 affected_records: Vec<RepairAffectedRecord>,
374 resolution: RepairResolution,
375}
376
377#[derive(Deserialize)]
378#[serde(deny_unknown_fields)]
379struct RepairPlanEntryWire {
380 finding_kind: RepairFindingKind,
381 check_id: String,
382 occurrence_digest: RepairDigest,
383 affected_records: Vec<RepairAffectedRecord>,
384 resolution: RepairResolution,
385}
386
387impl RepairPlanEntry {
388 pub fn try_new(
389 finding_kind: RepairFindingKind,
390 check_id: String,
391 occurrence_digest: RepairDigest,
392 mut affected_records: Vec<RepairAffectedRecord>,
393 resolution: RepairResolution,
394 ) -> Result<Self, RepairPlanContractError> {
395 let target_resolution = matches!(
396 resolution,
397 RepairResolution::Ready { .. } | RepairResolution::Review { .. }
398 );
399 if !valid_nonempty(&check_id) || (target_resolution && affected_records.is_empty()) {
400 return Err(RepairPlanContractError::InvalidEntry);
401 }
402 if let RepairResolution::Review { review_item } = &resolution {
403 if review_item.check_id() != check_id {
404 return Err(RepairPlanContractError::InvalidEntry);
405 }
406 }
407 affected_records.sort();
408 if affected_records.windows(2).any(|pair| pair[0] == pair[1]) {
409 return Err(RepairPlanContractError::InvalidEntry);
410 }
411 Ok(Self {
412 finding_kind,
413 check_id,
414 occurrence_digest,
415 affected_records,
416 resolution,
417 })
418 }
419
420 pub const fn finding_kind(&self) -> RepairFindingKind {
421 self.finding_kind
422 }
423
424 pub fn check_id(&self) -> &str {
425 &self.check_id
426 }
427
428 pub fn occurrence_digest(&self) -> &RepairDigest {
429 &self.occurrence_digest
430 }
431
432 pub fn affected_records(&self) -> &[RepairAffectedRecord] {
433 &self.affected_records
434 }
435
436 pub fn resolution(&self) -> &RepairResolution {
437 &self.resolution
438 }
439}
440
441impl<'de> Deserialize<'de> for RepairPlanEntry {
442 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
443 where
444 D: Deserializer<'de>,
445 {
446 let wire = RepairPlanEntryWire::deserialize(deserializer)?;
447 Self::try_new(
448 wire.finding_kind,
449 wire.check_id,
450 wire.occurrence_digest,
451 wire.affected_records,
452 wire.resolution,
453 )
454 .map_err(D::Error::custom)
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
459pub struct RepairPlanReportReceipt {
460 report_schema_version: u16,
461 check_catalog_version: u16,
462 profile: LintProfile,
463 scope: LintScope,
464 snapshots: LintSnapshotReceipts,
465 producer_receipt: LintProducerReceipt,
466}
467
468#[derive(Deserialize)]
469#[serde(deny_unknown_fields)]
470struct RepairPlanReportReceiptWire {
471 report_schema_version: u16,
472 check_catalog_version: u16,
473 profile: LintProfile,
474 scope: LintScope,
475 snapshots: LintSnapshotReceipts,
476 producer_receipt: LintProducerReceipt,
477}
478
479impl RepairPlanReportReceipt {
480 pub fn try_new(
481 profile: LintProfile,
482 scope: LintScope,
483 snapshots: LintSnapshotReceipts,
484 producer_receipt: LintProducerReceipt,
485 ) -> Result<Self, RepairPlanContractError> {
486 Ok(Self {
487 report_schema_version: LINT_REPORT_SCHEMA_VERSION,
488 check_catalog_version: LINT_CHECK_CATALOG_VERSION,
489 profile,
490 scope,
491 snapshots,
492 producer_receipt,
493 })
494 }
495
496 pub fn from_report(report: &crate::LintReport) -> Self {
497 Self {
498 report_schema_version: LINT_REPORT_SCHEMA_VERSION,
499 check_catalog_version: LINT_CHECK_CATALOG_VERSION,
500 profile: report.profile(),
501 scope: report.scope().clone(),
502 snapshots: report.snapshots().clone(),
503 producer_receipt: report.producer_receipt().clone(),
504 }
505 }
506
507 pub const fn profile(&self) -> LintProfile {
508 self.profile
509 }
510
511 pub fn scope(&self) -> &LintScope {
512 &self.scope
513 }
514
515 pub fn snapshots(&self) -> &LintSnapshotReceipts {
516 &self.snapshots
517 }
518
519 pub fn producer_receipt(&self) -> &LintProducerReceipt {
520 &self.producer_receipt
521 }
522}
523
524impl<'de> Deserialize<'de> for RepairPlanReportReceipt {
525 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
526 where
527 D: Deserializer<'de>,
528 {
529 let wire = RepairPlanReportReceiptWire::deserialize(deserializer)?;
530 if wire.report_schema_version != LINT_REPORT_SCHEMA_VERSION
531 || wire.check_catalog_version != LINT_CHECK_CATALOG_VERSION
532 {
533 return Err(D::Error::custom(
534 RepairPlanContractError::InvalidReportReceipt,
535 ));
536 }
537 Self::try_new(
538 wire.profile,
539 wire.scope,
540 wire.snapshots,
541 wire.producer_receipt,
542 )
543 .map_err(D::Error::custom)
544 }
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct RepairPlanTotals {
549 deterministic: u64,
550 semantic: u64,
551 ready: u64,
552 review: u64,
553 system_action: u64,
554 blocked: u64,
555}
556
557impl RepairPlanTotals {
558 fn from_entries(entries: &[RepairPlanEntry]) -> Result<Self, RepairPlanContractError> {
559 let mut totals = Self {
560 deterministic: 0,
561 semantic: 0,
562 ready: 0,
563 review: 0,
564 system_action: 0,
565 blocked: 0,
566 };
567 for entry in entries {
568 match entry.finding_kind() {
569 RepairFindingKind::Deterministic => totals.deterministic += 1,
570 RepairFindingKind::Semantic => totals.semantic += 1,
571 }
572 match entry.resolution() {
573 RepairResolution::Ready { .. } => totals.ready += 1,
574 RepairResolution::Review { .. } => totals.review += 1,
575 RepairResolution::SystemAction { .. } => totals.system_action += 1,
576 RepairResolution::Blocked { .. } => totals.blocked += 1,
577 }
578 }
579 Ok(totals)
580 }
581
582 pub const fn deterministic(&self) -> u64 {
583 self.deterministic
584 }
585
586 pub const fn semantic(&self) -> u64 {
587 self.semantic
588 }
589
590 pub const fn ready(&self) -> u64 {
591 self.ready
592 }
593
594 pub const fn review(&self) -> u64 {
595 self.review
596 }
597
598 pub const fn system_action(&self) -> u64 {
599 self.system_action
600 }
601
602 pub const fn blocked(&self) -> u64 {
603 self.blocked
604 }
605}
606
607#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
608pub struct RepairPlanDraft {
609 plan_schema_version: u16,
610 plan_id: String,
611 scope: RepairLintScope,
612 general_report_receipt: RepairPlanReportReceipt,
613 #[serde(skip_serializing_if = "Option::is_none")]
614 deep_report_receipt: Option<RepairPlanReportReceipt>,
615 deterministic_complete: bool,
616 semantic_complete: bool,
617 entries: Vec<RepairPlanEntry>,
618 totals: RepairPlanTotals,
619}
620
621impl RepairPlanDraft {
622 #[allow(clippy::too_many_arguments)]
623 pub fn try_new(
624 plan_id: String,
625 scope: RepairLintScope,
626 general_report_receipt: RepairPlanReportReceipt,
627 deep_report_receipt: Option<RepairPlanReportReceipt>,
628 deterministic_complete: bool,
629 semantic_complete: bool,
630 mut entries: Vec<RepairPlanEntry>,
631 ) -> Result<Self, RepairPlanContractError> {
632 let reports_valid = general_report_receipt.profile() == LintProfile::General
633 && scope.matches_report_scope_kind(general_report_receipt.scope())
634 && deep_report_receipt.as_ref().is_none_or(|receipt| {
635 receipt.profile() == LintProfile::Deep
636 && scope.matches_report_scope_kind(receipt.scope())
637 && receipt.scope() == general_report_receipt.scope()
638 });
639 let semantic_valid = deep_report_receipt.is_some()
640 || (!semantic_complete
641 && entries
642 .iter()
643 .all(|entry| entry.finding_kind() != RepairFindingKind::Semantic));
644 let source_incomplete = |kind| {
645 entries.iter().any(|entry| {
646 entry.finding_kind() == kind
647 && matches!(
648 entry.resolution(),
649 RepairResolution::Blocked { blocked }
650 if blocked.reason_code() == RepairBlockedReasonCode::SourceIncomplete
651 )
652 })
653 };
654 let completeness_valid = deterministic_complete
655 != source_incomplete(RepairFindingKind::Deterministic)
656 && semantic_complete
657 == (deep_report_receipt.is_some()
658 && !source_incomplete(RepairFindingKind::Semantic));
659 if !valid_plan_id(&plan_id) || !reports_valid || !semantic_valid || !completeness_valid {
660 return Err(RepairPlanContractError::InvalidPlanId);
661 }
662 let mut occurrences = BTreeSet::new();
663 if entries
664 .iter()
665 .any(|entry| !occurrences.insert(entry.occurrence_digest().as_str().to_string()))
666 {
667 return Err(RepairPlanContractError::DuplicateOccurrence);
668 }
669 let mut ready_manifest_ids = BTreeSet::new();
670 if entries.iter().any(|entry| {
671 matches!(
672 entry.resolution(),
673 RepairResolution::Ready { manifest }
674 if !ready_manifest_ids.insert(manifest.manifest_id().to_string())
675 )
676 }) {
677 return Err(RepairPlanContractError::DuplicateReadyManifest);
678 }
679 entries.sort_by(|left, right| {
680 (
681 left.finding_kind(),
682 left.check_id(),
683 left.occurrence_digest().as_str(),
684 )
685 .cmp(&(
686 right.finding_kind(),
687 right.check_id(),
688 right.occurrence_digest().as_str(),
689 ))
690 });
691 let totals = RepairPlanTotals::from_entries(&entries)?;
692 Ok(Self {
693 plan_schema_version: REPAIR_PLAN_SCHEMA_VERSION,
694 plan_id,
695 scope,
696 general_report_receipt,
697 deep_report_receipt,
698 deterministic_complete,
699 semantic_complete,
700 entries,
701 totals,
702 })
703 }
704
705 pub fn canonical_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
706 serde_json::to_vec(self)
707 }
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
711pub struct RepairPlan {
712 #[serde(flatten)]
713 draft: RepairPlanDraft,
714 plan_digest: RepairDigest,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
718#[serde(deny_unknown_fields)]
719pub struct StoredRepairPlan {
720 plan_schema_version: u16,
721 plan_id: String,
722 scope: RepairLintScope,
723 general_report_receipt: RepairPlanReportReceipt,
724 #[serde(default)]
725 deep_report_receipt: Option<RepairPlanReportReceipt>,
726 deterministic_complete: bool,
727 semantic_complete: bool,
728 entries: Vec<RepairPlanEntry>,
729 totals: RepairPlanTotals,
730 plan_digest: RepairDigest,
731}
732
733impl RepairPlan {
734 pub fn try_new(
735 draft: RepairPlanDraft,
736 plan_digest: RepairDigest,
737 verify: impl FnOnce(&[u8], &RepairDigest) -> bool,
738 ) -> Result<Self, RepairPlanContractError> {
739 let canonical = draft
740 .canonical_bytes()
741 .map_err(|_| RepairPlanContractError::InvalidDigest)?;
742 if !verify(&canonical, &plan_digest) {
743 return Err(RepairPlanContractError::InvalidDigest);
744 }
745 Ok(Self { draft, plan_digest })
746 }
747
748 pub const fn schema_version(&self) -> u16 {
749 self.draft.plan_schema_version
750 }
751
752 pub fn plan_id(&self) -> &str {
753 &self.draft.plan_id
754 }
755
756 pub fn scope(&self) -> &RepairLintScope {
757 &self.draft.scope
758 }
759
760 pub fn general_report_receipt(&self) -> &RepairPlanReportReceipt {
761 &self.draft.general_report_receipt
762 }
763
764 pub fn deep_report_receipt(&self) -> Option<&RepairPlanReportReceipt> {
765 self.draft.deep_report_receipt.as_ref()
766 }
767
768 pub const fn deterministic_complete(&self) -> bool {
769 self.draft.deterministic_complete
770 }
771
772 pub const fn semantic_complete(&self) -> bool {
773 self.draft.semantic_complete
774 }
775
776 pub fn entries(&self) -> &[RepairPlanEntry] {
777 &self.draft.entries
778 }
779
780 pub const fn totals(&self) -> &RepairPlanTotals {
781 &self.draft.totals
782 }
783
784 pub fn plan_digest(&self) -> &RepairDigest {
785 &self.plan_digest
786 }
787
788 pub fn canonical_unsigned_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
789 self.draft.canonical_bytes()
790 }
791}
792
793impl StoredRepairPlan {
794 pub fn verify_and_try_into_current(
795 self,
796 verify: impl FnOnce(&[u8], &RepairDigest) -> bool,
797 ) -> Result<RepairPlan, RepairPlanContractError> {
798 if self.plan_schema_version != REPAIR_PLAN_SCHEMA_VERSION {
799 return Err(RepairPlanContractError::UnsupportedSchema);
800 }
801 let draft = RepairPlanDraft::try_new(
802 self.plan_id,
803 self.scope,
804 self.general_report_receipt,
805 self.deep_report_receipt,
806 self.deterministic_complete,
807 self.semantic_complete,
808 self.entries,
809 )?;
810 if draft.totals != self.totals {
811 return Err(RepairPlanContractError::InvalidTotals);
812 }
813 RepairPlan::try_new(draft, self.plan_digest, verify)
814 }
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
818#[serde(deny_unknown_fields)]
819pub struct PrepareRepairPlanResponse {
820 plan: RepairPlan,
821 artifact_path: String,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
825#[serde(deny_unknown_fields)]
826pub struct StoredPrepareRepairPlanResponse {
827 plan: StoredRepairPlan,
828 artifact_path: String,
829}
830
831impl StoredPrepareRepairPlanResponse {
832 pub fn verify_and_try_into_current(
833 self,
834 verify: impl FnOnce(&[u8], &RepairDigest) -> bool,
835 ) -> Result<PrepareRepairPlanResponse, RepairPlanContractError> {
836 PrepareRepairPlanResponse::try_new(
837 self.plan.verify_and_try_into_current(verify)?,
838 self.artifact_path,
839 )
840 }
841}
842
843impl PrepareRepairPlanResponse {
844 pub fn try_new(
845 plan: RepairPlan,
846 artifact_path: String,
847 ) -> Result<Self, RepairPlanContractError> {
848 if !valid_nonempty(&artifact_path) {
849 return Err(RepairPlanContractError::InvalidPlanId);
850 }
851 Ok(Self {
852 plan,
853 artifact_path,
854 })
855 }
856
857 pub fn plan(&self) -> &RepairPlan {
858 &self.plan
859 }
860
861 pub fn artifact_path(&self) -> &str {
862 &self.artifact_path
863 }
864
865 pub fn compact_summary(&self) -> Result<RepairPlanSummary, RepairPlanContractError> {
866 RepairPlanSummary::try_new(
867 self.plan.schema_version(),
868 self.plan.plan_id().to_string(),
869 self.plan.scope().clone(),
870 self.plan.plan_digest().clone(),
871 self.artifact_path.clone(),
872 self.plan.deterministic_complete(),
873 self.plan.semantic_complete(),
874 self.plan.totals().clone(),
875 self.plan.entries().len(),
876 )
877 }
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
881pub struct RepairPlanSummary {
882 plan_schema_version: u16,
883 plan_id: String,
884 scope: RepairLintScope,
885 plan_digest: RepairDigest,
886 artifact_path: String,
887 deterministic_complete: bool,
888 semantic_complete: bool,
889 totals: RepairPlanTotals,
890 entry_count: usize,
891}
892
893#[derive(Deserialize)]
894#[serde(deny_unknown_fields)]
895struct RepairPlanSummaryWire {
896 plan_schema_version: u16,
897 plan_id: String,
898 scope: RepairLintScope,
899 plan_digest: RepairDigest,
900 artifact_path: String,
901 deterministic_complete: bool,
902 semantic_complete: bool,
903 totals: RepairPlanTotals,
904 entry_count: usize,
905}
906
907impl RepairPlanSummary {
908 #[allow(clippy::too_many_arguments)]
909 pub fn try_new(
910 plan_schema_version: u16,
911 plan_id: String,
912 scope: RepairLintScope,
913 plan_digest: RepairDigest,
914 artifact_path: String,
915 deterministic_complete: bool,
916 semantic_complete: bool,
917 totals: RepairPlanTotals,
918 entry_count: usize,
919 ) -> Result<Self, RepairPlanContractError> {
920 let counted_entries = totals
921 .deterministic()
922 .checked_add(totals.semantic())
923 .and_then(|count| usize::try_from(count).ok());
924 let counted_resolutions = totals
925 .ready()
926 .checked_add(totals.review())
927 .and_then(|count| count.checked_add(totals.system_action()))
928 .and_then(|count| count.checked_add(totals.blocked()))
929 .and_then(|count| usize::try_from(count).ok());
930 if plan_schema_version != REPAIR_PLAN_SCHEMA_VERSION
931 || !valid_plan_id(&plan_id)
932 || !valid_nonempty(&artifact_path)
933 || counted_entries != Some(entry_count)
934 || counted_resolutions != Some(entry_count)
935 {
936 return Err(RepairPlanContractError::InvalidTotals);
937 }
938 Ok(Self {
939 plan_schema_version,
940 plan_id,
941 scope,
942 plan_digest,
943 artifact_path,
944 deterministic_complete,
945 semantic_complete,
946 totals,
947 entry_count,
948 })
949 }
950}
951
952impl<'de> Deserialize<'de> for RepairPlanSummary {
953 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
954 where
955 D: Deserializer<'de>,
956 {
957 let wire = RepairPlanSummaryWire::deserialize(deserializer)?;
958 Self::try_new(
959 wire.plan_schema_version,
960 wire.plan_id,
961 wire.scope,
962 wire.plan_digest,
963 wire.artifact_path,
964 wire.deterministic_complete,
965 wire.semantic_complete,
966 wire.totals,
967 wire.entry_count,
968 )
969 .map_err(D::Error::custom)
970 }
971}
972
973#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
974pub struct RepairPlanEntriesRequest {
975 plan_id: String,
976 plan_digest: RepairDigest,
977 offset: usize,
978 limit: usize,
979}
980
981#[derive(Deserialize)]
982#[serde(deny_unknown_fields)]
983struct RepairPlanEntriesRequestWire {
984 plan_id: String,
985 plan_digest: RepairDigest,
986 offset: usize,
987 limit: usize,
988}
989
990impl RepairPlanEntriesRequest {
991 pub fn try_new(
992 plan_id: String,
993 plan_digest: RepairDigest,
994 offset: usize,
995 limit: usize,
996 ) -> Result<Self, RepairPlanContractError> {
997 if !valid_plan_id(&plan_id) || !(1..=REPAIR_PLAN_PAGE_MAX_ENTRIES).contains(&limit) {
998 return Err(RepairPlanContractError::InvalidEntry);
999 }
1000 Ok(Self {
1001 plan_id,
1002 plan_digest,
1003 offset,
1004 limit,
1005 })
1006 }
1007
1008 pub fn plan_id(&self) -> &str {
1009 &self.plan_id
1010 }
1011
1012 pub fn plan_digest(&self) -> &RepairDigest {
1013 &self.plan_digest
1014 }
1015
1016 pub const fn offset(&self) -> usize {
1017 self.offset
1018 }
1019
1020 pub const fn limit(&self) -> usize {
1021 self.limit
1022 }
1023}
1024
1025impl<'de> Deserialize<'de> for RepairPlanEntriesRequest {
1026 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1027 where
1028 D: Deserializer<'de>,
1029 {
1030 let wire = RepairPlanEntriesRequestWire::deserialize(deserializer)?;
1031 Self::try_new(wire.plan_id, wire.plan_digest, wire.offset, wire.limit)
1032 .map_err(D::Error::custom)
1033 }
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1037pub struct RepairPlanEntriesPage {
1038 plan_id: String,
1039 plan_digest: RepairDigest,
1040 scope: RepairLintScope,
1041 offset: usize,
1042 #[serde(skip_serializing_if = "Option::is_none")]
1043 next_offset: Option<usize>,
1044 total_entries: usize,
1045 entries: Vec<RepairPlanEntry>,
1046}
1047
1048#[derive(Deserialize)]
1049#[serde(deny_unknown_fields)]
1050struct RepairPlanEntriesPageWire {
1051 plan_id: String,
1052 plan_digest: RepairDigest,
1053 scope: RepairLintScope,
1054 offset: usize,
1055 #[serde(default)]
1056 next_offset: Option<usize>,
1057 total_entries: usize,
1058 entries: Vec<RepairPlanEntry>,
1059}
1060
1061impl RepairPlanEntriesPage {
1062 pub fn try_new(
1063 plan_id: String,
1064 plan_digest: RepairDigest,
1065 scope: RepairLintScope,
1066 offset: usize,
1067 total_entries: usize,
1068 entries: Vec<RepairPlanEntry>,
1069 ) -> Result<Self, RepairPlanContractError> {
1070 if !valid_plan_id(&plan_id)
1071 || offset > total_entries
1072 || entries.len() > REPAIR_PLAN_PAGE_MAX_ENTRIES
1073 || entries.len() > total_entries.saturating_sub(offset)
1074 {
1075 return Err(RepairPlanContractError::InvalidEntry);
1076 }
1077 let consumed = offset
1078 .checked_add(entries.len())
1079 .ok_or(RepairPlanContractError::InvalidEntry)?;
1080 let next_offset = (consumed < total_entries).then_some(consumed);
1081 if next_offset.is_some() && entries.is_empty() {
1082 return Err(RepairPlanContractError::InvalidEntry);
1083 }
1084 let page = Self {
1085 plan_id,
1086 plan_digest,
1087 scope,
1088 offset,
1089 next_offset,
1090 total_entries,
1091 entries,
1092 };
1093 if serde_json::to_vec(&page)
1094 .map_err(|_| RepairPlanContractError::InvalidEntry)?
1095 .len()
1096 > REPAIR_PLAN_PAGE_MAX_BYTES
1097 {
1098 return Err(RepairPlanContractError::InvalidEntry);
1099 }
1100 Ok(page)
1101 }
1102
1103 pub fn entries(&self) -> &[RepairPlanEntry] {
1104 &self.entries
1105 }
1106
1107 pub fn scope(&self) -> &RepairLintScope {
1108 &self.scope
1109 }
1110}
1111
1112impl<'de> Deserialize<'de> for RepairPlanEntriesPage {
1113 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1114 where
1115 D: Deserializer<'de>,
1116 {
1117 let wire = RepairPlanEntriesPageWire::deserialize(deserializer)?;
1118 let page = Self::try_new(
1119 wire.plan_id,
1120 wire.plan_digest,
1121 wire.scope,
1122 wire.offset,
1123 wire.total_entries,
1124 wire.entries,
1125 )
1126 .map_err(D::Error::custom)?;
1127 if page.next_offset != wire.next_offset {
1128 return Err(D::Error::custom(RepairPlanContractError::InvalidEntry));
1129 }
1130 Ok(page)
1131 }
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1135pub struct RepairPlanRequest {
1136 scope: RepairLintScope,
1137 general_report: crate::LintReport,
1138 #[serde(skip_serializing_if = "Option::is_none")]
1139 deep_report: Option<crate::LintReport>,
1140}
1141
1142#[derive(Deserialize)]
1143#[serde(deny_unknown_fields)]
1144struct RepairPlanRequestWire {
1145 scope: RepairLintScope,
1146 general_report: crate::LintReport,
1147 #[serde(default)]
1148 deep_report: Option<crate::LintReport>,
1149}
1150
1151impl RepairPlanRequest {
1152 pub fn try_new(
1153 scope: RepairLintScope,
1154 general_report: crate::LintReport,
1155 deep_report: Option<crate::LintReport>,
1156 ) -> Result<Self, RepairPlanContractError> {
1157 let valid = general_report.profile() == LintProfile::General
1158 && scope.matches_report_scope_kind(general_report.scope())
1159 && deep_report.as_ref().is_none_or(|report| {
1160 report.profile() == LintProfile::Deep
1161 && scope.matches_report_scope_kind(report.scope())
1162 && report.scope() == general_report.scope()
1163 });
1164 if !valid {
1165 return Err(RepairPlanContractError::InvalidReportReceipt);
1166 }
1167 Ok(Self {
1168 scope,
1169 general_report,
1170 deep_report,
1171 })
1172 }
1173
1174 pub fn scope(&self) -> &RepairLintScope {
1175 &self.scope
1176 }
1177
1178 pub fn general_report(&self) -> &crate::LintReport {
1179 &self.general_report
1180 }
1181
1182 pub fn deep_report(&self) -> Option<&crate::LintReport> {
1183 self.deep_report.as_ref()
1184 }
1185}
1186
1187impl<'de> Deserialize<'de> for RepairPlanRequest {
1188 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1189 where
1190 D: Deserializer<'de>,
1191 {
1192 let wire = RepairPlanRequestWire::deserialize(deserializer)?;
1193 Self::try_new(wire.scope, wire.general_report, wire.deep_report).map_err(D::Error::custom)
1194 }
1195}