1use 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
16pub 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
51fn 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
70fn 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
125fn 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 let mut streams = if !force && !cli.json {
132 OutputStreams::new()
133 } else {
134 OutputStreams::with_pager(cli.pager_config())
135 };
136
137 let scope = if local {
139 Some(StorageScope::Local)
140 } else if global {
141 Some(StorageScope::Global)
142 } else {
143 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 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
192fn 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 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
227fn 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 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
277fn 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
535pub 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
580pub 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}