use std::time::Duration;
use crossterm::event::{poll, read, Event, KeyCode};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
const CARRIAGE_RETURN: &str = "\r";
const CLEAR_LINE: &str = "\x1b[K";
pub fn format_status_duration(duration: Duration) -> String {
let total = duration.as_secs();
let hours = total / 3600;
let minutes = (total % 3600) / 60;
let seconds = total % 60;
if hours > 0 {
format!("{}h{:02}m{:02}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m{:02}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
pub fn format_capture_status(elapsed: Duration, duration: Option<Duration>) -> String {
let elapsed_str = format_status_duration(elapsed);
match duration {
Some(total) => {
let remaining = total.saturating_sub(elapsed);
format!(
"Capturing [{} elapsed, {} remaining], press s to stop.",
elapsed_str,
format_status_duration(remaining),
)
}
None => format!(
"Capturing [{} elapsed], press s to stop.",
elapsed_str,
),
}
}
pub fn print_capture_status(elapsed: Duration, duration: Option<Duration>) {
let line = format_capture_status(elapsed, duration);
eprint!("{}{}{}", CARRIAGE_RETURN, line, CLEAR_LINE);
use std::io::Write;
let _ = std::io::stderr().flush();
}
pub struct StopKeyListener {
raw_mode_enabled: bool,
}
impl StopKeyListener {
pub fn new() -> Self {
Self {
raw_mode_enabled: enable_raw_mode().is_ok(),
}
}
pub fn wait_for_stop_key(&self, timeout: Duration) -> anyhow::Result<bool> {
if !self.raw_mode_enabled {
std::thread::sleep(timeout);
return Ok(false);
}
if !poll(timeout)? {
return Ok(false);
}
if let Event::Key(key) = read()? {
Ok(matches!(key.code, KeyCode::Char('s' | 'S')))
} else {
Ok(false)
}
}
}
impl Drop for StopKeyListener {
fn drop(&mut self) {
if self.raw_mode_enabled {
let _ = disable_raw_mode();
}
}
}
pub fn wait_for_stop_key(timeout: Duration) -> anyhow::Result<bool> {
let listener = StopKeyListener::new();
listener.wait_for_stop_key(timeout)
}
pub fn print_saved(path: &std::path::Path) {
eprintln!("Saved to {}", path.display());
}
pub fn print_cut_saved(path: &std::path::Path) {
eprintln!("Cut saved to {}", path.display());
}
pub fn print_warning(msg: &str) {
eprintln!("\x1b[33mwarning\x1b[0m: {}", msg);
}
pub fn print_error(msg: &str) {
eprintln!("\x1b[31merror\x1b[0m: {}", msg);
}
pub fn format_help() -> String {
let mut help = String::new();
help.push_str("vidcapture — CLI screen and audio recorder for macOS\n\n");
help.push_str("USAGE:\n");
help.push_str(" vidcapture <COMMAND> [FLAGS]\n");
help.push_str(" vidcapture -h | --help Show short flag help (auto-generated by clap)\n\n");
help.push_str("COMMANDS:\n");
help.push_str(" start Start capturing screen and audio\n");
help.push_str(" cut Cut a range out of an existing video file\n");
help.push_str(" help Show this help message with setup instructions\n\n");
help.push_str("FLAGS (start):\n");
help.push_str(" -d, --duration <TIME> Capture duration. Stops automatically after this time.\n");
help.push_str(" Examples: 10s, 2m, 1h, 1h30m10s\n");
help.push_str(" -e, --every <TIME> Interval mode. Split the capture into segments of this duration.\n");
help.push_str(" Examples: 10s, 2m\n");
help.push_str(" Each segment is a standalone, playable MP4.\n");
help.push_str(" -o, --output <DIR> Output directory for the recorded file(s).\n");
help.push_str(" Default: current working directory.\n");
help.push_str(" Example: ./recordings/\n");
help.push_str(" Created if it does not exist. Filenames auto-increment on collision.\n");
help.push_str(" -v, --verbose Show ffmpeg output while recording.\n\n");
help.push_str("FLAGS (cut):\n");
help.push_str(" <SOURCE> Path to the source video file. Required.\n");
help.push_str(" -f, --from <TIME> Start offset of the cut range (default: 0s).\n");
help.push_str(" -t, --to <TIME> End offset of the cut range. Conflicts with --length.\n");
help.push_str(" -l, --length <TIME> Cut length. Conflicts with --to.\n");
help.push_str(" -o, --output <PATH> Output file or directory.\n");
help.push_str(" --fast Stream-copy instead of re-encoding.\n");
help.push_str(" -v, --verbose Show ffmpeg output.\n");
help.push_str(" cut requires ffmpeg but not BlackHole, and never touches the source video.\n\n");
help.push_str("DURATION FORMAT:\n");
help.push_str(" A timespec is one of two notations:\n");
help.push('\n');
help.push_str(" Unit-suffixed: a sequence of <number><unit> pairs.\n");
help.push_str(" Units: h (hours), m (minutes), s (seconds), ms (milliseconds).\n");
help.push_str(" Decimals are allowed: 1.5s = 1500ms, 0.25m = 15s.\n");
help.push_str(" Examples:\n");
help.push_str(" 10s 10 seconds\n");
help.push_str(" 1500ms 1.5 seconds\n");
help.push_str(" 1.5s 1.5 seconds\n");
help.push_str(" 2m 2 minutes\n");
help.push_str(" 1h 1 hour\n");
help.push_str(" 1h30m 1 hour 30 minutes\n");
help.push_str(" 1h30m10s 1 hour 30 minutes 10 seconds\n");
help.push('\n');
help.push_str(" Timestamp: HH:MM:SS[.mmm] or MM:SS[.mmm].\n");
help.push_str(" Examples:\n");
help.push_str(" 00:01:30.500 1 minute 30.5 seconds\n");
help.push_str(" 01:30 1 minute 30 seconds\n");
help.push_str(" 1:02:03.250 1 hour 2 minutes 3.25 seconds\n\n");
help.push_str("SETUP:\n\n");
help.push_str(" 1. Install ffmpeg:\n");
help.push_str(" brew install ffmpeg\n");
help.push_str(" Verify with: ffmpeg -version\n\n");
help.push_str(" 2. Install BlackHole 2ch (for system audio capture):\n");
help.push_str(" brew install blackhole-2ch\n\n");
help.push_str(" 3. Configure Multi-Output Device in Audio MIDI Setup:\n");
help.push_str(" a. Open Audio MIDI Setup (in /Applications/Utilities).\n");
help.push_str(" b. Click the + button at the bottom-left and choose \"Create Multi-Output Device\".\n");
help.push_str(" c. In the new device, check both \"BlackHole 2ch\" and your speakers/headphones.\n");
help.push_str(" d. Right-click the Multi-Output Device and select \"Use This Device For Sound Output\".\n");
help.push_str(" e. Set its drift correction to the BlackHole entry.\n\n");
help.push_str(" 4. Sanity check that ffmpeg sees your devices:\n");
help.push_str(" ffmpeg -f avfoundation -list_devices true -i \"\"\n\n");
help.push_str("EXAMPLES:\n");
help.push_str(" vidcapture start # Record until you press 's'\n");
help.push_str(" vidcapture start -d 30s # Record for 30 seconds\n");
help.push_str(" vidcapture start -e 10s # Cut into 10-second segments\n");
help.push_str(" vidcapture start -d 1m -e 10s # 6 segments of 10 seconds, then stop\n");
help.push_str(" vidcapture start -o ./recordings/ # Save into ./recordings/\n");
help.push_str(" vidcapture start -v # Show ffmpeg output while recording\n");
help.push_str(" vidcapture cut talk.mp4 --length 5s # First 5 seconds\n");
help.push_str(" vidcapture cut talk.mp4 --from 10s --to 25s # From 10s to 25s\n");
help.push_str(" vidcapture cut talk.mp4 --from 10s --length 1.5s # 1.5s starting at 10s\n");
help
}
pub fn print_help() {
print!("{}", format_help());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_key_listener_returns_false_without_a_ready_key() {
let listener = StopKeyListener::new();
assert!(!listener.wait_for_stop_key(Duration::ZERO).unwrap());
}
#[test]
fn format_help_returns_non_empty_string() {
let help = format_help();
assert!(!help.is_empty(), "help text should not be empty");
}
#[test]
fn format_help_documents_start_command() {
let help = format_help();
assert!(
help.contains("start"),
"help text should mention the 'start' command, got: {}",
help
);
}
#[test]
fn format_help_documents_help_command() {
let help = format_help();
assert!(
help.contains("help"),
"help text should document the 'help' command, got: {}",
help
);
}
#[test]
fn format_help_documents_duration_flag_with_examples() {
let help = format_help();
assert!(help.contains("-d"), "help text should mention -d");
assert!(help.contains("--duration"), "help text should mention --duration");
assert!(
help.contains("10s"),
"help text should include duration examples like 10s"
);
assert!(
help.contains("2m"),
"help text should include duration examples like 2m"
);
assert!(
help.contains("1h"),
"help text should include duration examples like 1h"
);
}
#[test]
fn format_help_documents_every_flag_with_examples() {
let help = format_help();
assert!(help.contains("-e"), "help text should mention -e");
assert!(help.contains("--every"), "help text should mention --every");
assert!(
help.contains("segment"),
"help text should describe interval mode segments"
);
}
#[test]
fn format_help_documents_output_flag_with_examples() {
let help = format_help();
assert!(help.contains("-o"), "help text should mention -o");
assert!(help.contains("--output"), "help text should mention --output");
assert!(
help.contains("./recordings/"),
"help text should include an output directory example"
);
}
#[test]
fn format_help_documents_verbose_flag() {
let help = format_help();
assert!(help.contains("-v"), "help text should mention -v");
assert!(help.contains("--verbose"), "help text should mention --verbose");
}
#[test]
fn format_help_documents_default_output_directory() {
let help = format_help();
assert!(
help.contains("current working directory")
|| help.contains("current directory"),
"help text should state the default output directory"
);
}
#[test]
fn format_help_documents_short_help_flag() {
let help = format_help();
assert!(
help.contains("-h") && help.contains("--help"),
"help text should mention the auto-generated short help flag"
);
}
#[test]
fn format_help_documents_duration_format_compound_examples() {
let help = format_help();
assert!(
help.contains("1h30m"),
"help text should include compound duration example 1h30m"
);
assert!(
help.contains("1h30m10s"),
"help text should include full compound duration example 1h30m10s"
);
}
#[test]
fn format_help_documents_timespec_ms_unit() {
let help = format_help();
assert!(
help.contains("ms"),
"help text should document the ms (millisecond) unit"
);
assert!(
help.contains("1500ms"),
"help text should include an ms example"
);
}
#[test]
fn format_help_documents_timespec_decimal() {
let help = format_help();
assert!(
help.contains("1.5s"),
"help text should include a decimal seconds example"
);
}
#[test]
fn format_help_documents_timestamp_notation() {
let help = format_help();
assert!(
help.contains("HH:MM:SS"),
"help text should document the HH:MM:SS timestamp notation"
);
assert!(
help.contains("00:01:30.500"),
"help text should include a timestamp example"
);
}
#[test]
fn format_help_notes_cut_needs_ffmpeg_not_blackhole() {
let help = format_help();
assert!(
help.contains("ffmpeg") && help.contains("not BlackHole"),
"help text should note that cut requires ffmpeg but not BlackHole"
);
}
#[test]
fn format_help_attributes_the_blackhole_note_to_cut_not_to_verbose() {
let help = format_help();
let note_line = help
.lines()
.find(|line| line.contains("not BlackHole"))
.expect("help should carry the BlackHole note");
assert!(
note_line.contains("cut"),
"the note must name the cut command, got: {}",
note_line.trim()
);
}
#[test]
fn format_help_includes_ffmpeg_install_instructions() {
let help = format_help();
assert!(
help.contains("brew install ffmpeg"),
"help text should include the ffmpeg install command"
);
}
#[test]
fn format_help_includes_blackhole_setup_instructions() {
let help = format_help();
assert!(
help.to_lowercase().contains("blackhole"),
"help text should mention BlackHole"
);
assert!(
help.contains("Multi-Output Device"),
"help text should explain Multi-Output Device setup"
);
assert!(
help.contains("Audio MIDI Setup"),
"help text should mention Audio MIDI Setup"
);
}
#[test]
fn format_help_includes_list_devices_check() {
let help = format_help();
assert!(
help.contains("avfoundation") && help.contains("list_devices"),
"help text should include the avfoundation list_devices sanity check"
);
}
#[test]
fn format_help_includes_examples_section() {
let help = format_help();
assert!(
help.contains("EXAMPLES"),
"help text should include an EXAMPLES section header"
);
}
#[test]
fn format_status_duration_seconds_only() {
assert_eq!(format_status_duration(Duration::from_secs(0)), "0s");
assert_eq!(format_status_duration(Duration::from_secs(7)), "7s");
assert_eq!(format_status_duration(Duration::from_secs(59)), "59s");
}
#[test]
fn format_status_duration_minutes_and_seconds() {
assert_eq!(format_status_duration(Duration::from_secs(60)), "1m00s");
assert_eq!(format_status_duration(Duration::from_secs(125)), "2m05s");
assert_eq!(format_status_duration(Duration::from_secs(3599)), "59m59s");
}
#[test]
fn format_status_duration_full_hours_minutes_seconds() {
assert_eq!(
format_status_duration(Duration::from_secs(3_661)),
"1h01m01s"
);
assert_eq!(
format_status_duration(Duration::from_secs(7_200)),
"2h00m00s"
);
}
#[test]
fn format_capture_status_with_duration_includes_remaining() {
let line = format_capture_status(Duration::from_secs(5), Some(Duration::from_secs(10)));
assert!(line.contains("5s elapsed"), "got: {}", line);
assert!(line.contains("5s remaining"), "got: {}", line);
assert!(line.contains("press s to stop"), "got: {}", line);
}
#[test]
fn format_capture_status_without_duration_omits_remaining() {
let line = format_capture_status(Duration::from_secs(5), None);
assert!(line.contains("5s elapsed"), "got: {}", line);
assert!(
!line.contains("remaining"),
"indefinite status must not advertise a remaining time, got: {}",
line
);
}
#[test]
fn format_capture_status_remaining_does_not_go_negative() {
let line = format_capture_status(Duration::from_secs(11), Some(Duration::from_secs(10)));
assert!(line.contains("0s remaining"), "got: {}", line);
}
#[test]
fn format_capture_status_with_long_duration_uses_hours_form() {
let line = format_capture_status(
Duration::from_secs(300),
Some(Duration::from_secs(5_410)),
);
assert!(line.contains("5m00s elapsed"), "got: {}", line);
assert!(line.contains("1h25m10s remaining"), "got: {}", line);
}
}