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: String,
113
114 #[arg(short, long)]
116 force: bool,
117 },
118
119 Demo,
121
122 Doctor,
124
125 Rename {
127 old: String,
129
130 new: String,
132 },
133
134 Edit {
136 session: String,
138 },
139
140 Tag {
142 session: String,
144
145 #[arg(required = true)]
147 tags: Vec<String>,
148 },
149
150 Tags {
152 #[command(subcommand)]
153 action: Option<TagsAction>,
154 },
155
156 Export {
158 session: String,
160
161 #[arg(
163 short,
164 long,
165 value_enum,
166 long_help = "\
167Output format for the exported session.
168
169Available formats:
170 bash Bash shell script with set -e
171 makefile Makefile with command targets
172 markdown Markdown documentation with code blocks
173 github-action GitHub Actions workflow YAML
174 gitlab-ci GitLab CI/CD pipeline YAML
175 dockerfile Dockerfile with RUN commands
176 circleci CircleCI configuration YAML"
177 )]
178 format: ExportFormat,
179
180 #[arg(short, long)]
182 output: Option<PathBuf>,
183
184 #[arg(long)]
186 parameterize: bool,
187
188 #[arg(long = "param", value_name = "KEY=VALUE")]
190 params: Vec<String>,
191 },
192
193 Status,
195
196 #[command(name = "_hook", hide = true)]
201 Hook {
202 #[arg(value_enum)]
204 hook_type: HookType,
205
206 arg: String,
208 },
209
210 Import {
212 file: PathBuf,
214
215 #[arg(short, long)]
217 name: Option<String>,
218 },
219
220 Init {
222 #[arg(value_enum)]
224 shell: Option<Shell>,
225 },
226
227 Config {
229 #[arg(long)]
231 get: Option<String>,
232
233 #[arg(long, num_args = 2, value_names = ["KEY", "VALUE"])]
235 set: Option<Vec<String>>,
236
237 #[arg(long)]
239 edit: bool,
240
241 #[arg(long)]
243 path: bool,
244
245 #[arg(long)]
247 list: bool,
248 },
249
250 Search {
252 pattern: String,
254
255 #[arg(long)]
257 regex: bool,
258
259 #[arg(short, long)]
261 tag: Vec<String>,
262 },
263
264 Diff {
266 session1: String,
268
269 session2: String,
271 },
272
273 Stats,
275
276 Alias {
278 name: Option<String>,
280
281 session: Option<String>,
283
284 #[arg(long)]
286 list: bool,
287
288 #[arg(long)]
290 remove: Option<String>,
291 },
292
293 Completions {
295 #[arg(value_enum)]
297 shell: Shell,
298 },
299}
300
301#[derive(Subcommand)]
303pub enum TagsAction {
304 Normalize,
306}
307
308#[derive(Clone, Debug, ValueEnum)]
310pub enum ExportFormat {
311 Bash,
313 Makefile,
315 Markdown,
317 GithubAction,
319 GitlabCi,
321 Dockerfile,
323 Circleci,
325}
326
327impl ExportFormat {
328 #[must_use]
330 pub fn description(&self) -> &'static str {
331 match self {
332 ExportFormat::Bash => "Bash shell script with set -e",
333 ExportFormat::Makefile => "Makefile with command targets",
334 ExportFormat::Markdown => "Markdown documentation with code blocks",
335 ExportFormat::GithubAction => "GitHub Actions workflow YAML",
336 ExportFormat::GitlabCi => "GitLab CI/CD pipeline YAML",
337 ExportFormat::Dockerfile => "Dockerfile with RUN commands",
338 ExportFormat::Circleci => "CircleCI configuration YAML",
339 }
340 }
341
342 #[must_use]
344 pub fn name(&self) -> &'static str {
345 match self {
346 ExportFormat::Bash => "bash",
347 ExportFormat::Makefile => "makefile",
348 ExportFormat::Markdown => "markdown",
349 ExportFormat::GithubAction => "github-action",
350 ExportFormat::GitlabCi => "gitlab-ci",
351 ExportFormat::Dockerfile => "dockerfile",
352 ExportFormat::Circleci => "circleci",
353 }
354 }
355
356 #[must_use]
361 pub fn all_names() -> Vec<&'static str> {
362 Self::value_variants()
363 .iter()
364 .map(ExportFormat::name)
365 .collect()
366 }
367
368 #[must_use]
372 pub fn all_with_descriptions() -> Vec<(&'static str, &'static str)> {
373 Self::value_variants()
374 .iter()
375 .map(|v| (v.name(), v.description()))
376 .collect()
377 }
378
379 #[must_use]
384 pub fn suggest(invalid: &str) -> Option<String> {
385 let invalid_lower = invalid.to_lowercase();
386 Self::all_names()
387 .into_iter()
388 .filter(|name| levenshtein(&invalid_lower, name) <= 2)
389 .min_by_key(|name| levenshtein(&invalid_lower, name))
390 .map(std::string::ToString::to_string)
391 }
392}
393
394#[derive(Clone, Copy, Debug, ValueEnum)]
396pub enum Shell {
397 Bash,
399 Zsh,
401 Fish,
403}
404
405#[derive(Clone, Debug, ValueEnum)]
411pub enum HookType {
412 Preexec,
414 Precmd,
416}
417
418impl Shell {
419 #[must_use]
423 pub fn detect() -> Option<Self> {
424 let shell_path = std::env::var("SHELL").ok()?;
425 let shell_name = shell_path.rsplit('/').next()?;
426
427 match shell_name {
428 "bash" => Some(Shell::Bash),
429 "zsh" => Some(Shell::Zsh),
430 "fish" => Some(Shell::Fish),
431 _ => None,
432 }
433 }
434
435 #[must_use]
437 pub fn name(&self) -> &'static str {
438 match self {
439 Shell::Bash => "bash",
440 Shell::Zsh => "zsh",
441 Shell::Fish => "fish",
442 }
443 }
444
445 #[must_use]
447 pub fn rc_file(&self) -> &'static str {
448 match self {
449 Shell::Bash => "~/.bashrc",
450 Shell::Zsh => "~/.zshrc",
451 Shell::Fish => "~/.config/fish/config.fish",
452 }
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn export_format_description_returns_static_str() {
462 assert_eq!(
463 ExportFormat::Bash.description(),
464 "Bash shell script with set -e"
465 );
466 assert_eq!(
467 ExportFormat::Makefile.description(),
468 "Makefile with command targets"
469 );
470 assert_eq!(
471 ExportFormat::Markdown.description(),
472 "Markdown documentation with code blocks"
473 );
474 assert_eq!(
475 ExportFormat::GithubAction.description(),
476 "GitHub Actions workflow YAML"
477 );
478 assert_eq!(
479 ExportFormat::GitlabCi.description(),
480 "GitLab CI/CD pipeline YAML"
481 );
482 assert_eq!(
483 ExportFormat::Dockerfile.description(),
484 "Dockerfile with RUN commands"
485 );
486 assert_eq!(
487 ExportFormat::Circleci.description(),
488 "CircleCI configuration YAML"
489 );
490 }
491
492 #[test]
493 fn export_format_name_returns_kebab_case() {
494 assert_eq!(ExportFormat::Bash.name(), "bash");
495 assert_eq!(ExportFormat::GithubAction.name(), "github-action");
496 assert_eq!(ExportFormat::GitlabCi.name(), "gitlab-ci");
497 assert_eq!(ExportFormat::Circleci.name(), "circleci");
498 }
499
500 #[test]
501 fn export_format_all_names_returns_all_variants() {
502 let names = ExportFormat::all_names();
503 assert_eq!(names.len(), 7);
504 assert!(names.contains(&"bash"));
505 assert!(names.contains(&"makefile"));
506 assert!(names.contains(&"markdown"));
507 assert!(names.contains(&"github-action"));
508 assert!(names.contains(&"gitlab-ci"));
509 assert!(names.contains(&"dockerfile"));
510 assert!(names.contains(&"circleci"));
511 }
512
513 #[test]
514 fn export_format_all_with_descriptions_pairs() {
515 let pairs = ExportFormat::all_with_descriptions();
516 assert_eq!(pairs.len(), 7);
517 assert!(
519 pairs
520 .iter()
521 .any(|(n, d)| *n == "bash" && d.contains("Bash"))
522 );
523 assert!(
524 pairs
525 .iter()
526 .any(|(n, d)| *n == "circleci" && d.contains("CircleCI"))
527 );
528 }
529
530 #[test]
531 fn export_format_suggest_finds_close_match() {
532 assert_eq!(ExportFormat::suggest("bassh"), Some("bash".to_string()));
534 }
535
536 #[test]
537 fn export_format_suggest_finds_github_action() {
538 assert_eq!(
540 ExportFormat::suggest("github-acton"),
541 Some("github-action".to_string())
542 );
543 }
544
545 #[test]
546 fn export_format_suggest_returns_none_for_distant_match() {
547 assert_eq!(ExportFormat::suggest("zzzzz"), None);
549 }
550
551 #[test]
552 fn export_format_suggest_is_case_insensitive() {
553 assert_eq!(ExportFormat::suggest("BASH"), Some("bash".to_string()));
554 assert_eq!(ExportFormat::suggest("Bassh"), Some("bash".to_string()));
555 }
556}