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