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
260#[derive(Args, Debug)]
261pub struct RenameArgs {
262 #[arg(add = ArgValueCandidates::new(complete_commands))]
264 pub old_name: String,
265
266 pub new_name: String,
268}
269
270#[derive(Args, Debug)]
271pub struct ListArgs {
272 #[arg(add = ArgValueCandidates::new(complete_commands))]
274 pub name: Option<String>,
275
276 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
278 pub project: Option<String>,
279
280 #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
282 pub tag: Option<String>,
283
284 #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
286 pub environment: Option<String>,
287
288 #[arg(short, long)]
290 pub names: bool,
291}
292
293#[derive(Args, Debug)]
294pub struct RunArgs {
295 #[arg(add = ArgValueCandidates::new(complete_commands))]
297 pub name: String,
298
299 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
301 pub project: Option<String>,
302
303 #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
305 pub environment: Option<String>,
306}
307
308fn complete_commands() -> Vec<CompletionCandidate> {
309 let Ok(pacs) = Pacs::init_home() else {
310 return vec![];
311 };
312 pacs.suggest_command_names()
313 .into_iter()
314 .map(CompletionCandidate::new)
315 .collect()
316}
317
318fn complete_projects() -> Vec<CompletionCandidate> {
319 let Ok(pacs) = Pacs::init_home() else {
320 return vec![];
321 };
322 pacs.suggest_projects()
323 .into_iter()
324 .map(CompletionCandidate::new)
325 .collect()
326}
327
328fn complete_tags() -> Vec<CompletionCandidate> {
329 let Ok(pacs) = Pacs::init_home() else {
330 return vec![];
331 };
332 pacs.suggest_tags(None)
333 .into_iter()
334 .map(CompletionCandidate::new)
335 .collect()
336}
337
338fn complete_environments() -> Vec<CompletionCandidate> {
339 let Ok(pacs) = Pacs::init_home() else {
340 return vec![];
341 };
342 pacs.suggest_environments(None)
343 .into_iter()
344 .map(CompletionCandidate::new)
345 .collect()
346}
347
348pub fn run(cli: Cli) -> Result<()> {
349 if cli.ui {
350 return Ok(());
351 }
352
353 let Some(command) = cli.command else {
354 use clap::CommandFactory;
355 Cli::command().print_help()?;
356 println!();
357 return Ok(());
358 };
359
360 let mut pacs = Pacs::init_home().context("Failed to initialize pacs")?;
361
362 match command {
363 Commands::Init => {
364 println!("Pacs initialized at ~/.pacs/");
365
366 print!("Enter a name for your first project: ");
367 io::stdout().flush()?;
368 let mut project_name = String::new();
369 io::stdin().read_line(&mut project_name)?;
370 let project_name = project_name.trim();
371
372 if project_name.is_empty() {
373 anyhow::bail!("No project name entered");
374 }
375
376 pacs.init_project(project_name, None)?;
377 pacs.set_active_project(project_name)?;
378 }
379
380 Commands::Add(args) => {
381 let command = if let Some(cmd) = args.command {
382 cmd
383 } else {
384 let editor = env::var("VISUAL")
385 .ok()
386 .or_else(|| env::var("EDITOR").ok())
387 .unwrap_or_else(|| "vi".to_string());
388
389 let temp_file =
390 std::env::temp_dir().join(format!("pacs-{}.sh", std::process::id()));
391
392 fs::write(&temp_file, "")?;
393
394 let status = Command::new(&editor)
395 .arg(&temp_file)
396 .status()
397 .with_context(|| format!("Failed to open editor '{editor}'"))?;
398
399 if !status.success() {
400 fs::remove_file(&temp_file).ok();
401 anyhow::bail!("Editor exited with non-zero status");
402 }
403
404 let content = fs::read_to_string(&temp_file)?;
405 fs::remove_file(&temp_file).ok();
406
407 let command = content.trim().to_string();
408
409 if command.is_empty() {
410 anyhow::bail!("No command entered");
411 }
412
413 command + "\n"
414 };
415
416 let pacs_cmd = PacsCommand {
417 name: args.name.clone(),
418 command,
419 cwd: args.cwd,
420 tag: args.tag,
421 };
422
423 pacs.add_command(pacs_cmd, args.project.as_deref())
424 .with_context(|| format!("Failed to add command '{}'", args.name))?;
425
426 let project_name = if let Some(ref p) = args.project {
427 p.clone()
428 } else {
429 pacs.get_active_project_name()?
430 };
431
432 println!(
433 "Command '{}' added to project '{}'.",
434 args.name, project_name
435 );
436 }
437
438 Commands::Remove(args) => {
439 pacs.delete_command_auto(&args.name)
440 .with_context(|| format!("Failed to remove command '{}'", args.name))?;
441 println!("Command '{}' removed.", args.name);
442 }
443
444 Commands::Edit(args) => {
445 let cmd = pacs
446 .get_command_auto(&args.name)
447 .with_context(|| format!("Command '{}' not found", args.name))?;
448
449 let editor = env::var("VISUAL")
450 .ok()
451 .or_else(|| env::var("EDITOR").ok())
452 .unwrap_or_else(|| "vi".to_string());
453
454 let temp_file =
455 std::env::temp_dir().join(format!("pacs-edit-{}.sh", std::process::id()));
456
457 fs::write(&temp_file, &cmd.command)?;
458
459 let status = Command::new(&editor)
460 .arg(&temp_file)
461 .status()
462 .with_context(|| format!("Failed to open editor '{editor}'"))?;
463
464 if !status.success() {
465 fs::remove_file(&temp_file).ok();
466 anyhow::bail!("Editor exited with non-zero status");
467 }
468
469 let new_command = fs::read_to_string(&temp_file)?;
470 fs::remove_file(&temp_file).ok();
471
472 if new_command.trim().is_empty() {
473 anyhow::bail!("Command cannot be empty");
474 }
475
476 pacs.update_command_auto(&args.name, new_command)
477 .with_context(|| format!("Failed to update command '{}'", args.name))?;
478 println!("Command '{}' updated.", args.name);
479 }
480
481 Commands::Rename(args) => {
482 pacs.rename_command_auto(&args.old_name, &args.new_name)
483 .with_context(|| {
484 format!(
485 "Failed to rename command '{}' to '{}'",
486 args.old_name, args.new_name
487 )
488 })?;
489 println!(
490 "Command '{}' renamed to '{}'.",
491 args.old_name, args.new_name
492 );
493 }
494
495 Commands::List(args) => {
496 if let Some(ref name) = args.name {
497 let cmd = pacs
498 .resolve_command(name, None, args.environment.as_deref())
499 .with_context(|| format!("Command '{name}' not found"))?;
500 let tag_badge = if cmd.tag.is_empty() {
501 String::new()
502 } else {
503 format!(" {BOLD}{YELLOW}[{}]{RESET}", cmd.tag)
504 };
505 let cwd_badge = if let Some(ref cwd) = cmd.cwd {
506 format!(" {GREY}({cwd}){RESET}")
507 } else {
508 String::new()
509 };
510 println!("{BOLD}{CYAN}{}{RESET}{}{}", cmd.name, tag_badge, cwd_badge);
511 for line in cmd.command.lines() {
512 println!("{WHITE}{line}{RESET}");
513 }
514 return Ok(());
515 }
516
517 let filter_tag =
518 |cmd: &PacsCommand| -> bool { args.tag.as_ref().is_none_or(|t| &cmd.tag == t) };
519
520 let print_tagged = |commands: &[PacsCommand], scope_name: &str| {
521 if commands.is_empty() {
522 println!("No commands found. Use 'pacs add <name> <cmd>' to add one.");
523 return;
524 }
525
526 let mut tags: BTreeMap<Option<&str>, Vec<&PacsCommand>> = BTreeMap::new();
527 for cmd in commands.iter().filter(|c| filter_tag(c)) {
528 let key = if cmd.tag.is_empty() {
529 None
530 } else {
531 Some(cmd.tag.as_str())
532 };
533 tags.entry(key).or_default().push(cmd);
534 }
535
536 if tags.is_empty() {
537 return;
538 }
539
540 println!("{BOLD}{GREEN}{scope_name}{RESET}{RESET}");
541 println!();
542
543 for (tag, cmds) in tags {
544 if let Some(name) = tag {
545 println!("{BOLD}{YELLOW}[{name}]{RESET}");
546 }
547
548 for cmd in cmds {
549 if args.names {
550 println!("{BOLD}{CYAN}{}{RESET}", cmd.name);
551 } else {
552 let cwd_badge = if let Some(ref cwd) = cmd.cwd {
553 format!(" {GREY}({cwd}){RESET}")
554 } else {
555 String::new()
556 };
557 println!("{BOLD}{CYAN}{}{RESET}{}", cmd.name, cwd_badge);
558 for line in cmd.command.lines() {
559 println!("{WHITE}{line}{RESET}");
560 }
561 println!();
562 }
563 }
564 }
565 };
566
567 if let Some(ref project) = args.project {
568 let commands = pacs.list(Some(project), args.environment.as_deref())?;
569 print_tagged(&commands, project);
570 } else {
571 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.")?;
572 let commands = pacs.list(None, args.environment.as_deref())?;
573 print_tagged(&commands, &active_project);
574 }
575 }
576
577 Commands::Run(args) => {
578 pacs.run(
579 &args.name,
580 args.project.as_deref(),
581 args.environment.as_deref(),
582 )
583 .with_context(|| format!("Failed to run command '{}'", args.name))?;
584 }
585
586 Commands::Copy(args) => {
587 let cmd = pacs
588 .copy(&args.name, None, args.environment.as_deref())
589 .with_context(|| format!("Command '{}' not found", args.name))?;
590 arboard::Clipboard::new()
591 .and_then(|mut cb| cb.set_text(cmd.command.trim()))
592 .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {e}"))?;
593 println!("Copied '{}' to clipboard.", args.name);
594 }
595
596 Commands::Search(args) => {
597 let matches = pacs.search(&args.query);
598 if matches.is_empty() {
599 println!("No matches found.");
600 } else {
601 for cmd in matches {
602 println!("{}", cmd.name);
603 }
604 }
605 }
606
607 Commands::Project { command } => match command {
608 ProjectCommands::Add(args) => {
609 pacs.init_project(&args.name, args.path)
610 .with_context(|| format!("Failed to create project '{}'", args.name))?;
611 pacs.set_active_project(&args.name)
612 .with_context(|| format!("Failed to switch to project '{}'", args.name))?;
613 println!("Project '{}' created and activated.", args.name);
614 }
615 ProjectCommands::Remove(args) => {
616 pacs.delete_project(&args.name)
617 .with_context(|| format!("Failed to delete project '{}'", args.name))?;
618 println!("Project '{}' deleted.", args.name);
619 }
620 ProjectCommands::List => {
621 if pacs.projects.is_empty() {
622 println!("No projects. Use 'pacs project add' to create one.");
623 } else {
624 let active = pacs.get_active_project_name().ok();
625 for project in &pacs.projects {
626 let path_info = project
627 .path
628 .as_ref()
629 .map(|p| format!(" ({p})"))
630 .unwrap_or_default();
631 let active_marker = if active.as_ref() == Some(&project.name) {
632 format!(" {GREEN}*{RESET}")
633 } else {
634 String::new()
635 };
636 println!(
637 "{}{}{}{}{}",
638 BLUE, project.name, RESET, path_info, active_marker
639 );
640 }
641 }
642 }
643 ProjectCommands::Switch(args) => {
644 pacs.set_active_project(&args.name)
645 .with_context(|| format!("Failed to switch to project '{}'", args.name))?;
646 println!("Switched to project '{}'.", args.name);
647 }
648 ProjectCommands::Clear => {
649 pacs.clear_active_project()?;
650 println!("Active project cleared.");
651 }
652 ProjectCommands::Active => match pacs.get_active_project_name() {
653 Ok(active) => println!("{active}"),
654 Err(_) => println!("No active project."),
655 },
656 },
657 Commands::Env { command } => match command {
658 EnvCommands::Add(args) => {
659 let project = resolve_project_name(&pacs, args.project)?;
660
661 pacs.add_environment(&project, &args.name)
662 .with_context(|| {
663 format!(
664 "Failed to add environment '{}' to project '{}'",
665 args.name, project
666 )
667 })?;
668 pacs.set_active_environment(&project, &args.name)
669 .with_context(|| {
670 format!(
671 "Failed to activate environment '{}' in project '{}'",
672 args.name, project
673 )
674 })?;
675 println!(
676 "Environment '{}' added and activated in project '{}'.",
677 args.name, project
678 );
679 }
680 EnvCommands::Remove(args) => {
681 let project = resolve_project_name(&pacs, args.project)?;
682
683 pacs.remove_environment(&project, &args.name)
684 .with_context(|| {
685 format!(
686 "Failed to remove environment '{}' from project '{}'",
687 args.name, project
688 )
689 })?;
690 println!(
691 "Environment '{}' removed from project '{}'.",
692 args.name, project
693 );
694 }
695 EnvCommands::Edit(args) => {
696 #[derive(serde::Deserialize)]
697 struct EditDoc {
698 #[serde(default)]
699 active_environment: Option<String>,
700 #[serde(default)]
701 environments: std::collections::BTreeMap<String, EnvValues>,
702 }
703 #[derive(serde::Deserialize)]
704 struct EnvValues {
705 #[serde(default)]
706 values: BTreeMap<String, String>,
707 }
708
709 let editor = env::var("VISUAL")
710 .ok()
711 .or_else(|| env::var("EDITOR").ok())
712 .unwrap_or_else(|| "vi".to_string());
713
714 let project = resolve_project_name(&pacs, args.project)?;
715
716 let project_ref = pacs
717 .projects
718 .iter()
719 .find(|p| p.name.eq_ignore_ascii_case(&project))
720 .with_context(|| format!("Project '{project}' not found"))?;
721
722 let mut buf = String::new();
723 if let Some(active_env) = &project_ref.active_environment {
724 write!(buf, "active_environment = \"{active_env}\"\n\n").unwrap();
725 }
726
727 for env in &project_ref.environments {
728 writeln!(buf, "[environments.{}.values]", env.name).unwrap();
729 for (k, v) in &env.values {
730 writeln!(buf, "{k} = \"{}\"", v.replace('"', "\\\"")).unwrap();
731 }
732 buf.push('\n');
733 }
734
735 let temp_file =
736 std::env::temp_dir().join(format!("pacs-env-{}.toml", std::process::id()));
737 fs::write(&temp_file, buf)?;
738
739 let status = Command::new(&editor)
740 .arg(&temp_file)
741 .status()
742 .with_context(|| format!("Failed to open editor '{editor}'"))?;
743
744 if !status.success() {
745 fs::remove_file(&temp_file).ok();
746 anyhow::bail!("Editor exited with non-zero status");
747 }
748
749 let edited = fs::read_to_string(&temp_file)?;
750 fs::remove_file(&temp_file).ok();
751
752 let doc: EditDoc =
753 toml::from_str(&edited).with_context(|| "Failed to parse edited TOML")?;
754
755 if let Some(active_name) = doc.active_environment {
756 pacs.set_active_environment(&project, &active_name)
757 .with_context(|| {
758 format!("Failed to set active environment '{active_name}'")
759 })?;
760 }
761
762 for (env_name, env_values) in doc.environments {
763 pacs.edit_environment_values(&project, &env_name, env_values.values.clone())
764 .with_context(|| {
765 format!(
766 "Failed to update environment '{env_name}' values for project '{project}'"
767 )
768 })?;
769 }
770 println!("All environments updated for project '{project}'.");
771 }
772 EnvCommands::List(args) => {
773 let environments = pacs
774 .list_environments(args.project.as_deref())
775 .context("Failed to list environments")?;
776 let active = pacs
777 .get_active_environment(args.project.as_deref())
778 .context("Failed to get active environment")?;
779
780 if environments.is_empty() {
781 println!("No environments.");
782 } else {
783 for env in environments {
784 let active_marker = if active.as_deref() == Some(env.name.as_str()) {
785 format!(" {GREEN}*{RESET}")
786 } else {
787 String::new()
788 };
789 println!("{CYAN}{BOLD}{}{active_marker}{RESET}", env.name);
790 if !env.values.is_empty() {
791 for (k, v) in &env.values {
792 println!(" {GREY}{k}{RESET} = {WHITE}{v}{RESET}");
793 }
794 }
795 }
796 }
797 }
798 EnvCommands::Switch(args) => {
799 let project = resolve_project_name(&pacs, args.project)?;
800
801 pacs.set_active_environment(&project, &args.name)
802 .with_context(|| {
803 format!(
804 "Failed to switch to environment '{}' in project '{}'",
805 args.name, project
806 )
807 })?;
808 println!(
809 "Switched to environment '{}' in project '{}'.",
810 args.name, project
811 );
812 }
813 EnvCommands::Active(args) => {
814 let project = resolve_project_name(&pacs, args.project)?;
815
816 match pacs.get_active_environment(Some(&project))? {
817 Some(name) => println!("{name}"),
818 None => println!("No active environment."),
819 }
820 }
821 },
822 }
823
824 Ok(())
825}
826
827fn resolve_project_name(pacs: &Pacs, project_name: Option<String>) -> Result<String> {
828 match project_name {
829 Some(p) => Ok(p),
830 None => pacs.get_active_project_name().map_err(|_| {
831 anyhow::anyhow!(
832 "No project specified and no active project set. \
833 Use 'pacs project add' to create one or 'pacs project switch' to activate one."
834 )
835 }),
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use clap::CommandFactory;
843
844 #[test]
845 fn verify_cli() {
846 Cli::command().debug_assert();
847 }
848}