arch_toolkit/index/persist.rs
1//! Index persistence functions for loading and saving the official package index.
2
3use std::path::Path;
4
5use crate::error::{ArchToolkitError, Result};
6use crate::types::index::OfficialIndex;
7
8/// What: Load an official package index from a JSON file on disk.
9///
10/// Inputs:
11/// - `path`: File path to read the JSON index from.
12///
13/// Output:
14/// - Returns `Ok(OfficialIndex)` with the deserialized index on success.
15/// - Returns `Err(ArchToolkitError::Io)` if the file cannot be read.
16/// - Returns `Err(ArchToolkitError::Json)` if the content is not valid index JSON.
17///
18/// Details:
19/// - Rebuilds the `name_to_idx` `HashMap` after deserialization so O(1) name
20/// lookups via `find_package_by_name()` work immediately.
21/// - Unlike Pacsea's original implementation, errors are propagated instead of
22/// silently ignored; callers decide how to handle a missing or corrupt index.
23///
24/// # Errors
25///
26/// Returns an error if the file cannot be read or the JSON cannot be parsed.
27///
28/// # Example
29///
30/// ```no_run
31/// use arch_toolkit::index::load_from_disk;
32/// use std::path::Path;
33///
34/// let index = load_from_disk(Path::new("official_index.json"))?;
35/// println!("Loaded {} packages", index.pkgs.len());
36/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
37/// ```
38pub fn load_from_disk(path: &Path) -> Result<OfficialIndex> {
39 tracing::debug!(path = %path.display(), "Loading official index from disk");
40 let content = std::fs::read_to_string(path)
41 .map_err(|e| ArchToolkitError::io(path.display().to_string(), e))?;
42 let mut index: OfficialIndex = serde_json::from_str(&content)?;
43 index.rebuild_name_index();
44 tracing::debug!(
45 path = %path.display(),
46 package_count = index.pkgs.len(),
47 "Successfully loaded official index"
48 );
49 Ok(index)
50}
51
52/// What: Load an official package index from disk, tolerating missing or corrupt files.
53///
54/// Inputs:
55/// - `path`: File path to read the JSON index from.
56///
57/// Output:
58/// - The deserialized index, or an empty `OfficialIndex` when the file is
59/// missing, unreadable, or not valid index JSON.
60///
61/// Details:
62/// - Convenience wrapper over [`load_from_disk`] for resilient startup paths
63/// (Pacsea's original semantics): a corrupt cache should trigger a fresh
64/// fetch, not a startup error. The failure reason is logged at debug level.
65///
66/// # Example
67///
68/// ```no_run
69/// use arch_toolkit::index::{fetch_official_index, load_from_disk_or_default};
70/// use std::path::Path;
71///
72/// let mut index = load_from_disk_or_default(Path::new("official_index.json"));
73/// if index.pkgs.is_empty() {
74/// index = fetch_official_index()?;
75/// }
76/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
77/// ```
78#[must_use]
79pub fn load_from_disk_or_default(path: &Path) -> OfficialIndex {
80 load_from_disk(path).unwrap_or_else(|e| {
81 tracing::debug!(
82 path = %path.display(),
83 error = %e,
84 "Failed to load official index; returning empty index"
85 );
86 OfficialIndex::default()
87 })
88}
89
90/// What: Persist an official package index to a JSON file on disk.
91///
92/// Inputs:
93/// - `index`: The index to serialize and write.
94/// - `path`: File path to write the JSON index to.
95///
96/// Output:
97/// - Returns `Ok(())` on success.
98/// - Returns `Err(ArchToolkitError::Io)` if the directory or file cannot be written.
99/// - Returns `Err(ArchToolkitError::Json)` if serialization fails.
100///
101/// Details:
102/// - Creates the parent directory if it does not exist.
103/// - The derived `name_to_idx` field is skipped during serialization; it is
104/// rebuilt on load via `load_from_disk()`.
105/// - Logs a warning when saving an empty index, since that usually indicates
106/// the index was never populated.
107///
108/// # Errors
109///
110/// Returns an error if the parent directory cannot be created, serialization
111/// fails, or the file cannot be written.
112///
113/// # Example
114///
115/// ```no_run
116/// use arch_toolkit::index::{save_to_disk, OfficialIndex};
117/// use std::path::Path;
118///
119/// let index = OfficialIndex::default();
120/// save_to_disk(&index, Path::new("official_index.json"))?;
121/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
122/// ```
123pub fn save_to_disk(index: &OfficialIndex, path: &Path) -> Result<()> {
124 if index.pkgs.is_empty() {
125 tracing::warn!(
126 path = %path.display(),
127 "Saving empty index to disk"
128 );
129 }
130 let json = serde_json::to_string(index)?;
131 if let Some(parent) = path.parent()
132 && !parent.as_os_str().is_empty()
133 {
134 std::fs::create_dir_all(parent)
135 .map_err(|e| ArchToolkitError::io(parent.display().to_string(), e))?;
136 }
137 std::fs::write(path, json).map_err(|e| ArchToolkitError::io(path.display().to_string(), e))?;
138 tracing::debug!(
139 path = %path.display(),
140 package_count = index.pkgs.len(),
141 "Successfully saved official index to disk"
142 );
143 Ok(())
144}
145
146/// What: Load an official package index from disk asynchronously.
147///
148/// Inputs:
149/// - `path`: File path to read the JSON index from.
150///
151/// Output:
152/// - Returns a future resolving to `Result<OfficialIndex>` (same semantics as `load_from_disk`).
153///
154/// Details:
155/// - Uses `tokio::task::spawn_blocking` to avoid blocking the async runtime on file I/O.
156///
157/// # Errors
158///
159/// Returns an error if the blocking task fails, the file cannot be read, or
160/// the JSON cannot be parsed.
161///
162/// # Example
163///
164/// ```no_run
165/// use arch_toolkit::index::load_from_disk_async;
166/// use std::path::PathBuf;
167///
168/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
169/// let index = load_from_disk_async(PathBuf::from("official_index.json")).await?;
170/// println!("Loaded {} packages", index.pkgs.len());
171/// # Ok(())
172/// # }
173/// ```
174#[cfg(feature = "index")]
175pub async fn load_from_disk_async(path: std::path::PathBuf) -> Result<OfficialIndex> {
176 tokio::task::spawn_blocking(move || load_from_disk(&path))
177 .await
178 .map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
179}
180
181/// What: Persist an official package index to disk asynchronously.
182///
183/// Inputs:
184/// - `index`: The index to serialize and write (moved into the blocking task).
185/// - `path`: File path to write the JSON index to.
186///
187/// Output:
188/// - Returns a future resolving to `Result<()>` (same semantics as `save_to_disk`).
189///
190/// Details:
191/// - Uses `tokio::task::spawn_blocking` to avoid blocking the async runtime on file I/O.
192/// - Takes the index by value because the blocking task requires `'static` data;
193/// clone before calling if the index is still needed.
194///
195/// # Errors
196///
197/// Returns an error if the blocking task fails, the parent directory cannot be
198/// created, serialization fails, or the file cannot be written.
199///
200/// # Example
201///
202/// ```no_run
203/// use arch_toolkit::index::{save_to_disk_async, OfficialIndex};
204/// use std::path::PathBuf;
205///
206/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
207/// let index = OfficialIndex::default();
208/// save_to_disk_async(index, PathBuf::from("official_index.json")).await?;
209/// # Ok(())
210/// # }
211/// ```
212#[cfg(feature = "index")]
213pub async fn save_to_disk_async(index: OfficialIndex, path: std::path::PathBuf) -> Result<()> {
214 tokio::task::spawn_blocking(move || save_to_disk(&index, &path))
215 .await
216 .map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::types::index::OfficialPackage;
223
224 fn sample_index() -> OfficialIndex {
225 let mut index = OfficialIndex {
226 pkgs: vec![
227 OfficialPackage {
228 name: "ripgrep".to_string(),
229 repo: "extra".to_string(),
230 arch: "x86_64".to_string(),
231 version: "14.0.0".to_string(),
232 description: "Fast grep".to_string(),
233 },
234 OfficialPackage {
235 name: "vim".to_string(),
236 repo: "extra".to_string(),
237 arch: "x86_64".to_string(),
238 version: "9.0".to_string(),
239 description: "Text editor".to_string(),
240 },
241 ],
242 name_to_idx: std::collections::HashMap::new(),
243 };
244 index.rebuild_name_index();
245 index
246 }
247
248 fn temp_path(tag: &str) -> std::path::PathBuf {
249 let mut path = std::env::temp_dir();
250 path.push(format!(
251 "arch_toolkit_persist_{tag}_{}_{}.json",
252 std::process::id(),
253 std::time::SystemTime::now()
254 .duration_since(std::time::UNIX_EPOCH)
255 .expect("System time is before UNIX epoch")
256 .as_nanos()
257 ));
258 path
259 }
260
261 #[test]
262 /// What: Verify a saved index round-trips through disk unchanged.
263 ///
264 /// Inputs:
265 /// - Sample index with two packages saved to a temp file.
266 ///
267 /// Output:
268 /// - Loaded index has the same packages and a rebuilt name index.
269 ///
270 /// Details:
271 /// - Confirms `name_to_idx` is rebuilt on load so O(1) lookups work.
272 fn save_and_load_roundtrip() {
273 let index = sample_index();
274 let path = temp_path("roundtrip");
275
276 save_to_disk(&index, &path).expect("save should succeed");
277 let loaded = load_from_disk(&path).expect("load should succeed");
278
279 assert_eq!(loaded.pkgs, index.pkgs);
280 assert_eq!(loaded.name_to_idx.len(), 2);
281 let found = loaded.find_package_by_name("RIPGREP");
282 assert_eq!(found.map(|p| p.name.as_str()), Some("ripgrep"));
283
284 let _ = std::fs::remove_file(&path);
285 }
286
287 #[test]
288 /// What: Verify loading a missing file returns an `Io` error.
289 ///
290 /// Inputs:
291 /// - Path that does not exist on disk.
292 ///
293 /// Output:
294 /// - `Err(ArchToolkitError::Io)` with the path in the message.
295 ///
296 /// Details:
297 /// - Errors are propagated (not silently ignored) so callers can fall back to fetching.
298 fn load_missing_file_returns_io_error() {
299 let path = temp_path("missing");
300 let result = load_from_disk(&path);
301 match result {
302 Err(ArchToolkitError::Io { path: p, .. }) => {
303 assert!(p.contains("arch_toolkit_persist_missing"));
304 }
305 other => panic!("Expected Io error, got {other:?}"),
306 }
307 }
308
309 #[test]
310 /// What: Verify loading invalid JSON returns a `Json` error.
311 ///
312 /// Inputs:
313 /// - Temp file containing invalid JSON content.
314 ///
315 /// Output:
316 /// - `Err(ArchToolkitError::Json)`.
317 ///
318 /// Details:
319 /// - Corrupt cache files must surface as errors instead of empty indices.
320 fn load_invalid_json_returns_json_error() {
321 let path = temp_path("invalid");
322 std::fs::write(&path, "not valid json {").expect("write should succeed");
323
324 let result = load_from_disk(&path);
325 assert!(matches!(result, Err(ArchToolkitError::Json(_))));
326
327 let _ = std::fs::remove_file(&path);
328 }
329
330 #[test]
331 /// What: Verify saving creates missing parent directories.
332 ///
333 /// Inputs:
334 /// - Path with a non-existent parent directory.
335 ///
336 /// Output:
337 /// - Save succeeds and the file exists.
338 ///
339 /// Details:
340 /// - Matches Pacsea behavior of creating parent directories before writing.
341 fn save_creates_parent_directories() {
342 let mut dir = std::env::temp_dir();
343 dir.push(format!(
344 "arch_toolkit_persist_dir_{}_{}",
345 std::process::id(),
346 std::time::SystemTime::now()
347 .duration_since(std::time::UNIX_EPOCH)
348 .expect("System time is before UNIX epoch")
349 .as_nanos()
350 ));
351 let path = dir.join("nested").join("index.json");
352
353 let index = sample_index();
354 save_to_disk(&index, &path).expect("save should create parent dirs");
355 assert!(path.exists());
356
357 let _ = std::fs::remove_dir_all(&dir);
358 }
359
360 #[test]
361 /// What: Verify saving an empty index succeeds (with a warning logged).
362 ///
363 /// Inputs:
364 /// - Default (empty) `OfficialIndex`.
365 ///
366 /// Output:
367 /// - Save succeeds and the loaded index is empty.
368 ///
369 /// Details:
370 /// - Empty indices are legal but unusual; they warn rather than fail.
371 fn save_empty_index_succeeds() {
372 let index = OfficialIndex::default();
373 let path = temp_path("empty");
374
375 save_to_disk(&index, &path).expect("save should succeed");
376 let loaded = load_from_disk(&path).expect("load should succeed");
377 assert!(loaded.pkgs.is_empty());
378
379 let _ = std::fs::remove_file(&path);
380 }
381
382 #[test]
383 /// What: Verify the serialized file does not contain the derived name index.
384 ///
385 /// Inputs:
386 /// - Sample index with populated `name_to_idx` saved to disk.
387 ///
388 /// Output:
389 /// - File content lacks the `name_to_idx` key.
390 ///
391 /// Details:
392 /// - The lookup map is derived data and must be rebuilt on load, not persisted.
393 fn saved_file_skips_name_index() {
394 let index = sample_index();
395 let path = temp_path("skipidx");
396
397 save_to_disk(&index, &path).expect("save should succeed");
398 let content = std::fs::read_to_string(&path).expect("read should succeed");
399 assert!(!content.contains("name_to_idx"));
400
401 let _ = std::fs::remove_file(&path);
402 }
403
404 #[cfg(feature = "index")]
405 #[tokio::test]
406 /// What: Verify the async save/load variants round-trip correctly.
407 ///
408 /// Inputs:
409 /// - Sample index saved and loaded via the async functions.
410 ///
411 /// Output:
412 /// - Loaded index matches the original.
413 ///
414 /// Details:
415 /// - Exercises the `spawn_blocking` wrappers end to end.
416 async fn async_save_and_load_roundtrip() {
417 let index = sample_index();
418 let path = temp_path("async");
419
420 save_to_disk_async(index.clone(), path.clone())
421 .await
422 .expect("async save should succeed");
423 let loaded = load_from_disk_async(path.clone())
424 .await
425 .expect("async load should succeed");
426
427 assert_eq!(loaded.pkgs, index.pkgs);
428 assert_eq!(loaded.name_to_idx.len(), 2);
429
430 let _ = std::fs::remove_file(&path);
431 }
432}