use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::RwLock;
use crate::persistence::config::PersistenceConfig;
use crate::persistence::types::{StorageScope, USER_METADATA_VERSION, UserMetadata};
pub const GLOBAL_INDEX_FILE: &str = "global.index.user";
pub const LOCAL_INDEX_FILE: &str = ".sqry-index.user";
#[derive(Debug)]
pub struct UserMetadataIndex {
config: PersistenceConfig,
project_root: Option<PathBuf>,
global_cache: RwLock<Option<UserMetadata>>,
local_cache: RwLock<Option<UserMetadata>>,
}
impl UserMetadataIndex {
const MAX_METADATA_BYTES: u64 = 10 * 1024 * 1024;
pub fn open(project_root: Option<&Path>, config: PersistenceConfig) -> anyhow::Result<Self> {
let global_dir = config.global_config_dir()?;
if !global_dir.exists() {
fs::create_dir_all(&global_dir)?;
}
Ok(Self {
config,
project_root: project_root.map(Path::to_path_buf),
global_cache: RwLock::new(None),
local_cache: RwLock::new(None),
})
}
pub fn path_for_scope(&self, scope: StorageScope) -> anyhow::Result<PathBuf> {
match scope {
StorageScope::Global => {
let dir = self.config.global_config_dir()?;
Ok(dir.join(GLOBAL_INDEX_FILE))
}
StorageScope::Local => {
let project_root = self
.project_root
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No project root set for local storage"))?;
let dir = self.config.local_config_dir(project_root);
Ok(dir.join(LOCAL_INDEX_FILE))
}
}
}
pub fn load(&self, scope: StorageScope) -> anyhow::Result<UserMetadata> {
let cache = match scope {
StorageScope::Global => &self.global_cache,
StorageScope::Local => &self.local_cache,
};
if let Some(cached) = cache.read().as_ref() {
return Ok(cached.clone());
}
let path = self.path_for_scope(scope)?;
let metadata = Self::load_from_path(&path)?;
*cache.write() = Some(metadata.clone());
Ok(metadata)
}
pub fn save(&self, scope: StorageScope, metadata: &UserMetadata) -> anyhow::Result<()> {
let path = self.path_for_scope(scope)?;
Self::save_to_path(&path, metadata)?;
let cache = match scope {
StorageScope::Global => &self.global_cache,
StorageScope::Local => &self.local_cache,
};
*cache.write() = Some(metadata.clone());
Ok(())
}
pub fn update<F>(&self, scope: StorageScope, f: F) -> anyhow::Result<()>
where
F: FnOnce(&mut UserMetadata) -> anyhow::Result<()>,
{
let cache = match scope {
StorageScope::Global => &self.global_cache,
StorageScope::Local => &self.local_cache,
};
let mut cache_guard = cache.write();
let path = self.path_for_scope(scope)?;
let mut metadata = Self::load_from_path(&path)?;
f(&mut metadata)?;
Self::save_to_path(&path, &metadata)?;
*cache_guard = Some(metadata);
Ok(())
}
pub fn index_size(&self, scope: StorageScope) -> anyhow::Result<u64> {
let path = self.path_for_scope(scope)?;
match fs::metadata(&path) {
Ok(meta) => Ok(meta.len()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(e.into()),
}
}
pub fn needs_rotation(&self, scope: StorageScope) -> anyhow::Result<bool> {
let size = self.index_size(scope)?;
Ok(size > self.config.max_index_bytes)
}
pub fn invalidate_cache(&self, scope: StorageScope) {
let cache = match scope {
StorageScope::Global => &self.global_cache,
StorageScope::Local => &self.local_cache,
};
*cache.write() = None;
}
pub fn invalidate_all_caches(&self) {
*self.global_cache.write() = None;
*self.local_cache.write() = None;
}
#[must_use]
pub fn has_project_root(&self) -> bool {
self.project_root.is_some()
}
#[must_use]
pub fn project_root(&self) -> Option<&Path> {
self.project_root.as_deref()
}
#[must_use]
pub fn config(&self) -> &PersistenceConfig {
&self.config
}
fn load_from_path(path: &Path) -> anyhow::Result<UserMetadata> {
if !path.exists() {
return Ok(UserMetadata::default());
}
let file_size = fs::metadata(path)?.len();
if file_size > Self::MAX_METADATA_BYTES {
anyhow::bail!(
"metadata file {} is unexpectedly large ({file_size} bytes, max {})",
path.display(),
Self::MAX_METADATA_BYTES
);
}
let data = fs::read(path)?;
let metadata: UserMetadata = match postcard::from_bytes(&data) {
Ok(m) => m,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("allocation")
|| err_str.contains("invalid")
|| err_str.contains("unexpected end")
{
let backup_path = path.with_extension("corrupt.bak");
if let Err(backup_err) = fs::copy(path, &backup_path) {
log::warn!(
"Failed to back up corrupted file {}: {}",
path.display(),
backup_err
);
} else {
log::warn!(
"User metadata at {} was corrupted and has been backed up to {}. \
Starting with fresh metadata. Error: {}",
path.display(),
backup_path.display(),
e
);
}
if let Err(rm_err) = fs::remove_file(path) {
log::warn!(
"Failed to remove corrupted file {}: {}",
path.display(),
rm_err
);
}
return Ok(UserMetadata::default());
}
return Err(anyhow::anyhow!(
"Failed to deserialize user metadata from {}: {}. \
The index may be corrupted. Try removing the file and recreating your aliases.",
path.display(),
e
));
}
};
if metadata.version != USER_METADATA_VERSION {
anyhow::bail!(
"Unsupported user metadata version {} (expected {}). \
Please upgrade sqry or remove the index file at {}",
metadata.version,
USER_METADATA_VERSION,
path.display()
);
}
Ok(metadata)
}
fn save_to_path(path: &Path, metadata: &UserMetadata) -> anyhow::Result<()> {
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let temp_name = format!(
"{}.tmp.{}",
path.file_name().and_then(|n| n.to_str()).unwrap_or("index"),
std::process::id()
);
let temp_path = path.with_file_name(temp_name);
{
let data = postcard::to_allocvec(metadata)
.map_err(|e| anyhow::anyhow!("Failed to serialize user metadata: {e}"))?;
let mut file = File::create(&temp_path)?;
file.write_all(&data)?;
file.flush()?;
file.sync_all()?;
}
fs::rename(&temp_path, path)?;
Ok(())
}
}
pub fn open_shared_index(
project_root: Option<&Path>,
config: PersistenceConfig,
) -> anyhow::Result<Arc<UserMetadataIndex>> {
let index = UserMetadataIndex::open(project_root, config)?;
Ok(Arc::new(index))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::persistence::types::SavedAlias;
use chrono::Utc;
use tempfile::TempDir;
fn test_config(dir: &TempDir) -> PersistenceConfig {
PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
local_dir_override: None,
history_enabled: true,
max_history_entries: 100,
max_index_bytes: 1024 * 1024,
redact_secrets: false,
}
}
#[test]
fn test_open_creates_global_dir() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let global_dir = config.global_config_dir().unwrap();
assert!(!global_dir.exists());
let _index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
assert!(global_dir.exists());
}
#[test]
fn test_load_returns_default_for_missing_file() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let metadata = index.load(StorageScope::Global).unwrap();
assert_eq!(metadata.version, USER_METADATA_VERSION);
assert!(metadata.aliases.is_empty());
assert!(metadata.history.entries.is_empty());
}
#[test]
fn test_save_and_load_roundtrip() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let mut metadata = UserMetadata::default();
metadata.aliases.insert(
"test".to_string(),
SavedAlias {
command: "search".to_string(),
args: vec!["main".to_string()],
created: Utc::now(),
description: Some("Test alias".to_string()),
},
);
index.save(StorageScope::Global, &metadata).unwrap();
index.invalidate_cache(StorageScope::Global);
let loaded = index.load(StorageScope::Global).unwrap();
assert_eq!(loaded.aliases.len(), 1);
assert!(loaded.aliases.contains_key("test"));
assert_eq!(loaded.aliases["test"].command, "search");
}
#[test]
fn test_update_atomic() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
index
.update(StorageScope::Global, |m| {
m.aliases.insert(
"first".to_string(),
SavedAlias {
command: "query".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
Ok(())
})
.unwrap();
index
.update(StorageScope::Global, |m| {
m.aliases.insert(
"second".to_string(),
SavedAlias {
command: "search".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
Ok(())
})
.unwrap();
let metadata = index.load(StorageScope::Global).unwrap();
assert_eq!(metadata.aliases.len(), 2);
assert!(metadata.aliases.contains_key("first"));
assert!(metadata.aliases.contains_key("second"));
}
#[test]
fn test_local_and_global_scopes_independent() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
index
.update(StorageScope::Global, |m| {
m.aliases.insert(
"global-alias".to_string(),
SavedAlias {
command: "query".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
Ok(())
})
.unwrap();
index
.update(StorageScope::Local, |m| {
m.aliases.insert(
"local-alias".to_string(),
SavedAlias {
command: "search".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
Ok(())
})
.unwrap();
let global = index.load(StorageScope::Global).unwrap();
let local = index.load(StorageScope::Local).unwrap();
assert_eq!(global.aliases.len(), 1);
assert!(global.aliases.contains_key("global-alias"));
assert_eq!(local.aliases.len(), 1);
assert!(local.aliases.contains_key("local-alias"));
}
#[test]
fn test_path_for_scope() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config.clone()).unwrap();
let global_path = index.path_for_scope(StorageScope::Global).unwrap();
assert!(global_path.ends_with(GLOBAL_INDEX_FILE));
let local_path = index.path_for_scope(StorageScope::Local).unwrap();
assert!(local_path.ends_with(LOCAL_INDEX_FILE));
}
#[test]
fn test_index_size() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
assert_eq!(index.index_size(StorageScope::Global).unwrap(), 0);
let metadata = UserMetadata::default();
index.save(StorageScope::Global, &metadata).unwrap();
let size = index.index_size(StorageScope::Global).unwrap();
assert!(size > 0);
}
#[test]
fn test_needs_rotation() {
let dir = TempDir::new().unwrap();
let config = PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
max_index_bytes: 1, ..Default::default()
};
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
assert!(!index.needs_rotation(StorageScope::Global).unwrap());
let metadata = UserMetadata::default();
index.save(StorageScope::Global, &metadata).unwrap();
assert!(index.needs_rotation(StorageScope::Global).unwrap());
}
#[test]
fn test_cache_invalidation() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let _metadata = index.load(StorageScope::Global).unwrap();
let path = index.path_for_scope(StorageScope::Global).unwrap();
let mut modified = UserMetadata::default();
modified.aliases.insert(
"external".to_string(),
SavedAlias {
command: "test".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
let data = postcard::to_allocvec(&modified).unwrap();
let mut file = File::create(&path).unwrap();
file.write_all(&data).unwrap();
file.flush().unwrap();
let cached = index.load(StorageScope::Global).unwrap();
assert!(cached.aliases.is_empty());
index.invalidate_cache(StorageScope::Global);
let fresh = index.load(StorageScope::Global).unwrap();
assert!(fresh.aliases.contains_key("external"));
}
#[test]
fn test_open_shared_index() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let shared = open_shared_index(Some(dir.path()), config).unwrap();
assert!(shared.has_project_root());
assert_eq!(shared.project_root(), Some(dir.path()));
}
#[test]
fn test_no_project_root_local_fails() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(None, config).unwrap();
assert!(!index.has_project_root());
let result = index.path_for_scope(StorageScope::Local);
assert!(result.is_err());
}
#[test]
fn test_invalidate_all_caches() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let _ = index.load(StorageScope::Global).unwrap();
let _ = index.load(StorageScope::Local).unwrap();
index.invalidate_all_caches();
let mut modified = UserMetadata::default();
modified.aliases.insert(
"post-invalidate".to_string(),
SavedAlias {
command: "search".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
index.save(StorageScope::Global, &modified).unwrap();
index.invalidate_all_caches();
let reloaded = index.load(StorageScope::Global).unwrap();
assert!(reloaded.aliases.contains_key("post-invalidate"));
}
#[test]
fn test_config_accessor() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let max_bytes = config.max_index_bytes;
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
assert_eq!(index.config().max_index_bytes, max_bytes);
}
#[test]
fn test_save_and_load_local_scope() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let mut metadata = UserMetadata::default();
metadata.aliases.insert(
"local-only".to_string(),
SavedAlias {
command: "query".to_string(),
args: vec!["main".to_string()],
created: Utc::now(),
description: None,
},
);
index.save(StorageScope::Local, &metadata).unwrap();
index.invalidate_cache(StorageScope::Local);
let loaded = index.load(StorageScope::Local).unwrap();
assert!(loaded.aliases.contains_key("local-only"));
}
#[test]
fn test_load_uses_cache_on_second_call() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let first = index.load(StorageScope::Global).unwrap();
let mut different = UserMetadata::default();
different.aliases.insert(
"should-not-be-seen".to_string(),
SavedAlias {
command: "x".to_string(),
args: vec![],
created: Utc::now(),
description: None,
},
);
index.save(StorageScope::Global, &different).unwrap();
let second = index.load(StorageScope::Global).unwrap();
assert_eq!(first.aliases.len(), 0, "Empty on first load");
assert!(second.aliases.contains_key("should-not-be-seen"));
}
#[test]
fn test_update_error_propagation() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
let result = index.update(StorageScope::Global, |_m| {
Err(anyhow::anyhow!("intentional closure error"))
});
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("intentional closure error")
);
}
#[test]
fn test_open_shared_index_without_project_root() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let shared = open_shared_index(None, config).unwrap();
assert!(!shared.has_project_root());
assert_eq!(shared.project_root(), None);
}
}