Skip to main content

qubit_fs/copy/
copy_outcome.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Copy operation outcome.
9
10use crate::copy::CopyConflictPolicy;
11use crate::copy::CopyMethod;
12use crate::copy::CopyMode;
13use crate::copy::CopyOptions;
14use crate::copy::CopyStats;
15use crate::copy::MetadataPreservePolicy;
16use crate::copy::ServerSidePreference;
17use crate::metadata::AchievedAtomicity;
18use crate::metadata::NonSensitiveMetadata;
19use crate::metadata::ResourceVersion;
20use crate::metadata::UserMetadata;
21
22/// Outcome returned by copy operations.
23///
24/// # Examples
25///
26/// ```rust
27/// use qubit_fs::copy::{CopyMethod, CopyOutcome, CopyStats};
28/// use qubit_fs::metadata::AchievedAtomicity;
29///
30/// let outcome = CopyOutcome::new(CopyStats::default(), CopyMethod::Streamed, AchievedAtomicity::Atomic);
31/// assert_eq!(CopyMethod::Streamed, outcome.method());
32/// ```
33#[derive(Clone, Debug, PartialEq)]
34pub struct CopyOutcome {
35    /// Aggregate counts and bytes reported for the completed copy.
36    stats: CopyStats,
37    /// Actual transfer method used by the provider or facade.
38    method: CopyMethod,
39    /// Atomicity achieved while publishing the destination.
40    atomicity: AchievedAtomicity,
41    /// Whether requested durability was confirmed.
42    durable: bool,
43    /// Metadata preservation level actually achieved.
44    metadata: MetadataPreservePolicy,
45    /// Optional version assigned to the destination.
46    target_version: Option<ResourceVersion>,
47    /// Whether the facade completed the copy after provider decline.
48    used_fallback: bool,
49    /// Scrubbed provider diagnostics.
50    diagnostics: NonSensitiveMetadata,
51}
52
53impl CopyOutcome {
54    /// Creates a copy outcome.
55    ///
56    /// # Parameters
57    /// - `stats`: Copy statistics.
58    /// - `method`: Method used to complete the copy.
59    /// - `atomicity`: Atomicity achieved while publishing the destination.
60    ///
61    /// # Returns
62    /// New copy outcome without diagnostics.
63    #[inline]
64    #[must_use]
65    pub fn new(stats: CopyStats, method: CopyMethod, atomicity: AchievedAtomicity) -> Self {
66        Self {
67            stats,
68            method,
69            atomicity,
70            durable: false,
71            metadata: MetadataPreservePolicy::None,
72            target_version: None,
73            used_fallback: false,
74            diagnostics: NonSensitiveMetadata::new(),
75        }
76    }
77
78    /// Replaces provider-native diagnostics that have already passed key
79    /// validation.
80    #[inline]
81    #[must_use]
82    pub fn with_diagnostics(mut self, diagnostics: UserMetadata) -> Self {
83        self.diagnostics = NonSensitiveMetadata::from(diagnostics);
84        self
85    }
86
87    /// Returns the completed copy statistics.
88    #[inline]
89    #[must_use]
90    pub const fn stats(&self) -> &CopyStats {
91        &self.stats
92    }
93    /// Returns the actual method used by the completed operation.
94    #[inline]
95    #[must_use]
96    pub const fn method(&self) -> CopyMethod {
97        self.method
98    }
99    /// Returns the atomicity actually achieved while publishing the target.
100    #[inline]
101    #[must_use]
102    pub const fn atomicity(&self) -> AchievedAtomicity {
103        self.atomicity
104    }
105    /// Returns whether provider-confirmed durability synchronization completed.
106    #[inline]
107    #[must_use]
108    pub const fn durable(&self) -> bool {
109        self.durable
110    }
111    /// Replaces the provider-reported durability completion fact.
112    #[inline]
113    #[must_use]
114    pub fn with_durable(mut self, durable: bool) -> Self {
115        self.durable = durable;
116        self
117    }
118
119    /// Records the metadata preservation policy actually achieved by the
120    /// provider.
121    #[inline]
122    #[must_use]
123    pub fn with_metadata(mut self, metadata: MetadataPreservePolicy) -> Self {
124        self.metadata = metadata;
125        self
126    }
127
128    /// Records the destination version reported after publication.
129    #[inline]
130    #[must_use]
131    pub fn with_target_version(mut self, target_version: ResourceVersion) -> Self {
132        self.target_version = Some(target_version);
133        self
134    }
135    /// Returns the metadata preservation result represented by this outcome.
136    #[inline]
137    #[must_use]
138    pub const fn metadata(&self) -> MetadataPreservePolicy {
139        self.metadata
140    }
141    /// Returns the target version when the provider reported one.
142    #[inline]
143    #[must_use]
144    pub const fn target_version(&self) -> Option<&ResourceVersion> {
145        self.target_version.as_ref()
146    }
147    /// Returns whether the facade streamed after the provider declined its fast
148    /// path.
149    #[inline]
150    #[must_use]
151    pub const fn used_fallback(&self) -> bool {
152        self.used_fallback
153    }
154    /// Returns provider diagnostics that are safe to expose.
155    #[inline]
156    #[must_use]
157    pub const fn diagnostics(&self) -> &NonSensitiveMetadata {
158        &self.diagnostics
159    }
160    /// Marks this result as the facade's streamed fallback.
161    pub(crate) fn streamed_fallback(stats: CopyStats, atomicity: AchievedAtomicity, durable: bool) -> Self {
162        Self {
163            stats,
164            method: CopyMethod::Streamed,
165            atomicity,
166            durable,
167            metadata: MetadataPreservePolicy::None,
168            target_version: None,
169            used_fallback: true,
170            diagnostics: NonSensitiveMetadata::new(),
171        }
172    }
173
174    /// Returns the first provider-completed outcome fact that contradicts the
175    /// resolved copy request.
176    pub(crate) fn contract_violation(&self, options: &CopyOptions) -> Option<&'static str> {
177        if self.used_fallback || self.method == CopyMethod::Streamed {
178            return Some("provider returned a facade streamed-fallback outcome as native success");
179        }
180        if options.atomicity() == crate::metadata::AtomicityRequirement::Required
181            && self.atomicity != AchievedAtomicity::Atomic
182        {
183            return Some("provider reported non-atomic success for an atomic-required copy");
184        }
185        if options.durability() == crate::metadata::DurabilityRequirement::Required && !self.durable {
186            return Some("provider reported non-durable success for a durability-required copy");
187        }
188        if options.server_side() == ServerSidePreference::Require && self.method != CopyMethod::ServerSide {
189            return Some("provider reported a non-server-side success for a server-side-required copy");
190        }
191        if options.server_side() == ServerSidePreference::Disable && self.method == CopyMethod::ServerSide {
192            return Some("provider reported a server-side success for a server-side-disabled copy");
193        }
194        if self.metadata != options.preserve_metadata() {
195            return Some("provider reported metadata preservation different from the copy request");
196        }
197        if !options.continue_on_error() && self.stats.failed != 0 {
198            return Some("provider reported failed copy entries without continue-on-error");
199        }
200        if options.conflict() != CopyConflictPolicy::Skip && self.stats.skipped != 0 {
201            return Some("provider reported skipped copy entries without a skip conflict policy");
202        }
203        if options.conflict() != CopyConflictPolicy::Overwrite && self.stats.overwritten != 0 {
204            return Some("provider reported overwritten copy entries without an overwrite conflict policy");
205        }
206        if options.max_bytes().is_some_and(|maximum| self.stats.bytes > maximum) {
207            return Some("provider reported copy bytes beyond the requested limit");
208        }
209        let entries = self
210            .stats
211            .files
212            .checked_add(self.stats.directories)
213            .and_then(|value| value.checked_add(self.stats.symlinks))
214            .and_then(|value| value.checked_add(self.stats.objects))
215            .and_then(|value| value.checked_add(self.stats.prefixes));
216        let valid_skipped_file = options.mode() == CopyMode::File
217            && options.conflict() == CopyConflictPolicy::Skip
218            && self.stats.skipped == 1
219            && entries == Some(0);
220        let valid_copied_file = entries == Some(1) && self.stats.directories == 0 && self.stats.prefixes == 0;
221        if options.mode() == CopyMode::File && !valid_copied_file && !valid_skipped_file {
222            return Some("provider reported a file-mode copy without exactly one resource");
223        }
224        if entries.is_none()
225            || options.max_entries().is_some_and(|maximum| {
226                u64::try_from(maximum).is_ok_and(|maximum| entries.is_some_and(|entries| entries > maximum))
227            })
228        {
229            return Some("provider reported copy entries beyond the requested limit");
230        }
231        None
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use std::hint::black_box;
238
239    use super::CopyOutcome;
240    use crate::copy::CopyMethod;
241    use crate::copy::CopyOptions;
242    use crate::copy::CopyStats;
243    use crate::copy::MetadataPreservePolicy;
244    use crate::metadata::AchievedAtomicity;
245    use crate::metadata::NonSensitiveMetadata;
246    use crate::metadata::ResourceVersion;
247
248    #[test]
249    fn outcome_accessors_are_executed_at_runtime() {
250        let constructor: fn(CopyStats, CopyMethod, AchievedAtomicity) -> CopyOutcome = black_box(CopyOutcome::new);
251        let metadata: fn(&CopyOutcome) -> MetadataPreservePolicy = black_box(CopyOutcome::metadata);
252        let target_version: for<'a> fn(&'a CopyOutcome) -> Option<&'a ResourceVersion> =
253            black_box(CopyOutcome::target_version);
254        let diagnostics: fn(&CopyOutcome) -> &NonSensitiveMetadata = black_box(CopyOutcome::diagnostics);
255        let with_target_version: fn(CopyOutcome, ResourceVersion) -> CopyOutcome =
256            black_box(CopyOutcome::with_target_version);
257        let with_diagnostics: fn(CopyOutcome, crate::metadata::UserMetadata) -> CopyOutcome =
258            black_box(CopyOutcome::with_diagnostics);
259
260        let outcome = with_diagnostics(
261            with_target_version(
262                constructor(CopyStats::default(), CopyMethod::Native, AchievedAtomicity::Atomic),
263                ResourceVersion::new("generation-7"),
264            ),
265            crate::metadata::UserMetadata::new(),
266        );
267        assert_eq!(MetadataPreservePolicy::None, metadata(&outcome));
268        assert_eq!(
269            Some("generation-7"),
270            target_version(&outcome).map(ResourceVersion::as_str)
271        );
272        assert!(diagnostics(&outcome).is_empty());
273
274        let bytes_exceeded = CopyOutcome::new(
275            CopyStats {
276                bytes: 11,
277                ..CopyStats::default()
278            },
279            CopyMethod::Native,
280            AchievedAtomicity::Atomic,
281        );
282        assert!(
283            bytes_exceeded
284                .contract_violation(&CopyOptions::default().with_max_bytes(Some(10)))
285                .is_some()
286        );
287
288        let entries_exceeded = CopyOutcome::new(
289            CopyStats {
290                files: 2,
291                ..CopyStats::default()
292            },
293            CopyMethod::Native,
294            AchievedAtomicity::Atomic,
295        );
296        assert!(
297            entries_exceeded
298                .contract_violation(&CopyOptions::tree().with_max_entries(Some(1)))
299                .is_some()
300        );
301    }
302}