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