Skip to main content

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}