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#[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 Init,
38
39 Add(AddArgs),
41
42 #[command(visible_alias = "rm")]
44 Remove(RemoveArgs),
45
46 Edit(EditArgs),
48
49 Rename(RenameArgs),
51
52 #[command(visible_alias = "ls")]
54 List(ListArgs),
55
56 Run(RunArgs),
58
59 #[command(visible_alias = "cp")]
61 Copy(CopyArgs),
62
63 Search(SearchArgs),
65
66 Project {
68 #[command(subcommand)]
69 command: ProjectCommands,
70 },
71}
72
73#[derive(Subcommand, Debug)]
74pub enum ProjectCommands {
75 Add(ProjectAddArgs),
77
78 #[command(visible_alias = "rm")]
80 Remove(ProjectRemoveArgs),
81
82 #[command(visible_alias = "ls")]
84 List,
85
86 Activate(ProjectActivateArgs),
88
89 Deactivate,
91
92 Active,
94}
95
96#[derive(Args, Debug)]
97pub struct ProjectAddArgs {
98 pub name: String,
100
101 #[arg(short, long)]
103 pub path: Option<String>,
104}
105
106#[derive(Args, Debug)]
107pub struct ProjectRemoveArgs {
108 #[arg(add = ArgValueCandidates::new(complete_projects))]
110 pub name: String,
111}
112
113#[derive(Args, Debug)]
114pub struct ProjectActivateArgs {
115 #[arg(add = ArgValueCandidates::new(complete_projects))]
117 pub name: String,
118}
119
120#[derive(Args, Debug)]
121pub struct AddArgs {
122 pub name: String,
124
125 pub command: Option<String>,
127
128 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
130 pub project: Option<String>,
131
132 #[arg(short, long)]
134 pub global: bool,
135
136 #[arg(short, long)]
138 pub cwd: Option<String>,
139
140 #[arg(short, long, default_value = "", add = ArgValueCandidates::new(complete_tags))]
142 pub tag: String,
143}
144
145#[derive(Args, Debug)]
146pub struct CopyArgs {
147 #[arg(add = ArgValueCandidates::new(complete_commands))]
149 pub name: String,
150}
151
152#[derive(Args, Debug)]
153pub struct SearchArgs {
154 pub query: String,
156}
157
158#[derive(Args, Debug)]
159pub struct RemoveArgs {
160 #[arg(add = ArgValueCandidates::new(complete_commands))]
162 pub name: String,
163}
164
165#[derive(Args, Debug)]
166pub struct EditArgs {
167 #[arg(add = ArgValueCandidates::new(complete_commands))]
169 pub name: String,
170}
171
172#[derive(Args, Debug)]
173pub struct RenameArgs {
174 #[arg(add = ArgValueCandidates::new(complete_commands))]
176 pub old_name: String,
177
178 pub new_name: String,
180}
181
182#[derive(Args, Debug)]
183pub struct ListArgs {
184 #[arg(add = ArgValueCandidates::new(complete_commands))]
186 pub name: Option<String>,
187
188 #[arg(short, long, add = ArgValueCandidates::new(complete_projects))]
190 pub project: Option<String>,
191
192 #[arg(short, long)]
194 pub global: bool,
195
196 #[arg(short, long, add = ArgValueCandidates::new(complete_tags))]
198 pub tag: Option<String>,
199}
200
201#[derive(Args, Debug)]
202pub struct RunArgs {
203 #[arg(add = ArgValueCandidates::new(complete_commands))]
205 pub name: String,
206
207 #[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 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}