use std::path::Path;
use crate::error::{ArchToolkitError, Result};
use crate::types::index::OfficialIndex;
pub fn load_from_disk(path: &Path) -> Result<OfficialIndex> {
tracing::debug!(path = %path.display(), "Loading official index from disk");
let content = std::fs::read_to_string(path)
.map_err(|e| ArchToolkitError::io(path.display().to_string(), e))?;
let mut index: OfficialIndex = serde_json::from_str(&content)?;
index.rebuild_name_index();
tracing::debug!(
path = %path.display(),
package_count = index.pkgs.len(),
"Successfully loaded official index"
);
Ok(index)
}
#[must_use]
pub fn load_from_disk_or_default(path: &Path) -> OfficialIndex {
load_from_disk(path).unwrap_or_else(|e| {
tracing::debug!(
path = %path.display(),
error = %e,
"Failed to load official index; returning empty index"
);
OfficialIndex::default()
})
}
pub fn save_to_disk(index: &OfficialIndex, path: &Path) -> Result<()> {
if index.pkgs.is_empty() {
tracing::warn!(
path = %path.display(),
"Saving empty index to disk"
);
}
let json = serde_json::to_string(index)?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| ArchToolkitError::io(parent.display().to_string(), e))?;
}
std::fs::write(path, json).map_err(|e| ArchToolkitError::io(path.display().to_string(), e))?;
tracing::debug!(
path = %path.display(),
package_count = index.pkgs.len(),
"Successfully saved official index to disk"
);
Ok(())
}
#[cfg(feature = "index")]
pub async fn load_from_disk_async(path: std::path::PathBuf) -> Result<OfficialIndex> {
tokio::task::spawn_blocking(move || load_from_disk(&path))
.await
.map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
}
#[cfg(feature = "index")]
pub async fn save_to_disk_async(index: OfficialIndex, path: std::path::PathBuf) -> Result<()> {
tokio::task::spawn_blocking(move || save_to_disk(&index, &path))
.await
.map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::index::OfficialPackage;
fn sample_index() -> OfficialIndex {
let mut index = OfficialIndex {
pkgs: vec![
OfficialPackage {
name: "ripgrep".to_string(),
repo: "extra".to_string(),
arch: "x86_64".to_string(),
version: "14.0.0".to_string(),
description: "Fast grep".to_string(),
},
OfficialPackage {
name: "vim".to_string(),
repo: "extra".to_string(),
arch: "x86_64".to_string(),
version: "9.0".to_string(),
description: "Text editor".to_string(),
},
],
name_to_idx: std::collections::HashMap::new(),
};
index.rebuild_name_index();
index
}
fn temp_path(tag: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"arch_toolkit_persist_{tag}_{}_{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
path
}
#[test]
fn save_and_load_roundtrip() {
let index = sample_index();
let path = temp_path("roundtrip");
save_to_disk(&index, &path).expect("save should succeed");
let loaded = load_from_disk(&path).expect("load should succeed");
assert_eq!(loaded.pkgs, index.pkgs);
assert_eq!(loaded.name_to_idx.len(), 2);
let found = loaded.find_package_by_name("RIPGREP");
assert_eq!(found.map(|p| p.name.as_str()), Some("ripgrep"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_missing_file_returns_io_error() {
let path = temp_path("missing");
let result = load_from_disk(&path);
match result {
Err(ArchToolkitError::Io { path: p, .. }) => {
assert!(p.contains("arch_toolkit_persist_missing"));
}
other => panic!("Expected Io error, got {other:?}"),
}
}
#[test]
fn load_invalid_json_returns_json_error() {
let path = temp_path("invalid");
std::fs::write(&path, "not valid json {").expect("write should succeed");
let result = load_from_disk(&path);
assert!(matches!(result, Err(ArchToolkitError::Json(_))));
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_creates_parent_directories() {
let mut dir = std::env::temp_dir();
dir.push(format!(
"arch_toolkit_persist_dir_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let path = dir.join("nested").join("index.json");
let index = sample_index();
save_to_disk(&index, &path).expect("save should create parent dirs");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_empty_index_succeeds() {
let index = OfficialIndex::default();
let path = temp_path("empty");
save_to_disk(&index, &path).expect("save should succeed");
let loaded = load_from_disk(&path).expect("load should succeed");
assert!(loaded.pkgs.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn saved_file_skips_name_index() {
let index = sample_index();
let path = temp_path("skipidx");
save_to_disk(&index, &path).expect("save should succeed");
let content = std::fs::read_to_string(&path).expect("read should succeed");
assert!(!content.contains("name_to_idx"));
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "index")]
#[tokio::test]
async fn async_save_and_load_roundtrip() {
let index = sample_index();
let path = temp_path("async");
save_to_disk_async(index.clone(), path.clone())
.await
.expect("async save should succeed");
let loaded = load_from_disk_async(path.clone())
.await
.expect("async load should succeed");
assert_eq!(loaded.pkgs, index.pkgs);
assert_eq!(loaded.name_to_idx.len(), 2);
let _ = std::fs::remove_file(&path);
}
}