use clap::{Args, Parser, Subcommand};
use std::str::FromStr;
#[derive(Debug, Parser)]
#[command(
name = "mtp-rs",
about = "Universal MTP/PTP file transfer CLI",
version
)]
pub struct Cli {
#[arg(long, global = true, conflicts_with = "location")]
pub device: Option<String>,
#[arg(long, global = true, value_parser = parse_u64_hex_or_dec, conflicts_with = "device")]
pub location: Option<u64>,
#[arg(long, global = true)]
pub storage: Option<String>,
#[arg(long, global = true, value_parser = parse_vid_pid)]
pub known: Vec<(u16, u16)>,
#[arg(long, global = true, default_value_t = 30)]
pub timeout: u64,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, short, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Devices,
Info,
Ls(LsArgs),
Put(PutArgs),
Get(GetArgs),
Mkdir(RemotePathArg),
Rm(RmArgs),
Rename(RenameArgs),
Mv(MoveArgs),
Cp(CopyArgs),
Doctor,
}
#[derive(Debug, Args)]
pub struct LsArgs {
#[arg(default_value = "/")]
pub remote_path: String,
#[arg(long)]
pub recursive: bool,
}
#[derive(Debug, Args)]
pub struct PutArgs {
pub local_path: std::path::PathBuf,
pub remote_path: String,
#[arg(long)]
pub replace: bool,
#[arg(long)]
pub verify: bool,
}
#[derive(Debug, Args)]
pub struct GetArgs {
pub remote_path: String,
pub local_path: std::path::PathBuf,
#[arg(long)]
pub replace: bool,
}
#[derive(Debug, Args)]
pub struct RemotePathArg {
pub remote_path: String,
}
#[derive(Debug, Args)]
pub struct RmArgs {
pub remote_path: String,
#[arg(long)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct RenameArgs {
pub remote_path: String,
pub new_name: String,
}
#[derive(Debug, Args)]
pub struct MoveArgs {
pub source_path: String,
pub destination_path: String,
#[arg(long)]
pub replace: bool,
}
#[derive(Debug, Args)]
pub struct CopyArgs {
pub source_path: String,
pub destination_path: String,
#[arg(long)]
pub replace: bool,
}
fn parse_vid_pid(input: &str) -> Result<(u16, u16), String> {
let (vid, pid) = input
.split_once(':')
.ok_or_else(|| "expected VID:PID, for example 091e:0003".to_string())?;
Ok((parse_u16_hex(vid)?, parse_u16_hex(pid)?))
}
fn parse_u16_hex(input: &str) -> Result<u16, String> {
let trimmed = input
.strip_prefix("0x")
.or_else(|| input.strip_prefix("0X"))
.unwrap_or(input);
u16::from_str_radix(trimmed, 16).map_err(|_| format!("invalid hex u16 value '{input}'"))
}
pub fn parse_u64_hex_or_dec(input: &str) -> Result<u64, String> {
if let Some(hex) = input
.strip_prefix("0x")
.or_else(|| input.strip_prefix("0X"))
{
return u64::from_str_radix(hex, 16)
.map_err(|_| format!("invalid hex u64 value '{input}'"));
}
if input.chars().any(|c| matches!(c, 'a'..='f' | 'A'..='F')) {
return u64::from_str_radix(input, 16)
.map_err(|_| format!("invalid hex u64 value '{input}'"));
}
u64::from_str(input).map_err(|_| format!("invalid decimal u64 value '{input}'"))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn clap_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn commands_parse() {
for args in [
["mtp-rs", "devices"].as_slice(),
["mtp-rs", "info"].as_slice(),
["mtp-rs", "ls", "/"].as_slice(),
["mtp-rs", "put", "local.bin", "/remote.bin"].as_slice(),
["mtp-rs", "get", "/remote.bin", "local.bin"].as_slice(),
["mtp-rs", "mkdir", "/Upload"].as_slice(),
["mtp-rs", "rm", "/Upload/old.bin", "--yes"].as_slice(),
["mtp-rs", "rename", "/Upload/old.bin", "new.bin"].as_slice(),
["mtp-rs", "mv", "/Upload/file.bin", "/Archive/file.bin"].as_slice(),
["mtp-rs", "cp", "/Upload/file.bin", "/Archive/file.bin"].as_slice(),
["mtp-rs", "doctor"].as_slice(),
] {
Cli::try_parse_from(args.iter().copied()).unwrap();
}
}
#[test]
fn device_and_location_conflict() {
assert!(
Cli::try_parse_from(["mtp-rs", "--device", "serial", "--location", "0x1", "info"])
.is_err()
);
}
#[test]
fn known_vid_pid_accepts_hex_pair() {
assert_eq!(parse_vid_pid("091e:0003").unwrap(), (0x091e, 0x0003));
assert_eq!(parse_vid_pid("0x091e:0x0003").unwrap(), (0x091e, 0x0003));
}
#[test]
fn known_vid_pid_rejects_malformed_input() {
assert!(parse_vid_pid("091e").is_err());
assert!(parse_vid_pid("nope:0003").is_err());
}
#[test]
fn location_parser_accepts_decimal_and_hex() {
assert_eq!(parse_u64_hex_or_dec("42").unwrap(), 42);
assert_eq!(parse_u64_hex_or_dec("0x2a").unwrap(), 42);
assert_eq!(parse_u64_hex_or_dec("2a").unwrap(), 42);
}
}