shell_scene/
cli.rs

1use clap::builder::BoolishValueParser;
2use clap::{value_parser, Arg, ArgAction, Command};
3use clap_complete::shells::Shell;
4use std::io;
5use std::path::PathBuf;
6
7fn leak_str(s: &str) -> &'static str {
8    // returns a &'static str for the life of the process.
9    Box::leak(s.to_owned().into_boxed_str())
10}
11
12pub fn app(binary_name: &str) -> Command {
13    Command::new(leak_str(binary_name))
14        .version(env!("CARGO_PKG_VERSION"))
15        .author(env!("CARGO_PKG_AUTHORS"))
16        .about(env!("CARGO_PKG_DESCRIPTION"))
17        // Global logging controls
18        .arg(
19            Arg::new("log")
20                .long("log")
21                .global(true)
22                .num_args(1)
23                .value_name("LEVEL")
24                .value_parser(["trace", "debug", "info", "warn", "error"])
25                .help("Sets the log level, overriding the RUST_LOG environment variable."),
26        )
27        .arg(
28            Arg::new("verbose")
29                .short('v')
30                .global(true)
31                .help("Sets the log level to debug.")
32                .action(clap::ArgAction::SetTrue),
33        )
34        // --- record (user-facing) ---
35        .subcommand(
36            Command::new("record")
37                .about("Record an asciicast via ttyd")
38                .arg(
39                    Arg::new("session")
40                        .long("session")
41                        .num_args(1)
42                        .value_name("NAME")
43                        .env("SESSION")
44                        .default_value("cast")
45                        .help("tmux session name"),
46                )
47                .arg(
48                    Arg::new("cols")
49                        .long("cols")
50                        .num_args(1)
51                        .value_name("N")
52                        .env("TMUX_COLS")
53                        .value_parser(value_parser!(u32))
54                        .default_value("80")
55                        .help("tmux cols"),
56                )
57                .arg(
58                    Arg::new("rows")
59                        .long("rows")
60                        .num_args(1)
61                        .value_name("N")
62                        .env("TMUX_ROWS")
63                        .value_parser(value_parser!(u32))
64                        .default_value("24")
65                        .help("tmux rows"),
66                )
67                .arg(
68                    Arg::new("port")
69                        .long("port")
70                        .num_args(1)
71                        .value_name("PORT")
72                        .env("TT_PORT")
73                        .value_parser(value_parser!(u16))
74                        .default_value("7681")
75                        .help("starting port for ttyd"),
76                )
77                .arg(
78                    Arg::new("font_size")
79                        .long("font-size")
80                        .num_args(1)
81                        .value_name("PT")
82                        .env("FONT_SIZE")
83                        .value_parser(value_parser!(u32))
84                        .default_value("24")
85                        .help("font size for ttyd"),
86                )
87                .arg(
88                    Arg::new("out")
89                        .long("out")
90                        .num_args(1)
91                        .value_name("PATH")
92                        .env("ASCII_OUT")
93                        .value_parser(value_parser!(PathBuf))
94                        .help("ascii output path (.cast). Default set dynamically."),
95                )
96                .arg(
97                    Arg::new("workdir")
98                        .long("workdir")
99                        .num_args(1)
100                        .value_name("PATH")
101                        .env("WORKING_DIRECTORY")
102                        .value_parser(value_parser!(PathBuf))
103                        .help("working directory for tmux session. Default: $HOME"),
104                )
105                .arg(
106                    Arg::new("kill_on_detach")
107                        .long("kill-on-detach")
108                        .env("TMUX_KILL_ON_DETACH")
109                    // accept as a flag OR with an optional value
110                        .num_args(0..=1)
111                        .require_equals(false)
112                    // if provided without a value, treat as "true"
113                        .default_missing_value("true")
114                    // parse 1/0, true/false, yes/no, on/off
115                        .value_parser(BoolishValueParser::new())
116                    // store the parsed bool
117                        .action(ArgAction::Set)
118                        .help("Kill tmux session after detach (supports true/false/1/0/yes/no/on/off)"),
119                ),
120        )
121        // --- record-hook (internal) ---
122        .subcommand(
123            Command::new("record-hook")
124                .about("INTERNAL: tmux/asciinema worker invoked inside ttyd")
125                .hide(true)
126                .arg(
127                    Arg::new("child")
128                        .long("child")
129                        .action(clap::ArgAction::SetTrue),
130                )
131                .arg(
132                    Arg::new("session")
133                        .long("session")
134                        .num_args(1)
135                        .value_name("NAME")
136                        .env("SESSION")
137                        .default_value("cast"),
138                )
139                .arg(
140                    Arg::new("cols")
141                        .long("cols")
142                        .num_args(1)
143                        .value_name("N")
144                        .env("TMUX_COLS")
145                        .value_parser(value_parser!(u32))
146                        .default_value("80"),
147                )
148                .arg(
149                    Arg::new("rows")
150                        .long("rows")
151                        .num_args(1)
152                        .value_name("N")
153                        .env("TMUX_ROWS")
154                        .value_parser(value_parser!(u32))
155                        .default_value("24"),
156                )
157                .arg(
158                    Arg::new("out")
159                        .long("out")
160                        .num_args(1)
161                        .value_name("PATH")
162                        .env("ASCII_OUT")
163                        .value_parser(value_parser!(PathBuf))
164                        .help("ascii output path (.cast). Default set dynamically."),
165                )
166                .arg(
167                    Arg::new("workdir")
168                        .long("workdir")
169                        .num_args(1)
170                        .value_name("PATH")
171                        .env("WORKING_DIRECTORY")
172                        .value_parser(value_parser!(PathBuf))
173                        .help("working directory for tmux session. Default: $HOME"),
174                )
175                .arg(
176                    Arg::new("kill_on_detach")
177                        .long("kill-on-detach")
178                        .env("TMUX_KILL_ON_DETACH")
179                        .action(clap::ArgAction::SetTrue),
180                ),
181        )
182        // --- completions ---
183        .subcommand(
184            Command::new("completions")
185                .about("Generates shell completions script (tab completion)")
186                .arg(
187                    Arg::new("shell")
188                        .help("The shell to generate completions for")
189                        .required(false)
190                        .value_parser(["bash", "zsh", "fish"]),
191                ),
192        )
193}
194
195pub fn generate_completion_script(shell: Shell, binary_name: &str) {
196    clap_complete::generate(shell, &mut app(binary_name), binary_name, &mut io::stdout())
197}
198
199pub fn print_completion_instructions(binary_name: &str) {
200    eprintln!("### Instructions to enable tab completion for {binary_name}\n");
201    eprintln!("### Bash (~/.bashrc)\n  source <({binary_name} completions bash)\n");
202    eprintln!("### Fish (~/.config/fish/config.fish)\n  {binary_name} completions fish | source\n");
203    eprintln!("### Zsh (~/.zshrc)\n  autoload -U compinit; compinit; source <({binary_name} completions zsh)");
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    // Helper: make a command using a stable name for tests.
211    fn test_cmd() -> Command {
212        // If your app() takes a name, use app("shell-scene"); otherwise just app()
213        #[allow(unused_mut)]
214        let mut cmd = { app("shell-scene") };
215        // sanity: no panics when building help
216        let _ = cmd.render_help();
217        cmd
218    }
219
220    #[test]
221    fn record_kill_flag_true_when_present_without_value() {
222        let cmd = test_cmd();
223        let m = cmd
224            .clone()
225            .try_get_matches_from(["shell-scene", "record", "--kill-on-detach"])
226            .expect("parse should succeed");
227        let sub = m.subcommand().expect("has sub");
228        assert_eq!(sub.0, "record");
229        let kill = *sub.1.get_one::<bool>("kill_on_detach").unwrap_or(&false);
230        assert!(kill, "flag without value should imply true");
231    }
232
233    #[test]
234    fn record_kill_flag_false_when_zero_value() {
235        let cmd = test_cmd();
236        let m = cmd
237            .clone()
238            .try_get_matches_from(["shell-scene", "record", "--kill-on-detach=0"])
239            .expect("parse should succeed");
240        let sub = m.subcommand().expect("has sub");
241        assert_eq!(sub.0, "record");
242        let kill = *sub.1.get_one::<bool>("kill_on_detach").unwrap_or(&true);
243        assert!(!kill, "explicit =0 should parse as false");
244    }
245}