use crate::args::{AliasAction, Cli, ImportConflictArg};
use crate::output::OutputStreams;
use crate::persistence::{
AliasError, AliasExportFile, AliasManager, ImportConflictStrategy, PersistenceConfig,
StorageScope, open_shared_index,
};
use anyhow::{Context, Result, bail};
use std::fs;
use std::io::{self, Read as IoRead, Write as IoWrite};
use std::path::Path;
pub fn run_alias(cli: &Cli, action: &AliasAction) -> Result<()> {
match action {
AliasAction::List { local, global } => run_list(cli, *local, *global),
AliasAction::Show { name } => run_show(cli, name),
AliasAction::Delete {
name,
local,
global,
force,
} => run_delete(cli, name, *local, *global, *force),
AliasAction::Rename {
old_name,
new_name,
local,
global,
} => run_rename(cli, old_name, new_name, *local, *global),
AliasAction::Export {
file,
local,
global,
} => run_export(cli, file, *local, *global),
AliasAction::Import {
file,
local,
global,
on_conflict,
dry_run,
} => run_import(cli, file, *local, *global, *on_conflict, *dry_run),
}
}
fn run_list(cli: &Cli, local_only: bool, global_only: bool) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let aliases = manager.list()?;
let filtered = filter_aliases(&aliases, local_only, global_only);
if cli.json {
write_aliases_json(&mut streams, &filtered)?;
} else {
write_aliases_text(&mut streams, &filtered)?;
}
streams.finish_checked()
}
fn run_show(cli: &Cli, name: &str) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
match manager.get(name) {
Ok(alias_with_scope) => {
if cli.json {
let output = serde_json::json!({
"name": alias_with_scope.name,
"command": alias_with_scope.alias.command,
"args": alias_with_scope.alias.args,
"description": alias_with_scope.alias.description,
"scope": match alias_with_scope.scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
},
"created": alias_with_scope.alias.created.to_rfc3339(),
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
let scope_label = match alias_with_scope.scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
};
streams.write_result(&format!("Alias: @{}\n", alias_with_scope.name))?;
streams.write_result(&format!(" Scope: {scope_label}\n"))?;
streams
.write_result(&format!(" Command: {}\n", alias_with_scope.alias.command))?;
if !alias_with_scope.alias.args.is_empty() {
streams.write_result(&format!(
" Arguments: {}\n",
alias_with_scope.alias.args.join(" ")
))?;
}
if let Some(desc) = &alias_with_scope.alias.description {
streams.write_result(&format!(" Description: {desc}\n"))?;
}
streams.write_result(&format!(
" Created: {}\n",
alias_with_scope.alias.created.format("%Y-%m-%d %H:%M:%S")
))?;
}
}
Err(AliasError::NotFound { name: n }) => {
bail!("Alias '@{n}' not found");
}
Err(e) => return Err(e.into()),
}
streams.finish_checked()
}
fn run_delete(cli: &Cli, name: &str, local: bool, global: bool, force: bool) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = if !force && !cli.json {
OutputStreams::new()
} else {
OutputStreams::with_pager(cli.pager_config())
};
let scope = if local {
Some(StorageScope::Local)
} else if global {
Some(StorageScope::Global)
} else {
match manager.get(name) {
Ok(alias_with_scope) => Some(alias_with_scope.scope),
Err(AliasError::NotFound { .. }) => {
bail!("Alias '@{name}' not found");
}
Err(e) => return Err(e.into()),
}
};
let scope = scope.unwrap();
if !force && !cli.json {
streams.write_result(&format!(
"Delete alias '@{name}' from {} storage? [y/N] ",
match scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
}
))?;
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
streams.write_result("Cancelled.\n")?;
return streams.finish_checked();
}
}
manager.delete(name, Some(scope))?;
if cli.json {
let output = serde_json::json!({
"deleted": name,
"scope": match scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
},
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
streams.write_result(&format!("Deleted alias '@{name}'.\n"))?;
}
streams.finish_checked()
}
fn run_rename(cli: &Cli, old_name: &str, new_name: &str, local: bool, global: bool) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let scope = if local {
Some(StorageScope::Local)
} else if global {
Some(StorageScope::Global)
} else {
None
};
let result_scope = manager.rename(old_name, new_name, scope)?;
if cli.json {
let output = serde_json::json!({
"old_name": old_name,
"new_name": new_name,
"scope": match result_scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
},
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
streams.write_result(&format!("Renamed '@{old_name}' to '@{new_name}'.\n"))?;
}
streams.finish_checked()
}
fn run_export(cli: &Cli, file: &str, local_only: bool, global_only: bool) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let aliases = manager.list()?;
let filtered: Vec<_> = aliases
.into_iter()
.filter(|a| {
if local_only {
matches!(a.scope, StorageScope::Local)
} else if global_only {
matches!(a.scope, StorageScope::Global)
} else {
true
}
})
.collect();
let export = AliasExportFile::from_aliases(&filtered);
let json = serde_json::to_string_pretty(&export).context("Failed to serialize aliases")?;
if file == "-" {
streams.write_result(&json)?;
} else {
fs::write(file, &json).with_context(|| format!("Failed to write to {file}"))?;
if !cli.json {
streams.write_result(&format!(
"Exported {} aliases to '{}'.\n",
filtered.len(),
file
))?;
}
}
if cli.json && file != "-" {
let output = serde_json::json!({
"exported": filtered.len(),
"file": file,
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
}
streams.finish_checked()
}
fn run_import(
cli: &Cli,
file: &str,
_local: bool,
global: bool,
on_conflict: ImportConflictArg,
dry_run: bool,
) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let scope = import_scope_from_flags(global);
let json = read_import_input(file)?;
let export = parse_alias_export_file(file, &json)?;
let strategy = import_strategy_from_arg(on_conflict);
if dry_run {
let preview = preview_import(&manager, &export, strategy)?;
write_import_preview(&mut streams, cli, &preview)?;
} else {
let result = manager.import(&export, scope, strategy)?;
write_import_result(&mut streams, cli, &export, scope, &result)?;
}
streams.finish_checked()
}
fn filter_aliases(
aliases: &[crate::persistence::AliasWithScope],
local_only: bool,
global_only: bool,
) -> Vec<&crate::persistence::AliasWithScope> {
aliases
.iter()
.filter(|a| {
if local_only {
matches!(a.scope, StorageScope::Local)
} else if global_only {
matches!(a.scope, StorageScope::Global)
} else {
true
}
})
.collect()
}
fn write_aliases_json(
streams: &mut OutputStreams,
aliases: &[&crate::persistence::AliasWithScope],
) -> Result<()> {
let json_aliases: Vec<_> = aliases
.iter()
.map(|a| {
serde_json::json!({
"name": a.name,
"command": a.alias.command,
"args": a.alias.args,
"description": a.alias.description,
"scope": match a.scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
},
"created": a.alias.created.to_rfc3339(),
})
})
.collect();
let output = serde_json::to_string_pretty(&json_aliases)?;
streams.write_result(&output)?;
Ok(())
}
fn write_aliases_text(
streams: &mut OutputStreams,
aliases: &[&crate::persistence::AliasWithScope],
) -> Result<()> {
if aliases.is_empty() {
streams.write_result("No aliases found.")?;
return Ok(());
}
streams.write_result(&format!("Aliases ({}):\n", aliases.len()))?;
for alias in aliases {
let scope_label = match alias.scope {
StorageScope::Global => "[global]",
StorageScope::Local => "[local]",
};
let desc = alias
.alias
.description
.as_ref()
.map(|d| format!(" - {d}"))
.unwrap_or_default();
let args_str = if alias.alias.args.is_empty() {
String::new()
} else {
format!(" {}", alias.alias.args.join(" "))
};
streams.write_result(&format!(
" @{} {} => {}{}{}\n",
alias.name, scope_label, alias.alias.command, args_str, desc
))?;
}
Ok(())
}
fn import_scope_from_flags(global: bool) -> StorageScope {
if global {
StorageScope::Global
} else {
StorageScope::Local
}
}
fn read_import_input(file: &str) -> Result<String> {
if file == "-" {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
Ok(buf)
} else {
fs::read_to_string(file).with_context(|| format!("Failed to read from {file}"))
}
}
fn parse_alias_export_file(file: &str, json: &str) -> Result<AliasExportFile> {
let trimmed = json.trim_start();
let source = if file == "-" { "stdin" } else { file };
if trimmed.is_empty() {
bail!(
"Alias export file '{source}' is empty. Expected the output of `sqry alias export` \
(a JSON object with `version`, `exported_at`, and `aliases`)."
);
}
let first = trimmed.as_bytes()[0];
if first != b'{' {
let shape = match first {
b'[' => "a JSON array",
b'"' => "a JSON string",
b't' | b'f' => "a JSON boolean",
b'n' => "JSON null",
b'-' | b'0'..=b'9' => "a JSON number",
_ => "non-JSON content",
};
bail!(
"Alias export file '{source}' contains {shape}, not a JSON object. \
Expected the output of `sqry alias export` (a JSON object with `version`, \
`exported_at`, and `aliases`). The file was likely written by another \
tool or overwritten before import."
);
}
serde_json::from_str(json)
.with_context(|| format!("Failed to parse alias export file '{source}'"))
}
fn import_strategy_from_arg(on_conflict: ImportConflictArg) -> ImportConflictStrategy {
match on_conflict {
ImportConflictArg::Error => ImportConflictStrategy::Fail,
ImportConflictArg::Skip => ImportConflictStrategy::Skip,
ImportConflictArg::Overwrite => ImportConflictStrategy::Overwrite,
}
}
struct ImportPreview {
would_import: usize,
would_skip: usize,
would_conflict: usize,
total: usize,
}
fn preview_import(
manager: &AliasManager,
export: &AliasExportFile,
strategy: ImportConflictStrategy,
) -> Result<ImportPreview> {
let mut would_import = 0;
let mut would_skip = 0;
let mut would_conflict = 0;
for name in export.aliases.keys() {
match manager.get(name) {
Ok(_) => match strategy {
ImportConflictStrategy::Fail => would_conflict += 1,
ImportConflictStrategy::Skip => would_skip += 1,
ImportConflictStrategy::Overwrite => would_import += 1,
},
Err(AliasError::NotFound { .. }) => would_import += 1,
Err(e) => return Err(e.into()),
}
}
Ok(ImportPreview {
would_import,
would_skip,
would_conflict,
total: export.aliases.len(),
})
}
fn write_import_preview(
streams: &mut OutputStreams,
cli: &Cli,
preview: &ImportPreview,
) -> Result<()> {
if cli.json {
let output = serde_json::json!({
"dry_run": true,
"would_import": preview.would_import,
"would_skip": preview.would_skip,
"would_conflict": preview.would_conflict,
"total": preview.total,
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
streams.write_result(&format!(
"Dry run: {} aliases would be imported, {} skipped, {} conflicts.\n",
preview.would_import, preview.would_skip, preview.would_conflict
))?;
}
Ok(())
}
fn write_import_result(
streams: &mut OutputStreams,
cli: &Cli,
export: &AliasExportFile,
scope: StorageScope,
result: &crate::persistence::ImportResult,
) -> Result<()> {
if cli.json {
let output = serde_json::json!({
"imported": result.imported,
"skipped": result.skipped,
"overwritten": result.overwritten,
"total": export.aliases.len(),
"scope": match scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
},
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
let scope_label = match scope {
StorageScope::Global => "global",
StorageScope::Local => "local",
};
streams.write_result(&format!(
"Imported {} aliases to {} storage ({} skipped, {} overwritten).\n",
result.imported, scope_label, result.skipped, result.overwritten
))?;
}
Ok(())
}
pub fn save_search_alias(
cli: &Cli,
name: &str,
pattern: &str,
global: bool,
description: Option<&str>,
) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let scope = if global {
StorageScope::Global
} else {
StorageScope::Local
};
let args = vec![pattern.to_string()];
manager.save(name, "search", &args, description, scope)?;
if cli.json {
let output = serde_json::json!({
"saved": name,
"command": "search",
"pattern": pattern,
"scope": if global { "global" } else { "local" },
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
let scope_label = if global { "global" } else { "local" };
streams.write_result(&format!(
"Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
))?;
}
streams.finish_checked()
}
pub fn save_query_alias(
cli: &Cli,
name: &str,
query: &str,
global: bool,
description: Option<&str>,
) -> Result<()> {
let config = PersistenceConfig::from_env();
let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
let manager = AliasManager::new(index);
let mut streams = OutputStreams::with_pager(cli.pager_config());
let scope = if global {
StorageScope::Global
} else {
StorageScope::Local
};
let args = vec![query.to_string()];
manager.save(name, "query", &args, description, scope)?;
if cli.json {
let output = serde_json::json!({
"saved": name,
"command": "query",
"query": query,
"scope": if global { "global" } else { "local" },
});
streams.write_result(&serde_json::to_string_pretty(&output)?)?;
} else {
let scope_label = if global { "global" } else { "local" };
streams.write_result(&format!(
"Saved alias '@{name}' ({scope_label}). Use with: sqry @{name} [PATH]\n"
))?;
}
streams.finish_checked()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::Cli;
use crate::large_stack_test;
use clap::Parser;
use tempfile::TempDir;
fn create_test_cli(args: &[&str]) -> Cli {
let mut full_args = vec!["sqry"];
full_args.extend(args);
Cli::parse_from(full_args)
}
large_stack_test! {
#[test]
fn test_alias_list_empty() {
let temp_dir = TempDir::new().unwrap();
let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
let result = run_list(&cli, false, false);
assert!(result.is_ok());
}
}
large_stack_test! {
#[test]
fn test_alias_show_not_found() {
let temp_dir = TempDir::new().unwrap();
let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
let result = run_show(&cli, "nonexistent");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("not found"));
}
}
#[test]
fn test_import_conflict_arg_conversion() {
assert!(matches!(ImportConflictArg::Error, ImportConflictArg::Error));
assert!(matches!(ImportConflictArg::Skip, ImportConflictArg::Skip));
assert!(matches!(
ImportConflictArg::Overwrite,
ImportConflictArg::Overwrite
));
}
}