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