Skip to main content

rec/cli/
commands.rs

1use crate::replay::DangerPolicy;
2use clap::{Parser, Subcommand, ValueEnum};
3use std::path::PathBuf;
4use strsim::levenshtein;
5
6/// CLI Terminal Recorder - Record, replay, and export terminal sessions.
7///
8/// rec captures your terminal commands and lets you replay them later,
9/// export them to scripts, or share them as documentation.
10#[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    /// Increase output verbosity (show debug messages)
21    #[arg(short, long, global = true)]
22    pub verbose: bool,
23
24    /// Suppress all output except errors
25    #[arg(short, long, global = true)]
26    pub quiet: bool,
27
28    /// Output in JSON format for scripting
29    #[arg(long, global = true)]
30    pub json: bool,
31
32    #[command(subcommand)]
33    pub command: Option<Commands>,
34}
35
36/// Available commands for managing terminal recordings.
37#[derive(Subcommand)]
38pub enum Commands {
39    /// Start recording a new session
40    Start {
41        /// Name for the session (auto-generated if not provided)
42        #[arg(short, long)]
43        name: Option<String>,
44    },
45
46    /// Stop the current recording
47    Stop,
48
49    /// Replay a recorded session
50    #[command(name = "replay", alias = "play")]
51    Replay {
52        /// Session name or ID
53        session: String,
54
55        /// Preview commands without executing
56        #[arg(long)]
57        dry_run: bool,
58
59        /// Execute one command at a time with confirmation
60        #[arg(long)]
61        step: bool,
62
63        /// Skip specific command indices (comma-separated, 1-based)
64        #[arg(long, value_delimiter = ',')]
65        skip: Option<Vec<u32>>,
66
67        /// Start from a specific command index (1-based)
68        #[arg(long)]
69        from: Option<u32>,
70
71        /// Bypass destructive command prompts
72        #[arg(long)]
73        force: bool,
74
75        /// Glob patterns to skip matching commands (comma-separated)
76        #[arg(long, value_delimiter = ',')]
77        skip_pattern: Option<Vec<String>>,
78
79        /// Replay in each command's original working directory
80        #[arg(long)]
81        cwd: bool,
82
83        /// Policy for handling dangerous commands (skip, abort, allow)
84        #[arg(long, value_enum)]
85        danger_policy: Option<DangerPolicy>,
86    },
87
88    /// List all recorded sessions
89    List {
90        /// Filter by tag (can be specified multiple times)
91        #[arg(short, long)]
92        tag: Vec<String>,
93
94        /// Require all tags to match (default: any)
95        #[arg(long)]
96        tag_all: bool,
97    },
98
99    /// Show details of a session
100    Show {
101        /// Session name or ID
102        session: String,
103
104        /// Filter commands by regex pattern
105        #[arg(long)]
106        grep: Option<String>,
107    },
108
109    /// Delete a session
110    Delete {
111        /// Session name or ID (not required with --all)
112        session: Option<String>,
113
114        /// Skip confirmation prompt
115        #[arg(short, long)]
116        force: bool,
117
118        /// Delete all sessions
119        #[arg(long)]
120        all: bool,
121    },
122
123    /// Run an interactive walkthrough of rec's features
124    Demo,
125
126    /// Diagnose installation and configuration issues
127    Doctor,
128
129    /// Copy a session with a new name
130    Copy {
131        /// Source session name or ID
132        source: String,
133
134        /// Name for the copy
135        name: String,
136    },
137
138    /// Rename a session
139    Rename {
140        /// Current session name or ID
141        old: String,
142
143        /// New session name
144        new: String,
145    },
146
147    /// Edit a session in $EDITOR
148    Edit {
149        /// Session name or ID
150        session: String,
151    },
152
153    /// Add or remove tags on a session
154    Tag {
155        /// Session name or ID
156        session: String,
157
158        /// Tags to add
159        #[arg(required = true)]
160        tags: Vec<String>,
161    },
162
163    /// Manage tags across sessions
164    Tags {
165        #[command(subcommand)]
166        action: Option<TagsAction>,
167    },
168
169    /// Export a session to another format
170    Export {
171        /// Session name or ID
172        session: String,
173
174        /// Output format
175        #[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        /// Output file (stdout if not provided)
194        #[arg(short, long)]
195        output: Option<PathBuf>,
196
197        /// Enable auto-parameterization of values
198        #[arg(long)]
199        parameterize: bool,
200
201        /// Set parameter values (format: KEY=VALUE, can be repeated)
202        #[arg(long = "param", value_name = "KEY=VALUE")]
203        params: Vec<String>,
204    },
205
206    /// Show current recording status
207    Status,
208
209    /// Internal: Handle shell hook events (preexec, precmd)
210    ///
211    /// This command is called by shell hooks to capture commands.
212    /// Not intended for direct user invocation.
213    #[command(name = "_hook", hide = true)]
214    Hook {
215        /// Hook type: preexec or precmd
216        #[arg(value_enum)]
217        hook_type: HookType,
218
219        /// Argument: command text (preexec) or exit code (precmd)
220        arg: String,
221    },
222
223    /// Import a session from a bash script or shell history file
224    Import {
225        /// Path to the file to import
226        file: PathBuf,
227
228        /// Session name (auto-generated from filename if not provided)
229        #[arg(short, long)]
230        name: Option<String>,
231    },
232
233    /// Initialize shell hooks for automatic recording
234    Init {
235        /// Shell to initialize (auto-detected if not provided)
236        #[arg(value_enum)]
237        shell: Option<Shell>,
238    },
239
240    /// Show or modify configuration
241    Config {
242        /// Get a specific config value
243        #[arg(long)]
244        get: Option<String>,
245
246        /// Set a config value (KEY VALUE)
247        #[arg(long, num_args = 2, value_names = ["KEY", "VALUE"])]
248        set: Option<Vec<String>>,
249
250        /// Open config file in $EDITOR
251        #[arg(long)]
252        edit: bool,
253
254        /// Show config file path
255        #[arg(long)]
256        path: bool,
257
258        /// List all config values with sources
259        #[arg(long)]
260        list: bool,
261    },
262
263    /// Search across all recorded sessions
264    Search {
265        /// Search pattern (substring or regex with --regex)
266        pattern: String,
267
268        /// Use regex pattern matching
269        #[arg(long)]
270        regex: bool,
271
272        /// Filter by tag before searching
273        #[arg(short, long)]
274        tag: Vec<String>,
275    },
276
277    /// Compare commands between two sessions
278    Diff {
279        /// First session name or ID
280        session1: String,
281
282        /// Second session name or ID
283        session2: String,
284    },
285
286    /// Show recording statistics
287    Stats,
288
289    /// Create or manage named aliases for sessions
290    Alias {
291        /// Alias name (for create/lookup)
292        name: Option<String>,
293
294        /// Target session name or ID (for create)
295        session: Option<String>,
296
297        /// List all aliases
298        #[arg(long)]
299        list: bool,
300
301        /// Remove an alias by name
302        #[arg(long)]
303        remove: Option<String>,
304    },
305
306    /// Generate shell completions
307    Completions {
308        /// Shell to generate completions for
309        #[arg(value_enum)]
310        shell: Shell,
311    },
312
313    /// Launch interactive terminal UI (requires --features tui)
314    #[cfg(feature = "tui")]
315    Ui,
316
317    /// Launch interactive terminal UI (requires --features tui)
318    #[cfg(not(feature = "tui"))]
319    Ui,
320}
321
322/// Sub-actions for the `tags` command group.
323#[derive(Subcommand)]
324pub enum TagsAction {
325    /// Normalize all existing tags (lowercase, trim, hyphenate)
326    Normalize,
327}
328
329/// Available export formats for sessions.
330#[derive(Clone, Debug, ValueEnum)]
331pub enum ExportFormat {
332    /// Bash shell script with set -e
333    Bash,
334    /// Makefile with command targets
335    Makefile,
336    /// Markdown documentation with code blocks
337    Markdown,
338    /// GitHub Actions workflow YAML
339    GithubAction,
340    /// GitLab CI/CD pipeline YAML
341    GitlabCi,
342    /// Dockerfile with RUN commands
343    Dockerfile,
344    /// `CircleCI` configuration YAML
345    Circleci,
346}
347
348impl ExportFormat {
349    /// Returns a one-line description for this format.
350    #[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    /// Returns the kebab-case name for this format as clap displays it.
364    #[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    /// Returns all format names as clap would display them (kebab-case).
378    ///
379    /// Uses clap's `ValueEnum::value_variants()` to iterate all variants,
380    /// ensuring the list stays in sync with the enum definition.
381    #[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    /// Returns `(name, description)` pairs for all formats.
390    ///
391    /// Useful for --help text and error messages.
392    #[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    /// Suggests the closest format name for an invalid input using fuzzy matching.
401    ///
402    /// Returns `Some(name)` if a format name is within Levenshtein edit distance 2
403    /// of the input, otherwise `None`.
404    #[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/// Supported shells for initialization.
416#[derive(Clone, Copy, Debug, ValueEnum)]
417pub enum Shell {
418    /// Bash shell
419    Bash,
420    /// Zsh shell
421    Zsh,
422    /// Fish shell
423    Fish,
424}
425
426/// Shell hook event types.
427///
428/// Used by the hidden `_hook` subcommand to distinguish between
429/// pre-execution (captures command text) and post-execution
430/// (captures exit code) hook events.
431#[derive(Clone, Debug, ValueEnum)]
432pub enum HookType {
433    /// Before command execution - receives command text
434    Preexec,
435    /// After command execution - receives exit code
436    Precmd,
437}
438
439impl Shell {
440    /// Detect the current shell from the SHELL environment variable.
441    ///
442    /// Returns None if detection fails (unknown shell or SHELL not set).
443    #[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    /// Get the display name for the shell.
457    #[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    /// Get the typical rc file path for this shell.
467    #[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        // Check first and last
539        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        // "bassh" → "bash" (distance 1)
554        assert_eq!(ExportFormat::suggest("bassh"), Some("bash".to_string()));
555    }
556
557    #[test]
558    fn export_format_suggest_finds_github_action() {
559        // "github-acton" → "github-action" (distance 1)
560        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        // "zzzzz" is far from everything
569        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}