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