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
112        session: String,
113
114        /// Skip confirmation prompt
115        #[arg(short, long)]
116        force: bool,
117    },
118
119    /// Run an interactive walkthrough of rec's features
120    Demo,
121
122    /// Diagnose installation and configuration issues
123    Doctor,
124
125    /// Rename a session
126    Rename {
127        /// Current session name or ID
128        old: String,
129
130        /// New session name
131        new: String,
132    },
133
134    /// Edit a session in $EDITOR
135    Edit {
136        /// Session name or ID
137        session: String,
138    },
139
140    /// Add or remove tags on a session
141    Tag {
142        /// Session name or ID
143        session: String,
144
145        /// Tags to add
146        #[arg(required = true)]
147        tags: Vec<String>,
148    },
149
150    /// Manage tags across sessions
151    Tags {
152        #[command(subcommand)]
153        action: Option<TagsAction>,
154    },
155
156    /// Export a session to another format
157    Export {
158        /// Session name or ID
159        session: String,
160
161        /// Output format
162        #[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        /// Output file (stdout if not provided)
181        #[arg(short, long)]
182        output: Option<PathBuf>,
183
184        /// Enable auto-parameterization of values
185        #[arg(long)]
186        parameterize: bool,
187
188        /// Set parameter values (format: KEY=VALUE, can be repeated)
189        #[arg(long = "param", value_name = "KEY=VALUE")]
190        params: Vec<String>,
191    },
192
193    /// Show current recording status
194    Status,
195
196    /// Internal: Handle shell hook events (preexec, precmd)
197    ///
198    /// This command is called by shell hooks to capture commands.
199    /// Not intended for direct user invocation.
200    #[command(name = "_hook", hide = true)]
201    Hook {
202        /// Hook type: preexec or precmd
203        #[arg(value_enum)]
204        hook_type: HookType,
205
206        /// Argument: command text (preexec) or exit code (precmd)
207        arg: String,
208    },
209
210    /// Import a session from a bash script or shell history file
211    Import {
212        /// Path to the file to import
213        file: PathBuf,
214
215        /// Session name (auto-generated from filename if not provided)
216        #[arg(short, long)]
217        name: Option<String>,
218    },
219
220    /// Initialize shell hooks for automatic recording
221    Init {
222        /// Shell to initialize (auto-detected if not provided)
223        #[arg(value_enum)]
224        shell: Option<Shell>,
225    },
226
227    /// Show or modify configuration
228    Config {
229        /// Get a specific config value
230        #[arg(long)]
231        get: Option<String>,
232
233        /// Set a config value (KEY VALUE)
234        #[arg(long, num_args = 2, value_names = ["KEY", "VALUE"])]
235        set: Option<Vec<String>>,
236
237        /// Open config file in $EDITOR
238        #[arg(long)]
239        edit: bool,
240
241        /// Show config file path
242        #[arg(long)]
243        path: bool,
244
245        /// List all config values with sources
246        #[arg(long)]
247        list: bool,
248    },
249
250    /// Search across all recorded sessions
251    Search {
252        /// Search pattern (substring or regex with --regex)
253        pattern: String,
254
255        /// Use regex pattern matching
256        #[arg(long)]
257        regex: bool,
258
259        /// Filter by tag before searching
260        #[arg(short, long)]
261        tag: Vec<String>,
262    },
263
264    /// Compare commands between two sessions
265    Diff {
266        /// First session name or ID
267        session1: String,
268
269        /// Second session name or ID
270        session2: String,
271    },
272
273    /// Show recording statistics
274    Stats,
275
276    /// Create or manage named aliases for sessions
277    Alias {
278        /// Alias name (for create/lookup)
279        name: Option<String>,
280
281        /// Target session name or ID (for create)
282        session: Option<String>,
283
284        /// List all aliases
285        #[arg(long)]
286        list: bool,
287
288        /// Remove an alias by name
289        #[arg(long)]
290        remove: Option<String>,
291    },
292
293    /// Generate shell completions
294    Completions {
295        /// Shell to generate completions for
296        #[arg(value_enum)]
297        shell: Shell,
298    },
299}
300
301/// Sub-actions for the `tags` command group.
302#[derive(Subcommand)]
303pub enum TagsAction {
304    /// Normalize all existing tags (lowercase, trim, hyphenate)
305    Normalize,
306}
307
308/// Available export formats for sessions.
309#[derive(Clone, Debug, ValueEnum)]
310pub enum ExportFormat {
311    /// Bash shell script with set -e
312    Bash,
313    /// Makefile with command targets
314    Makefile,
315    /// Markdown documentation with code blocks
316    Markdown,
317    /// GitHub Actions workflow YAML
318    GithubAction,
319    /// GitLab CI/CD pipeline YAML
320    GitlabCi,
321    /// Dockerfile with RUN commands
322    Dockerfile,
323    /// `CircleCI` configuration YAML
324    Circleci,
325}
326
327impl ExportFormat {
328    /// Returns a one-line description for this format.
329    #[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    /// Returns the kebab-case name for this format as clap displays it.
343    #[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    /// Returns all format names as clap would display them (kebab-case).
357    ///
358    /// Uses clap's `ValueEnum::value_variants()` to iterate all variants,
359    /// ensuring the list stays in sync with the enum definition.
360    #[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    /// Returns `(name, description)` pairs for all formats.
369    ///
370    /// Useful for --help text and error messages.
371    #[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    /// Suggests the closest format name for an invalid input using fuzzy matching.
380    ///
381    /// Returns `Some(name)` if a format name is within Levenshtein edit distance 2
382    /// of the input, otherwise `None`.
383    #[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/// Supported shells for initialization.
395#[derive(Clone, Copy, Debug, ValueEnum)]
396pub enum Shell {
397    /// Bash shell
398    Bash,
399    /// Zsh shell
400    Zsh,
401    /// Fish shell
402    Fish,
403}
404
405/// Shell hook event types.
406///
407/// Used by the hidden `_hook` subcommand to distinguish between
408/// pre-execution (captures command text) and post-execution
409/// (captures exit code) hook events.
410#[derive(Clone, Debug, ValueEnum)]
411pub enum HookType {
412    /// Before command execution - receives command text
413    Preexec,
414    /// After command execution - receives exit code
415    Precmd,
416}
417
418impl Shell {
419    /// Detect the current shell from the SHELL environment variable.
420    ///
421    /// Returns None if detection fails (unknown shell or SHELL not set).
422    #[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    /// Get the display name for the shell.
436    #[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    /// Get the typical rc file path for this shell.
446    #[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        // Check first and last
518        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        // "bassh" → "bash" (distance 1)
533        assert_eq!(ExportFormat::suggest("bassh"), Some("bash".to_string()));
534    }
535
536    #[test]
537    fn export_format_suggest_finds_github_action() {
538        // "github-acton" → "github-action" (distance 1)
539        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        // "zzzzz" is far from everything
548        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}