Skip to main content

qubit_fs/temp/
async_temp_file.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// facade.
9//! Runtime-neutral asynchronous temporary-file facade handle.
10
11use std::pin::Pin;
12
13use crate::AsyncFileSystem;
14use crate::error::FsError;
15use crate::error::FsErrorKind;
16use crate::error::FsOperation;
17use crate::error::FsResult;
18use crate::metadata::AchievedAtomicity;
19use crate::metadata::AtomicityRequirement;
20use crate::path::Path;
21use crate::path::PathComponent;
22use crate::spi::AsyncTempResourceSpi;
23use crate::spi::PersistRequest;
24use crate::spi::SpiFuture;
25use crate::temp::PersistFailure;
26use crate::temp::PersistFailureState;
27use crate::temp::PersistOptions;
28use crate::temp::PersistOutcome;
29use crate::temp::TempResourceState;
30use crate::temp::internal::TempLifecycle;
31
32/// A facade-owned asynchronous temporary file.
33///
34/// # Examples
35///
36/// This example uses an isolated in-memory provider fixture.
37///
38/// ```rust
39/// # mod support { include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/common/rustdoc_support.rs")); }
40/// # use support::*;
41/// # let (filesystem, _) = async_recording_spi::async_recording_file_system(Default::default());
42/// # poll_support::ready(async {
43/// use qubit_fs::temp::TempOptions;
44/// use qubit_fs::temp::TempResourceState;
45///
46/// let mut temporary = filesystem.create_temp_file(TempOptions::default()).await?;
47/// temporary.cleanup().await?;
48/// assert_eq!(TempResourceState::Cleaned, temporary.state());
49/// # Ok::<(), Box<dyn std::error::Error>>(())
50/// # }).unwrap();
51/// ```
52pub struct AsyncTempFile {
53    /// Facade that owns validation and persistence policy.
54    file_system: AsyncFileSystem,
55    /// Provider-local temporary path.
56    path: Path,
57    /// Pinned provider lifecycle session.
58    session: Pin<Box<dyn AsyncTempResourceSpi>>,
59    /// Current cleanup and publication lifecycle state.
60    lifecycle: TempLifecycle,
61    /// Human-readable resource kind used in lifecycle diagnostics.
62    resource_name: &'static str,
63}
64
65impl AsyncTempFile {
66    /// Binds a validated provider temporary session to its owning facade.
67    ///
68    /// # Parameters
69    /// - `file_system`: Facade that owns validation and persistence policy.
70    /// - `path`: Validated provider-local temporary path.
71    /// - `session`: Provider lifecycle session.
72    ///
73    /// # Returns
74    /// An owned asynchronous temporary-file handle.
75    pub(crate) fn new(
76        file_system: AsyncFileSystem,
77        path: Path,
78        session: Box<dyn AsyncTempResourceSpi>,
79        resource_name: &'static str,
80    ) -> Self {
81        Self {
82            file_system,
83            path,
84            session: Box::into_pin(session),
85            lifecycle: TempLifecycle::new(),
86            resource_name,
87        }
88    }
89
90    /// Returns the provider-local temporary path.
91    ///
92    /// # Returns
93    /// The validated path supplied by the provider.
94    #[inline]
95    #[must_use]
96    pub const fn path(&self) -> &Path {
97        &self.path
98    }
99
100    /// Returns the current ownership lifecycle state.
101    ///
102    /// # Returns
103    /// The handle's current cleanup and publication state.
104    #[inline]
105    #[must_use]
106    pub const fn state(&self) -> TempResourceState {
107        self.lifecycle.state()
108    }
109
110    /// Returns one lexically safe child path.
111    #[inline]
112    #[must_use]
113    pub fn child(&self, component: &PathComponent) -> Path {
114        self.path.child(component)
115    }
116
117    /// Returns one lexically safe descendant path.
118    #[inline]
119    #[must_use]
120    pub fn descendant(&self, relative: &crate::path::RelativePath) -> Path {
121        self.path.join(relative)
122    }
123
124    /// Asynchronously confirms cleanup of this temporary resource.
125    ///
126    /// # Returns
127    /// A future resolving after provider cleanup is confirmed.
128    ///
129    /// # Errors
130    /// Resolves to an invalid-state error when cleanup is no longer legal, or
131    /// to the provider cleanup failure.
132    #[inline]
133    pub fn cleanup(&mut self) -> SpiFuture<'_, FsResult<()>> {
134        self.lifecycle("cannot be cleaned now", FsOperation::CleanupTemp, |session| {
135            session.cleanup()
136        })
137    }
138
139    /// Asynchronously publishes this temporary resource to a generated target.
140    ///
141    /// # Returns
142    /// A future resolving to the provider's confirmed publication outcome.
143    ///
144    /// # Errors
145    /// Resolves to an invalid-state error when the resource is no longer owned,
146    /// or to the provider ownership-transfer failure. An invalid-state failure
147    /// retains any previously confirmed publication target and recovery state.
148    #[inline]
149    pub fn keep(&mut self) -> SpiFuture<'_, Result<PersistOutcome, PersistFailure>> {
150        if self.lifecycle.state() != TempResourceState::Owned {
151            let error = self.invalid_state(FsOperation::KeepTemp, "cannot be kept now");
152            return Box::pin(async move {
153                Err(PersistFailure::new(error, self.lifecycle.failure_state())
154                    .with_publication_target(self.lifecycle.publication_target()))
155            });
156        }
157        Box::pin(async move {
158            self.lifecycle.begin_pending();
159            match self.session.as_mut().keep().await {
160                Ok(outcome) => {
161                    if let Err(error) = self.file_system.validate_temp_keep_target(&self.path, outcome.target()) {
162                        return Err(PersistFailure::new(error, PersistFailureState::Indeterminate));
163                    }
164                    self.path = outcome.target().clone();
165                    self.lifecycle.record_success(true, outcome.target().clone());
166                    Ok(outcome)
167                }
168                Err(failure) => {
169                    let (error, state) = failure.into_parts();
170                    self.lifecycle.record_failure(state, error.target().cloned(), true);
171                    let target = self.path.clone();
172                    Err(PersistFailure::new(
173                        error.with_operation(FsOperation::KeepTemp).with_missing_context(
174                            &self.path,
175                            Some(&target),
176                            self.file_system.properties().info().provider_id(),
177                        ),
178                        state,
179                    )
180                    .with_publication_target(self.lifecycle.publication_target()))
181                }
182            }
183        })
184    }
185
186    /// Asynchronously persists this resource to a validated destination.
187    ///
188    /// # Parameters
189    /// - `target`: Validated destination path.
190    /// - `options`: Persistence atomicity and publication requirements.
191    /// - `options`: Persistence atomicity and publication requirements.
192    ///
193    /// # Returns
194    /// A future resolving to the confirmed persistence outcome.
195    ///
196    /// # Errors
197    /// Resolves to a typed failure for invalid lifecycle state, failed local
198    /// preflight, provider failure, or provider contract violation. Rejected
199    /// repeated calls preserve the previously confirmed publication facts.
200    pub fn persist<'a>(
201        &'a mut self,
202        target: &'a Path,
203        options: PersistOptions,
204    ) -> SpiFuture<'a, Result<PersistOutcome, PersistFailure>> {
205        if self.lifecycle.state() != TempResourceState::Owned {
206            let error = self.invalid_state(FsOperation::PersistTemp, "cannot be persisted now");
207            return Box::pin(async move {
208                Err(PersistFailure::new(error, self.lifecycle.failure_state())
209                    .with_publication_target(self.lifecycle.publication_target()))
210            });
211        }
212        if let Err(error) = self.file_system.preflight_temp_persist(&self.path, target, &options) {
213            return Box::pin(async move { Err(PersistFailure::new(error, PersistFailureState::NotPublished)) });
214        }
215        Box::pin(async move {
216            self.lifecycle.begin_pending();
217            let atomicity = options.atomicity();
218            let result = self
219                .session
220                .as_mut()
221                .persist(PersistRequest::new(target, options))
222                .await;
223            match &result {
224                Ok(outcome)
225                    if outcome.target() == target
226                        && !(atomicity == AtomicityRequirement::Required
227                            && outcome.atomicity() != AchievedAtomicity::Atomic) =>
228                {
229                    self.lifecycle.record_success(false, outcome.target().clone());
230                }
231                Ok(outcome) if outcome.target() != target => {
232                    self.lifecycle
233                        .record_failure(PersistFailureState::Indeterminate, Some(target.clone()), false);
234                }
235                Ok(_) => self.lifecycle.record_failure(
236                    PersistFailureState::PublishedSourceRetained,
237                    Some(target.clone()),
238                    false,
239                ),
240                Err(failure) => self
241                    .lifecycle
242                    .record_failure(failure.state(), Some(target.clone()), false),
243            }
244            match result {
245                Ok(outcome) if outcome.target() != target => Err(PersistFailure::new(
246                    FsError::new(
247                        FsErrorKind::ProviderContractViolation,
248                        FsOperation::PersistTemp,
249                        "provider reported a persistence target different from the request",
250                    )
251                    .with_path(self.path.clone())
252                    .with_target(target.clone()),
253                    PersistFailureState::Indeterminate,
254                )),
255                Ok(outcome)
256                    if atomicity == AtomicityRequirement::Required
257                        && outcome.atomicity() != AchievedAtomicity::Atomic =>
258                {
259                    Err(PersistFailure::new(
260                        FsError::new(
261                            FsErrorKind::ProviderContractViolation,
262                            FsOperation::PersistTemp,
263                            "provider reported non-atomic success for atomic-required persist",
264                        )
265                        .with_path(self.path.clone())
266                        .with_target(target.clone()),
267                        PersistFailureState::PublishedSourceRetained,
268                    )
269                    .with_publication_target(self.lifecycle.publication_target()))
270                }
271                Err(failure) => {
272                    let (error, state) = failure.into_parts();
273                    Err(PersistFailure::new(self.contextual_persist_error(error, target), state)
274                        .with_publication_target(self.lifecycle.publication_target()))
275                }
276                Ok(outcome) => Ok(outcome),
277            }
278        })
279    }
280
281    /// Runs one lifecycle operation while retaining an indeterminate
282    /// cancellation state.
283    ///
284    /// # Type Parameters
285    /// - `F`: One-shot provider lifecycle operation.
286    ///
287    /// # Parameters
288    /// - `action`: Resource-specific action text used when the lifecycle state
289    ///   rejects the operation.
290    /// - `operation`: Filesystem operation recorded in generated errors.
291    /// - `call`: Provider operation invoked after local state validation.
292    ///
293    /// # Returns
294    /// A future resolving to the provider lifecycle result.
295    ///
296    /// # Errors
297    /// Resolves to an invalid-state error or the provider lifecycle failure.
298    fn lifecycle<'a, F>(
299        &'a mut self,
300        action: &'static str,
301        operation: FsOperation,
302        call: F,
303    ) -> SpiFuture<'a, FsResult<()>>
304    where
305        F: FnOnce(Pin<&'a mut dyn AsyncTempResourceSpi>) -> SpiFuture<'a, FsResult<()>> + Send + 'a,
306    {
307        if !matches!(
308            self.lifecycle.state(),
309            TempResourceState::Owned | TempResourceState::CleanupRequired
310        ) {
311            let error = self.invalid_state(operation, action);
312            return Box::pin(async move { Err(error) });
313        }
314        Box::pin(async move {
315            let previous_lifecycle = self.lifecycle.clone();
316            self.lifecycle.begin_pending();
317            let result = call(self.session.as_mut()).await;
318            self.lifecycle = previous_lifecycle;
319            match &result {
320                Ok(()) => self.lifecycle.record_cleanup_success(),
321                Err(error) => self.lifecycle.record_cleanup_error(error),
322            }
323            result.map_err(|error| {
324                error.with_operation(operation).with_missing_context(
325                    &self.path,
326                    None,
327                    self.file_system.properties().info().provider_id(),
328                )
329            })
330        })
331    }
332
333    /// Builds an invalid-state error for this handle.
334    ///
335    /// # Parameters
336    /// - `operation`: Rejected lifecycle operation.
337    /// - `action`: Stable action text describing the rejected operation.
338    ///
339    /// # Returns
340    /// A contextual invalid-state error containing the temporary path.
341    fn invalid_state(&self, operation: FsOperation, action: &str) -> FsError {
342        let message = format!("{} {}", self.resource_name, action);
343        FsError::new(FsErrorKind::InvalidState, operation, &message).with_path(self.path.clone())
344    }
345
346    /// Adds only missing facade facts to a provider persistence error.
347    ///
348    /// # Parameters
349    /// - `error`: Provider persistence error.
350    /// - `target`: Requested persistence target.
351    ///
352    /// # Returns
353    /// The error enriched with missing operation, path, target, and provider
354    /// context.
355    fn contextual_persist_error(&self, error: FsError, target: &Path) -> FsError {
356        error.with_operation(FsOperation::PersistTemp).with_missing_context(
357            &self.path,
358            Some(target),
359            self.file_system.properties().info().provider_id(),
360        )
361    }
362}
363
364impl Drop for AsyncTempFile {
365    fn drop(&mut self) {
366        if matches!(
367            self.lifecycle.state(),
368            TempResourceState::Owned | TempResourceState::CleanupRequired
369        ) {
370            self.session.as_mut().cancel_on_drop();
371        }
372    }
373}