1#![allow(dead_code)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::too_many_lines)]
4use std::collections::BTreeMap;
5use std::env;
6use std::fmt::Write;
7use std::fs;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use clap::{Args, Parser, Subcommand};
12use clap_complete::{ArgValueCandidates, CompletionCandidate};
13
14use pacs_core::{Pacs, PacsCommand, Scope};
15
16const BOLD: &str = "\x1b[1m";
17const GREEN: &str = "\x1b[32m";
18const BLUE: &str = "\x1b[34m";
19const YELLOW: &str = "\x1b[33m";
20const MAGENTA: &str = "\x1b[35m";
21const CYAN: &str = "\x1b[36m";
22const WHITE: &str = "\x1b[37m";
23const GREY: &str = "\x1b[90m";
24const RESET: &str = "\x1b[0m";
25
26#[derive(Parser, Debug)]
28#[command(name = "pacs")]
29#[command(author, version, about, long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Commands,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Commands {
37 Init,
39
40 Add(AddArgs),
42
43 #[command(visible_alias = "rm")]
45 Remove(RemoveArgs),
46
47 Edit(EditArgs),
49
50 Rename(RenameArgs),
52
53 #[command(visible_alias = "ls")]
55 List(ListArgs),
56
57 Run(RunArgs),
59
60 #[command(visible_alias = "cp")]
62 Copy(CopyArgs),
63
64 Search(SearchArgs),
66
67 #[command(visible_alias = "p")]
69 Project {
70 #[command(subcommand)]
71 command: ProjectCommands,
72 },
73
74 #[command(visible_alias = "env")]
76 Environment {
77 #[command(subcommand)]
78 command: EnvironmentCommands,
79 },
80}
81
82#[derive(Subcommand, Debug)]
83pub enum ProjectCommands {
84 Add(ProjectAddArgs),
86
87 #[command(visible_alias = "rm")]
89 Remove(ProjectRemoveArgs),
90
91 #[command(visible_alias = "ls")]
93 List,
94
95 Switch(ProjectSwitchArgs),
97
98 Clear,
100
101 Active,
103}
104
105#[derive(Subcommand, Debug)]
106pub enum EnvironmentCommands {
107 Add(EnvironmentAddArgs),
109
110 #[command(visible_alias = "rm")]
112 Remove(EnvironmentRemoveArgs),
113
114 Edit(EnvironmentEditArgs),
116
117 #[command(visible_alias = "ls")]
119 List(EnvironmentListArgs),
120
121 Switch(EnvironmentSwitchArgs),
123
124 Active(EnvironmentActiveArgs),
126}
127
128#[derive(Args, Debug)]
129pub struct ProjectAddArgs {
130 pub name: String,
132
133 #[arg(short, long)]
135 pub path: Option<String>,
136}
137
138#[derive(Args, Debug)]
139pub struct ProjectRemoveArgs {
140 #[arg(add = ArgValueCandidates::new(complete_projects))]
142 pub name: String,
143}
144
145#[derive(Args, Debug)]
146pub struct ProjectSwitchArgs {
147 #[arg(add = ArgValueCandidates::new(complete_projects))]
149 pub name: String,
150}
151
152#[derive(Args, Debug)]
153pub struct EnvironmentAddArgs {
154 pub name: String,
156
157 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
159 pub project: Option<String>,
160}
161
162#[derive(Args, Debug)]
163pub struct EnvironmentRemoveArgs {
164 pub name: String,
166
167 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
169 pub project: Option<String>,
170}
171
172#[derive(Args, Debug)]
173pub struct EnvironmentEditArgs {
174 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
176 pub project: Option<String>,
177}
178
179#[derive(Args, Debug)]
180pub struct EnvironmentListArgs {
181 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
183 pub project: Option<String>,
184}
185
186#[derive(Args, Debug)]
187pub struct EnvironmentSwitchArgs {
188 pub name: String,
190
191 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
193 pub project: Option<String>,
194}
195
196#[derive(Args, Debug)]
197pub struct EnvironmentActiveArgs {
198 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
200 pub project: Option<String>,
201}
202
203#[derive(Args, Debug)]
204pub struct AddArgs {
205 pub name: String,
207
208 pub command: Option<String>,
210
211 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
213 pub project: Option<String>,
214
215 #[arg(short, long)]
217 pub global: bool,
218
219 #[arg(short, long)]
221 pub cwd: Option<String>,
222
223 #[arg(short, long, default_value = "", add = ArgValueCandidates::new(complete_tags))]
225 pub tag: String,
226}
227
228#[derive(Args, Debug)]
229pub struct CopyArgs {
230 #[arg(add = ArgValueCandidates::new(complete_commands))]
232 pub name: String,
233
234 #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
236 pub environment: Option<String>,
237}
238
239#[derive(Args, Debug)]
240pub struct SearchArgs {
241 pub query: String,
243}
244
245#[derive(Args, Debug)]
246pub struct RemoveArgs {
247 #[arg(add = ArgValueCandidates::new(complete_commands))]
249 pub name: String,
250}
251
252#[derive(Args, Debug)]
253pub struct EditArgs {
254 #[arg(add = ArgValueCandidates::new(complete_commands))]
256 pub name: String,
257}
258
259#[derive(Args, Debug)]
260pub struct RenameArgs {
261 #[arg(add = ArgValueCandidates::new(complete_commands))]
263 pub old_name: String,
264
265 pub new_name: String,
267}
268
269#[derive(Args, Debug)]
270pub struct ListArgs {
271 #[arg(add = ArgValueCandidates::new(complete_commands))]
273 pub name: Option<String>,
274
275 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
277 pub project: Option<String>,
278
279 #[arg(short, long)]
281 pub global: bool,
282
283 #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
285 pub tag: Option<String>,
286
287 #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
289 pub environment: Option<String>,
290
291 #[arg(short, long)]
293 pub names: bool,
294}
295
296#[derive(Args, Debug)]
297pub struct RunArgs {
298 #[arg(add = ArgValueCandidates::new(complete_commands))]
300 pub name: String,
301
302 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
304 pub project: Option<String>,
305
306 #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
308 pub environment: Option<String>,
309}
310
311fn complete_commands() -> Vec<CompletionCandidate> {
312 let Ok(pacs) = Pacs::init_home() else {
313 return vec![];
314 };
315 pacs.suggest_command_names()
316 .into_iter()
317 .map(CompletionCandidate::new)
318 .collect()
319}
320
321fn complete_projects() -> Vec<CompletionCandidate> {
322 let Ok(pacs) = Pacs::init_home() else {
323 return vec![];
324 };
325 pacs.suggest_projects()
326 .into_iter()
327 .map(CompletionCandidate::new)
328 .collect()
329}
330
331fn complete_tags() -> Vec<CompletionCandidate> {
332 let Ok(pacs) = Pacs::init_home() else {
333 return vec![];
334 };
335 pacs.suggest_tags()
336 .into_iter()
337 .map(CompletionCandidate::new)
338 .collect()
339}
340
341fn complete_environments() -> Vec<CompletionCandidate> {
342 let Ok(pacs) = Pacs::init_home() else {
343 return vec![];
344 };
345 pacs.suggest_environments(None)
346 .into_iter()
347 .map(CompletionCandidate::new)
348 .collect()
349}
350
351pub fn run(cli: Cli) -> Result<()> {
352 let mut pacs = Pacs::init_home().context("Failed to initialize pacs")?;
353
354 match cli.command {
355 Commands::Init => {
356 println!("Pacs initialized at ~/.pacs/");
357 }
358
359 Commands::Add(args) => {
360 let command = if let Some(cmd) = args.command {
361 cmd
362 } else {
363 let editor = env::var("VISUAL")
364 .ok()
365 .or_else(|| env::var("EDITOR").ok())
366 .unwrap_or_else(|| "vi".to_string());
367
368 let temp_file =
369 std::env::temp_dir().join(format!("pacs-{}.sh", std::process::id()));
370
371 fs::write(&temp_file, "")?;
372
373 let status = Command::new(&editor)
374 .arg(&temp_file)
375 .status()
376 .with_context(|| format!("Failed to open editor '{editor}'"))?;
377
378 if !status.success() {
379 fs::remove_file(&temp_file).ok();
380 anyhow::bail!("Editor exited with non-zero status");
381 }
382
383 let content = fs::read_to_string(&temp_file)?;
384 fs::remove_file(&temp_file).ok();
385
386 let command = content.trim().to_string();
387
388 if command.is_empty() {
389 anyhow::bail!("No command entered");
390 }
391
392 command + "\n"
393 };
394
395 let pacs_cmd = PacsCommand {
396 name: args.name.clone(),
397 command,
398 cwd: args.cwd,
399 tag: args.tag,
400 };
401
402 let scope_name: Option<String> = if let Some(ref p) = args.project {
404 Some(p.clone())
405 } else if args.global {
406 None
407 } else {
408 pacs.get_active_project()?
409 };
410
411 if let Some(ref project) = scope_name {
412 pacs.add_command(pacs_cmd, Scope::Project(project))
413 .with_context(|| format!("Failed to add command '{}'", args.name))?;
414 println!("Command '{}' added to project '{}'.", args.name, project);
415 } else {
416 pacs.add_command(pacs_cmd, Scope::Global)
417 .with_context(|| format!("Failed to add command '{}'", args.name))?;
418 println!("Command '{}' added to global.", args.name);
419 }
420 }
421
422 Commands::Remove(args) => {
423 pacs.delete_command_auto(&args.name)
424 .with_context(|| format!("Failed to remove command '{}'", args.name))?;
425 println!("Command '{}' removed.", args.name);
426 }
427
428 Commands::Edit(args) => {
429 let cmd = pacs
430 .get_command_auto(&args.name)
431 .with_context(|| format!("Command '{}' not found", args.name))?;
432
433 let editor = env::var("VISUAL")
434 .ok()
435 .or_else(|| env::var("EDITOR").ok())
436 .unwrap_or_else(|| "vi".to_string());
437
438 let temp_file =
439 std::env::temp_dir().join(format!("pacs-edit-{}.sh", std::process::id()));
440
441 fs::write(&temp_file, &cmd.command)?;
442
443 let status = Command::new(&editor)
444 .arg(&temp_file)
445 .status()
446 .with_context(|| format!("Failed to open editor '{editor}'"))?;
447
448 if !status.success() {
449 fs::remove_file(&temp_file).ok();
450 anyhow::bail!("Editor exited with non-zero status");
451 }
452
453 let new_command = fs::read_to_string(&temp_file)?;
454 fs::remove_file(&temp_file).ok();
455
456 if new_command.trim().is_empty() {
457 anyhow::bail!("Command cannot be empty");
458 }
459
460 pacs.update_command_auto(&args.name, new_command)
461 .with_context(|| format!("Failed to update command '{}'", args.name))?;
462 println!("Command '{}' updated.", args.name);
463 }
464
465 Commands::Rename(args) => {
466 pacs.rename_command_auto(&args.old_name, &args.new_name)
467 .with_context(|| {
468 format!(
469 "Failed to rename command '{}' to '{}'",
470 args.old_name, args.new_name
471 )
472 })?;
473 println!(
474 "Command '{}' renamed to '{}'.",
475 args.old_name, args.new_name
476 );
477 }
478
479 Commands::List(args) => {
480 if let Some(ref name) = args.name {
481 let cmd = pacs
482 .get_command_auto(name)
483 .with_context(|| format!("Command '{name}' not found"))?;
484 let tag_badge = if cmd.tag.is_empty() {
485 String::new()
486 } else {
487 format!(" {BOLD}{YELLOW}[{}]{RESET}", cmd.tag)
488 };
489 let cwd_badge = if let Some(ref cwd) = cmd.cwd {
490 format!(" {GREY}({cwd}){RESET}")
491 } else {
492 String::new()
493 };
494 println!("{BOLD}{CYAN}{}{RESET}{}{}", cmd.name, tag_badge, cwd_badge);
495 println!();
496 for line in cmd.command.lines() {
497 println!("{BLUE}{line}{RESET}");
498 }
499 return Ok(());
500 }
501
502 let filter_tag =
503 |cmd: &PacsCommand| -> bool { args.tag.as_ref().is_none_or(|t| &cmd.tag == t) };
504
505 let print_tagged = |commands: &[PacsCommand], scope_name: &str| {
506 if commands.is_empty() {
507 return;
508 }
509
510 let mut tags: BTreeMap<Option<&str>, Vec<&PacsCommand>> = BTreeMap::new();
511 for cmd in commands.iter().filter(|c| filter_tag(c)) {
512 let key = if cmd.tag.is_empty() {
513 None
514 } else {
515 Some(cmd.tag.as_str())
516 };
517 tags.entry(key).or_default().push(cmd);
518 }
519
520 if tags.is_empty() {
521 return;
522 }
523
524 println!("{BOLD}{MAGENTA}── {scope_name} ──{RESET}");
525
526 for (tag, cmds) in tags {
527 if let Some(name) = tag {
528 println!("{BOLD}{YELLOW}[{name}]{RESET}");
529 }
530
531 for cmd in cmds {
532 if args.names {
533 println!("{BOLD}{CYAN}{}{RESET}", cmd.name);
534 } else {
535 let cwd_badge = if let Some(ref cwd) = cmd.cwd {
536 format!(" {GREY}({cwd}){RESET}")
537 } else {
538 String::new()
539 };
540 println!("{BOLD}{CYAN}{}{RESET}{}", cmd.name, cwd_badge);
541 for line in cmd.command.lines() {
542 println!("{BLUE}{line}{RESET}");
543 }
544 println!();
545 }
546 }
547 }
548 };
549
550 if let Some(ref project) = args.project {
551 let commands =
552 pacs.list(Some(Scope::Project(project)), args.environment.as_deref())?;
553 print_tagged(&commands, project);
554 } else if args.global {
555 let commands = pacs.list(Some(Scope::Global), None)?;
556 print_tagged(&commands, "Global");
557 } else {
558 let commands = pacs.list(Some(Scope::Global), None)?;
559 print_tagged(&commands, "Global");
560
561 if let Some(active_project) = pacs.get_active_project()? {
562 let commands = pacs.list(
563 Some(Scope::Project(&active_project)),
564 args.environment.as_deref(),
565 )?;
566 print_tagged(&commands, &active_project);
567 } else {
568 for project in &pacs.projects {
569 let commands = pacs.list(
570 Some(Scope::Project(&project.name)),
571 args.environment.as_deref(),
572 )?;
573 print_tagged(&commands, &project.name);
574 }
575 }
576 }
577 }
578
579 Commands::Run(args) => {
580 let scope = args.project.as_ref().map(|p| Scope::Project(p.as_str()));
581 pacs.run(&args.name, scope, args.environment.as_deref())
582 .with_context(|| format!("Failed to run command '{}'", args.name))?;
583 }
584
585 Commands::Copy(args) => {
586 let cmd = pacs
587 .copy(&args.name, None, args.environment.as_deref())
588 .with_context(|| format!("Command '{}' not found", args.name))?;
589 arboard::Clipboard::new()
590 .and_then(|mut cb| cb.set_text(cmd.command.trim()))
591 .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {e}"))?;
592 println!("Copied '{}' to clipboard.", args.name);
593 }
594
595 Commands::Search(args) => {
596 let matches = pacs.search(&args.query);
597 if matches.is_empty() {
598 println!("No matches found.");
599 } else {
600 for cmd in matches {
601 println!("{}", cmd.name);
602 }
603 }
604 }
605
606 Commands::Project { command } => match command {
607 ProjectCommands::Add(args) => {
608 pacs.init_project(&args.name, args.path)
609 .with_context(|| format!("Failed to create project '{}'", args.name))?;
610 pacs.set_active_project(&args.name)
611 .with_context(|| format!("Failed to switch to project '{}'", args.name))?;
612 println!("Project '{}' created and activated.", args.name);
613 }
614 ProjectCommands::Remove(args) => {
615 pacs.delete_project(&args.name)
616 .with_context(|| format!("Failed to delete project '{}'", args.name))?;
617 println!("Project '{}' deleted.", args.name);
618 }
619 ProjectCommands::List => {
620 if pacs.projects.is_empty() {
621 println!("No projects.");
622 } else {
623 let active = pacs.get_active_project().ok().flatten();
624 for project in &pacs.projects {
625 let path_info = project
626 .path
627 .as_ref()
628 .map(|p| format!(" ({p})"))
629 .unwrap_or_default();
630 let active_marker = if active.as_ref() == Some(&project.name) {
631 format!(" {GREEN}*{RESET}")
632 } else {
633 String::new()
634 };
635 println!(
636 "{}{}{}{}{}",
637 BLUE, project.name, RESET, path_info, active_marker
638 );
639 }
640 }
641 }
642 ProjectCommands::Switch(args) => {
643 pacs.set_active_project(&args.name)
644 .with_context(|| format!("Failed to switch to project '{}'", args.name))?;
645 println!("Switched to project '{}'.", args.name);
646 }
647 ProjectCommands::Clear => {
648 pacs.clear_active_project()?;
649 println!("Active project cleared.");
650 }
651 ProjectCommands::Active => {
652 if let Some(active) = pacs.get_active_project()? {
653 println!("{active}");
654 } else {
655 println!("No active project.");
656 }
657 }
658 },
659 Commands::Environment { command } => match command {
660 EnvironmentCommands::Add(args) => {
661 let project = if let Some(p) = args.project.clone() {
662 p
663 } else if let Some(active) = pacs.get_active_project().ok().flatten() {
664 active
665 } else {
666 anyhow::bail!("No project specified and no active project set");
667 };
668 pacs.add_environment(&project, &args.name)
669 .with_context(|| {
670 format!(
671 "Failed to add environment '{}' to project '{}'",
672 args.name, project
673 )
674 })?;
675 pacs.activate_environment(&project, &args.name)
676 .with_context(|| {
677 format!(
678 "Failed to activate environment '{}' in project '{}'",
679 args.name, project
680 )
681 })?;
682 println!(
683 "Environment '{}' added and activated in project '{}'.",
684 args.name, project
685 );
686 }
687 EnvironmentCommands::Remove(args) => {
688 let project = if let Some(p) = args.project.clone() {
689 p
690 } else if let Some(active) = pacs.get_active_project()? {
691 active
692 } else {
693 anyhow::bail!("No project specified and no active project set");
694 };
695 pacs.remove_environment(&project, &args.name)
696 .with_context(|| {
697 format!(
698 "Failed to remove environment '{}' from project '{}'",
699 args.name, project
700 )
701 })?;
702 println!(
703 "Environment '{}' removed from project '{}'.",
704 args.name, project
705 );
706 }
707 EnvironmentCommands::Edit(args) => {
708 #[derive(serde::Deserialize)]
709 struct EditDoc {
710 #[serde(default)]
711 active_environment: Option<String>,
712 #[serde(default)]
713 environments: std::collections::BTreeMap<String, EnvValues>,
714 }
715 #[derive(serde::Deserialize)]
716 struct EnvValues {
717 #[serde(default)]
718 values: BTreeMap<String, String>,
719 }
720
721 let editor = env::var("VISUAL")
722 .ok()
723 .or_else(|| env::var("EDITOR").ok())
724 .unwrap_or_else(|| "vi".to_string());
725
726 let project = if let Some(p) = args.project.clone() {
728 p
729 } else if let Some(active) = pacs.get_active_project()? {
730 active
731 } else {
732 anyhow::bail!("No project specified and no active project set");
733 };
734
735 let project_ref = pacs
737 .projects
738 .iter()
739 .find(|p| p.name.eq_ignore_ascii_case(&project))
740 .with_context(|| format!("Project '{project}' not found"))?;
741
742 let mut buf = String::new();
743 if let Some(active_env) = &project_ref.active_environment {
744 write!(buf, "active_environment = \"{active_env}\"\n\n").unwrap();
745 }
746
747 for env in &project_ref.environments {
748 writeln!(buf, "[environments.{}.values]", env.name).unwrap();
749 for (k, v) in &env.values {
750 writeln!(buf, "{k} = \"{}\"", v.replace('"', "\\\"")).unwrap();
751 }
752 buf.push('\n');
753 }
754
755 let temp_file =
756 std::env::temp_dir().join(format!("pacs-env-{}.toml", std::process::id()));
757 fs::write(&temp_file, buf)?;
758
759 let status = Command::new(&editor)
760 .arg(&temp_file)
761 .status()
762 .with_context(|| format!("Failed to open editor '{editor}'"))?;
763
764 if !status.success() {
765 fs::remove_file(&temp_file).ok();
766 anyhow::bail!("Editor exited with non-zero status");
767 }
768
769 let edited = fs::read_to_string(&temp_file)?;
770 fs::remove_file(&temp_file).ok();
771
772 let doc: EditDoc =
773 toml::from_str(&edited).with_context(|| "Failed to parse edited TOML")?;
774
775 if let Some(active_name) = doc.active_environment {
776 pacs.activate_environment(&project, &active_name)
777 .with_context(|| {
778 format!("Failed to set active environment '{active_name}'")
779 })?;
780 }
781
782 for (env_name, env_values) in doc.environments {
784 pacs.edit_environment_values(&project, &env_name, env_values.values.clone())
785 .with_context(|| {
786 format!(
787 "Failed to update environment '{env_name}' values for project '{project}'"
788 )
789 })?;
790 }
791 println!("All environments updated for project '{project}'.");
792 }
793 EnvironmentCommands::List(args) => {
794 let project_name = if let Some(p) = args.project.clone() {
796 p
797 } else if let Some(active) = pacs.get_active_project()? {
798 active
799 } else {
800 anyhow::bail!("No project specified and no active project set");
801 };
802 let project = pacs
803 .projects
804 .iter()
805 .find(|p| p.name.eq_ignore_ascii_case(&project_name))
806 .with_context(|| format!("Project '{project_name}' not found"))?;
807 let active = project.active_environment.as_ref();
808 if project.environments.is_empty() {
809 println!("No environments.");
810 } else {
811 for env in &project.environments {
812 let active_marker = if active == Some(&env.name) {
813 format!(" {GREEN}*{RESET}")
814 } else {
815 String::new()
816 };
817 println!("{CYAN}{BOLD}{}{active_marker}{RESET}", env.name);
818 if !env.values.is_empty() {
819 for (k, v) in &env.values {
820 println!(" {GREY}{k}{RESET} = {WHITE}{v}{RESET}");
821 }
822 }
823 }
824 }
825 }
826 EnvironmentCommands::Switch(args) => {
827 let project = if let Some(p) = args.project.clone() {
828 p
829 } else if let Some(active) = pacs.get_active_project()? {
830 active
831 } else {
832 anyhow::bail!("No project specified and no active project set");
833 };
834 pacs.activate_environment(&project, &args.name)
835 .with_context(|| {
836 format!(
837 "Failed to switch to environment '{}' in project '{}'",
838 args.name, project
839 )
840 })?;
841 println!(
842 "Switched to environment '{}' in project '{}'.",
843 args.name, project
844 );
845 }
846 EnvironmentCommands::Active(args) => {
847 let project = if let Some(p) = args.project.clone() {
848 p
849 } else if let Some(active) = pacs.get_active_project()? {
850 active
851 } else {
852 anyhow::bail!("No project specified and no active project set");
853 };
854 match pacs.get_active_environment(&project)? {
855 Some(name) => println!("{name}"),
856 None => println!("No active environment."),
857 }
858 }
859 },
860 }
861
862 Ok(())
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use clap::CommandFactory;
869
870 #[test]
871 fn verify_cli() {
872 Cli::command().debug_assert();
873 }
874}