diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ef8e8d0 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,234 @@ +use clap::{Args, ValueEnum}; +use clap::{Parser, Subcommand}; +use std::net::SocketAddr; +use std::num::ParseIntError; +use std::path::PathBuf; + +pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080"; + +#[derive(Debug, Parser)] +#[clap(author, version, about)] +#[command(name = "asciinema")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// asciinema server URL + #[arg(long, global = true)] + pub server_url: Option, + + /// Quiet mode, i.e. suppress diagnostic messages + #[clap(short, long, global = true)] + pub quiet: bool, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Record a terminal session + Rec(Record), + + /// Replay a terminal session + Play(Play), + + /// Stream a terminal session + Stream(Stream), + + /// Concatenate multiple recordings + Cat(Cat), + + /// Convert a recording into another format + Convert(Convert), + + /// Upload a recording to an asciinema server + Upload(Upload), + + /// Authenticate this CLI with an asciinema server account + Auth(Auth), +} + +#[derive(Debug, Args)] +pub struct Record { + pub filename: String, + + /// Enable input recording + #[arg(long, short = 'I', alias = "stdin")] + pub input: bool, + + /// Append to an existing recording file + #[arg(short, long)] + pub append: bool, + + /// Recording file format [default: asciicast] + #[arg(short, long, value_enum)] + pub format: Option, + + #[arg(long, hide = true)] + pub raw: bool, + + /// Overwrite target file if it already exists + #[arg(long, conflicts_with = "append")] + pub overwrite: bool, + + /// Command to record [default: $SHELL] + #[arg(short, long)] + pub command: Option, + + /// List of env vars to save [default: TERM,SHELL] + #[arg(long)] + pub env: Option, + + /// Title of the recording + #[arg(short, long)] + pub title: Option, + + /// Limit idle time to a given number of seconds + #[arg(short, long, value_name = "SECS")] + pub idle_time_limit: Option, + + /// Override terminal size for the recorded command + #[arg(long, value_name = "COLSxROWS", value_parser = parse_tty_size)] + pub tty_size: Option<(Option, Option)>, + // pub tty_size: Option, + #[arg(long, hide = true)] + cols: Option, + + #[arg(long, hide = true)] + rows: Option, +} + +#[derive(Debug, Args)] +pub struct Play { + #[arg(value_name = "FILENAME_OR_URL")] + pub filename: String, + + /// Limit idle time to a given number of seconds + #[arg(short, long, value_name = "SECS")] + pub idle_time_limit: Option, + + /// Set playback speed + #[arg(short, long)] + pub speed: Option, + + /// Loop loop loop loop + #[arg(short, long, name = "loop")] + pub loop_: bool, + + /// Automatically pause on markers + #[arg(short = 'm', long)] + pub pause_on_markers: bool, +} + +#[derive(Debug, Args)] +pub struct Stream { + /// Enable input capture + #[arg(long, short = 'I', alias = "stdin")] + pub input: bool, + + /// Command to stream [default: $SHELL] + #[arg(short, long)] + pub command: Option, + + /// Serve the stream with the built-in HTTP server + #[arg(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)] + pub serve: Option, + + /// Relay the stream via an asciinema server + #[arg(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)] + pub relay: Option, + + /// Override terminal size for the session + #[arg(long, value_name = "COLSxROWS", value_parser = parse_tty_size)] + pub tty_size: Option<(Option, Option)>, + // pub tty_size: Option, + /// Log file path + #[arg(long)] + pub log_file: Option, +} + +#[derive(Debug, Args)] +pub struct Cat { + #[arg(required = true)] + pub filename: Vec, +} + +#[derive(Debug, Args)] +pub struct Convert { + #[arg(value_name = "INPUT_FILENAME_OR_URL")] + pub input_filename: String, + + pub output_filename: String, + + /// Output file format [default: asciicast] + #[arg(short, long, value_enum)] + pub format: Option, + + /// Overwrite target file if it already exists + #[arg(long)] + pub overwrite: bool, +} + +#[derive(Debug, Args)] +pub struct Upload { + /// Filename/path of asciicast to upload + pub filename: String, +} + +#[derive(Debug, Args)] +pub struct Auth {} + +#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] +pub enum Format { + Asciicast, + Raw, + Txt, +} + +#[derive(Debug, Clone)] +pub enum RelayTarget { + StreamId(String), + WsProducerUrl(url::Url), +} + +fn parse_tty_size(s: &str) -> Result<(Option, Option), String> { + match s.split_once('x') { + Some((cols, "")) => { + let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?; + + Ok((Some(cols), None)) + } + + Some(("", rows)) => { + let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?; + + Ok((None, Some(rows))) + } + + Some((cols, rows)) => { + let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?; + let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?; + + Ok((Some(cols), Some(rows))) + } + + None => Err(s.to_owned()), + } +} + +fn validate_forward_target(s: &str) -> Result { + let s = s.trim(); + + match url::Url::parse(s) { + Ok(url) => { + let scheme = url.scheme(); + + if scheme == "ws" || scheme == "wss" { + Ok(RelayTarget::WsProducerUrl(url)) + } else { + Err("must be a WebSocket URL (ws:// or wss://)".to_owned()) + } + } + + Err(url::ParseError::RelativeUrlWithoutBase) => Ok(RelayTarget::StreamId(s.to_owned())), + Err(e) => Err(e.to_string()), + } +} diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index d83ed56..07ff956 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -1,13 +1,11 @@ +use super::Command; use crate::api; +use crate::cli; use crate::config::Config; use anyhow::Result; -use clap::Args; -#[derive(Debug, Args)] -pub struct Cli {} - -impl Cli { - pub fn run(self, config: &Config) -> Result<()> { +impl Command for cli::Auth { + fn run(self, config: &Config) -> Result<()> { let server_url = config.get_server_url()?; let server_hostname = server_url.host().unwrap(); let auth_url = api::get_auth_url(config)?; diff --git a/src/cmd/cat.rs b/src/cmd/cat.rs index 8409476..d13c9ce 100644 --- a/src/cmd/cat.rs +++ b/src/cmd/cat.rs @@ -1,16 +1,12 @@ +use super::Command; use crate::asciicast; +use crate::cli; +use crate::config::Config; use anyhow::Result; -use clap::Args; use std::io; -#[derive(Debug, Args)] -pub struct Cli { - #[arg(required = true)] - filename: Vec, -} - -impl Cli { - pub fn run(self) -> Result<()> { +impl Command for cli::Cat { + fn run(self, _config: &Config) -> Result<()> { let mut writer = asciicast::Writer::new(io::stdout(), 0); let mut time_offset: u64 = 0; let mut first = true; diff --git a/src/cmd/convert.rs b/src/cmd/convert.rs index e60b17c..b0038b3 100644 --- a/src/cmd/convert.rs +++ b/src/cmd/convert.rs @@ -1,45 +1,24 @@ +use super::Command; use crate::asciicast::{self, Header}; -use crate::encoder; +use crate::cli::{self, Format}; +use crate::config::Config; +use crate::encoder::{self, EncoderExt}; use crate::util; use anyhow::{bail, Result}; -use clap::{Args, ValueEnum}; use std::fs; use std::path::Path; -#[derive(Debug, Args)] -pub struct Cli { - #[arg(value_name = "INPUT_FILENAME_OR_URL")] - input_filename: String, - - output_filename: String, - - /// Output file format [default: asciicast] - #[arg(short, long, value_enum)] - format: Option, - - /// Overwrite target file if it already exists - #[arg(long)] - overwrite: bool, -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -enum Format { - Asciicast, - Raw, - Txt, -} - -use crate::encoder::EncoderExt; - -impl Cli { - pub fn run(self) -> Result<()> { +impl Command for cli::Convert { + fn run(self, _config: &Config) -> Result<()> { let path = util::get_local_path(&self.input_filename)?; let input = asciicast::open_from_path(&*path)?; let mut output = self.get_output(&input.header)?; output.encode(input) } +} +impl cli::Convert { fn get_output(&self, header: &Header) -> Result> { let file = self.open_file()?; diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 6d97b53..91374ec 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -10,6 +10,10 @@ use crate::notifier; use std::collections::HashMap; use std::env; +pub trait Command { + fn run(self, config: &Config) -> anyhow::Result<()>; +} + fn get_notifier(config: &Config) -> Box { if config.notifications.enabled { notifier::get_notifier(config.notifications.command.clone()) diff --git a/src/cmd/play.rs b/src/cmd/play.rs index d4c32a2..05d89a3 100644 --- a/src/cmd/play.rs +++ b/src/cmd/play.rs @@ -1,36 +1,15 @@ +use super::Command; use crate::asciicast; +use crate::cli; use crate::config::Config; use crate::logger; use crate::player::{self, KeyBindings}; use crate::tty; use crate::util; use anyhow::Result; -use clap::Args; -#[derive(Debug, Args)] -pub struct Cli { - #[arg(value_name = "FILENAME_OR_URL")] - filename: String, - - /// Limit idle time to a given number of seconds - #[arg(short, long, value_name = "SECS")] - idle_time_limit: Option, - - /// Set playback speed - #[arg(short, long)] - speed: Option, - - /// Loop loop loop loop - #[arg(short, long, name = "loop")] - loop_: bool, - - /// Automatically pause on markers - #[arg(short = 'm', long)] - pause_on_markers: bool, -} - -impl Cli { - pub fn run(self, config: &Config) -> Result<()> { +impl Command for cli::Play { + fn run(self, config: &Config) -> Result<()> { let speed = self.speed.or(config.cmd_play_speed()).unwrap_or(1.0); let idle_time_limit = self.idle_time_limit.or(config.cmd_play_idle_time_limit()); diff --git a/src/cmd/rec.rs b/src/cmd/rec.rs index f6785cd..96c488d 100644 --- a/src/cmd/rec.rs +++ b/src/cmd/rec.rs @@ -1,4 +1,6 @@ +use super::Command; use crate::asciicast; +use crate::cli; use crate::config::Config; use crate::encoder; use crate::locale; @@ -7,71 +9,14 @@ use crate::pty; use crate::recorder::{self, KeyBindings}; use crate::tty; use anyhow::{bail, Result}; -use clap::{Args, ValueEnum}; +use cli::Format; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::path::Path; -#[derive(Debug, Args)] -pub struct Cli { - filename: String, - - /// Enable input recording - #[arg(long, short = 'I', alias = "stdin")] - input: bool, - - /// Append to an existing recording file - #[arg(short, long)] - append: bool, - - /// Recording file format [default: asciicast] - #[arg(short, long, value_enum)] - format: Option, - - #[arg(long, hide = true)] - raw: bool, - - /// Overwrite target file if it already exists - #[arg(long, conflicts_with = "append")] - overwrite: bool, - - /// Command to record [default: $SHELL] - #[arg(short, long)] - command: Option, - - /// List of env vars to save [default: TERM,SHELL] - #[arg(long)] - env: Option, - - /// Title of the recording - #[arg(short, long)] - title: Option, - - /// Limit idle time to a given number of seconds - #[arg(short, long, value_name = "SECS")] - idle_time_limit: Option, - - /// Override terminal size for the recorded command - #[arg(long, value_name = "COLSxROWS")] - tty_size: Option, - - #[arg(long, hide = true)] - cols: Option, - - #[arg(long, hide = true)] - rows: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] -enum Format { - Asciicast, - Raw, - Txt, -} - -impl Cli { - pub fn run(self, config: &Config) -> Result<()> { +impl Command for cli::Record { + fn run(self, config: &Config) -> Result<()> { locale::check_utf8_locale()?; let format = self.get_format(); @@ -116,7 +61,9 @@ impl Cli { Ok(()) } +} +impl cli::Record { fn get_mode(&self) -> Result<(bool, bool)> { let mut overwrite = self.overwrite; let mut append = self.append; diff --git a/src/cmd/stream.rs b/src/cmd/stream.rs index 943cf99..830788b 100644 --- a/src/cmd/stream.rs +++ b/src/cmd/stream.rs @@ -1,4 +1,6 @@ +use super::Command; use crate::api; +use crate::cli; use crate::config::Config; use crate::locale; use crate::logger; @@ -8,80 +10,24 @@ use crate::tty; use crate::util; use anyhow::bail; use anyhow::{anyhow, Context, Result}; -use clap::Args; +use cli::{RelayTarget, DEFAULT_LISTEN_ADDR}; use std::collections::HashMap; use std::env; use std::fmt::Debug; use std::fs; -use std::net::SocketAddr; use std::net::TcpListener; -use std::path::PathBuf; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; use url::Url; -const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080"; - -#[derive(Debug, Args)] -pub struct Cli { - /// Enable input capture - #[arg(long, short = 'I', alias = "stdin")] - input: bool, - - /// Command to stream [default: $SHELL] - #[arg(short, long)] - command: Option, - - /// Serve the stream with the built-in HTTP server - #[clap(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)] - serve: Option, - - /// Relay the stream via an asciinema server - #[clap(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)] - relay: Option, - - /// Override terminal size for the session - #[arg(long, value_name = "COLSxROWS")] - tty_size: Option, - - /// Log file path - #[arg(long)] - log_file: Option, -} - -#[derive(Debug, Clone)] -enum RelayTarget { - StreamId(String), - WsProducerUrl(url::Url), -} - #[derive(Debug)] struct Relay { ws_producer_url: Url, url: Option, } -fn validate_forward_target(s: &str) -> Result { - let s = s.trim(); - - match url::Url::parse(s) { - Ok(url) => { - let scheme = url.scheme(); - - if scheme == "ws" || scheme == "wss" { - Ok(RelayTarget::WsProducerUrl(url)) - } else { - Err("must be a WebSocket URL (ws:// or wss://)".to_owned()) - } - } - - Err(url::ParseError::RelativeUrlWithoutBase) => Ok(RelayTarget::StreamId(s.to_owned())), - Err(e) => Err(e.to_string()), - } -} - -impl Cli { - pub fn run(mut self, config: &Config) -> Result<()> { +impl Command for cli::Stream { + fn run(mut self, config: &Config) -> Result<()> { locale::check_utf8_locale()?; if self.serve.is_none() && self.relay.is_none() { @@ -157,7 +103,9 @@ impl Cli { Ok(()) } +} +impl cli::Stream { fn get_command(&self, config: &Config) -> Option { self.command .as_ref() diff --git a/src/cmd/upload.rs b/src/cmd/upload.rs index fdfbb02..04f0aa9 100644 --- a/src/cmd/upload.rs +++ b/src/cmd/upload.rs @@ -1,17 +1,12 @@ +use super::Command; use crate::api; use crate::asciicast; +use crate::cli; use crate::config::Config; use anyhow::Result; -use clap::Args; -#[derive(Debug, Args)] -pub struct Cli { - /// Filename/path of asciicast to upload - filename: String, -} - -impl Cli { - pub fn run(self, config: &Config) -> Result<()> { +impl Command for cli::Upload { + fn run(self, config: &Config) -> Result<()> { let _ = asciicast::open_from_path(&self.filename)?; let response = api::upload_asciicast(&self.filename, config)?; println!("{}", response.message.unwrap_or(response.url)); diff --git a/src/main.rs b/src/main.rs index a5fb27f..61634a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod api; mod asciicast; +mod cli; mod cmd; mod config; mod encoder; @@ -13,51 +14,12 @@ mod recorder; mod streamer; mod tty; mod util; +use crate::cli::{Cli, Commands}; use crate::config::Config; -use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::Parser; +use cmd::Command; -#[derive(Debug, Parser)] -#[clap(author, version, about)] -#[command(name = "asciinema")] -struct Cli { - #[command(subcommand)] - command: Commands, - - /// asciinema server URL - #[arg(long, global = true)] - server_url: Option, - - /// Quiet mode, i.e. suppress diagnostic messages - #[clap(short, long, global = true)] - quiet: bool, -} - -#[derive(Debug, Subcommand)] -enum Commands { - /// Record a terminal session - Rec(cmd::rec::Cli), - - /// Replay a terminal session - Play(cmd::play::Cli), - - /// Stream a terminal session - Stream(cmd::stream::Cli), - - /// Concatenate multiple recordings - Cat(cmd::cat::Cli), - - /// Convert a recording into another format - Convert(cmd::convert::Cli), - - /// Upload a recording to an asciinema server - Upload(cmd::upload::Cli), - - /// Authenticate this CLI with an asciinema server account - Auth(cmd::auth::Cli), -} - -fn main() -> Result<()> { +fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let config = Config::new(cli.server_url.clone())?; @@ -69,8 +31,8 @@ fn main() -> Result<()> { Commands::Rec(record) => record.run(&config), Commands::Play(play) => play.run(&config), Commands::Stream(stream) => stream.run(&config), - Commands::Cat(cat) => cat.run(), - Commands::Convert(convert) => convert.run(), + Commands::Cat(cat) => cat.run(&config), + Commands::Convert(convert) => convert.run(&config), Commands::Upload(upload) => upload.run(&config), Commands::Auth(auth) => auth.run(&config), } diff --git a/src/pty.rs b/src/pty.rs index 220ebe8..6545ed1 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -15,9 +15,9 @@ use std::io::{self, ErrorKind, Read, Write}; use std::os::fd::{AsFd, RawFd}; use std::os::fd::{BorrowedFd, OwnedFd}; use std::os::unix::io::{AsRawFd, FromRawFd}; -use std::str::FromStr; use std::{env, fs}; +type TtySizeOverride = (Option, Option); type ExtraEnv = HashMap; pub trait Recorder { @@ -31,10 +31,10 @@ pub fn exec, T: Tty + ?Sized, R: Recorder>( command: &[S], extra_env: &ExtraEnv, tty: &mut T, - winsize_override: Option, + tty_size_override: Option, recorder: &mut R, ) -> Result { - let winsize = get_winsize(&*tty, winsize_override.as_ref()); + let winsize = get_winsize(&*tty, &tty_size_override); recorder.start(winsize.into())?; let result = unsafe { pty::forkpty(Some(&winsize), None) }?; @@ -43,7 +43,7 @@ pub fn exec, T: Tty + ?Sized, R: Recorder>( result.master.as_raw_fd(), child, tty, - winsize_override, + tty_size_override, recorder, ), @@ -58,10 +58,10 @@ fn handle_parent( master_fd: RawFd, child: unistd::Pid, tty: &mut T, - winsize_override: Option, + tty_size_override: Option, recorder: &mut R, ) -> Result { - let wait_result = match copy(master_fd, child, tty, winsize_override, recorder) { + let wait_result = match copy(master_fd, child, tty, tty_size_override, recorder) { Ok(Some(status)) => Ok(status), Ok(None) => wait::waitpid(child, None), @@ -85,7 +85,7 @@ fn copy( master_raw_fd: RawFd, child: unistd::Pid, tty: &mut T, - winsize_override: Option, + tty_size_override: Option, recorder: &mut R, ) -> Result> { let mut master = unsafe { fs::File::from_raw_fd(master_raw_fd) }; @@ -223,7 +223,7 @@ fn copy( if sigwinch_read { sigwinch_fd.flush(); - let winsize = get_winsize(&*tty, winsize_override.as_ref()); + let winsize = get_winsize(&*tty, &tty_size_override); set_pty_size(master_raw_fd, &winsize); recorder.resize(winsize.into()); } @@ -287,61 +287,25 @@ fn handle_child>(command: &[S], extra_env: &ExtraEnv) -> Result<() unsafe { libc::_exit(1) } } -#[derive(Clone, Debug)] -pub enum WinsizeOverride { - Full(u16, u16), - Cols(u16), - Rows(u16), -} - -impl FromStr for WinsizeOverride { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s.split_once('x') { - Some((cols, "")) => { - let cols: u16 = cols.parse()?; - - Ok(WinsizeOverride::Cols(cols)) - } - - Some(("", rows)) => { - let rows: u16 = rows.parse()?; - - Ok(WinsizeOverride::Rows(rows)) - } - - Some((cols, rows)) => { - let cols: u16 = cols.parse()?; - let rows: u16 = rows.parse()?; - - Ok(WinsizeOverride::Full(cols, rows)) - } - - None => { - bail!("{s}") - } - } - } -} - fn get_winsize( tty: &T, - winsize_override: Option<&WinsizeOverride>, + tty_size_override: &Option, ) -> pty::Winsize { let mut winsize = tty.get_size(); - match winsize_override { - Some(WinsizeOverride::Full(cols, rows)) => { + match tty_size_override { + Some((None, None)) => (), + + Some((Some(cols), None)) => { winsize.ws_col = *cols; + } + + Some((None, Some(rows))) => { winsize.ws_row = *rows; } - Some(WinsizeOverride::Cols(cols)) => { + Some((Some(cols), Some(rows))) => { winsize.ws_col = *cols; - } - - Some(WinsizeOverride::Rows(rows)) => { winsize.ws_row = *rows; } @@ -438,7 +402,7 @@ impl Drop for SignalFd { #[cfg(test)] mod tests { use super::Recorder; - use crate::pty::{ExtraEnv, WinsizeOverride}; + use crate::pty::ExtraEnv; use crate::tty::{NullTty, TtySize}; #[derive(Default)] @@ -558,7 +522,7 @@ sys.stdout.write('bar'); &["true"], &ExtraEnv::new(), &mut NullTty::open().unwrap(), - Some(WinsizeOverride::Full(100, 50)), + Some((Some(100), Some(50))), &mut recorder, ) .unwrap();