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: AliasExportFile =
294        serde_json::from_str(&json).context("Failed to parse alias export file")?;
295    let strategy = import_strategy_from_arg(on_conflict);
296
297    if dry_run {
298        let preview = preview_import(&manager, &export, strategy)?;
299        write_import_preview(&mut streams, cli, &preview)?;
300    } else {
301        let result = manager.import(&export, scope, strategy)?;
302        write_import_result(&mut streams, cli, &export, scope, &result)?;
303    }
304
305    streams.finish_checked()
306}
307
308fn filter_aliases(
309    aliases: &[crate::persistence::AliasWithScope],
310    local_only: bool,
311    global_only: bool,
312) -> Vec<&crate::persistence::AliasWithScope> {
313    aliases
314        .iter()
315        .filter(|a| {
316            if local_only {
317                matches!(a.scope, StorageScope::Local)
318            } else if global_only {
319                matches!(a.scope, StorageScope::Global)
320            } else {
321                true
322            }
323        })
324        .collect()
325}
326
327fn write_aliases_json(
328    streams: &mut OutputStreams,
329    aliases: &[&crate::persistence::AliasWithScope],
330) -> Result<()> {
331    let json_aliases: Vec<_> = aliases
332        .iter()
333        .map(|a| {
334            serde_json::json!({
335                "name": a.name,
336                "command": a.alias.command,
337                "args": a.alias.args,
338                "description": a.alias.description,
339                "scope": match a.scope {
340                    StorageScope::Global => "global",
341                    StorageScope::Local => "local",
342                },
343                "created": a.alias.created.to_rfc3339(),
344            })
345        })
346        .collect();
347    let output = serde_json::to_string_pretty(&json_aliases)?;
348    streams.write_result(&output)?;
349    Ok(())
350}
351
352fn write_aliases_text(
353    streams: &mut OutputStreams,
354    aliases: &[&crate::persistence::AliasWithScope],
355) -> Result<()> {
356    if aliases.is_empty() {
357        streams.write_result("No aliases found.")?;
358        return Ok(());
359    }
360
361    streams.write_result(&format!("Aliases ({}):\n", aliases.len()))?;
362    for alias in aliases {
363        let scope_label = match alias.scope {
364            StorageScope::Global => "[global]",
365            StorageScope::Local => "[local]",
366        };
367        let desc = alias
368            .alias
369            .description
370            .as_ref()
371            .map(|d| format!(" - {d}"))
372            .unwrap_or_default();
373        let args_str = if alias.alias.args.is_empty() {
374            String::new()
375        } else {
376            format!(" {}", alias.alias.args.join(" "))
377        };
378        streams.write_result(&format!(
379            "  @{} {} => {}{}{}\n",
380            alias.name, scope_label, alias.alias.command, args_str, desc
381        ))?;
382    }
383
384    Ok(())
385}
386
387fn import_scope_from_flags(global: bool) -> StorageScope {
388    if global {
389        StorageScope::Global
390    } else {
391        StorageScope::Local
392    }
393}
394
395fn read_import_input(file: &str) -> Result<String> {
396    if file == "-" {
397        let mut buf = String::new();
398        io::stdin()
399            .read_to_string(&mut buf)
400            .context("Failed to read from stdin")?;
401        Ok(buf)
402    } else {
403        fs::read_to_string(file).with_context(|| format!("Failed to read from {file}"))
404    }
405}
406
407fn import_strategy_from_arg(on_conflict: ImportConflictArg) -> ImportConflictStrategy {
408    match on_conflict {
409        ImportConflictArg::Error => ImportConflictStrategy::Fail,
410        ImportConflictArg::Skip => ImportConflictStrategy::Skip,
411        ImportConflictArg::Overwrite => ImportConflictStrategy::Overwrite,
412    }
413}
414
415struct ImportPreview {
416    would_import: usize,
417    would_skip: usize,
418    would_conflict: usize,
419    total: usize,
420}
421
422fn preview_import(
423    manager: &AliasManager,
424    export: &AliasExportFile,
425    strategy: ImportConflictStrategy,
426) -> Result<ImportPreview> {
427    let mut would_import = 0;
428    let mut would_skip = 0;
429    let mut would_conflict = 0;
430
431    for name in export.aliases.keys() {
432        match manager.get(name) {
433            Ok(_) => match strategy {
434                ImportConflictStrategy::Fail => would_conflict += 1,
435                ImportConflictStrategy::Skip => would_skip += 1,
436                ImportConflictStrategy::Overwrite => would_import += 1,
437            },
438            Err(AliasError::NotFound { .. }) => would_import += 1,
439            Err(e) => return Err(e.into()),
440        }
441    }
442
443    Ok(ImportPreview {
444        would_import,
445        would_skip,
446        would_conflict,
447        total: export.aliases.len(),
448    })
449}
450
451fn write_import_preview(
452    streams: &mut OutputStreams,
453    cli: &Cli,
454    preview: &ImportPreview,
455) -> Result<()> {
456    if cli.json {
457        let output = serde_json::json!({
458            "dry_run": true,
459            "would_import": preview.would_import,
460            "would_skip": preview.would_skip,
461            "would_conflict": preview.would_conflict,
462            "total": preview.total,
463        });
464        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
465    } else {
466        streams.write_result(&format!(
467            "Dry run: {} aliases would be imported, {} skipped, {} conflicts.\n",
468            preview.would_import, preview.would_skip, preview.would_conflict
469        ))?;
470    }
471    Ok(())
472}
473
474fn write_import_result(
475    streams: &mut OutputStreams,
476    cli: &Cli,
477    export: &AliasExportFile,
478    scope: StorageScope,
479    result: &crate::persistence::ImportResult,
480) -> Result<()> {
481    if cli.json {
482        let output = serde_json::json!({
483            "imported": result.imported,
484            "skipped": result.skipped,
485            "overwritten": result.overwritten,
486            "total": export.aliases.len(),
487            "scope": match scope {
488                StorageScope::Global => "global",
489                StorageScope::Local => "local",
490            },
491        });
492        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
493    } else {
494        let scope_label = match scope {
495            StorageScope::Global => "global",
496            StorageScope::Local => "local",
497        };
498        streams.write_result(&format!(
499            "Imported {} aliases to {} storage ({} skipped, {} overwritten).\n",
500            result.imported, scope_label, result.skipped, result.overwritten
501        ))?;
502    }
503    Ok(())
504}
505
506/// Save a search command as an alias.
507///
508/// Called when user runs `sqry search <pattern> --save-as <name>`.
509///
510/// # Errors
511/// Returns an error if persistence operations fail or output cannot be written.
512pub fn save_search_alias(
513    cli: &Cli,
514    name: &str,
515    pattern: &str,
516    global: bool,
517    description: Option<&str>,
518) -> Result<()> {
519    let config = PersistenceConfig::from_env();
520    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
521    let manager = AliasManager::new(index);
522    let mut streams = OutputStreams::with_pager(cli.pager_config());
523
524    let scope = if global {
525        StorageScope::Global
526    } else {
527        StorageScope::Local
528    };
529
530    let args = vec![pattern.to_string()];
531    manager.save(name, "search", &args, description, scope)?;
532
533    if cli.json {
534        let output = serde_json::json!({
535            "saved": name,
536            "command": "search",
537            "pattern": pattern,
538            "scope": if global { "global" } else { "local" },
539        });
540        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
541    } else {
542        let scope_label = if global { "global" } else { "local" };
543        streams.write_result(&format!(
544            "Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
545        ))?;
546    }
547
548    streams.finish_checked()
549}
550
551/// Save a query command as an alias.
552///
553/// Called when user runs `sqry query <query> --save-as <name>`.
554///
555/// # Errors
556/// Returns an error if persistence operations fail or output cannot be written.
557pub fn save_query_alias(
558    cli: &Cli,
559    name: &str,
560    query: &str,
561    global: bool,
562    description: Option<&str>,
563) -> Result<()> {
564    let config = PersistenceConfig::from_env();
565    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
566    let manager = AliasManager::new(index);
567    let mut streams = OutputStreams::with_pager(cli.pager_config());
568
569    let scope = if global {
570        StorageScope::Global
571    } else {
572        StorageScope::Local
573    };
574
575    let args = vec![query.to_string()];
576    manager.save(name, "query", &args, description, scope)?;
577
578    if cli.json {
579        let output = serde_json::json!({
580            "saved": name,
581            "command": "query",
582            "query": query,
583            "scope": if global { "global" } else { "local" },
584        });
585        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
586    } else {
587        let scope_label = if global { "global" } else { "local" };
588        streams.write_result(&format!(
589            "Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
590        ))?;
591    }
592
593    streams.finish_checked()
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use crate::args::Cli;
600    use crate::large_stack_test;
601    use clap::Parser;
602    use tempfile::TempDir;
603
604    fn create_test_cli(args: &[&str]) -> Cli {
605        let mut full_args = vec!["sqry"];
606        full_args.extend(args);
607        Cli::parse_from(full_args)
608    }
609
610    large_stack_test! {
611    #[test]
612    fn test_alias_list_empty() {
613        let temp_dir = TempDir::new().unwrap();
614        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
615
616        let result = run_list(&cli, false, false);
617        assert!(result.is_ok());
618    }
619    }
620
621    large_stack_test! {
622    #[test]
623    fn test_alias_show_not_found() {
624        let temp_dir = TempDir::new().unwrap();
625        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
626
627        let result = run_show(&cli, "nonexistent");
628        assert!(result.is_err());
629        let err = result.unwrap_err().to_string();
630        assert!(err.contains("not found"));
631    }
632    }
633
634    #[test]
635    fn test_import_conflict_arg_conversion() {
636        assert!(matches!(ImportConflictArg::Error, ImportConflictArg::Error));
637        assert!(matches!(ImportConflictArg::Skip, ImportConflictArg::Skip));
638        assert!(matches!(
639            ImportConflictArg::Overwrite,
640            ImportConflictArg::Overwrite
641        ));
642    }
643}