Skip to main content

pacs_cli/
lib.rs

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/// A command-line tool for managing and running saved shell commands.
28#[derive(Parser, Debug)]
29#[command(name = "pacs")]
30#[command(author, version, about, long_about = None)]
31pub struct Cli {
32    /// Launch the terminal user interface
33    #[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    /// Initialize pacs
43    Init,
44
45    /// Add a new command
46    Add(AddArgs),
47
48    /// Remove a command
49    #[command(visible_alias = "rm")]
50    Remove(RemoveArgs),
51
52    /// Edit an existing command
53    Edit(EditArgs),
54
55    /// Rename a command
56    Rename(RenameArgs),
57
58    /// List commands
59    #[command(visible_alias = "ls")]
60    List(ListArgs),
61
62    /// Run a saved command
63    Run(RunArgs),
64
65    /// Copy command to clipboard
66    #[command(visible_alias = "cp")]
67    Copy(CopyArgs),
68
69    /// Search commands by name or content
70    Search(SearchArgs),
71
72    /// Manage projects
73    #[command(visible_alias = "p")]
74    Project {
75        #[command(subcommand)]
76        command: ProjectCommands,
77    },
78
79    /// Manage project-specific environments
80    #[command(visible_alias = "e")]
81    Env {
82        #[command(subcommand)]
83        command: EnvCommands,
84    },
85}
86
87#[derive(Subcommand, Debug)]
88pub enum ProjectCommands {
89    /// Create a new project
90    Add(ProjectAddArgs),
91
92    /// Remove a project
93    #[command(visible_alias = "rm")]
94    Remove(ProjectRemoveArgs),
95
96    /// List all projects
97    #[command(visible_alias = "ls")]
98    List,
99
100    /// Switch to a project
101    Switch(ProjectSwitchArgs),
102
103    /// Clear the active project
104    Clear,
105
106    /// Show the current active project
107    Active,
108}
109
110#[derive(Subcommand, Debug)]
111pub enum EnvCommands {
112    /// Add a new empty environment to a project
113    Add(EnvAddArgs),
114
115    /// Remove an environment from a project
116    #[command(visible_alias = "rm")]
117    Remove(EnvRemoveArgs),
118
119    /// Edit an environment's values (opens editor)
120    Edit(EnvEditArgs),
121
122    /// List environments for a project
123    #[command(visible_alias = "ls")]
124    List(EnvListArgs),
125
126    /// Switch to an environment
127    Switch(EnvSwitchArgs),
128
129    /// Show the active environment for a project
130    Active(EnvActiveArgs),
131}
132
133#[derive(Args, Debug)]
134pub struct ProjectAddArgs {
135    /// Name of the project
136    pub name: String,
137
138    /// Path associated with the project
139    #[arg(short, long)]
140    pub path: Option<String>,
141}
142
143#[derive(Args, Debug)]
144pub struct ProjectRemoveArgs {
145    /// Name of the project to remove
146    #[arg(add = ArgValueCandidates::new(complete_projects))]
147    pub name: String,
148}
149
150#[derive(Args, Debug)]
151pub struct ProjectSwitchArgs {
152    /// Name of the project to switch to
153    #[arg(add = ArgValueCandidates::new(complete_projects))]
154    pub name: String,
155}
156
157#[derive(Args, Debug)]
158pub struct EnvAddArgs {
159    /// Environment name to add (e.g., dev, stg)
160    pub name: String,
161
162    /// Target project (defaults to active project if omitted)
163    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
164    pub project: Option<String>,
165}
166
167#[derive(Args, Debug)]
168pub struct EnvRemoveArgs {
169    /// Environment name to remove
170    pub name: String,
171
172    /// Target project (defaults to active project if omitted)
173    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
174    pub project: Option<String>,
175}
176
177#[derive(Args, Debug)]
178pub struct EnvEditArgs {
179    /// Target project (defaults to active project if omitted)
180    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
181    pub project: Option<String>,
182}
183
184#[derive(Args, Debug)]
185pub struct EnvListArgs {
186    /// Target project (defaults to active project if omitted)
187    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
188    pub project: Option<String>,
189}
190
191#[derive(Args, Debug)]
192pub struct EnvSwitchArgs {
193    /// Environment name to switch to
194    pub name: String,
195
196    /// Target project (defaults to active project if omitted)
197    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
198    pub project: Option<String>,
199}
200
201#[derive(Args, Debug)]
202pub struct EnvActiveArgs {
203    /// Target project (defaults to active project if omitted)
204    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
205    pub project: Option<String>,
206}
207
208#[derive(Args, Debug)]
209pub struct AddArgs {
210    /// Name for the command
211    pub name: String,
212
213    /// The shell command to save
214    pub command: Option<String>,
215
216    /// Add to a specific project
217    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
218    pub project: Option<String>,
219
220    /// Working directory for the command
221    #[arg(short, long)]
222    pub cwd: Option<String>,
223
224    /// Tag for organizing commands
225    #[arg(short, long, default_value = "", add = ArgValueCandidates::new(complete_tags))]
226    pub tag: String,
227}
228
229#[derive(Args, Debug)]
230pub struct CopyArgs {
231    /// Name of the command to copy
232    #[arg(add = ArgValueCandidates::new(complete_commands))]
233    pub name: String,
234
235    /// Use a specific environment when expanding placeholders
236    #[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    /// Search query (fuzzy matched against name and command)
243    pub query: String,
244}
245
246#[derive(Args, Debug)]
247pub struct RemoveArgs {
248    /// Name of the command to remove
249    #[arg(add = ArgValueCandidates::new(complete_commands))]
250    pub name: String,
251}
252
253#[derive(Args, Debug)]
254pub struct EditArgs {
255    /// Name of the command to edit
256    #[arg(add = ArgValueCandidates::new(complete_commands))]
257    pub name: String,
258}
259
260#[derive(Args, Debug)]
261pub struct RenameArgs {
262    /// Current name of the command
263    #[arg(add = ArgValueCandidates::new(complete_commands))]
264    pub old_name: String,
265
266    /// New name for the command
267    pub new_name: String,
268}
269
270#[derive(Args, Debug)]
271pub struct ListArgs {
272    /// Command name to show details for
273    #[arg(add = ArgValueCandidates::new(complete_commands))]
274    pub name: Option<String>,
275
276    /// List commands from a specific project only
277    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
278    pub project: Option<String>,
279
280    /// Filter commands by tag
281    #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
282    pub tag: Option<String>,
283
284    /// Show commands resolved for a specific environment (project scope)
285    #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
286    pub environment: Option<String>,
287
288    /// Show only command names (no bodies)
289    #[arg(short, long)]
290    pub names: bool,
291}
292
293#[derive(Args, Debug)]
294pub struct RunArgs {
295    /// Name of the command to run
296    #[arg(add = ArgValueCandidates::new(complete_commands))]
297    pub name: String,
298
299    /// Run from a specific project instead of global
300    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
301    pub project: Option<String>,
302
303    /// Use a specific environment for this run
304    #[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}