arch_toolkit/index/refresh.rs
1//! Cancellable background index-refresh handle.
2
3use std::future::Future;
4
5use crate::error::{ArchToolkitError, Result};
6use crate::types::index::OfficialIndex;
7
8/// What: Represent one caller-supplied background official-index refresh.
9///
10/// Inputs:
11/// - Created by [`spawn_index_refresh`] from a future that produces an
12/// [`OfficialIndex`] or a structured toolkit error.
13///
14/// Output:
15/// - A handle that can cancel pending async work and explicitly await its
16/// result/error delivery.
17///
18/// Details:
19/// - This handle owns no global index and performs no system mutation.
20/// - Cancellation aborts the async task; cooperative futures such as caller
21/// HTTP requests are dropped promptly, while caller code still owns any
22/// external resource semantics.
23#[derive(Debug)]
24pub struct IndexRefreshHandle {
25 /// Tokio task delivering the caller-supplied refresh result.
26 task: tokio::task::JoinHandle<Result<OfficialIndex>>,
27}
28
29/// What: Start a caller-supplied async index refresh in the background.
30///
31/// Inputs:
32/// - `refresh`: Sendable `'static` future returning a refreshed index or a
33/// structured error.
34///
35/// Output:
36/// - [`IndexRefreshHandle`] for cancellation and explicit result delivery.
37///
38/// Details:
39/// - The API intentionally accepts a future rather than hiding `pacman` or
40/// network policy. Callers can use a local fetch, a caller-client HTTP
41/// fetcher, or an in-memory fixture with the same cancellation contract.
42/// - Dropping the handle detaches the task; call [`IndexRefreshHandle::cancel`]
43/// when a caller no longer wants the refresh to continue.
44pub fn spawn_index_refresh<F>(refresh: F) -> IndexRefreshHandle
45where
46 F: Future<Output = Result<OfficialIndex>> + Send + 'static,
47{
48 IndexRefreshHandle {
49 task: tokio::spawn(refresh),
50 }
51}
52
53impl IndexRefreshHandle {
54 /// What: Request cancellation of a pending background refresh.
55 ///
56 /// Inputs: None.
57 ///
58 /// Output:
59 /// - Requests task abortion; [`Self::wait`] reports a structured cancelled
60 /// error if the task did not complete first.
61 ///
62 /// Details:
63 /// - Cancellation is idempotent and does not block the calling thread.
64 /// - It is meaningful for async futures; callers should not wrap a
65 /// non-cancellable `spawn_blocking` operation in this API and expect it
66 /// to stop after it has started.
67 pub fn cancel(&self) {
68 self.task.abort();
69 }
70
71 /// What: Check whether the refresh task has completed.
72 ///
73 /// Inputs: None.
74 ///
75 /// Output:
76 /// - `true` after successful, failed, panicked, or cancelled completion.
77 ///
78 /// Details:
79 /// - This is an observation only; use [`Self::wait`] for explicit result
80 /// or error delivery.
81 #[must_use]
82 pub fn is_finished(&self) -> bool {
83 self.task.is_finished()
84 }
85
86 /// What: Await the background refresh result and deliver task failures explicitly.
87 ///
88 /// Inputs:
89 /// - `self`: Consumes the handle and awaits the owned task.
90 ///
91 /// Output:
92 /// - Refreshed [`OfficialIndex`] or the refresh/task/cancellation error.
93 ///
94 /// Details:
95 /// - A cancelled task maps to an actionable parse error instead of silently
96 /// returning an empty index. Panics and runtime failures also remain
97 /// visible to the caller.
98 ///
99 /// # Errors
100 ///
101 /// Returns the caller future's error or a descriptive task failure mapped to
102 /// [`ArchToolkitError::Parse`].
103 pub async fn wait(self) -> Result<OfficialIndex> {
104 self.task
105 .await
106 .map_err(|error| map_refresh_join_error(&error))?
107 }
108}
109
110/// What: Translate a Tokio task join error into a public toolkit error.
111///
112/// Inputs:
113/// - `error`: Join failure observed while awaiting a refresh task.
114///
115/// Output:
116/// - Actionable cancellation or task-failure error.
117///
118/// Details:
119/// - Keeps cancellation distinguishable from a caller fetch error while
120/// avoiding exposure of Tokio error types in the public return contract.
121fn map_refresh_join_error(error: &tokio::task::JoinError) -> ArchToolkitError {
122 if error.is_cancelled() {
123 return ArchToolkitError::Parse("background index refresh was cancelled".to_string());
124 }
125 ArchToolkitError::Parse(format!("background index refresh task failed: {error}"))
126}
127
128#[cfg(test)]
129mod tests {
130 use std::time::Duration;
131
132 use crate::error::Result;
133 use crate::types::index::OfficialIndex;
134
135 use super::spawn_index_refresh;
136
137 #[tokio::test]
138 /// What: Verify a background refresh delivers its successful index explicitly.
139 ///
140 /// Inputs:
141 /// - A fixture-only async future returning an empty official index.
142 ///
143 /// Output:
144 /// - The same completed index from [`IndexRefreshHandle::wait`].
145 ///
146 /// Details:
147 /// - Demonstrates that the API returns a handle rather than hidden global
148 /// state or an unobservable detached task.
149 async fn refresh_delivers_successful_result() {
150 let handle =
151 spawn_index_refresh(async { Result::<OfficialIndex>::Ok(OfficialIndex::default()) });
152 let index = handle.wait().await.expect("refresh result");
153 assert!(index.pkgs.is_empty());
154 }
155
156 #[tokio::test]
157 /// What: Verify cancellation produces an explicit error on wait.
158 ///
159 /// Inputs:
160 /// - A pending fixture-only async refresh future.
161 ///
162 /// Output:
163 /// - A cancellation error instead of a false successful empty index.
164 ///
165 /// Details:
166 /// - Uses Tokio time only; no system command or remote endpoint is touched.
167 async fn refresh_cancellation_is_explicit() {
168 let handle = spawn_index_refresh(async {
169 tokio::time::sleep(Duration::from_mins(1)).await;
170 Result::<OfficialIndex>::Ok(OfficialIndex::default())
171 });
172 handle.cancel();
173 let error = handle.wait().await.expect_err("cancelled refresh error");
174 assert!(error.to_string().contains("cancelled"));
175 }
176}