Skip to main content

sqry_cli/commands/
alias.rs

1//! Alias management command implementation
2//!
3//! Handles the `sqry alias` subcommand for managing saved query aliases.
4
5use crate::args::{AliasAction, Cli, ImportConflictArg};
6use crate::output::OutputStreams;
7use crate::persistence::{
8    AliasError, AliasExportFile, AliasManager, ImportConflictStrategy, PersistenceConfig,
9    StorageScope, open_shared_index,
10};
11use anyhow::{Context, Result, bail};
12use std::fs;
13use std::io::{self, Read as IoRead, Write as IoWrite};
14use std::path::Path;
15
16/// Run the alias command.
17///
18/// # Errors
19/// Returns an error if persistence operations fail or output cannot be written.
20pub fn run_alias(cli: &Cli, action: &AliasAction) -> Result<()> {
21    match action {
22        AliasAction::List { local, global } => run_list(cli, *local, *global),
23        AliasAction::Show { name } => run_show(cli, name),
24        AliasAction::Delete {
25            name,
26            local,
27            global,
28            force,
29        } => run_delete(cli, name, *local, *global, *force),
30        AliasAction::Rename {
31            old_name,
32            new_name,
33            local,
34            global,
35        } => run_rename(cli, old_name, new_name, *local, *global),
36        AliasAction::Export {
37            file,
38            local,
39            global,
40        } => run_export(cli, file, *local, *global),
41        AliasAction::Import {
42            file,
43            local,
44            global,
45            on_conflict,
46            dry_run,
47        } => run_import(cli, file, *local, *global, *on_conflict, *dry_run),
48    }
49}
50
51/// List all aliases
52fn run_list(cli: &Cli, local_only: bool, global_only: bool) -> Result<()> {
53    let config = PersistenceConfig::from_env();
54    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
55    let manager = AliasManager::new(index);
56    let mut streams = OutputStreams::with_pager(cli.pager_config());
57
58    let aliases = manager.list()?;
59    let filtered = filter_aliases(&aliases, local_only, global_only);
60
61    if cli.json {
62        write_aliases_json(&mut streams, &filtered)?;
63    } else {
64        write_aliases_text(&mut streams, &filtered)?;
65    }
66
67    streams.finish_checked()
68}
69
70/// Show details of a specific alias
71fn run_show(cli: &Cli, name: &str) -> Result<()> {
72    let config = PersistenceConfig::from_env();
73    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
74    let manager = AliasManager::new(index);
75    let mut streams = OutputStreams::with_pager(cli.pager_config());
76
77    match manager.get(name) {
78        Ok(alias_with_scope) => {
79            if cli.json {
80                let output = serde_json::json!({
81                    "name": alias_with_scope.name,
82                    "command": alias_with_scope.alias.command,
83                    "args": alias_with_scope.alias.args,
84                    "description": alias_with_scope.alias.description,
85                    "scope": match alias_with_scope.scope {
86                        StorageScope::Global => "global",
87                        StorageScope::Local => "local",
88                    },
89                    "created": alias_with_scope.alias.created.to_rfc3339(),
90                });
91                streams.write_result(&serde_json::to_string_pretty(&output)?)?;
92            } else {
93                let scope_label = match alias_with_scope.scope {
94                    StorageScope::Global => "global",
95                    StorageScope::Local => "local",
96                };
97                streams.write_result(&format!("Alias: @{}\n", alias_with_scope.name))?;
98                streams.write_result(&format!("  Scope: {scope_label}\n"))?;
99                streams
100                    .write_result(&format!("  Command: {}\n", alias_with_scope.alias.command))?;
101                if !alias_with_scope.alias.args.is_empty() {
102                    streams.write_result(&format!(
103                        "  Arguments: {}\n",
104                        alias_with_scope.alias.args.join(" ")
105                    ))?;
106                }
107                if let Some(desc) = &alias_with_scope.alias.description {
108                    streams.write_result(&format!("  Description: {desc}\n"))?;
109                }
110                streams.write_result(&format!(
111                    "  Created: {}\n",
112                    alias_with_scope.alias.created.format("%Y-%m-%d %H:%M:%S")
113                ))?;
114            }
115        }
116        Err(AliasError::NotFound { name: n }) => {
117            bail!("Alias '@{n}' not found");
118        }
119        Err(e) => return Err(e.into()),
120    }
121
122    streams.finish_checked()
123}
124
125/// Delete an alias
126fn run_delete(cli: &Cli, name: &str, local: bool, global: bool, force: bool) -> Result<()> {
127    let config = PersistenceConfig::from_env();
128    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
129    let manager = AliasManager::new(index);
130    // Interactive prompt must not be buffered behind the pager or the command will appear hung.
131    let mut streams = if !force && !cli.json {
132        OutputStreams::new()
133    } else {
134        OutputStreams::with_pager(cli.pager_config())
135    };
136
137    // Determine scope - if both are false, auto-detect where alias exists
138    let scope = if local {
139        Some(StorageScope::Local)
140    } else if global {
141        Some(StorageScope::Global)
142    } else {
143        // Auto-detect: find where the alias exists
144        match manager.get(name) {
145            Ok(alias_with_scope) => Some(alias_with_scope.scope),
146            Err(AliasError::NotFound { .. }) => {
147                bail!("Alias '@{name}' not found");
148            }
149            Err(e) => return Err(e.into()),
150        }
151    };
152
153    let scope = scope.unwrap();
154
155    // Confirm deletion unless --force
156    if !force && !cli.json {
157        streams.write_result(&format!(
158            "Delete alias '@{name}' from {} storage? [y/N] ",
159            match scope {
160                StorageScope::Global => "global",
161                StorageScope::Local => "local",
162            }
163        ))?;
164        io::stdout().flush()?;
165
166        let mut input = String::new();
167        io::stdin().read_line(&mut input)?;
168        if !input.trim().eq_ignore_ascii_case("y") {
169            streams.write_result("Cancelled.\n")?;
170            return streams.finish_checked();
171        }
172    }
173
174    manager.delete(name, Some(scope))?;
175
176    if cli.json {
177        let output = serde_json::json!({
178            "deleted": name,
179            "scope": match scope {
180                StorageScope::Global => "global",
181                StorageScope::Local => "local",
182            },
183        });
184        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
185    } else {
186        streams.write_result(&format!("Deleted alias '@{name}'.\n"))?;
187    }
188
189    streams.finish_checked()
190}
191
192/// Rename an alias
193fn run_rename(cli: &Cli, old_name: &str, new_name: &str, local: bool, global: bool) -> Result<()> {
194    let config = PersistenceConfig::from_env();
195    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
196    let manager = AliasManager::new(index);
197    let mut streams = OutputStreams::with_pager(cli.pager_config());
198
199    // Determine scope
200    let scope = if local {
201        Some(StorageScope::Local)
202    } else if global {
203        Some(StorageScope::Global)
204    } else {
205        None
206    };
207
208    let result_scope = manager.rename(old_name, new_name, scope)?;
209
210    if cli.json {
211        let output = serde_json::json!({
212            "old_name": old_name,
213            "new_name": new_name,
214            "scope": match result_scope {
215                StorageScope::Global => "global",
216                StorageScope::Local => "local",
217            },
218        });
219        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
220    } else {
221        streams.write_result(&format!("Renamed '@{old_name}' to '@{new_name}'.\n"))?;
222    }
223
224    streams.finish_checked()
225}
226
227/// Export aliases to a file
228fn run_export(cli: &Cli, file: &str, local_only: bool, global_only: bool) -> Result<()> {
229    let config = PersistenceConfig::from_env();
230    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
231    let manager = AliasManager::new(index);
232    let mut streams = OutputStreams::with_pager(cli.pager_config());
233
234    let aliases = manager.list()?;
235
236    // Filter by scope if requested
237    let filtered: Vec<_> = aliases
238        .into_iter()
239        .filter(|a| {
240            if local_only {
241                matches!(a.scope, StorageScope::Local)
242            } else if global_only {
243                matches!(a.scope, StorageScope::Global)
244            } else {
245                true
246            }
247        })
248        .collect();
249
250    let export = AliasExportFile::from_aliases(&filtered);
251    let json = serde_json::to_string_pretty(&export).context("Failed to serialize aliases")?;
252
253    if file == "-" {
254        streams.write_result(&json)?;
255    } else {
256        fs::write(file, &json).with_context(|| format!("Failed to write to {file}"))?;
257        if !cli.json {
258            streams.write_result(&format!(
259                "Exported {} aliases to '{}'.\n",
260                filtered.len(),
261                file
262            ))?;
263        }
264    }
265
266    if cli.json && file != "-" {
267        let output = serde_json::json!({
268            "exported": filtered.len(),
269            "file": file,
270        });
271        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
272    }
273
274    streams.finish_checked()
275}
276
277/// Import aliases from a file
278fn run_import(
279    cli: &Cli,
280    file: &str,
281    _local: bool,
282    global: bool,
283    on_conflict: ImportConflictArg,
284    dry_run: bool,
285) -> Result<()> {
286    let config = PersistenceConfig::from_env();
287    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
288    let manager = AliasManager::new(index);
289    let mut streams = OutputStreams::with_pager(cli.pager_config());
290
291    let scope = import_scope_from_flags(global);
292    let json = read_import_input(file)?;
293    let export = parse_alias_export_file(file, &json)?;
294    let strategy = import_strategy_from_arg(on_conflict);
295
296    if dry_run {
297        let preview = preview_import(&manager, &export, strategy)?;
298        write_import_preview(&mut streams, cli, &preview)?;
299    } else {
300        let result = manager.import(&export, scope, strategy)?;
301        write_import_result(&mut streams, cli, &export, scope, &result)?;
302    }
303
304    streams.finish_checked()
305}
306
307fn filter_aliases(
308    aliases: &[crate::persistence::AliasWithScope],
309    local_only: bool,
310    global_only: bool,
311) -> Vec<&crate::persistence::AliasWithScope> {
312    aliases
313        .iter()
314        .filter(|a| {
315            if local_only {
316                matches!(a.scope, StorageScope::Local)
317            } else if global_only {
318                matches!(a.scope, StorageScope::Global)
319            } else {
320                true
321            }
322        })
323        .collect()
324}
325
326fn write_aliases_json(
327    streams: &mut OutputStreams,
328    aliases: &[&crate::persistence::AliasWithScope],
329) -> Result<()> {
330    let json_aliases: Vec<_> = aliases
331        .iter()
332        .map(|a| {
333            serde_json::json!({
334                "name": a.name,
335                "command": a.alias.command,
336                "args": a.alias.args,
337                "description": a.alias.description,
338                "scope": match a.scope {
339                    StorageScope::Global => "global",
340                    StorageScope::Local => "local",
341                },
342                "created": a.alias.created.to_rfc3339(),
343            })
344        })
345        .collect();
346    let output = serde_json::to_string_pretty(&json_aliases)?;
347    streams.write_result(&output)?;
348    Ok(())
349}
350
351fn write_aliases_text(
352    streams: &mut OutputStreams,
353    aliases: &[&crate::persistence::AliasWithScope],
354) -> Result<()> {
355    if aliases.is_empty() {
356        streams.write_result("No aliases found.")?;
357        return Ok(());
358    }
359
360    streams.write_result(&format!("Aliases ({}):\n", aliases.len()))?;
361    for alias in aliases {
362        let scope_label = match alias.scope {
363            StorageScope::Global => "[global]",
364            StorageScope::Local => "[local]",
365        };
366        let desc = alias
367            .alias
368            .description
369            .as_ref()
370            .map(|d| format!(" - {d}"))
371            .unwrap_or_default();
372        let args_str = if alias.alias.args.is_empty() {
373            String::new()
374        } else {
375            format!(" {}", alias.alias.args.join(" "))
376        };
377        streams.write_result(&format!(
378            "  @{} {} => {}{}{}\n",
379            alias.name, scope_label, alias.alias.command, args_str, desc
380        ))?;
381    }
382
383    Ok(())
384}
385
386fn import_scope_from_flags(global: bool) -> StorageScope {
387    if global {
388        StorageScope::Global
389    } else {
390        StorageScope::Local
391    }
392}
393
394fn read_import_input(file: &str) -> Result<String> {
395    if file == "-" {
396        let mut buf = String::new();
397        io::stdin()
398            .read_to_string(&mut buf)
399            .context("Failed to read from stdin")?;
400        Ok(buf)
401    } else {
402        fs::read_to_string(file).with_context(|| format!("Failed to read from {file}"))
403    }
404}
405
406fn parse_alias_export_file(file: &str, json: &str) -> Result<AliasExportFile> {
407    let trimmed = json.trim_start();
408    let source = if file == "-" { "stdin" } else { file };
409    if trimmed.is_empty() {
410        bail!(
411            "Alias export file '{source}' is empty. Expected the output of `sqry alias export` \
412             (a JSON object with `version`, `exported_at`, and `aliases`)."
413        );
414    }
415    let first = trimmed.as_bytes()[0];
416    if first != b'{' {
417        let shape = match first {
418            b'[' => "a JSON array",
419            b'"' => "a JSON string",
420            b't' | b'f' => "a JSON boolean",
421            b'n' => "JSON null",
422            b'-' | b'0'..=b'9' => "a JSON number",
423            _ => "non-JSON content",
424        };
425        bail!(
426            "Alias export file '{source}' contains {shape}, not a JSON object. \
427             Expected the output of `sqry alias export` (a JSON object with `version`, \
428             `exported_at`, and `aliases`). The file was likely written by another \
429             tool or overwritten before import."
430        );
431    }
432    serde_json::from_str(json)
433        .with_context(|| format!("Failed to parse alias export file '{source}'"))
434}
435
436fn import_strategy_from_arg(on_conflict: ImportConflictArg) -> ImportConflictStrategy {
437    match on_conflict {
438        ImportConflictArg::Error => ImportConflictStrategy::Fail,
439        ImportConflictArg::Skip => ImportConflictStrategy::Skip,
440        ImportConflictArg::Overwrite => ImportConflictStrategy::Overwrite,
441    }
442}
443
444struct ImportPreview {
445    would_import: usize,
446    would_skip: usize,
447    would_conflict: usize,
448    total: usize,
449}
450
451fn preview_import(
452    manager: &AliasManager,
453    export: &AliasExportFile,
454    strategy: ImportConflictStrategy,
455) -> Result<ImportPreview> {
456    let mut would_import = 0;
457    let mut would_skip = 0;
458    let mut would_conflict = 0;
459
460    for name in export.aliases.keys() {
461        match manager.get(name) {
462            Ok(_) => match strategy {
463                ImportConflictStrategy::Fail => would_conflict += 1,
464                ImportConflictStrategy::Skip => would_skip += 1,
465                ImportConflictStrategy::Overwrite => would_import += 1,
466            },
467            Err(AliasError::NotFound { .. }) => would_import += 1,
468            Err(e) => return Err(e.into()),
469        }
470    }
471
472    Ok(ImportPreview {
473        would_import,
474        would_skip,
475        would_conflict,
476        total: export.aliases.len(),
477    })
478}
479
480fn write_import_preview(
481    streams: &mut OutputStreams,
482    cli: &Cli,
483    preview: &ImportPreview,
484) -> Result<()> {
485    if cli.json {
486        let output = serde_json::json!({
487            "dry_run": true,
488            "would_import": preview.would_import,
489            "would_skip": preview.would_skip,
490            "would_conflict": preview.would_conflict,
491            "total": preview.total,
492        });
493        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
494    } else {
495        streams.write_result(&format!(
496            "Dry run: {} aliases would be imported, {} skipped, {} conflicts.\n",
497            preview.would_import, preview.would_skip, preview.would_conflict
498        ))?;
499    }
500    Ok(())
501}
502
503fn write_import_result(
504    streams: &mut OutputStreams,
505    cli: &Cli,
506    export: &AliasExportFile,
507    scope: StorageScope,
508    result: &crate::persistence::ImportResult,
509) -> Result<()> {
510    if cli.json {
511        let output = serde_json::json!({
512            "imported": result.imported,
513            "skipped": result.skipped,
514            "overwritten": result.overwritten,
515            "total": export.aliases.len(),
516            "scope": match scope {
517                StorageScope::Global => "global",
518                StorageScope::Local => "local",
519            },
520        });
521        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
522    } else {
523        let scope_label = match scope {
524            StorageScope::Global => "global",
525            StorageScope::Local => "local",
526        };
527        streams.write_result(&format!(
528            "Imported {} aliases to {} storage ({} skipped, {} overwritten).\n",
529            result.imported, scope_label, result.skipped, result.overwritten
530        ))?;
531    }
532    Ok(())
533}
534
535/// Save a search command as an alias.
536///
537/// Called when user runs `sqry search <pattern> --save-as <name>`.
538///
539/// # Errors
540/// Returns an error if persistence operations fail or output cannot be written.
541pub fn save_search_alias(
542    cli: &Cli,
543    name: &str,
544    pattern: &str,
545    global: bool,
546    description: Option<&str>,
547) -> Result<()> {
548    let config = PersistenceConfig::from_env();
549    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
550    let manager = AliasManager::new(index);
551    let mut streams = OutputStreams::with_pager(cli.pager_config());
552
553    let scope = if global {
554        StorageScope::Global
555    } else {
556        StorageScope::Local
557    };
558
559    let args = vec![pattern.to_string()];
560    manager.save(name, "search", &args, description, scope)?;
561
562    if cli.json {
563        let output = serde_json::json!({
564            "saved": name,
565            "command": "search",
566            "pattern": pattern,
567            "scope": if global { "global" } else { "local" },
568        });
569        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
570    } else {
571        let scope_label = if global { "global" } else { "local" };
572        streams.write_result(&format!(
573            "Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
574        ))?;
575    }
576
577    streams.finish_checked()
578}
579
580/// Save a query command as an alias.
581///
582/// Called when user runs `sqry query <query> --save-as <name>`.
583///
584/// # Errors
585/// Returns an error if persistence operations fail or output cannot be written.
586pub fn save_query_alias(
587    cli: &Cli,
588    name: &str,
589    query: &str,
590    global: bool,
591    description: Option<&str>,
592) -> Result<()> {
593    let config = PersistenceConfig::from_env();
594    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
595    let manager = AliasManager::new(index);
596    let mut streams = OutputStreams::with_pager(cli.pager_config());
597
598    let scope = if global {
599        StorageScope::Global
600    } else {
601        StorageScope::Local
602    };
603
604    let args = vec![query.to_string()];
605    manager.save(name, "query", &args, description, scope)?;
606
607    if cli.json {
608        let output = serde_json::json!({
609            "saved": name,
610            "command": "query",
611            "query": query,
612            "scope": if global { "global" } else { "local" },
613        });
614        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
615    } else {
616        let scope_label = if global { "global" } else { "local" };
617        streams.write_result(&format!(
618            "Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
619        ))?;
620    }
621
622    streams.finish_checked()
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::args::Cli;
629    use crate::large_stack_test;
630    use clap::Parser;
631    use tempfile::TempDir;
632
633    fn create_test_cli(args: &[&str]) -> Cli {
634        let mut full_args = vec!["sqry"];
635        full_args.extend(args);
636        Cli::parse_from(full_args)
637    }
638
639    large_stack_test! {
640    #[test]
641    fn test_alias_list_empty() {
642        let temp_dir = TempDir::new().unwrap();
643        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
644
645        let result = run_list(&cli, false, false);
646        assert!(result.is_ok());
647    }
648    }
649
650    large_stack_test! {
651    #[test]
652    fn test_alias_show_not_found() {
653        let temp_dir = TempDir::new().unwrap();
654        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
655
656        let result = run_show(&cli, "nonexistent");
657        assert!(result.is_err());
658        let err = result.unwrap_err().to_string();
659        assert!(err.contains("not found"));
660    }
661    }
662
663    #[test]
664    fn test_import_conflict_arg_conversion() {
665        assert!(matches!(ImportConflictArg::Error, ImportConflictArg::Error));
666        assert!(matches!(ImportConflictArg::Skip, ImportConflictArg::Skip));
667        assert!(matches!(
668            ImportConflictArg::Overwrite,
669            ImportConflictArg::Overwrite
670        ));
671    }
672}