1use crate::replay::DangerPolicy;
2use clap::{Parser, Subcommand, ValueEnum};
3use std::path::PathBuf;
4use strsim::levenshtein;
5
6#[derive(Parser)]
11#[command(name = "rec")]
12#[command(
13 author,
14 version,
15 about = "Record, replay, and export terminal sessions",
16 after_help = "Exit codes: 0 success, 1 user error, 2 system error, 130 interrupted"
17)]
18#[command(propagate_version = true)]
19pub struct Cli {
20 #[arg(short, long, global = true)]
22 pub verbose: bool,
23
24 #[arg(short, long, global = true)]
26 pub quiet: bool,
27
28 #[arg(long, global = true)]
30 pub json: bool,
31
32 #[command(subcommand)]
33 pub command: Option<Commands>,
34}
35
36#[derive(Subcommand)]
38pub enum Commands {
39 Start {
41 #[arg(short, long)]
43 name: Option<String>,
44 },
45
46 Stop,
48
49 #[command(name = "replay", alias = "play")]
51 Replay {
52 session: String,
54
55 #[arg(long)]
57 dry_run: bool,
58
59 #[arg(long)]
61 step: bool,
62
63 #[arg(long, value_delimiter = ',')]
65 skip: Option<Vec<u32>>,
66
67 #[arg(long)]
69 from: Option<u32>,
70
71 #[arg(long)]
73 force: bool,
74
75 #[arg(long, value_delimiter = ',')]
77 skip_pattern: Option<Vec<String>>,
78
79 #[arg(long)]
81 cwd: bool,
82
83 #[arg(long, value_enum)]
85 danger_policy: Option<DangerPolicy>,
86 },
87
88 List {
90 #[arg(short, long)]
92 tag: Vec<String>,
93
94 #[arg(long)]
96 tag_all: bool,
97 },
98
99 Show {
101 session: String,
103
104 #[arg(long)]
106 grep: Option<String>,
107 },
108
109 Delete {
111 session: Option<String>,
113
114 #[arg(short, long)]
116 force: bool,
117
118 #[arg(long)]
120 all: bool,
121 },
122
123 Demo,
125
126 Doctor,
128
129 Copy {
131 source: String,
133
134 name: String,
136 },
137
138 Rename {
140 old: String,
142
143 new: String,
145 },
146
147 Edit {
149 session: String,
151 },
152
153 Tag {
155 session: String,
157
158 #[arg(required = true)]
160 tags: Vec<String>,
161 },
162
163 Tags {
165 #[command(subcommand)]
166 action: Option<TagsAction>,
167 },
168
169 Export {
171 session: String,
173
174 #[arg(
176 short,
177 long,
178 value_enum,
179 long_help = "\
180Output format for the exported session.
181
182Available formats:
183 bash Bash shell script with set -e
184 makefile Makefile with command targets
185 markdown Markdown documentation with code blocks
186 github-action GitHub Actions workflow YAML
187 gitlab-ci GitLab CI/CD pipeline YAML
188 dockerfile Dockerfile with RUN commands
189 circleci CircleCI configuration YAML"
190 )]
191 format: ExportFormat,
192
193 #[arg(short, long)]
195 output: Option<PathBuf>,
196
197 #[arg(long)]
199 parameterize: bool,
200
201 #[arg(long = "param", value_name = "KEY=VALUE")]
203 params: Vec<String>,
204 },
205
206 Status,
208
209 #[command(name = "_hook", hide = true)]
214 Hook {
215 #[arg(value_enum)]
217 hook_type: HookType,
218
219 arg: String,
221 },
222
223 Import {
225 file: PathBuf,
227
228 #[arg(short, long)]
230 name: Option<String>,
231 },
232
233 Init {
235 #[arg(value_enum)]
237 shell: Option<Shell>,
238 },
239
240 Config {
242 #[arg(long)]
244 get: Option<String>,
245
246 #[arg(long, num_args = 2, value_names = ["KEY", "VALUE"])]
248 set: Option<Vec<String>>,
249
250 #[arg(long)]
252 edit: bool,
253
254 #[arg(long)]
256 path: bool,
257
258 #[arg(long)]
260 list: bool,
261 },
262
263 Search {
265 pattern: String,
267
268 #[arg(long)]
270 regex: bool,
271
272 #[arg(short, long)]
274 tag: Vec<String>,
275 },
276
277 Diff {
279 session1: String,
281
282 session2: String,
284 },
285
286 Stats,
288
289 Alias {
291 name: Option<String>,
293
294 session: Option<String>,
296
297 #[arg(long)]
299 list: bool,
300
301 #[arg(long)]
303 remove: Option<String>,
304 },
305
306 Completions {
308 #[arg(value_enum)]
310 shell: Shell,
311 },
312
313 #[cfg(feature = "tui")]
315 Ui,
316
317 #[cfg(not(feature = "tui"))]
319 Ui,
320}
321
322#[derive(Subcommand)]
324pub enum TagsAction {
325 Normalize,
327}
328
329#[derive(Clone, Debug, ValueEnum)]
331pub enum ExportFormat {
332 Bash,
334 Makefile,
336 Markdown,
338 GithubAction,
340 GitlabCi,
342 Dockerfile,
344 Circleci,
346}
347
348impl ExportFormat {
349 #[must_use]
351 pub fn description(&self) -> &'static str {
352 match self {
353 ExportFormat::Bash => "Bash shell script with set -e",
354 ExportFormat::Makefile => "Makefile with command targets",
355 ExportFormat::Markdown => "Markdown documentation with code blocks",
356 ExportFormat::GithubAction => "GitHub Actions workflow YAML",
357 ExportFormat::GitlabCi => "GitLab CI/CD pipeline YAML",
358 ExportFormat::Dockerfile => "Dockerfile with RUN commands",
359 ExportFormat::Circleci => "CircleCI configuration YAML",
360 }
361 }
362
363 #[must_use]
365 pub fn name(&self) -> &'static str {
366 match self {
367 ExportFormat::Bash => "bash",
368 ExportFormat::Makefile => "makefile",
369 ExportFormat::Markdown => "markdown",
370 ExportFormat::GithubAction => "github-action",
371 ExportFormat::GitlabCi => "gitlab-ci",
372 ExportFormat::Dockerfile => "dockerfile",
373 ExportFormat::Circleci => "circleci",
374 }
375 }
376
377 #[must_use]
382 pub fn all_names() -> Vec<&'static str> {
383 Self::value_variants()
384 .iter()
385 .map(ExportFormat::name)
386 .collect()
387 }
388
389 #[must_use]
393 pub fn all_with_descriptions() -> Vec<(&'static str, &'static str)> {
394 Self::value_variants()
395 .iter()
396 .map(|v| (v.name(), v.description()))
397 .collect()
398 }
399
400 #[must_use]
405 pub fn suggest(invalid: &str) -> Option<String> {
406 let invalid_lower = invalid.to_lowercase();
407 Self::all_names()
408 .into_iter()
409 .filter(|name| levenshtein(&invalid_lower, name) <= 2)
410 .min_by_key(|name| levenshtein(&invalid_lower, name))
411 .map(std::string::ToString::to_string)
412 }
413}
414
415#[derive(Clone, Copy, Debug, ValueEnum)]
417pub enum Shell {
418 Bash,
420 Zsh,
422 Fish,
424}
425
426#[derive(Clone, Debug, ValueEnum)]
432pub enum HookType {
433 Preexec,
435 Precmd,
437}
438
439impl Shell {
440 #[must_use]
444 pub fn detect() -> Option<Self> {
445 let shell_path = std::env::var("SHELL").ok()?;
446 let shell_name = shell_path.rsplit('/').next()?;
447
448 match shell_name {
449 "bash" => Some(Shell::Bash),
450 "zsh" => Some(Shell::Zsh),
451 "fish" => Some(Shell::Fish),
452 _ => None,
453 }
454 }
455
456 #[must_use]
458 pub fn name(&self) -> &'static str {
459 match self {
460 Shell::Bash => "bash",
461 Shell::Zsh => "zsh",
462 Shell::Fish => "fish",
463 }
464 }
465
466 #[must_use]
468 pub fn rc_file(&self) -> &'static str {
469 match self {
470 Shell::Bash => "~/.bashrc",
471 Shell::Zsh => "~/.zshrc",
472 Shell::Fish => "~/.config/fish/config.fish",
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn export_format_description_returns_static_str() {
483 assert_eq!(
484 ExportFormat::Bash.description(),
485 "Bash shell script with set -e"
486 );
487 assert_eq!(
488 ExportFormat::Makefile.description(),
489 "Makefile with command targets"
490 );
491 assert_eq!(
492 ExportFormat::Markdown.description(),
493 "Markdown documentation with code blocks"
494 );
495 assert_eq!(
496 ExportFormat::GithubAction.description(),
497 "GitHub Actions workflow YAML"
498 );
499 assert_eq!(
500 ExportFormat::GitlabCi.description(),
501 "GitLab CI/CD pipeline YAML"
502 );
503 assert_eq!(
504 ExportFormat::Dockerfile.description(),
505 "Dockerfile with RUN commands"
506 );
507 assert_eq!(
508 ExportFormat::Circleci.description(),
509 "CircleCI configuration YAML"
510 );
511 }
512
513 #[test]
514 fn export_format_name_returns_kebab_case() {
515 assert_eq!(ExportFormat::Bash.name(), "bash");
516 assert_eq!(ExportFormat::GithubAction.name(), "github-action");
517 assert_eq!(ExportFormat::GitlabCi.name(), "gitlab-ci");
518 assert_eq!(ExportFormat::Circleci.name(), "circleci");
519 }
520
521 #[test]
522 fn export_format_all_names_returns_all_variants() {
523 let names = ExportFormat::all_names();
524 assert_eq!(names.len(), 7);
525 assert!(names.contains(&"bash"));
526 assert!(names.contains(&"makefile"));
527 assert!(names.contains(&"markdown"));
528 assert!(names.contains(&"github-action"));
529 assert!(names.contains(&"gitlab-ci"));
530 assert!(names.contains(&"dockerfile"));
531 assert!(names.contains(&"circleci"));
532 }
533
534 #[test]
535 fn export_format_all_with_descriptions_pairs() {
536 let pairs = ExportFormat::all_with_descriptions();
537 assert_eq!(pairs.len(), 7);
538 assert!(
540 pairs
541 .iter()
542 .any(|(n, d)| *n == "bash" && d.contains("Bash"))
543 );
544 assert!(
545 pairs
546 .iter()
547 .any(|(n, d)| *n == "circleci" && d.contains("CircleCI"))
548 );
549 }
550
551 #[test]
552 fn export_format_suggest_finds_close_match() {
553 assert_eq!(ExportFormat::suggest("bassh"), Some("bash".to_string()));
555 }
556
557 #[test]
558 fn export_format_suggest_finds_github_action() {
559 assert_eq!(
561 ExportFormat::suggest("github-acton"),
562 Some("github-action".to_string())
563 );
564 }
565
566 #[test]
567 fn export_format_suggest_returns_none_for_distant_match() {
568 assert_eq!(ExportFormat::suggest("zzzzz"), None);
570 }
571
572 #[test]
573 fn export_format_suggest_is_case_insensitive() {
574 assert_eq!(ExportFormat::suggest("BASH"), Some("bash".to_string()));
575 assert_eq!(ExportFormat::suggest("Bassh"), Some("bash".to_string()));
576 }
577}