ffsend/src/util.rs

748 lines
21 KiB
Rust

#[cfg(all(feature = "clipboard", not(target_os = "linux")))]
extern crate clipboard;
extern crate colored;
extern crate directories;
extern crate fs2;
extern crate open;
use std::borrow::Borrow;
use std::env::{current_exe, var_os};
use std::ffi::OsStr;
use std::fmt::{Debug, Display};
#[cfg(feature = "clipboard")]
use std::io::ErrorKind as IoErrorKind;
use std::io::{stderr, stdin, Error as IoError, Write};
use std::path::Path;
#[cfg(feature = "history")]
use std::path::PathBuf;
use std::process::{exit, ExitStatus};
#[cfg(all(feature = "clipboard", target_os = "linux"))]
use std::process::{Command, Stdio};
#[cfg(all(feature = "clipboard", not(target_os = "linux")))]
use self::clipboard::{ClipboardContext, ClipboardProvider};
use self::colored::*;
#[cfg(feature = "history")]
use self::directories::ProjectDirs;
use self::fs2::available_space;
use chrono::Duration;
use failure::{err_msg, Fail};
#[cfg(all(feature = "clipboard", not(target_os = "linux")))]
use failure::{Compat, Error};
use ffsend_api::url::Url;
use rpassword::prompt_password_stderr;
use cmd::matcher::MainMatcher;
/// Print a success message.
pub fn print_success(msg: &str) {
eprintln!("{}", msg.green());
}
/// Print the given error in a proper format for the user,
/// with it's causes.
pub fn print_error<E: Fail>(err: impl Borrow<E>) {
// Report each printable error, count them
let count = err
.borrow()
.causes()
.map(|err| format!("{}", err))
.filter(|err| !err.is_empty())
.enumerate()
.map(|(i, err)| {
if i == 0 {
eprintln!("{} {}", highlight_error("error:"), err);
} else {
eprintln!("{} {}", highlight_error("caused by:"), err);
}
})
.count();
// Fall back to a basic message
if count == 0 {
eprintln!(
"{} {}",
highlight_error("error:"),
"an undefined error occurred"
);
}
}
/// Print the given error message in a proper format for the user,
/// with it's causes.
pub fn print_error_msg<S>(err: S)
where
S: AsRef<str> + Display + Debug + Sync + Send + 'static,
{
print_error(err_msg(err).compat());
}
/// Print a warning.
#[cfg(feature = "history")]
pub fn print_warning<S>(err: S)
where
S: AsRef<str> + Display + Debug + Sync + Send + 'static,
{
eprintln!("{} {}", highlight_warning("warning:"), err);
}
/// Quit the application regularly.
pub fn quit() -> ! {
exit(0);
}
/// Quit the application with an error code,
/// and print the given error.
pub fn quit_error<E: Fail>(err: E, hints: impl Borrow<ErrorHints>) -> ! {
// Print the error
print_error(err);
// Print error hints
hints.borrow().print();
// Quit
exit(1);
}
/// Quit the application with an error code,
/// and print the given error message.
pub fn quit_error_msg<S>(err: S, hints: impl Borrow<ErrorHints>) -> !
where
S: AsRef<str> + Display + Debug + Sync + Send + 'static,
{
quit_error(err_msg(err).compat(), hints);
}
/// The error hint configuration.
#[derive(Clone, Builder)]
#[builder(default)]
pub struct ErrorHints {
/// A list of info messages to print along with the error.
info: Vec<String>,
/// Show about the password option.
password: bool,
/// Show about the owner option.
owner: bool,
/// Show about the history flag.
#[cfg(feature = "history")]
history: bool,
/// Show about the force flag.
force: bool,
/// Show about the verbose flag.
verbose: bool,
/// Show about the help flag.
help: bool,
}
impl ErrorHints {
/// Check whether any hint should be printed.
pub fn any(&self) -> bool {
// Determine the result
#[allow(unused_mut)]
let mut result = self.password || self.owner || self.force || self.verbose || self.help;
// Factor in the history hint when enabled
#[cfg(feature = "history")]
{
result = result || self.history;
}
result
}
/// Print the error hints.
pub fn print(&self) {
// Print info messages
for msg in &self.info {
eprintln!("{} {}", highlight_info("info:"), msg);
}
// Stop if nothing should be printed
if !self.any() {
return;
}
eprint!("\n");
// Print hints
if self.password {
eprintln!(
"Use '{}' to specify a password",
highlight("--password <PASSWORD>")
);
}
if self.owner {
eprintln!(
"Use '{}' to specify an owner token",
highlight("--owner <TOKEN>")
);
}
#[cfg(feature = "history")]
{
if self.history {
eprintln!(
"Use '{}' to specify a history file",
highlight("--history <FILE>")
);
}
}
if self.force {
eprintln!("Use '{}' to force", highlight("--force"));
}
if self.verbose {
eprintln!("For detailed errors try '{}'", highlight("--verbose"));
}
if self.help {
eprintln!("For more information try '{}'", highlight("--help"));
}
// Flush
let _ = stderr().flush();
}
}
impl Default for ErrorHints {
fn default() -> Self {
ErrorHints {
info: Vec::new(),
password: false,
owner: false,
#[cfg(feature = "history")]
history: false,
force: false,
verbose: true,
help: true,
}
}
}
impl ErrorHintsBuilder {
/// Add a single info entry.
pub fn add_info(mut self, info: String) -> Self {
// Initialize the info list
if self.info.is_none() {
self.info = Some(Vec::new());
}
// Add the item to the info list
if let Some(ref mut list) = self.info {
list.push(info);
}
self
}
}
/// Highlight the given text with a color.
pub fn highlight(msg: &str) -> ColoredString {
msg.yellow()
}
/// Highlight the given text with an error color.
pub fn highlight_error(msg: &str) -> ColoredString {
msg.red().bold()
}
/// Highlight the given text with an warning color.
#[cfg(feature = "history")]
pub fn highlight_warning(msg: &str) -> ColoredString {
highlight(msg).bold()
}
/// Highlight the given text with an info color
pub fn highlight_info(msg: &str) -> ColoredString {
msg.cyan()
}
/// Open the given URL in the users default browser.
/// The browsers exit statis is returned.
pub fn open_url(url: impl Borrow<Url>) -> Result<ExitStatus, IoError> {
open_path(url.borrow().as_str())
}
/// Open the given path or URL using the program configured on the system.
/// The program exit statis is returned.
pub fn open_path(path: &str) -> Result<ExitStatus, IoError> {
open::that(path)
}
/// Set the clipboard of the user to the given `content` string.
#[cfg(feature = "clipboard")]
pub fn set_clipboard(content: String) -> Result<(), ClipboardError> {
#[cfg(not(target_os = "linux"))]
{
ClipboardProvider::new()
.and_then(|mut context: ClipboardContext| context.set_contents(content))
.map_err(|err| format_err!("{}", err).compat())
.map_err(ClipboardError::Generic)
}
#[cfg(target_os = "linux")]
{
// Open an xclip process
let mut process = match Command::new("xclip")
.arg("-sel")
.arg("clip")
.stdin(Stdio::piped())
.spawn()
{
Ok(process) => process,
Err(err) => {
return Err(match err.kind() {
IoErrorKind::NotFound => ClipboardError::NoXclip,
_ => ClipboardError::Xclip(err),
})
}
};
// Write the contents to the xclip process
process
.stdin
.as_mut()
.unwrap()
.write_all(content.as_bytes())
.map_err(ClipboardError::Xclip)?;
// Wait for xclip to exit
let status = process.wait().map_err(ClipboardError::Xclip)?;
if !status.success() {
return Err(ClipboardError::XclipStatus(status.code().unwrap_or(0)));
}
Ok(())
}
}
#[cfg(feature = "clipboard")]
#[derive(Debug, Fail)]
pub enum ClipboardError {
/// A generic error occurred while setting the clipboard contents
#[cfg(not(target_os = "linux"))]
#[fail(display = "failed to access clipboard")]
Generic(#[cause] Compat<Error>),
/// Xclip is not installed on the system, which is required for clipboard support.
#[cfg(target_os = "linux")]
#[fail(display = "failed to access clipboard, xclip is not installed")]
NoXclip,
/// An error occurred while using xclip to set the clipboard contents.
/// This problem probably occurred when stargin the xclip process, or while piping the
/// clipboard contents to the process.
#[cfg(target_os = "linux")]
#[fail(display = "failed to access clipboard using xclip")]
Xclip(#[cause] IoError),
/// Xclip unexpectetly exited with a non-successful status code.
#[cfg(target_os = "linux")]
#[fail(
display = "failed to use clipboard, xclip exited with status code {}",
_0
)]
XclipStatus(i32),
}
/// Check for an emtpy password in the given `password`.
/// If the password is emtpy the program will quit with an error unless
/// forced.
// TODO: move this to a better module
pub fn check_empty_password(password: &str, matcher_main: &MainMatcher) {
if !matcher_main.force() && password.is_empty() {
quit_error_msg(
"an empty password is not supported by the web interface",
ErrorHintsBuilder::default()
.force(true)
.verbose(false)
.build()
.unwrap(),
)
}
}
/// Prompt the user to enter a password.
///
/// If `empty` is `false`, emtpy passwords aren't allowed unless forced.
pub fn prompt_password(main_matcher: &MainMatcher) -> String {
// Quit with an error if we may not interact
if main_matcher.no_interact() {
quit_error_msg(
"missing password, must be specified in no-interact mode",
ErrorHintsBuilder::default()
.password(true)
.verbose(false)
.build()
.unwrap(),
);
}
// Prompt for the password
match prompt_password_stderr("Password: ") {
Ok(password) => password,
Err(err) => quit_error(
err.context("failed to read password from password prompt"),
ErrorHints::default(),
),
}
}
/// Get a password if required.
/// This method will ensure a password is set (or not) in the given `password`
/// parameter, as defined by `needs`.
///
/// This method will prompt the user for a password, if one is required but
/// wasn't set. An ignore message will be shown if it was not required while it
/// was set.
pub fn ensure_password(password: &mut Option<String>, needs: bool, main_matcher: &MainMatcher) {
// Return if we're fine
if password.is_some() == needs {
return;
}
// Prompt for the password, or clear it if not required
if needs {
println!("This file is protected with a password.");
*password = Some(prompt_password(main_matcher));
} else {
println!("Ignoring password, it is not required");
*password = None;
}
}
/// Prompt the user to enter some value.
/// The prompt that is shown should be passed to `msg`,
/// excluding the `:` suffix.
pub fn prompt(msg: &str, main_matcher: &MainMatcher) -> String {
// Quit with an error if we may not interact
if main_matcher.no_interact() {
quit_error_msg(
format!(
"could not prompt for '{}' in no-interact mode, maybe specify it",
msg,
),
ErrorHints::default(),
);
}
// Show the prompt
eprint!("{}: ", msg);
let _ = stderr().flush();
// Get the input
let mut input = String::new();
if let Err(err) = stdin().read_line(&mut input) {
quit_error(
err.context("failed to read input from prompt"),
ErrorHints::default(),
);
}
// Trim and return
input.trim().to_owned()
}
/// Prompt the user for a question, allowing a yes or now answer.
/// True is returned if yes was answered, false if no.
///
/// A default may be given, which is chosen if no-interact mode is
/// enabled, or if enter was pressed by the user without entering anything.
pub fn prompt_yes(msg: &str, def: Option<bool>, main_matcher: &MainMatcher) -> bool {
// Define the available options string
let options = format!(
"[{}/{}]",
match def {
Some(def) if def => "Y",
_ => "y",
},
match def {
Some(def) if !def => "N",
_ => "n",
}
);
// Assume yes
if main_matcher.assume_yes() {
eprintln!("{} {}: yes", msg, options);
return true;
}
// Autoselect if in no-interact mode
if main_matcher.no_interact() {
if let Some(def) = def {
eprintln!("{} {}: {}", msg, options, if def { "yes" } else { "no" });
return def;
} else {
quit_error_msg(
format!(
"could not prompt question '{}' in no-interact mode, maybe specify it",
msg,
),
ErrorHints::default(),
);
}
}
// Get the user input
let answer = prompt(&format!("{} {}", msg, options), main_matcher);
// Assume the default if the answer is empty
if answer.is_empty() && def.is_some() {
return def.unwrap();
}
// Derive a boolean and return
match derive_bool(&answer) {
Some(answer) => answer,
None => prompt_yes(msg, def, main_matcher),
}
}
/// Try to derive true or false (yes or no) from the given input.
/// None is returned if no boolean could be derived accurately.
fn derive_bool(input: &str) -> Option<bool> {
// Process the input
let input = input.trim().to_lowercase();
// Handle short or incomplete answers
match input.as_str() {
"y" | "ye" | "t" | "1" => return Some(true),
"n" | "f" | "0" => return Some(false),
_ => {}
}
// Handle complete answers with any suffix
if input.starts_with("yes") || input.starts_with("true") {
return Some(true);
}
if input.starts_with("no") || input.starts_with("false") {
return Some(false);
}
// The answer could not be determined, return none
None
}
/// Prompt the user to enter an owner token.
pub fn prompt_owner_token(main_matcher: &MainMatcher) -> String {
prompt("Owner token", main_matcher)
}
/// Get the owner token.
/// This method will ensure an owner token is set in the given `token`
/// parameter.
///
/// This method will prompt the user for the token, if it wasn't set.
pub fn ensure_owner_token(token: &mut Option<String>, main_matcher: &MainMatcher) {
// Check whehter we allow interaction
let interact = !main_matcher.no_interact();
// Notify that an owner token is required
if interact && token.is_none() {
println!("The file owner token is required for authentication.");
}
loop {
// Prompt for an owner token
if token.is_none() {
if interact {
*token = Some(prompt_owner_token(main_matcher));
} else {
quit_error_msg(
"missing owner token, must be specified in no-interact mode",
ErrorHintsBuilder::default()
.owner(true)
.verbose(false)
.build()
.unwrap(),
);
}
}
// The token must not be empty
if token.as_ref().unwrap().is_empty() {
eprintln!(
"Empty owner token given, which is invalid. Use {} to cancel.",
highlight("[CTRL+C]"),
);
*token = None;
} else {
break;
}
}
}
/// Format the given number of bytes readable for humans.
pub fn format_bytes(bytes: u64) -> String {
let bytes = bytes as f64;
let kb = 1024f64;
match bytes {
bytes if bytes >= kb.powf(4_f64) => format!("{:.*} TiB", 2, bytes / kb.powf(4_f64)),
bytes if bytes >= kb.powf(3_f64) => format!("{:.*} GiB", 2, bytes / kb.powf(3_f64)),
bytes if bytes >= kb.powf(2_f64) => format!("{:.*} MiB", 2, bytes / kb.powf(2_f64)),
bytes if bytes >= kb => format!("{:.*} KiB", 2, bytes / kb),
_ => format!("{:.*} B", 0, bytes),
}
}
/// Format the given duration in a human readable format.
/// This method builds a string of time components to represent
/// the given duration.
///
/// The following time units are used:
/// - `w`: weeks
/// - `d`: days
/// - `h`: hours
/// - `m`: minutes
/// - `s`: seconds
///
/// Only the two most significant units are returned.
/// If the duration is zero seconds or less `now` is returned.
///
/// The following time strings may be produced:
/// - `8w6d`
/// - `23h14m`
/// - `9m55s`
/// - `1s`
/// - `now`
pub fn format_duration(duration: impl Borrow<Duration>) -> String {
// Get the total number of seconds, return immediately if zero or less
let mut secs = duration.borrow().num_seconds();
if secs <= 0 {
return "now".into();
}
// Build a list of time units, define a list for time components
let mut components = Vec::new();
let units = [
(60 * 60 * 24 * 7, "w"),
(60 * 60 * 24, "d"),
(60 * 60, "h"),
(60, "m"),
(1, "s"),
];
// Fill the list of time components based on the units which fit
for unit in &units {
if secs >= unit.0 {
components.push(format!("{}{}", secs / unit.0, unit.1));
secs %= unit.0;
}
}
// Show only the two most significant components and join them in a string
components.truncate(2);
components.join("")
}
/// Format the given boolean, as `yes` or `no`.
pub fn format_bool(b: bool) -> &'static str {
if b {
"yes"
} else {
"no"
}
}
/// Get the name of the executable that was invoked.
pub fn bin_name() -> String {
current_exe()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_owned()))
.and_then(|n| n.into_string().ok())
.unwrap_or_else(|| crate_name!().into())
}
/// Ensure that there is enough free disk space available at the given `path`,
/// to store a file with the given `size`.
///
/// If an error occurred while querying the file system,
/// the error is reported to the user and the method returns.
///
/// If there is not enough disk space available,
/// an error is reported and the program will quit.
pub fn ensure_enough_space<P: AsRef<Path>>(path: P, size: u64) {
// Get the available space at this path
let space = match available_space(path) {
Ok(space) => space,
Err(err) => {
print_error(err.context("failed to check available space on disk, ignoring"));
return;
}
};
// Return if enough disk space is avaiable
if space >= size {
return;
}
// Create an info message giving details about the required space
let info = format!(
"{} of space required, but only {} is available",
format_bytes(size),
format_bytes(space),
);
// Print an descriptive error and quit
quit_error(
err_msg("not enough disk space available in the target directory")
.context("failed to download file"),
ErrorHintsBuilder::default()
.add_info(info)
.force(true)
.verbose(false)
.build()
.unwrap(),
);
}
/// Get the project directories instance for this application.
/// This may be used to determine the project, cache, configuration, data and
/// some other directory paths.
#[cfg(feature = "history")]
pub fn app_project_dirs() -> ProjectDirs {
ProjectDirs::from("", "", crate_name!())
.expect("failed to determine location of project directories")
}
/// Get the default path to use for the history file.
#[cfg(feature = "history")]
pub fn app_history_file_path() -> PathBuf {
app_project_dirs().cache_dir().join("history.toml")
}
/// Get the default path to use for the history file, as a string.
#[cfg(feature = "history")]
pub fn app_history_file_path_string() -> String {
app_history_file_path().to_str().unwrap().to_owned()
}
/// Check whether an environment variable with the given key is present in the context of the
/// current process. The environment variable doesn't have to hold any specific value.
/// Returns `true` if present, `false` if not.
pub fn env_var_present(key: impl AsRef<OsStr>) -> bool {
var_os(key).is_some()
}
/// Get a list of all features that were enabled during compilation.
pub fn features_list() -> Vec<&'static str> {
// Build the list
#[allow(unused_mut)]
let mut features = Vec::new();
// Add each feature
#[cfg(feature = "archive")]
features.push("archive");
#[cfg(feature = "clipboard")]
features.push("clipboard");
#[cfg(feature = "history")]
features.push("history");
#[cfg(feature = "no-color")]
features.push("no-color");
features
}