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