Skip to main content

delta_funnel/
report.rs

1//! Shared report primitives for query-load readiness.
2//!
3//! This module owns the vocabulary that later dry-run, execution, validation,
4//! timing, tracing, Rust, and Python report slices reuse. The types are kept
5//! serializable-friendly by exposing stable string codes and primitive values,
6//! with JSON adapters isolated in the report JSON module.
7
8use std::{
9    fmt,
10    time::{Duration, Instant},
11};
12
13use crate::error::DeltaFunnelError;
14
15pub mod delta;
16mod json;
17pub mod sql_server;
18
19pub use delta::{
20    DeltaProtocolReport, DeltaProviderSchedulingReport, DeltaSourceReport, SourceUsageStatus,
21};
22pub use sql_server::{
23    MssqlDryRunOutputFieldReport, MssqlDryRunOutputReport, MssqlDryRunSqlIdentityReport,
24    MssqlDryRunSqlIdentityState, MssqlDryRunWorkflowReport, WriteAllCacheAliasReport,
25    WriteAllCacheAliasStatus, WriteAllCacheCandidateSkip, WriteAllCacheCandidateSkipReason,
26    WriteAllCacheReport, WriteAllNoCacheReason, WriteAllReport,
27};
28
29/// Saturates a platform-sized count into the public `u64` report shape.
30#[must_use]
31pub const fn usize_to_u64_saturating(value: usize) -> u64 {
32    if size_of::<usize>() > size_of::<u64>() && value > u64::MAX as usize {
33        u64::MAX
34    } else {
35        value as u64
36    }
37}
38
39/// Saturates a wide count into the public `u64` report shape.
40#[must_use]
41pub const fn u128_to_u64_saturating(value: u128) -> u64 {
42    if value > u64::MAX as u128 {
43        u64::MAX
44    } else {
45        value as u64
46    }
47}
48
49/// Saturates a [`Duration`] into the public microsecond timing report shape.
50#[must_use]
51pub fn duration_to_micros_saturating(duration: Duration) -> u64 {
52    u128_to_u64_saturating(duration.as_micros())
53}
54
55/// Controls whether target-side validation should run when a workflow supports it.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub enum TargetValidationMode {
58    /// Do not run target-side validation.
59    Disabled,
60    /// Run target-side validation when the selected workflow can do so.
61    #[default]
62    ValidateIfPossible,
63    /// Require target-side validation and fail when it cannot be completed.
64    Require,
65}
66
67impl TargetValidationMode {
68    /// Returns a stable lower-snake-case code for report serialization.
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::Disabled => "disabled",
73            Self::ValidateIfPossible => "validate_if_possible",
74            Self::Require => "require",
75        }
76    }
77}
78
79impl fmt::Display for TargetValidationMode {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        formatter.write_str(self.as_str())
82    }
83}
84
85/// Controls how much source scan metadata a dry-run report should collect.
86///
87/// This only affects dry-run source reporting. It does not allow row
88/// production, SQL Server lifecycle work, bulk writer construction, or writes.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
90pub enum DryRunScanSummaryMode {
91    /// Use source metadata already available from normal dry-run output
92    /// planning.
93    ///
94    /// This is the cheaper default. It avoids DataFusion physical scan
95    /// planning and reports source file counts as unavailable for cost
96    /// avoidance.
97    #[default]
98    MetadataOnly,
99    /// Run DataFusion physical scan planning to collect provider scan stats.
100    ///
101    /// This may perform extra table metadata work so dry-run reports can expose
102    /// provider stats and exact or estimated source file counts. It still stops
103    /// before producing rows.
104    ExhaustScanMetadata,
105}
106
107impl DryRunScanSummaryMode {
108    /// Returns a stable lower-snake-case code for report serialization.
109    #[must_use]
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Self::MetadataOnly => "metadata_only",
113            Self::ExhaustScanMetadata => "exhaust_scan_metadata",
114        }
115    }
116}
117
118impl fmt::Display for DryRunScanSummaryMode {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        formatter.write_str(self.as_str())
121    }
122}
123
124/// Classification for a reported row count.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum RowCountKind {
127    /// The count is exact for the reported scope.
128    Exact,
129    /// The count is an estimate from metadata, planning, or another non-exact source.
130    Estimated,
131    /// The count is an observed partial total and must not be treated as exact.
132    Partial,
133    /// No row count is available.
134    Unavailable,
135}
136
137impl RowCountKind {
138    /// Returns a stable lower-snake-case code for report serialization.
139    #[must_use]
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Exact => "exact",
143            Self::Estimated => "estimated",
144            Self::Partial => "partial",
145            Self::Unavailable => "unavailable",
146        }
147    }
148}
149
150impl fmt::Display for RowCountKind {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        formatter.write_str(self.as_str())
153    }
154}
155
156/// Row count evidence for a report field.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum RowCount {
159    /// An exact row count for the reported scope.
160    Exact(u64),
161    /// A row count estimate from metadata, planning, or another non-exact source.
162    Estimated(u64),
163    /// A partial observed count. This is not proof of the final total.
164    Partial(u64),
165    /// No row count is available.
166    Unavailable,
167}
168
169impl RowCount {
170    /// Creates an exact row count.
171    #[must_use]
172    pub const fn exact(value: u64) -> Self {
173        Self::Exact(value)
174    }
175
176    /// Creates an exact row count from a platform-sized value, saturating at `u64::MAX`.
177    #[must_use]
178    pub const fn exact_from_usize(value: usize) -> Self {
179        Self::Exact(usize_to_u64_saturating(value))
180    }
181
182    /// Creates an exact row count from a wide value, saturating at `u64::MAX`.
183    #[must_use]
184    pub const fn exact_from_u128(value: u128) -> Self {
185        Self::Exact(u128_to_u64_saturating(value))
186    }
187
188    /// Creates an estimated row count.
189    #[must_use]
190    pub const fn estimated(value: u64) -> Self {
191        Self::Estimated(value)
192    }
193
194    /// Creates an estimated row count from a platform-sized value, saturating at `u64::MAX`.
195    #[must_use]
196    pub const fn estimated_from_usize(value: usize) -> Self {
197        Self::Estimated(usize_to_u64_saturating(value))
198    }
199
200    /// Creates an estimated row count from a wide value, saturating at `u64::MAX`.
201    #[must_use]
202    pub const fn estimated_from_u128(value: u128) -> Self {
203        Self::Estimated(u128_to_u64_saturating(value))
204    }
205
206    /// Creates a partial observed row count.
207    #[must_use]
208    pub const fn partial(value: u64) -> Self {
209        Self::Partial(value)
210    }
211
212    /// Creates a partial observed row count from a platform-sized value, saturating at `u64::MAX`.
213    #[must_use]
214    pub const fn partial_from_usize(value: usize) -> Self {
215        Self::Partial(usize_to_u64_saturating(value))
216    }
217
218    /// Creates a partial observed row count from a wide value, saturating at `u64::MAX`.
219    #[must_use]
220    pub const fn partial_from_u128(value: u128) -> Self {
221        Self::Partial(u128_to_u64_saturating(value))
222    }
223
224    /// Creates an unavailable row count.
225    #[must_use]
226    pub const fn unavailable() -> Self {
227        Self::Unavailable
228    }
229
230    /// Returns the count classification.
231    #[must_use]
232    pub const fn kind(&self) -> RowCountKind {
233        match self {
234            Self::Exact(_) => RowCountKind::Exact,
235            Self::Estimated(_) => RowCountKind::Estimated,
236            Self::Partial(_) => RowCountKind::Partial,
237            Self::Unavailable => RowCountKind::Unavailable,
238        }
239    }
240
241    /// Returns the numeric count when the report carries one.
242    #[must_use]
243    pub const fn value(&self) -> Option<u64> {
244        match self {
245            Self::Exact(value) | Self::Estimated(value) | Self::Partial(value) => Some(*value),
246            Self::Unavailable => None,
247        }
248    }
249
250    /// Returns the exact count, if this value proves one.
251    #[must_use]
252    pub const fn exact_value(&self) -> Option<u64> {
253        match self {
254            Self::Exact(value) => Some(*value),
255            Self::Estimated(_) | Self::Partial(_) | Self::Unavailable => None,
256        }
257    }
258
259    /// Returns the estimated count, if this value carries an estimate.
260    #[must_use]
261    pub const fn estimated_value(&self) -> Option<u64> {
262        match self {
263            Self::Estimated(value) => Some(*value),
264            Self::Exact(_) | Self::Partial(_) | Self::Unavailable => None,
265        }
266    }
267
268    /// Returns the partial count, if this value carries a partial observation.
269    #[must_use]
270    pub const fn partial_value(&self) -> Option<u64> {
271        match self {
272            Self::Partial(value) => Some(*value),
273            Self::Exact(_) | Self::Estimated(_) | Self::Unavailable => None,
274        }
275    }
276
277    /// Returns whether the count is exact.
278    #[must_use]
279    pub const fn is_exact(&self) -> bool {
280        matches!(self, Self::Exact(_))
281    }
282
283    /// Returns whether the count is estimated.
284    #[must_use]
285    pub const fn is_estimated(&self) -> bool {
286        matches!(self, Self::Estimated(_))
287    }
288
289    /// Returns whether the count is partial.
290    #[must_use]
291    pub const fn is_partial(&self) -> bool {
292        matches!(self, Self::Partial(_))
293    }
294
295    /// Returns whether no row count is available.
296    #[must_use]
297    pub const fn is_unavailable(&self) -> bool {
298        matches!(self, Self::Unavailable)
299    }
300}
301
302impl fmt::Display for RowCount {
303    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self.value() {
305            Some(value) => write!(formatter, "{}:{value}", self.kind()),
306            None => formatter.write_str(self.kind().as_str()),
307        }
308    }
309}
310
311/// Classification for a reported file count.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum FileCountKind {
314    /// The count is exact for the reported scope.
315    Exact,
316    /// The count is an estimate from metadata, planning, or another non-exact source.
317    Estimated,
318    /// No file count is available.
319    Unavailable,
320    /// File counting was intentionally skipped.
321    Skipped,
322    /// The workflow step that would count files did not execute.
323    NotExecuted,
324}
325
326impl FileCountKind {
327    /// Returns a stable lower-snake-case code for report serialization.
328    #[must_use]
329    pub const fn as_str(self) -> &'static str {
330        match self {
331            Self::Exact => "exact",
332            Self::Estimated => "estimated",
333            Self::Unavailable => "unavailable",
334            Self::Skipped => "skipped",
335            Self::NotExecuted => "not_executed",
336        }
337    }
338}
339
340impl fmt::Display for FileCountKind {
341    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
342        formatter.write_str(self.as_str())
343    }
344}
345
346/// File count evidence for a report field.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum FileCount {
349    /// An exact file count for the reported scope.
350    Exact(u64),
351    /// A file count estimate from metadata, planning, or another non-exact source.
352    Estimated(u64),
353    /// No file count is available.
354    Unavailable,
355    /// File counting was intentionally skipped.
356    Skipped,
357    /// The workflow step that would count files did not execute.
358    NotExecuted,
359}
360
361impl FileCount {
362    /// Creates an exact file count.
363    #[must_use]
364    pub const fn exact(value: u64) -> Self {
365        Self::Exact(value)
366    }
367
368    /// Creates an exact file count from a platform-sized value, saturating at `u64::MAX`.
369    #[must_use]
370    pub const fn exact_from_usize(value: usize) -> Self {
371        Self::Exact(usize_to_u64_saturating(value))
372    }
373
374    /// Creates an exact file count from a wide value, saturating at `u64::MAX`.
375    #[must_use]
376    pub const fn exact_from_u128(value: u128) -> Self {
377        Self::Exact(u128_to_u64_saturating(value))
378    }
379
380    /// Creates an estimated file count.
381    #[must_use]
382    pub const fn estimated(value: u64) -> Self {
383        Self::Estimated(value)
384    }
385
386    /// Creates an estimated file count from a platform-sized value, saturating at `u64::MAX`.
387    #[must_use]
388    pub const fn estimated_from_usize(value: usize) -> Self {
389        Self::Estimated(usize_to_u64_saturating(value))
390    }
391
392    /// Creates an estimated file count from a wide value, saturating at `u64::MAX`.
393    #[must_use]
394    pub const fn estimated_from_u128(value: u128) -> Self {
395        Self::Estimated(u128_to_u64_saturating(value))
396    }
397
398    /// Creates an unavailable file count.
399    #[must_use]
400    pub const fn unavailable() -> Self {
401        Self::Unavailable
402    }
403
404    /// Creates a skipped file count.
405    #[must_use]
406    pub const fn skipped() -> Self {
407        Self::Skipped
408    }
409
410    /// Creates a not-executed file count.
411    #[must_use]
412    pub const fn not_executed() -> Self {
413        Self::NotExecuted
414    }
415
416    /// Returns the count classification.
417    #[must_use]
418    pub const fn kind(&self) -> FileCountKind {
419        match self {
420            Self::Exact(_) => FileCountKind::Exact,
421            Self::Estimated(_) => FileCountKind::Estimated,
422            Self::Unavailable => FileCountKind::Unavailable,
423            Self::Skipped => FileCountKind::Skipped,
424            Self::NotExecuted => FileCountKind::NotExecuted,
425        }
426    }
427
428    /// Returns the numeric count when the report carries one.
429    #[must_use]
430    pub const fn value(&self) -> Option<u64> {
431        match self {
432            Self::Exact(value) | Self::Estimated(value) => Some(*value),
433            Self::Unavailable | Self::Skipped | Self::NotExecuted => None,
434        }
435    }
436
437    /// Returns the exact count, if this value proves one.
438    #[must_use]
439    pub const fn exact_value(&self) -> Option<u64> {
440        match self {
441            Self::Exact(value) => Some(*value),
442            Self::Estimated(_) | Self::Unavailable | Self::Skipped | Self::NotExecuted => None,
443        }
444    }
445
446    /// Returns the estimated count, if this value carries an estimate.
447    #[must_use]
448    pub const fn estimated_value(&self) -> Option<u64> {
449        match self {
450            Self::Estimated(value) => Some(*value),
451            Self::Exact(_) | Self::Unavailable | Self::Skipped | Self::NotExecuted => None,
452        }
453    }
454
455    /// Returns whether the count is exact.
456    #[must_use]
457    pub const fn is_exact(&self) -> bool {
458        matches!(self, Self::Exact(_))
459    }
460
461    /// Returns whether the count is estimated.
462    #[must_use]
463    pub const fn is_estimated(&self) -> bool {
464        matches!(self, Self::Estimated(_))
465    }
466
467    /// Returns whether no file count is available.
468    #[must_use]
469    pub const fn is_unavailable(&self) -> bool {
470        matches!(self, Self::Unavailable)
471    }
472
473    /// Returns whether file counting was intentionally skipped.
474    #[must_use]
475    pub const fn is_skipped(&self) -> bool {
476        matches!(self, Self::Skipped)
477    }
478
479    /// Returns whether the workflow step that would count files did not execute.
480    #[must_use]
481    pub const fn is_not_executed(&self) -> bool {
482        matches!(self, Self::NotExecuted)
483    }
484}
485
486impl fmt::Display for FileCount {
487    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
488        match self.value() {
489            Some(value) => write!(formatter, "{}:{value}", self.kind()),
490            None => formatter.write_str(self.kind().as_str()),
491        }
492    }
493}
494
495/// Stable reason codes for skipped, unavailable, and not-executed report states.
496///
497/// These codes are intentionally reusable across validation, source summary,
498/// output execution, and workflow phases so later reports do not need parallel
499/// reason vocabularies for the same state.
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum ReportReasonCode {
502    /// Validation was disabled by caller configuration.
503    ValidationDisabled,
504    /// The workflow ran in dry-run mode.
505    DryRun,
506    /// A required system, provider, or output capability was unavailable.
507    CapabilityUnavailable,
508    /// A required permission was unavailable.
509    PermissionUnavailable,
510    /// A prior failure made this report state unreachable.
511    PriorFailure,
512    /// The requested load mode does not support this report state.
513    UnsupportedLoadMode,
514    /// The target could not be accessed for this report state.
515    MissingTargetAccess,
516    /// Exact output row evidence was required but not available.
517    MissingExactOutputRows,
518    /// Work was skipped to avoid expensive or invasive reads.
519    CostAvoidance,
520    /// The workflow step was not executed.
521    NotExecuted,
522    /// The workflow failed before validation could run.
523    FailureBeforeValidation,
524}
525
526impl ReportReasonCode {
527    /// Returns a stable lower-snake-case code for report serialization.
528    #[must_use]
529    pub const fn as_str(self) -> &'static str {
530        match self {
531            Self::ValidationDisabled => "validation_disabled",
532            Self::DryRun => "dry_run",
533            Self::CapabilityUnavailable => "capability_unavailable",
534            Self::PermissionUnavailable => "permission_unavailable",
535            Self::PriorFailure => "prior_failure",
536            Self::UnsupportedLoadMode => "unsupported_load_mode",
537            Self::MissingTargetAccess => "missing_target_access",
538            Self::MissingExactOutputRows => "missing_exact_output_rows",
539            Self::CostAvoidance => "cost_avoidance",
540            Self::NotExecuted => "not_executed",
541            Self::FailureBeforeValidation => "failure_before_validation",
542        }
543    }
544}
545
546impl fmt::Display for ReportReasonCode {
547    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548        formatter.write_str(self.as_str())
549    }
550}
551
552/// Classification for target-side validation status.
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum ValidationStatusKind {
555    /// Validation was disabled by caller configuration.
556    Disabled,
557    /// Validation ran and passed.
558    Passed,
559    /// Validation ran and failed.
560    Failed,
561    /// Validation did not run because it was intentionally skipped.
562    Skipped,
563    /// Validation could not run because required evidence was unavailable.
564    Unavailable,
565    /// Validation was required and could not pass.
566    RequiredButFailed,
567}
568
569impl ValidationStatusKind {
570    /// Returns a stable lower-snake-case code for report serialization.
571    #[must_use]
572    pub const fn as_str(self) -> &'static str {
573        match self {
574            Self::Disabled => "disabled",
575            Self::Passed => "passed",
576            Self::Failed => "failed",
577            Self::Skipped => "skipped",
578            Self::Unavailable => "unavailable",
579            Self::RequiredButFailed => "required_but_failed",
580        }
581    }
582}
583
584impl fmt::Display for ValidationStatusKind {
585    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
586        formatter.write_str(self.as_str())
587    }
588}
589
590/// Target-side validation status for a report scope.
591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum ValidationStatus {
593    /// Validation was disabled by caller configuration.
594    Disabled {
595        /// Stable reason code explaining why validation is disabled.
596        reason: ReportReasonCode,
597    },
598    /// Validation ran and passed.
599    Passed,
600    /// Validation ran and failed.
601    Failed,
602    /// Validation did not run because it was intentionally skipped.
603    Skipped {
604        /// Stable reason code explaining why validation was skipped.
605        reason: ReportReasonCode,
606    },
607    /// Validation could not run because required evidence was unavailable.
608    Unavailable {
609        /// Stable reason code explaining why validation was unavailable.
610        reason: ReportReasonCode,
611    },
612    /// Validation was required and could not pass.
613    RequiredButFailed {
614        /// Stable reason code explaining why required validation failed.
615        reason: ReportReasonCode,
616    },
617}
618
619impl ValidationStatus {
620    /// Creates a disabled validation status.
621    #[must_use]
622    pub const fn disabled() -> Self {
623        Self::Disabled {
624            reason: ReportReasonCode::ValidationDisabled,
625        }
626    }
627
628    /// Creates a passed validation status.
629    #[must_use]
630    pub const fn passed() -> Self {
631        Self::Passed
632    }
633
634    /// Creates a failed validation status.
635    #[must_use]
636    pub const fn failed() -> Self {
637        Self::Failed
638    }
639
640    /// Creates a skipped validation status with a stable reason code.
641    #[must_use]
642    pub const fn skipped(reason: ReportReasonCode) -> Self {
643        Self::Skipped { reason }
644    }
645
646    /// Creates an unavailable validation status with a stable reason code.
647    #[must_use]
648    pub const fn unavailable(reason: ReportReasonCode) -> Self {
649        Self::Unavailable { reason }
650    }
651
652    /// Creates a required-but-failed validation status with a stable reason code.
653    #[must_use]
654    pub const fn required_but_failed(reason: ReportReasonCode) -> Self {
655        Self::RequiredButFailed { reason }
656    }
657
658    /// Returns the validation status classification.
659    #[must_use]
660    pub const fn kind(&self) -> ValidationStatusKind {
661        match self {
662            Self::Disabled { .. } => ValidationStatusKind::Disabled,
663            Self::Passed => ValidationStatusKind::Passed,
664            Self::Failed => ValidationStatusKind::Failed,
665            Self::Skipped { .. } => ValidationStatusKind::Skipped,
666            Self::Unavailable { .. } => ValidationStatusKind::Unavailable,
667            Self::RequiredButFailed { .. } => ValidationStatusKind::RequiredButFailed,
668        }
669    }
670
671    /// Returns the stable reason code when this status carries one.
672    #[must_use]
673    pub const fn reason(&self) -> Option<ReportReasonCode> {
674        match self {
675            Self::Disabled { reason }
676            | Self::Skipped { reason }
677            | Self::Unavailable { reason }
678            | Self::RequiredButFailed { reason } => Some(*reason),
679            Self::Passed | Self::Failed => None,
680        }
681    }
682
683    /// Returns whether validation was disabled.
684    #[must_use]
685    pub const fn is_disabled(&self) -> bool {
686        matches!(self, Self::Disabled { .. })
687    }
688
689    /// Returns whether validation passed.
690    #[must_use]
691    pub const fn is_passed(&self) -> bool {
692        matches!(self, Self::Passed)
693    }
694
695    /// Returns whether validation failed after running.
696    #[must_use]
697    pub const fn is_failed(&self) -> bool {
698        matches!(self, Self::Failed)
699    }
700
701    /// Returns whether validation was skipped.
702    #[must_use]
703    pub const fn is_skipped(&self) -> bool {
704        matches!(self, Self::Skipped { .. })
705    }
706
707    /// Returns whether validation was unavailable.
708    #[must_use]
709    pub const fn is_unavailable(&self) -> bool {
710        matches!(self, Self::Unavailable { .. })
711    }
712
713    /// Returns whether required validation failed.
714    #[must_use]
715    pub const fn is_required_but_failed(&self) -> bool {
716        matches!(self, Self::RequiredButFailed { .. })
717    }
718
719    /// Returns whether this status represents successful validation.
720    #[must_use]
721    pub const fn is_success(&self) -> bool {
722        matches!(self, Self::Passed)
723    }
724}
725
726impl fmt::Display for ValidationStatus {
727    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
728        match self.reason() {
729            Some(reason) => write!(formatter, "{}:{reason}", self.kind()),
730            None => formatter.write_str(self.kind().as_str()),
731        }
732    }
733}
734
735/// Classification for a workflow phase status.
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum PhaseStatusKind {
738    /// The phase completed successfully.
739    Completed,
740    /// The phase failed.
741    Failed,
742    /// The phase was intentionally skipped.
743    Skipped,
744    /// The phase had not started.
745    NotStarted,
746    /// The phase status was unavailable.
747    Unavailable,
748}
749
750impl PhaseStatusKind {
751    /// Returns a stable lower-snake-case code for report serialization.
752    #[must_use]
753    pub const fn as_str(self) -> &'static str {
754        match self {
755            Self::Completed => "completed",
756            Self::Failed => "failed",
757            Self::Skipped => "skipped",
758            Self::NotStarted => "not_started",
759            Self::Unavailable => "unavailable",
760        }
761    }
762}
763
764impl fmt::Display for PhaseStatusKind {
765    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
766        formatter.write_str(self.as_str())
767    }
768}
769
770/// Status for a workflow phase such as planning, loading, writing, or validation.
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub enum PhaseStatus {
773    /// The phase completed successfully.
774    Completed,
775    /// The phase failed.
776    Failed,
777    /// The phase was intentionally skipped.
778    Skipped {
779        /// Stable reason code explaining why the phase was skipped.
780        reason: ReportReasonCode,
781    },
782    /// The phase had not started.
783    NotStarted {
784        /// Stable reason code explaining why the phase did not start.
785        reason: ReportReasonCode,
786    },
787    /// The phase status was unavailable.
788    Unavailable {
789        /// Stable reason code explaining why the phase status was unavailable.
790        reason: ReportReasonCode,
791    },
792}
793
794impl PhaseStatus {
795    /// Creates a completed phase status.
796    #[must_use]
797    pub const fn completed() -> Self {
798        Self::Completed
799    }
800
801    /// Creates a failed phase status.
802    #[must_use]
803    pub const fn failed() -> Self {
804        Self::Failed
805    }
806
807    /// Creates a skipped phase status with a stable reason code.
808    #[must_use]
809    pub const fn skipped(reason: ReportReasonCode) -> Self {
810        Self::Skipped { reason }
811    }
812
813    /// Creates a not-started phase status with a stable reason code.
814    #[must_use]
815    pub const fn not_started(reason: ReportReasonCode) -> Self {
816        Self::NotStarted { reason }
817    }
818
819    /// Creates an unavailable phase status with a stable reason code.
820    #[must_use]
821    pub const fn unavailable(reason: ReportReasonCode) -> Self {
822        Self::Unavailable { reason }
823    }
824
825    /// Returns the phase status classification.
826    #[must_use]
827    pub const fn kind(&self) -> PhaseStatusKind {
828        match self {
829            Self::Completed => PhaseStatusKind::Completed,
830            Self::Failed => PhaseStatusKind::Failed,
831            Self::Skipped { .. } => PhaseStatusKind::Skipped,
832            Self::NotStarted { .. } => PhaseStatusKind::NotStarted,
833            Self::Unavailable { .. } => PhaseStatusKind::Unavailable,
834        }
835    }
836
837    /// Returns the stable reason code when this status carries one.
838    #[must_use]
839    pub const fn reason(&self) -> Option<ReportReasonCode> {
840        match self {
841            Self::Skipped { reason }
842            | Self::NotStarted { reason }
843            | Self::Unavailable { reason } => Some(*reason),
844            Self::Completed | Self::Failed => None,
845        }
846    }
847
848    /// Returns whether the phase completed successfully.
849    #[must_use]
850    pub const fn is_completed(&self) -> bool {
851        matches!(self, Self::Completed)
852    }
853
854    /// Returns whether the phase failed.
855    #[must_use]
856    pub const fn is_failed(&self) -> bool {
857        matches!(self, Self::Failed)
858    }
859
860    /// Returns whether the phase was skipped.
861    #[must_use]
862    pub const fn is_skipped(&self) -> bool {
863        matches!(self, Self::Skipped { .. })
864    }
865
866    /// Returns whether the phase had not started.
867    #[must_use]
868    pub const fn is_not_started(&self) -> bool {
869        matches!(self, Self::NotStarted { .. })
870    }
871
872    /// Returns whether the phase status was unavailable.
873    #[must_use]
874    pub const fn is_unavailable(&self) -> bool {
875        matches!(self, Self::Unavailable { .. })
876    }
877}
878
879impl fmt::Display for PhaseStatus {
880    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
881        match self.reason() {
882            Some(reason) => write!(formatter, "{}:{reason}", self.kind()),
883            None => formatter.write_str(self.kind().as_str()),
884        }
885    }
886}
887
888/// Timing evidence for one stable workflow phase.
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct PhaseTimingReport {
891    phase_name: String,
892    status: PhaseStatus,
893    elapsed_micros: Option<u64>,
894}
895
896impl PhaseTimingReport {
897    /// Creates a completed phase timing report.
898    #[must_use]
899    pub fn completed(phase_name: impl Into<String>, elapsed: Duration) -> Self {
900        Self {
901            phase_name: phase_name.into(),
902            status: PhaseStatus::completed(),
903            elapsed_micros: Some(duration_to_micros_saturating(elapsed)),
904        }
905    }
906
907    /// Creates a failed phase timing report with elapsed time through failure.
908    #[must_use]
909    pub fn failed(phase_name: impl Into<String>, elapsed: Duration) -> Self {
910        Self {
911            phase_name: phase_name.into(),
912            status: PhaseStatus::failed(),
913            elapsed_micros: Some(duration_to_micros_saturating(elapsed)),
914        }
915    }
916
917    /// Creates a skipped phase timing report.
918    #[must_use]
919    pub fn skipped(phase_name: impl Into<String>, reason: ReportReasonCode) -> Self {
920        Self {
921            phase_name: phase_name.into(),
922            status: PhaseStatus::skipped(reason),
923            elapsed_micros: None,
924        }
925    }
926
927    /// Creates a not-started phase timing report.
928    #[must_use]
929    pub fn not_started(phase_name: impl Into<String>, reason: ReportReasonCode) -> Self {
930        Self {
931            phase_name: phase_name.into(),
932            status: PhaseStatus::not_started(reason),
933            elapsed_micros: None,
934        }
935    }
936
937    /// Creates an unavailable phase timing report.
938    #[must_use]
939    pub fn unavailable(phase_name: impl Into<String>, reason: ReportReasonCode) -> Self {
940        Self {
941            phase_name: phase_name.into(),
942            status: PhaseStatus::unavailable(reason),
943            elapsed_micros: None,
944        }
945    }
946
947    /// Returns the stable phase name.
948    #[must_use]
949    pub fn phase_name(&self) -> &str {
950        &self.phase_name
951    }
952
953    /// Returns the phase timing status.
954    #[must_use]
955    pub const fn status(&self) -> PhaseStatus {
956        self.status
957    }
958
959    /// Returns elapsed phase time in microseconds when measured.
960    #[must_use]
961    pub const fn elapsed_micros(&self) -> Option<u64> {
962        self.elapsed_micros
963    }
964}
965
966/// Monotonic timer used to build durable phase timing reports.
967#[derive(Debug)]
968pub(crate) struct PhaseTimer {
969    phase_name: &'static str,
970    started_at: Instant,
971}
972
973impl PhaseTimer {
974    pub(crate) fn start(phase_name: &'static str) -> Self {
975        Self {
976            phase_name,
977            started_at: Instant::now(),
978        }
979    }
980
981    pub(crate) fn completed(self) -> PhaseTimingReport {
982        PhaseTimingReport::completed(self.phase_name, self.started_at.elapsed())
983    }
984
985    pub(crate) fn failed(self) -> PhaseTimingReport {
986        PhaseTimingReport::failed(self.phase_name, self.started_at.elapsed())
987    }
988}
989
990/// Classification for an output-level workflow status.
991#[derive(Debug, Clone, Copy, PartialEq, Eq)]
992pub enum OutputStatusKind {
993    /// The output was planned but not executed yet.
994    Planned,
995    /// The output completed successfully.
996    Succeeded,
997    /// The output failed during planning, execution, or reporting.
998    Failed,
999    /// The output was intentionally skipped.
1000    Skipped,
1001    /// The output was planned as part of a dry run.
1002    DryRunPlanned,
1003    /// The output failed validation.
1004    ValidationFailed,
1005}
1006
1007impl OutputStatusKind {
1008    /// Returns a stable lower-snake-case code for report serialization.
1009    #[must_use]
1010    pub const fn as_str(self) -> &'static str {
1011        match self {
1012            Self::Planned => "planned",
1013            Self::Succeeded => "succeeded",
1014            Self::Failed => "failed",
1015            Self::Skipped => "skipped",
1016            Self::DryRunPlanned => "dry_run_planned",
1017            Self::ValidationFailed => "validation_failed",
1018        }
1019    }
1020}
1021
1022impl fmt::Display for OutputStatusKind {
1023    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1024        formatter.write_str(self.as_str())
1025    }
1026}
1027
1028/// Output-level workflow status.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum OutputStatus {
1031    /// The output was planned but not executed yet.
1032    Planned,
1033    /// The output completed successfully.
1034    Succeeded,
1035    /// The output failed during planning, execution, or reporting.
1036    Failed,
1037    /// The output was intentionally skipped.
1038    Skipped {
1039        /// Stable reason code explaining why the output was skipped.
1040        reason: ReportReasonCode,
1041    },
1042    /// The output was planned as part of a dry run.
1043    DryRunPlanned,
1044    /// The output failed validation.
1045    ValidationFailed {
1046        /// Validation status explaining the validation failure.
1047        validation: ValidationStatus,
1048    },
1049}
1050
1051impl OutputStatus {
1052    /// Creates a planned output status.
1053    #[must_use]
1054    pub const fn planned() -> Self {
1055        Self::Planned
1056    }
1057
1058    /// Creates a succeeded output status.
1059    #[must_use]
1060    pub const fn succeeded() -> Self {
1061        Self::Succeeded
1062    }
1063
1064    /// Creates a failed output status.
1065    #[must_use]
1066    pub const fn failed() -> Self {
1067        Self::Failed
1068    }
1069
1070    /// Creates a skipped output status with a stable reason code.
1071    #[must_use]
1072    pub const fn skipped(reason: ReportReasonCode) -> Self {
1073        Self::Skipped { reason }
1074    }
1075
1076    /// Creates a dry-run-planned output status.
1077    #[must_use]
1078    pub const fn dry_run_planned() -> Self {
1079        Self::DryRunPlanned
1080    }
1081
1082    /// Creates a validation-failed output status.
1083    #[must_use]
1084    pub const fn validation_failed(validation: ValidationStatus) -> Self {
1085        Self::ValidationFailed { validation }
1086    }
1087
1088    /// Returns the output status classification.
1089    #[must_use]
1090    pub const fn kind(&self) -> OutputStatusKind {
1091        match self {
1092            Self::Planned => OutputStatusKind::Planned,
1093            Self::Succeeded => OutputStatusKind::Succeeded,
1094            Self::Failed => OutputStatusKind::Failed,
1095            Self::Skipped { .. } => OutputStatusKind::Skipped,
1096            Self::DryRunPlanned => OutputStatusKind::DryRunPlanned,
1097            Self::ValidationFailed { .. } => OutputStatusKind::ValidationFailed,
1098        }
1099    }
1100
1101    /// Returns the stable reason code when this status carries one.
1102    #[must_use]
1103    pub const fn reason(&self) -> Option<ReportReasonCode> {
1104        match self {
1105            Self::Skipped { reason } => Some(*reason),
1106            Self::Planned
1107            | Self::Succeeded
1108            | Self::Failed
1109            | Self::DryRunPlanned
1110            | Self::ValidationFailed { .. } => None,
1111        }
1112    }
1113
1114    /// Returns the validation status when this output failed validation.
1115    #[must_use]
1116    pub const fn validation(&self) -> Option<ValidationStatus> {
1117        match self {
1118            Self::ValidationFailed { validation } => Some(*validation),
1119            Self::Planned
1120            | Self::Succeeded
1121            | Self::Failed
1122            | Self::Skipped { .. }
1123            | Self::DryRunPlanned => None,
1124        }
1125    }
1126
1127    /// Returns whether the output was planned but not executed yet.
1128    #[must_use]
1129    pub const fn is_planned(&self) -> bool {
1130        matches!(self, Self::Planned)
1131    }
1132
1133    /// Returns whether the output succeeded.
1134    #[must_use]
1135    pub const fn is_succeeded(&self) -> bool {
1136        matches!(self, Self::Succeeded)
1137    }
1138
1139    /// Returns whether the output failed.
1140    #[must_use]
1141    pub const fn is_failed(&self) -> bool {
1142        matches!(self, Self::Failed | Self::ValidationFailed { .. })
1143    }
1144
1145    /// Returns whether the output was skipped.
1146    #[must_use]
1147    pub const fn is_skipped(&self) -> bool {
1148        matches!(self, Self::Skipped { .. })
1149    }
1150
1151    /// Returns whether the output was planned as part of a dry run.
1152    #[must_use]
1153    pub const fn is_dry_run_planned(&self) -> bool {
1154        matches!(self, Self::DryRunPlanned)
1155    }
1156
1157    /// Returns whether the output failed validation.
1158    #[must_use]
1159    pub const fn is_validation_failed(&self) -> bool {
1160        matches!(self, Self::ValidationFailed { .. })
1161    }
1162}
1163
1164impl fmt::Display for OutputStatus {
1165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1166        match self {
1167            Self::Skipped { reason } => write!(formatter, "{}:{reason}", self.kind()),
1168            Self::ValidationFailed { validation } => {
1169                write!(formatter, "{}:{validation}", self.kind())
1170            }
1171            Self::Planned | Self::Succeeded | Self::Failed | Self::DryRunPlanned => {
1172                formatter.write_str(self.kind().as_str())
1173            }
1174        }
1175    }
1176}
1177
1178/// Classification for a workflow-level status.
1179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1180pub enum WorkflowStatusKind {
1181    /// The workflow completed successfully.
1182    Success,
1183    /// The workflow completed with at least one successful output and at least one non-success.
1184    PartialSuccess,
1185    /// The workflow failed without a complete successful result.
1186    Failure,
1187    /// The workflow was intentionally skipped.
1188    Skipped,
1189    /// The workflow had no work to perform.
1190    NoOp,
1191}
1192
1193impl WorkflowStatusKind {
1194    /// Returns a stable lower-snake-case code for report serialization.
1195    #[must_use]
1196    pub const fn as_str(self) -> &'static str {
1197        match self {
1198            Self::Success => "success",
1199            Self::PartialSuccess => "partial_success",
1200            Self::Failure => "failure",
1201            Self::Skipped => "skipped",
1202            Self::NoOp => "no_op",
1203        }
1204    }
1205}
1206
1207impl fmt::Display for WorkflowStatusKind {
1208    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1209        formatter.write_str(self.as_str())
1210    }
1211}
1212
1213/// Workflow-level status for a report.
1214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1215pub enum WorkflowStatus {
1216    /// The workflow completed successfully.
1217    Success,
1218    /// The workflow completed with at least one successful output and at least one non-success.
1219    PartialSuccess,
1220    /// The workflow failed without a complete successful result.
1221    Failure,
1222    /// The workflow was intentionally skipped.
1223    Skipped {
1224        /// Stable reason code explaining why the workflow was skipped.
1225        reason: ReportReasonCode,
1226    },
1227    /// The workflow had no work to perform.
1228    NoOp {
1229        /// Stable reason code explaining why the workflow had no work.
1230        reason: ReportReasonCode,
1231    },
1232}
1233
1234impl WorkflowStatus {
1235    /// Creates a successful workflow status.
1236    #[must_use]
1237    pub const fn success() -> Self {
1238        Self::Success
1239    }
1240
1241    /// Creates a partial-success workflow status.
1242    #[must_use]
1243    pub const fn partial_success() -> Self {
1244        Self::PartialSuccess
1245    }
1246
1247    /// Creates a failed workflow status.
1248    #[must_use]
1249    pub const fn failure() -> Self {
1250        Self::Failure
1251    }
1252
1253    /// Creates a skipped workflow status with a stable reason code.
1254    #[must_use]
1255    pub const fn skipped(reason: ReportReasonCode) -> Self {
1256        Self::Skipped { reason }
1257    }
1258
1259    /// Creates a no-op workflow status with a stable reason code.
1260    #[must_use]
1261    pub const fn no_op(reason: ReportReasonCode) -> Self {
1262        Self::NoOp { reason }
1263    }
1264
1265    /// Returns the workflow status classification.
1266    #[must_use]
1267    pub const fn kind(&self) -> WorkflowStatusKind {
1268        match self {
1269            Self::Success => WorkflowStatusKind::Success,
1270            Self::PartialSuccess => WorkflowStatusKind::PartialSuccess,
1271            Self::Failure => WorkflowStatusKind::Failure,
1272            Self::Skipped { .. } => WorkflowStatusKind::Skipped,
1273            Self::NoOp { .. } => WorkflowStatusKind::NoOp,
1274        }
1275    }
1276
1277    /// Returns the stable reason code when this status carries one.
1278    #[must_use]
1279    pub const fn reason(&self) -> Option<ReportReasonCode> {
1280        match self {
1281            Self::Skipped { reason } | Self::NoOp { reason } => Some(*reason),
1282            Self::Success | Self::PartialSuccess | Self::Failure => None,
1283        }
1284    }
1285
1286    /// Returns whether the workflow completed successfully.
1287    #[must_use]
1288    pub const fn is_success(&self) -> bool {
1289        matches!(self, Self::Success)
1290    }
1291
1292    /// Returns whether the workflow partially succeeded.
1293    #[must_use]
1294    pub const fn is_partial_success(&self) -> bool {
1295        matches!(self, Self::PartialSuccess)
1296    }
1297
1298    /// Returns whether the workflow failed.
1299    #[must_use]
1300    pub const fn is_failure(&self) -> bool {
1301        matches!(self, Self::Failure)
1302    }
1303
1304    /// Returns whether the workflow was skipped.
1305    #[must_use]
1306    pub const fn is_skipped(&self) -> bool {
1307        matches!(self, Self::Skipped { .. })
1308    }
1309
1310    /// Returns whether the workflow had no work to perform.
1311    #[must_use]
1312    pub const fn is_no_op(&self) -> bool {
1313        matches!(self, Self::NoOp { .. })
1314    }
1315}
1316
1317impl fmt::Display for WorkflowStatus {
1318    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1319        match self.reason() {
1320            Some(reason) => write!(formatter, "{}:{reason}", self.kind()),
1321            None => formatter.write_str(self.kind().as_str()),
1322        }
1323    }
1324}
1325
1326/// Validation and scan-summary options checked before workflow side effects.
1327///
1328/// This type carries validation intent without starting validation I/O. Target
1329/// row-count validation, source scan summaries, and related reports are wired in
1330/// later issue slices through these stable options.
1331///
1332/// Defaults are intentionally conservative: validate targets when the selected
1333/// workflow supports it, use planning metadata for dry-run source summaries,
1334/// and treat local planning failures as terminal.
1335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1336pub struct ValidationOptions {
1337    /// Target-side validation behavior for workflows that can compare written
1338    /// output with target evidence.
1339    target_validation_mode: TargetValidationMode,
1340    /// Dry-run source scan summary depth. Metadata-only mode uses only normal
1341    /// dry-run planning metadata. Exhaustive mode performs extra DataFusion
1342    /// scan planning for provider stats without producing rows.
1343    dry_run_scan_summary_mode: DryRunScanSummaryMode,
1344    /// Whether local planning failures should fail the workflow instead of
1345    /// being reported as unavailable validation evidence.
1346    require_successful_planning: bool,
1347}
1348
1349impl Default for ValidationOptions {
1350    fn default() -> Self {
1351        Self::new()
1352    }
1353}
1354
1355impl ValidationOptions {
1356    /// Creates default validation options.
1357    #[must_use]
1358    pub const fn new() -> Self {
1359        Self {
1360            target_validation_mode: TargetValidationMode::ValidateIfPossible,
1361            dry_run_scan_summary_mode: DryRunScanSummaryMode::MetadataOnly,
1362            require_successful_planning: true,
1363        }
1364    }
1365
1366    /// Sets target-side validation behavior.
1367    #[must_use]
1368    pub const fn with_target_validation_mode(
1369        mut self,
1370        target_validation_mode: TargetValidationMode,
1371    ) -> Self {
1372        self.target_validation_mode = target_validation_mode;
1373        self
1374    }
1375
1376    /// Sets dry-run source scan summary behavior.
1377    #[must_use]
1378    pub const fn with_dry_run_scan_summary_mode(
1379        mut self,
1380        dry_run_scan_summary_mode: DryRunScanSummaryMode,
1381    ) -> Self {
1382        self.dry_run_scan_summary_mode = dry_run_scan_summary_mode;
1383        self
1384    }
1385
1386    /// Sets whether local planning failures should be terminal.
1387    #[must_use]
1388    pub const fn with_require_successful_planning(
1389        mut self,
1390        require_successful_planning: bool,
1391    ) -> Self {
1392        self.require_successful_planning = require_successful_planning;
1393        self
1394    }
1395
1396    /// Returns target-side validation behavior.
1397    #[must_use]
1398    pub const fn target_validation_mode(&self) -> TargetValidationMode {
1399        self.target_validation_mode
1400    }
1401
1402    /// Returns dry-run source scan summary behavior.
1403    #[must_use]
1404    pub const fn dry_run_scan_summary_mode(&self) -> DryRunScanSummaryMode {
1405        self.dry_run_scan_summary_mode
1406    }
1407
1408    /// Returns whether local planning failures should be terminal.
1409    #[must_use]
1410    pub const fn require_successful_planning(&self) -> bool {
1411        self.require_successful_planning
1412    }
1413
1414    /// Validates local validation options before workflow side effects.
1415    ///
1416    /// # Errors
1417    ///
1418    /// Currently returns `Ok(())` for all representable values. The method is
1419    /// intentionally present so later validation options can be wired through
1420    /// the same pre-side-effect path.
1421    pub const fn validate(&self) -> Result<(), DeltaFunnelError> {
1422        let _ = self.target_validation_mode;
1423        let _ = self.dry_run_scan_summary_mode;
1424        let _ = self.require_successful_planning;
1425        Ok(())
1426    }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use std::time::Duration;
1432
1433    use super::{
1434        DryRunScanSummaryMode, FileCount, FileCountKind, OutputStatus, OutputStatusKind,
1435        PhaseStatus, PhaseStatusKind, PhaseTimer, PhaseTimingReport, ReportReasonCode, RowCount,
1436        RowCountKind, TargetValidationMode, ValidationOptions, ValidationStatus,
1437        ValidationStatusKind, WorkflowStatus, WorkflowStatusKind, duration_to_micros_saturating,
1438        u128_to_u64_saturating,
1439    };
1440
1441    #[test]
1442    fn target_validation_modes_expose_stable_codes() {
1443        assert_eq!(TargetValidationMode::Disabled.as_str(), "disabled");
1444        assert_eq!(
1445            TargetValidationMode::ValidateIfPossible.as_str(),
1446            "validate_if_possible"
1447        );
1448        assert_eq!(TargetValidationMode::Require.as_str(), "require");
1449        assert_eq!(
1450            TargetValidationMode::ValidateIfPossible.to_string(),
1451            "validate_if_possible"
1452        );
1453    }
1454
1455    #[test]
1456    fn dry_run_scan_summary_modes_expose_stable_codes() {
1457        assert_eq!(
1458            DryRunScanSummaryMode::MetadataOnly.as_str(),
1459            "metadata_only"
1460        );
1461        assert_eq!(
1462            DryRunScanSummaryMode::ExhaustScanMetadata.as_str(),
1463            "exhaust_scan_metadata"
1464        );
1465        assert_eq!(
1466            DryRunScanSummaryMode::ExhaustScanMetadata.to_string(),
1467            "exhaust_scan_metadata"
1468        );
1469    }
1470
1471    #[test]
1472    fn validation_options_default_preserves_planning_intent() {
1473        let options = ValidationOptions::default();
1474
1475        assert_eq!(
1476            options.target_validation_mode(),
1477            TargetValidationMode::ValidateIfPossible
1478        );
1479        assert_eq!(
1480            options.dry_run_scan_summary_mode(),
1481            DryRunScanSummaryMode::MetadataOnly
1482        );
1483        assert!(options.require_successful_planning());
1484        assert!(options.validate().is_ok());
1485    }
1486
1487    #[test]
1488    fn validation_options_accessors_return_configured_values() {
1489        let options = ValidationOptions::new()
1490            .with_target_validation_mode(TargetValidationMode::Require)
1491            .with_dry_run_scan_summary_mode(DryRunScanSummaryMode::ExhaustScanMetadata)
1492            .with_require_successful_planning(false);
1493
1494        assert_eq!(
1495            options.target_validation_mode(),
1496            TargetValidationMode::Require
1497        );
1498        assert_eq!(
1499            options.dry_run_scan_summary_mode(),
1500            DryRunScanSummaryMode::ExhaustScanMetadata
1501        );
1502        assert!(!options.require_successful_planning());
1503    }
1504
1505    #[test]
1506    fn validation_options_debug_does_not_include_external_values() {
1507        let options = ValidationOptions::new()
1508            .with_target_validation_mode(TargetValidationMode::Disabled)
1509            .with_dry_run_scan_summary_mode(DryRunScanSummaryMode::MetadataOnly);
1510
1511        let debug = format!("{options:?}");
1512
1513        assert!(debug.contains("Disabled"));
1514        assert!(debug.contains("MetadataOnly"));
1515        assert!(!debug.contains("server="));
1516        assert!(!debug.contains("password"));
1517    }
1518
1519    #[test]
1520    fn row_count_variants_expose_kind_and_values() {
1521        let exact = RowCount::exact(10);
1522        let estimated = RowCount::estimated(20);
1523        let partial = RowCount::partial(30);
1524        let unavailable = RowCount::unavailable();
1525
1526        assert_eq!(exact.kind(), RowCountKind::Exact);
1527        assert_eq!(exact.value(), Some(10));
1528        assert_eq!(exact.exact_value(), Some(10));
1529        assert!(exact.is_exact());
1530
1531        assert_eq!(estimated.kind(), RowCountKind::Estimated);
1532        assert_eq!(estimated.value(), Some(20));
1533        assert_eq!(estimated.estimated_value(), Some(20));
1534        assert!(estimated.is_estimated());
1535
1536        assert_eq!(partial.kind(), RowCountKind::Partial);
1537        assert_eq!(partial.value(), Some(30));
1538        assert_eq!(partial.partial_value(), Some(30));
1539        assert_eq!(partial.exact_value(), None);
1540        assert!(partial.is_partial());
1541
1542        assert_eq!(unavailable.kind(), RowCountKind::Unavailable);
1543        assert_eq!(unavailable.value(), None);
1544        assert!(unavailable.is_unavailable());
1545    }
1546
1547    #[test]
1548    fn file_count_variants_expose_kind_and_values() {
1549        let exact = FileCount::exact(2);
1550        let estimated = FileCount::estimated(3);
1551        let unavailable = FileCount::unavailable();
1552        let skipped = FileCount::skipped();
1553        let not_executed = FileCount::not_executed();
1554
1555        assert_eq!(exact.kind(), FileCountKind::Exact);
1556        assert_eq!(exact.value(), Some(2));
1557        assert_eq!(exact.exact_value(), Some(2));
1558        assert!(exact.is_exact());
1559
1560        assert_eq!(estimated.kind(), FileCountKind::Estimated);
1561        assert_eq!(estimated.value(), Some(3));
1562        assert_eq!(estimated.estimated_value(), Some(3));
1563        assert!(estimated.is_estimated());
1564
1565        assert_eq!(unavailable.kind(), FileCountKind::Unavailable);
1566        assert_eq!(unavailable.value(), None);
1567        assert!(unavailable.is_unavailable());
1568
1569        assert_eq!(skipped.kind(), FileCountKind::Skipped);
1570        assert_eq!(skipped.value(), None);
1571        assert!(skipped.is_skipped());
1572
1573        assert_eq!(not_executed.kind(), FileCountKind::NotExecuted);
1574        assert_eq!(not_executed.value(), None);
1575        assert!(not_executed.is_not_executed());
1576    }
1577
1578    #[test]
1579    fn count_kinds_expose_stable_codes() {
1580        assert_eq!(RowCountKind::Exact.as_str(), "exact");
1581        assert_eq!(RowCountKind::Estimated.as_str(), "estimated");
1582        assert_eq!(RowCountKind::Partial.as_str(), "partial");
1583        assert_eq!(RowCountKind::Unavailable.as_str(), "unavailable");
1584        assert_eq!(RowCountKind::Partial.to_string(), "partial");
1585
1586        assert_eq!(FileCountKind::Exact.as_str(), "exact");
1587        assert_eq!(FileCountKind::Estimated.as_str(), "estimated");
1588        assert_eq!(FileCountKind::Unavailable.as_str(), "unavailable");
1589        assert_eq!(FileCountKind::Skipped.as_str(), "skipped");
1590        assert_eq!(FileCountKind::NotExecuted.as_str(), "not_executed");
1591        assert_eq!(FileCountKind::NotExecuted.to_string(), "not_executed");
1592    }
1593
1594    #[test]
1595    fn count_display_is_stable_and_safe() {
1596        assert_eq!(RowCount::exact(12).to_string(), "exact:12");
1597        assert_eq!(RowCount::estimated(34).to_string(), "estimated:34");
1598        assert_eq!(RowCount::partial(56).to_string(), "partial:56");
1599        assert_eq!(RowCount::unavailable().to_string(), "unavailable");
1600
1601        assert_eq!(FileCount::exact(7).to_string(), "exact:7");
1602        assert_eq!(FileCount::estimated(8).to_string(), "estimated:8");
1603        assert_eq!(FileCount::unavailable().to_string(), "unavailable");
1604        assert_eq!(FileCount::skipped().to_string(), "skipped");
1605        assert_eq!(FileCount::not_executed().to_string(), "not_executed");
1606
1607        let debug = format!("{:?} {:?}", RowCount::partial(1), FileCount::not_executed());
1608        assert!(!debug.contains("server="));
1609        assert!(!debug.contains("password"));
1610    }
1611
1612    #[test]
1613    fn count_constructors_saturate_wide_values() {
1614        assert_eq!(u128_to_u64_saturating(u128::from(u64::MAX) + 1), u64::MAX);
1615        assert_eq!(RowCount::exact_from_u128(u128::MAX).value(), Some(u64::MAX));
1616        assert_eq!(
1617            RowCount::estimated_from_u128(u128::MAX).value(),
1618            Some(u64::MAX)
1619        );
1620        assert_eq!(
1621            RowCount::partial_from_u128(u128::MAX).value(),
1622            Some(u64::MAX)
1623        );
1624        assert_eq!(
1625            FileCount::exact_from_u128(u128::MAX).value(),
1626            Some(u64::MAX)
1627        );
1628        assert_eq!(
1629            FileCount::estimated_from_u128(u128::MAX).value(),
1630            Some(u64::MAX)
1631        );
1632
1633        assert_eq!(RowCount::exact_from_usize(5).value(), Some(5));
1634        assert_eq!(RowCount::estimated_from_usize(6).value(), Some(6));
1635        assert_eq!(RowCount::partial_from_usize(7).value(), Some(7));
1636        assert_eq!(FileCount::exact_from_usize(8).value(), Some(8));
1637        assert_eq!(FileCount::estimated_from_usize(9).value(), Some(9));
1638    }
1639
1640    #[test]
1641    fn report_reason_codes_cover_stable_skip_and_unavailable_reasons() {
1642        let cases = [
1643            (ReportReasonCode::ValidationDisabled, "validation_disabled"),
1644            (ReportReasonCode::DryRun, "dry_run"),
1645            (
1646                ReportReasonCode::CapabilityUnavailable,
1647                "capability_unavailable",
1648            ),
1649            (
1650                ReportReasonCode::PermissionUnavailable,
1651                "permission_unavailable",
1652            ),
1653            (ReportReasonCode::PriorFailure, "prior_failure"),
1654            (
1655                ReportReasonCode::UnsupportedLoadMode,
1656                "unsupported_load_mode",
1657            ),
1658            (
1659                ReportReasonCode::MissingTargetAccess,
1660                "missing_target_access",
1661            ),
1662            (
1663                ReportReasonCode::MissingExactOutputRows,
1664                "missing_exact_output_rows",
1665            ),
1666            (ReportReasonCode::CostAvoidance, "cost_avoidance"),
1667            (ReportReasonCode::NotExecuted, "not_executed"),
1668            (
1669                ReportReasonCode::FailureBeforeValidation,
1670                "failure_before_validation",
1671            ),
1672        ];
1673
1674        for (reason, code) in cases {
1675            assert_eq!(reason.as_str(), code);
1676            assert_eq!(reason.to_string(), code);
1677        }
1678    }
1679
1680    #[test]
1681    fn report_reason_debug_does_not_include_external_values() {
1682        let debug = format!("{:?}", ReportReasonCode::MissingTargetAccess);
1683
1684        assert!(debug.contains("MissingTargetAccess"));
1685        assert!(!debug.contains("server="));
1686        assert!(!debug.contains("password"));
1687    }
1688
1689    #[test]
1690    fn validation_status_kinds_expose_stable_codes() {
1691        assert_eq!(ValidationStatusKind::Disabled.as_str(), "disabled");
1692        assert_eq!(ValidationStatusKind::Passed.as_str(), "passed");
1693        assert_eq!(ValidationStatusKind::Failed.as_str(), "failed");
1694        assert_eq!(ValidationStatusKind::Skipped.as_str(), "skipped");
1695        assert_eq!(ValidationStatusKind::Unavailable.as_str(), "unavailable");
1696        assert_eq!(
1697            ValidationStatusKind::RequiredButFailed.as_str(),
1698            "required_but_failed"
1699        );
1700        assert_eq!(
1701            ValidationStatusKind::RequiredButFailed.to_string(),
1702            "required_but_failed"
1703        );
1704    }
1705
1706    #[test]
1707    fn validation_status_variants_expose_kind_reasons_and_helpers() {
1708        let disabled = ValidationStatus::disabled();
1709        let passed = ValidationStatus::passed();
1710        let failed = ValidationStatus::failed();
1711        let skipped = ValidationStatus::skipped(ReportReasonCode::DryRun);
1712        let unavailable = ValidationStatus::unavailable(ReportReasonCode::MissingTargetAccess);
1713        let required =
1714            ValidationStatus::required_but_failed(ReportReasonCode::MissingExactOutputRows);
1715
1716        assert_eq!(disabled.kind(), ValidationStatusKind::Disabled);
1717        assert_eq!(
1718            disabled.reason(),
1719            Some(ReportReasonCode::ValidationDisabled)
1720        );
1721        assert!(disabled.is_disabled());
1722
1723        assert_eq!(passed.kind(), ValidationStatusKind::Passed);
1724        assert_eq!(passed.reason(), None);
1725        assert!(passed.is_passed());
1726        assert!(passed.is_success());
1727
1728        assert_eq!(failed.kind(), ValidationStatusKind::Failed);
1729        assert_eq!(failed.reason(), None);
1730        assert!(failed.is_failed());
1731        assert!(!failed.is_success());
1732
1733        assert_eq!(skipped.kind(), ValidationStatusKind::Skipped);
1734        assert_eq!(skipped.reason(), Some(ReportReasonCode::DryRun));
1735        assert!(skipped.is_skipped());
1736
1737        assert_eq!(unavailable.kind(), ValidationStatusKind::Unavailable);
1738        assert_eq!(
1739            unavailable.reason(),
1740            Some(ReportReasonCode::MissingTargetAccess)
1741        );
1742        assert!(unavailable.is_unavailable());
1743
1744        assert_eq!(required.kind(), ValidationStatusKind::RequiredButFailed);
1745        assert_eq!(
1746            required.reason(),
1747            Some(ReportReasonCode::MissingExactOutputRows)
1748        );
1749        assert!(required.is_required_but_failed());
1750    }
1751
1752    #[test]
1753    fn validation_status_display_is_stable_and_safe() {
1754        assert_eq!(
1755            ValidationStatus::disabled().to_string(),
1756            "disabled:validation_disabled"
1757        );
1758        assert_eq!(ValidationStatus::passed().to_string(), "passed");
1759        assert_eq!(ValidationStatus::failed().to_string(), "failed");
1760        assert_eq!(
1761            ValidationStatus::skipped(ReportReasonCode::DryRun).to_string(),
1762            "skipped:dry_run"
1763        );
1764        assert_eq!(
1765            ValidationStatus::unavailable(ReportReasonCode::PermissionUnavailable).to_string(),
1766            "unavailable:permission_unavailable"
1767        );
1768        assert_eq!(
1769            ValidationStatus::required_but_failed(ReportReasonCode::MissingExactOutputRows)
1770                .to_string(),
1771            "required_but_failed:missing_exact_output_rows"
1772        );
1773
1774        let debug = format!(
1775            "{:?}",
1776            ValidationStatus::required_but_failed(ReportReasonCode::MissingTargetAccess)
1777        );
1778        assert!(debug.contains("RequiredButFailed"));
1779        assert!(!debug.contains("server="));
1780        assert!(!debug.contains("password"));
1781    }
1782
1783    #[test]
1784    fn phase_status_kinds_expose_stable_codes() {
1785        assert_eq!(PhaseStatusKind::Completed.as_str(), "completed");
1786        assert_eq!(PhaseStatusKind::Failed.as_str(), "failed");
1787        assert_eq!(PhaseStatusKind::Skipped.as_str(), "skipped");
1788        assert_eq!(PhaseStatusKind::NotStarted.as_str(), "not_started");
1789        assert_eq!(PhaseStatusKind::Unavailable.as_str(), "unavailable");
1790        assert_eq!(PhaseStatusKind::NotStarted.to_string(), "not_started");
1791    }
1792
1793    #[test]
1794    fn phase_status_variants_expose_kind_reasons_and_helpers() {
1795        let completed = PhaseStatus::completed();
1796        let failed = PhaseStatus::failed();
1797        let skipped = PhaseStatus::skipped(ReportReasonCode::DryRun);
1798        let not_started = PhaseStatus::not_started(ReportReasonCode::PriorFailure);
1799        let unavailable = PhaseStatus::unavailable(ReportReasonCode::CapabilityUnavailable);
1800
1801        assert_eq!(completed.kind(), PhaseStatusKind::Completed);
1802        assert_eq!(completed.reason(), None);
1803        assert!(completed.is_completed());
1804
1805        assert_eq!(failed.kind(), PhaseStatusKind::Failed);
1806        assert_eq!(failed.reason(), None);
1807        assert!(failed.is_failed());
1808
1809        assert_eq!(skipped.kind(), PhaseStatusKind::Skipped);
1810        assert_eq!(skipped.reason(), Some(ReportReasonCode::DryRun));
1811        assert!(skipped.is_skipped());
1812
1813        assert_eq!(not_started.kind(), PhaseStatusKind::NotStarted);
1814        assert_eq!(not_started.reason(), Some(ReportReasonCode::PriorFailure));
1815        assert!(not_started.is_not_started());
1816
1817        assert_eq!(unavailable.kind(), PhaseStatusKind::Unavailable);
1818        assert_eq!(
1819            unavailable.reason(),
1820            Some(ReportReasonCode::CapabilityUnavailable)
1821        );
1822        assert!(unavailable.is_unavailable());
1823    }
1824
1825    #[test]
1826    fn phase_status_display_is_stable_and_safe() {
1827        assert_eq!(PhaseStatus::completed().to_string(), "completed");
1828        assert_eq!(PhaseStatus::failed().to_string(), "failed");
1829        assert_eq!(
1830            PhaseStatus::skipped(ReportReasonCode::DryRun).to_string(),
1831            "skipped:dry_run"
1832        );
1833        assert_eq!(
1834            PhaseStatus::not_started(ReportReasonCode::PriorFailure).to_string(),
1835            "not_started:prior_failure"
1836        );
1837        assert_eq!(
1838            PhaseStatus::unavailable(ReportReasonCode::CapabilityUnavailable).to_string(),
1839            "unavailable:capability_unavailable"
1840        );
1841
1842        let debug = format!(
1843            "{:?}",
1844            PhaseStatus::unavailable(ReportReasonCode::MissingTargetAccess)
1845        );
1846        assert!(debug.contains("Unavailable"));
1847        assert!(!debug.contains("server="));
1848        assert!(!debug.contains("password"));
1849    }
1850
1851    #[test]
1852    fn duration_to_micros_saturates_wide_durations() {
1853        assert_eq!(duration_to_micros_saturating(Duration::from_micros(42)), 42);
1854        assert_eq!(
1855            duration_to_micros_saturating(Duration::new(u64::MAX, 999_999_999)),
1856            u64::MAX
1857        );
1858    }
1859
1860    #[test]
1861    fn phase_timing_report_preserves_status_reason_and_elapsed_time() {
1862        let completed =
1863            PhaseTimingReport::completed("sql_target_planning", Duration::from_micros(1_234));
1864        assert_eq!(completed.phase_name(), "sql_target_planning");
1865        assert_eq!(completed.status(), PhaseStatus::completed());
1866        assert_eq!(completed.elapsed_micros(), Some(1_234));
1867
1868        let failed = PhaseTimingReport::failed("sql_write", Duration::from_millis(2));
1869        assert_eq!(failed.status(), PhaseStatus::failed());
1870        assert_eq!(failed.elapsed_micros(), Some(2_000));
1871
1872        let skipped =
1873            PhaseTimingReport::skipped("validation", ReportReasonCode::ValidationDisabled);
1874        assert_eq!(
1875            skipped.status(),
1876            PhaseStatus::skipped(ReportReasonCode::ValidationDisabled)
1877        );
1878        assert_eq!(skipped.elapsed_micros(), None);
1879
1880        let not_started =
1881            PhaseTimingReport::not_started("finalize", ReportReasonCode::PriorFailure);
1882        assert_eq!(
1883            not_started.status(),
1884            PhaseStatus::not_started(ReportReasonCode::PriorFailure)
1885        );
1886        assert_eq!(not_started.elapsed_micros(), None);
1887
1888        let unavailable =
1889            PhaseTimingReport::unavailable("source_loading", ReportReasonCode::NotExecuted);
1890        assert_eq!(
1891            unavailable.status(),
1892            PhaseStatus::unavailable(ReportReasonCode::NotExecuted)
1893        );
1894        assert_eq!(unavailable.elapsed_micros(), None);
1895    }
1896
1897    #[test]
1898    fn phase_timer_uses_monotonic_elapsed_time() {
1899        let completed = PhaseTimer::start("config_validation").completed();
1900        assert_eq!(completed.phase_name(), "config_validation");
1901        assert_eq!(completed.status(), PhaseStatus::completed());
1902        assert!(completed.elapsed_micros().is_some());
1903
1904        let failed = PhaseTimer::start("source_loading").failed();
1905        assert_eq!(failed.phase_name(), "source_loading");
1906        assert_eq!(failed.status(), PhaseStatus::failed());
1907        assert!(failed.elapsed_micros().is_some());
1908    }
1909
1910    #[test]
1911    fn output_status_kinds_expose_stable_codes() {
1912        assert_eq!(OutputStatusKind::Planned.as_str(), "planned");
1913        assert_eq!(OutputStatusKind::Succeeded.as_str(), "succeeded");
1914        assert_eq!(OutputStatusKind::Failed.as_str(), "failed");
1915        assert_eq!(OutputStatusKind::Skipped.as_str(), "skipped");
1916        assert_eq!(OutputStatusKind::DryRunPlanned.as_str(), "dry_run_planned");
1917        assert_eq!(
1918            OutputStatusKind::ValidationFailed.as_str(),
1919            "validation_failed"
1920        );
1921        assert_eq!(
1922            OutputStatusKind::DryRunPlanned.to_string(),
1923            "dry_run_planned"
1924        );
1925    }
1926
1927    #[test]
1928    fn output_status_variants_expose_kind_reasons_validation_and_helpers() {
1929        let planned = OutputStatus::planned();
1930        let succeeded = OutputStatus::succeeded();
1931        let failed = OutputStatus::failed();
1932        let skipped = OutputStatus::skipped(ReportReasonCode::PriorFailure);
1933        let dry_run = OutputStatus::dry_run_planned();
1934        let validation =
1935            ValidationStatus::required_but_failed(ReportReasonCode::MissingExactOutputRows);
1936        let validation_failed = OutputStatus::validation_failed(validation);
1937
1938        assert_eq!(planned.kind(), OutputStatusKind::Planned);
1939        assert_eq!(planned.reason(), None);
1940        assert!(planned.is_planned());
1941
1942        assert_eq!(succeeded.kind(), OutputStatusKind::Succeeded);
1943        assert!(succeeded.is_succeeded());
1944
1945        assert_eq!(failed.kind(), OutputStatusKind::Failed);
1946        assert!(failed.is_failed());
1947        assert!(!failed.is_validation_failed());
1948
1949        assert_eq!(skipped.kind(), OutputStatusKind::Skipped);
1950        assert_eq!(skipped.reason(), Some(ReportReasonCode::PriorFailure));
1951        assert!(skipped.is_skipped());
1952
1953        assert_eq!(dry_run.kind(), OutputStatusKind::DryRunPlanned);
1954        assert!(dry_run.is_dry_run_planned());
1955
1956        assert_eq!(validation_failed.kind(), OutputStatusKind::ValidationFailed);
1957        assert_eq!(validation_failed.validation(), Some(validation));
1958        assert!(validation_failed.is_failed());
1959        assert!(validation_failed.is_validation_failed());
1960    }
1961
1962    #[test]
1963    fn output_status_display_is_stable_and_safe() {
1964        assert_eq!(OutputStatus::planned().to_string(), "planned");
1965        assert_eq!(OutputStatus::succeeded().to_string(), "succeeded");
1966        assert_eq!(OutputStatus::failed().to_string(), "failed");
1967        assert_eq!(
1968            OutputStatus::skipped(ReportReasonCode::PriorFailure).to_string(),
1969            "skipped:prior_failure"
1970        );
1971        assert_eq!(
1972            OutputStatus::dry_run_planned().to_string(),
1973            "dry_run_planned"
1974        );
1975        assert_eq!(
1976            OutputStatus::validation_failed(ValidationStatus::required_but_failed(
1977                ReportReasonCode::MissingExactOutputRows
1978            ))
1979            .to_string(),
1980            "validation_failed:required_but_failed:missing_exact_output_rows"
1981        );
1982
1983        let debug = format!(
1984            "{:?}",
1985            OutputStatus::validation_failed(ValidationStatus::unavailable(
1986                ReportReasonCode::MissingTargetAccess
1987            ))
1988        );
1989        assert!(debug.contains("ValidationFailed"));
1990        assert!(!debug.contains("server="));
1991        assert!(!debug.contains("password"));
1992    }
1993
1994    #[test]
1995    fn workflow_status_kinds_expose_stable_codes() {
1996        assert_eq!(WorkflowStatusKind::Success.as_str(), "success");
1997        assert_eq!(
1998            WorkflowStatusKind::PartialSuccess.as_str(),
1999            "partial_success"
2000        );
2001        assert_eq!(WorkflowStatusKind::Failure.as_str(), "failure");
2002        assert_eq!(WorkflowStatusKind::Skipped.as_str(), "skipped");
2003        assert_eq!(WorkflowStatusKind::NoOp.as_str(), "no_op");
2004        assert_eq!(
2005            WorkflowStatusKind::PartialSuccess.to_string(),
2006            "partial_success"
2007        );
2008    }
2009
2010    #[test]
2011    fn workflow_status_variants_expose_kind_reasons_and_helpers() {
2012        let success = WorkflowStatus::success();
2013        let partial = WorkflowStatus::partial_success();
2014        let failure = WorkflowStatus::failure();
2015        let skipped = WorkflowStatus::skipped(ReportReasonCode::DryRun);
2016        let no_op = WorkflowStatus::no_op(ReportReasonCode::NotExecuted);
2017
2018        assert_eq!(success.kind(), WorkflowStatusKind::Success);
2019        assert_eq!(success.reason(), None);
2020        assert!(success.is_success());
2021
2022        assert_eq!(partial.kind(), WorkflowStatusKind::PartialSuccess);
2023        assert_eq!(partial.reason(), None);
2024        assert!(partial.is_partial_success());
2025
2026        assert_eq!(failure.kind(), WorkflowStatusKind::Failure);
2027        assert_eq!(failure.reason(), None);
2028        assert!(failure.is_failure());
2029
2030        assert_eq!(skipped.kind(), WorkflowStatusKind::Skipped);
2031        assert_eq!(skipped.reason(), Some(ReportReasonCode::DryRun));
2032        assert!(skipped.is_skipped());
2033
2034        assert_eq!(no_op.kind(), WorkflowStatusKind::NoOp);
2035        assert_eq!(no_op.reason(), Some(ReportReasonCode::NotExecuted));
2036        assert!(no_op.is_no_op());
2037    }
2038
2039    #[test]
2040    fn workflow_status_display_is_stable_and_safe() {
2041        assert_eq!(WorkflowStatus::success().to_string(), "success");
2042        assert_eq!(
2043            WorkflowStatus::partial_success().to_string(),
2044            "partial_success"
2045        );
2046        assert_eq!(WorkflowStatus::failure().to_string(), "failure");
2047        assert_eq!(
2048            WorkflowStatus::skipped(ReportReasonCode::DryRun).to_string(),
2049            "skipped:dry_run"
2050        );
2051        assert_eq!(
2052            WorkflowStatus::no_op(ReportReasonCode::NotExecuted).to_string(),
2053            "no_op:not_executed"
2054        );
2055
2056        let debug = format!(
2057            "{:?}",
2058            WorkflowStatus::skipped(ReportReasonCode::PriorFailure)
2059        );
2060        assert!(debug.contains("Skipped"));
2061        assert!(!debug.contains("server="));
2062        assert!(!debug.contains("password"));
2063    }
2064}