Support remote libraries #36

Merged
wojtek merged 5 commits from 5---support-remote-libraries into main 2023-04-14 16:21:25 +02:00
5 changed files with 339 additions and 49 deletions
Showing only changes of commit ba44c80211 - Show all commits

202
Cargo.lock generated
View File

@ -43,6 +43,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytes"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
[[package]]
name = "cassowary"
version = "0.3.0"
@ -107,6 +113,26 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "downcast"
version = "0.11.0"
@ -164,6 +190,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa"
[[package]]
name = "getrandom"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "heck"
version = "0.3.3"
@ -312,11 +349,13 @@ dependencies = [
"crossterm",
"mockall",
"once_cell",
"openssh",
"ratatui",
"serde",
"serde_json",
"structopt",
"tempfile",
"tokio",
"uuid",
]
@ -341,6 +380,39 @@ version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "openssh"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca6c277973fb549b36dd8980941b5ea3ecebea026f5b1f0060acde74d893c22"
dependencies = [
"dirs",
"libc",
"once_cell",
"openssh-mux-client",
"shell-escape",
"tempfile",
"thiserror",
"tokio",
"tokio-pipe",
]
[[package]]
name = "openssh-mux-client"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88eac793af6170bcd6d4f39c3b7ba3f4227cab5680d7189ba30f9d174600b75f"
dependencies = [
"once_cell",
"sendfd",
"serde",
"ssh_format",
"thiserror",
"tokio",
"tokio-io-utility",
"typed-builder",
]
[[package]]
name = "parking_lot"
version = "0.12.1"
@ -364,6 +436,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "pin-project-lite"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
[[package]]
name = "predicates"
version = "2.1.5"
@ -467,6 +545,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
dependencies = [
"getrandom",
"redox_syscall 0.2.16",
"thiserror",
]
[[package]]
name = "regex"
version = "1.7.3"
@ -510,6 +599,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "sendfd"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604b71b8fc267e13bb3023a2c901126c8f349393666a6d98ac1ae5729b701798"
dependencies = [
"libc",
"tokio",
]
[[package]]
name = "serde"
version = "1.0.159"
@ -541,6 +640,12 @@ dependencies = [
"serde",
]
[[package]]
name = "shell-escape"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
[[package]]
name = "signal-hook"
version = "0.3.15"
@ -577,6 +682,25 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "socket2"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "ssh_format"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8701239872766d43b8a5f9a560ff7f002b48064fadea87f44a70507069fb482"
dependencies = [
"serde",
]
[[package]]
name = "strsim"
version = "0.8.0"
@ -657,6 +781,84 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.11",
]
[[package]]
name = "tokio"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001"
dependencies = [
"autocfg",
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-io-utility"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d672654d175710e52c7c41f6aec77c62b3c0954e2a7ebce9049d1e94ed7c263"
dependencies = [
"tokio",
]
[[package]]
name = "tokio-macros"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.11",
]
[[package]]
name = "tokio-pipe"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f213a84bffbd61b8fa0ba8a044b4bbe35d471d0b518867181e82bd5c15542784"
dependencies = [
"libc",
"tokio",
]
[[package]]
name = "typed-builder"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "unicode-ident"
version = "1.0.8"

View File

@ -7,10 +7,12 @@ edition = "2021"
[dependencies]
crossterm = "0.26.1"
openssh = { version = "0.9.9", features = ["native-mux"], default-features = false }
serde = { version = "1.0.159", features = ["derive"] }
serde_json = "1.0.95"
structopt = "0.3.26"
ratatui = "0.20.1"
tokio = { version = "1.27.0", features = ["rt"] }
uuid = { version = "1.3.0", features = ["serde"] }
[dev-dependencies]

View File

