1use prikk_error::{PrikkError, Result};
8
9use crate::block_state::BlockStateStatus;
10use crate::layout::RepositoryLayout;
11use crate::lock::ActiveLock;
12use crate::refs::{RefFileStatus, RefItemStatus};
13use crate::verify::{
14 ActiveWalMetadataStatus, ObjectItemStatus, RepositoryVerification, StageStatus,
15 verify_repository,
16};
17use crate::wal::{Wal, WalRecordStatus, WalRepair};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DoctorSeverity {
22 Info,
24 Warning,
26 Error,
28}
29
30impl DoctorSeverity {
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Info => "info",
36 Self::Warning => "warning",
37 Self::Error => "error",
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DoctorIssue {
45 pub code: &'static str,
47 pub severity: DoctorSeverity,
49 pub message: String,
51 pub recommendation: String,
53}
54
55impl DoctorIssue {
56 #[must_use]
58 pub fn info(
59 code: &'static str,
60 message: impl Into<String>,
61 recommendation: impl Into<String>,
62 ) -> Self {
63 Self {
64 code,
65 severity: DoctorSeverity::Info,
66 message: message.into(),
67 recommendation: recommendation.into(),
68 }
69 }
70
71 #[must_use]
73 pub fn warning(
74 code: &'static str,
75 message: impl Into<String>,
76 recommendation: impl Into<String>,
77 ) -> Self {
78 Self {
79 code,
80 severity: DoctorSeverity::Warning,
81 message: message.into(),
82 recommendation: recommendation.into(),
83 }
84 }
85
86 #[must_use]
88 pub fn error(
89 code: &'static str,
90 message: impl Into<String>,
91 recommendation: impl Into<String>,
92 ) -> Self {
93 Self {
94 code,
95 severity: DoctorSeverity::Error,
96 message: message.into(),
97 recommendation: recommendation.into(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct DoctorReport {
105 pub verification: Option<RepositoryVerification>,
107 pub issues: Vec<DoctorIssue>,
109}
110
111impl DoctorReport {
112 #[must_use]
114 pub fn is_healthy(&self) -> bool {
115 !self
116 .issues
117 .iter()
118 .any(|issue| issue.severity == DoctorSeverity::Error)
119 }
120
121 #[must_use]
123 pub fn count_by_severity(&self, severity: DoctorSeverity) -> usize {
124 self.issues
125 .iter()
126 .filter(|issue| issue.severity == severity)
127 .count()
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct DoctorRepairOptions {
134 pub truncate_wal_tail: bool,
137 pub reconstruct_main_ref: bool,
139}
140
141impl DoctorRepairOptions {
142 #[must_use]
144 pub const fn none() -> Self {
145 Self {
146 truncate_wal_tail: false,
147 reconstruct_main_ref: false,
148 }
149 }
150
151 #[must_use]
153 pub const fn truncate_wal_tail() -> Self {
154 Self {
155 truncate_wal_tail: true,
156 reconstruct_main_ref: false,
157 }
158 }
159
160 #[must_use]
162 pub const fn reconstruct_main_ref() -> Self {
163 Self {
164 truncate_wal_tail: false,
165 reconstruct_main_ref: true,
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct DoctorRepairReport {
173 pub before: DoctorReport,
175 pub wal_repair: WalRepair,
177 pub after: DoctorReport,
179}
180
181#[must_use]
183pub fn doctor_repository(layout: &RepositoryLayout) -> DoctorReport {
184 let mut issues = Vec::new();
185 match verify_repository(layout) {
186 Ok(verification) => {
187 issues.push(DoctorIssue::info(
188 "PRIKK-DOCTOR-VERIFY-OK",
189 "repository structural verification scan completed",
190 "review the remaining diagnostics before deciding whether action is required",
191 ));
192 for outcome in &verification.stage_outcomes {
197 let message = match &outcome.status {
198 StageStatus::Evaluated => continue,
199 StageStatus::Failed { message } => {
200 format!("verification stage {} failed: {message}", outcome.stage)
201 }
202 StageStatus::NotEvaluated { blocked_by } => {
203 format!(
204 "verification stage {} could not run because stage {blocked_by} did not evaluate",
205 outcome.stage
206 )
207 }
208 StageStatus::Halted { after } => {
209 format!(
210 "verification stage {} was not attempted because stage {after} failed and halted the walk (--stop-on-first-error)",
211 outcome.stage
212 )
213 }
214 };
215 issues.push(DoctorIssue::error(
216 "PRIKK-DOCTOR-VERIFY-STAGE-INCOMPLETE",
217 message,
218 "preserve the repository and inspect the failing stage before attempting repair",
219 ));
220 }
221 for outcome in &verification.object_outcomes {
226 if let ObjectItemStatus::Failed { message } = &outcome.status {
227 issues.push(DoctorIssue::error(
228 "PRIKK-DOCTOR-VERIFY-OBJECT-INCOMPLETE",
229 format!(
230 "object {} ({}) failed verification: {message}",
231 outcome.path.display(),
232 outcome.object_type
233 ),
234 "preserve the repository and inspect the failing object before attempting repair",
235 ));
236 }
237 }
238 for outcome in &verification.block_state_outcomes {
239 let message = match &outcome.status {
240 BlockStateStatus::Verified => continue,
241 BlockStateStatus::Failed { message } => {
242 format!(
243 "Block {} state-root verification failed: {message}",
244 outcome.block_id
245 )
246 }
247 BlockStateStatus::NotEvaluated { blocked_by } => {
248 format!(
249 "Block {} state root could not be verified because its state-derivation \
250 parent {blocked_by} did not evaluate",
251 outcome.block_id
252 )
253 }
254 };
255 issues.push(DoctorIssue::error(
256 "PRIKK-DOCTOR-VERIFY-BLOCK-STATE-INCOMPLETE",
257 message,
258 "preserve the repository and inspect the failing block before attempting repair",
259 ));
260 }
261 for outcome in verification
265 .pointer_outcomes
266 .iter()
267 .chain(&verification.log_outcomes)
268 {
269 if let RefFileStatus::Failed { message } = &outcome.status {
270 issues.push(DoctorIssue::error(
271 "PRIKK-DOCTOR-VERIFY-REF-FILE-INCOMPLETE",
272 format!("ref file {} failed verification: {message}", outcome.path.display()),
273 "preserve the repository and inspect the failing ref file before attempting repair",
274 ));
275 }
276 }
277 for outcome in &verification.ref_item_outcomes {
278 if let RefItemStatus::Failed { message } = &outcome.status {
279 issues.push(DoctorIssue::error(
280 "PRIKK-DOCTOR-VERIFY-REF-ITEM-INCOMPLETE",
281 format!("ref {} failed verification: {message}", outcome.ref_name),
282 "preserve the repository and inspect the failing ref before attempting repair",
283 ));
284 }
285 }
286 for outcome in &verification.wal_record_outcomes {
290 if let WalRecordStatus::Failed { message } = &outcome.status {
291 issues.push(DoctorIssue::error(
292 "PRIKK-DOCTOR-VERIFY-WAL-RECORD-INCOMPLETE",
293 format!(
294 "WAL record at offset {} failed verification: {message}",
295 outcome.offset
296 ),
297 "preserve the repository and inspect the failing WAL record before attempting repair",
298 ));
299 }
300 }
301 if verification
302 .trailing_partial_wal_bytes
303 .is_some_and(|n| n != 0)
304 {
305 issues.push(DoctorIssue::warning(
306 "PRIKK-DOCTOR-WAL-TRAILING-PARTIAL",
307 format!(
308 concat!(
309 "active WAL has {} trailing byte(s) that look like an incomplete ",
310 "final record"
311 ),
312 verification.trailing_partial_wal_bytes.unwrap_or_default()
313 ),
314 "run `prikk doctor --repair-wal-tail` to truncate only the incomplete \
315 final WAL bytes",
316 ));
317 }
318 for issue in &verification.publication_trust_issues {
319 issues.push(DoctorIssue::error(
320 issue.code,
321 issue.message.clone(),
322 "configure trusted MAINTAINER keys and re-run verification; doctor will not \
323 auto-trust keys or repair signatures",
324 ));
325 }
326 for issue in &verification.signature_envelope_issues {
327 issues.push(DoctorIssue::warning(
328 issue.code,
329 format!("{}: {}", issue.source, issue.message),
330 "preserve the format-1 bytes for inspection; do not normalize or reuse the envelope for mutation",
331 ));
332 }
333 for path in &verification.object_temp_paths {
334 let name = path
335 .file_name()
336 .and_then(|value| value.to_str())
337 .unwrap_or("<non-UTF-8 object temp>");
338 issues.push(DoctorIssue::warning(
339 "PRIKK-DOCTOR-OBJECT-TEMP-DEBRIS",
340 format!("non-authoritative object publication temp remains: {name}"),
341 "preserve it for inspection; doctor does not infer ownership or remove object temps",
342 ));
343 }
344 for issue in &verification.ref_publication_issues {
345 let recommendation = match issue.code {
346 "PRIKK-VERIFY-REF-POINTER-LEADS-LOG"
347 | "PRIKK-VERIFY-REF-LEGACY-LOG-LEADS"
348 | "PRIKK-VERIFY-REF-ACTIVE-CLEANUP-PENDING" => {
349 "run signer-backed `prikk seal --allow-no-audit` for the affected ref; doctor does not sign or append"
350 }
351 "PRIKK-VERIFY-REF-POINTER-MISSING" => {
352 "preserve the repository; use signer-backed seal retry only with matching retained active state, otherwise restore from backup"
353 }
354 "PRIKK-VERIFY-REF-LEGACY-TIMESTAMP" => {
355 "treat the value as non-authoritative legacy data; do not normalize signed bytes in place"
356 }
357 "PRIKK-VERIFY-REF-DIVERGENCE" => {
358 "preserve the repository for manual recovery; signer-backed retry is not authorized without exact retained evidence"
359 }
360 _ => {
361 "preserve the candidate for inspection; doctor does not infer ownership or remove it"
362 }
363 };
364 let doctor_issue = if issue.blocking {
365 DoctorIssue::error(issue.code, issue.message.clone(), recommendation)
366 } else {
367 DoctorIssue::warning(issue.code, issue.message.clone(), recommendation)
368 };
369 issues.push(doctor_issue);
370 }
371 add_active_wal_metadata_issues(&verification, &mut issues);
372 DoctorReport {
373 verification: Some(verification),
374 issues,
375 }
376 }
377 Err(error) => {
378 issues.push(issue_for_verification_error(error));
379 DoctorReport {
380 verification: None,
381 issues,
382 }
383 }
384 }
385}
386
387pub fn repair_repository(
393 layout: &RepositoryLayout,
394 options: DoctorRepairOptions,
395) -> Result<DoctorRepairReport> {
396 layout.require_current_format()?;
397 if options.reconstruct_main_ref {
398 return Err(PrikkError::Integrity(
399 "format-1 missing-pointer doctor repair is unsupported in 0.18.0; preserve the repository for signer-backed retry or later recovery tooling"
400 .to_string(),
401 ));
402 }
403 let _active_lock = ActiveLock::acquire(layout)?;
404 crate::refs::ensure_no_incomplete_publication(layout)?;
405 let before = doctor_repository(layout);
406 if !before.is_healthy() {
407 return Err(PrikkError::Integrity(
408 "doctor repair refused because repository verification has errors".to_string(),
409 ));
410 }
411 let wal_repair = if options.truncate_wal_tail {
412 let wal = Wal::for_layout(layout);
413 wal.truncate_trailing_partial()?
414 } else {
415 WalRepair {
416 preserved_records: 0,
417 truncated_bytes: 0,
418 preserved_patch_ids: Vec::new(),
419 }
420 };
421 let after = doctor_repository(layout);
422 Ok(DoctorRepairReport {
423 before,
424 wal_repair,
425 after,
426 })
427}
428
429fn add_active_wal_metadata_issues(
430 verification: &RepositoryVerification,
431 issues: &mut Vec<DoctorIssue>,
432) {
433 let Some(status) = &verification.active_wal_metadata_status else {
436 return;
437 };
438 match status {
439 ActiveWalMetadataStatus::MissingForNonEmptyWal => issues.push(DoctorIssue::error(
440 "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MISSING",
441 "active WAL has records but active ref metadata is missing",
442 "preserve the repository and inspect the active WAL before sealing or appending",
443 )),
444 ActiveWalMetadataStatus::InvalidForNonEmptyWal { reason } => {
445 issues.push(DoctorIssue::error(
446 "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MALFORMED",
447 format!("active WAL has records but active ref metadata is malformed: {reason}"),
448 "preserve the repository and inspect the active WAL before sealing or appending",
449 ));
450 }
451 ActiveWalMetadataStatus::ValidForEmptyWal { ref_name } => issues.push(
452 DoctorIssue::warning(
453 "PRIKK-DOCTOR-ACTIVE-REF-METADATA-DEBRIS",
454 format!("active WAL is empty but stale ref metadata remains for {ref_name}"),
455 "no repair is required; the next guarded active-WAL append will replace stale metadata",
456 ),
457 ),
458 ActiveWalMetadataStatus::InvalidForEmptyWal { reason } => issues.push(
459 DoctorIssue::warning(
460 "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MALFORMED-DEBRIS",
461 format!("active WAL is empty but malformed ref metadata remains: {reason}"),
462 "no repair is required; the next guarded active-WAL append will replace stale metadata",
463 ),
464 ),
465 ActiveWalMetadataStatus::MissingForEmptyWal
466 | ActiveWalMetadataStatus::ValidForNonEmptyWal { .. } => {}
467 }
468}
469
470fn issue_for_verification_error(error: PrikkError) -> DoctorIssue {
471 DoctorIssue::error(
472 "PRIKK-DOCTOR-VERIFY-ERROR",
473 format!("repository verification failed: {error}"),
474 "do not run seal or publish operations; preserve the repository and inspect the \
475 failing path before attempting repair",
476 )
477}
478
479#[cfg(test)]
480mod tests;