瀏覽代碼

Redo CLI command handling, make it modular with detached matchers

timvisee 7 年之前
父節點
當前提交
7ced1f4278

+ 1 - 0
api/src/action/params.rs

@@ -15,6 +15,7 @@ const HEADER_AUTH_NONCE: &'static str = "WWW-Authenticate";
 
 /// The default download count.
 pub const PARAMS_DEFAULT_DOWNLOAD: u8 = 1;
+pub const PARAMS_DEFAULT_DOWNLOAD_STR: &'static str = "1";
 
 /// The minimum allowed number of downloads, enforced by the server.
 pub const PARAMS_DOWNLOAD_MIN: u8 = 1;

+ 13 - 6
cli/src/action/delete.rs

@@ -1,3 +1,4 @@
+use clap::ArgMatches;
 use ffsend_api::action::delete::{
     Error as DeleteError,
     Delete as ApiDelete,
@@ -8,34 +9,40 @@ use ffsend_api::file::remote_file::{
 };
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_delete::CmdDelete;
+use cmd::matcher::{
+    Matcher,
+    delete::DeleteMatcher,
+};
 use error::ActionError;
 use util::print_success;
 
 /// A file delete action.
 pub struct Delete<'a> {
-    cmd: &'a CmdDelete<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Delete<'a> {
     /// Construct a new delete action.
-    pub fn new(cmd: &'a CmdDelete<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the delete action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), ActionError> {
+        // Create the command matchers
+        let matcher_delete = DeleteMatcher::with(self.cmd_matches).unwrap();
+
         // Get the share URL
-        let url = self.cmd.url();
+        let url = matcher_delete.url();
 
         // Create a reqwest client
         let client = Client::new();
 
         // Parse the remote file based on the share URL, get the password
-        let file = RemoteFile::parse_url(url, self.cmd.owner())?;
+        let file = RemoteFile::parse_url(url, matcher_delete.owner())?;
 
         // TODO: show an informative error if the owner token isn't set
 

+ 14 - 7
cli/src/action/download.rs

@@ -1,31 +1,38 @@
 use std::sync::{Arc, Mutex};
 
+use clap::ArgMatches;
 use ffsend_api::action::download::Download as ApiDownload;
 use ffsend_api::file::remote_file::RemoteFile;
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_download::CmdDownload;
+use cmd::matcher::{
+    Matcher,
+    download::DownloadMatcher,
+};
 use error::ActionError;
 use progress::ProgressBar;
 
 /// A file download action.
 pub struct Download<'a> {
-    cmd: &'a CmdDownload<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Download<'a> {
     /// Construct a new download action.
-    pub fn new(cmd: &'a CmdDownload<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the download action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), ActionError> {
+        // Create the command matchers
+        let matcher_download = DownloadMatcher::with(self.cmd_matches).unwrap();
+
         // Get the share URL
-        let url = self.cmd.url();
+        let url = matcher_download.url();
 
         // Create a reqwest client
         let client = Client::new();
@@ -35,7 +42,7 @@ impl<'a> Download<'a> {
         let file = RemoteFile::parse_url(url, None)?;
 
         // Get the target file or directory
-        let target = self.cmd.output();
+        let target = matcher_download.output();
 
         // Create a progress bar reporter
         let bar = Arc::new(Mutex::new(ProgressBar::new_download()));
@@ -44,7 +51,7 @@ impl<'a> Download<'a> {
         ApiDownload::new(
             &file,
             target,
-            self.cmd.password(),
+            matcher_download.password(),
         ).invoke(&client, bar)?;
 
         // TODO: open the file, or it's location

+ 14 - 7
cli/src/action/info.rs

@@ -1,3 +1,4 @@
+use clap::ArgMatches;
 use failure::Fail;
 use ffsend_api::action::exists::{
     Error as ExistsError,
@@ -14,34 +15,40 @@ use ffsend_api::file::remote_file::{
 };
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_info::CmdInfo;
+use cmd::matcher::{
+    Matcher,
+    info::InfoMatcher,
+};
 use util::print_error;
 
 /// A file info action.
 pub struct Info<'a> {
-    cmd: &'a CmdInfo<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Info<'a> {
     /// Construct a new info action.
-    pub fn new(cmd: &'a CmdInfo<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the info action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), Error> {
+        // Create the command matchers
+        let matcher_info = InfoMatcher::with(self.cmd_matches).unwrap();
+
         // Get the share URL
-        let url = self.cmd.url();
+        let url = matcher_info.url();
 
         // Create a reqwest client
         let client = Client::new();
 
         // Parse the remote file based on the share URL, get the password
-        let file = RemoteFile::parse_url(url, self.cmd.owner())?;
-        let password = self.cmd.password();
+        let file = RemoteFile::parse_url(url, matcher_info.owner())?;
+        let password = matcher_info.password();
 
         // TODO: show an informative error if the owner token isn't set
 

+ 14 - 7
cli/src/action/params.rs

@@ -1,3 +1,4 @@
+use clap::ArgMatches;
 use ffsend_api::action::params::{
     Params as ApiParams,
     ParamsDataBuilder,
@@ -5,41 +6,47 @@ use ffsend_api::action::params::{
 use ffsend_api::file::remote_file::RemoteFile;
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_params::CmdParams;
+use cmd::matcher::{
+    Matcher,
+    params::ParamsMatcher,
+};
 use error::ActionError;
 use util::print_success;
 
 /// A file parameters action.
 pub struct Params<'a> {
-    cmd: &'a CmdParams<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Params<'a> {
     /// Construct a new parameters action.
-    pub fn new(cmd: &'a CmdParams<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the parameters action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), ActionError> {
+        // Create the command matchers
+        let matcher_params = ParamsMatcher::with(self.cmd_matches).unwrap();
+
         // Get the share URL
-        let url = self.cmd.url();
+        let url = matcher_params.url();
 
         // Create a reqwest client
         let client = Client::new();
 
         // Parse the remote file based on the share URL
         // TODO: handle error here
-        let file = RemoteFile::parse_url(url, self.cmd.owner())?;
+        let file = RemoteFile::parse_url(url, matcher_params.owner())?;
 
         // TODO: show an informative error if the owner token isn't set
 
         // Build the parameters data object
         let data = ParamsDataBuilder::default()
-            .download_limit(self.cmd.download_limit())
+            .download_limit(matcher_params.download_limit())
             .build()
             .unwrap();
 

+ 14 - 7
cli/src/action/password.rs

@@ -1,41 +1,48 @@
+use clap::ArgMatches;
 use ffsend_api::action::password::Password as ApiPassword;
 use ffsend_api::file::remote_file::RemoteFile;
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_password::CmdPassword;
+use cmd::matcher::{
+    Matcher,
+    password::PasswordMatcher,
+};
 use error::ActionError;
 use util::print_success;
 
 /// A file password action.
 pub struct Password<'a> {
-    cmd: &'a CmdPassword<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Password<'a> {
     /// Construct a new password action.
-    pub fn new(cmd: &'a CmdPassword<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the password action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), ActionError> {
+        // Create the command matchers
+        let matcher_password = PasswordMatcher::with(self.cmd_matches).unwrap();
+
         // Get the share URL
-        let url = self.cmd.url();
+        let url = matcher_password.url();
 
         // Create a reqwest client
         let client = Client::new();
 
         // Parse the remote file based on the share URL
         // TODO: handle error here
-        let file = RemoteFile::parse_url(url, self.cmd.owner())?;
+        let file = RemoteFile::parse_url(url, matcher_password.owner())?;
 
         // TODO: show an informative error if the owner token isn't set
 
         // Execute an password action
-        ApiPassword::new(&file, &self.cmd.password(), None).invoke(&client)?;
+        ApiPassword::new(&file, &matcher_password.password(), None).invoke(&client)?;
 
         // Print a success message
         print_success("Password set");

+ 18 - 11
cli/src/action/upload.rs

@@ -1,12 +1,16 @@
 use std::path::Path;
 use std::sync::{Arc, Mutex};
 
+use clap::ArgMatches;
 use failure::{err_msg, Fail};
 use ffsend_api::action::params::ParamsDataBuilder;
 use ffsend_api::action::upload::Upload as ApiUpload;
 use ffsend_api::reqwest::Client;
 
-use cmd::cmd_upload::CmdUpload;
+use cmd::matcher::{
+    Matcher,
+    upload::UploadMatcher,
+};
 use error::ActionError;
 use progress::ProgressBar;
 use util::open_url;
@@ -15,23 +19,26 @@ use util::{print_error, set_clipboard};
 
 /// A file upload action.
 pub struct Upload<'a> {
-    cmd: &'a CmdUpload<'a>,
+    cmd_matches: &'a ArgMatches<'a>,
 }
 
 impl<'a> Upload<'a> {
     /// Construct a new upload action.
-    pub fn new(cmd: &'a CmdUpload<'a>) -> Self {
+    pub fn new(cmd_matches: &'a ArgMatches<'a>) -> Self {
         Self {
-            cmd,
+            cmd_matches,
         }
     }
 
     /// Invoke the upload action.
     // TODO: create a trait for this method
     pub fn invoke(&self) -> Result<(), ActionError> {
+        // Create the command matchers
+        let matcher_upload = UploadMatcher::with(self.cmd_matches).unwrap();
+
         // Get API parameters
-        let path = Path::new(self.cmd.file()).to_path_buf();
-        let host = self.cmd.host();
+        let path = Path::new(matcher_upload.file()).to_path_buf();
+        let host = matcher_upload.host();
 
         // Create a reqwest client
         let client = Client::new();
@@ -43,7 +50,7 @@ impl<'a> Upload<'a> {
         let params = {
             // Build the parameters data object
             let mut params = ParamsDataBuilder::default()
-                .download_limit(self.cmd.download_limit())
+                .download_limit(matcher_upload.download_limit())
                 .build()
                 .unwrap();
 
@@ -59,8 +66,8 @@ impl<'a> Upload<'a> {
         let file = ApiUpload::new(
             host,
             path,
-            self.cmd.name().map(|name| name.to_owned()),
-            self.cmd.password(),
+            matcher_upload.name().map(|name| name.to_owned()),
+            matcher_upload.password(),
             params,
         ).invoke(&client, bar)?;
 
@@ -70,7 +77,7 @@ impl<'a> Upload<'a> {
         println!("Owner token: {}", file.owner_token().unwrap());
 
         // Open the URL in the browser
-        if self.cmd.open() {
+        if matcher_upload.open() {
             if let Err(err) = open_url(url.clone()) {
                 print_error(
                     err.context("Failed to open the URL in the browser")
@@ -81,7 +88,7 @@ impl<'a> Upload<'a> {
         // Copy the URL in the user's clipboard
         #[cfg(feature = "clipboard")]
         {
-            if self.cmd.copy() {
+            if matcher_upload.copy() {
                 if set_clipboard(url.as_str().to_owned()).is_err() {
                     print_error(
                         err_msg("Failed to copy the URL to the clipboard")

+ 55 - 0
cli/src/cmd/arg/download_limit.rs

@@ -0,0 +1,55 @@
+use clap::{Arg, ArgMatches};
+use ffsend_api::action::params::{
+    PARAMS_DOWNLOAD_MIN as DOWNLOAD_MIN,
+    PARAMS_DOWNLOAD_MAX as DOWNLOAD_MAX,
+};
+
+use super::{CmdArg, CmdArgFlag, CmdArgOption};
+
+/// The download limit argument.
+pub struct ArgDownloadLimit { }
+
+impl CmdArg for ArgDownloadLimit {
+    fn name() -> &'static str {
+        "download-limit"
+    }
+
+    fn build<'b, 'c>() -> Arg<'b, 'c> {
+        Arg::with_name("download-limit")
+            .long("download-limit")
+            .short("d")
+            .alias("downloads")
+            .alias("download")
+            .alias("down")
+            .alias("dlimit")
+            .alias("limit")
+            .alias("lim")
+            .alias("l")
+            .value_name("COUNT")
+            .help("The file download limit")
+    }
+}
+
+impl CmdArgFlag for ArgDownloadLimit { }
+
+impl<'a> CmdArgOption<'a> for ArgDownloadLimit {
+    type Value = Option<u8>;
+
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value {
+        // TODO: do not unwrap, report an error
+        Self::value_raw(matches)
+            .map(|d| d.parse::<u8>().expect("invalid download limit"))
+            .and_then(|d| {
+                // Check the download limit bounds
+                if d < DOWNLOAD_MIN || d > DOWNLOAD_MAX {
+                    panic!(
+                        "invalid download limit, must be between {} and {}",
+                        DOWNLOAD_MIN,
+                        DOWNLOAD_MAX,
+                    );
+                }
+
+                Some(d)
+            })
+    }
+}

+ 53 - 0
cli/src/cmd/arg/host.rs

@@ -0,0 +1,53 @@
+use clap::{Arg, ArgMatches};
+use ffsend_api::url::{ParseError, Url};
+
+use app::SEND_DEF_HOST;
+use super::{CmdArg, CmdArgOption};
+use util::quit_error_msg;
+
+/// The host argument.
+pub struct ArgHost { }
+
+impl CmdArg for ArgHost {
+    fn name() -> &'static str {
+        "host"
+    }
+
+    fn build<'b, 'c>() -> Arg<'b, 'c> {
+        Arg::with_name("host")
+            .long("host")
+            .short("h")
+            .alias("server")
+            .value_name("URL")
+            .default_value(SEND_DEF_HOST)
+            .help("The remote host to upload to")
+    }
+}
+
+impl<'a> CmdArgOption<'a> for ArgHost {
+    type Value = Url;
+
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value {
+        // Get the URL
+        let url = Self::value_raw(matches).expect("missing host");
+
+        // Parse the URL
+        // TODO: improve these error messages
+        match Url::parse(url) {
+            Ok(url) => url,
+            Err(ParseError::EmptyHost) =>
+                quit_error_msg("Emtpy host given"),
+            Err(ParseError::InvalidPort) =>
+                quit_error_msg("Invalid host port"),
+            Err(ParseError::InvalidIpv4Address) =>
+                quit_error_msg("Invalid IPv4 address in host"),
+            Err(ParseError::InvalidIpv6Address) =>
+                quit_error_msg("Invalid IPv6 address in host"),
+            Err(ParseError::InvalidDomainCharacter) =>
+                quit_error_msg("Host domains contains an invalid character"),
+            Err(ParseError::RelativeUrlWithoutBase) =>
+                quit_error_msg("Host domain doesn't contain a host"),
+            _ => quit_error_msg("The given host is invalid"),
+        }
+    }
+}

+ 49 - 0
cli/src/cmd/arg/mod.rs

@@ -0,0 +1,49 @@
+pub mod download_limit;
+pub mod host;
+pub mod owner;
+pub mod password;
+pub mod url;
+
+// Reexport to arg module
+pub use self::download_limit::ArgDownloadLimit;
+pub use self::host::ArgHost;
+pub use self::owner::ArgOwner;
+pub use self::password::ArgPassword;
+pub use self::url::ArgUrl;
+
+use clap::{Arg, ArgMatches};
+
+/// A generic trait, for a reusable command argument struct.
+/// The `CmdArgFlag` and `CmdArgOption` traits further specify what kind of
+/// argument this is.
+pub trait CmdArg {
+    /// Get the argument name that is used as main identifier.
+    fn name() -> &'static str;
+
+    /// Build the argument.
+    fn build<'a, 'b>() -> Arg<'a, 'b>;
+}
+
+/// This `CmdArg` specification defines that this argument may be tested as
+/// flag. This will allow to test whether the flag is present in the given
+/// matches.
+pub trait CmdArgFlag: CmdArg {
+    /// Check whether the argument is present in the given matches.
+    fn is_present<'a>(matches: &ArgMatches<'a>) -> bool {
+        matches.is_present(Self::name())
+    }
+}
+
+/// This `CmdArg` specification defines that this argument may be tested as
+/// option. This will allow to fetch the value of the argument.
+pub trait CmdArgOption<'a>: CmdArg {
+    /// The type of the argument value.
+    type Value;
+
+    /// Get the argument value.
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value;
+
+    /// Get the raw argument value, as a string reference.
+    fn value_raw<'b: 'a>(matches: &'a ArgMatches<'b>) -> Option<&'a str> {
+        matches.value_of(Self::name()) }
+}

+ 31 - 0
cli/src/cmd/arg/owner.rs

@@ -0,0 +1,31 @@
+use clap::{Arg, ArgMatches};
+
+use super::{CmdArg, CmdArgOption};
+
+/// The owner argument.
+pub struct ArgOwner { }
+
+impl CmdArg for ArgOwner {
+    fn name() -> &'static str {
+        "owner"
+    }
+
+    fn build<'b, 'c>() -> Arg<'b, 'c> {
+        Arg::with_name("owner")
+            .long("owner")
+            .short("o")
+            .alias("own")
+            .alias("owner-token")
+            .alias("token")
+            .value_name("TOKEN")
+            .help("Specify the file owner token")
+    }
+}
+
+impl<'a> CmdArgOption<'a> for ArgOwner {
+    type Value = Option<&'a str>;
+
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value {
+        Self::value_raw(matches)
+    }
+}

+ 51 - 0
cli/src/cmd/arg/password.rs

@@ -0,0 +1,51 @@
+use clap::{Arg, ArgMatches};
+use rpassword::prompt_password_stderr;
+
+use super::{CmdArg, CmdArgFlag, CmdArgOption};
+
+/// The password argument.
+pub struct ArgPassword { }
+
+impl CmdArg for ArgPassword {
+    fn name() -> &'static str {
+        "password"
+    }
+
+    fn build<'b, 'c>() -> Arg<'b, 'c> {
+        Arg::with_name("password")
+            .long("password")
+            .short("p")
+            .alias("pass")
+            .value_name("PASSWORD")
+            .min_values(0)
+            .max_values(1)
+            .help("Unlock a password protected file")
+    }
+}
+
+impl CmdArgFlag for ArgPassword { }
+
+impl<'a> CmdArgOption<'a> for ArgPassword {
+    type Value = Option<String>;
+
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value {
+        // The password flag must be present
+        if !Self::is_present(matches) {
+            return None;
+        }
+
+        // Get the password from the argument if set
+        match Self::value_raw(matches) {
+            None => {},
+            p => return p.map(|p| p.into()),
+        }
+
+        // Prompt for the password
+        // TODO: don't unwrap/expect
+        // TODO: create utility function for this
+        Some(
+            prompt_password_stderr("Password: ")
+                .expect("failed to read password from stdin")
+        )
+    }
+}

+ 49 - 0
cli/src/cmd/arg/url.rs

@@ -0,0 +1,49 @@
+use clap::{Arg, ArgMatches};
+use ffsend_api::url::{ParseError, Url};
+
+use super::{CmdArg, CmdArgOption};
+use util::quit_error_msg;
+
+/// The URL argument.
+pub struct ArgUrl { }
+
+impl CmdArg for ArgUrl {
+    fn name() -> &'static str {
+        "URL"
+    }
+
+    fn build<'b, 'c>() -> Arg<'b, 'c> {
+        Arg::with_name("URL")
+            .required(true)
+            .multiple(false)
+            .help("The share URL")
+    }
+}
+
+impl<'a> CmdArgOption<'a> for ArgUrl {
+    type Value = Url;
+
+    fn value<'b: 'a>(matches: &'a ArgMatches<'b>) -> Self::Value {
+        // Get the URL
+        let url = Self::value_raw(matches).expect("missing URL");
+
+        // Parse the URL
+        // TODO: improve these error messages
+        match Url::parse(url) {
+            Ok(url) => url,
+            Err(ParseError::EmptyHost) =>
+                quit_error_msg("Emtpy host given"),
+            Err(ParseError::InvalidPort) =>
+                quit_error_msg("Invalid host port"),
+            Err(ParseError::InvalidIpv4Address) =>
+                quit_error_msg("Invalid IPv4 address in host"),
+            Err(ParseError::InvalidIpv6Address) =>
+                quit_error_msg("Invalid IPv6 address in host"),
+            Err(ParseError::InvalidDomainCharacter) =>
+                quit_error_msg("Host domains contains an invalid character"),
+            Err(ParseError::RelativeUrlWithoutBase) =>
+                quit_error_msg("Host domain doesn't contain a host"),
+            _ => quit_error_msg("The given host is invalid"),
+        }
+    }
+}

+ 1 - 1
cli/src/cmd/cmd_password.rs → cli/src/cmd/cmd/.rs

@@ -1,6 +1,6 @@
 use ffsend_api::url::{ParseError, Url};
 
-use super::clap::{App, Arg, ArgMatches, SubCommand};
+use clap::{App, Arg, ArgMatches, SubCommand};
 use rpassword::prompt_password_stderr;
 
 use util::quit_error_msg;

+ 19 - 0
cli/src/cmd/cmd/delete.rs

@@ -0,0 +1,19 @@
+use clap::{App, SubCommand};
+
+use cmd::arg::{ArgOwner, ArgUrl, CmdArg};
+
+/// The delete command definition.
+pub struct CmdDelete;
+
+impl CmdDelete {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        SubCommand::with_name("delete")
+            .about("Delete a shared file.")
+            .visible_alias("del")
+            .alias("r")
+            .alias("rem")
+            .alias("remove")
+            .arg(ArgUrl::build())
+            .arg(ArgOwner::build())
+    }
+}

+ 25 - 0
cli/src/cmd/cmd/download.rs

@@ -0,0 +1,25 @@
+use clap::{App, Arg, SubCommand};
+
+use cmd::arg::{ArgPassword, ArgUrl, CmdArg};
+
+/// The download command definition.
+pub struct CmdDownload;
+
+impl CmdDownload {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        SubCommand::with_name("download")
+            .about("Download files.")
+            .visible_alias("d")
+            .visible_alias("down")
+            .arg(ArgUrl::build())
+            .arg(ArgPassword::build())
+            .arg(Arg::with_name("output")
+                 .long("output")
+                 .short("o")
+                 .alias("output-file")
+                 .alias("out")
+                 .alias("file")
+                 .value_name("PATH")
+                 .help("The output file or directory"))
+    }
+}

+ 18 - 0
cli/src/cmd/cmd/info.rs

@@ -0,0 +1,18 @@
+use clap::{App, SubCommand};
+
+use cmd::arg::{ArgOwner, ArgPassword, ArgUrl, CmdArg};
+
+/// The info command definition.
+pub struct CmdInfo;
+
+impl CmdInfo {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        SubCommand::with_name("info")
+            .about("Fetch info about a shared file.")
+            .visible_alias("i")
+            .alias("information")
+            .arg(ArgUrl::build())
+            .arg(ArgOwner::build())
+            .arg(ArgPassword::build())
+    }
+}

+ 14 - 0
cli/src/cmd/cmd/mod.rs

@@ -0,0 +1,14 @@
+pub mod delete;
+pub mod download;
+pub mod info;
+pub mod params;
+pub mod password;
+pub mod upload;
+
+// Reexport to cmd module
+pub use self::delete::CmdDelete;
+pub use self::download::CmdDownload;
+pub use self::info::CmdInfo;
+pub use self::params::CmdParams;
+pub use self::password::CmdPassword;
+pub use self::upload::CmdUpload;

+ 25 - 0
cli/src/cmd/cmd/params.rs

@@ -0,0 +1,25 @@
+use clap::{App, SubCommand};
+
+use cmd::arg::{ArgDownloadLimit, ArgOwner, ArgUrl, CmdArg};
+
+/// The params command definition.
+pub struct CmdParams;
+
+impl CmdParams {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        // Create a list of parameter arguments, of which one is required
+        let param_args = [
+            ArgDownloadLimit::name(),
+        ];
+
+        SubCommand::with_name("parameters")
+            .about("Change parameters of a shared file.")
+            .visible_alias("params")
+            .alias("par")
+            .alias("param")
+            .alias("parameter")
+            .arg(ArgUrl::build())
+            .arg(ArgOwner::build())
+            .arg(ArgDownloadLimit::build().required_unless_one(&param_args))
+    }
+}

+ 19 - 0
cli/src/cmd/cmd/password.rs

@@ -0,0 +1,19 @@
+use clap::{App, SubCommand};
+
+use cmd::arg::{ArgOwner, ArgPassword, ArgUrl, CmdArg};
+
+/// The password command definition.
+pub struct CmdPassword;
+
+impl CmdPassword {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        SubCommand::with_name("password")
+            .about("Change the password of a shared file.")
+            .visible_alias("pass")
+            .visible_alias("p")
+            .arg(ArgUrl::build())
+            .arg(ArgPassword::build()
+                 .help("Specify a password, do not prompt"))
+            .arg(ArgOwner::build())
+    }
+}

+ 47 - 0
cli/src/cmd/cmd/upload.rs

@@ -0,0 +1,47 @@
+use clap::{App, Arg, SubCommand};
+use ffsend_api::action::params::{
+    PARAMS_DEFAULT_DOWNLOAD_STR as DOWNLOAD_DEFAULT,
+};
+
+use cmd::arg::{ArgDownloadLimit, ArgHost, ArgPassword, CmdArg};
+
+/// The uplaod command definition.
+pub struct CmdUpload;
+
+impl CmdUpload {
+    pub fn build<'a, 'b>() -> App<'a, 'b> {
+        // Build the subcommand
+        #[allow(unused_mut)]
+        let mut cmd = SubCommand::with_name("upload")
+            .about("Upload files.")
+            .visible_alias("u")
+            .visible_alias("up")
+            .arg(Arg::with_name("FILE")
+                .help("The file to upload")
+                .required(true)
+                .multiple(false))
+            .arg(ArgPassword::build())
+            .arg(ArgDownloadLimit::build().default_value(DOWNLOAD_DEFAULT))
+            .arg(ArgHost::build())
+            .arg(Arg::with_name("name")
+                .long("name")
+                .short("n")
+                .alias("file")
+                .alias("f")
+                .value_name("NAME")
+                .help("Rename the file being uploaded"))
+            .arg(Arg::with_name("open")
+                .long("open")
+                .short("o")
+                .help("Open the share link in your browser"));
+
+        // Optional clipboard support
+        #[cfg(feature = "clipboard")] {
+            cmd = cmd.arg(Arg::with_name("copy")
+                .long("copy")
+                .short("c")
+                .help("Copy the share link to your clipboard"));
+        } 
+        cmd
+    }
+}

+ 0 - 81
cli/src/cmd/cmd_delete.rs

@@ -1,81 +0,0 @@
-use ffsend_api::url::{ParseError, Url};
-
-use super::clap::{App, Arg, ArgMatches, SubCommand};
-
-use util::quit_error_msg;
-
-/// The delete command.
-pub struct CmdDelete<'a> {
-    matches: &'a ArgMatches<'a>,
-}
-
-impl<'a: 'b, 'b> CmdDelete<'a> {
-    /// Build the sub command definition.
-    pub fn build<'y, 'z>() -> App<'y, 'z> {
-        // Build the subcommand
-        let cmd = SubCommand::with_name("delete")
-            .about("Delete a shared file.")
-            .visible_alias("d")
-            .visible_alias("del")
-            .alias("r")
-            .alias("rem")
-            .alias("remove")
-            .arg(Arg::with_name("URL")
-                .help("The share URL")
-                .required(true)
-                .multiple(false))
-            .arg(Arg::with_name("owner")
-                .long("owner")
-                .short("o")
-                .alias("own")
-                .alias("owner-token")
-                .alias("token")
-                .value_name("TOKEN")
-                .help("File owner token"));
-
-        cmd
-    }
-
-    /// Parse CLI arguments, from the given parent command matches.
-    pub fn parse(parent: &'a ArgMatches<'a>) -> Option<CmdDelete<'a>> {
-        parent.subcommand_matches("delete")
-            .map(|matches| CmdDelete { matches })
-    }
-
-    /// Get the owner token.
-    pub fn owner(&'a self) -> Option<String> {
-        // TODO: validate the owner token if set
-        self.matches.value_of("owner")
-            .map(|token| token.to_owned())
-    }
-
-    /// Get the file share URL.
-    ///
-    /// This method parses the URL into an `Url`.
-    /// If the given URL is invalid,
-    /// the program will quit with an error message.
-    pub fn url(&'a self) -> Url {
-        // Get the host
-        let url = self.matches.value_of("URL")
-            .expect("missing URL");
-
-        // Parse the URL
-        // TODO: improve these error messages
-        match Url::parse(url) {
-            Ok(url) => url,
-            Err(ParseError::EmptyHost) =>
-                quit_error_msg("Emtpy host given"),
-            Err(ParseError::InvalidPort) =>
-                quit_error_msg("Invalid host port"),
-            Err(ParseError::InvalidIpv4Address) =>
-                quit_error_msg("Invalid IPv4 address in host"),
-            Err(ParseError::InvalidIpv6Address) =>
-                quit_error_msg("Invalid IPv6 address in host"),
-            Err(ParseError::InvalidDomainCharacter) =>
-                quit_error_msg("Host domains contains an invalid character"),
-            Err(ParseError::RelativeUrlWithoutBase) =>
-                quit_error_msg("Host domain doesn't contain a host"),
-            _ => quit_error_msg("The given host is invalid"),
-        }
-    }
-}

+ 0 - 113
cli/src/cmd/cmd_download.rs

@@ -1,113 +0,0 @@
-use std::path::PathBuf;
-
-use ffsend_api::url::{ParseError, Url};
-
-use rpassword::prompt_password_stderr;
-use super::clap::{App, Arg, ArgMatches, SubCommand};
-
-use util::quit_error_msg;
-
-/// The download command.
-pub struct CmdDownload<'a> {
-    matches: &'a ArgMatches<'a>,
-}
-
-impl<'a: 'b, 'b> CmdDownload<'a> {
-    /// Build the sub command definition.
-    pub fn build<'y, 'z>() -> App<'y, 'z> {
-        // Build the subcommand
-        let cmd = SubCommand::with_name("download")
-            .about("Download files.")
-            .visible_alias("d")
-            .visible_alias("down")
-            .arg(Arg::with_name("URL")
-                .help("The share URL")
-                .required(true)
-                .multiple(false))
-            .arg(Arg::with_name("output")
-                .long("output")
-                .short("o")
-                .alias("output-file")
-                .alias("out")
-                .alias("file")
-                .value_name("PATH")
-                .help("The output file or directory"))
-            .arg(Arg::with_name("password")
-                .long("password")
-                .short("p")
-                .alias("pass")
-                .value_name("PASSWORD")
-                .min_values(0)
-                .max_values(1)
-                .help("Unlock a password protected file"));
-
-        cmd
-    }
-
-    /// Parse CLI arguments, from the given parent command matches.
-    pub fn parse(parent: &'a ArgMatches<'a>) -> Option<CmdDownload<'a>> {
-        parent.subcommand_matches("download")
-            .map(|matches| CmdDownload { matches })
-    }
-
-    /// Get the file share URL, to download the file from.
-    ///
-    /// This method parses the URL into an `Url`.
-    /// If the given URL is invalid,
-    /// the program will quit with an error message.
-    pub fn url(&'a self) -> Url {
-        // Get the host
-        let url = self.matches.value_of("URL")
-            .expect("missing URL");
-
-        // Parse the URL
-        // TODO: improve these error messages
-        match Url::parse(url) {
-            Ok(url) => url,
-            Err(ParseError::EmptyHost) =>
-                quit_error_msg("Emtpy host given"),
-            Err(ParseError::InvalidPort) =>
-                quit_error_msg("Invalid host port"),
-            Err(ParseError::InvalidIpv4Address) =>
-                quit_error_msg("Invalid IPv4 address in host"),
-            Err(ParseError::InvalidIpv6Address) =>
-                quit_error_msg("Invalid IPv6 address in host"),
-            Err(ParseError::InvalidDomainCharacter) =>
-                quit_error_msg("Host domains contains an invalid character"),
-            Err(ParseError::RelativeUrlWithoutBase) =>
-                quit_error_msg("Host domain doesn't contain a host"),
-            _ => quit_error_msg("The given host is invalid"),
-        }
-    }
-
-    /// The target file or directory to download the file to.
-    /// If a directory is given, the file name of the original uploaded file
-    /// will be used.
-    pub fn output(&'a self) -> PathBuf {
-        self.matches.value_of("output")
-            .map(|path| PathBuf::from(path))
-            .unwrap_or(PathBuf::from("./"))
-    }
-
-    /// Get the password.
-    /// `None` is returned if no password was specified.
-    pub fn password(&'a self) -> Option<String> {
-        // Return none if the property was not set
-        if !self.matches.is_present("password") {
-            return None;
-        }
-
-        // Get the password from the arguments
-        if let Some(password) = self.matches.value_of("password") {
-            return Some(password.into());
-        }
-
-        // Prompt for the password
-        // TODO: don't unwrap/expect
-        // TODO: create utility function for this
-        Some(
-            prompt_password_stderr("Password: ")
-                .expect("failed to read password from stdin")
-        )
-    }
-}

+ 0 - 109
cli/src/cmd/cmd_info.rs

@@ -1,109 +0,0 @@
-use ffsend_api::url::{ParseError, Url};
-
-use rpassword::prompt_password_stderr;
-use super::clap::{App, Arg, ArgMatches, SubCommand};
-
-use util::quit_error_msg;
-
-/// The info command.
-pub struct CmdInfo<'a> {
-    matches: &'a ArgMatches<'a>,
-}
-
-impl<'a: 'b, 'b> CmdInfo<'a> {
-    /// Build the sub command definition.
-    pub fn build<'y, 'z>() -> App<'y, 'z> {
-        // Build the subcommand
-        let cmd = SubCommand::with_name("info")
-            .about("Fetch info about a shared file.")
-            .visible_alias("i")
-            .alias("information")
-            .arg(Arg::with_name("URL")
-                .help("The share URL")
-                .required(true)
-                .multiple(false))
-            .arg(Arg::with_name("owner")
-                .long("owner")
-                .short("o")
-                .alias("own")
-                .alias("owner-token")
-                .alias("token")
-                .value_name("TOKEN")
-                .help("File owner token"))
-            .arg(Arg::with_name("password")
-                .long("password")
-                .short("p")
-                .alias("pass")
-                .value_name("PASSWORD")
-                .min_values(0)
-                .max_values(1)
-                .help("Unlock a password protected file"));
-
-        cmd
-    }
-
-    /// Parse CLI arguments, from the given parent command matches.
-    pub fn parse(parent: &'a ArgMatches<'a>) -> Option<CmdInfo<'a>> {
-        parent.subcommand_matches("info")
-            .map(|matches| CmdInfo { matches })
-    }
-
-    /// Get the owner token.
-    pub fn owner(&'a self) -> Option<String> {
-        // TODO: validate the owner token if set
-        self.matches.value_of("owner")
-            .map(|token| token.to_owned())
-    }
-
-    /// Get the file share URL.
-    ///
-    /// This method parses the URL into an `Url`.
-    /// If the given URL is invalid,
-    /// the program will quit with an error message.
-    pub fn url(&'a self) -> Url {
-        // Get the host
-        let url = self.matches.value_of("URL")
-            .expect("missing URL");
-
-        // Parse the URL
-        // TODO: improve these error messages
-        match Url::parse(url) {
-            Ok(url) => url,
-            Err(ParseError::EmptyHost) =>
-                quit_error_msg("Emtpy host given"),
-            Err(ParseError::InvalidPort) =>
-                quit_error_msg("Invalid host port"),
-            Err(ParseError::InvalidIpv4Address) =>
-                quit_error_msg("Invalid IPv4 address in host"),
-            Err(ParseError::InvalidIpv6Address) =>
-                quit_error_msg("Invalid IPv6 address in host"),
-            Err(ParseError::InvalidDomainCharacter) =>
-                quit_error_msg("Host domains contains an invalid character"),
-            Err(ParseError::RelativeUrlWithoutBase) =>
-                quit_error_msg("Host domain doesn't contain a host"),
-            _ => quit_error_msg("The given host is invalid"),
-        }
-    }
-
-    /// Get the password.
-    /// `None` is returned if no password was specified.
-    pub fn password(&'a self) -> Option<String> {
-        // Return none if the property was not set
-        if !self.matches.is_present("password") {
-            return None;
-        }
-
-        // Get the password from the arguments
-        if let Some(password) = self.matches.value_of("password") {
-            return Some(password.into());
-        }
-
-        // Prompt for the password
-        // TODO: don't unwrap/expect
-        // TODO: create utility function for this
-        Some(
-            prompt_password_stderr("Password: ")
-                .expect("failed to read password from stdin")
-        )
-    }
-}

+ 0 - 120
cli/src/cmd/cmd_params.rs

@@ -1,120 +0,0 @@
-use ffsend_api::action::params::{
-    PARAMS_DOWNLOAD_MIN as DOWNLOAD_MIN,
-    PARAMS_DOWNLOAD_MAX as DOWNLOAD_MAX,
-};
-use ffsend_api::url::{ParseError, Url};
-
-use super::clap::{App, Arg, ArgMatches, SubCommand};
-
-use util::quit_error_msg;
-
-/// The parameters command.
-pub struct CmdParams<'a> {
-    matches: &'a ArgMatches<'a>,
-}
-
-impl<'a: 'b, 'b> CmdParams<'a> {
-    /// Build the sub command definition.
-    pub fn build<'y, 'z>() -> App<'y, 'z> {
-        // Build a list of data parameter arguments of which one is required
-        let param_args = ["download-limit"];
-
-        // Build the subcommand
-        let cmd = SubCommand::with_name("parameters")
-            .about("Change parameters of a shared file.")
-            .visible_alias("params")
-            .alias("par")
-            .alias("param")
-            .alias("parameter")
-            .arg(Arg::with_name("URL")
-                .help("The share URL")
-                .required(true)
-                .multiple(false))
-            .arg(Arg::with_name("owner")
-                .long("owner")
-                .short("o")
-                .alias("own")
-                .alias("owner-token")
-                .alias("token")
-                .value_name("TOKEN")
-                .help("File owner token"))
-            .arg(Arg::with_name("download-limit")
-                .long("download-limit")
-                .short("d")
-                .alias("downloads")
-                .alias("download")
-                .alias("down")
-                .alias("dlimit")
-                .alias("limit")
-                .alias("lim")
-                .alias("l")
-                .required_unless_one(&param_args)
-                .value_name("COUNT")
-                .help("Set the download limit parameter"));
-
-        cmd
-    }
-
-    /// Parse CLI arguments, from the given parent command matches.
-    pub fn parse(parent: &'a ArgMatches<'a>) -> Option<CmdParams<'a>> {
-        parent.subcommand_matches("parameters")
-            .map(|matches| CmdParams { matches })
-    }
-
-    /// Get the file share URL.
-    ///
-    /// This method parses the URL into an `Url`.
-    /// If the given URL is invalid,
-    /// the program will quit with an error message.
-    pub fn url(&'a self) -> Url {
-        // Get the host
-        let url = self.matches.value_of("URL")
-            .expect("missing URL");
-
-        // Parse the URL
-        // TODO: improve these error messages
-        match Url::parse(url) {
-            Ok(url) => url,
-            Err(ParseError::EmptyHost) =>
-                quit_error_msg("Emtpy host given"),
-            Err(ParseError::InvalidPort) =>
-                quit_error_msg("Invalid host port"),
-            Err(ParseError::InvalidIpv4Address) =>
-                quit_error_msg("Invalid IPv4 address in host"),
-            Err(ParseError::InvalidIpv6Address) =>
-                quit_error_msg("Invalid IPv6 address in host"),
-            Err(ParseError::InvalidDomainCharacter) =>
-                quit_error_msg("Host domains contains an invalid character"),
-            Err(ParseError::RelativeUrlWithoutBase) =>
-                quit_error_msg("Host domain doesn't contain a host"),
-            _ => quit_error_msg("The given host is invalid"),
-        }
-    }
-
-    /// Get the owner token.
-    pub fn owner(&'a self) -> Option<String> {
-        // TODO: validate the owner token if set
-        self.matches.value_of("owner")
-            .map(|token| token.to_owned())
-    }
-
-    /// Get the download limit.
-    pub fn download_limit(&'a self) -> Option<u8> {
-        // TODO: do not unwrap, report an error
-        self.matches.value_of("download-limit")
-            .map(|d| d.parse::<u8>().expect("invalid download limit"))
-            .and_then(|d| {
-                // Check the download limit bounds
-                if d < DOWNLOAD_MIN || d > DOWNLOAD_MAX {
-                    panic!(
-                        "invalid download limit, must be between {} and {}",
-                        DOWNLOAD_MIN,
-                        DOWNLOAD_MAX,
-                    );
-                }
-
-                // Return the value
-                Some(d)
-            })
-    }
-}

+ 0 - 196
cli/src/cmd/cmd_upload.rs

@@ -1,196 +0,0 @@
-use ffsend_api::action::params::{
-    PARAMS_DEFAULT_DOWNLOAD as DOWNLOAD_DEFAULT,
-    PARAMS_DOWNLOAD_MIN as DOWNLOAD_MIN,
-    PARAMS_DOWNLOAD_MAX as DOWNLOAD_MAX,
-};
-use ffsend_api::url::{ParseError, Url};
-
-use rpassword::prompt_password_stderr;
-use super::clap::{App, Arg, ArgMatches, SubCommand};
-
-use app::SEND_DEF_HOST;
-use util::quit_error_msg;
-
-/// The upload command.
-pub struct CmdUpload<'a> {
-    matches: &'a ArgMatches<'a>,
-}
-
-impl<'a: 'b, 'b> CmdUpload<'a> {
-    /// Build the sub command definition.
-    pub fn build<'y, 'z>() -> App<'y, 'z> {
-        // Build the subcommand
-        #[allow(unused_mut)]
-        let mut cmd = SubCommand::with_name("upload")
-            .about("Upload files.")
-            .visible_alias("u")
-            .visible_alias("up")
-            .arg(Arg::with_name("FILE")
-                .help("The file to upload")
-                .required(true)
-                .multiple(false))
-            .arg(Arg::with_name("name")
-                .long("name")
-                .short("n")
-                .alias("file")
-                .alias("f")
-                .value_name("NAME")
-                .help("Rename the file being uploaded"))
-            .arg(Arg::with_name("password")
-                .long("password")
-                .short("p")
-                .alias("pass")
-                .value_name("PASSWORD")
-                .min_values(0)
-                .max_values(1)
-                .help("Protect the file with a password"))
-            .arg(Arg::with_name("downloads-limit")
-                .long("download-limit")
-                .short("d")
-                .alias("downloads")
-                .alias("download")
-                .alias("down")
-                .alias("dlimit")
-                .alias("limit")
-                .alias("lim")
-                .alias("l")
-                .value_name("COUNT")
-                .default_value("1")
-                .help("Set the download limit"))
-            .arg(Arg::with_name("host")
-                .long("host")
-                .short("h")
-                .alias("server")
-                .value_name("URL")
-                .default_value(SEND_DEF_HOST)
-                .help("The Send host to upload to"))
-            .arg(Arg::with_name("open")
-                .long("open")
-                .short("o")
-                .help("Open the share link in your browser"));
-
-        // Optional clipboard support
-        #[cfg(feature = "clipboard")] {
-            cmd = cmd.arg(Arg::with_name("copy")
-                .long("copy")
-                .short("c")
-                .help("Copy the share link to your clipboard"));
-        }
-
-        cmd
-    }
-
-    /// Parse CLI arguments, from the given parent command matches.
-    pub fn parse(parent: &'a ArgMatches<'a>) -> Option<CmdUpload<'a>> {
-        parent.subcommand_matches("upload")
-            .map(|matches| CmdUpload { matches })
-    }
-
-    /// The the name to use for the uploaded file.
-    /// If no custom name is given, none is returned.
-    // TODO: validate custom names, no path separators
-    // TODO: only allow extension renaming with force flag
-    pub fn name(&'a self) -> Option<&'a str> {
-        // Get the chosen file name
-        let name = self.matches.value_of("name")?;
-
-        // The file name must not be empty
-        if name.trim().is_empty() {
-            // TODO: return an error here
-            panic!("the new name must not be empty");
-        }
-
-        Some(name)
-    }
-
-    /// Get the selected file to upload.
-    // TODO: maybe return a file or path instance here
-    pub fn file(&'a self) -> &'a str {
-        self.matches.value_of("FILE")
-            .expect("no file specified to upload")
-    }
-
-    /// Get the host to upload to.
-    ///
-    /// This method parses the host into an `Url`.
-    /// If the given host is invalid,
-    /// the program will quit with an error message.
-    pub fn host(&'a self) -> Url {
-        // Get the host
-        let host = self.matches.value_of("host")
-            .expect("missing host");
-
-        // Parse the URL
-        match Url::parse(host) {
-            Ok(url) => url,
-            Err(ParseError::EmptyHost) =>
-                quit_error_msg("Emtpy host given"),
-            Err(ParseError::InvalidPort) =>
-                quit_error_msg("Invalid host port"),
-            Err(ParseError::InvalidIpv4Address) =>
-                quit_error_msg("Invalid IPv4 address in host"),
-            Err(ParseError::InvalidIpv6Address) =>
-                quit_error_msg("Invalid IPv6 address in host"),
-            Err(ParseError::InvalidDomainCharacter) =>
-                quit_error_msg("Host domains contains an invalid character"),
-            Err(ParseError::RelativeUrlWithoutBase) =>
-                quit_error_msg("Host domain doesn't contain a host"),
-            _ => quit_error_msg("The given host is invalid"),
-        }
-    }
-
-    /// Check whether to open the file URL in the user's browser.
-    pub fn open(&self) -> bool {
-        self.matches.is_present("open")
-    }
-
-    /// Check whether to copy the file URL in the user's clipboard.
-    #[cfg(feature = "clipboard")]
-    pub fn copy(&self) -> bool {
-        self.matches.is_present("copy")
-    }
-
-    /// Get the password.
-    /// `None` is returned if no password was specified.
-    pub fn password(&'a self) -> Option<String> {
-        // Return none if the property was not set
-        if !self.matches.is_present("password") {
-            return None;
-        }
-
-        // Get the password from the arguments
-        if let Some(password) = self.matches.value_of("password") {
-            return Some(password.into());
-        }
-
-        // Prompt for the password
-        // TODO: don't unwrap/expect
-        // TODO: create utility function for this
-        Some(
-            prompt_password_stderr("Password: ")
-                .expect("failed to read password from stdin")
-        )
-    }
-
-    /// Get the download limit if set.
-    pub fn download_limit(&'a self) -> Option<u8> {
-        // Get the download limit, or None if not set or default
-        // TODO: do not unwrap, report an error
-        self.matches.value_of("download-limit")
-            .map(|d| d.parse::<u8>().expect("invalid download limit"))
-            .and_then(|d| if d == DOWNLOAD_DEFAULT { None } else { Some(d) })
-            .and_then(|d| {
-                // Check the download limit bounds
-                if d < DOWNLOAD_MIN || d > DOWNLOAD_MAX {
-                    panic!(
-                        "invalid download limit, must be between {} and {}",
-                        DOWNLOAD_MIN,
-                        DOWNLOAD_MAX,
-                    );
-                }
-
-                // Return the value
-                Some(d)
-            })
-    }
-}

+ 37 - 22
cli/src/cmd/handler.rs

@@ -1,13 +1,24 @@
-use super::clap::{App, AppSettings, Arg, ArgMatches};
+use clap::{App, AppSettings, Arg, ArgMatches};
 
 use app::*;
 
-use super::cmd_delete::CmdDelete;
-use super::cmd_download::CmdDownload;
-use super::cmd_info::CmdInfo;
-use super::cmd_params::CmdParams;
-use super::cmd_password::CmdPassword;
-use super::cmd_upload::CmdUpload;
+use super::matcher::{
+    DeleteMatcher,
+    DownloadMatcher,
+    InfoMatcher,
+    Matcher,
+    ParamsMatcher,
+    PasswordMatcher,
+    UploadMatcher,
+};
+use super::cmd::{
+    CmdDelete,
+    CmdDownload,
+    CmdInfo,
+    CmdParams,
+    CmdPassword,
+    CmdUpload,
+};
 
 /// CLI argument handler.
 pub struct Handler<'a> {
@@ -22,8 +33,7 @@ impl<'a: 'b, 'b> Handler<'a> {
             .version(APP_VERSION)
             .author(APP_AUTHOR)
             .about(APP_ABOUT)
-            .global_setting(AppSettings::GlobalVersion)
-            .global_setting(AppSettings::VersionlessSubcommands)
+            .global_setting(AppSettings::GlobalVersion) .global_setting(AppSettings::VersionlessSubcommands)
             // TODO: enable below command when it doesn't break `p` anymore.
             // .global_setting(AppSettings::InferSubcommands)
             .arg(Arg::with_name("force")
@@ -59,33 +69,38 @@ impl<'a: 'b, 'b> Handler<'a> {
         }
     }
 
+    /// Get the raw matches.
+    pub fn matches(&'a self) -> &'a ArgMatches {
+        &self.matches
+    }
+
     /// Get the delete sub command, if matched.
-    pub fn delete(&'a self) -> Option<CmdDelete<'a>> {
-        CmdDelete::parse(&self.matches)
+    pub fn delete(&'a self) -> Option<DeleteMatcher> {
+        DeleteMatcher::with(&self.matches)
     }
 
     /// Get the download sub command, if matched.
-    pub fn download(&'a self) -> Option<CmdDownload<'a>> {
-        CmdDownload::parse(&self.matches)
+    pub fn download(&'a self) -> Option<DownloadMatcher> {
+        DownloadMatcher::with(&self.matches)
     }
 
-    /// Get the info sub command, if matched.
-    pub fn info(&'a self) -> Option<CmdInfo<'a>> {
-        CmdInfo::parse(&self.matches)
+    /// Get the info matcher, if that subcommand is entered.
+    pub fn info(&'a self) -> Option<InfoMatcher> {
+        InfoMatcher::with(&self.matches)
     }
 
     /// Get the parameters sub command, if matched.
-    pub fn params(&'a self) -> Option<CmdParams<'a>> {
-        CmdParams::parse(&self.matches)
+    pub fn params(&'a self) -> Option<ParamsMatcher> {
+        ParamsMatcher::with(&self.matches)
     }
 
     /// Get the password sub command, if matched.
-    pub fn password(&'a self) -> Option<CmdPassword<'a>> {
-        CmdPassword::parse(&self.matches)
+    pub fn password(&'a self) -> Option<PasswordMatcher> {
+        PasswordMatcher::with(&self.matches)
     }
 
     /// Get the upload sub command, if matched.
-    pub fn upload(&'a self) -> Option<CmdUpload<'a>> {
-        CmdUpload::parse(&self.matches)
+    pub fn upload(&'a self) -> Option<UploadMatcher> {
+        UploadMatcher::with(&self.matches)
     }
 }

+ 39 - 0
cli/src/cmd/matcher/delete.rs

@@ -0,0 +1,39 @@
+use clap::ArgMatches;
+use ffsend_api::url::Url;
+
+use cmd::arg::{ArgOwner, ArgUrl, CmdArgOption};
+use super::Matcher;
+
+/// The delete command matcher.
+pub struct DeleteMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> DeleteMatcher<'a> {
+    /// Get the file share URL.
+    ///
+    /// This method parses the URL into an `Url`.
+    /// If the given URL is invalid,
+    /// the program will quit with an error message.
+    pub fn url(&'a self) -> Url {
+        ArgUrl::value(self.matches)
+    }
+
+    /// Get the owner token.
+    pub fn owner(&'a self) -> Option<String> {
+        // TODO: just return a string reference here?
+        ArgOwner::value(self.matches)
+            .map(|token| token.to_owned())
+    }
+}
+
+impl<'a> Matcher<'a> for DeleteMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("delete")
+            .map(|matches|
+                 DeleteMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 49 - 0
cli/src/cmd/matcher/download.rs

@@ -0,0 +1,49 @@
+use std::path::PathBuf;
+
+use clap::ArgMatches;
+use ffsend_api::url::Url;
+
+use cmd::arg::{ArgPassword, ArgUrl, CmdArgOption};
+use super::Matcher;
+
+/// The download command matcher.
+pub struct DownloadMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> DownloadMatcher<'a> {
+    /// Get the file share URL.
+    ///
+    /// This method parses the URL into an `Url`.
+    /// If the given URL is invalid,
+    /// the program will quit with an error message.
+    pub fn url(&'a self) -> Url {
+        ArgUrl::value(self.matches)
+    }
+
+    /// Get the password.
+    /// `None` is returned if no password was specified.
+    pub fn password(&'a self) -> Option<String> {
+        ArgPassword::value(self.matches)
+    }
+
+    /// The target file or directory to download the file to.
+    /// If a directory is given, the file name of the original uploaded file
+    /// will be used.
+    pub fn output(&'a self) -> PathBuf {
+        self.matches.value_of("output")
+            .map(|path| PathBuf::from(path))
+            .unwrap_or(PathBuf::from("./"))
+    }
+}
+
+impl<'a> Matcher<'a> for DownloadMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("download")
+            .map(|matches|
+                 DownloadMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 46 - 0
cli/src/cmd/matcher/info.rs

@@ -0,0 +1,46 @@
+use ffsend_api::url::Url;
+
+use clap::ArgMatches;
+
+use cmd::arg::{ArgOwner, ArgPassword, ArgUrl, CmdArgOption};
+use super::Matcher;
+
+/// The info command matcher.
+pub struct InfoMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> InfoMatcher<'a> {
+    /// Get the file share URL.
+    ///
+    /// This method parses the URL into an `Url`.
+    /// If the given URL is invalid,
+    /// the program will quit with an error message.
+    pub fn url(&'a self) -> Url {
+        ArgUrl::value(self.matches)
+    }
+
+    /// Get the owner token.
+    pub fn owner(&'a self) -> Option<String> {
+        // TODO: just return a string reference here?
+        ArgOwner::value(self.matches)
+            .map(|token| token.to_owned())
+    }
+
+    /// Get the password.
+    /// `None` is returned if no password was specified.
+    pub fn password(&'a self) -> Option<String> {
+        ArgPassword::value(self.matches)
+    }
+}
+
+impl<'a> Matcher<'a> for InfoMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("info")
+            .map(|matches|
+                 InfoMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 21 - 0
cli/src/cmd/matcher/mod.rs

@@ -0,0 +1,21 @@
+pub mod delete;
+pub mod download;
+pub mod info;
+pub mod params;
+pub mod password;
+pub mod upload;
+
+// Reexport to matcher module
+pub use self::delete::DeleteMatcher;
+pub use self::download::DownloadMatcher;
+pub use self::info::InfoMatcher;
+pub use self::params::ParamsMatcher;
+pub use self::password::PasswordMatcher;
+pub use self::upload::UploadMatcher;
+
+use clap::ArgMatches;
+
+pub trait Matcher<'a>: Sized {
+    // Construct a new matcher instance from these argument matches.
+    fn with(matches: &'a ArgMatches) -> Option<Self>;
+}

+ 44 - 0
cli/src/cmd/matcher/params.rs

@@ -0,0 +1,44 @@
+use clap::ArgMatches;
+use ffsend_api::url::Url;
+
+use cmd::arg::{ArgDownloadLimit, ArgOwner, ArgUrl, CmdArgOption};
+use super::Matcher;
+
+/// The params command matcher.
+pub struct ParamsMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> ParamsMatcher<'a> {
+    /// Get the file share URL.
+    ///
+    /// This method parses the URL into an `Url`.
+    /// If the given URL is invalid,
+    /// the program will quit with an error message.
+    pub fn url(&'a self) -> Url {
+        ArgUrl::value(self.matches)
+    }
+
+    /// Get the owner token.
+    pub fn owner(&'a self) -> Option<String> {
+        // TODO: just return a string reference here?
+        ArgOwner::value(self.matches)
+            .map(|token| token.to_owned())
+    }
+
+    /// Get the download limit.
+    pub fn download_limit(&'a self) -> Option<u8> {
+        ArgDownloadLimit::value(self.matches)
+    }
+}
+
+impl<'a> Matcher<'a> for ParamsMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("parameters")
+            .map(|matches|
+                 ParamsMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 55 - 0
cli/src/cmd/matcher/password.rs

@@ -0,0 +1,55 @@
+use clap::ArgMatches;
+use ffsend_api::url::Url;
+use rpassword::prompt_password_stderr;
+
+use cmd::arg::{ArgOwner, ArgPassword, ArgUrl, CmdArgOption};
+use super::Matcher;
+
+/// The password command matcher.
+pub struct PasswordMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> PasswordMatcher<'a> {
+    /// Get the file share URL.
+    ///
+    /// This method parses the URL into an `Url`.
+    /// If the given URL is invalid,
+    /// the program will quit with an error message.
+    pub fn url(&'a self) -> Url {
+        ArgUrl::value(self.matches)
+    }
+
+    /// Get the owner token.
+    pub fn owner(&'a self) -> Option<String> {
+        // TODO: just return a string reference here?
+        ArgOwner::value(self.matches)
+            .map(|token| token.to_owned())
+    }
+
+    /// Get the password.
+    pub fn password(&'a self) -> String {
+        // Get the password, or prompt for it
+        match ArgPassword::value(self.matches) {
+            Some(password) => password,
+            None => {
+                // Prompt for the password
+                // TODO: don't unwrap/expect
+                // TODO: create utility function for this
+                prompt_password_stderr("New password: ")
+                    .expect("failed to read password from stdin")
+            },
+        }
+    }
+}
+
+impl<'a> Matcher<'a> for PasswordMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("password")
+            .map(|matches|
+                 PasswordMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 87 - 0
cli/src/cmd/matcher/upload.rs

@@ -0,0 +1,87 @@
+use clap::ArgMatches;
+use ffsend_api::action::params::{
+    PARAMS_DEFAULT_DOWNLOAD as DOWNLOAD_DEFAULT,
+};
+use ffsend_api::url::Url;
+
+use cmd::arg::{ArgDownloadLimit, ArgHost, ArgPassword, CmdArgOption};
+use super::Matcher;
+
+/// The upload command matcher.
+pub struct UploadMatcher<'a> {
+    matches: &'a ArgMatches<'a>,
+}
+
+impl<'a: 'b, 'b> UploadMatcher<'a> {
+    /// Get the selected file to upload.
+    // TODO: maybe return a file or path instance here
+    pub fn file(&'a self) -> &'a str {
+        self.matches.value_of("FILE")
+            .expect("no file specified to upload")
+    }
+
+    /// The the name to use for the uploaded file.
+    /// If no custom name is given, none is returned.
+    // TODO: validate custom names, no path separators
+    // TODO: only allow extension renaming with force flag
+    pub fn name(&'a self) -> Option<&'a str> {
+        // Get the chosen file name
+        let name = self.matches.value_of("name")?;
+
+        // The file name must not be empty
+        if name.trim().is_empty() {
+            // TODO: return an error here
+            panic!("the new name must not be empty");
+        }
+
+        Some(name)
+    }
+
+    /// Get the host to upload to.
+    ///
+    /// This method parses the host into an `Url`.
+    /// If the given host is invalid,
+    /// the program will quit with an error message.
+    pub fn host(&'a self) -> Url {
+        ArgHost::value(self.matches)
+    }
+
+    /// Get the password.
+    /// `None` is returned if no password was specified.
+    pub fn password(&'a self) -> Option<String> {
+        ArgPassword::value(self.matches)
+    }
+
+    /// Get the download limit.
+    /// If the download limit was the default, `None` is returned to not
+    /// explicitly set it.
+    pub fn download_limit(&'a self) -> Option<u8> {
+        ArgDownloadLimit::value(self.matches)
+            .and_then(|d| match d {
+                DOWNLOAD_DEFAULT => None,
+                d => Some(d),
+            })
+    }
+
+    /// Check whether to open the file URL in the user's browser.
+    pub fn open(&self) -> bool {
+        self.matches.is_present("open")
+    }
+
+    /// Check whether to copy the file URL in the user's clipboard.
+    #[cfg(feature = "clipboard")]
+    pub fn copy(&self) -> bool {
+        self.matches.is_present("copy")
+    }
+}
+
+impl<'a> Matcher<'a> for UploadMatcher<'a> {
+    fn with(matches: &'a ArgMatches) -> Option<Self> {
+        matches.subcommand_matches("upload")
+            .map(|matches|
+                 UploadMatcher {
+                     matches,
+                 }
+            )
+    }
+}

+ 3 - 8
cli/src/cmd/mod.rs

@@ -1,12 +1,7 @@
-extern crate clap;
-
-pub mod cmd_delete;
-pub mod cmd_download;
-pub mod cmd_info;
-pub mod cmd_params;
-pub mod cmd_password;
-pub mod cmd_upload;
+pub mod arg;
+pub mod cmd;
 pub mod handler;
+pub mod matcher;
 
 // Reexport modules
 pub use self::handler::Handler;

+ 13 - 12
cli/src/main.rs

@@ -1,3 +1,4 @@
+extern crate clap;
 extern crate failure;
 #[macro_use]
 extern crate failure_derive;
@@ -38,38 +39,38 @@ fn main() {
 /// message.
 fn invoke_action(handler: &Handler) -> Result<(), Error> {
     // Match the delete command
-    if let Some(cmd) = handler.delete() {
-        return Delete::new(&cmd).invoke()
+    if handler.delete().is_some() {
+        return Delete::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }
 
     // Match the download command
-    if let Some(cmd) = handler.download() {
-        return Download::new(&cmd).invoke()
+    if handler.download().is_some() {
+        return Download::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }
 
     // Match the info command
-    if let Some(cmd) = handler.info() {
-        return Info::new(&cmd).invoke()
+    if handler.info().is_some() {
+        return Info::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }
 
     // Match the parameters command
-    if let Some(cmd) = handler.params() {
-        return Params::new(&cmd).invoke()
+    if handler.params().is_some() {
+        return Params::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }
 
     // Match the password command
-    if let Some(cmd) = handler.password() {
-        return Password::new(&cmd).invoke()
+    if handler.password().is_some() {
+        return Password::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }
 
     // Match the upload command
-    if let Some(cmd) = handler.upload() {
-        return Upload::new(&cmd).invoke()
+    if handler.upload().is_some() {
+        return Upload::new(handler.matches()).invoke()
             .map_err(|err| err.into());
     }