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    /// Set or update the tag for this command.
260    /// Use an empty string to remove the tag: --tag ""
261    #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
262    pub tag: Option<String>,
263}
264
265#[derive(Args, Debug)]
266pub struct RenameArgs {
267    /// Current name of the command
268    #[arg(add = ArgValueCandidates::new(complete_commands))]
269    pub old_name: String,
270
271    /// New name for the command
272    pub new_name: String,
273}
274
275#[derive(Args, Debug)]
276pub struct ListArgs {
277    /// Command name to show details for
278    #[arg(add = ArgValueCandidates::new(complete_commands))]
279    pub name: Option<String>,
280
281    /// List commands from a specific project only
282    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
283    pub project: Option<String>,
284
285    /// Filter commands by tag
286    #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
287    pub tag: Option<String>,
288
289    /// Show commands resolved for a specific environment (project scope)
290    #[arg(short = 'e', long = "env", add = ArgValueCandidates::new(complete_environments))]
291    pub environment: Option<String>,
292
293    /// Show only command names (no bodies)
294    #[arg(short, long)]
295    pub names: bool,
296}
297
298#[derive(Args, Debug)]
299pub struct RunArgs {
300    /// Name of the command to run
301    #[arg(add = ArgValueCandidates::new(complete_commands))]
302    pub name: String,
303
304    /// Run from a specific project instead of global
305    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
306    pub project: Option<String>,
307
308    /// Use a specific environment for this run
309    #[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}