@ -5,10 +5,13 @@ use std::{
collections::{HashMap, HashSet},
ffi::OsString,
path::PathBuf,
process::Command,
process::{Command, Output},
str,
};
use openssh::{KnownHosts, Session};
use tokio::runtime::{self, Runtime};
#[cfg(test)]
use mockall::automock;
@ -22,6 +25,7 @@ macro_rules! list_format_separator {
};
}
const BEET_DEFAULT: &str = "beet";
const CMD_LIST: &str = "ls";
const LIST_FORMAT_SEPARATOR: &str = list_format_separator!();
const LIST_FORMAT_ARG: &str = concat!(
@ -198,6 +202,18 @@ impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
}
}
trait BeetsLibraryExecutorPrivate {
fn output(output: Output) -> Result<Vec<String>, Error> {
if !output.status.success() {
return Err(Error::Executor(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
let output = str::from_utf8(&output.stdout)?;
Ok(output.split('\n').map(|s| s.to_string()).collect())
}
}
/// Beets library executor that executes beets commands in their own process.
pub struct BeetsLibraryProcessExecutor {
bin: OsString,
@ -205,8 +221,13 @@ pub struct BeetsLibraryProcessExecutor {
}
impl BeetsLibraryProcessExecutor {
/// Create a new [`BeetsLibraryProcessExecutor`] that uses the default beets executable.
pub fn new() -> Self {
Self::bin(BEET_DEFAULT)
}
/// Create a new [`BeetsLibraryProcessExecutor`] that uses the provided beets executable.
pub fn new<S: Into<OsString>>(bin: S) -> Self {
pub fn bin<S: Into<OsString>>(bin: S) -> Self {
BeetsLibraryProcessExecutor {
bin: bin.into(),
config: None,
@ -224,7 +245,7 @@ impl Default for BeetsLibraryProcessExecutor {
/// Create a new [`BeetsLibraryProcessExecutor`] that uses the system's default beets
/// executable.
fn default() -> Self {
BeetsLibraryProcessExecutor::new("beet")
BeetsLibraryProcessExecutor::new()
}
}
@ -236,16 +257,75 @@ impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor {
cmd.arg(path);
}
let output = cmd.args(arguments.iter().map(|s| s.as_ref())).output()?;
if !output.status.success() {
return Err(Error::CmdExec(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
let output = str::from_utf8(&output.stdout)?;
Ok(output.split('\n').map(|s| s.to_string()).collect())
Self::output(output)
}
}
impl BeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
// GRCOV_EXCL_START
/// Beets library executor that executes beets commands over SSH.
pub struct BeetsLibrarySshExecutor {
rt: Runtime,
session: Session,
bin: String,
config: Option<String>,
}
impl From<openssh::Error> for Error {
fn from(err: openssh::Error) -> Error {
Error::Executor(err.to_string())
}
}
impl BeetsLibrarySshExecutor {
/// Create a new [`BeetsLibrarySshExecutor`] that uses the default beets executable over an SSH
/// connection. This call will attempt to establish the connection and will fail if the
/// connection fails.
pub fn new<H: AsRef<str>>(host: H) -> Result<Self, Error> {
Self::bin(host, BEET_DEFAULT)
}
/// Create a new [`BeetsLibrarySshExecutor`] that uses the provided beets executable over an SSH
/// connection. This call will attempt to establish the connection and will fail if the
/// connection fails.
pub fn bin<H: AsRef<str>, S: Into<String>>(host: H, bin: S) -> Result<Self, Error> {
let rt = runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let session = rt.block_on(Session::connect_mux(host, KnownHosts::Strict))?;
Ok(BeetsLibrarySshExecutor {
rt,
session,
bin: bin.into(),
config: None,
})
}
/// Update the configuration file passed to the beets executable.
pub fn config<P: Into<String>>(mut self, path: Option<P>) -> Self {
self.config = path.map(|p| p.into());
self
}
}
impl BeetsLibraryExecutor for BeetsLibrarySshExecutor {
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error> {
let mut cmd = self.session.command(&self.bin);
if let Some(ref path) = self.config {
cmd.arg("--config");
cmd.arg(path);
}
cmd.args(arguments.iter().map(|s| s.as_ref()));
let output = self.rt.block_on(cmd.output())?;
Self::output(output)
}
}
impl BeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {}
// GRCOV_EXCL_STOP
#[cfg(test)]
mod tests {
use mockall::predicate;

View File

@ -60,26 +60,26 @@ impl Query {
/// Error type for library calls.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The underlying library failed to execute a command.
CmdExec(String),
/// The underlying library returned invalid data.
Invalid(String),
/// The underlying library experienced an I/O error.
/// The library's executor failed.
Executor(String),
/// The library experienced an I/O error.
Io(String),
/// The underlying library failed to parse an integer.
/// The library received invalid data.
Invalid(String),
/// The library failed to parse an integer.
ParseInt(String),
/// The underlying library failed to parse a UTF-8 string.
/// The library failed to parse a UTF-8 string.
Utf8(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::CmdExec(ref s) => write!(f, "the library failed to execute a command: {s}"),
Self::Invalid(ref s) => write!(f, "the library received invalid data: {s}"),
Self::Executor(ref s) => write!(f, "the library's executor failed: {s}"),
Self::Io(ref s) => write!(f, "the library experienced an I/O error: {s}"),
Self::ParseInt(ref s) => write!(f, "the library received an invalid integer: {s}"),
Self::Utf8(ref s) => write!(f, "the library received invalid UTF-8: {s}"),
Self::Invalid(ref s) => write!(f, "the library received invalid data: {s}"),
Self::ParseInt(ref s) => write!(f, "the library failed to parse an integer: {s}"),
Self::Utf8(ref s) => write!(f, "the library failed to parse a UTF-8 string: {s}"),
}
}
}
@ -133,21 +133,21 @@ mod tests {
#[test]
fn errors() {
let cmd_err = Error::CmdExec(String::from("CmdExec"));
let inv_err = Error::Invalid(String::from("Invalid"));
let exe_err = Error::Executor(String::from("Executor"));
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "Interrupted").into();
let inv_err = Error::Invalid(String::from("Invalid"));
let int_err: Error = "five".parse::<u32>().unwrap_err().into();
let utf_err: Error = std::str::from_utf8(b"\xe2\x28\xa1").unwrap_err().into();
assert!(!cmd_err.to_string().is_empty());
assert!(!inv_err.to_string().is_empty());
assert!(!exe_err.to_string().is_empty());
assert!(!io_err.to_string().is_empty());
assert!(!inv_err.to_string().is_empty());
assert!(!int_err.to_string().is_empty());
assert!(!utf_err.to_string().is_empty());
assert!(!format!("{:?}", cmd_err).is_empty());
assert!(!format!("{:?}", inv_err).is_empty());
assert!(!format!("{:?}", exe_err).is_empty());
assert!(!format!("{:?}", io_err).is_empty());
assert!(!format!("{:?}", inv_err).is_empty());
assert!(!format!("{:?}", int_err).is_empty());
assert!(!format!("{:?}", utf_err).is_empty());
}

View File

@ -1,7 +1,10 @@
use musichoard::database::Database;
use musichoard::library::beets::BeetsLibrarySshExecutor;
use musichoard::library::Library;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io;
use std::path::PathBuf;
use std::{ffi::OsString, io};
use structopt::StructOpt;
@ -19,35 +22,22 @@ use tui::{
#[derive(StructOpt)]
struct Opt {
#[structopt(
short = "b",
long = "beets",
name = "beets config file path",
parse(from_os_str)
)]
beets_config_file_path: Option<PathBuf>,
#[structopt(long = "ssh", name = "beets SSH URI")]
beets_ssh_uri: Option<OsString>,
#[structopt(long = "beets", name = "beets config file path")]
beets_config_file_path: Option<OsString>,
#[structopt(
short = "d",
long = "database",
name = "database file path",
default_value = "database.json",
parse(from_os_str)
default_value = "database.json"
)]
database_file_path: PathBuf,
}
fn main() {
// Create the application.
let opt = Opt::from_args();
let beets = BeetsLibrary::new(
BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path.as_deref()),
);
let database = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path));
let collection_manager = MhCollectionManager::new(beets, database);
fn with<LIB: Library, DB: Database>(lib: LIB, db: DB) {
let collection_manager = MhCollectionManager::new(lib, db);
// Initialize the terminal user interface.
let backend = CrosstermBackend::new(io::stdout());
@ -65,6 +55,22 @@ fn main() {
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
}
fn main() {
// Create the application.
let opt = Opt::from_args();
if let Some(uri) = opt.beets_ssh_uri {
let uri = uri.into_string().expect("invalid SSH URI");
let lib_exec = BeetsLibrarySshExecutor::new(uri).expect("failed to initialise beets");
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
} else {
let lib_exec = BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path);
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
}
}
#[cfg(test)]
#[macro_use]
mod testlib;