1use std::fs::{self, File};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use parking_lot::RwLock;
12
13use crate::persistence::config::PersistenceConfig;
14use crate::persistence::types::{StorageScope, USER_METADATA_VERSION, UserMetadata};
15
16pub const GLOBAL_INDEX_FILE: &str = "global.index.user";
18
19pub const LOCAL_INDEX_FILE: &str = ".sqry-index.user";
21
22#[derive(Debug)]
30pub struct UserMetadataIndex {
31 config: PersistenceConfig,
33
34 project_root: Option<PathBuf>,
36
37 global_cache: RwLock<Option<UserMetadata>>,
39
40 local_cache: RwLock<Option<UserMetadata>>,
42}
43
44impl UserMetadataIndex {
45 const MAX_METADATA_BYTES: u64 = 10 * 1024 * 1024;
46
47 pub fn open(project_root: Option<&Path>, config: PersistenceConfig) -> anyhow::Result<Self> {
58 let global_dir = config.global_config_dir()?;
60 if !global_dir.exists() {
61 fs::create_dir_all(&global_dir)?;
62 }
63
64 Ok(Self {
65 config,
66 project_root: project_root.map(Path::to_path_buf),
67 global_cache: RwLock::new(None),
68 local_cache: RwLock::new(None),
69 })
70 }
71
72 pub fn path_for_scope(&self, scope: StorageScope) -> anyhow::Result<PathBuf> {
78 match scope {
79 StorageScope::Global => {
80 let dir = self.config.global_config_dir()?;
81 Ok(dir.join(GLOBAL_INDEX_FILE))
82 }
83 StorageScope::Local => {
84 let project_root = self
85 .project_root
86 .as_ref()
87 .ok_or_else(|| anyhow::anyhow!("No project root set for local storage"))?;
88 let dir = self.config.local_config_dir(project_root);
89 Ok(dir.join(LOCAL_INDEX_FILE))
90 }
91 }
92 }
93
94 pub fn load(&self, scope: StorageScope) -> anyhow::Result<UserMetadata> {
102 let cache = match scope {
104 StorageScope::Global => &self.global_cache,
105 StorageScope::Local => &self.local_cache,
106 };
107
108 if let Some(cached) = cache.read().as_ref() {
109 return Ok(cached.clone());
110 }
111
112 let path = self.path_for_scope(scope)?;
114 let metadata = Self::load_from_path(&path)?;
115
116 *cache.write() = Some(metadata.clone());
118
119 Ok(metadata)
120 }
121
122 pub fn save(&self, scope: StorageScope, metadata: &UserMetadata) -> anyhow::Result<()> {
130 let path = self.path_for_scope(scope)?;
131 Self::save_to_path(&path, metadata)?;
132
133 let cache = match scope {
135 StorageScope::Global => &self.global_cache,
136 StorageScope::Local => &self.local_cache,
137 };
138 *cache.write() = Some(metadata.clone());
139
140 Ok(())
141 }
142
143 pub fn update<F>(&self, scope: StorageScope, f: F) -> anyhow::Result<()>
152 where
153 F: FnOnce(&mut UserMetadata) -> anyhow::Result<()>,
154 {
155 let cache = match scope {
156 StorageScope::Global => &self.global_cache,
157 StorageScope::Local => &self.local_cache,
158 };
159
160 let mut cache_guard = cache.write();
162
163 let path = self.path_for_scope(scope)?;
165 let mut metadata = Self::load_from_path(&path)?;
166
167 f(&mut metadata)?;
169
170 Self::save_to_path(&path, &metadata)?;
172
173 *cache_guard = Some(metadata);
175
176 Ok(())
177 }
178
179 pub fn index_size(&self, scope: StorageScope) -> anyhow::Result<u64> {
187 let path = self.path_for_scope(scope)?;
188 match fs::metadata(&path) {
189 Ok(meta) => Ok(meta.len()),
190 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
191 Err(e) => Err(e.into()),
192 }
193 }
194
195 pub fn needs_rotation(&self, scope: StorageScope) -> anyhow::Result<bool> {
201 let size = self.index_size(scope)?;
202 Ok(size > self.config.max_index_bytes)
203 }
204
205 pub fn invalidate_cache(&self, scope: StorageScope) {
209 let cache = match scope {
210 StorageScope::Global => &self.global_cache,
211 StorageScope::Local => &self.local_cache,
212 };
213 *cache.write() = None;
214 }
215
216 pub fn invalidate_all_caches(&self) {
218 *self.global_cache.write() = None;
219 *self.local_cache.write() = None;
220 }
221
222 #[must_use]
224 pub fn has_project_root(&self) -> bool {
225 self.project_root.is_some()
226 }
227
228 #[must_use]
230 pub fn project_root(&self) -> Option<&Path> {
231 self.project_root.as_deref()
232 }
233
234 #[must_use]
236 pub fn config(&self) -> &PersistenceConfig {
237 &self.config
238 }
239
240 fn load_from_path(path: &Path) -> anyhow::Result<UserMetadata> {
247 if !path.exists() {
248 return Ok(UserMetadata::default());
249 }
250
251 let file_size = fs::metadata(path)?.len();
254 if file_size > Self::MAX_METADATA_BYTES {
255 anyhow::bail!(
256 "metadata file {} is unexpectedly large ({file_size} bytes, max {})",
257 path.display(),
258 Self::MAX_METADATA_BYTES
259 );
260 }
261
262 let data = fs::read(path)?;
263
264 let metadata: UserMetadata = match postcard::from_bytes(&data) {
265 Ok(m) => m,
266 Err(e) => {
267 let err_str = e.to_string();
269 if err_str.contains("allocation")
270 || err_str.contains("invalid")
271 || err_str.contains("unexpected end")
272 {
273 let backup_path = path.with_extension("corrupt.bak");
275 if let Err(backup_err) = fs::copy(path, &backup_path) {
276 log::warn!(
277 "Failed to back up corrupted file {}: {}",
278 path.display(),
279 backup_err
280 );
281 } else {
282 log::warn!(
283 "User metadata at {} was corrupted and has been backed up to {}. \
284 Starting with fresh metadata. Error: {}",
285 path.display(),
286 backup_path.display(),
287 e
288 );
289 }
290 if let Err(rm_err) = fs::remove_file(path) {
292 log::warn!(
293 "Failed to remove corrupted file {}: {}",
294 path.display(),
295 rm_err
296 );
297 }
298 return Ok(UserMetadata::default());
300 }
301 return Err(anyhow::anyhow!(
303 "Failed to deserialize user metadata from {}: {}. \
304 The index may be corrupted. Try removing the file and recreating your aliases.",
305 path.display(),
306 e
307 ));
308 }
309 };
310
311 if metadata.version != USER_METADATA_VERSION {
313 anyhow::bail!(
314 "Unsupported user metadata version {} (expected {}). \
315 Please upgrade sqry or remove the index file at {}",
316 metadata.version,
317 USER_METADATA_VERSION,
318 path.display()
319 );
320 }
321
322 Ok(metadata)
323 }
324
325 fn save_to_path(path: &Path, metadata: &UserMetadata) -> anyhow::Result<()> {
330 if let Some(parent) = path.parent()
332 && !parent.exists()
333 {
334 fs::create_dir_all(parent)?;
335 }
336
337 let temp_name = format!(
340 "{}.tmp.{}",
341 path.file_name().and_then(|n| n.to_str()).unwrap_or("index"),
342 std::process::id()
343 );
344 let temp_path = path.with_file_name(temp_name);
345
346 {
348 let data = postcard::to_allocvec(metadata)
349 .map_err(|e| anyhow::anyhow!("Failed to serialize user metadata: {e}"))?;
350 let mut file = File::create(&temp_path)?;
351 file.write_all(&data)?;
352 file.flush()?;
353 file.sync_all()?;
355 }
356
357 fs::rename(&temp_path, path)?;
359
360 Ok(())
361 }
362}
363
364pub fn open_shared_index(
372 project_root: Option<&Path>,
373 config: PersistenceConfig,
374) -> anyhow::Result<Arc<UserMetadataIndex>> {
375 let index = UserMetadataIndex::open(project_root, config)?;
376 Ok(Arc::new(index))
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::persistence::types::SavedAlias;
383 use chrono::Utc;
384 use tempfile::TempDir;
385
386 fn test_config(dir: &TempDir) -> PersistenceConfig {
387 PersistenceConfig {
388 global_dir_override: Some(dir.path().join("global")),
389 local_dir_override: None,
390 history_enabled: true,
391 max_history_entries: 100,
392 max_index_bytes: 1024 * 1024,
393 redact_secrets: false,
394 }
395 }
396
397 #[test]
398 fn test_open_creates_global_dir() {
399 let dir = TempDir::new().unwrap();
400 let config = test_config(&dir);
401 let global_dir = config.global_config_dir().unwrap();
402
403 assert!(!global_dir.exists());
404
405 let _index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
406
407 assert!(global_dir.exists());
408 }
409
410 #[test]
411 fn test_load_returns_default_for_missing_file() {
412 let dir = TempDir::new().unwrap();
413 let config = test_config(&dir);
414 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
415
416 let metadata = index.load(StorageScope::Global).unwrap();
417
418 assert_eq!(metadata.version, USER_METADATA_VERSION);
419 assert!(metadata.aliases.is_empty());
420 assert!(metadata.history.entries.is_empty());
421 }
422
423 #[test]
424 fn test_save_and_load_roundtrip() {
425 let dir = TempDir::new().unwrap();
426 let config = test_config(&dir);
427 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
428
429 let mut metadata = UserMetadata::default();
430 metadata.aliases.insert(
431 "test".to_string(),
432 SavedAlias {
433 command: "search".to_string(),
434 args: vec!["main".to_string()],
435 created: Utc::now(),
436 description: Some("Test alias".to_string()),
437 },
438 );
439
440 index.save(StorageScope::Global, &metadata).unwrap();
441
442 index.invalidate_cache(StorageScope::Global);
444
445 let loaded = index.load(StorageScope::Global).unwrap();
446
447 assert_eq!(loaded.aliases.len(), 1);
448 assert!(loaded.aliases.contains_key("test"));
449 assert_eq!(loaded.aliases["test"].command, "search");
450 }
451
452 #[test]
453 fn test_update_atomic() {
454 let dir = TempDir::new().unwrap();
455 let config = test_config(&dir);
456 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
457
458 index
460 .update(StorageScope::Global, |m| {
461 m.aliases.insert(
462 "first".to_string(),
463 SavedAlias {
464 command: "query".to_string(),
465 args: vec![],
466 created: Utc::now(),
467 description: None,
468 },
469 );
470 Ok(())
471 })
472 .unwrap();
473
474 index
476 .update(StorageScope::Global, |m| {
477 m.aliases.insert(
478 "second".to_string(),
479 SavedAlias {
480 command: "search".to_string(),
481 args: vec![],
482 created: Utc::now(),
483 description: None,
484 },
485 );
486 Ok(())
487 })
488 .unwrap();
489
490 let metadata = index.load(StorageScope::Global).unwrap();
491 assert_eq!(metadata.aliases.len(), 2);
492 assert!(metadata.aliases.contains_key("first"));
493 assert!(metadata.aliases.contains_key("second"));
494 }
495
496 #[test]
497 fn test_local_and_global_scopes_independent() {
498 let dir = TempDir::new().unwrap();
499 let config = test_config(&dir);
500 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
501
502 index
504 .update(StorageScope::Global, |m| {
505 m.aliases.insert(
506 "global-alias".to_string(),
507 SavedAlias {
508 command: "query".to_string(),
509 args: vec![],
510 created: Utc::now(),
511 description: None,
512 },
513 );
514 Ok(())
515 })
516 .unwrap();
517
518 index
520 .update(StorageScope::Local, |m| {
521 m.aliases.insert(
522 "local-alias".to_string(),
523 SavedAlias {
524 command: "search".to_string(),
525 args: vec![],
526 created: Utc::now(),
527 description: None,
528 },
529 );
530 Ok(())
531 })
532 .unwrap();
533
534 let global = index.load(StorageScope::Global).unwrap();
535 let local = index.load(StorageScope::Local).unwrap();
536
537 assert_eq!(global.aliases.len(), 1);
538 assert!(global.aliases.contains_key("global-alias"));
539
540 assert_eq!(local.aliases.len(), 1);
541 assert!(local.aliases.contains_key("local-alias"));
542 }
543
544 #[test]
545 fn test_path_for_scope() {
546 let dir = TempDir::new().unwrap();
547 let config = test_config(&dir);
548 let index = UserMetadataIndex::open(Some(dir.path()), config.clone()).unwrap();
549
550 let global_path = index.path_for_scope(StorageScope::Global).unwrap();
551 assert!(global_path.ends_with(GLOBAL_INDEX_FILE));
552
553 let local_path = index.path_for_scope(StorageScope::Local).unwrap();
554 assert!(local_path.ends_with(LOCAL_INDEX_FILE));
555 }
556
557 #[test]
558 fn test_index_size() {
559 let dir = TempDir::new().unwrap();
560 let config = test_config(&dir);
561 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
562
563 assert_eq!(index.index_size(StorageScope::Global).unwrap(), 0);
565
566 let metadata = UserMetadata::default();
568 index.save(StorageScope::Global, &metadata).unwrap();
569
570 let size = index.index_size(StorageScope::Global).unwrap();
572 assert!(size > 0);
573 }
574
575 #[test]
576 fn test_needs_rotation() {
577 let dir = TempDir::new().unwrap();
578 let config = PersistenceConfig {
579 global_dir_override: Some(dir.path().join("global")),
580 max_index_bytes: 1, ..Default::default()
582 };
583 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
584
585 assert!(!index.needs_rotation(StorageScope::Global).unwrap());
587
588 let metadata = UserMetadata::default();
590 index.save(StorageScope::Global, &metadata).unwrap();
591
592 assert!(index.needs_rotation(StorageScope::Global).unwrap());
594 }
595
596 #[test]
597 fn test_cache_invalidation() {
598 let dir = TempDir::new().unwrap();
599 let config = test_config(&dir);
600 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
601
602 let _metadata = index.load(StorageScope::Global).unwrap();
604
605 let path = index.path_for_scope(StorageScope::Global).unwrap();
607 let mut modified = UserMetadata::default();
608 modified.aliases.insert(
609 "external".to_string(),
610 SavedAlias {
611 command: "test".to_string(),
612 args: vec![],
613 created: Utc::now(),
614 description: None,
615 },
616 );
617
618 let data = postcard::to_allocvec(&modified).unwrap();
619 let mut file = File::create(&path).unwrap();
620 file.write_all(&data).unwrap();
621 file.flush().unwrap();
622
623 let cached = index.load(StorageScope::Global).unwrap();
625 assert!(cached.aliases.is_empty());
626
627 index.invalidate_cache(StorageScope::Global);
629 let fresh = index.load(StorageScope::Global).unwrap();
630 assert!(fresh.aliases.contains_key("external"));
631 }
632
633 #[test]
634 fn test_open_shared_index() {
635 let dir = TempDir::new().unwrap();
636 let config = test_config(&dir);
637
638 let shared = open_shared_index(Some(dir.path()), config).unwrap();
639
640 assert!(shared.has_project_root());
641 assert_eq!(shared.project_root(), Some(dir.path()));
642 }
643
644 #[test]
645 fn test_no_project_root_local_fails() {
646 let dir = TempDir::new().unwrap();
647 let config = test_config(&dir);
648 let index = UserMetadataIndex::open(None, config).unwrap();
649
650 assert!(!index.has_project_root());
651
652 let result = index.path_for_scope(StorageScope::Local);
653 assert!(result.is_err());
654 }
655
656 #[test]
657 fn test_invalidate_all_caches() {
658 let dir = TempDir::new().unwrap();
659 let config = test_config(&dir);
660 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
661
662 let _ = index.load(StorageScope::Global).unwrap();
664 let _ = index.load(StorageScope::Local).unwrap();
665
666 index.invalidate_all_caches();
668
669 let mut modified = UserMetadata::default();
671 modified.aliases.insert(
672 "post-invalidate".to_string(),
673 SavedAlias {
674 command: "search".to_string(),
675 args: vec![],
676 created: Utc::now(),
677 description: None,
678 },
679 );
680 index.save(StorageScope::Global, &modified).unwrap();
681 index.invalidate_all_caches();
682 let reloaded = index.load(StorageScope::Global).unwrap();
683 assert!(reloaded.aliases.contains_key("post-invalidate"));
684 }
685
686 #[test]
687 fn test_config_accessor() {
688 let dir = TempDir::new().unwrap();
689 let config = test_config(&dir);
690 let max_bytes = config.max_index_bytes;
691 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
692
693 assert_eq!(index.config().max_index_bytes, max_bytes);
694 }
695
696 #[test]
697 fn test_save_and_load_local_scope() {
698 let dir = TempDir::new().unwrap();
699 let config = test_config(&dir);
700 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
701
702 let mut metadata = UserMetadata::default();
703 metadata.aliases.insert(
704 "local-only".to_string(),
705 SavedAlias {
706 command: "query".to_string(),
707 args: vec!["main".to_string()],
708 created: Utc::now(),
709 description: None,
710 },
711 );
712
713 index.save(StorageScope::Local, &metadata).unwrap();
714 index.invalidate_cache(StorageScope::Local);
715 let loaded = index.load(StorageScope::Local).unwrap();
716
717 assert!(loaded.aliases.contains_key("local-only"));
718 }
719
720 #[test]
721 fn test_load_uses_cache_on_second_call() {
722 let dir = TempDir::new().unwrap();
723 let config = test_config(&dir);
724 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
725
726 let first = index.load(StorageScope::Global).unwrap();
728
729 let mut different = UserMetadata::default();
731 different.aliases.insert(
732 "should-not-be-seen".to_string(),
733 SavedAlias {
734 command: "x".to_string(),
735 args: vec![],
736 created: Utc::now(),
737 description: None,
738 },
739 );
740 index.save(StorageScope::Global, &different).unwrap();
741 let second = index.load(StorageScope::Global).unwrap();
746
747 assert_eq!(first.aliases.len(), 0, "Empty on first load");
750 assert!(second.aliases.contains_key("should-not-be-seen"));
752 }
753
754 #[test]
755 fn test_update_error_propagation() {
756 let dir = TempDir::new().unwrap();
757 let config = test_config(&dir);
758 let index = UserMetadataIndex::open(Some(dir.path()), config).unwrap();
759
760 let result = index.update(StorageScope::Global, |_m| {
761 Err(anyhow::anyhow!("intentional closure error"))
762 });
763
764 assert!(result.is_err());
765 assert!(
766 result
767 .unwrap_err()
768 .to_string()
769 .contains("intentional closure error")
770 );
771 }
772
773 #[test]
774 fn test_open_shared_index_without_project_root() {
775 let dir = TempDir::new().unwrap();
776 let config = test_config(&dir);
777 let shared = open_shared_index(None, config).unwrap();
778 assert!(!shared.has_project_root());
779 assert_eq!(shared.project_root(), None);
780 }
781}