Skip to main content

wenlan_types/
repair_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Total lint-repair resolution contracts shared by the daemon and local clients.
3
4use 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;
16/// Keeps each tool result below transcript truncation limits as well as MCP's
17/// larger transport ceiling.
18pub 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}