Skip to main content

qubit_fs/temp/
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//! Provider-backed temporary file lifecycle handle.
9
10use std::fmt::Debug;
11use std::fmt::Formatter;
12use std::fmt::Result as FmtResult;
13
14use crate::FileSystem;
15use crate::error::FsError;
16use crate::error::FsErrorKind;
17use crate::error::FsOperation;
18use crate::error::FsResult;
19use crate::metadata::AchievedAtomicity;
20use crate::metadata::AtomicityRequirement;
21use crate::path::Path;
22use crate::spi::PersistRequest;
23use crate::spi::SpiPersistFailure;
24use crate::spi::TempResourceSpi;
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/// Temporary file retaining the provider session until its lifecycle completes.
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/// # let filesystem = support::rustdoc_provider::filesystem();
41/// use qubit_fs::temp::TempOptions;
42/// use qubit_fs::temp::TempResourceState;
43///
44/// let mut temporary = filesystem.create_temp_file(TempOptions::default())?;
45/// assert_eq!(TempResourceState::Owned, temporary.state());
46/// temporary.cleanup()?;
47/// assert_eq!(TempResourceState::Cleaned, temporary.state());
48/// # Ok::<(), Box<dyn std::error::Error>>(())
49/// ```
50pub struct TempFile {
51    /// Facade that owns validation and persistence policy.
52    filesystem: FileSystem,
53    /// Provider-local temporary file path.
54    path: Path,
55    /// Provider lifecycle session.
56    session: Box<dyn TempResourceSpi>,
57    /// Current cleanup and publication lifecycle state.
58    lifecycle: TempLifecycle,
59}
60
61impl TempFile {
62    /// Creates the facade handle from validated provider parts.
63    pub(crate) fn new(filesystem: FileSystem, path: Path, session: Box<dyn TempResourceSpi>) -> Self {
64        Self {
65            filesystem,
66            path,
67            session,
68            lifecycle: TempLifecycle::new(),
69        }
70    }
71    /// Returns the temporary logical path.
72    #[inline]
73    #[must_use]
74    pub const fn path(&self) -> &Path {
75        &self.path
76    }
77    /// Returns the resource lifecycle state.
78    #[inline]
79    #[must_use]
80    pub const fn state(&self) -> TempResourceState {
81        self.lifecycle.state()
82    }
83    /// Persists this temporary file to `target`.
84    ///
85    /// # Parameters
86    /// - `target`: Destination path validated by the owning filesystem.
87    /// - `options`: Atomicity and publication requirements.
88    ///
89    /// # Returns
90    /// The provider-confirmed publication outcome.
91    ///
92    /// # Errors
93    /// Returns a typed failure for invalid lifecycle state, failed preflight,
94    /// provider failure, or a provider contract violation.
95    #[allow(clippy::result_large_err)]
96    pub fn persist(&mut self, target: &Path, options: PersistOptions) -> Result<PersistOutcome, PersistFailure> {
97        if self.lifecycle.state() != TempResourceState::Owned {
98            return Err(PersistFailure::new(
99                self.invalid_state(FsOperation::PersistTemp),
100                self.lifecycle.failure_state(),
101            )
102            .with_publication_target(self.lifecycle.publication_target()));
103        }
104        if let Err(error) = self.filesystem.preflight_temp_persist(&self.path, target, &options) {
105            return Err(PersistFailure::new(error, PersistFailureState::NotPublished));
106        }
107        match self.session.persist(PersistRequest::new(target, options.clone())) {
108            Ok(outcome) => {
109                if outcome.target() != target {
110                    self.lifecycle
111                        .record_failure(PersistFailureState::Indeterminate, Some(target.clone()), false);
112                    return Err(PersistFailure::new(
113                        FsError::new(
114                            FsErrorKind::ProviderContractViolation,
115                            FsOperation::PersistTemp,
116                            "provider reported a persistence target different from the request",
117                        )
118                        .with_path(self.path.clone())
119                        .with_target(target.clone()),
120                        PersistFailureState::Indeterminate,
121                    ));
122                }
123                if options.atomicity() == AtomicityRequirement::Required
124                    && outcome.atomicity() != AchievedAtomicity::Atomic
125                {
126                    self.lifecycle.record_failure(
127                        PersistFailureState::PublishedSourceRetained,
128                        Some(target.clone()),
129                        false,
130                    );
131                    return Err(PersistFailure::new(
132                        FsError::new(
133                            FsErrorKind::ProviderContractViolation,
134                            FsOperation::PersistTemp,
135                            "provider reported non-atomic success for atomic-required persist",
136                        )
137                        .with_path(self.path.clone())
138                        .with_target(target.clone()),
139                        PersistFailureState::PublishedSourceRetained,
140                    )
141                    .with_publication_target(self.lifecycle.publication_target()));
142                }
143                self.lifecycle.record_success(false, outcome.target().clone());
144                Ok(outcome)
145            }
146            Err(failure) => Err(self.record_persist_failure(failure, target, FsOperation::PersistTemp)),
147        }
148    }
149    /// Publishes this temporary file to the provider-generated target.
150    ///
151    /// # Returns
152    /// The provider-confirmed publication outcome and generated target.
153    ///
154    /// # Errors
155    /// Returns a typed failure when ownership is unavailable, provider
156    /// publication fails, or the provider returns an invalid target.
157    #[allow(clippy::result_large_err)]
158    pub fn keep(&mut self) -> Result<PersistOutcome, PersistFailure> {
159        if let Err(error) = self.ensure_owned(FsOperation::KeepTemp) {
160            return Err(PersistFailure::new(error, self.lifecycle.failure_state())
161                .with_publication_target(self.lifecycle.publication_target()));
162        }
163        match self.session.keep() {
164            Ok(outcome) => {
165                if let Err(error) = self.filesystem.validate_temp_keep_target(&self.path, outcome.target()) {
166                    self.lifecycle.record_failure(
167                        PersistFailureState::Indeterminate,
168                        Some(outcome.target().clone()),
169                        true,
170                    );
171                    return Err(PersistFailure::new(error, PersistFailureState::Indeterminate));
172                }
173                self.path = outcome.target().clone();
174                self.lifecycle.record_success(true, outcome.target().clone());
175                Ok(outcome)
176            }
177            Err(failure) => Err(self.record_persist_failure(failure, &self.path.clone(), FsOperation::KeepTemp)),
178        }
179    }
180    /// Cleans the source and releases the session responsibility.
181    ///
182    /// # Errors
183    /// Returns an invalid-state error when cleanup is no longer legal, or the
184    /// provider cleanup error when cleanup cannot be confirmed.
185    pub fn cleanup(&mut self) -> FsResult<()> {
186        if !matches!(
187            self.lifecycle.state(),
188            TempResourceState::Owned | TempResourceState::CleanupRequired
189        ) {
190            return Err(self.invalid_state(FsOperation::CleanupTemp));
191        }
192        self.session
193            .cleanup()
194            .map(|()| self.lifecycle.record_cleanup_success())
195            .map_err(|error| self.record_lifecycle_error(error, FsOperation::CleanupTemp))
196    }
197    /// Records provider partial persistence facts in facade state and error.
198    fn record_persist_failure(
199        &mut self,
200        failure: SpiPersistFailure,
201        target: &Path,
202        operation: FsOperation,
203    ) -> PersistFailure {
204        let (error, state) = failure.into_parts();
205        let publication_target = if operation == FsOperation::KeepTemp {
206            error.target().cloned()
207        } else {
208            Some(target.clone())
209        };
210        self.lifecycle
211            .record_failure(state, publication_target, operation == FsOperation::KeepTemp);
212        PersistFailure::new(
213            error.with_operation(operation).with_missing_context(
214                &self.path,
215                Some(target),
216                self.filesystem.properties().info().provider_id(),
217            ),
218            state,
219        )
220        .with_publication_target(self.lifecycle.publication_target())
221    }
222    /// Requires an owned, unpublished source.
223    fn ensure_owned(&self, operation: FsOperation) -> FsResult<()> {
224        if self.lifecycle.state() == TempResourceState::Owned {
225            Ok(())
226        } else {
227            Err(self.invalid_state(operation))
228        }
229    }
230    /// Records a cleanup or ownership-transfer error with resource context.
231    fn record_lifecycle_error(&mut self, error: FsError, operation: FsOperation) -> FsError {
232        self.lifecycle.record_cleanup_error(&error);
233        error.with_operation(operation).with_missing_context(
234            &self.path,
235            None,
236            self.filesystem.properties().info().provider_id(),
237        )
238    }
239    /// Builds a contextual invalid-state error.
240    fn invalid_state(&self, operation: FsOperation) -> FsError {
241        FsError::new(
242            FsErrorKind::InvalidState,
243            operation,
244            "temporary file cannot perform this lifecycle operation",
245        )
246        .with_path(self.path.clone())
247    }
248}
249
250impl Debug for TempFile {
251    #[inline]
252    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
253        f.debug_struct("TempFile")
254            .field("path", &self.path)
255            .field("state", &self.lifecycle.state())
256            .finish_non_exhaustive()
257    }
258}
259impl Drop for TempFile {
260    fn drop(&mut self) {
261        if matches!(
262            self.lifecycle.state(),
263            TempResourceState::Owned | TempResourceState::CleanupRequired
264        ) {
265            let _ = self.session.cleanup();
266        }
267    }
268}