Skip to main content

pacs_cli/
lib.rs

1#![allow(dead_code)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::too_many_lines)]
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::process::Command;
8
9use anyhow::{Context, Result};
10use clap::{Args, Parser, Subcommand};
11use clap_complete::{ArgValueCandidates, CompletionCandidate};
12
13use pacs_core::{Pacs, PacsCommand, Scope};
14
15const BOLD: &str = "\x1b[1m";
16const GREEN: &str = "\x1b[32m";
17const BLUE: &str = "\x1b[34m";
18const YELLOW: &str = "\x1b[33m";
19const MAGENTA: &str = "\x1b[35m";
20const CYAN: &str = "\x1b[36m";
21const WHITE: &str = "\x1b[37m";
22const GREY: &str = "\x1b[90m";
23const RESET: &str = "\x1b[0m";
24
25/// A command-line tool for managing and running saved shell commands.
26#[derive(Parser, Debug)]
27#[command(name = "pacs")]
28#[command(author, version, about, long_about = None)]
29pub struct Cli {
30    #[command(subcommand)]
31    pub command: Commands,
32}
33
34#[derive(Subcommand, Debug)]
35pub enum Commands {
36    /// Initialize pacs
37    Init,
38
39    /// Add a new command
40    Add(AddArgs),
41
42    /// Remove a command
43    #[command(visible_alias = "rm")]
44    Remove(RemoveArgs),
45
46    /// Edit an existing command
47    Edit(EditArgs),
48
49    /// Rename a command
50    Rename(RenameArgs),
51
52    /// List commands
53    #[command(visible_alias = "ls")]
54    List(ListArgs),
55
56    /// Run a saved command
57    Run(RunArgs),
58
59    /// Copy command to clipboard
60    #[command(visible_alias = "cp")]
61    Copy(CopyArgs),
62
63    /// Search commands by name or content
64    Search(SearchArgs),
65
66    /// Manage projects
67    Project {
68        #[command(subcommand)]
69        command: ProjectCommands,
70    },
71}
72
73#[derive(Subcommand, Debug)]
74pub enum ProjectCommands {
75    /// Create a new project
76    Add(ProjectAddArgs),
77
78    /// Remove a project
79    #[command(visible_alias = "rm")]
80    Remove(ProjectRemoveArgs),
81
82    /// List all projects
83    #[command(visible_alias = "ls")]
84    List,
85
86    /// Set a project as active
87    Activate(ProjectActivateArgs),
88
89    /// Clear the active project
90    Deactivate,
91
92    /// Show the current active project
93    Active,
94}
95
96#[derive(Args, Debug)]
97pub struct ProjectAddArgs {
98    /// Name of the project
99    pub name: String,
100
101    /// Path associated with the project
102    #[arg(short, long)]
103    pub path: Option<String>,
104}
105
106#[derive(Args, Debug)]
107pub struct ProjectRemoveArgs {
108    /// Name of the project to remove
109    #[arg(add = ArgValueCandidates::new(complete_projects))]
110    pub name: String,
111}
112
113#[derive(Args, Debug)]
114pub struct ProjectActivateArgs {
115    /// Name of the project to activate
116    #[arg(add = ArgValueCandidates::new(complete_projects))]
117    pub name: String,
118}
119
120#[derive(Args, Debug)]
121pub struct AddArgs {
122    /// Name for the command
123    pub name: String,
124
125    /// The shell command to save
126    pub command: Option<String>,
127
128    /// Add to a specific project
129    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
130    pub project: Option<String>,
131
132    /// Add to global scope (default: adds to active project if set, otherwise global)
133    #[arg(short, long)]
134    pub global: bool,
135
136    /// Working directory for the command
137    #[arg(short, long)]
138    pub cwd: Option<String>,
139
140    /// Tag for organizing commands
141    #[arg(short, long, default_value = "", add = ArgValueCandidates::new(complete_tags))]
142    pub tag: String,
143}
144
145#[derive(Args, Debug)]
146pub struct CopyArgs {
147    /// Name of the command to copy
148    #[arg(add = ArgValueCandidates::new(complete_commands))]
149    pub name: String,
150}
151
152#[derive(Args, Debug)]
153pub struct SearchArgs {
154    /// Search query (fuzzy matched against name and command)
155    pub query: String,
156}
157
158#[derive(Args, Debug)]
159pub struct RemoveArgs {
160    /// Name of the command to remove
161    #[arg(add = ArgValueCandidates::new(complete_commands))]
162    pub name: String,
163}
164
165#[derive(Args, Debug)]
166pub struct EditArgs {
167    /// Name of the command to edit
168    #[arg(add = ArgValueCandidates::new(complete_commands))]
169    pub name: String,
170}
171
172#[derive(Args, Debug)]
173pub struct RenameArgs {
174    /// Current name of the command
175    #[arg(add = ArgValueCandidates::new(complete_commands))]
176    pub old_name: String,
177
178    /// New name for the command
179    pub new_name: String,
180}
181
182#[derive(Args, Debug)]
183pub struct ListArgs {
184    /// Command name to show details for
185    #[arg(add = ArgValueCandidates::new(complete_commands))]
186    pub name: Option<String>,
187
188    /// List commands from a specific project only
189    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
190    pub project: Option<String>,
191
192    /// List only global commands
193    #[arg(short, long)]
194    pub global: bool,
195
196    /// Filter commands by tag
197    #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
198    pub tag: Option<String>,
199}
200
201#[derive(Args, Debug)]
202pub struct RunArgs {
203    /// Name of the command to run
204    #[arg(add = ArgValueCandidates::new(complete_commands))]
205    pub name: String,
206
207    /// Run from a specific project instead of global
208    #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
209    pub project: Option<String>,
210}
211
212fn complete_commands() -> Vec<CompletionCandidate> {
213    let Ok(pacs) = Pacs::init_home() else {
214        return vec![];
215    };
216    pacs.suggest_command_names()
217        .into_iter()
218        .map(CompletionCandidate::new)
219        .collect()
220}
221
222fn complete_projects() -> Vec<CompletionCandidate> {
223    let Ok(pacs) = Pacs::init_home() else {
224        return vec![];
225    };
226    pacs.suggest_projects()
227        .into_iter()
228        .map(CompletionCandidate::new)
229        .collect()
230}
231
232fn complete_tags() -> Vec<CompletionCandidate> {
233    let Ok(pacs) = Pacs::init_home() else {
234        return vec![];
235    };
236    pacs.suggest_tags()
237        .into_iter()
238        .map(CompletionCandidate::new)
239        .collect()
240}
241
242pub fn run(cli: Cli) -> Result<()> {
243    let mut pacs = Pacs::init_home().context("Failed to initialize pacs")?;
244
245    match cli.command {
246        Commands::Init => {
247            println!("Pacs initialized at ~/.pacs/");
248        }
249
250        Commands::Add(args) => {
251            let command = if let Some(cmd) = args.command {
252                cmd
253            } else {
254                let editor = env::var("VISUAL")
255                    .or_else(|_| env::var("EDITOR"))
256                    .unwrap_or_else(|_| "vi".to_string());
257
258                let temp_file =
259                    std::env::temp_dir().join(format!("pacs-{}.sh", std::process::id()));
260
261                fs::write(&temp_file, "")?;
262
263                let status = Command::new(&editor)
264                    .arg(&temp_file)
265                    .status()
266                    .with_context(|| format!("Failed to open editor '{editor}'"))?;
267
268                if !status.success() {
269                    fs::remove_file(&temp_file).ok();
270                    anyhow::bail!("Editor exited with non-zero status");
271                }
272
273                let content = fs::read_to_string(&temp_file)?;
274                fs::remove_file(&temp_file).ok();
275
276                let command = content.trim().to_string();
277
278                if command.is_empty() {
279                    anyhow::bail!("No command entered");
280                }
281
282                command + "\n"
283            };
284
285            let pacs_cmd = PacsCommand {
286                name: args.name.clone(),
287                command,
288                cwd: args.cwd,
289                tag: args.tag,
290            };
291
292            // Determine scope: explicit project > global flag > active project > global
293            let scope_name: Option<String> = if let Some(ref p) = args.project {
294                Some(p.clone())
295            } else if args.global {
296                None
297            } else {
298                pacs.get_active_project()?
299            };
300
301            if let Some(ref project) = scope_name {
302                pacs.add_command(pacs_cmd, Scope::Project(project))
303                    .with_context(|| format!("Failed to add command '{}'", args.name))?;
304                println!("Command '{}' added to project '{}'.", args.name, project);
305            } else {
306                pacs.add_command(pacs_cmd, Scope::Global)
307                    .with_context(|| format!("Failed to add command '{}'", args.name))?;
308                println!("Command '{}' added to global.", args.name);
309            }
310        }
311
312        Commands::Remove(args) => {
313            pacs.delete_command_auto(&args.name)
314                .with_context(|| format!("Failed to remove command '{}'", args.name))?;
315            println!("Command '{}' removed.", args.name);
316        }
317
318        Commands::Edit(args) => {
319            let cmd = pacs
320                .get_command_auto(&args.name)
321                .with_context(|| format!("Command '{}' not found", args.name))?;
322
323            let editor = env::var("VISUAL")
324                .or_else(|_| env::var("EDITOR"))
325                .unwrap_or_else(|_| "vi".to_string());
326
327            let temp_file =
328                std::env::temp_dir().join(format!("pacs-edit-{}.sh", std::process::id()));
329
330            fs::write(&temp_file, &cmd.command)?;
331
332            let status = Command::new(&editor)
333                .arg(&temp_file)
334                .status()
335                .with_context(|| format!("Failed to open editor '{editor}'"))?;
336
337            if !status.success() {
338                fs::remove_file(&temp_file).ok();
339                anyhow::bail!("Editor exited with non-zero status");
340            }
341
342            let new_command = fs::read_to_string(&temp_file)?;
343            fs::remove_file(&temp_file).ok();
344
345            if new_command.trim().is_empty() {
346                anyhow::bail!("Command cannot be empty");
347            }
348
349            pacs.update_command_auto(&args.name, new_command)
350                .with_context(|| format!("Failed to update command '{}'", args.name))?;
351            println!("Command '{}' updated.", args.name);
352        }
353
354        Commands::Rename(args) => {
355            pacs.rename_command_auto(&args.old_name, &args.new_name)
356                .with_context(|| {
357                    format!(
358                        "Failed to rename command '{}' to '{}'",
359                        args.old_name, args.new_name
360                    )
361                })?;
362            println!(
363                "Command '{}' renamed to '{}'.",
364                args.old_name, args.new_name
365            );
366        }
367
368        Commands::List(args) => {
369            if let Some(ref name) = args.name {
370                let cmd = pacs
371                    .get_command_auto(name)
372                    .with_context(|| format!("Command '{name}' not found"))?;
373                println!("{}:", cmd.name);
374                if !cmd.tag.is_empty() {
375                    println!("{}tag:{} {}", GREY, RESET, cmd.tag);
376                }
377                if let Some(ref cwd) = cmd.cwd {
378                    println!("{GREY}cwd:{RESET} {cwd}");
379                }
380                println!();
381                for line in cmd.command.lines() {
382                    println!("  {BLUE}{line}{RESET}");
383                }
384                return Ok(());
385            }
386
387            let filter_tag =
388                |cmd: &PacsCommand| -> bool { args.tag.as_ref().is_none_or(|t| &cmd.tag == t) };
389
390            let print_tagged = |commands: &[&PacsCommand], scope_name: &str| {
391                if commands.is_empty() {
392                    return;
393                }
394
395                let mut tags: BTreeMap<Option<&str>, Vec<&PacsCommand>> = BTreeMap::new();
396                for cmd in commands.iter().filter(|c| filter_tag(c)) {
397                    let key = if cmd.tag.is_empty() {
398                        None
399                    } else {
400                        Some(cmd.tag.as_str())
401                    };
402                    tags.entry(key).or_default().push(cmd);
403                }
404
405                if tags.is_empty() {
406                    return;
407                }
408
409                println!("{BOLD}{MAGENTA}── {scope_name} ──{RESET}");
410
411                for (tag, cmds) in tags {
412                    if let Some(name) = tag {
413                        println!("{YELLOW}[{name}]{RESET}");
414                    }
415
416                    for cmd in cmds {
417                        println!("{}:", cmd.name);
418                        for line in cmd.command.lines() {
419                            println!("  {BLUE}{line}{RESET}");
420                        }
421                        println!();
422                    }
423                }
424            };
425
426            if let Some(ref project) = args.project {
427                let commands = pacs.list_commands(Scope::Project(project))?;
428                print_tagged(&commands, project);
429            } else if args.global {
430                let commands = pacs.list_commands(Scope::Global)?;
431                print_tagged(&commands, "Global");
432            } else {
433                let commands = pacs.list_commands(Scope::Global)?;
434                print_tagged(&commands, "Global");
435
436                if let Some(active_project) = pacs.get_active_project()? {
437                    let commands = pacs.list_commands(Scope::Project(&active_project))?;
438                    print_tagged(&commands, &active_project);
439                } else {
440                    for project in &pacs.projects {
441                        let commands = pacs.list_commands(Scope::Project(&project.name))?;
442                        print_tagged(&commands, &project.name);
443                    }
444                }
445            }
446        }
447
448        Commands::Run(args) => {
449            if let Some(ref project) = args.project {
450                pacs.run(&args.name, Scope::Project(project))
451                    .with_context(|| format!("Failed to run command '{}'", args.name))?;
452            } else {
453                pacs.run_auto(&args.name)
454                    .with_context(|| format!("Failed to run command '{}'", args.name))?;
455            }
456        }
457
458        Commands::Copy(args) => {
459            let cmd = pacs
460                .get_command_auto(&args.name)
461                .with_context(|| format!("Command '{}' not found", args.name))?;
462            arboard::Clipboard::new()
463                .and_then(|mut cb| cb.set_text(cmd.command.trim()))
464                .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {e}"))?;
465            println!("Copied '{}' to clipboard.", args.name);
466        }
467
468        Commands::Search(args) => {
469            let matches = pacs.search(&args.query);
470            if matches.is_empty() {
471                println!("No matches found.");
472            } else {
473                for cmd in matches {
474                    println!("{}", cmd.name);
475                }
476            }
477        }
478
479        Commands::Project { command } => match command {
480            ProjectCommands::Add(args) => {
481                pacs.init_project(&args.name, args.path)
482                    .with_context(|| format!("Failed to create project '{}'", args.name))?;
483                println!("Project '{}' created.", args.name);
484            }
485            ProjectCommands::Remove(args) => {
486                pacs.delete_project(&args.name)
487                    .with_context(|| format!("Failed to delete project '{}'", args.name))?;
488                println!("Project '{}' deleted.", args.name);
489            }
490            ProjectCommands::List => {
491                if pacs.projects.is_empty() {
492                    println!("No projects.");
493                } else {
494                    let active = pacs.get_active_project().ok().flatten();
495                    for project in &pacs.projects {
496                        let path_info = project
497                            .path
498                            .as_ref()
499                            .map(|p| format!(" ({p})"))
500                            .unwrap_or_default();
501                        let active_marker = if active.as_ref() == Some(&project.name) {
502                            format!(" {GREEN}*{RESET}")
503                        } else {
504                            String::new()
505                        };
506                        println!(
507                            "{}{}{}{}{}",
508                            BLUE, project.name, RESET, path_info, active_marker
509                        );
510                    }
511                }
512            }
513            ProjectCommands::Activate(args) => {
514                pacs.set_active_project(&args.name)
515                    .with_context(|| format!("Failed to activate project '{}'", args.name))?;
516                println!("Project '{}' is now active.", args.name);
517            }
518            ProjectCommands::Deactivate => {
519                pacs.clear_active_project()
520                    .context("Failed to deactivate project")?;
521                println!("Active project cleared.");
522            }
523            ProjectCommands::Active => {
524                if let Some(active) = pacs.get_active_project()? {
525                    println!("{active}");
526                } else {
527                    println!("No active project.");
528                }
529            }
530        },
531    }
532
533    Ok(())
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use clap::CommandFactory;
540
541    #[test]
542    fn verify_cli() {
543        Cli::command().debug_assert();
544    }
545}