Skip to main content

sqry_cli/persistence/
alias.rs

1//! Alias management for saved queries.
2//!
3//! The `AliasManager` provides a high-level API for creating, retrieving,
4//! updating, and deleting saved query aliases.
5
6use std::sync::Arc;
7
8use chrono::Utc;
9
10use crate::persistence::index::UserMetadataIndex;
11use crate::persistence::types::{
12    AliasExportFile, AliasWithScope, ImportConflictStrategy, ImportResult, SavedAlias,
13    StorageScope, UserMetadata,
14};
15use crate::persistence::validation::{AliasNameError, validate_alias_name};
16
17/// Error type for alias operations.
18#[derive(Debug)]
19pub enum AliasError {
20    /// Alias name validation failed.
21    InvalidName(AliasNameError),
22    /// Alias not found.
23    NotFound { name: String },
24    /// Alias already exists.
25    AlreadyExists { name: String, scope: StorageScope },
26    /// Storage operation failed.
27    Storage(anyhow::Error),
28}
29
30impl std::fmt::Display for AliasError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::InvalidName(e) => write!(f, "invalid alias name: {e}"),
34            Self::NotFound { name } => write!(f, "alias '{name}' not found"),
35            Self::AlreadyExists { name, scope } => {
36                write!(f, "alias '{name}' already exists in {scope} storage")
37            }
38            Self::Storage(e) => write!(f, "storage error: {e}"),
39        }
40    }
41}
42
43impl std::error::Error for AliasError {
44    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
45        match self {
46            Self::InvalidName(e) => Some(e),
47            Self::Storage(e) => e.source(),
48            _ => None,
49        }
50    }
51}
52
53impl From<AliasNameError> for AliasError {
54    fn from(e: AliasNameError) -> Self {
55        Self::InvalidName(e)
56    }
57}
58
59impl From<anyhow::Error> for AliasError {
60    fn from(e: anyhow::Error) -> Self {
61        Self::Storage(e)
62    }
63}
64
65/// Manager for saved query aliases.
66///
67/// Provides CRUD operations for aliases stored in the user metadata index.
68/// Local aliases take precedence over global aliases when resolving by name.
69#[derive(Debug, Clone)]
70pub struct AliasManager {
71    index: Arc<UserMetadataIndex>,
72}
73
74impl AliasManager {
75    /// Create a new alias manager.
76    #[must_use]
77    pub fn new(index: Arc<UserMetadataIndex>) -> Self {
78        Self { index }
79    }
80
81    /// Save a new alias.
82    ///
83    /// # Arguments
84    ///
85    /// * `name` - The alias name (validated against naming rules)
86    /// * `command` - The command to execute (e.g., "query", "search")
87    /// * `args` - Command arguments
88    /// * `description` - Optional description
89    /// * `scope` - Where to store the alias (Global or Local)
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if:
94    /// - The name is invalid
95    /// - An alias with the same name already exists in the target scope
96    /// - The storage operation fails
97    pub fn save(
98        &self,
99        name: &str,
100        command: &str,
101        args: &[String],
102        description: Option<&str>,
103        scope: StorageScope,
104    ) -> Result<(), AliasError> {
105        // Validate name
106        validate_alias_name(name)?;
107
108        // Check if alias already exists in target scope
109        self.index
110            .update(scope, |metadata| {
111                if metadata.aliases.contains_key(name) {
112                    anyhow::bail!("alias '{name}' already exists");
113                }
114
115                let alias = SavedAlias {
116                    command: command.to_string(),
117                    args: args.to_vec(),
118                    created: Utc::now(),
119                    description: description.map(String::from),
120                };
121
122                metadata.aliases.insert(name.to_string(), alias);
123                Ok(())
124            })
125            .map_err(|e| {
126                if e.to_string().contains("already exists") {
127                    AliasError::AlreadyExists {
128                        name: name.to_string(),
129                        scope,
130                    }
131                } else {
132                    AliasError::Storage(e)
133                }
134            })
135    }
136
137    /// Get an alias by name.
138    ///
139    /// Searches local storage first (if available), then global storage.
140    /// Returns the alias and the scope it was found in.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if the alias is not found or storage fails.
145    pub fn get(&self, name: &str) -> Result<AliasWithScope, AliasError> {
146        // Check local first (if project root is set)
147        if self.index.has_project_root() {
148            let local = self.index.load(StorageScope::Local)?;
149            if let Some(alias) = local.aliases.get(name) {
150                return Ok(AliasWithScope {
151                    name: name.to_string(),
152                    alias: alias.clone(),
153                    scope: StorageScope::Local,
154                });
155            }
156        }
157
158        // Check global
159        let global = self.index.load(StorageScope::Global)?;
160        if let Some(alias) = global.aliases.get(name) {
161            return Ok(AliasWithScope {
162                name: name.to_string(),
163                alias: alias.clone(),
164                scope: StorageScope::Global,
165            });
166        }
167
168        Err(AliasError::NotFound {
169            name: name.to_string(),
170        })
171    }
172
173    /// Get an alias from a specific scope.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the alias is not found or storage fails.
178    pub fn get_from_scope(
179        &self,
180        name: &str,
181        scope: StorageScope,
182    ) -> Result<SavedAlias, AliasError> {
183        let metadata = self.index.load(scope)?;
184        metadata
185            .aliases
186            .get(name)
187            .cloned()
188            .ok_or_else(|| AliasError::NotFound {
189                name: name.to_string(),
190            })
191    }
192
193    /// List all aliases.
194    ///
195    /// Returns aliases from both local and global storage.
196    /// If an alias exists in both scopes, only the local version is returned
197    /// (local takes precedence).
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if storage fails.
202    pub fn list(&self) -> Result<Vec<AliasWithScope>, AliasError> {
203        let mut result = Vec::new();
204        let mut seen_names = std::collections::HashSet::new();
205
206        // Load local aliases first (they take precedence)
207        if self.index.has_project_root() {
208            let local = self.index.load(StorageScope::Local)?;
209            for (name, alias) in local.aliases {
210                seen_names.insert(name.clone());
211                result.push(AliasWithScope {
212                    name,
213                    alias,
214                    scope: StorageScope::Local,
215                });
216            }
217        }
218
219        // Load global aliases (skip those already in local)
220        let global = self.index.load(StorageScope::Global)?;
221        for (name, alias) in global.aliases {
222            if !seen_names.contains(&name) {
223                result.push(AliasWithScope {
224                    name,
225                    alias,
226                    scope: StorageScope::Global,
227                });
228            }
229        }
230
231        // Sort by name for consistent output
232        result.sort_by(|a, b| a.name.cmp(&b.name));
233
234        Ok(result)
235    }
236
237    /// List aliases from a specific scope only.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if storage fails.
242    pub fn list_scope(&self, scope: StorageScope) -> Result<Vec<AliasWithScope>, AliasError> {
243        let metadata = self.index.load(scope)?;
244        let mut result: Vec<AliasWithScope> = metadata
245            .aliases
246            .into_iter()
247            .map(|(name, alias)| AliasWithScope { name, alias, scope })
248            .collect();
249
250        result.sort_by(|a, b| a.name.cmp(&b.name));
251        Ok(result)
252    }
253
254    /// Delete an alias.
255    ///
256    /// If no scope is specified, deletes from both scopes.
257    /// If a scope is specified, only deletes from that scope.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the alias is not found or storage fails.
262    pub fn delete(&self, name: &str, scope: Option<StorageScope>) -> Result<(), AliasError> {
263        let mut deleted = false;
264
265        if let Some(s) = scope {
266            // Delete from specific scope
267            self.delete_in_scope(s, name, &mut deleted)?;
268        } else {
269            // Delete from both scopes
270            if self.index.has_project_root() {
271                self.delete_in_scope(StorageScope::Local, name, &mut deleted)?;
272            }
273
274            self.delete_in_scope(StorageScope::Global, name, &mut deleted)?;
275        }
276
277        if deleted {
278            Ok(())
279        } else {
280            Err(AliasError::NotFound {
281                name: name.to_string(),
282            })
283        }
284    }
285
286    fn delete_in_scope(
287        &self,
288        scope: StorageScope,
289        name: &str,
290        deleted: &mut bool,
291    ) -> Result<(), AliasError> {
292        self.index.update(scope, |metadata| {
293            if metadata.aliases.remove(name).is_some() {
294                *deleted = true;
295            }
296            Ok(())
297        })?;
298        Ok(())
299    }
300
301    /// Rename an alias.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if:
306    /// - The old alias doesn't exist
307    /// - The new name is invalid
308    /// - An alias with the new name already exists
309    /// - Storage fails
310    pub fn rename(
311        &self,
312        old_name: &str,
313        new_name: &str,
314        scope: Option<StorageScope>,
315    ) -> Result<StorageScope, AliasError> {
316        // Validate new name
317        validate_alias_name(new_name)?;
318
319        let found_scope = self.resolve_alias_scope(old_name, scope)?;
320
321        self.perform_rename(found_scope, old_name, new_name)?;
322
323        Ok(found_scope)
324    }
325
326    fn resolve_alias_scope(
327        &self,
328        old_name: &str,
329        scope: Option<StorageScope>,
330    ) -> Result<StorageScope, AliasError> {
331        if let Some(s) = scope {
332            let metadata = self.index.load(s)?;
333            if metadata.aliases.contains_key(old_name) {
334                return Ok(s);
335            }
336
337            return Err(AliasError::NotFound {
338                name: old_name.to_string(),
339            });
340        }
341
342        let mut found = None;
343        if self.index.has_project_root() {
344            let local = self.index.load(StorageScope::Local)?;
345            if local.aliases.contains_key(old_name) {
346                found = Some(StorageScope::Local);
347            }
348        }
349        if found.is_none() {
350            let global = self.index.load(StorageScope::Global)?;
351            if global.aliases.contains_key(old_name) {
352                found = Some(StorageScope::Global);
353            }
354        }
355
356        found.ok_or_else(|| AliasError::NotFound {
357            name: old_name.to_string(),
358        })
359    }
360
361    fn perform_rename(
362        &self,
363        found_scope: StorageScope,
364        old_name: &str,
365        new_name: &str,
366    ) -> Result<(), AliasError> {
367        self.index
368            .update(found_scope, |metadata| {
369                // Check new name doesn't already exist
370                if metadata.aliases.contains_key(new_name) {
371                    anyhow::bail!("alias '{new_name}' already exists");
372                }
373
374                // Remove old and insert new
375                if let Some(alias) = metadata.aliases.remove(old_name) {
376                    metadata.aliases.insert(new_name.to_string(), alias);
377                }
378                Ok(())
379            })
380            .map_err(|e| {
381                if e.to_string().contains("already exists") {
382                    AliasError::AlreadyExists {
383                        name: new_name.to_string(),
384                        scope: found_scope,
385                    }
386                } else {
387                    AliasError::Storage(e)
388                }
389            })?;
390
391        Ok(())
392    }
393
394    fn ensure_no_conflicts(
395        &self,
396        export: &AliasExportFile,
397        scope: StorageScope,
398    ) -> Result<(), AliasError> {
399        let existing = self.index.load(scope)?;
400        for name in export.aliases.keys() {
401            if existing.aliases.contains_key(name) {
402                return Err(AliasError::AlreadyExists {
403                    name: name.clone(),
404                    scope,
405                });
406            }
407        }
408        Ok(())
409    }
410
411    fn apply_import_entry(
412        metadata: &mut UserMetadata,
413        name: &str,
414        alias: &SavedAlias,
415        strategy: ImportConflictStrategy,
416        result: &mut ImportResult,
417    ) {
418        if metadata.aliases.contains_key(name) {
419            match strategy {
420                ImportConflictStrategy::Skip => {
421                    result.skipped += 1;
422                    result.skipped_names.push(name.to_string());
423                }
424                ImportConflictStrategy::Overwrite => {
425                    metadata.aliases.insert(name.to_string(), alias.clone());
426                    result.overwritten += 1;
427                }
428                ImportConflictStrategy::Fail => {
429                    // Should not reach here due to first pass check
430                    unreachable!();
431                }
432            }
433        } else {
434            metadata.aliases.insert(name.to_string(), alias.clone());
435            result.imported += 1;
436        }
437    }
438
439    /// Check if an alias exists.
440    ///
441    /// Checks both local and global storage.
442    #[must_use]
443    pub fn exists(&self, name: &str) -> bool {
444        self.get(name).is_ok()
445    }
446
447    /// Get the count of aliases in each scope.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if storage fails.
452    pub fn count(&self) -> Result<(usize, usize), AliasError> {
453        let local_count = if self.index.has_project_root() {
454            self.index.load(StorageScope::Local)?.aliases.len()
455        } else {
456            0
457        };
458        let global_count = self.index.load(StorageScope::Global)?.aliases.len();
459        Ok((local_count, global_count))
460    }
461
462    /// Import aliases from an export file.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if storage fails or conflict strategy is Fail and conflicts exist.
467    pub fn import(
468        &self,
469        export: &AliasExportFile,
470        scope: StorageScope,
471        strategy: ImportConflictStrategy,
472    ) -> Result<ImportResult, AliasError> {
473        let mut result = ImportResult {
474            imported: 0,
475            skipped: 0,
476            failed: 0,
477            overwritten: 0,
478            skipped_names: Vec::new(),
479        };
480
481        // First pass: check for conflicts if strategy is Fail
482        if strategy == ImportConflictStrategy::Fail {
483            self.ensure_no_conflicts(export, scope)?;
484        }
485
486        // Import each alias
487        self.index.update(scope, |metadata| {
488            for (name, alias) in &export.aliases {
489                Self::apply_import_entry(metadata, name, alias, strategy, &mut result);
490            }
491            Ok(())
492        })?;
493
494        Ok(result)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::persistence::config::PersistenceConfig;
502    use tempfile::TempDir;
503
504    fn setup() -> (TempDir, Arc<UserMetadataIndex>) {
505        let dir = TempDir::new().unwrap();
506        let config = PersistenceConfig {
507            global_dir_override: Some(dir.path().join("global")),
508            ..Default::default()
509        };
510        let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
511        (dir, index)
512    }
513
514    #[test]
515    fn test_save_and_get_alias() {
516        let (_dir, index) = setup();
517        let manager = AliasManager::new(index);
518
519        manager
520            .save(
521                "test-query",
522                "search",
523                &[
524                    "main".to_string(),
525                    "--kind".to_string(),
526                    "function".to_string(),
527                ],
528                Some("Find main functions"),
529                StorageScope::Global,
530            )
531            .unwrap();
532
533        let alias = manager.get("test-query").unwrap();
534        assert_eq!(alias.name, "test-query");
535        assert_eq!(alias.alias.command, "search");
536        assert_eq!(alias.alias.args, vec!["main", "--kind", "function"]);
537        assert_eq!(
538            alias.alias.description,
539            Some("Find main functions".to_string())
540        );
541        assert_eq!(alias.scope, StorageScope::Global);
542    }
543
544    #[test]
545    fn test_local_takes_precedence() {
546        let (_dir, index) = setup();
547        let manager = AliasManager::new(index);
548
549        // Save to global
550        manager
551            .save(
552                "shared",
553                "search",
554                &["global".to_string()],
555                None,
556                StorageScope::Global,
557            )
558            .unwrap();
559
560        // Save same name to local
561        manager
562            .save(
563                "shared",
564                "query",
565                &["local".to_string()],
566                None,
567                StorageScope::Local,
568            )
569            .unwrap();
570
571        // Get should return local version
572        let alias = manager.get("shared").unwrap();
573        assert_eq!(alias.alias.command, "query");
574        assert_eq!(alias.alias.args, vec!["local"]);
575        assert_eq!(alias.scope, StorageScope::Local);
576    }
577
578    #[test]
579    fn test_list_aliases() {
580        let (_dir, index) = setup();
581        let manager = AliasManager::new(index);
582
583        manager
584            .save("alpha", "search", &[], None, StorageScope::Global)
585            .unwrap();
586        manager
587            .save("beta", "query", &[], None, StorageScope::Local)
588            .unwrap();
589        manager
590            .save("gamma", "search", &[], None, StorageScope::Global)
591            .unwrap();
592
593        let list = manager.list().unwrap();
594        assert_eq!(list.len(), 3);
595        assert_eq!(list[0].name, "alpha");
596        assert_eq!(list[1].name, "beta");
597        assert_eq!(list[2].name, "gamma");
598    }
599
600    #[test]
601    fn test_delete_alias() {
602        let (_dir, index) = setup();
603        let manager = AliasManager::new(index);
604
605        manager
606            .save("to-delete", "search", &[], None, StorageScope::Global)
607            .unwrap();
608
609        assert!(manager.exists("to-delete"));
610
611        manager.delete("to-delete", None).unwrap();
612
613        assert!(!manager.exists("to-delete"));
614    }
615
616    #[test]
617    fn test_delete_from_specific_scope() {
618        let (_dir, index) = setup();
619        let manager = AliasManager::new(index);
620
621        // Save to both scopes
622        manager
623            .save(
624                "shared",
625                "search",
626                &["global".to_string()],
627                None,
628                StorageScope::Global,
629            )
630            .unwrap();
631        manager
632            .save(
633                "shared",
634                "query",
635                &["local".to_string()],
636                None,
637                StorageScope::Local,
638            )
639            .unwrap();
640
641        // Delete only from local
642        manager.delete("shared", Some(StorageScope::Local)).unwrap();
643
644        // Should still exist in global
645        let alias = manager.get("shared").unwrap();
646        assert_eq!(alias.scope, StorageScope::Global);
647        assert_eq!(alias.alias.args, vec!["global"]);
648    }
649
650    #[test]
651    fn test_rename_alias() {
652        let (_dir, index) = setup();
653        let manager = AliasManager::new(index);
654
655        manager
656            .save(
657                "old-name",
658                "search",
659                &["test".to_string()],
660                None,
661                StorageScope::Global,
662            )
663            .unwrap();
664
665        let scope = manager.rename("old-name", "new-name", None).unwrap();
666        assert_eq!(scope, StorageScope::Global);
667
668        assert!(!manager.exists("old-name"));
669        assert!(manager.exists("new-name"));
670
671        let alias = manager.get("new-name").unwrap();
672        assert_eq!(alias.alias.args, vec!["test"]);
673    }
674
675    #[test]
676    fn test_rename_to_existing_fails() {
677        let (_dir, index) = setup();
678        let manager = AliasManager::new(index);
679
680        manager
681            .save("first", "search", &[], None, StorageScope::Global)
682            .unwrap();
683        manager
684            .save("second", "query", &[], None, StorageScope::Global)
685            .unwrap();
686
687        let result = manager.rename("first", "second", None);
688        assert!(matches!(result, Err(AliasError::AlreadyExists { .. })));
689    }
690
691    #[test]
692    fn test_save_invalid_name_fails() {
693        let (_dir, index) = setup();
694        let manager = AliasManager::new(index);
695
696        let result = manager.save("123invalid", "search", &[], None, StorageScope::Global);
697        assert!(matches!(result, Err(AliasError::InvalidName(_))));
698    }
699
700    #[test]
701    fn test_get_nonexistent_fails() {
702        let (_dir, index) = setup();
703        let manager = AliasManager::new(index);
704
705        let result = manager.get("nonexistent");
706        assert!(matches!(result, Err(AliasError::NotFound { .. })));
707    }
708
709    #[test]
710    fn test_duplicate_save_fails() {
711        let (_dir, index) = setup();
712        let manager = AliasManager::new(index);
713
714        manager
715            .save("unique", "search", &[], None, StorageScope::Global)
716            .unwrap();
717
718        let result = manager.save("unique", "query", &[], None, StorageScope::Global);
719        assert!(matches!(result, Err(AliasError::AlreadyExists { .. })));
720    }
721
722    #[test]
723    fn test_count() {
724        let (_dir, index) = setup();
725        let manager = AliasManager::new(index);
726
727        assert_eq!(manager.count().unwrap(), (0, 0));
728
729        manager
730            .save("global1", "search", &[], None, StorageScope::Global)
731            .unwrap();
732        manager
733            .save("global2", "search", &[], None, StorageScope::Global)
734            .unwrap();
735        manager
736            .save("local1", "search", &[], None, StorageScope::Local)
737            .unwrap();
738
739        assert_eq!(manager.count().unwrap(), (1, 2));
740    }
741
742    #[test]
743    fn test_list_scope() {
744        let (_dir, index) = setup();
745        let manager = AliasManager::new(index);
746
747        manager
748            .save("global1", "search", &[], None, StorageScope::Global)
749            .unwrap();
750        manager
751            .save("local1", "query", &[], None, StorageScope::Local)
752            .unwrap();
753
754        let global_list = manager.list_scope(StorageScope::Global).unwrap();
755        assert_eq!(global_list.len(), 1);
756        assert_eq!(global_list[0].name, "global1");
757
758        let local_list = manager.list_scope(StorageScope::Local).unwrap();
759        assert_eq!(local_list.len(), 1);
760        assert_eq!(local_list[0].name, "local1");
761    }
762
763    #[test]
764    fn test_error_display() {
765        let err = AliasError::NotFound {
766            name: "test".to_string(),
767        };
768        assert_eq!(err.to_string(), "alias 'test' not found");
769
770        let err = AliasError::AlreadyExists {
771            name: "test".to_string(),
772            scope: StorageScope::Global,
773        };
774        assert_eq!(
775            err.to_string(),
776            "alias 'test' already exists in global storage"
777        );
778    }
779}