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 contexts
75    #[command(visible_alias = "c")]
76    Context {
77        #[command(subcommand)]
78        command: ContextCommands,
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    /// Set a project as active
96    Activate(ProjectActivateArgs),
97
98    /// Clear the active project
99    Deactivate,
100
101    /// Show the current active project
102    Active,
103}
104
105#[derive(Subcommand, Debug)]
106pub enum ContextCommands {
107    /// Add a new empty context to a project
108    Add(ContextAddArgs),
109
110    /// Remove a context from a project
111    #[command(visible_alias = "rm")]
112    Remove(ContextRemoveArgs),
113
114    /// Edit a context's values (opens editor)
115    Edit(ContextEditArgs),
116
117    /// List contexts for a project
118    #[command(visible_alias = "ls")]
119    List(ContextListArgs),
120
121    /// Activates a context
122    Activate(ContextActivateArgs),
123
124    /// Show the active context for a project
125    Active(ContextActiveArgs),
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 ProjectActivateArgs {
147    /// Name of the project to activate
148    #[arg(add = ArgValueCandidates::new(complete_projects))]
149    pub name: String,
150}
151
152#[derive(Args, Debug)]
153pub struct ContextAddArgs {
154    /// Context 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 ContextRemoveArgs {
164    /// Context 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 ContextEditArgs {
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 ContextListArgs {
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 ContextActivateArgs {
188    /// Context name to activate
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 ContextDeactivateArgs {
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 ContextActiveArgs {
205    /// Target project (defaults to active project if omitted)
206    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
207    pub project: Option<String>,
208}
209
210#[derive(Args, Debug)]
211pub struct AddArgs {
212    /// Name for the command
213    pub name: String,
214
215    /// The shell command to save
216    pub command: Option<String>,
217
218    /// Add to a specific project
219    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
220    pub project: Option<String>,
221
222    /// Add to global scope (default: adds to active project if set, otherwise global)
223    #[arg(short, long)]
224    pub global: bool,
225
226    /// Working directory for the command
227    #[arg(short, long)]
228    pub cwd: Option<String>,
229
230    /// Tag for organizing commands
231    #[arg(short, long, default_value = "", add = ArgValueCandidates::new(complete_tags))]
232    pub tag: String,
233}
234
235#[derive(Args, Debug)]
236pub struct CopyArgs {
237    /// Name of the command to copy
238    #[arg(add = ArgValueCandidates::new(complete_commands))]
239    pub name: String,
240
241    /// Use a specific context for expansion before copying
242    #[arg(short = 'c', long = "context")]
243    pub context: Option<String>,
244}
245
246#[derive(Args, Debug)]
247pub struct SearchArgs {
248    /// Search query (fuzzy matched against name and command)
249    pub query: String,
250}
251
252#[derive(Args, Debug)]
253pub struct RemoveArgs {
254    /// Name of the command to remove
255    #[arg(add = ArgValueCandidates::new(complete_commands))]
256    pub name: String,
257}
258
259#[derive(Args, Debug)]
260pub struct EditArgs {
261    /// Name of the command to edit
262    #[arg(add = ArgValueCandidates::new(complete_commands))]
263    pub name: String,
264}
265
266#[derive(Args, Debug)]
267pub struct RenameArgs {
268    /// Current name of the command
269    #[arg(add = ArgValueCandidates::new(complete_commands))]
270    pub old_name: String,
271
272    /// New name for the command
273    pub new_name: String,
274}
275
276#[derive(Args, Debug)]
277pub struct ListArgs {
278    /// Command name to show details for
279    #[arg(add = ArgValueCandidates::new(complete_commands))]
280    pub name: Option<String>,
281
282    /// List commands from a specific project only
283    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
284    pub project: Option<String>,
285
286    /// List only global commands
287    #[arg(short, long)]
288    pub global: bool,
289
290    /// Filter commands by tag
291    #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
292    pub tag: Option<String>,
293
294    /// Show commands resolved for a specific context (project scope)
295    #[arg(short = 'c', long = "context")]
296    pub context: Option<String>,
297
298    /// Show only command names (no bodies)
299    #[arg(short, long)]
300    pub names: bool,
301}
302
303#[derive(Args, Debug)]
304pub struct RunArgs {
305    /// Name of the command to run
306    #[arg(add = ArgValueCandidates::new(complete_commands))]
307    pub name: String,
308
309    /// Run from a specific project instead of global
310    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
311    pub project: Option<String>,
312
313    /// Use a specific context for this run
314    #[arg(short = 'c', long = "context")]
315    pub context: Option<String>,
316}
317
318fn complete_commands() -> Vec<CompletionCandidate> {
319    let Ok(pacs) = Pacs::init_home() else {
320        return vec![];
321    };
322    pacs.suggest_command_names()
323        .into_iter()
324        .map(CompletionCandidate::new)
325        .collect()
326}
327
328fn complete_projects() -> Vec<CompletionCandidate> {
329    let Ok(pacs) = Pacs::init_home() else {
330        return vec![];
331    };
332    pacs.suggest_projects()
333        .into_iter()
334        .map(CompletionCandidate::new)
335        .collect()
336}
337
338fn complete_tags() -> Vec<CompletionCandidate> {
339    let Ok(pacs) = Pacs::init_home() else {
340        return vec![];
341    };
342    pacs.suggest_tags()
343        .into_iter()
344        .map(CompletionCandidate::new)
345        .collect()
346}
347
348pub fn run(cli: Cli) -> Result<()> {
349    let mut pacs = Pacs::init_home().context("Failed to initialize pacs")?;
350
351    match cli.command {
352        Commands::Init => {
353            println!("Pacs initialized at ~/.pacs/");
354        }
355
356        Commands::Add(args) => {
357            let command = if let Some(cmd) = args.command {
358                cmd
359            } else {
360                let editor = env::var("VISUAL")
361                    .ok()
362                    .or_else(|| env::var("EDITOR").ok())
363                    .unwrap_or_else(|| "vi".to_string());
364
365                let temp_file =
366                    std::env::temp_dir().join(format!("pacs-{}.sh", std::process::id()));
367
368                fs::write(&temp_file, "")?;
369
370                let status = Command::new(&editor)
371                    .arg(&temp_file)
372                    .status()
373                    .with_context(|| format!("Failed to open editor '{editor}'"))?;
374
375                if !status.success() {
376                    fs::remove_file(&temp_file).ok();
377                    anyhow::bail!("Editor exited with non-zero status");
378                }
379
380                let content = fs::read_to_string(&temp_file)?;
381                fs::remove_file(&temp_file).ok();
382
383                let command = content.trim().to_string();
384
385                if command.is_empty() {
386                    anyhow::bail!("No command entered");
387                }
388
389                command + "\n"
390            };
391
392            let pacs_cmd = PacsCommand {
393                name: args.name.clone(),
394                command,
395                cwd: args.cwd,
396                tag: args.tag,
397            };
398
399            // Determine scope: explicit project > global flag > active project > global
400            let scope_name: Option<String> = if let Some(ref p) = args.project {
401                Some(p.clone())
402            } else if args.global {
403                None
404            } else {
405                pacs.get_active_project()?
406            };
407
408            if let Some(ref project) = scope_name {
409                pacs.add_command(pacs_cmd, Scope::Project(project))
410                    .with_context(|| format!("Failed to add command '{}'", args.name))?;
411                println!("Command '{}' added to project '{}'.", args.name, project);
412            } else {
413                pacs.add_command(pacs_cmd, Scope::Global)
414                    .with_context(|| format!("Failed to add command '{}'", args.name))?;
415                println!("Command '{}' added to global.", args.name);
416            }
417        }
418
419        Commands::Remove(args) => {
420            pacs.delete_command_auto(&args.name)
421                .with_context(|| format!("Failed to remove command '{}'", args.name))?;
422            println!("Command '{}' removed.", args.name);
423        }
424
425        Commands::Edit(args) => {
426            let cmd = pacs
427                .get_command_auto(&args.name)
428                .with_context(|| format!("Command '{}' not found", args.name))?;
429
430            let editor = env::var("VISUAL")
431                .ok()
432                .or_else(|| env::var("EDITOR").ok())
433                .unwrap_or_else(|| "vi".to_string());
434
435            let temp_file =
436                std::env::temp_dir().join(format!("pacs-edit-{}.sh", std::process::id()));
437
438            fs::write(&temp_file, &cmd.command)?;
439
440            let status = Command::new(&editor)
441                .arg(&temp_file)
442                .status()
443                .with_context(|| format!("Failed to open editor '{editor}'"))?;
444
445            if !status.success() {
446                fs::remove_file(&temp_file).ok();
447                anyhow::bail!("Editor exited with non-zero status");
448            }
449
450            let new_command = fs::read_to_string(&temp_file)?;
451            fs::remove_file(&temp_file).ok();
452
453            if new_command.trim().is_empty() {
454                anyhow::bail!("Command cannot be empty");
455            }
456
457            pacs.update_command_auto(&args.name, new_command)
458                .with_context(|| format!("Failed to update command '{}'", args.name))?;
459            println!("Command '{}' updated.", args.name);
460        }
461
462        Commands::Rename(args) => {
463            pacs.rename_command_auto(&args.old_name, &args.new_name)
464                .with_context(|| {
465                    format!(
466                        "Failed to rename command '{}' to '{}'",
467                        args.old_name, args.new_name
468                    )
469                })?;
470            println!(
471                "Command '{}' renamed to '{}'.",
472                args.old_name, args.new_name
473            );
474        }
475
476        Commands::List(args) => {
477            if let Some(ref name) = args.name {
478                let cmd = pacs
479                    .get_command_auto(name)
480                    .with_context(|| format!("Command '{name}' not found"))?;
481                let tag_badge = if cmd.tag.is_empty() {
482                    String::new()
483                } else {
484                    format!(" {BOLD}{YELLOW}[{}]{RESET}", cmd.tag)
485                };
486                let cwd_badge = if let Some(ref cwd) = cmd.cwd {
487                    format!(" {GREY}({cwd}){RESET}")
488                } else {
489                    String::new()
490                };
491                println!("{BOLD}{CYAN}{}{RESET}{}{}", cmd.name, tag_badge, cwd_badge);
492                println!();
493                for line in cmd.command.lines() {
494                    println!("{BLUE}{line}{RESET}");
495                }
496                return Ok(());
497            }
498
499            let filter_tag =
500                |cmd: &PacsCommand| -> bool { args.tag.as_ref().is_none_or(|t| &cmd.tag == t) };
501
502            let print_tagged = |commands: &[PacsCommand], scope_name: &str| {
503                if commands.is_empty() {
504                    return;
505                }
506
507                let mut tags: BTreeMap<Option<&str>, Vec<&PacsCommand>> = BTreeMap::new();
508                for cmd in commands.iter().filter(|c| filter_tag(c)) {
509                    let key = if cmd.tag.is_empty() {
510                        None
511                    } else {
512                        Some(cmd.tag.as_str())
513                    };
514                    tags.entry(key).or_default().push(cmd);
515                }
516
517                if tags.is_empty() {
518                    return;
519                }
520
521                println!("{BOLD}{MAGENTA}── {scope_name} ──{RESET}");
522
523                for (tag, cmds) in tags {
524                    if let Some(name) = tag {
525                        println!("{BOLD}{YELLOW}[{name}]{RESET}");
526                    }
527
528                    for cmd in cmds {
529                        if args.names {
530                            println!("{BOLD}{CYAN}{}{RESET}", cmd.name);
531                        } else {
532                            let cwd_badge = if let Some(ref cwd) = cmd.cwd {
533                                format!(" {GREY}({cwd}){RESET}")
534                            } else {
535                                String::new()
536                            };
537                            println!("{BOLD}{CYAN}{}{RESET}{}", cmd.name, cwd_badge);
538                            for line in cmd.command.lines() {
539                                println!("{BLUE}{line}{RESET}");
540                            }
541                            println!();
542                        }
543                    }
544                }
545            };
546
547            if let Some(ref project) = args.project {
548                let commands =
549                    pacs.list_commands(Scope::Project(project), args.context.as_deref())?;
550                print_tagged(&commands, project);
551            } else if args.global {
552                let commands = pacs.list_commands(Scope::Global, None)?;
553                print_tagged(&commands, "Global");
554            } else {
555                let commands = pacs.list_commands(Scope::Global, None)?;
556                print_tagged(&commands, "Global");
557
558                if let Some(active_project) = pacs.get_active_project()? {
559                    let commands = pacs
560                        .list_commands(Scope::Project(&active_project), args.context.as_deref())?;
561                    print_tagged(&commands, &active_project);
562                } else {
563                    for project in &pacs.projects {
564                        let commands = pacs.list_commands(
565                            Scope::Project(&project.name),
566                            args.context.as_deref(),
567                        )?;
568                        print_tagged(&commands, &project.name);
569                    }
570                }
571            }
572        }
573
574        Commands::Run(args) => {
575            // If a context is provided, temporarily switch context for the target project
576            match (&args.project, &args.context) {
577                (Some(project), Some(ctx)) => {
578                    let prev_ctx = pacs.get_active_context(project).ok().flatten();
579                    pacs.activate_context(project, ctx).ok();
580                    let result = pacs.run(&args.name, Scope::Project(project));
581                    match prev_ctx {
582                        Some(prev) => {
583                            pacs.activate_context(project, &prev).ok();
584                        }
585                        None => {
586                            pacs.deactivate_context(project).ok();
587                        }
588                    }
589                    result.with_context(|| format!("Failed to run command '{}'", args.name))?;
590                }
591                (Some(project), None) => {
592                    pacs.run(&args.name, Scope::Project(project))
593                        .with_context(|| format!("Failed to run command '{}'", args.name))?;
594                }
595                (None, Some(ctx)) => {
596                    // Use active project with specified context if available
597                    if let Some(active_project) = pacs.get_active_project()? {
598                        let prev_ctx = pacs.get_active_context(&active_project).ok().flatten();
599                        pacs.activate_context(&active_project, ctx).ok();
600                        let result = pacs.run(&args.name, Scope::Project(&active_project));
601                        match prev_ctx {
602                            Some(prev) => {
603                                pacs.activate_context(&active_project, &prev).ok();
604                            }
605                            None => {
606                                pacs.deactivate_context(&active_project).ok();
607                            }
608                        }
609                        result.with_context(|| format!("Failed to run command '{}'", args.name))?;
610                    } else {
611                        anyhow::bail!("No active project. Use '--project' with '--context'.");
612                    }
613                }
614                (None, None) => {
615                    pacs.run_auto(&args.name)
616                        .with_context(|| format!("Failed to run command '{}'", args.name))?;
617                }
618            }
619        }
620
621        Commands::Copy(args) => {
622            let cmd = match (&args.context, pacs.get_active_project()?) {
623                (Some(ctx), Some(active_project)) => {
624                    // Temporarily switch context to expand, then restore
625                    let prev_ctx = pacs.get_active_context(&active_project).ok().flatten();
626                    pacs.activate_context(&active_project, ctx).ok();
627                    let expanded = pacs
628                        .expand_command_auto(&args.name)
629                        .with_context(|| format!("Command '{}' not found", args.name));
630                    match prev_ctx {
631                        Some(prev) => {
632                            pacs.activate_context(&active_project, &prev).ok();
633                        }
634                        None => {
635                            pacs.deactivate_context(&active_project).ok();
636                        }
637                    }
638                    expanded?
639                }
640                (Some(_), None) => {
641                    anyhow::bail!("No active project. Use '--project' with '--context'.");
642                }
643                (None, _) => pacs
644                    .expand_command_auto(&args.name)
645                    .with_context(|| format!("Command '{}' not found", args.name))?,
646            };
647            arboard::Clipboard::new()
648                .and_then(|mut cb| cb.set_text(cmd.command.trim()))
649                .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {e}"))?;
650            println!("Copied '{}' to clipboard.", args.name);
651        }
652
653        Commands::Search(args) => {
654            let matches = pacs.search(&args.query);
655            if matches.is_empty() {
656                println!("No matches found.");
657            } else {
658                for cmd in matches {
659                    println!("{}", cmd.name);
660                }
661            }
662        }
663
664        Commands::Project { command } => match command {
665            ProjectCommands::Add(args) => {
666                pacs.init_project(&args.name, args.path)
667                    .with_context(|| format!("Failed to create project '{}'", args.name))?;
668                println!("Project '{}' created.", args.name);
669            }
670            ProjectCommands::Remove(args) => {
671                pacs.delete_project(&args.name)
672                    .with_context(|| format!("Failed to delete project '{}'", args.name))?;
673                println!("Project '{}' deleted.", args.name);
674            }
675            ProjectCommands::List => {
676                if pacs.projects.is_empty() {
677                    println!("No projects.");
678                } else {
679                    let active = pacs.get_active_project().ok().flatten();
680                    for project in &pacs.projects {
681                        let path_info = project
682                            .path
683                            .as_ref()
684                            .map(|p| format!(" ({p})"))
685                            .unwrap_or_default();
686                        let active_marker = if active.as_ref() == Some(&project.name) {
687                            format!(" {GREEN}*{RESET}")
688                        } else {
689                            String::new()
690                        };
691                        println!(
692                            "{}{}{}{}{}",
693                            BLUE, project.name, RESET, path_info, active_marker
694                        );
695                    }
696                }
697            }
698            ProjectCommands::Activate(args) => {
699                pacs.set_active_project(&args.name)
700                    .with_context(|| format!("Failed to activate project '{}'", args.name))?;
701                println!("Project '{}' is now active.", args.name);
702            }
703            ProjectCommands::Deactivate => {
704                pacs.clear_active_project()
705                    .context("Failed to deactivate project")?;
706                println!("Active project cleared.");
707            }
708            ProjectCommands::Active => {
709                if let Some(active) = pacs.get_active_project()? {
710                    println!("{active}");
711                } else {
712                    println!("No active project.");
713                }
714            }
715        },
716        Commands::Context { command } => match command {
717            ContextCommands::Add(args) => {
718                let project = if let Some(p) = args.project.clone() {
719                    p
720                } else if let Some(active) = pacs.get_active_project().ok().flatten() {
721                    active
722                } else {
723                    anyhow::bail!("No project specified and no active project set");
724                };
725                pacs.add_context(&project, &args.name).with_context(|| {
726                    format!(
727                        "Failed to add context '{}' to project '{}'",
728                        args.name, project
729                    )
730                })?;
731                println!("Context '{}' added to project '{}'.", args.name, project);
732            }
733            ContextCommands::Remove(args) => {
734                let project = if let Some(p) = args.project.clone() {
735                    p
736                } else if let Some(active) = pacs.get_active_project()? {
737                    active
738                } else {
739                    anyhow::bail!("No project specified and no active project set");
740                };
741                pacs.remove_context(&project, &args.name).with_context(|| {
742                    format!(
743                        "Failed to remove context '{}' from project '{}'",
744                        args.name, project
745                    )
746                })?;
747                println!(
748                    "Context '{}' removed from project '{}'.",
749                    args.name, project
750                );
751            }
752            ContextCommands::Edit(args) => {
753                let editor = env::var("VISUAL")
754                    .ok()
755                    .or_else(|| env::var("EDITOR").ok())
756                    .unwrap_or_else(|| "vi".to_string());
757
758                // Resolve target project
759                let project = if let Some(p) = args.project.clone() {
760                    p
761                } else if let Some(active) = pacs.get_active_project()? {
762                    active
763                } else {
764                    anyhow::bail!("No project specified and no active project set");
765                };
766
767                // Build TOML with existing contexts and values
768                let project_ref = pacs
769                    .projects
770                    .iter()
771                    .find(|p| p.name.eq_ignore_ascii_case(&project))
772                    .with_context(|| format!("Project '{project}' not found"))?;
773
774                #[derive(serde::Deserialize)]
775                #[allow(clippy::items_after_statements)]
776                struct EditDoc {
777                    #[serde(default)]
778                    active_context: Option<String>,
779                    #[serde(default)]
780                    contexts: std::collections::BTreeMap<String, CtxValues>,
781                }
782                #[derive(serde::Deserialize)]
783                struct CtxValues {
784                    #[serde(default)]
785                    values: BTreeMap<String, String>,
786                }
787
788                let mut buf = String::new();
789                if let Some(active_ctx) = &project_ref.active_context {
790                    write!(buf, "active_context = \"{active_ctx}\"\n\n").unwrap();
791                }
792
793                for ctx in &project_ref.contexts {
794                    writeln!(buf, "[contexts.{}.values]", ctx.name).unwrap();
795                    for (k, v) in &ctx.values {
796                        writeln!(buf, "{k} = \"{}\"", v.replace('"', "\\\"")).unwrap();
797                    }
798                    buf.push('\n');
799                }
800
801                let temp_file =
802                    std::env::temp_dir().join(format!("pacs-ctx-{}.toml", std::process::id()));
803                fs::write(&temp_file, buf)?;
804
805                let status = Command::new(&editor)
806                    .arg(&temp_file)
807                    .status()
808                    .with_context(|| format!("Failed to open editor '{editor}'"))?;
809
810                if !status.success() {
811                    fs::remove_file(&temp_file).ok();
812                    anyhow::bail!("Editor exited with non-zero status");
813                }
814
815                let edited = fs::read_to_string(&temp_file)?;
816                fs::remove_file(&temp_file).ok();
817
818                let doc: EditDoc =
819                    toml::from_str(&edited).with_context(|| "Failed to parse edited TOML")?;
820
821                if let Some(active_name) = doc.active_context {
822                    pacs.activate_context(&project, &active_name)
823                        .with_context(|| format!("Failed to set active context '{active_name}'"))?;
824                }
825
826                // Update all contexts from the file
827                for (ctx_name, ctx_values) in doc.contexts {
828                    pacs.edit_context_values(&project, &ctx_name, ctx_values.values.clone())
829                        .with_context(|| {
830                            format!(
831                                "Failed to update context '{ctx_name}' values for project '{project}'"
832                            )
833                        })?;
834                }
835                println!("All contexts updated for project '{project}'.");
836            }
837            ContextCommands::List(args) => {
838                // Resolve project: use provided or active
839                let project_name = if let Some(p) = args.project.clone() {
840                    p
841                } else if let Some(active) = pacs.get_active_project()? {
842                    active
843                } else {
844                    anyhow::bail!("No project specified and no active project set");
845                };
846                let project = pacs
847                    .projects
848                    .iter()
849                    .find(|p| p.name.eq_ignore_ascii_case(&project_name))
850                    .with_context(|| format!("Project '{project_name}' not found"))?;
851                let active = project.active_context.as_ref();
852                if project.contexts.is_empty() {
853                    println!("No contexts.");
854                } else {
855                    for ctx in &project.contexts {
856                        let active_marker = if Some(&ctx.name) == active {
857                            format!(" {GREEN}*{RESET}")
858                        } else {
859                            String::new()
860                        };
861                        println!("{}{}{}{}", BLUE, ctx.name, RESET, active_marker);
862                        if !ctx.values.is_empty() {
863                            for (k, v) in &ctx.values {
864                                println!("  {GREY}{k}:{RESET} {v}");
865                            }
866                        }
867                    }
868                }
869            }
870            ContextCommands::Activate(args) => {
871                let project = if let Some(p) = args.project.clone() {
872                    p
873                } else if let Some(active) = pacs.get_active_project()? {
874                    active
875                } else {
876                    anyhow::bail!("No project specified and no active project set");
877                };
878                pacs.activate_context(&project, &args.name)
879                    .with_context(|| {
880                        format!(
881                            "Failed to activate context '{}' for project '{}'",
882                            args.name, project
883                        )
884                    })?;
885                println!(
886                    "Context '{}' activated for project '{}'.",
887                    args.name, project
888                );
889            }
890            ContextCommands::Active(args) => {
891                let project = if let Some(p) = args.project.clone() {
892                    p
893                } else if let Some(active) = pacs.get_active_project()? {
894                    active
895                } else {
896                    anyhow::bail!("No project specified and no active project set");
897                };
898                match pacs.get_active_context(&project)? {
899                    Some(name) => println!("{name}"),
900                    None => println!("No active context."),
901                }
902            }
903        },
904    }
905
906    Ok(())
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912    use clap::CommandFactory;
913
914    #[test]
915    fn verify_cli() {
916        Cli::command().debug_assert();
917    }
918}