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: 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
506pub 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
551pub 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}