From 4b983b02296820261ac5daac79f51019079796a0 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 22 Apr 2023 02:40:21 +0200 Subject: [PATCH] Refactor into unified settings (#172) --- CHANGELOG.md | 43 +- Cargo.lock | 9 +- Cargo.toml | 6 +- distant-core/Cargo.toml | 5 +- distant-core/README.md | 2 - distant-core/src/client/watcher.rs | 4 +- distant-core/src/data.rs | 59 +- distant-core/src/data/change.rs | 22 +- distant-core/src/data/clap_impl.rs | 106 - distant-core/src/data/error.rs | 7 + distant-core/src/data/search.rs | 15 - distant-net/Cargo.toml | 2 +- distant-net/src/client.rs | 27 +- distant-net/src/common/map.rs | 52 + distant-net/src/common/port.rs | 26 +- distant-ssh2/Cargo.toml | 4 +- src/cli.rs | 108 +- src/cli/commands.rs | 31 +- src/cli/commands/client.rs | 1898 ++++---- src/cli/commands/client/lsp.rs | 5 +- src/cli/commands/client/shell.rs | 5 +- src/cli/commands/common.rs | 8 + src/cli/commands/{client => common}/buf.rs | 0 src/cli/commands/{client => common}/format.rs | 26 +- src/cli/commands/{client => common}/link.rs | 21 +- src/cli/commands/{client => common}/stdin.rs | 0 src/cli/commands/generate.rs | 151 +- src/cli/commands/manager.rs | 878 ++-- src/cli/commands/manager/handlers.rs | 2 +- src/cli/commands/server.rs | 402 +- src/cli/common.rs | 11 + src/cli/{ => common}/cache.rs | 2 +- src/cli/{ => common}/cache/id.rs | 0 src/cli/{ => common}/client.rs | 12 +- src/cli/{ => common}/manager.rs | 6 +- src/cli/{client => common}/msg.rs | 0 src/cli/{ => common}/spawner.rs | 0 src/config/client/action.rs | 8 - src/config/client/launch.rs | 83 - src/config/client/repl.rs | 8 - src/config/manager.rs | 19 - src/constants.rs | 116 + src/lib.rs | 5 +- src/options.rs | 4138 +++++++++++++++++ src/options/common.rs | 13 + .../listen.rs => options/common/address.rs} | 114 +- src/options/common/cmd.rs | 146 + .../common.rs => options/common/logging.rs} | 37 +- src/{config => options/common}/network.rs | 23 +- src/options/common/search.rs | 81 + src/options/common/value.rs | 124 + src/{ => options}/config.rs | 136 +- src/{ => options}/config.toml | 23 +- src/{ => options}/config/client.rs | 19 +- src/options/config/client/api.rs | 6 + src/{ => options}/config/client/connect.rs | 8 +- src/options/config/client/launch.rs | 54 + src/{ => options}/config/generate.rs | 4 +- src/options/config/manager.rs | 14 + src/{ => options}/config/server.rs | 4 +- src/options/config/server/listen.rs | 61 + src/paths.rs | 107 - tests/cli/action/file_append.rs | 65 - tests/cli/action/file_append_text.rs | 65 - tests/cli/action/file_read.rs | 41 - tests/cli/action/file_read_text.rs | 41 - tests/cli/action/file_write.rs | 49 - tests/cli/action/file_write_text.rs | 59 - tests/cli/{repl => api}/capabilities.rs | 6 +- tests/cli/{repl => api}/copy.rs | 18 +- tests/cli/{repl => api}/dir_create.rs | 18 +- tests/cli/{repl => api}/dir_read.rs | 30 +- tests/cli/{repl => api}/exists.rs | 12 +- tests/cli/{repl => api}/file_append.rs | 12 +- tests/cli/{repl => api}/file_append_text.rs | 12 +- tests/cli/{repl => api}/file_read.rs | 12 +- tests/cli/{repl => api}/file_read_text.rs | 12 +- tests/cli/{repl => api}/file_write.rs | 12 +- tests/cli/{repl => api}/file_write_text.rs | 12 +- tests/cli/{repl => api}/metadata.rs | 30 +- tests/cli/{action => api}/mod.rs | 0 tests/cli/{repl => api}/proc_spawn.rs | 48 +- tests/cli/{repl => api}/remove.rs | 24 +- tests/cli/{repl => api}/rename.rs | 18 +- tests/cli/{repl => api}/search.rs | 12 +- tests/cli/{repl => api}/system_info.rs | 6 +- tests/cli/{repl => api}/watch.rs | 40 +- tests/cli/{action => client}/capabilities.rs | 7 +- .../cli/{action/copy.rs => client/fs_copy.rs} | 29 +- .../{action/exists.rs => client/fs_exists.rs} | 17 +- .../dir_create.rs => client/fs_make_dir.rs} | 25 +- .../metadata.rs => client/fs_metadata.rs} | 49 +- .../fs_read_directory.rs} | 49 +- tests/cli/client/fs_read_file.rs | 42 + .../{action/remove.rs => client/fs_remove.rs} | 31 +- .../{action/rename.rs => client/fs_rename.rs} | 23 +- .../{action/search.rs => client/fs_search.rs} | 6 +- .../{action/watch.rs => client/fs_watch.rs} | 29 +- tests/cli/client/fs_write.rs | 125 + tests/cli/client/mod.rs | 14 + .../{action/proc_spawn.rs => client/spawn.rs} | 49 +- tests/cli/{action => client}/system_info.rs | 9 +- tests/cli/fixtures.rs | 42 +- tests/cli/fixtures/{repl.rs => api.rs} | 12 +- tests/cli/mod.rs | 4 +- tests/cli/repl/mod.rs | 18 - tests/cli/utils.rs | 5 - 107 files changed, 7420 insertions(+), 3125 deletions(-) delete mode 100644 distant-core/src/data/clap_impl.rs create mode 100644 src/cli/commands/common.rs rename src/cli/commands/{client => common}/buf.rs (100%) rename src/cli/commands/{client => common}/format.rs (96%) rename src/cli/commands/{client => common}/link.rs (84%) rename src/cli/commands/{client => common}/stdin.rs (100%) create mode 100644 src/cli/common.rs rename src/cli/{ => common}/cache.rs (98%) rename src/cli/{ => common}/cache/id.rs (100%) rename src/cli/{ => common}/client.rs (97%) rename src/cli/{ => common}/manager.rs (94%) rename src/cli/{client => common}/msg.rs (100%) rename src/cli/{ => common}/spawner.rs (100%) delete mode 100644 src/config/client/action.rs delete mode 100644 src/config/client/launch.rs delete mode 100644 src/config/client/repl.rs delete mode 100644 src/config/manager.rs create mode 100644 src/options.rs create mode 100644 src/options/common.rs rename src/{config/server/listen.rs => options/common/address.rs} (67%) create mode 100644 src/options/common/cmd.rs rename src/{config/common.rs => options/common/logging.rs} (89%) rename src/{config => options/common}/network.rs (79%) create mode 100644 src/options/common/search.rs create mode 100644 src/options/common/value.rs rename src/{ => options}/config.rs (73%) rename src/{ => options}/config.toml (94%) rename src/{ => options}/config/client.rs (62%) create mode 100644 src/options/config/client/api.rs rename src/{ => options}/config/client/connect.rs (54%) create mode 100644 src/options/config/client/launch.rs rename src/{ => options}/config/generate.rs (76%) create mode 100644 src/options/config/manager.rs rename src/{ => options}/config/server.rs (80%) create mode 100644 src/options/config/server/listen.rs delete mode 100644 src/paths.rs delete mode 100644 tests/cli/action/file_append.rs delete mode 100644 tests/cli/action/file_append_text.rs delete mode 100644 tests/cli/action/file_read.rs delete mode 100644 tests/cli/action/file_read_text.rs delete mode 100644 tests/cli/action/file_write.rs delete mode 100644 tests/cli/action/file_write_text.rs rename tests/cli/{repl => api}/capabilities.rs (81%) rename tests/cli/{repl => api}/copy.rs (78%) rename tests/cli/{repl => api}/dir_create.rs (77%) rename tests/cli/{repl => api}/dir_read.rs (89%) rename tests/cli/{repl => api}/exists.rs (74%) rename tests/cli/{repl => api}/file_append.rs (81%) rename tests/cli/{repl => api}/file_append_text.rs (81%) rename tests/cli/{repl => api}/file_read.rs (75%) rename tests/cli/{repl => api}/file_read_text.rs (75%) rename tests/cli/{repl => api}/file_write.rs (80%) rename tests/cli/{repl => api}/file_write_text.rs (80%) rename tests/cli/{repl => api}/metadata.rs (80%) rename tests/cli/{action => api}/mod.rs (100%) rename tests/cli/{repl => api}/proc_spawn.rs (80%) rename tests/cli/{repl => api}/remove.rs (77%) rename tests/cli/{repl => api}/rename.rs (79%) rename tests/cli/{repl => api}/search.rs (86%) rename tests/cli/{repl => api}/system_info.rs (83%) rename tests/cli/{repl => api}/watch.rs (83%) rename tests/cli/{action => client}/capabilities.rs (96%) rename tests/cli/{action/copy.rs => client/fs_copy.rs} (65%) rename tests/cli/{action/exists.rs => client/fs_exists.rs} (59%) rename tests/cli/{action/dir_create.rs => client/fs_make_dir.rs} (67%) rename tests/cli/{action/metadata.rs => client/fs_metadata.rs} (69%) rename tests/cli/{action/dir_read.rs => client/fs_read_directory.rs} (82%) create mode 100644 tests/cli/client/fs_read_file.rs rename tests/cli/{action/remove.rs => client/fs_remove.rs} (70%) rename tests/cli/{action/rename.rs => client/fs_rename.rs} (71%) rename tests/cli/{action/search.rs => client/fs_search.rs} (90%) rename tests/cli/{action/watch.rs => client/fs_watch.rs} (84%) create mode 100644 tests/cli/client/fs_write.rs create mode 100644 tests/cli/client/mod.rs rename tests/cli/{action/proc_spawn.rs => client/spawn.rs} (73%) rename tests/cli/{action => client}/system_info.rs (81%) rename tests/cli/fixtures/{repl.rs => api.rs} (96%) delete mode 100644 tests/cli/repl/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ae5d9..7090e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.20.0-alpha.4] +## [0.20.0-alpha.5] + +### Added + +- CLI now offers the following new subcommands + - `distant fs copy` is a refactoring of `distant client action copy` + - `distant fs exists` is a refactoring of `distant client action exists` + - `distant fs read` is a refactoring of `distant client action file-read`, + `distant client action file-read-text`, and `distant client action dir-read` + - `distant fs rename` is a refactoring of `distant client action rename` + - `distant fs write` is a refactoring of `distant client action file-write`, + `distant client action file-write-text`, `distant client action file-append`, + - `distant fs make-dir` is a refactoring of `distant client action dir-create` + - `distant fs metadata` is a refactoring of `distant client action metadata` + - `distant fs remove` is a refactoring of `distant client action remove` + - `distant fs search` is a refactoring of `distant client action search` + - `distant fs watch` is a refactoring of `distant client action watch` + - `distant spawn` is a refactoring of `distant client action proc-spawn` + with `distant client lsp` merged in using the `--lsp` flag + - `distant system-info` is a refactoring of `distant client action system-info` + +### Changed + +- CLI subcommands refactored + - `distant client select` moved to `distant manager select` + - `distant client action` moved to `distant action` + - `distant client launch` moved to `distant launch` + - `distant client connect` moved to `distant connect` + - `distant client lsp` moved to `distant lsp` + - `distant client repl` moved to `distant api` + - `distant client shell` moved to `distant shell` + +### Removed + +- `distant-core` crate no longer offers the `clap` feature + +## [0.20.0-alpha.4] - 2023-03-31 ### Added @@ -20,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated a variety of dependencies to latest versions -## [0.20.0-alpha.3] +## [0.20.0-alpha.3] - 2022-11-27 ### Added @@ -375,7 +411,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pending upon full channel and no longer locks up - stdout, stderr, and stdin of `RemoteProcess` no longer cause deadlock -[Unreleased]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.4...HEAD +[Unreleased]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.5...HEAD +[0.20.0-alpha.5]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.4...v0.20.0-alpha.5 [0.20.0-alpha.4]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.3...v0.20.0-alpha.4 [0.20.0-alpha.3]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.2...v0.20.0-alpha.3 [0.20.0-alpha.2]: https://github.com/chipsenkbeil/distant/compare/v0.20.0-alpha.1...v0.20.0-alpha.2 diff --git a/Cargo.lock b/Cargo.lock index 28cefdc..924e6f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "distant" -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" dependencies = [ "anyhow", "assert_cmd", @@ -892,13 +892,12 @@ dependencies = [ [[package]] name = "distant-core" -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" dependencies = [ "assert_fs", "async-trait", "bitflags 2.0.2", "bytes", - "clap", "derive_more", "distant-net", "env_logger", @@ -932,7 +931,7 @@ dependencies = [ [[package]] name = "distant-net" -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" dependencies = [ "async-trait", "bytes", @@ -961,7 +960,7 @@ dependencies = [ [[package]] name = "distant-ssh2" -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" dependencies = [ "anyhow", "assert_fs", diff --git a/Cargo.toml b/Cargo.toml index 3a8608f..9cec499 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "distant" description = "Operate on a remote computer through file and process manipulation" categories = ["command-line-utilities"] keywords = ["cli"] -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" authors = ["Chip Senkbeil "] edition = "2021" homepage = "https://github.com/chipsenkbeil/distant" @@ -32,7 +32,7 @@ clap_complete = "4.2.0" config = { version = "0.13.3", default-features = false, features = ["toml"] } derive_more = { version = "0.99.17", default-features = false, features = ["display", "from", "error", "is_variant"] } dialoguer = { version = "0.10.3", default-features = false } -distant-core = { version = "=0.20.0-alpha.4", path = "distant-core", features = ["clap", "schemars"] } +distant-core = { version = "=0.20.0-alpha.5", path = "distant-core", features = ["schemars"] } directories = "5.0.0" flexi_logger = "0.25.3" indoc = "2.0.1" @@ -54,7 +54,7 @@ winsplit = "0.1.0" whoami = "1.4.0" # Optional native SSH functionality -distant-ssh2 = { version = "=0.20.0-alpha.4", path = "distant-ssh2", default-features = false, features = ["serde"], optional = true } +distant-ssh2 = { version = "=0.20.0-alpha.5", path = "distant-ssh2", default-features = false, features = ["serde"], optional = true } [target.'cfg(unix)'.dependencies] fork = "0.1.21" diff --git a/distant-core/Cargo.toml b/distant-core/Cargo.toml index 90121aa..47b16d1 100644 --- a/distant-core/Cargo.toml +++ b/distant-core/Cargo.toml @@ -3,7 +3,7 @@ name = "distant-core" description = "Core library for distant, enabling operation on a remote computer through file and process manipulation" categories = ["network-programming"] keywords = ["api", "async"] -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" authors = ["Chip Senkbeil "] edition = "2021" homepage = "https://github.com/chipsenkbeil/distant" @@ -19,7 +19,7 @@ async-trait = "0.1.68" bitflags = "2.0.2" bytes = "1.4.0" derive_more = { version = "0.99.17", default-features = false, features = ["as_mut", "as_ref", "deref", "deref_mut", "display", "from", "error", "into", "into_iterator", "is_variant", "try_into"] } -distant-net = { version = "=0.20.0-alpha.4", path = "../distant-net" } +distant-net = { version = "=0.20.0-alpha.5", path = "../distant-net" } futures = "0.3.28" grep = "0.2.11" hex = "0.4.3" @@ -43,7 +43,6 @@ whoami = "1.4.0" winsplit = "0.1.0" # Optional dependencies based on features -clap = { version = "4.2.1", features = ["derive"], optional = true } schemars = { version = "0.8.12", optional = true } [dev-dependencies] diff --git a/distant-core/README.md b/distant-core/README.md index 4b67c3b..16e8d23 100644 --- a/distant-core/README.md +++ b/distant-core/README.md @@ -35,8 +35,6 @@ distant-core = "0.19" Currently, the library supports the following features: -- `clap`: generates [`Clap`](https://github.com/clap-rs) bindings for - `DistantRequestData` (used by cli to expose request actions) - `schemars`: derives the `schemars::JsonSchema` interface on `DistantMsg`, `DistantRequestData`, and `DistantResponseData` data types diff --git a/distant-core/src/client/watcher.rs b/distant-core/src/client/watcher.rs index 3760fe1..5c603cc 100644 --- a/distant-core/src/client/watcher.rs +++ b/distant-core/src/client/watcher.rs @@ -61,8 +61,8 @@ impl Watcher { DistantRequestData::Watch { path: path.to_path_buf(), recursive, - only: only.into_vec(), - except: except.into_vec(), + only: only.into_sorted_vec(), + except: except.into_sorted_vec(), }, ))) .await?; diff --git a/distant-core/src/data.rs b/distant-core/src/data.rs index 5089632..dcc6c7a 100644 --- a/distant-core/src/data.rs +++ b/distant-core/src/data.rs @@ -3,9 +3,6 @@ use serde::{Deserialize, Serialize}; use std::{io, path::PathBuf}; use strum::{AsRefStr, EnumDiscriminants, EnumIter, EnumMessage, EnumString}; -#[cfg(feature = "clap")] -use strum::VariantNames; - mod capabilities; pub use capabilities::*; @@ -15,9 +12,6 @@ pub use change::*; mod cmd; pub use cmd::*; -#[cfg(feature = "clap")] -mod clap_impl; - mod error; pub use error::*; @@ -45,17 +39,6 @@ pub type ProcessId = u32; /// Mapping of environment variables pub type Environment = distant_net::common::Map; -/// Type alias for a vec of bytes -/// -/// NOTE: This only exists to support properly parsing a Vec from an entire string -/// with clap rather than trying to parse a string as a singular u8 -pub type ByteVec = Vec; - -#[cfg(feature = "clap")] -fn parse_byte_vec(src: &str) -> Result { - Ok(src.as_bytes().to_vec()) -} - /// Represents a wrapper around a distant message, supporting single and batch requests #[derive(Clone, Debug, From, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -143,7 +126,6 @@ impl DistantMsg { /// Represents the payload of a request to be performed on the remote machine #[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants, IsVariant, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[cfg_attr(feature = "clap", derive(clap::Subcommand))] #[strum_discriminants(derive( AsRefStr, strum::Display, @@ -164,14 +146,12 @@ impl DistantMsg { #[strum_discriminants(name(CapabilityKind))] #[strum_discriminants(strum(serialize_all = "snake_case"))] #[serde(rename_all = "snake_case", deny_unknown_fields, tag = "type")] -#[cfg_attr(feature = "clap", clap(rename_all = "kebab-case"))] pub enum DistantRequestData { /// Retrieve information about the server's capabilities #[strum_discriminants(strum(message = "Supports retrieving capabilities"))] Capabilities {}, /// Reads a file from the specified path on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["cat"]))] #[strum_discriminants(strum(message = "Supports reading binary file"))] FileRead { /// The path to the file on the remote machine @@ -194,8 +174,9 @@ pub enum DistantRequestData { path: PathBuf, /// Data for server-side writing of content - #[cfg_attr(feature = "clap", clap(value_parser = parse_byte_vec))] - data: ByteVec, + #[serde(with = "serde_bytes")] + #[cfg_attr(feature = "schemars", schemars(with = "Vec"))] + data: Vec, }, /// Writes a file using text instead of bytes, creating it if it does not exist, @@ -216,8 +197,9 @@ pub enum DistantRequestData { path: PathBuf, /// Data for server-side writing of content - #[cfg_attr(feature = "clap", clap(value_parser = parse_byte_vec))] - data: ByteVec, + #[serde(with = "serde_bytes")] + #[cfg_attr(feature = "schemars", schemars(with = "Vec"))] + data: Vec, }, /// Appends text to a file, creating it if it does not exist, on the remote machine @@ -231,7 +213,6 @@ pub enum DistantRequestData { }, /// Reads a directory from the specified path on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["ls"]))] #[strum_discriminants(strum(message = "Supports reading directory"))] DirRead { /// The path to the directory on the remote machine @@ -241,12 +222,10 @@ pub enum DistantRequestData { /// depth and 1 indicating the most immediate children within the /// directory #[serde(default = "one")] - #[cfg_attr(feature = "clap", clap(long, default_value = "1"))] depth: usize, /// Whether or not to return absolute or relative paths #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] absolute: bool, /// Whether or not to canonicalize the resulting paths, meaning @@ -256,7 +235,6 @@ pub enum DistantRequestData { /// Note that the flag absolute must be true to have absolute paths /// returned, even if canonicalize is flagged as true #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] canonicalize: bool, /// Whether or not to include the root directory in the retrieved @@ -265,12 +243,10 @@ pub enum DistantRequestData { /// If included, the root directory will also be a canonicalized, /// absolute path and will not follow any of the other flags #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] include_root: bool, }, /// Creates a directory on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["mkdir"]))] #[strum_discriminants(strum(message = "Supports creating directory"))] DirCreate { /// The path to the directory on the remote machine @@ -278,12 +254,10 @@ pub enum DistantRequestData { /// Whether or not to create all parent directories #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] all: bool, }, /// Removes a file or directory on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["rm"]))] #[strum_discriminants(strum(message = "Supports removing files, directories, and symlinks"))] Remove { /// The path to the file or directory on the remote machine @@ -292,12 +266,10 @@ pub enum DistantRequestData { /// Whether or not to remove all contents within directory if is a directory. /// Does nothing different for files #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] force: bool, }, /// Copies a file or directory on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["cp"]))] #[strum_discriminants(strum(message = "Supports copying files, directories, and symlinks"))] Copy { /// The path to the file or directory on the remote machine @@ -308,7 +280,6 @@ pub enum DistantRequestData { }, /// Moves/renames a file or directory on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["mv"]))] #[strum_discriminants(strum(message = "Supports renaming files, directories, and symlinks"))] Rename { /// The path to the file or directory on the remote machine @@ -327,23 +298,14 @@ pub enum DistantRequestData { /// If true, will recursively watch for changes within directories, othewise /// will only watch for changes immediately within directories #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] recursive: bool, /// Filter to only report back specified changes #[serde(default)] - #[cfg_attr( - feature = "clap", - clap(long, value_parser = clap::builder::PossibleValuesParser::new(ChangeKind::VARIANTS)) - )] only: Vec, /// Filter to report back changes except these specified changes #[serde(default)] - #[cfg_attr( - feature = "clap", - clap(long, value_parser = clap::builder::PossibleValuesParser::new(ChangeKind::VARIANTS)) - )] except: Vec, }, @@ -373,12 +335,10 @@ pub enum DistantRequestData { /// returning the canonical, absolute form of a path with all /// intermediate components normalized and symbolic links resolved #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] canonicalize: bool, /// Whether or not to follow symlinks to determine absolute file type (dir/file) #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] resolve_file_type: bool, }, @@ -386,7 +346,6 @@ pub enum DistantRequestData { #[strum_discriminants(strum(message = "Supports searching filesystem using queries"))] Search { /// Query to perform against the filesystem - #[cfg_attr(feature = "clap", clap(flatten))] query: SearchQuery, }, @@ -400,31 +359,25 @@ pub enum DistantRequestData { }, /// Spawns a new process on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["spawn", "run"]))] #[strum_discriminants(strum(message = "Supports spawning a process"))] ProcSpawn { /// The full command to run including arguments - #[cfg_attr(feature = "clap", clap(flatten))] cmd: Cmd, /// Environment to provide to the remote process #[serde(default)] - #[cfg_attr(feature = "clap", clap(long, default_value_t = Environment::default()))] environment: Environment, /// Alternative current directory for the remote process #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] current_dir: Option, /// If provided, will spawn process in a pty, otherwise spawns directly #[serde(default)] - #[cfg_attr(feature = "clap", clap(long))] pty: Option, }, /// Kills a process running on the remote machine - #[cfg_attr(feature = "clap", clap(visible_aliases = &["kill"]))] #[strum_discriminants(strum(message = "Supports killing a spawned process"))] ProcKill { /// Id of the actively-running process diff --git a/distant-core/src/data/change.rs b/distant-core/src/data/change.rs index 593f5ae..b506529 100644 --- a/distant-core/src/data/change.rs +++ b/distant-core/src/data/change.rs @@ -10,7 +10,7 @@ use std::{ path::PathBuf, str::FromStr, }; -use strum::{EnumString, EnumVariantNames}; +use strum::{EnumString, EnumVariantNames, VariantNames}; /// Change to one or more paths on the filesystem #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -58,8 +58,6 @@ impl From for Change { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", deny_unknown_fields)] #[strum(serialize_all = "snake_case")] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[cfg_attr(feature = "clap", clap(rename_all = "snake_case"))] pub enum ChangeKind { /// Something about a file or directory was accessed, but /// no specific details were known @@ -142,6 +140,16 @@ pub enum ChangeKind { } impl ChangeKind { + /// Returns a list of all variants as str names + pub const fn variants() -> &'static [&'static str] { + Self::VARIANTS + } + + /// Returns a list of all variants as a vec + pub fn all() -> Vec { + ChangeKindSet::all().into_sorted_vec() + } + /// Returns true if the change is a kind of access pub fn is_access_kind(&self) -> bool { self.is_open_access_kind() @@ -375,9 +383,11 @@ impl ChangeKindSet { ChangeKind::Rename | ChangeKind::RenameBoth | ChangeKind::RenameFrom | ChangeKind::RenameTo } - /// Consumes set and returns a vec of the kinds of changes - pub fn into_vec(self) -> Vec { - self.0.into_iter().collect() + /// Consumes set and returns a sorted vec of the kinds of changes + pub fn into_sorted_vec(self) -> Vec { + let mut v = self.0.into_iter().collect::>(); + v.sort(); + v } } diff --git a/distant-core/src/data/clap_impl.rs b/distant-core/src/data/clap_impl.rs deleted file mode 100644 index 0c8930e..0000000 --- a/distant-core/src/data/clap_impl.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::{data::Cmd, DistantMsg, DistantRequestData}; -use clap::{ - error::{Error, ErrorKind}, - Arg, ArgAction, ArgMatches, Args, Command, FromArgMatches, Subcommand, -}; - -impl FromArgMatches for Cmd { - fn from_arg_matches(matches: &ArgMatches) -> Result { - let mut matches = matches.clone(); - Self::from_arg_matches_mut(&mut matches) - } - fn from_arg_matches_mut(matches: &mut ArgMatches) -> Result { - let cmd = matches.get_one::("cmd").ok_or_else(|| { - Error::raw( - ErrorKind::MissingRequiredArgument, - "program must be specified", - ) - })?; - let args: Vec = matches - .get_many::("arg") - .unwrap_or_default() - .map(ToString::to_string) - .collect(); - Ok(Self::new(format!("{cmd} {}", args.join(" ")))) - } - fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> { - let mut matches = matches.clone(); - self.update_from_arg_matches_mut(&mut matches) - } - fn update_from_arg_matches_mut(&mut self, _matches: &mut ArgMatches) -> Result<(), Error> { - Ok(()) - } -} - -impl Args for Cmd { - fn augment_args(cmd: Command) -> Command { - cmd.arg( - Arg::new("cmd") - .required(true) - .value_name("CMD") - .action(ArgAction::Set), - ) - .trailing_var_arg(true) - .arg( - Arg::new("arg") - .value_name("ARGS") - .num_args(1..) - .action(ArgAction::Append), - ) - } - fn augment_args_for_update(cmd: Command) -> Command { - cmd - } -} - -impl FromArgMatches for DistantMsg { - fn from_arg_matches(matches: &ArgMatches) -> Result { - match matches.subcommand() { - Some(("single", args)) => Ok(Self::Single(DistantRequestData::from_arg_matches(args)?)), - Some((_, _)) => Err(Error::raw( - ErrorKind::InvalidSubcommand, - "Valid subcommand is `single`", - )), - None => Err(Error::raw( - ErrorKind::MissingSubcommand, - "Valid subcommand is `single`", - )), - } - } - - fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> { - match matches.subcommand() { - Some(("single", args)) => { - *self = Self::Single(DistantRequestData::from_arg_matches(args)?) - } - Some((_, _)) => { - return Err(Error::raw( - ErrorKind::InvalidSubcommand, - "Valid subcommand is `single`", - )) - } - None => (), - }; - Ok(()) - } -} - -impl Subcommand for DistantMsg { - fn augment_subcommands(cmd: Command) -> Command { - cmd.subcommand(DistantRequestData::augment_subcommands(Command::new( - "single", - ))) - .subcommand_required(true) - } - - fn augment_subcommands_for_update(cmd: Command) -> Command { - cmd.subcommand(DistantRequestData::augment_subcommands(Command::new( - "single", - ))) - .subcommand_required(true) - } - - fn has_subcommand(name: &str) -> bool { - matches!(name, "single") - } -} diff --git a/distant-core/src/data/error.rs b/distant-core/src/data/error.rs index f9e5807..30bbc12 100644 --- a/distant-core/src/data/error.rs +++ b/distant-core/src/data/error.rs @@ -18,6 +18,13 @@ pub struct Error { impl std::error::Error for Error {} +impl Error { + /// Produces an [`io::Error`] from this error. + pub fn to_io_error(&self) -> io::Error { + io::Error::new(self.kind.into(), self.description.to_string()) + } +} + #[cfg(feature = "schemars")] impl Error { pub fn root_schema() -> schemars::schema::RootSchema { diff --git a/distant-core/src/data/search.rs b/distant-core/src/data/search.rs index 1e5353e..88edfba 100644 --- a/distant-core/src/data/search.rs +++ b/distant-core/src/data/search.rs @@ -7,24 +7,19 @@ pub type SearchId = u32; /// Represents a query to perform against the filesystem #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "clap", derive(clap::Args))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct SearchQuery { /// Kind of data to examine using condition - #[cfg_attr(feature = "clap", clap(long, value_enum, default_value_t = SearchQueryTarget::Contents))] pub target: SearchQueryTarget, /// Condition to meet to be considered a match - #[cfg_attr(feature = "clap", clap(name = "pattern"))] pub condition: SearchQueryCondition, /// Paths in which to perform the query - #[cfg_attr(feature = "clap", clap(default_value = "."))] pub paths: Vec, /// Options to apply to the query #[serde(default)] - #[cfg_attr(feature = "clap", clap(flatten))] pub options: SearchQueryOptions, } @@ -46,9 +41,7 @@ impl FromStr for SearchQuery { /// Kind of data to examine using conditions #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[cfg_attr(feature = "clap", clap(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] pub enum SearchQueryTarget { /// Checks path of file, directory, or symlink @@ -176,32 +169,26 @@ impl FromStr for SearchQueryCondition { /// Options associated with a search query #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "clap", derive(clap::Args))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct SearchQueryOptions { /// Restrict search to only these file types (otherwise all are allowed) - #[cfg_attr(feature = "clap", clap(skip))] #[serde(default)] pub allowed_file_types: HashSet, /// Regex to use to filter paths being searched to only those that match the include condition - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub include: Option, /// Regex to use to filter paths being searched to only those that do not match the exclude /// condition - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub exclude: Option, /// Search should follow symbolic links - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub follow_symbolic_links: bool, /// Maximum results to return before stopping the query - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub limit: Option, @@ -213,13 +200,11 @@ pub struct SearchQueryOptions { /// /// Note that this will not simply filter the entries of the iterator, but it will actually /// avoid descending into directories when the depth is exceeded. - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub max_depth: Option, /// Amount of results to batch before sending back excluding final submission that will always /// include the remaining results even if less than pagination request - #[cfg_attr(feature = "clap", clap(long))] #[serde(default)] pub pagination: Option, } diff --git a/distant-net/Cargo.toml b/distant-net/Cargo.toml index a18d829..cca4fa8 100644 --- a/distant-net/Cargo.toml +++ b/distant-net/Cargo.toml @@ -3,7 +3,7 @@ name = "distant-net" description = "Network library for distant, providing implementations to support client/server architecture" categories = ["network-programming"] keywords = ["api", "async"] -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" authors = ["Chip Senkbeil "] edition = "2021" homepage = "https://github.com/chipsenkbeil/distant" diff --git a/distant-net/src/client.rs b/distant-net/src/client.rs index a99c5a1..7b7faac 100644 --- a/distant-net/src/client.rs +++ b/distant-net/src/client.rs @@ -302,17 +302,32 @@ impl UntypedClient { Ok(response) => { if log_enabled!(Level::Trace) { trace!( - "Client receiving {}", - String::from_utf8_lossy(&response.to_bytes()) - .to_string() + "Client receiving (id:{} | origin: {}): {}", + response.id, + response.origin_id, + String::from_utf8_lossy(&response.payload).to_string() ); } + + // For trace-level logging, we need to clone the id and + // origin id before passing the response ownership to + // be delivered elsewhere + let (id, origin_id) = if log_enabled!(Level::Trace) { + (response.id.to_string(), response.origin_id.to_string()) + } else { + (String::new(), String::new()) + }; + // Try to send response to appropriate mailbox // TODO: This will block if full... is that a problem? - // TODO: How should we handle false response? Did logging in past - post_office + if post_office .deliver_untyped_response(response.into_owned()) - .await; + .await + { + trace!("Client delivered response {id} to {origin_id}"); + } else { + trace!("Client dropped response {id} to {origin_id}"); + } } Err(x) => { error!("Invalid response: {x}"); diff --git a/distant-net/src/common/map.rs b/distant-net/src/common/map.rs index 98811e1..6c0acbb 100644 --- a/distant-net/src/common/map.rs +++ b/distant-net/src/common/map.rs @@ -2,6 +2,7 @@ use crate::common::utils::{deserialize_from_str, serialize_to_str}; use derive_more::{Display, Error, From, IntoIterator}; use serde::{de::Deserializer, ser::Serializer, Deserialize, Serialize}; use std::{ + collections::hash_map::Entry, collections::HashMap, fmt, ops::{Deref, DerefMut}, @@ -21,6 +22,57 @@ impl Map { pub fn into_map(self) -> HashMap { self.0 } + + /// Merges this map with another map. When there is a conflict + /// where both maps have the same key, the other map's key is + /// used UNLESS the `keep` flag is set to true, where this + /// map's key will be used instead. + /// + /// ### Examples + /// + /// Keeping the value will result in `x` retaining the `a` key's original value: + /// + /// ```rust + /// use distant_net::map; + /// + /// let mut x = map!("a" -> "hello", "b" -> "world"); + /// let y = map!("a" -> "foo", "c" -> "bar"); + /// + /// x.merge(y, /* keep */ true); + /// + /// assert_eq!(x, map!("a" -> "hello", "b" -> "world", "c" -> "bar")); + /// ``` + /// + /// Not keeping the value will result in `x` replacing the `a` key's value: + /// + /// ```rust + /// use distant_net::map; + /// + /// let mut x = map!("a" -> "hello", "b" -> "world"); + /// let y = map!("a" -> "foo", "c" -> "bar"); + /// + /// x.merge(y, /* keep */ false); + /// + /// assert_eq!(x, map!("a" -> "foo", "b" -> "world", "c" -> "bar")); + /// ``` + pub fn merge(&mut self, other: Map, keep: bool) { + for (key, value) in other { + match self.0.entry(key) { + // If we want to keep the original value, skip replacing it + Entry::Occupied(_) if keep => continue, + + // If we want to use the other value, replace it + Entry::Occupied(mut x) => { + x.insert(value); + } + + // Otherwise, nothing found, so insert it + Entry::Vacant(x) => { + x.insert(value); + } + } + } + } } #[cfg(feature = "schemars")] diff --git a/distant-net/src/common/port.rs b/distant-net/src/common/port.rs index 964f157..09066d2 100644 --- a/distant-net/src/common/port.rs +++ b/distant-net/src/common/port.rs @@ -20,6 +20,21 @@ pub struct PortRange { } impl PortRange { + /// Represents an ephemeral port as defined using the port range of 0. + pub const EPHEMERAL: Self = Self { + start: 0, + end: None, + }; + + /// Creates a port range targeting a single `port`. + #[inline] + pub fn single(port: u16) -> Self { + Self { + start: port, + end: None, + } + } + /// Builds a collection of `SocketAddr` instances from the port range and given ip address pub fn make_socket_addrs(&self, addr: impl Into) -> Vec { let mut socket_addrs = Vec::new(); @@ -31,14 +46,17 @@ impl PortRange { socket_addrs } + + /// Returns true if port range represents the ephemeral port. + #[inline] + pub fn is_ephemeral(&self) -> bool { + self == &Self::EPHEMERAL + } } impl From for PortRange { fn from(port: u16) -> Self { - Self { - start: port, - end: None, - } + Self::single(port) } } diff --git a/distant-ssh2/Cargo.toml b/distant-ssh2/Cargo.toml index 9b56c70..1b5f2e0 100644 --- a/distant-ssh2/Cargo.toml +++ b/distant-ssh2/Cargo.toml @@ -2,7 +2,7 @@ name = "distant-ssh2" description = "Library to enable native ssh-2 protocol for use with distant sessions" categories = ["network-programming"] -version = "0.20.0-alpha.4" +version = "0.20.0-alpha.5" authors = ["Chip Senkbeil "] edition = "2021" homepage = "https://github.com/chipsenkbeil/distant" @@ -20,7 +20,7 @@ async-compat = "0.2.1" async-once-cell = "0.4.4" async-trait = "0.1.68" derive_more = { version = "0.99.17", default-features = false, features = ["display", "error"] } -distant-core = { version = "=0.20.0-alpha.4", path = "../distant-core" } +distant-core = { version = "=0.20.0-alpha.5", path = "../distant-core" } futures = "0.3.28" hex = "0.4.3" log = "0.4.17" diff --git a/src/cli.rs b/src/cli.rs index bc54480..683f904 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,44 +1,21 @@ -use crate::{ - config::{CommonConfig, Config}, - paths, CliResult, -}; -use clap::Parser; -use std::{ffi::OsString, path::PathBuf}; +use crate::options::DistantSubcommand; +use crate::{CliResult, Options}; +use std::ffi::OsString; -mod cache; -mod client; mod commands; -mod manager; -mod spawner; +mod common; -pub(crate) use cache::Cache; -pub(crate) use client::Client; -use commands::DistantSubcommand; -pub(crate) use manager::Manager; +pub(crate) use common::Cache; +pub(crate) use common::Client; +pub(crate) use common::Manager; #[cfg_attr(unix, allow(unused_imports))] -pub(crate) use spawner::Spawner; +pub(crate) use common::Spawner; /// Represents the primary CLI entrypoint +#[derive(Debug)] pub struct Cli { - common: CommonConfig, - command: DistantSubcommand, - config: Config, -} - -#[derive(Debug, Parser)] -#[clap(author, version, about)] -#[clap(name = "distant")] -struct Opt { - #[clap(flatten)] - common: CommonConfig, - - /// Configuration file to load instead of the default paths - #[clap(short = 'c', long = "config", global = true, value_parser)] - config_path: Option, - - #[clap(subcommand)] - command: DistantSubcommand, + options: Options, } impl Cli { @@ -53,54 +30,8 @@ impl Cli { I: IntoIterator, T: Into + Clone, { - // NOTE: We should NOT provide context here as printing help and version are both - // reported this way and providing context puts them under the "caused by" section - let Opt { - mut common, - config_path, - command, - } = Opt::try_parse_from(args)?; - - // Try to load a configuration file, defaulting if no config file is found - let config = Config::load_multi(config_path)?; - - // Extract the common config from our config file - let config_common = match &command { - DistantSubcommand::Client(_) => config.client.common.clone(), - DistantSubcommand::Generate(_) => config.generate.common.clone(), - DistantSubcommand::Manager(_) => config.manager.common.clone(), - DistantSubcommand::Server(_) => config.server.common.clone(), - }; - - // Blend common configs together - common.log_file = common.log_file.or(config_common.log_file); - common.log_level = common.log_level.or(config_common.log_level); - - // Assign the appropriate log file based on client/manager/server - if common.log_file.is_none() { - // NOTE: We assume that any of these commands will log to the user-specific path - // and that services that run manager will explicitly override the - // log file path - common.log_file = Some(match &command { - DistantSubcommand::Client(_) => paths::user::CLIENT_LOG_FILE_PATH.to_path_buf(), - DistantSubcommand::Server(_) => paths::user::SERVER_LOG_FILE_PATH.to_path_buf(), - DistantSubcommand::Generate(_) => paths::user::GENERATE_LOG_FILE_PATH.to_path_buf(), - - // If we are listening as a manager, then we want to log to a manager-specific file - DistantSubcommand::Manager(cmd) if cmd.is_listen() => { - paths::user::MANAGER_LOG_FILE_PATH.to_path_buf() - } - - // Otherwise, if we are performing some operation as a client talking to the - // manager, then we want to log to the client file - DistantSubcommand::Manager(_) => paths::user::CLIENT_LOG_FILE_PATH.to_path_buf(), - }); - } - Ok(Cli { - common, - command, - config, + options: Options::load_from(args)?, }) } @@ -117,7 +48,8 @@ impl Cli { for module in modules { builder.module( module, - self.common + self.options + .logging .log_level .unwrap_or_default() .to_log_level_filter(), @@ -130,7 +62,7 @@ impl Cli { // Assign our log output to a file // NOTE: We can unwrap here as we assign the log file earlier let logger = logger.log_to_file( - FileSpec::try_from(self.common.log_file.as_ref().unwrap()) + FileSpec::try_from(self.options.logging.log_file.as_ref().unwrap()) .expect("Failed to create log file spec"), ); @@ -139,7 +71,7 @@ impl Cli { #[cfg(windows)] pub fn is_manager_listen_command(&self) -> bool { - match &self.command { + match &self.options.command { DistantSubcommand::Manager(cmd) => cmd.is_listen(), _ => false, } @@ -147,11 +79,11 @@ impl Cli { /// Runs the CLI pub fn run(self) -> CliResult { - match self.command { - DistantSubcommand::Client(cmd) => cmd.run(self.config.client), - DistantSubcommand::Generate(cmd) => cmd.run(self.config.generate), - DistantSubcommand::Manager(cmd) => cmd.run(self.config.manager), - DistantSubcommand::Server(cmd) => cmd.run(self.config.server), + match self.options.command { + DistantSubcommand::Client(cmd) => commands::client::run(cmd), + DistantSubcommand::Generate(cmd) => commands::generate::run(cmd), + DistantSubcommand::Manager(cmd) => commands::manager::run(cmd), + DistantSubcommand::Server(cmd) => commands::server::run(cmd), } } } diff --git a/src/cli/commands.rs b/src/cli/commands.rs index ebf4757..2532f07 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,26 +1,5 @@ -use clap::Subcommand; - -mod client; -mod generate; -mod manager; -mod server; - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Subcommand)] -pub enum DistantSubcommand { - /// Perform client commands - #[clap(subcommand)] - Client(client::ClientSubcommand), - - /// Perform manager commands - #[clap(subcommand)] - Manager(manager::ManagerSubcommand), - - /// Perform server commands - #[clap(subcommand)] - Server(server::ServerSubcommand), - - /// Perform generation commands - #[clap(subcommand)] - Generate(generate::GenerateSubcommand), -} +pub mod client; +mod common; +pub mod generate; +pub mod manager; +pub mod server; diff --git a/src/cli/commands/client.rs b/src/cli/commands/client.rs index a2e6653..cf6226a 100644 --- a/src/cli/commands/client.rs +++ b/src/cli/commands/client.rs @@ -1,907 +1,1125 @@ -use crate::{ - cli::{ - client::{JsonAuthHandler, MsgReceiver, MsgSender, PromptAuthHandler}, - Cache, Client, - }, - config::{ - ClientActionConfig, ClientConfig, ClientConnectConfig, ClientLaunchConfig, - ClientReplConfig, NetworkConfig, - }, - paths::user::CACHE_FILE_PATH_STR, - CliError, CliResult, +use crate::cli::common::{ + Cache, Client, JsonAuthHandler, MsgReceiver, MsgSender, PromptAuthHandler, }; +use crate::constants::MAX_PIPE_CHUNK_SIZE; +use crate::options::{ClientFileSystemSubcommand, ClientSubcommand, Format, NetworkSettings}; +use crate::{CliError, CliResult}; use anyhow::Context; -use clap::{Subcommand, ValueHint}; -use dialoguer::{console::Term, theme::ColorfulTheme, Select}; -use distant_core::{ - data::{ChangeKindSet, Environment}, - net::common::{ConnectionId, Destination, Host, Map, Request, Response}, - net::manager::ManagerClient, - DistantMsg, DistantRequestData, DistantResponseData, RemoteCommand, Searcher, Watcher, -}; +use distant_core::data::{ChangeKindSet, FileType, SearchQuery, SystemInfo}; +use distant_core::net::common::{ConnectionId, Host, Map, Request, Response}; +use distant_core::net::manager::ManagerClient; +use distant_core::{DistantChannel, DistantChannelExt, Watcher}; +use distant_core::{DistantMsg, DistantRequestData, DistantResponseData, RemoteCommand, Searcher}; use log::*; -use serde_json::{json, Value}; -use std::{ - io, - path::{Path, PathBuf}, - time::Duration, -}; +use serde_json::json; +use std::io::Write; +use std::{io, path::Path, time::Duration}; +use tabled::{object::Rows, style::Style, Alignment, Disable, Modify, Table, Tabled}; use tokio::sync::mpsc; -mod buf; -mod format; -mod link; mod lsp; mod shell; -mod stdin; -pub use format::Format; -use format::Formatter; -use link::RemoteProcessLink; +use super::common::{Formatter, RemoteProcessLink}; use lsp::Lsp; use shell::Shell; const SLEEP_DURATION: Duration = Duration::from_millis(1); -#[derive(Debug, Subcommand)] -pub enum ClientSubcommand { - /// Performs some action on a remote machine - Action { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - #[clap(flatten)] - config: ClientActionConfig, - - /// Specify a connection being managed - #[clap(long)] - connection: Option, - - #[clap(flatten)] - network: NetworkConfig, - - #[clap(subcommand)] - request: DistantRequestData, - }, - - /// Requests that active manager connects to the server at the specified destination - Connect { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - #[clap(flatten)] - config: ClientConnectConfig, - - #[clap(flatten)] - network: NetworkConfig, - - #[clap(short, long, default_value_t, value_enum)] - format: Format, - - destination: Box, - }, - - /// Launches the server-portion of the binary on a remote machine - Launch { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - #[clap(flatten)] - config: ClientLaunchConfig, - - #[clap(flatten)] - network: NetworkConfig, - - #[clap(short, long, default_value_t, value_enum)] - format: Format, - - destination: Box, - }, - - /// Specialized treatment of running a remote LSP process - Lsp { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - /// Specify a connection being managed - #[clap(long)] - connection: Option, - - #[clap(flatten)] - network: NetworkConfig, - - /// Alternative current directory for the remote process - #[clap(long)] - current_dir: Option, - - /// If provided, will run LSP in a pty - #[clap(long)] - pty: bool, - - cmd: String, - }, - - /// Runs actions in a read-eval-print loop - Repl { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - #[clap(flatten)] - config: ClientReplConfig, - - /// Specify a connection being managed - #[clap(long)] - connection: Option, - - #[clap(flatten)] - network: NetworkConfig, - - /// Format used for input into and output from the repl - #[clap(short, long, default_value_t, value_enum)] - format: Format, - }, - - /// Select the active connection - Select { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - /// Connection to use, otherwise will prompt to select - connection: Option, - - #[clap(short, long, default_value_t, value_enum)] - format: Format, - - #[clap(flatten)] - network: NetworkConfig, - }, - - /// Specialized treatment of running a remote shell process - Shell { - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - - /// Specify a connection being managed - #[clap(long)] - connection: Option, - - #[clap(flatten)] - network: NetworkConfig, - - /// Alternative current directory for the remote process - #[clap(long)] - current_dir: Option, - - /// Environment variables to provide to the shell - #[clap(long, default_value_t)] - environment: Environment, - - /// Optional command to run instead of $SHELL - cmd: Option, - }, +pub fn run(cmd: ClientSubcommand) -> CliResult { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async_run(cmd)) } -impl ClientSubcommand { - pub fn run(self, config: ClientConfig) -> CliResult { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(Self::async_run(self, config)) - } +async fn read_cache(path: &Path) -> Cache { + // If we get an error, just default anyway + Cache::read_from_disk_or_default(path.to_path_buf()) + .await + .unwrap_or_else(|_| Cache::new(path.to_path_buf())) +} + +async fn async_run(cmd: ClientSubcommand) -> CliResult { + match cmd { + ClientSubcommand::Capabilities { + cache, + connection, + format, + network, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening raw channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| { + format!("Failed to open raw channel to connection {connection_id}") + })?; + + debug!("Retrieving capabilities"); + let capabilities = channel + .into_client() + .into_channel() + .capabilities() + .await + .with_context(|| { + format!("Failed to retrieve capabilities using connection {connection_id}") + })?; + + match format { + Format::Shell => { + #[derive(Tabled)] + struct EntryRow { + kind: String, + description: String, + } - fn cache_path(&self) -> &Path { - match self { - Self::Action { cache, .. } => cache.as_path(), - Self::Connect { cache, .. } => cache.as_path(), - Self::Launch { cache, .. } => cache.as_path(), - Self::Lsp { cache, .. } => cache.as_path(), - Self::Repl { cache, .. } => cache.as_path(), - Self::Select { cache, .. } => cache.as_path(), - Self::Shell { cache, .. } => cache.as_path(), + let table = Table::new(capabilities.into_sorted_vec().into_iter().map(|cap| { + EntryRow { + kind: cap.kind, + description: cap.description, + } + })) + .with(Style::ascii()) + .with(Modify::new(Rows::new(..)).with(Alignment::left())) + .to_string(); + + println!("{table}"); + } + Format::Json => println!("{}", serde_json::to_string(&capabilities).unwrap()), + } } - } + ClientSubcommand::Connect { + cache, + destination, + format, + network, + options, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + // Trigger our manager to connect to the launched server + debug!("Connecting to server at {} with {}", destination, options); + let id = match format { + Format::Shell => client + .connect(*destination, options, PromptAuthHandler::new()) + .await + .context("Failed to connect to server")?, + Format::Json => client + .connect(*destination, options, JsonAuthHandler::default()) + .await + .context("Failed to connect to server")?, + }; + + // Mark the server's id as the new default + debug!("Updating selected connection id in cache to {}", id); + let mut cache = read_cache(&cache).await; + *cache.data.selected = id; + cache.write_to_disk().await?; + + match format { + Format::Shell => println!("{id}"), + Format::Json => println!( + "{}", + serde_json::to_string(&json!({ + "type": "connected", + "id": id, + })) + .unwrap() + ), + } + } + ClientSubcommand::Launch { + cache, + mut destination, + distant_args, + distant_bin, + distant_bind_server, + format, + network, + mut options, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + // Grab the host we are connecting to for later use + let host = destination.host.to_string(); + + // If we have no scheme on launch, we need to fill it in with something + // + // TODO: Can we have the server support this instead of the client? Right now, the + // server is failing because it cannot parse //localhost/ as it fails with + // an invalid IPv4 or registered name character error on host + if destination.scheme.is_none() { + destination.scheme = Some("ssh".to_string()); + } - async fn async_run(self, config: ClientConfig) -> CliResult { - // If we get an error, just default anyway - let mut cache = Cache::read_from_disk_or_default(self.cache_path().to_path_buf()) - .await - .unwrap_or_else(|_| Cache::new(self.cache_path().to_path_buf())); - - match self { - Self::Action { - config: action_config, - connection, - network, - request, - .. - } => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = Client::new(network) - .using_prompt_auth_handler() - .connect() + // TODO: Handle this more cleanly + if let Some(x) = distant_args { + options.insert("distant.args".to_string(), x); + } + if let Some(x) = distant_bin { + options.insert("distant.bin".to_string(), x); + } + if let Some(x) = distant_bind_server { + options.insert("distant.bind_server".to_string(), x.to_string()); + } + + // Start the server using our manager + debug!("Launching server at {} with {}", destination, options); + let mut new_destination = match format { + Format::Shell => client + .launch(*destination, options, PromptAuthHandler::new()) + .await + .context("Failed to launch server")?, + Format::Json => client + .launch(*destination, options, JsonAuthHandler::default()) .await - .context("Failed to connect to manager")?; + .context("Failed to launch server")?, + }; - let connection_id = - use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + // Update the new destination with our previously-used host if the + // new host is not globally-accessible + if !new_destination.host.is_global() { + trace!( + "Updating host to {:?} from non-global {:?}", + host, + new_destination.host.to_string() + ); + new_destination.host = host + .parse::() + .map_err(|x| anyhow::anyhow!(x)) + .context("Failed to replace host")?; + } else { + trace!("Host {:?} is global", new_destination.host.to_string()); + } - debug!("Opening channel to connection {}", connection_id); - let channel = client - .open_raw_channel(connection_id) + // Trigger our manager to connect to the launched server + debug!("Connecting to server at {}", new_destination); + let id = match format { + Format::Shell => client + .connect(new_destination, Map::new(), PromptAuthHandler::new()) .await - .with_context(|| { - format!("Failed to open channel to connection {connection_id}") - })?; + .context("Failed to connect to server")?, + Format::Json => client + .connect(new_destination, Map::new(), JsonAuthHandler::default()) + .await + .context("Failed to connect to server")?, + }; + + // Mark the server's id as the new default + debug!("Updating selected connection id in cache to {}", id); + let mut cache = read_cache(&cache).await; + *cache.data.selected = id; + cache.write_to_disk().await?; + + match format { + Format::Shell => println!("{id}"), + Format::Json => println!( + "{}", + serde_json::to_string(&json!({ + "type": "launched", + "id": id, + })) + .unwrap() + ), + } + } + ClientSubcommand::Api { + cache, + connection, + network, + timeout, + } => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_json_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - let timeout = match action_config.timeout.or(config.action.timeout) { - Some(timeout) if timeout >= f32::EPSILON => Some(timeout), - _ => None, - }; + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - debug!( - "Timeout configured to be {}", - match timeout { - Some(secs) => format!("{secs}s"), - None => "none".to_string(), - } - ); + let timeout = match timeout { + Some(timeout) if timeout >= f32::EPSILON => Some(timeout), + _ => None, + }; - let mut formatter = Formatter::shell(); - - debug!("Sending request {:?}", request); - match request { - DistantRequestData::ProcSpawn { - cmd, - environment, - current_dir, - pty, - } => { - debug!("Special request spawning {:?}", cmd); - let mut proc = RemoteCommand::new() - .environment(environment) - .current_dir(current_dir) - .pty(pty) - .spawn(channel.into_client().into_channel(), cmd.as_str()) - .await - .with_context(|| format!("Failed to spawn {cmd}"))?; - - // Now, map the remote process' stdin/stdout/stderr to our own process - let link = RemoteProcessLink::from_remote_pipes( - proc.stdin.take(), - proc.stdout.take().unwrap(), - proc.stderr.take().unwrap(), - ); - - let status = proc.wait().await.context("Failed to wait for process")?; - - // Shut down our link - link.shutdown().await; - - if !status.success { - if let Some(code) = status.code { - return Err(CliError::Exit(code as u8)); - } else { - return Err(CliError::FAILURE); + debug!("Opening raw channel to connection {}", connection_id); + let mut channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| { + format!("Failed to open raw channel to connection {connection_id}") + })?; + + debug!( + "Timeout configured to be {}", + match timeout { + Some(secs) => format!("{secs}s"), + None => "none".to_string(), + } + ); + + debug!("Starting api tasks"); + let (msg_tx, mut msg_rx) = mpsc::channel(1); + let request_task = tokio::spawn(async move { + let mut rx = + MsgReceiver::from_stdin().into_rx::>>(); + loop { + match rx.recv().await { + Some(Ok(request)) => { + if let Err(x) = msg_tx.send(request).await { + error!("Failed to forward request: {x}"); + break; } } - } - DistantRequestData::Search { query } => { - debug!("Special request creating searcher for {:?}", query); - let mut searcher = - Searcher::search(channel.into_client().into_channel(), query) - .await - .context("Failed to start search")?; - - // Continue to receive and process matches - while let Some(m) = searcher.next().await { - // TODO: Provide a cleaner way to print just a match - let res = Response::new( - "".to_string(), - DistantMsg::Single(DistantResponseData::SearchResults { - id: 0, - matches: vec![m], - }), - ); - - formatter.print(res).context("Failed to print match")?; + Some(Err(x)) => error!("{}", x), + None => { + debug!("Shutting down repl"); + break; } } - DistantRequestData::Watch { - path, - recursive, - only, - except, - } => { - debug!("Special request creating watcher for {:?}", path); - let mut watcher = Watcher::watch( - channel.into_client().into_channel(), - path.as_path(), - recursive, - only.into_iter().collect::(), - except.into_iter().collect::(), - ) - .await - .with_context(|| format!("Failed to watch {path:?}"))?; - - // Continue to receive and process changes - while let Some(change) = watcher.next().await { - // TODO: Provide a cleaner way to print just a change - let res = Response::new( - "".to_string(), - DistantMsg::Single(DistantResponseData::Changed(change)), - ); - - formatter.print(res).context("Failed to print change")?; + } + io::Result::Ok(()) + }); + let channel_task = tokio::task::spawn(async move { + let tx = MsgSender::from_stdout(); + + loop { + let ready = channel.readable_or_writeable().await?; + + // Keep track of whether we read or wrote anything + let mut read_blocked = !ready.is_readable(); + let mut write_blocked = !ready.is_writable(); + + if ready.is_readable() { + match channel + .try_read_frame_as::>>() + { + Ok(Some(msg)) => tx.send_blocking(&msg)?, + Ok(None) => break, + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + read_blocked = true; + } + Err(x) => return Err(x), } } - request => { - let response = channel - .into_client() - .into_channel() - .send_timeout( - DistantMsg::Single(request), - timeout.map(Duration::from_secs_f32), - ) - .await - .context("Failed to send request")?; - - debug!("Got response {:?}", response); - - // NOTE: We expect a single response, and if that is an error then - // we want to pass that error up the stack - let id = response.id; - let origin_id = response.origin_id; - match response.payload { - DistantMsg::Single(DistantResponseData::Error(x)) => { - return Err(CliError::Error(anyhow::anyhow!(x))); + + if ready.is_writable() { + if let Ok(msg) = msg_rx.try_recv() { + match channel.try_write_frame_for(&msg) { + Ok(_) => (), + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + write_blocked = true + } + Err(x) => return Err(x), + } + } else { + match channel.try_flush() { + Ok(0) => write_blocked = true, + Ok(_) => (), + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + write_blocked = true + } + Err(x) => { + error!("Failed to flush outgoing data: {x}"); + } } - payload => formatter - .print(Response { - id, - origin_id, - payload, - }) - .context("Failed to print response")?, } } - } - } - Self::Connect { - config: connect_config, - network, - format, - destination, - .. - } => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = match format { - Format::Shell => Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - Format::Json => Client::new(network) - .using_json_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - }; - - // Merge our connect configs, overwriting anything in the config file with our cli - // arguments - let mut options = Map::from(config.connect); - options.extend(Map::from(connect_config).into_map()); - - // Trigger our manager to connect to the launched server - debug!("Connecting to server at {} with {}", destination, options); - let id = match format { - Format::Shell => client - .connect(*destination, options, PromptAuthHandler::new()) - .await - .context("Failed to connect to server")?, - Format::Json => client - .connect(*destination, options, JsonAuthHandler::default()) - .await - .context("Failed to connect to server")?, - }; - - // Mark the server's id as the new default - debug!("Updating selected connection id in cache to {}", id); - *cache.data.selected = id; - cache.write_to_disk().await?; - match format { - Format::Shell => println!("{id}"), - Format::Json => println!( - "{}", - serde_json::to_string(&json!({ - "type": "connected", - "id": id, - })) - .unwrap() - ), + // If we did not read or write anything, sleep a bit to offload CPU usage + if read_blocked && write_blocked { + tokio::time::sleep(SLEEP_DURATION).await; + } } + + io::Result::Ok(()) + }); + + let (r1, r2) = tokio::join!(request_task, channel_task); + match r1 { + Err(x) => error!("{}", x), + Ok(Err(x)) => error!("{}", x), + _ => (), + } + match r2 { + Err(x) => error!("{}", x), + Ok(Err(x)) => error!("{}", x), + _ => (), } - Self::Launch { - config: launch_config, - network, - format, - mut destination, - .. - } => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = match format { - Format::Shell => Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - Format::Json => Client::new(network) - .using_json_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - }; - - // Merge our launch configs, overwriting anything in the config file - // with our cli arguments - let mut options = Map::from(config.launch); - options.extend(Map::from(launch_config).into_map()); - - // Grab the host we are connecting to for later use - let host = destination.host.to_string(); - - // If we have no scheme on launch, we need to fill it in with something - // - // TODO: Can we have the server support this instead of the client? Right now, the - // server is failing because it cannot parse //localhost/ as it fails with - // an invalid IPv4 or registered name character error on host - if destination.scheme.is_none() { - destination.scheme = Some("ssh".to_string()); - } - // Start the server using our manager - debug!("Launching server at {} with {}", destination, options); - let mut new_destination = match format { - Format::Shell => client - .launch(*destination, options, PromptAuthHandler::new()) - .await - .context("Failed to launch server")?, - Format::Json => client - .launch(*destination, options, JsonAuthHandler::default()) - .await - .context("Failed to launch server")?, - }; - - // Update the new destination with our previously-used host if the - // new host is not globally-accessible - if !new_destination.host.is_global() { - trace!( - "Updating host to {:?} from non-global {:?}", - host, - new_destination.host.to_string() - ); - new_destination.host = host - .parse::() - .map_err(|x| anyhow::anyhow!(x)) - .context("Failed to replace host")?; - } else { - trace!("Host {:?} is global", new_destination.host.to_string()); - } + debug!("Shutting down repl"); + } + ClientSubcommand::Shell { + cache, + cmd, + connection, + current_dir, + environment, + network, + } => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - // Trigger our manager to connect to the launched server - debug!("Connecting to server at {}", new_destination); - let id = match format { - Format::Shell => client - .connect(new_destination, Map::new(), PromptAuthHandler::new()) - .await - .context("Failed to connect to server")?, - Format::Json => client - .connect(new_destination, Map::new(), JsonAuthHandler::default()) - .await - .context("Failed to connect to server")?, - }; - - // Mark the server's id as the new default - debug!("Updating selected connection id in cache to {}", id); - *cache.data.selected = id; - cache.write_to_disk().await?; + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - match format { - Format::Shell => println!("{id}"), - Format::Json => println!( - "{}", - serde_json::to_string(&json!({ - "type": "launched", - "id": id, - })) - .unwrap() - ), + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + // Convert cmd into string + let cmd = cmd.map(|cmd| cmd.join(" ")); + + debug!( + "Spawning shell (environment = {:?}): {}", + environment, + cmd.as_deref().unwrap_or(r"$SHELL") + ); + Shell::new(channel.into_client().into_channel()) + .spawn(cmd, environment, current_dir, MAX_PIPE_CHUNK_SIZE) + .await?; + } + ClientSubcommand::Spawn { + cache, + connection, + cmd, + current_dir, + environment, + lsp, + pty, + network, + } => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + // Convert cmd into string + let cmd = cmd.join(" "); + + if lsp { + debug!( + "Spawning LSP server (pty = {}, cwd = {:?}): {}", + pty, current_dir, cmd + ); + Lsp::new(channel.into_client().into_channel()) + .spawn(cmd, current_dir, pty, MAX_PIPE_CHUNK_SIZE) + .await?; + } else if pty { + debug!( + "Spawning pty process (environment = {:?}, cwd = {:?}): {}", + environment, current_dir, cmd + ); + Shell::new(channel.into_client().into_channel()) + .spawn(cmd, environment, current_dir, MAX_PIPE_CHUNK_SIZE) + .await?; + } else { + debug!( + "Spawning regular process (environment = {:?}, cwd = {:?}): {}", + environment, current_dir, cmd + ); + let mut proc = RemoteCommand::new() + .environment(environment) + .current_dir(current_dir) + .pty(None) + .spawn(channel.into_client().into_channel(), &cmd) + .await + .with_context(|| format!("Failed to spawn {cmd}"))?; + + // Now, map the remote process' stdin/stdout/stderr to our own process + let link = RemoteProcessLink::from_remote_pipes( + proc.stdin.take(), + proc.stdout.take().unwrap(), + proc.stderr.take().unwrap(), + MAX_PIPE_CHUNK_SIZE, + ); + + let status = proc.wait().await.context("Failed to wait for process")?; + + // Shut down our link + link.shutdown().await; + + if !status.success { + if let Some(code) = status.code { + return Err(CliError::Exit(code as u8)); + } else { + return Err(CliError::FAILURE); + } } } - Self::Lsp { - connection, - network, + } + ClientSubcommand::SystemInfo { + cache, + connection, + network, + } => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + debug!("Retrieving system information"); + let SystemInfo { + family, + os, + arch, current_dir, - pty, - cmd, - .. - } => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?; + main_separator, + username, + shell, + } = channel + .into_client() + .into_channel() + .system_info() + .await + .with_context(|| { + format!( + "Failed to retrieve system information using connection {connection_id}" + ) + })?; + + let mut out = std::io::stdout(); + + out.write_all( + &format!( + concat!( + "Family: {:?}\n", + "Operating System: {:?}\n", + "Arch: {:?}\n", + "Cwd: {:?}\n", + "Path Sep: {:?}\n", + "Username: {:?}\n", + "Shell: {:?}" + ), + family, os, arch, current_dir, main_separator, username, shell + ) + .into_bytes(), + ) + .context("Failed to write system information to stdout")?; + out.flush().context("Failed to flush stdout")?; + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Copy { + cache, + connection, + network, + src, + dst, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - let connection_id = - use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - debug!("Opening channel to connection {}", connection_id); - let channel = client - .open_raw_channel(connection_id) - .await - .with_context(|| { - format!("Failed to open channel to connection {connection_id}") - })?; + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; - debug!("Spawning LSP server (pty = {}): {}", pty, cmd); - Lsp::new(channel.into_client().into_channel()) - .spawn(cmd, current_dir, pty) - .await?; + debug!("Copying {src:?} to {dst:?}"); + channel + .into_client() + .into_channel() + .copy(src.as_path(), dst.as_path()) + .await + .with_context(|| { + format!("Failed to copy {src:?} to {dst:?} using connection {connection_id}") + })?; + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Exists { + cache, + connection, + network, + path, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + debug!("Checking existence of {path:?}"); + let exists = channel + .into_client() + .into_channel() + .exists(path.as_path()) + .await + .with_context(|| { + format!( + "Failed to check existence of {path:?} using connection {connection_id}" + ) + })?; + + if exists { + println!("true"); + } else { + println!("false"); } - Self::Repl { - config: repl_config, - connection, - network, - format, - .. - } => { - // TODO: Support shell format? - if !format.is_json() { - return Err(CliError::Error(anyhow::anyhow!( - "Only JSON format is supported" - ))); - } + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::MakeDir { + cache, + connection, + network, + path, + all, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = Client::new(network) - .using_json_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?; + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - let connection_id = - use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; - let timeout = match repl_config.timeout.or(config.repl.timeout) { - Some(timeout) if timeout >= f32::EPSILON => Some(timeout), - _ => None, - }; + debug!("Making directory {path:?} (all = {all})"); + channel + .into_client() + .into_channel() + .create_dir(path.as_path(), all) + .await + .with_context(|| { + format!("Failed to make directory {path:?} using connection {connection_id}") + })?; + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Metadata { + cache, + connection, + network, + canonicalize, + resolve_file_type, + path, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - debug!("Opening raw channel to connection {}", connection_id); - let mut channel = - client - .open_raw_channel(connection_id) - .await - .with_context(|| { - format!("Failed to open raw channel to connection {connection_id}") - })?; + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - debug!( - "Timeout configured to be {}", - match timeout { - Some(secs) => format!("{secs}s"), - None => "none".to_string(), - } - ); + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; - debug!("Starting repl using format {:?}", format); - let (msg_tx, mut msg_rx) = mpsc::channel(1); - let request_task = tokio::spawn(async move { - let mut rx = MsgReceiver::from_stdin() - .into_rx::>>(); - loop { - match rx.recv().await { - Some(Ok(request)) => { - if let Err(x) = msg_tx.send(request).await { - error!("Failed to forward request: {x}"); - break; - } - } - Some(Err(x)) => error!("{}", x), - None => { - debug!("Shutting down repl"); - break; - } - } - } - io::Result::Ok(()) - }); - let channel_task = tokio::task::spawn(async move { - let tx = MsgSender::from_stdout(); - - loop { - let ready = channel.readable_or_writeable().await?; - - // Keep track of whether we read or wrote anything - let mut read_blocked = !ready.is_readable(); - let mut write_blocked = !ready.is_writable(); - - if ready.is_readable() { - match channel - .try_read_frame_as::>>() - { - Ok(Some(msg)) => tx.send_blocking(&msg)?, - Ok(None) => break, - Err(x) if x.kind() == io::ErrorKind::WouldBlock => { - read_blocked = true; - } - Err(x) => return Err(x), - } - } + debug!("Retrieving metadata of {path:?}"); + let metadata = channel + .into_client() + .into_channel() + .metadata(path.as_path(), canonicalize, resolve_file_type) + .await + .with_context(|| { + format!( + "Failed to retrieve metadata of {path:?} using connection {connection_id}" + ) + })?; + + println!( + concat!( + "{}", + "Type: {}\n", + "Len: {}\n", + "Readonly: {}\n", + "Created: {}\n", + "Last Accessed: {}\n", + "Last Modified: {}\n", + "{}", + "{}", + "{}", + ), + metadata + .canonicalized_path + .map(|p| format!("Canonicalized Path: {p:?}\n")) + .unwrap_or_default(), + metadata.file_type.as_ref(), + metadata.len, + metadata.readonly, + metadata.created.unwrap_or_default(), + metadata.accessed.unwrap_or_default(), + metadata.modified.unwrap_or_default(), + metadata + .unix + .map(|u| format!( + concat!( + "Owner Read: {}\n", + "Owner Write: {}\n", + "Owner Exec: {}\n", + "Group Read: {}\n", + "Group Write: {}\n", + "Group Exec: {}\n", + "Other Read: {}\n", + "Other Write: {}\n", + "Other Exec: {}", + ), + u.owner_read, + u.owner_write, + u.owner_exec, + u.group_read, + u.group_write, + u.group_exec, + u.other_read, + u.other_write, + u.other_exec + )) + .unwrap_or_default(), + metadata + .windows + .map(|w| format!( + concat!( + "Archive: {}\n", + "Compressed: {}\n", + "Encrypted: {}\n", + "Hidden: {}\n", + "Integrity Stream: {}\n", + "Normal: {}\n", + "Not Content Indexed: {}\n", + "No Scrub Data: {}\n", + "Offline: {}\n", + "Recall on Data Access: {}\n", + "Recall on Open: {}\n", + "Reparse Point: {}\n", + "Sparse File: {}\n", + "System: {}\n", + "Temporary: {}", + ), + w.archive, + w.compressed, + w.encrypted, + w.hidden, + w.integrity_stream, + w.normal, + w.not_content_indexed, + w.no_scrub_data, + w.offline, + w.recall_on_data_access, + w.recall_on_open, + w.reparse_point, + w.sparse_file, + w.system, + w.temporary, + )) + .unwrap_or_default(), + if metadata.unix.is_none() && metadata.windows.is_none() { + String::from("\n") + } else { + String::new() + } + ) + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Read { + cache, + connection, + network, + path, + depth, + absolute, + canonicalize, + include_root, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - if ready.is_writable() { - if let Ok(msg) = msg_rx.try_recv() { - match channel.try_write_frame_for(&msg) { - Ok(_) => (), - Err(x) if x.kind() == io::ErrorKind::WouldBlock => { - write_blocked = true - } - Err(x) => return Err(x), - } - } else { - match channel.try_flush() { - Ok(0) => write_blocked = true, - Ok(_) => (), - Err(x) if x.kind() == io::ErrorKind::WouldBlock => { - write_blocked = true - } - Err(x) => { - error!("Failed to flush outgoing data: {x}"); - } - } - } - } + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - // If we did not read or write anything, sleep a bit to offload CPU usage - if read_blocked && write_blocked { - tokio::time::sleep(SLEEP_DURATION).await; + debug!("Opening channel to connection {}", connection_id); + let mut channel: DistantChannel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))? + .into_client() + .into_channel(); + + // NOTE: We don't know whether the path is for a file or directory, so we try both + // at the same time and return the first result, or fail if both fail! + debug!( + "Reading {path:?} (depth = {}, absolute = {}, canonicalize = {}, include_root = {})", + depth, absolute, canonicalize, include_root + ); + let results = channel + .send(DistantMsg::Batch(vec![ + DistantRequestData::FileRead { + path: path.to_path_buf(), + }, + DistantRequestData::DirRead { + path: path.to_path_buf(), + depth, + absolute, + canonicalize, + include_root, + }, + ])) + .await + .with_context(|| { + format!("Failed to read {path:?} using connection {connection_id}") + })?; + + let mut errors = Vec::new(); + for response in results + .payload + .into_batch() + .context("Got single response to batch request")? + { + match response { + DistantResponseData::DirEntries { entries, .. } => { + #[derive(Tabled)] + struct EntryRow { + ty: String, + path: String, } + + let data = Table::new(entries.into_iter().map(|entry| EntryRow { + ty: String::from(match entry.file_type { + FileType::Dir => "", + FileType::File => "", + FileType::Symlink => "", + }), + path: entry.path.to_string_lossy().to_string(), + })) + .with(Style::blank()) + .with(Disable::row(Rows::new(..1))) + .with(Modify::new(Rows::new(..)).with(Alignment::left())) + .to_string() + .into_bytes(); + + let mut out = std::io::stdout(); + out.write_all(&data) + .context("Failed to write directory contents to stdout")?; + out.flush().context("Failed to flush stdout")?; + return Ok(()); + } + DistantResponseData::Blob { data } => { + let mut out = std::io::stdout(); + out.write_all(&data) + .context("Failed to write file contents to stdout")?; + out.flush().context("Failed to flush stdout")?; + return Ok(()); } + DistantResponseData::Error(x) => errors.push(x), + _ => continue, + } + } - io::Result::Ok(()) - }); + if let Some(x) = errors.first() { + return Err(CliError::from(anyhow::anyhow!(x.to_io_error()))); + } + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Remove { + cache, + connection, + network, + path, + force, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - let (r1, r2) = tokio::join!(request_task, channel_task); - match r1 { - Err(x) => error!("{}", x), - Ok(Err(x)) => error!("{}", x), - _ => (), - } - match r2 { - Err(x) => error!("{}", x), - Ok(Err(x)) => error!("{}", x), - _ => (), - } + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + debug!("Removing {path:?} (force = {force}"); + channel + .into_client() + .into_channel() + .remove(path.as_path(), force) + .await + .with_context(|| { + format!("Failed to remove {path:?} using connection {connection_id}") + })?; + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Rename { + cache, + connection, + network, + src, + dst, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - debug!("Shutting down repl"); + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + debug!("Renaming {src:?} to {dst:?}"); + channel + .into_client() + .into_channel() + .rename(src.as_path(), dst.as_path()) + .await + .with_context(|| { + format!("Failed to rename {src:?} to {dst:?} using connection {connection_id}") + })?; + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Search { + cache, + connection, + network, + target, + condition, + options, + paths, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + let mut formatter = Formatter::shell(); + let query = SearchQuery { + target: target.into(), + condition, + paths, + options: options.into(), + }; + + let mut searcher = Searcher::search(channel.into_client().into_channel(), query) + .await + .context("Failed to start search")?; + + // Continue to receive and process matches + while let Some(m) = searcher.next().await { + // TODO: Provide a cleaner way to print just a match + let res = Response::new( + "".to_string(), + DistantMsg::Single(DistantResponseData::SearchResults { + id: 0, + matches: vec![m], + }), + ); + + formatter.print(res).context("Failed to print match")?; } - Self::Select { - connection, - format, - network, - .. - } => match connection { - Some(id) => { - *cache.data.selected = id; - cache.write_to_disk().await?; - } - None => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = match format { - Format::Json => Client::new(network) - .using_json_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - Format::Shell => Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?, - }; - let list = client - .list() - .await - .context("Failed to get a list of managed connections")?; - - if list.is_empty() { - return Err(CliError::Error(anyhow::anyhow!( - "No connection available in manager" - ))); - } + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Watch { + cache, + connection, + network, + recursive, + only, + except, + path, + }) => { + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - // Figure out the current selection - let current = list - .iter() - .enumerate() - .find_map(|(i, (id, _))| { - if *cache.data.selected == *id { - Some(i) - } else { - None - } - }) - .unwrap_or_default(); - - trace!("Building selection prompt of {} choices", list.len()); - let items: Vec = list - .iter() - .map(|(_, destination)| { - format!( - "{}{}{}", - destination - .scheme - .as_ref() - .map(|scheme| format!(r"{scheme}://")) - .unwrap_or_default(), - destination.host, - destination - .port - .map(|port| format!(":{port}")) - .unwrap_or_default() - ) - }) - .collect(); - - // Prompt for a selection, with None meaning no change - let selected = match format { - Format::Shell => { - trace!("Rendering prompt"); - Select::with_theme(&ColorfulTheme::default()) - .items(&items) - .default(current) - .interact_on_opt(&Term::stderr()) - .context("Failed to render prompt")? - } + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; - Format::Json => { - // Print out choices - MsgSender::from_stdout() - .send_blocking(&json!({ - "type": "select", - "choices": items, - "current": current, - })) - .context("Failed to send JSON choices")?; - - // Wait for a response - let msg = MsgReceiver::from_stdin() - .recv_blocking::() - .context("Failed to receive JSON selection")?; - - // Verify the response type is "selected" - match msg.get("type") { - Some(value) if value == "selected" => msg - .get("choice") - .and_then(|value| value.as_u64()) - .map(|choice| choice as usize), - Some(value) => { - return Err(CliError::Error(anyhow::anyhow!( - "Unexpected 'type' field value: {value}" - ))) - } - None => { - return Err(CliError::Error(anyhow::anyhow!( - "Missing 'type' field" - ))) - } - } - } - }; - - match selected { - Some(index) => { - trace!("Selected choice {}", index); - if let Some((id, _)) = list.iter().nth(index) { - debug!("Updating selected connection id in cache to {}", id); - *cache.data.selected = *id; - cache.write_to_disk().await?; - } - } - None => { - debug!("No change in selection of default connection id"); - } + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + debug!("Special request creating watcher for {:?}", path); + let mut watcher = Watcher::watch( + channel.into_client().into_channel(), + path.as_path(), + recursive, + only.into_iter().collect::(), + except.into_iter().collect::(), + ) + .await + .with_context(|| format!("Failed to watch {path:?}"))?; + + // Continue to receive and process changes + let mut formatter = Formatter::shell(); + while let Some(change) = watcher.next().await { + // TODO: Provide a cleaner way to print just a change + let res = Response::new( + "".to_string(), + DistantMsg::Single(DistantResponseData::Changed(change)), + ); + + formatter.print(res).context("Failed to print change")?; + } + } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Write { + cache, + connection, + network, + append, + path, + data, + }) => { + let data = match data { + Some(x) => match x.into_string() { + Ok(x) => x.into_bytes(), + Err(_) => { + return Err(CliError::from(anyhow::anyhow!( + "Non-unicode input is disallowed!" + ))); } + }, + None => { + debug!("No data provided, reading from stdin"); + use std::io::Read; + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .context("Failed to read stdin")?; + buf } - }, - Self::Shell { - connection, - network, - current_dir, - environment, - cmd, - .. - } => { - let network = network.merge(config.network); - debug!("Connecting to manager"); - let mut client = Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")?; + }; - let connection_id = - use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; - debug!("Opening channel to connection {}", connection_id); - let channel = client - .open_raw_channel(connection_id) + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + if append { + debug!("Appending contents to {path:?}"); + channel + .into_client() + .into_channel() + .append_file(path.as_path(), data) .await .with_context(|| { - format!("Failed to open channel to connection {connection_id}") + format!("Failed to write to {path:?} using connection {connection_id}") + })?; + } else { + debug!("Writing contents to {path:?}"); + channel + .into_client() + .into_channel() + .write_file(path.as_path(), data) + .await + .with_context(|| { + format!("Failed to write to {path:?} using connection {connection_id}") })?; - - debug!( - "Spawning shell (environment = {:?}): {}", - environment, - cmd.as_deref().unwrap_or(r"$SHELL") - ); - Shell::new(channel.into_client().into_channel()) - .spawn(cmd, environment, current_dir) - .await?; } } - - Ok(()) } + + Ok(()) } async fn use_or_lookup_connection_id( @@ -945,3 +1163,21 @@ async fn use_or_lookup_connection_id( } } } + +async fn connect_to_manager( + format: Format, + network: NetworkSettings, +) -> anyhow::Result { + Ok(match format { + Format::Shell => Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?, + Format::Json => Client::new(network) + .using_json_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?, + }) +} diff --git a/src/cli/commands/client/lsp.rs b/src/cli/commands/client/lsp.rs index e877708..abb4153 100644 --- a/src/cli/commands/client/lsp.rs +++ b/src/cli/commands/client/lsp.rs @@ -1,4 +1,5 @@ -use super::{link::RemoteProcessLink, CliError, CliResult}; +use super::super::common::RemoteProcessLink; +use super::{CliError, CliResult}; use anyhow::Context; use distant_core::{data::PtySize, DistantChannel, RemoteLspCommand}; use std::path::PathBuf; @@ -17,6 +18,7 @@ impl Lsp { cmd: impl Into, current_dir: Option, pty: bool, + max_chunk_size: usize, ) -> CliResult { let cmd = cmd.into(); let mut proc = RemoteLspCommand::new() @@ -37,6 +39,7 @@ impl Lsp { proc.stdin.take(), proc.stdout.take().unwrap(), proc.stderr.take().unwrap(), + max_chunk_size, ); let status = proc.wait().await.context("Failed to wait for process")?; diff --git a/src/cli/commands/client/shell.rs b/src/cli/commands/client/shell.rs index f76983d..d3ff492 100644 --- a/src/cli/commands/client/shell.rs +++ b/src/cli/commands/client/shell.rs @@ -1,4 +1,5 @@ -use super::{link::RemoteProcessLink, CliError, CliResult}; +use super::super::common::RemoteProcessLink; +use super::{CliError, CliResult}; use anyhow::Context; use distant_core::{ data::{Environment, PtySize}, @@ -27,6 +28,7 @@ impl Shell { cmd: impl Into>, mut environment: Environment, current_dir: Option, + max_chunk_size: usize, ) -> CliResult { // Automatically add TERM=xterm-256color if not specified if !environment.contains_key("TERM") { @@ -116,6 +118,7 @@ impl Shell { None, proc.stdout.take().unwrap(), proc.stderr.take().unwrap(), + max_chunk_size, ); // Continually loop to check for terminal resize changes while the process is still running diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs new file mode 100644 index 0000000..63e76ed --- /dev/null +++ b/src/cli/commands/common.rs @@ -0,0 +1,8 @@ +mod buf; +mod format; +mod link; +pub mod stdin; + +pub use buf::*; +pub use format::*; +pub use link::*; diff --git a/src/cli/commands/client/buf.rs b/src/cli/commands/common/buf.rs similarity index 100% rename from src/cli/commands/client/buf.rs rename to src/cli/commands/common/buf.rs diff --git a/src/cli/commands/client/format.rs b/src/cli/commands/common/format.rs similarity index 96% rename from src/cli/commands/client/format.rs rename to src/cli/commands/common/format.rs index 81751ee..b630ad7 100644 --- a/src/cli/commands/client/format.rs +++ b/src/cli/commands/common/format.rs @@ -1,4 +1,4 @@ -use clap::ValueEnum; +use crate::options::Format; use distant_core::{ data::{ ChangeKind, DistantMsg, DistantResponseData, Error, FileType, Metadata, @@ -14,30 +14,6 @@ use std::{ }; use tabled::{object::Rows, style::Style, Alignment, Disable, Modify, Table, Tabled}; -#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] -#[clap(rename_all = "snake_case")] -pub enum Format { - /// Sends and receives data in JSON format - Json, - - /// Commands are traditional shell commands and output responses are - /// inline with what is expected of a program's output in a shell - Shell, -} - -impl Format { - /// Returns true if json format - pub fn is_json(self) -> bool { - matches!(self, Self::Json) - } -} - -impl Default for Format { - fn default() -> Self { - Self::Shell - } -} - #[derive(Default)] struct FormatterState { /// Last seen path during search diff --git a/src/cli/commands/client/link.rs b/src/cli/commands/common/link.rs similarity index 84% rename from src/cli/commands/client/link.rs rename to src/cli/commands/common/link.rs index d1e3dd8..2a0bd52 100644 --- a/src/cli/commands/client/link.rs +++ b/src/cli/commands/common/link.rs @@ -1,5 +1,4 @@ use super::stdin; -use crate::constants::MAX_PIPE_CHUNK_SIZE; use distant_core::{ RemoteLspStderr, RemoteLspStdin, RemoteLspStdout, RemoteStderr, RemoteStdin, RemoteStdout, }; @@ -20,11 +19,11 @@ pub struct RemoteProcessLink { } macro_rules! from_pipes { - ($stdin:expr, $stdout:expr, $stderr:expr) => {{ + ($stdin:expr, $stdout:expr, $stderr:expr, $buffer:expr) => {{ let mut stdin_thread = None; let mut stdin_task = None; if let Some(mut stdin_handle) = $stdin { - let (thread, mut rx) = stdin::spawn_channel(MAX_PIPE_CHUNK_SIZE); + let (thread, mut rx) = stdin::spawn_channel($buffer); let task = tokio::spawn(async move { loop { if let Some(input) = rx.recv().await { @@ -77,22 +76,30 @@ macro_rules! from_pipes { } impl RemoteProcessLink { - /// Creates a new process link from the pipes of a remote process + /// Creates a new process link from the pipes of a remote process. + /// + /// `max_pipe_chunk_size` represents the maximum size (in bytes) of data that will be read from + /// stdin at one time to forward to the remote process. pub fn from_remote_pipes( stdin: Option, mut stdout: RemoteStdout, mut stderr: RemoteStderr, + max_pipe_chunk_size: usize, ) -> Self { - from_pipes!(stdin, stdout, stderr) + from_pipes!(stdin, stdout, stderr, max_pipe_chunk_size) } - /// Creates a new process link from the pipes of a remote LSP server process + /// Creates a new process link from the pipes of a remote LSP server process. + /// + /// `max_pipe_chunk_size` represents the maximum size (in bytes) of data that will be read from + /// stdin at one time to forward to the remote process. pub fn from_remote_lsp_pipes( stdin: Option, mut stdout: RemoteLspStdout, mut stderr: RemoteLspStderr, + max_pipe_chunk_size: usize, ) -> Self { - from_pipes!(stdin, stdout, stderr) + from_pipes!(stdin, stdout, stderr, max_pipe_chunk_size) } /// Shuts down the link, aborting any running tasks, and swallowing join errors diff --git a/src/cli/commands/client/stdin.rs b/src/cli/commands/common/stdin.rs similarity index 100% rename from src/cli/commands/client/stdin.rs rename to src/cli/commands/common/stdin.rs diff --git a/src/cli/commands/generate.rs b/src/cli/commands/generate.rs index b0e40c4..147bec6 100644 --- a/src/cli/commands/generate.rs +++ b/src/cli/commands/generate.rs @@ -1,107 +1,72 @@ -use crate::{ - cli::Opt, - config::{Config, GenerateConfig}, - CliResult, -}; +use crate::options::{Config, GenerateSubcommand}; +use crate::{CliResult, Options}; use anyhow::Context; -use clap::{CommandFactory, Subcommand}; -use clap_complete::{generate as clap_generate, Shell}; -use distant_core::{ - net::common::{Request, Response}, - DistantMsg, DistantRequestData, DistantResponseData, -}; -use std::{fs, io, path::PathBuf}; +use clap::CommandFactory; +use clap_complete::generate as clap_generate; +use distant_core::net::common::{Request, Response}; +use distant_core::{DistantMsg, DistantRequestData, DistantResponseData}; +use std::{fs, io}; -#[derive(Debug, Subcommand)] -pub enum GenerateSubcommand { - /// Generate configuration file with base settings - Config { - /// Path to where the configuration file should be created - file: PathBuf, - }, - - /// Generate JSON schema for server request/response - Schema { - /// If specified, will output to the file at the given path instead of stdout - #[clap(long)] - file: Option, - }, - - // Generate completion info for CLI - Completion { - /// If specified, will output to the file at the given path instead of stdout - #[clap(long)] - file: Option, - - /// Specific shell to target for the generated output - #[clap(value_enum, value_parser)] - shell: Shell, - }, +pub fn run(cmd: GenerateSubcommand) -> CliResult { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async_run(cmd)) } -impl GenerateSubcommand { - pub fn run(self, _config: GenerateConfig) -> CliResult { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(Self::async_run(self)) - } - - async fn async_run(self) -> CliResult { - match self { - Self::Config { file } => tokio::fs::write(file, Config::default_raw_str()) - .await - .context("Failed to write default config to {file:?}")?, +async fn async_run(cmd: GenerateSubcommand) -> CliResult { + match cmd { + GenerateSubcommand::Config { file } => tokio::fs::write(file, Config::default_raw_str()) + .await + .context("Failed to write default config to {file:?}")?, - Self::Schema { file } => { - let request_schema = - serde_json::to_value(&Request::>::root_schema()) - .context("Failed to serialize request schema")?; - let response_schema = serde_json::to_value(&Response::< - DistantMsg, - >::root_schema()) - .context("Failed to serialize response schema")?; + GenerateSubcommand::Schema { file } => { + let request_schema = + serde_json::to_value(&Request::>::root_schema()) + .context("Failed to serialize request schema")?; + let response_schema = + serde_json::to_value(&Response::>::root_schema()) + .context("Failed to serialize response schema")?; - let schema = serde_json::json!({ - "request": request_schema, - "response": response_schema, - }); + let schema = serde_json::json!({ + "request": request_schema, + "response": response_schema, + }); - if let Some(path) = file { - serde_json::to_writer_pretty( - &mut fs::OpenOptions::new() - .create(true) - .write(true) - .open(&path) - .with_context(|| format!("Failed to open {path:?}"))?, - &schema, - ) - .context("Failed to write to {path:?}")?; - } else { - serde_json::to_writer_pretty(&mut io::stdout(), &schema) - .context("Failed to print to stdout")?; - } + if let Some(path) = file { + serde_json::to_writer_pretty( + &mut fs::OpenOptions::new() + .create(true) + .write(true) + .open(&path) + .with_context(|| format!("Failed to open {path:?}"))?, + &schema, + ) + .context("Failed to write to {path:?}")?; + } else { + serde_json::to_writer_pretty(&mut io::stdout(), &schema) + .context("Failed to print to stdout")?; } + } - Self::Completion { file, shell } => { - let name = "distant"; - let mut cmd = Opt::command(); + GenerateSubcommand::Completion { file, shell } => { + let name = "distant"; + let mut cmd = Options::command(); - if let Some(path) = file { - clap_generate( - shell, - &mut cmd, - name, - &mut fs::OpenOptions::new() - .create(true) - .write(true) - .open(&path) - .with_context(|| format!("Failed to open {path:?}"))?, - ) - } else { - clap_generate(shell, &mut cmd, name, &mut io::stdout()) - } + if let Some(path) = file { + clap_generate( + shell, + &mut cmd, + name, + &mut fs::OpenOptions::new() + .create(true) + .write(true) + .open(&path) + .with_context(|| format!("Failed to open {path:?}"))?, + ) + } else { + clap_generate(shell, &mut cmd, name, &mut io::stdout()) } } - - Ok(()) } + + Ok(()) } diff --git a/src/cli/commands/manager.rs b/src/cli/commands/manager.rs index d2946d4..686cd14 100644 --- a/src/cli/commands/manager.rs +++ b/src/cli/commands/manager.rs @@ -1,18 +1,19 @@ -use crate::{ - cli::{Cache, Client, Manager}, - config::{AccessControl, ManagerConfig, NetworkConfig}, - paths::user::CACHE_FILE_PATH_STR, - CliResult, -}; +use crate::cli::common::{MsgReceiver, MsgSender}; +use crate::cli::{Cache, Client, Manager}; +use crate::options::{Format, ManagerServiceSubcommand, ManagerSubcommand, NetworkSettings}; +use crate::{CliError, CliResult}; use anyhow::Context; -use clap::{Subcommand, ValueHint}; +use dialoguer::{console::Term, theme::ColorfulTheme, Select}; use distant_core::net::common::ConnectionId; -use distant_core::net::manager::{Config as NetManagerConfig, ConnectHandler, LaunchHandler}; +use distant_core::net::manager::{ + Config as NetManagerConfig, ConnectHandler, LaunchHandler, ManagerClient, +}; use log::*; use once_cell::sync::Lazy; +use serde_json::{json, Value}; use service_manager::{ - ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager, ServiceManagerKind, - ServiceStartCtx, ServiceStopCtx, ServiceUninstallCtx, + ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, + ServiceUninstallCtx, }; use std::{collections::HashMap, ffi::OsString, path::PathBuf}; use tabled::{Table, Tabled}; @@ -26,442 +27,513 @@ static SERVICE_LABEL: Lazy = Lazy::new(|| ServiceLabel { mod handlers; -#[derive(Debug, Subcommand)] -pub enum ManagerSubcommand { - /// Interact with a manager being run by a service management platform - #[clap(subcommand)] - Service(ManagerServiceSubcommand), - - /// Listen for incoming requests as a manager - Listen { - /// Type of access to apply to created unix socket or windows pipe - #[clap(long, value_enum)] - access: Option, - - /// If specified, will fork the process to run as a standalone daemon - #[clap(long)] - daemon: bool, - - /// If specified, will listen on a user-local unix socket or local windows named pipe - #[clap(long)] - user: bool, - - #[clap(flatten)] - network: NetworkConfig, - }, - - /// Retrieve a list of capabilities that the manager supports - Capabilities { - #[clap(flatten)] - network: NetworkConfig, - }, - - /// Retrieve information about a specific connection - Info { - id: ConnectionId, - #[clap(flatten)] - network: NetworkConfig, - }, - - /// List information about all connections - List { - #[clap(flatten)] - network: NetworkConfig, - - /// Location to store cached data - #[clap( - long, - value_hint = ValueHint::FilePath, - value_parser, - default_value = CACHE_FILE_PATH_STR.as_str() - )] - cache: PathBuf, - }, - - /// Kill a specific connection - Kill { - #[clap(flatten)] - network: NetworkConfig, - id: ConnectionId, - }, +pub fn run(cmd: ManagerSubcommand) -> CliResult { + match &cmd { + ManagerSubcommand::Listen { daemon, .. } if *daemon => run_daemon(cmd), + _ => { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async_run(cmd)) + } + } } -#[derive(Debug, Subcommand)] -pub enum ManagerServiceSubcommand { - /// Start the manager as a service - Start { - /// Type of service manager used to run this service, defaulting to platform native - #[clap(long, value_enum)] - kind: Option, - - /// If specified, starts as a user-level service - #[clap(long)] - user: bool, - }, - - /// Stop the manager as a service - Stop { - #[clap(long, value_enum)] - kind: Option, - - /// If specified, stops a user-level service - #[clap(long)] - user: bool, - }, - - /// Install the manager as a service - Install { - #[clap(long, value_enum)] - kind: Option, - - /// If specified, installs as a user-level service - #[clap(long)] - user: bool, - }, - - /// Uninstall the manager as a service - Uninstall { - #[clap(long, value_enum)] - kind: Option, - - /// If specified, uninstalls a user-level service - #[clap(long)] - user: bool, - }, +#[cfg(windows)] +fn run_daemon(_cmd: ManagerSubcommand) -> CliResult { + use crate::cli::Spawner; + let pid = Spawner::spawn_running_background(Vec::new()) + .context("Failed to spawn background process")?; + println!("[distant manager detached, pid = {}]", pid); + Ok(()) } -impl ManagerSubcommand { - /// Returns true if the manager subcommand is listen - pub fn is_listen(&self) -> bool { - matches!(self, Self::Listen { .. }) - } +#[cfg(unix)] +fn run_daemon(cmd: ManagerSubcommand) -> CliResult { + use fork::{daemon, Fork}; - pub fn run(self, config: ManagerConfig) -> CliResult { - match &self { - Self::Listen { daemon, .. } if *daemon => Self::run_daemon(self, config), - _ => { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(Self::async_run(self, config)) + debug!("Forking process"); + match daemon(true, true) { + Ok(Fork::Child) => { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async { async_run(cmd).await })?; + Ok(()) + } + Ok(Fork::Parent(pid)) => { + println!("[distant manager detached, pid = {pid}]"); + if fork::close_fd().is_err() { + Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))) + } else { + Ok(()) } } + Err(_) => Err(CliError::Error(anyhow::anyhow!("Fork failed"))), } +} - #[cfg(windows)] - fn run_daemon(self, _config: ManagerConfig) -> CliResult { - use crate::cli::Spawner; - let pid = Spawner::spawn_running_background(Vec::new()) - .context("Failed to spawn background process")?; - println!("[distant manager detached, pid = {}]", pid); - Ok(()) - } - - #[cfg(unix)] - fn run_daemon(self, config: ManagerConfig) -> CliResult { - use crate::CliError; - use fork::{daemon, Fork}; +async fn async_run(cmd: ManagerSubcommand) -> CliResult { + match cmd { + ManagerSubcommand::Service(ManagerServiceSubcommand::Start { kind, user }) => { + debug!("Starting manager service via {:?}", kind); + let mut manager = ::target_or_native(kind) + .context("Failed to detect native service manager")?; - debug!("Forking process"); - match daemon(true, true) { - Ok(Fork::Child) => { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(async { Self::async_run(self, config).await })?; - Ok(()) - } - Ok(Fork::Parent(pid)) => { - println!("[distant manager detached, pid = {pid}]"); - if fork::close_fd().is_err() { - Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))) - } else { - Ok(()) - } + if user { + manager + .set_level(ServiceLevel::User) + .context("Failed to set service manager to user level")?; } - Err(_) => Err(CliError::Error(anyhow::anyhow!("Fork failed"))), + + manager + .start(ServiceStartCtx { + label: SERVICE_LABEL.clone(), + }) + .context("Failed to start service")?; + Ok(()) } - } + ManagerSubcommand::Service(ManagerServiceSubcommand::Stop { kind, user }) => { + debug!("Stopping manager service via {:?}", kind); + let mut manager = ::target_or_native(kind) + .context("Failed to detect native service manager")?; - async fn async_run(self, config: ManagerConfig) -> CliResult { - match self { - Self::Service(ManagerServiceSubcommand::Start { kind, user }) => { - debug!("Starting manager service via {:?}", kind); - let mut manager = ::target_or_native(kind) - .context("Failed to detect native service manager")?; - - if user { - manager - .set_level(ServiceLevel::User) - .context("Failed to set service manager to user level")?; - } + if user { + manager + .set_level(ServiceLevel::User) + .context("Failed to set service manager to user level")?; + } + manager + .stop(ServiceStopCtx { + label: SERVICE_LABEL.clone(), + }) + .context("Failed to stop service")?; + Ok(()) + } + ManagerSubcommand::Service(ManagerServiceSubcommand::Install { kind, user }) => { + debug!("Installing manager service via {:?}", kind); + let mut manager = ::target_or_native(kind) + .context("Failed to detect native service manager")?; + let mut args = vec![OsString::from("manager"), OsString::from("listen")]; + + if user { + args.push(OsString::from("--user")); manager - .start(ServiceStartCtx { - label: SERVICE_LABEL.clone(), - }) - .context("Failed to start service")?; - Ok(()) + .set_level(ServiceLevel::User) + .context("Failed to set service manager to user level")?; } - Self::Service(ManagerServiceSubcommand::Stop { kind, user }) => { - debug!("Stopping manager service via {:?}", kind); - let mut manager = ::target_or_native(kind) - .context("Failed to detect native service manager")?; - - if user { - manager - .set_level(ServiceLevel::User) - .context("Failed to set service manager to user level")?; - } + manager + .install(ServiceInstallCtx { + label: SERVICE_LABEL.clone(), + + // distant manager listen + program: std::env::current_exe() + .ok() + .unwrap_or_else(|| PathBuf::from("distant")), + args, + }) + .context("Failed to install service")?; + + Ok(()) + } + ManagerSubcommand::Service(ManagerServiceSubcommand::Uninstall { kind, user }) => { + debug!("Uninstalling manager service via {:?}", kind); + let mut manager = ::target_or_native(kind) + .context("Failed to detect native service manager")?; + if user { manager - .stop(ServiceStopCtx { - label: SERVICE_LABEL.clone(), - }) - .context("Failed to stop service")?; - Ok(()) + .set_level(ServiceLevel::User) + .context("Failed to set service manager to user level")?; } - Self::Service(ManagerServiceSubcommand::Install { kind, user }) => { - debug!("Installing manager service via {:?}", kind); - let mut manager = ::target_or_native(kind) - .context("Failed to detect native service manager")?; - let mut args = vec![OsString::from("manager"), OsString::from("listen")]; - - if user { - args.push(OsString::from("--user")); - manager - .set_level(ServiceLevel::User) - .context("Failed to set service manager to user level")?; + manager + .uninstall(ServiceUninstallCtx { + label: SERVICE_LABEL.clone(), + }) + .context("Failed to uninstall service")?; + + Ok(()) + } + ManagerSubcommand::Listen { + access, + daemon: _daemon, + network, + user, + } => { + let access = access.unwrap_or_default(); + + info!( + "Starting manager (network = {})", + if cfg!(windows) && network.windows_pipe.is_some() { + format!("custom:windows:{}", network.windows_pipe.as_ref().unwrap()) + } else if cfg!(unix) && network.unix_socket.is_some() { + format!("custom:unix:{:?}", network.unix_socket.as_ref().unwrap()) + } else if user { + "user".to_string() + } else { + "global".to_string() } + ); + let manager_ref = Manager { + access, + config: NetManagerConfig { + user, + launch_handlers: { + let mut handlers: HashMap> = HashMap::new(); + handlers.insert( + "manager".to_string(), + Box::new(handlers::ManagerLaunchHandler::new()), + ); + + #[cfg(any(feature = "libssh", feature = "ssh2"))] + handlers.insert("ssh".to_string(), Box::new(handlers::SshLaunchHandler)); + + handlers + }, + connect_handlers: { + let mut handlers: HashMap> = HashMap::new(); - manager - .install(ServiceInstallCtx { - label: SERVICE_LABEL.clone(), + handlers.insert( + "distant".to_string(), + Box::new(handlers::DistantConnectHandler), + ); - // distant manager listen - program: std::env::current_exe() - .ok() - .unwrap_or_else(|| PathBuf::from("distant")), - args, - }) - .context("Failed to install service")?; + #[cfg(any(feature = "libssh", feature = "ssh2"))] + handlers.insert("ssh".to_string(), Box::new(handlers::SshConnectHandler)); - Ok(()) + handlers + }, + ..Default::default() + }, + network, } - Self::Service(ManagerServiceSubcommand::Uninstall { kind, user }) => { - debug!("Uninstalling manager service via {:?}", kind); - let mut manager = ::target_or_native(kind) - .context("Failed to detect native service manager")?; - if user { - manager - .set_level(ServiceLevel::User) - .context("Failed to set service manager to user level")?; + .listen() + .await + .context("Failed to start manager")?; + + // Let our server run to completion + manager_ref + .as_ref() + .polling_wait() + .await + .context("Failed to wait on manager")?; + info!("Manager is shutting down"); + + Ok(()) + } + ManagerSubcommand::Capabilities { format, network } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + debug!("Getting list of capabilities"); + let caps = client + .capabilities() + .await + .context("Failed to get list of capabilities")?; + debug!("Got capabilities: {caps:?}"); + + match format { + Format::Json => { + println!( + "{}", + serde_json::to_string(&caps) + .context("Failed to format capabilities as json")? + ); } - manager - .uninstall(ServiceUninstallCtx { - label: SERVICE_LABEL.clone(), - }) - .context("Failed to uninstall service")?; + Format::Shell => { + #[derive(Tabled)] + struct CapabilityRow { + kind: String, + description: String, + } - Ok(()) + println!( + "{}", + Table::new(caps.into_sorted_vec().into_iter().map(|cap| { + CapabilityRow { + kind: cap.kind, + description: cap.description, + } + })) + ); + } } - Self::Listen { - access, - network, - user, - .. - } => { - let access = access.or(config.access).unwrap_or_default(); - let network = network.merge(config.network); - - info!( - "Starting manager (network = {})", - if (cfg!(windows) && network.windows_pipe.is_some()) - || (cfg!(unix) && network.unix_socket.is_some()) - { - "custom" - } else if user { - "user" - } else { - "global" + + Ok(()) + } + ManagerSubcommand::Info { + format, + id, + network, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + debug!("Getting info about connection {}", id); + let info = client + .info(id) + .await + .context("Failed to get info about connection")?; + debug!("Got info: {info:?}"); + + match format { + Format::Json => { + println!( + "{}", + serde_json::to_string(&info) + .context("Failed to format connection info as json")? + ); + } + Format::Shell => { + #[derive(Tabled)] + struct InfoRow { + id: ConnectionId, + scheme: String, + host: String, + port: String, + options: String, } - ); - let manager_ref = Manager { - access, - config: NetManagerConfig { - user, - launch_handlers: { - let mut handlers: HashMap> = - HashMap::new(); - handlers.insert( - "manager".to_string(), - Box::new(handlers::ManagerLaunchHandler::new()), - ); - - #[cfg(any(feature = "libssh", feature = "ssh2"))] - handlers - .insert("ssh".to_string(), Box::new(handlers::SshLaunchHandler)); - - handlers - }, - connect_handlers: { - let mut handlers: HashMap> = - HashMap::new(); - - handlers.insert( - "distant".to_string(), - Box::new(handlers::DistantConnectHandler), - ); - - #[cfg(any(feature = "libssh", feature = "ssh2"))] - handlers - .insert("ssh".to_string(), Box::new(handlers::SshConnectHandler)); - - handlers - }, - ..Default::default() - }, - network, + println!( + "{}", + Table::new(vec![InfoRow { + id: info.id, + scheme: info.destination.scheme.unwrap_or_default(), + host: info.destination.host.to_string(), + port: info + .destination + .port + .map(|x| x.to_string()) + .unwrap_or_default(), + options: info.options.to_string() + }]) + ); } - .listen() + } + + Ok(()) + } + ManagerSubcommand::List { + cache, + format, + network, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + debug!("Getting list of connections"); + let list = client + .list() .await - .context("Failed to start manager")?; + .context("Failed to get list of connections")?; + debug!("Got list: {list:?}"); - // Let our server run to completion - manager_ref - .as_ref() - .polling_wait() - .await - .context("Failed to wait on manager")?; - info!("Manager is shutting down"); + debug!("Looking up selected connection"); + let selected = Cache::read_from_disk_or_default(cache) + .await + .context("Failed to look up selected connection")? + .data + .selected; + debug!("Using selected: {selected}"); + + match format { + Format::Json => { + println!( + "{}", + serde_json::to_string(&list) + .context("Failed to format connection list as json")? + ); + } + Format::Shell => { + #[derive(Tabled)] + struct ListRow { + selected: bool, + id: ConnectionId, + scheme: String, + host: String, + port: String, + } - Ok(()) - } - Self::Capabilities { network } => { - let network = network.merge(config.network); - debug!("Getting list of capabilities"); - let caps = Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")? - .capabilities() - .await - .context("Failed to get list of capabilities")?; - debug!("Got capabilities: {caps:?}"); - - #[derive(Tabled)] - struct CapabilityRow { - kind: String, - description: String, + println!( + "{}", + Table::new(list.into_iter().map(|(id, destination)| { + ListRow { + selected: *selected == id, + id, + scheme: destination.scheme.unwrap_or_default(), + host: destination.host.to_string(), + port: destination.port.map(|x| x.to_string()).unwrap_or_default(), + } + })) + ); } + } - println!( - "{}", - Table::new(caps.into_sorted_vec().into_iter().map(|cap| { - CapabilityRow { - kind: cap.kind, - description: cap.description, - } - })) - ); + Ok(()) + } + ManagerSubcommand::Kill { + format, + id, + network, + } => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + + debug!("Killing connection {}", id); + client + .kill(id) + .await + .with_context(|| format!("Failed to kill connection to server {id}"))?; - Ok(()) + debug!("Connection killed"); + match format { + Format::Json => println!("{}", json!({"type": "ok"})), + Format::Shell => (), } - Self::Info { network, id } => { - let network = network.merge(config.network); - debug!("Getting info about connection {}", id); - let info = Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")? - .info(id) - .await - .context("Failed to get info about connection")?; - debug!("Got info: {info:?}"); - - #[derive(Tabled)] - struct InfoRow { - id: ConnectionId, - scheme: String, - host: String, - port: String, - options: String, - } - println!( - "{}", - Table::new(vec![InfoRow { - id: info.id, - scheme: info.destination.scheme.unwrap_or_default(), - host: info.destination.host.to_string(), - port: info - .destination - .port - .map(|x| x.to_string()) - .unwrap_or_default(), - options: info.options.to_string() - }]) - ); + Ok(()) + } + ManagerSubcommand::Select { + cache, + connection, + format, + network, + } => { + let mut cache = Cache::read_from_disk_or_default(cache) + .await + .context("Failed to look up cache")?; - Ok(()) - } - Self::List { network, cache } => { - let network = network.merge(config.network); - debug!("Getting list of connections"); - let list = Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")? - .list() - .await - .context("Failed to get list of connections")?; - debug!("Got list: {list:?}"); - - debug!("Looking up selected connection"); - let selected = Cache::read_from_disk_or_default(cache) - .await - .context("Failed to look up selected connection")? - .data - .selected; - debug!("Using selected: {selected}"); - - #[derive(Tabled)] - struct ListRow { - selected: bool, - id: ConnectionId, - scheme: String, - host: String, - port: String, + match connection { + Some(id) => { + *cache.data.selected = id; + cache.write_to_disk().await?; + Ok(()) } + None => { + debug!("Connecting to manager"); + let mut client = connect_to_manager(format, network).await?; + let list = client + .list() + .await + .context("Failed to get a list of managed connections")?; + + if list.is_empty() { + return Err(CliError::Error(anyhow::anyhow!( + "No connection available in manager" + ))); + } - println!( - "{}", - Table::new(list.into_iter().map(|(id, destination)| { - ListRow { - selected: *selected == id, - id, - scheme: destination.scheme.unwrap_or_default(), - host: destination.host.to_string(), - port: destination.port.map(|x| x.to_string()).unwrap_or_default(), + // Figure out the current selection + let current = list + .iter() + .enumerate() + .find_map(|(i, (id, _))| { + if *cache.data.selected == *id { + Some(i) + } else { + None + } + }) + .unwrap_or_default(); + + trace!("Building selection prompt of {} choices", list.len()); + let items: Vec = list + .iter() + .map(|(_, destination)| { + format!( + "{}{}{}", + destination + .scheme + .as_ref() + .map(|scheme| format!(r"{scheme}://")) + .unwrap_or_default(), + destination.host, + destination + .port + .map(|port| format!(":{port}")) + .unwrap_or_default() + ) + }) + .collect(); + + // Prompt for a selection, with None meaning no change + let selected = match format { + Format::Shell => { + trace!("Rendering prompt"); + Select::with_theme(&ColorfulTheme::default()) + .items(&items) + .default(current) + .interact_on_opt(&Term::stderr()) + .context("Failed to render prompt")? } - })) - ); - Ok(()) - } - Self::Kill { network, id } => { - let network = network.merge(config.network); - debug!("Killing connection {}", id); - Client::new(network) - .using_prompt_auth_handler() - .connect() - .await - .context("Failed to connect to manager")? - .kill(id) - .await - .with_context(|| format!("Failed to kill connection to server {id}"))?; - debug!("Connection killed"); - Ok(()) + Format::Json => { + // Print out choices + MsgSender::from_stdout() + .send_blocking(&json!({ + "type": "select", + "choices": items, + "current": current, + })) + .context("Failed to send JSON choices")?; + + // Wait for a response + let msg = MsgReceiver::from_stdin() + .recv_blocking::() + .context("Failed to receive JSON selection")?; + + // Verify the response type is "selected" + match msg.get("type") { + Some(value) if value == "selected" => msg + .get("choice") + .and_then(|value| value.as_u64()) + .map(|choice| choice as usize), + Some(value) => { + return Err(CliError::Error(anyhow::anyhow!( + "Unexpected 'type' field value: {value}" + ))) + } + None => { + return Err(CliError::Error(anyhow::anyhow!( + "Missing 'type' field" + ))) + } + } + } + }; + + match selected { + Some(index) => { + trace!("Selected choice {}", index); + if let Some((id, _)) = list.iter().nth(index) { + debug!("Updating selected connection id in cache to {}", id); + *cache.data.selected = *id; + cache.write_to_disk().await?; + } + Ok(()) + } + None => { + debug!("No change in selection of default connection id"); + Ok(()) + } + } + } } } } } + +async fn connect_to_manager( + format: Format, + network: NetworkSettings, +) -> anyhow::Result { + debug!("Connecting to manager"); + Ok(match format { + Format::Shell => Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?, + Format::Json => Client::new(network) + .using_json_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?, + }) +} diff --git a/src/cli/commands/manager/handlers.rs b/src/cli/commands/manager/handlers.rs index 9791941..878c802 100644 --- a/src/cli/commands/manager/handlers.rs +++ b/src/cli/commands/manager/handlers.rs @@ -1,4 +1,4 @@ -use crate::config::ClientLaunchConfig; +use crate::options::ClientLaunchConfig; use async_trait::async_trait; use distant_core::net::client::{Client, ClientConfig, ReconnectStrategy, UntypedClient}; use distant_core::net::common::authentication::msg::*; diff --git a/src/cli/commands/server.rs b/src/cli/commands/server.rs index c21fbba..113a667 100644 --- a/src/cli/commands/server.rs +++ b/src/cli/commands/server.rs @@ -1,9 +1,6 @@ -use crate::{ - config::{BindAddress, ServerConfig, ServerListenConfig}, - CliError, CliResult, -}; +use crate::options::ServerSubcommand; +use crate::{CliError, CliResult}; use anyhow::Context; -use clap::Subcommand; use distant_core::net::common::authentication::Verifier; use distant_core::net::common::{Host, SecretKey32}; use distant_core::net::server::{Server, ServerConfig as NetServerConfig, ServerRef}; @@ -11,239 +8,200 @@ use distant_core::{DistantApiServerHandler, DistantSingleKeyCredentials}; use log::*; use std::io::{self, Read, Write}; -#[derive(Debug, Subcommand)] -pub enum ServerSubcommand { - /// Listen for incoming requests as a server - Listen { - #[clap(flatten)] - config: ServerListenConfig, - - /// If specified, will fork the process to run as a standalone daemon - #[clap(long)] - daemon: bool, - - /// If specified, the server will not generate a key but instead listen on stdin for the next - /// 32 bytes that it will use as the key instead. Receiving less than 32 bytes before stdin - /// is closed is considered an error and any bytes after the first 32 are not used for the key - #[clap(long)] - key_from_stdin: bool, - - /// If specified, will send output to the specified named pipe (internal usage) - #[cfg(windows)] - #[clap(long, help = None, long_help = None)] - output_to_local_pipe: Option, - }, -} - -impl ServerSubcommand { - pub fn run(self, config: ServerConfig) -> CliResult { - match &self { - Self::Listen { daemon, .. } if *daemon => Self::run_daemon(self, config), - Self::Listen { .. } => { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(Self::async_run(self, config, false)) - } +pub fn run(cmd: ServerSubcommand) -> CliResult { + match &cmd { + ServerSubcommand::Listen { daemon, .. } if *daemon => run_daemon(cmd), + ServerSubcommand::Listen { .. } => { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async_run(cmd, false)) } } +} - #[cfg(windows)] - fn run_daemon(self, _config: ServerConfig) -> CliResult { - use crate::cli::Spawner; - use distant_core::net::common::{Listener, TransportExt, WindowsPipeListener}; - use std::ffi::OsString; - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(async { - let name = format!("distant_{}_{}", std::process::id(), rand::random::()); - let mut listener = WindowsPipeListener::bind_local(name.as_str()) - .with_context(|| "Failed to bind to local named pipe {name:?}")?; - - let pid = Spawner::spawn_running_background(vec![ - OsString::from("--output-to-local-pipe"), - OsString::from(name), - ]) - .context("Failed to spawn background process")?; - println!("[distant server detached, pid = {}]", pid); - - // Wait to receive a connection from the above process - let transport = listener.accept().await.context( - "Failed to receive connection from background process to send credentials", - )?; - - // Get the credentials and print them - let mut s = String::new(); - let n = transport - .read_to_string(&mut s) - .await - .context("Failed to receive credentials")?; - if n == 0 { - anyhow::bail!("No credentials received from spawned server"); - } - let credentials = s[..n] - .trim() - .parse::() - .context("Failed to parse server credentials")?; - - println!("\r"); - println!("{}", credentials); - println!("\r"); - io::stdout() - .flush() - .context("Failed to print server credentials")?; - Ok(()) - }) - .map_err(CliError::Error) - } +#[cfg(windows)] +fn run_daemon(_cmd: ServerSubcommand) -> CliResult { + use crate::cli::Spawner; + use distant_core::net::common::{Listener, TransportExt, WindowsPipeListener}; + use std::ffi::OsString; + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async { + let name = format!("distant_{}_{}", std::process::id(), rand::random::()); + let mut listener = WindowsPipeListener::bind_local(name.as_str()) + .with_context(|| "Failed to bind to local named pipe {name:?}")?; + + let pid = Spawner::spawn_running_background(vec![ + OsString::from("--output-to-local-pipe"), + OsString::from(name), + ]) + .context("Failed to spawn background process")?; + println!("[distant server detached, pid = {}]", pid); + + // Wait to receive a connection from the above process + let transport = listener + .accept() + .await + .context("Failed to receive connection from background process to send credentials")?; + + // Get the credentials and print them + let mut s = String::new(); + let n = transport + .read_to_string(&mut s) + .await + .context("Failed to receive credentials")?; + if n == 0 { + anyhow::bail!("No credentials received from spawned server"); + } + let credentials = s[..n] + .trim() + .parse::() + .context("Failed to parse server credentials")?; + + println!("\r"); + println!("{}", credentials); + println!("\r"); + io::stdout() + .flush() + .context("Failed to print server credentials")?; + Ok(()) + }) + .map_err(CliError::Error) +} - #[cfg(unix)] - fn run_daemon(self, config: ServerConfig) -> CliResult { - use fork::{daemon, Fork}; +#[cfg(unix)] +fn run_daemon(cmd: ServerSubcommand) -> CliResult { + use fork::{daemon, Fork}; - // NOTE: We keep the stdin, stdout, stderr open so we can print out the pid with the parent - debug!("Forking process"); - match daemon(true, true) { - Ok(Fork::Child) => { - let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; - rt.block_on(async { Self::async_run(self, config, true).await })?; + // NOTE: We keep the stdin, stdout, stderr open so we can print out the pid with the parent + debug!("Forking process"); + match daemon(true, true) { + Ok(Fork::Child) => { + let rt = tokio::runtime::Runtime::new().context("Failed to start up runtime")?; + rt.block_on(async { async_run(cmd, true).await })?; + Ok(()) + } + Ok(Fork::Parent(pid)) => { + println!("[distant server detached, pid = {pid}]"); + if fork::close_fd().is_err() { + Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))) + } else { Ok(()) } - Ok(Fork::Parent(pid)) => { - println!("[distant server detached, pid = {pid}]"); - if fork::close_fd().is_err() { - Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))) - } else { - Ok(()) - } - } - Err(_) => Err(CliError::Error(anyhow::anyhow!("Fork failed"))), } + Err(_) => Err(CliError::Error(anyhow::anyhow!("Fork failed"))), } +} - async fn async_run(self, config: ServerConfig, _is_forked: bool) -> CliResult { - match self { - Self::Listen { - config: listen_config, - key_from_stdin, - #[cfg(windows)] - output_to_local_pipe, - .. - } => { - macro_rules! get { - (@flag $field:ident) => {{ - config.listen.$field || listen_config.$field - }}; - ($field:ident) => {{ - config.listen.$field.or(listen_config.$field) - }}; - } - - let host = get!(host).unwrap_or(BindAddress::Any); - trace!("Starting server using unresolved host '{host}'"); - let addr = host.resolve(get!(@flag use_ipv6)).await?; - - // If specified, change the current working directory of this program - if let Some(path) = get!(current_dir) { - debug!("Setting current directory to {:?}", path); - std::env::set_current_dir(path) - .context("Failed to set new current directory")?; - } - - // Bind & start our server - let key = if key_from_stdin { - debug!("Reading secret key from stdin"); - let mut buf = [0u8; 32]; - io::stdin() - .read_exact(&mut buf) - .context("Failed to read secret key from stdin")?; - SecretKey32::from(buf) - } else { - SecretKey32::default() - }; - - debug!( - "Starting local API server, binding to {} {}", - addr, - match get!(port) { - Some(range) => format!("with port in range {range}"), - None => "using an ephemeral port".to_string(), - } - ); - let handler = DistantApiServerHandler::local() - .context("Failed to create local distant api")?; - let server = Server::tcp() - .config(NetServerConfig { - shutdown: get!(shutdown).unwrap_or_default(), - ..Default::default() - }) - .handler(handler) - .verifier(Verifier::static_key(key.clone())) - .start(addr, get!(port).unwrap_or_else(|| 0.into())) - .await - .with_context(|| { - format!( - "Failed to start server @ {} with {}", - addr, - get!(port) - .map(|p| format!("port in range {p}")) - .unwrap_or_else(|| String::from("ephemeral port")) - ) - })?; - - let credentials = DistantSingleKeyCredentials { - host: Host::from(addr), - port: server.port(), - key, - username: None, - }; - info!( - "Server listening at {}:{}", - credentials.host, credentials.port - ); - - // Print information about port, key, etc. - // NOTE: Following mosh approach of printing to make sure there's no garbage floating around - #[cfg(not(windows))] - { - println!("\r"); - println!("{credentials}"); - println!("\r"); - io::stdout() - .flush() - .context("Failed to print credentials")?; - } +async fn async_run(cmd: ServerSubcommand, _is_forked: bool) -> CliResult { + match cmd { + #[allow(unused_variables)] + ServerSubcommand::Listen { + host, + port, + use_ipv6, + shutdown, + current_dir, + daemon: _, + key_from_stdin, + output_to_local_pipe, + } => { + let host = host.into_inner(); + trace!("Starting server using unresolved host '{host}'"); + let addr = host.resolve(use_ipv6).await?; + + // If specified, change the current working directory of this program + if let Some(path) = current_dir { + debug!("Setting current directory to {:?}", path); + std::env::set_current_dir(path).context("Failed to set new current directory")?; + } - #[cfg(windows)] - if let Some(name) = output_to_local_pipe { - use distant_core::net::common::{TransportExt, WindowsPipeTransport}; - let transport = WindowsPipeTransport::connect_local(&name) - .await - .with_context(|| { - format!("Failed to connect to local pipe named {name:?}") - })?; - transport - .write_all(credentials.to_string().as_bytes()) - .await - .context("Failed to send credentials through pipe")?; + // Bind & start our server + let key = if key_from_stdin { + debug!("Reading secret key from stdin"); + let mut buf = [0u8; 32]; + io::stdin() + .read_exact(&mut buf) + .context("Failed to read secret key from stdin")?; + SecretKey32::from(buf) + } else { + SecretKey32::default() + }; + + let port = port.into_inner(); + debug!( + "Starting local API server, binding to {} {}", + addr, + if port.is_ephemeral() { + format!("with port in range {port}") } else { - println!("\r"); - println!("{}", credentials); - println!("\r"); - io::stdout() - .flush() - .context("Failed to print credentials")?; + "using an ephemeral port".to_string() } + ); + let handler = + DistantApiServerHandler::local().context("Failed to create local distant api")?; + let server = Server::tcp() + .config(NetServerConfig { + shutdown: shutdown.into_inner(), + ..Default::default() + }) + .handler(handler) + .verifier(Verifier::static_key(key.clone())) + .start(addr, port) + .await + .with_context(|| format!("Failed to start server @ {addr} with {port}"))?; + + let credentials = DistantSingleKeyCredentials { + host: Host::from(addr), + port: server.port(), + key, + username: None, + }; + info!( + "Server listening at {}:{}", + credentials.host, credentials.port + ); + + // Print information about port, key, etc. + // NOTE: Following mosh approach of printing to make sure there's no garbage floating around + #[cfg(not(windows))] + { + println!("\r"); + println!("{credentials}"); + println!("\r"); + io::stdout() + .flush() + .context("Failed to print credentials")?; + } - // For the child, we want to fully disconnect it from pipes, which we do now - #[cfg(unix)] - if _is_forked && fork::close_fd().is_err() { - return Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))); - } + #[cfg(windows)] + if let Some(name) = output_to_local_pipe { + use distant_core::net::common::{TransportExt, WindowsPipeTransport}; + let transport = WindowsPipeTransport::connect_local(&name) + .await + .with_context(|| format!("Failed to connect to local pipe named {name:?}"))?; + transport + .write_all(credentials.to_string().as_bytes()) + .await + .context("Failed to send credentials through pipe")?; + } else { + println!("\r"); + println!("{}", credentials); + println!("\r"); + io::stdout() + .flush() + .context("Failed to print credentials")?; + } - // Let our server run to completion - server.wait().await.context("Failed to wait on server")?; - info!("Server is shutting down"); + // For the child, we want to fully disconnect it from pipes, which we do now + #[cfg(unix)] + if _is_forked && fork::close_fd().is_err() { + return Err(CliError::Error(anyhow::anyhow!("Fork failed to close fd"))); } - } - Ok(()) + // Let our server run to completion + server.wait().await.context("Failed to wait on server")?; + info!("Server is shutting down"); + } } + + Ok(()) } diff --git a/src/cli/common.rs b/src/cli/common.rs new file mode 100644 index 0000000..3441db5 --- /dev/null +++ b/src/cli/common.rs @@ -0,0 +1,11 @@ +mod cache; +mod client; +mod manager; +mod msg; +mod spawner; + +pub use cache::*; +pub use client::*; +pub use manager::*; +pub use msg::*; +pub use spawner::*; diff --git a/src/cli/cache.rs b/src/cli/common/cache.rs similarity index 98% rename from src/cli/cache.rs rename to src/cli/common/cache.rs index 1c7d4db..af45484 100644 --- a/src/cli/cache.rs +++ b/src/cli/common/cache.rs @@ -1,4 +1,4 @@ -use crate::paths::user::CACHE_FILE_PATH; +use crate::constants::user::CACHE_FILE_PATH; use anyhow::Context; use distant_core::net::common::ConnectionId; use serde::{Deserialize, Serialize}; diff --git a/src/cli/cache/id.rs b/src/cli/common/cache/id.rs similarity index 100% rename from src/cli/cache/id.rs rename to src/cli/common/cache/id.rs diff --git a/src/cli/client.rs b/src/cli/common/client.rs similarity index 97% rename from src/cli/client.rs rename to src/cli/common/client.rs index 92812ed..2439d53 100644 --- a/src/cli/client.rs +++ b/src/cli/common/client.rs @@ -1,4 +1,5 @@ -use crate::config::NetworkConfig; +use crate::cli::common::{MsgReceiver, MsgSender}; +use crate::options::NetworkSettings; use async_trait::async_trait; use distant_core::net::client::{Client as NetClient, ClientConfig, ReconnectStrategy}; use distant_core::net::common::authentication::msg::*; @@ -10,16 +11,13 @@ use log::*; use std::io; use std::time::Duration; -mod msg; -pub use msg::*; - pub struct Client { - network: NetworkConfig, + network: NetworkSettings, auth_handler: T, } impl Client<()> { - pub fn new(network: NetworkConfig) -> Self { + pub fn new(network: NetworkSettings) -> Self { Self { network, auth_handler: (), @@ -45,7 +43,7 @@ impl Client { impl Client { /// Connect to the manager listening on the socket or windows pipe based on - /// the [`NetworkConfig`] provided to the client earlier. Will return a new instance + /// the [`NetworkSettings`] provided to the client earlier. Will return a new instance /// of the [`ManagerClient`] upon successful connection pub async fn connect(self) -> anyhow::Result { let client = self.connect_impl().await?; diff --git a/src/cli/manager.rs b/src/cli/common/manager.rs similarity index 94% rename from src/cli/manager.rs rename to src/cli/common/manager.rs index 16597f5..d11d138 100644 --- a/src/cli/manager.rs +++ b/src/cli/common/manager.rs @@ -1,6 +1,6 @@ use crate::{ - config::{AccessControl, NetworkConfig}, - paths::{global as global_paths, user as user_paths}, + constants::{global as global_paths, user as user_paths}, + options::{AccessControl, NetworkSettings}, }; use anyhow::Context; use distant_core::net::common::authentication::Verifier; @@ -11,7 +11,7 @@ use log::*; pub struct Manager { pub access: AccessControl, pub config: ManagerConfig, - pub network: NetworkConfig, + pub network: NetworkSettings, } impl Manager { diff --git a/src/cli/client/msg.rs b/src/cli/common/msg.rs similarity index 100% rename from src/cli/client/msg.rs rename to src/cli/common/msg.rs diff --git a/src/cli/spawner.rs b/src/cli/common/spawner.rs similarity index 100% rename from src/cli/spawner.rs rename to src/cli/common/spawner.rs diff --git a/src/config/client/action.rs b/src/config/client/action.rs deleted file mode 100644 index b6169f3..0000000 --- a/src/config/client/action.rs +++ /dev/null @@ -1,8 +0,0 @@ -use clap::Args; -use serde::{Deserialize, Serialize}; - -#[derive(Args, Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct ClientActionConfig { - /// Represents the maximum time (in seconds) to wait for a network request before timing out - pub timeout: Option, -} diff --git a/src/config/client/launch.rs b/src/config/client/launch.rs deleted file mode 100644 index 47607f9..0000000 --- a/src/config/client/launch.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::config::BindAddress; -use clap::Args; -use distant_core::net::common::Map; -use serde::{Deserialize, Serialize}; - -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ClientLaunchConfig { - #[clap(flatten)] - #[serde(flatten)] - pub distant: ClientLaunchDistantConfig, - - /// Additional options to provide, typically forwarded to the handler within the manager - /// facilitating the launch of a distant server. Options are key-value pairs separated by - /// comma. - /// - /// E.g. `key="value",key2="value2"` - #[clap(long, default_value_t)] - pub options: Map, -} - -impl From for ClientLaunchConfig { - fn from(mut map: Map) -> Self { - Self { - distant: ClientLaunchDistantConfig { - bin: map.remove("distant.bin"), - bind_server: map - .remove("distant.bind_server") - .and_then(|x| x.parse::().ok()), - args: map.remove("distant.args"), - }, - options: map, - } - } -} - -impl From for Map { - fn from(config: ClientLaunchConfig) -> Self { - let mut this = Self::new(); - - if let Some(x) = config.distant.bin { - this.insert("distant.bin".to_string(), x); - } - - if let Some(x) = config.distant.bind_server { - this.insert("distant.bind_server".to_string(), x.to_string()); - } - - if let Some(x) = config.distant.args { - this.insert("distant.args".to_string(), x); - } - - this.extend(config.options); - - this - } -} - -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ClientLaunchDistantConfig { - /// Path to distant program on remote machine to execute via ssh; - /// by default, this program needs to be available within PATH as - /// specified when compiling ssh (not your login shell) - #[clap(name = "distant", long)] - pub bin: Option, - - /// Control the IP address that the server binds to. - /// - /// The default is `ssh', in which case the server will reply from the IP address that the SSH - /// connection came from (as found in the SSH_CONNECTION environment variable). This is - /// useful for multihomed servers. - /// - /// With --bind-server=any, the server will reply on the default interface and will not bind to - /// a particular IP address. This can be useful if the connection is made through sslh or - /// another tool that makes the SSH connection appear to come from localhost. - /// - /// With --bind-server=IP, the server will attempt to bind to the specified IP address. - #[clap(name = "distant-bind-server", long, value_name = "ssh|any|IP")] - pub bind_server: Option, - - /// Additional arguments to provide to the server - #[clap(name = "distant-args", long, allow_hyphen_values(true))] - pub args: Option, -} diff --git a/src/config/client/repl.rs b/src/config/client/repl.rs deleted file mode 100644 index a72785c..0000000 --- a/src/config/client/repl.rs +++ /dev/null @@ -1,8 +0,0 @@ -use clap::Args; -use serde::{Deserialize, Serialize}; - -#[derive(Args, Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct ClientReplConfig { - /// Represents the maximum time (in seconds) to wait for a network request before timing out - pub timeout: Option, -} diff --git a/src/config/manager.rs b/src/config/manager.rs deleted file mode 100644 index 17a1421..0000000 --- a/src/config/manager.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::{AccessControl, CommonConfig, NetworkConfig}; -use clap::Args; -use serde::{Deserialize, Serialize}; - -/// Represents configuration settings for the distant manager -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ManagerConfig { - /// Type of access to apply to created unix socket or windows pipe - #[clap(long, value_enum)] - pub access: Option, - - #[clap(flatten)] - #[serde(flatten)] - pub common: CommonConfig, - - #[clap(flatten)] - #[serde(flatten)] - pub network: NetworkConfig, -} diff --git a/src/constants.rs b/src/constants.rs index efa51af..fd1508c 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,5 +1,121 @@ +use directories::ProjectDirs; +use once_cell::sync::Lazy; +use std::path::PathBuf; + /// Represents the maximum size (in bytes) that data will be read from pipes /// per individual `read` call /// /// Current setting is 16k size pub const MAX_PIPE_CHUNK_SIZE: usize = 16384; + +/// Internal name to use for socket files. +const SOCKET_FILE_STR: &str = "distant.sock"; + +/// User-oriented paths. +pub mod user { + use super::*; + + /// Root project directory used to calculate other paths + static PROJECT_DIR: Lazy = Lazy::new(|| { + ProjectDirs::from("", "", "distant").expect("Could not determine valid $HOME path") + }); + + /// Path to configuration settings for distant client/manager/server + pub static CONFIG_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.config_dir().join("config.toml")); + + /// Path to cache file used for arbitrary CLI data + pub static CACHE_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.cache_dir().join("cache.toml")); + + pub static CACHE_FILE_PATH_STR: Lazy = + Lazy::new(|| CACHE_FILE_PATH.to_string_lossy().to_string()); + + /// Path to log file for distant client + pub static CLIENT_LOG_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.cache_dir().join("client.log")); + + /// Path to log file for distant manager + pub static MANAGER_LOG_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.cache_dir().join("manager.log")); + + /// Path to log file for distant server + pub static SERVER_LOG_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.cache_dir().join("server.log")); + + /// Path to log file for distant generate + pub static GENERATE_LOG_FILE_PATH: Lazy = + Lazy::new(|| PROJECT_DIR.cache_dir().join("generate.log")); + + /// For Linux & BSD, this uses the runtime path. For Mac, this uses the tmp path + /// + /// * `/run/user/1001/distant/{user}.distant.sock` on Linux + /// * `/var/run/{user}.distant.sock` on BSD + /// * `/tmp/{user}.distant.dock` on MacOS + pub static UNIX_SOCKET_PATH: Lazy = Lazy::new(|| { + // Form of {user}.distant.sock + let mut file_name = whoami::username_os(); + file_name.push("."); + file_name.push(SOCKET_FILE_STR); + + PROJECT_DIR + .runtime_dir() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(std::env::temp_dir) + .join(file_name) + }); + + /// Name of the pipe used by Windows in the form of `{user}.distant` + pub static WINDOWS_PIPE_NAME: Lazy = + Lazy::new(|| format!("{}.distant", whoami::username())); +} + +/// Global paths. +pub mod global { + use super::*; + + /// Windows ProgramData directory from from the %ProgramData% environment variable + #[cfg(windows)] + static PROGRAM_DATA_DIR: Lazy = Lazy::new(|| { + PathBuf::from(std::env::var("ProgramData").expect("Could not determine %ProgramData%")) + }); + + /// Configuration directory for windows: `%ProgramData%\distant`. + #[cfg(windows)] + static CONFIG_DIR: Lazy = Lazy::new(|| PROGRAM_DATA_DIR.join("distant")); + + /// Configuration directory for unix: `/etc/distant`. + #[cfg(unix)] + static CONFIG_DIR: Lazy = Lazy::new(|| PathBuf::from("/etc").join("distant")); + + /// Path to configuration settings for distant client/manager/server. + pub static CONFIG_FILE_PATH: Lazy = Lazy::new(|| CONFIG_DIR.join("config.toml")); + + /// For Linux & BSD, this uses the runtime path. For Mac, this uses the tmp path + /// + /// * `/run/distant.sock` on Linux + /// * `/var/run/distant.sock` on BSD + /// * `/tmp/distant.dock` on MacOS + /// * `@TERMUX_PREFIX@/var/run/distant.sock` on Android (Termux) + pub static UNIX_SOCKET_PATH: Lazy = Lazy::new(|| { + if cfg!(target_os = "macos") { + std::env::temp_dir().join(SOCKET_FILE_STR) + } else if cfg!(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "openbsd", + target_os = "netbsd" + )) { + PathBuf::from("/var").join("run").join(SOCKET_FILE_STR) + } else if cfg!(target_os = "android") { + PathBuf::from("@TERMUX_PREFIX@/var") + .join("run") + .join(SOCKET_FILE_STR) + } else { + PathBuf::from("/run").join(SOCKET_FILE_STR) + } + }); + + /// Name of the pipe used by Windows. + pub static WINDOWS_PIPE_NAME: Lazy = Lazy::new(|| "distant".to_string()); +} diff --git a/src/lib.rs b/src/lib.rs index e19ce92..bbab03d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,15 +2,14 @@ use derive_more::{Display, Error, From}; use std::process::{ExitCode, Termination}; mod cli; -pub mod config; mod constants; -mod paths; +mod options; #[cfg(windows)] pub mod win_service; -pub use self::config::Config; pub use cli::Cli; +pub use options::Options; /// Wrapper around a [`CliResult`] that provides [`Termination`] support pub struct MainResult(CliResult); diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..95171fd --- /dev/null +++ b/src/options.rs @@ -0,0 +1,4138 @@ +use crate::constants; +use crate::constants::user::CACHE_FILE_PATH_STR; +use clap::builder::TypedValueParser as _; +use clap::{Parser, Subcommand, ValueEnum, ValueHint}; +use clap_complete::Shell as ClapCompleteShell; +use derive_more::IsVariant; +use distant_core::data::{ChangeKind, Environment}; +use distant_core::net::common::{ConnectionId, Destination, Map, PortRange}; +use distant_core::net::server::Shutdown; +use service_manager::ServiceManagerKind; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +mod common; +mod config; + +pub use self::config::*; +pub use common::*; + +/// Primary entrypoint into options & subcommands for the CLI. +#[derive(Debug, PartialEq, Parser)] +#[clap(author, version, about)] +#[clap(name = "distant")] +pub struct Options { + #[clap(flatten)] + pub logging: LoggingSettings, + + /// Configuration file to load instead of the default paths + #[clap(short = 'c', long = "config", global = true, value_parser)] + config_path: Option, + + #[clap(subcommand)] + pub command: DistantSubcommand, +} + +impl Options { + /// Creates a new CLI instance by parsing command-line arguments + pub fn load() -> anyhow::Result { + Self::load_from(std::env::args_os()) + } + + /// Creates a new CLI instance by parsing providing arguments + pub fn load_from(args: I) -> anyhow::Result + where + I: IntoIterator, + T: Into + Clone, + { + let mut this = Self::try_parse_from(args)?; + let config = Config::load_multi(this.config_path.take())?; + this.merge(config); + + // Assign the appropriate log file based on client/manager/server + if this.logging.log_file.is_none() { + // NOTE: We assume that any of these commands will log to the user-specific path + // and that services that run manager will explicitly override the + // log file path + this.logging.log_file = Some(match &this.command { + DistantSubcommand::Client(_) => constants::user::CLIENT_LOG_FILE_PATH.to_path_buf(), + DistantSubcommand::Server(_) => constants::user::SERVER_LOG_FILE_PATH.to_path_buf(), + DistantSubcommand::Generate(_) => { + constants::user::GENERATE_LOG_FILE_PATH.to_path_buf() + } + + // If we are listening as a manager, then we want to log to a manager-specific file + DistantSubcommand::Manager(cmd) if cmd.is_listen() => { + constants::user::MANAGER_LOG_FILE_PATH.to_path_buf() + } + + // Otherwise, if we are performing some operation as a client talking to the + // manager, then we want to log to the client file + DistantSubcommand::Manager(_) => { + constants::user::CLIENT_LOG_FILE_PATH.to_path_buf() + } + }); + } + + Ok(this) + } + + /// Updates options based on configuration values. + fn merge(&mut self, config: Config) { + macro_rules! update_logging { + ($kind:ident) => {{ + self.logging.log_file = self + .logging + .log_file + .take() + .or(config.$kind.logging.log_file); + self.logging.log_level = self.logging.log_level.or(config.$kind.logging.log_level); + }}; + } + + match &mut self.command { + DistantSubcommand::Client(cmd) => { + update_logging!(client); + match cmd { + ClientSubcommand::Api { + network, timeout, .. + } => { + network.merge(config.client.network); + *timeout = timeout.take().or(config.client.api.timeout); + } + ClientSubcommand::Capabilities { network, .. } => { + network.merge(config.client.network); + } + ClientSubcommand::Connect { + network, options, .. + } => { + network.merge(config.client.network); + options.merge(config.client.connect.options, /* keep */ true); + } + ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Copy { network, .. } + | ClientFileSystemSubcommand::Exists { network, .. } + | ClientFileSystemSubcommand::MakeDir { network, .. } + | ClientFileSystemSubcommand::Metadata { network, .. } + | ClientFileSystemSubcommand::Read { network, .. } + | ClientFileSystemSubcommand::Remove { network, .. } + | ClientFileSystemSubcommand::Rename { network, .. } + | ClientFileSystemSubcommand::Search { network, .. } + | ClientFileSystemSubcommand::Watch { network, .. } + | ClientFileSystemSubcommand::Write { network, .. }, + ) => { + network.merge(config.client.network); + } + ClientSubcommand::Launch { + distant_args, + distant_bin, + distant_bind_server, + network, + options, + .. + } => { + network.merge(config.client.network); + options.merge(config.client.launch.options, /* keep */ true); + *distant_args = distant_args.take().or(config.client.launch.distant.args); + *distant_bin = distant_bin.take().or(config.client.launch.distant.bin); + *distant_bind_server = + distant_bind_server + .take() + .or(config.client.launch.distant.bind_server); + } + ClientSubcommand::Shell { network, .. } => { + network.merge(config.client.network); + } + ClientSubcommand::Spawn { network, .. } => { + network.merge(config.client.network); + } + ClientSubcommand::SystemInfo { network, .. } => { + network.merge(config.client.network); + } + } + } + DistantSubcommand::Generate(_) => { + update_logging!(generate); + } + DistantSubcommand::Manager(cmd) => { + update_logging!(manager); + match cmd { + ManagerSubcommand::Capabilities { network, .. } => { + network.merge(config.manager.network); + } + ManagerSubcommand::Info { network, .. } => { + network.merge(config.manager.network); + } + ManagerSubcommand::Kill { network, .. } => { + network.merge(config.manager.network); + } + ManagerSubcommand::List { network, .. } => { + network.merge(config.manager.network); + } + ManagerSubcommand::Listen { + access, network, .. + } => { + *access = access.take().or(config.manager.access); + network.merge(config.manager.network); + } + ManagerSubcommand::Select { network, .. } => { + network.merge(config.manager.network); + } + ManagerSubcommand::Service(_) => (), + } + } + DistantSubcommand::Server(cmd) => { + update_logging!(server); + match cmd { + ServerSubcommand::Listen { + current_dir, + host, + port, + shutdown, + use_ipv6, + .. + } => { + *current_dir = current_dir.take().or(config.server.listen.current_dir); + if host.is_default() && config.server.listen.host.is_some() { + *host = Value::Explicit(config.server.listen.host.unwrap()); + } + if port.is_default() && config.server.listen.port.is_some() { + *port = Value::Explicit(config.server.listen.port.unwrap()); + } + if shutdown.is_default() && config.server.listen.shutdown.is_some() { + *shutdown = Value::Explicit(config.server.listen.shutdown.unwrap()); + } + if !*use_ipv6 && config.server.listen.use_ipv6 { + *use_ipv6 = true; + } + } + } + } + } + } +} + +/// Subcommands for the CLI. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq, Subcommand, IsVariant)] +pub enum DistantSubcommand { + /// Perform client commands + #[clap(flatten)] + Client(ClientSubcommand), + + /// Perform manager commands + #[clap(subcommand)] + Manager(ManagerSubcommand), + + /// Perform server commands + #[clap(subcommand)] + Server(ServerSubcommand), + + /// Perform generation commands + #[clap(subcommand)] + Generate(GenerateSubcommand), +} + +/// Subcommands for `distant client`. +#[derive(Debug, PartialEq, Subcommand, IsVariant)] +pub enum ClientSubcommand { + /// Listen over stdin & stdout to communicate with a distant server using the JSON lines API + Api { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Represents the maximum time (in seconds) to wait for a network request before timing out. + #[clap(long)] + timeout: Option, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + }, + + /// Retrieves capabilities of the remote server + Capabilities { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + #[clap(short, long, default_value_t, value_enum)] + format: Format, + }, + + /// Requests that active manager connects to the server at the specified destination + Connect { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Additional options to provide, typically forwarded to the handler within the manager + /// facilitating the connection. Options are key-value pairs separated by comma. + /// + /// E.g. `key="value",key2="value2"` + #[clap(long, default_value_t)] + options: Map, + + #[clap(flatten)] + network: NetworkSettings, + + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + destination: Box, + }, + + /// Subcommands for file system operations + #[clap(subcommand, name = "fs")] + FileSystem(ClientFileSystemSubcommand), + + /// Launches the server-portion of the binary on a remote machine + Launch { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Path to distant program on remote machine to execute via ssh; + /// by default, this program needs to be available within PATH as + /// specified when compiling ssh (not your login shell) + #[clap(name = "distant", long)] + distant_bin: Option, + + /// Control the IP address that the server binds to. + /// + /// The default is `ssh', in which case the server will reply from the IP address that the SSH + /// connection came from (as found in the SSH_CONNECTION environment variable). This is + /// useful for multihomed servers. + /// + /// With --bind-server=any, the server will reply on the default interface and will not bind to + /// a particular IP address. This can be useful if the connection is made through sslh or + /// another tool that makes the SSH connection appear to come from localhost. + /// + /// With --bind-server=IP, the server will attempt to bind to the specified IP address. + #[clap(name = "distant-bind-server", long, value_name = "ssh|any|IP")] + distant_bind_server: Option, + + /// Additional arguments to provide to the server + #[clap(name = "distant-args", long, allow_hyphen_values(true))] + distant_args: Option, + + /// Additional options to provide, typically forwarded to the handler within the manager + /// facilitating the launch of a distant server. Options are key-value pairs separated by + /// comma. + /// + /// E.g. `key="value",key2="value2"` + #[clap(long, default_value_t)] + options: Map, + + #[clap(flatten)] + network: NetworkSettings, + + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + destination: Box, + }, + + /// Specialized treatment of running a remote shell process + Shell { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Alternative current directory for the remote process + #[clap(long)] + current_dir: Option, + + /// Environment variables to provide to the shell + #[clap(long, default_value_t)] + environment: Environment, + + /// Optional command to run instead of $SHELL + #[clap(name = "CMD", last = true)] + cmd: Option>, + }, + + /// Spawn a process on the remote machine + Spawn { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// If specified, will assume the remote process is a LSP server + /// and will translate paths that are local into distant:// and vice versa + #[clap(long)] + lsp: bool, + + /// If specified, will spawn process using a pseudo tty + #[clap(long)] + pty: bool, + + /// Alternative current directory for the remote process + #[clap(long)] + current_dir: Option, + + /// Environment variables to provide to the shell + #[clap(long, default_value_t)] + environment: Environment, + + /// Command to run + #[clap(name = "CMD", num_args = 1.., last = true)] + cmd: Vec, + }, + + SystemInfo { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + }, +} + +impl ClientSubcommand { + pub fn cache_path(&self) -> &Path { + match self { + Self::Capabilities { cache, .. } => cache.as_path(), + Self::Connect { cache, .. } => cache.as_path(), + Self::FileSystem(fs) => fs.cache_path(), + Self::Launch { cache, .. } => cache.as_path(), + Self::Api { cache, .. } => cache.as_path(), + Self::Shell { cache, .. } => cache.as_path(), + Self::Spawn { cache, .. } => cache.as_path(), + Self::SystemInfo { cache, .. } => cache.as_path(), + } + } + + pub fn network_settings(&self) -> &NetworkSettings { + match self { + Self::Capabilities { network, .. } => network, + Self::Connect { network, .. } => network, + Self::FileSystem(fs) => fs.network_settings(), + Self::Launch { network, .. } => network, + Self::Api { network, .. } => network, + Self::Shell { network, .. } => network, + Self::Spawn { network, .. } => network, + Self::SystemInfo { network, .. } => network, + } + } +} + +/// Subcommands for `distant fs`. +#[derive(Debug, PartialEq, Eq, Subcommand, IsVariant)] +pub enum ClientFileSystemSubcommand { + /// Copies a file or directory on the remote machine + Copy { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// The path to the file or directory on the remote machine + src: PathBuf, + + /// New location on the remote machine for copy of file or directory + dst: PathBuf, + }, + + /// Checks whether the specified path exists on the remote machine + Exists { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// The path to the file or directory on the remote machine + path: PathBuf, + }, + + /// Creates a directory on the remote machine + MakeDir { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Whether or not to create all parent directories + #[clap(long)] + all: bool, + + /// The path to the directory on the remote machine + path: PathBuf, + }, + + /// Retrieves metadata for the specified path on the remote machine + Metadata { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Whether or not to include a canonicalized version of the path, meaning + /// returning the canonical, absolute form of a path with all + /// intermediate components normalized and symbolic links resolved + #[clap(long)] + canonicalize: bool, + + /// Whether or not to follow symlinks to determine absolute file type (dir/file) + #[clap(long)] + resolve_file_type: bool, + + /// The path to the file, directory, or symlink on the remote machine + path: PathBuf, + }, + + /// Reads the contents of a file or retrieves the entries within a directory on the remote + /// machine + Read { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Maximum depth to traverse with 0 indicating there is no maximum + /// depth and 1 indicating the most immediate children within the + /// directory. + /// + /// (directory only) + #[clap(long, default_value_t = 1)] + depth: usize, + + /// Whether or not to return absolute or relative paths. + /// + /// (directory only) + #[clap(long)] + absolute: bool, + + /// Whether or not to canonicalize the resulting paths, meaning + /// returning the canonical, absolute form of a path with all + /// intermediate components normalized and symbolic links resolved. + /// + /// Note that the flag absolute must be true to have absolute paths + /// returned, even if canonicalize is flagged as true. + /// + /// (directory only) + #[clap(long)] + canonicalize: bool, + + /// Whether or not to include the root directory in the retrieved entries. + /// + /// If included, the root directory will also be a canonicalized, + /// absolute path and will not follow any of the other flags. + /// + /// (directory only) + #[clap(long)] + include_root: bool, + + /// The path to the file or directory on the remote machine. + path: PathBuf, + }, + + /// Removes a file or directory on the remote machine + Remove { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Whether or not to remove all contents within directory if is a directory. + /// Does nothing different for files + #[clap(long)] + force: bool, + + /// The path to the file or directory on the remote machine + path: PathBuf, + }, + + /// Moves/renames a file or directory on the remote machine + Rename { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// The path to the file or directory on the remote machine + src: PathBuf, + + /// New location on the remote machine for the file or directory + dst: PathBuf, + }, + + /// Search files & directories on the remote machine + Search { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Kind of data to examine using condition + #[clap(long, value_enum, default_value_t = CliSearchQueryTarget::Contents)] + target: CliSearchQueryTarget, + + /// Condition to meet to be considered a match + #[clap(name = "pattern")] + condition: CliSearchQueryCondition, + + /// Options to apply to the query + #[clap(flatten)] + options: CliSearchQueryOptions, + + /// Paths in which to perform the query + #[clap(default_value = ".")] + paths: Vec, + }, + + /// Watch a path for changes on the remote machine + Watch { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// If true, will recursively watch for changes within directories, othewise + /// will only watch for changes immediately within directories + #[clap(long)] + recursive: bool, + + /// Filter to only report back specified changes + #[ + clap( + long, + value_parser = clap::builder::PossibleValuesParser::new(ChangeKind::variants()) + .map(|s| s.parse::().unwrap()), + ) + ] + only: Vec, + + /// Filter to report back changes except these specified changes + #[ + clap( + long, + value_parser = clap::builder::PossibleValuesParser::new(ChangeKind::variants()) + .map(|s| s.parse::().unwrap()), + ) + ] + except: Vec, + + /// The path to the file, directory, or symlink on the remote machine + path: PathBuf, + }, + + /// Writes the contents to a file on the remote machine + Write { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// If specified, will append to a file versus overwriting it + #[clap(long)] + append: bool, + + /// The path to the file on the remote machine + path: PathBuf, + + /// Data for server-side writing of content. If not provided, will read from stdin. + data: Option, + }, +} + +impl ClientFileSystemSubcommand { + pub fn cache_path(&self) -> &Path { + match self { + Self::Copy { cache, .. } => cache.as_path(), + Self::Exists { cache, .. } => cache.as_path(), + Self::MakeDir { cache, .. } => cache.as_path(), + Self::Metadata { cache, .. } => cache.as_path(), + Self::Read { cache, .. } => cache.as_path(), + Self::Remove { cache, .. } => cache.as_path(), + Self::Rename { cache, .. } => cache.as_path(), + Self::Search { cache, .. } => cache.as_path(), + Self::Watch { cache, .. } => cache.as_path(), + Self::Write { cache, .. } => cache.as_path(), + } + } + + pub fn network_settings(&self) -> &NetworkSettings { + match self { + Self::Copy { network, .. } => network, + Self::Exists { network, .. } => network, + Self::MakeDir { network, .. } => network, + Self::Metadata { network, .. } => network, + Self::Read { network, .. } => network, + Self::Remove { network, .. } => network, + Self::Rename { network, .. } => network, + Self::Search { network, .. } => network, + Self::Watch { network, .. } => network, + Self::Write { network, .. } => network, + } + } +} + +/// Subcommands for `distant generate`. +#[derive(Debug, PartialEq, Eq, Subcommand, IsVariant)] +pub enum GenerateSubcommand { + /// Generate configuration file with base settings + Config { + /// Path to where the configuration file should be created + file: PathBuf, + }, + + /// Generate JSON schema for server request/response + Schema { + /// If specified, will output to the file at the given path instead of stdout + #[clap(long)] + file: Option, + }, + + // Generate completion info for CLI + Completion { + /// If specified, will output to the file at the given path instead of stdout + #[clap(long)] + file: Option, + + /// Specific shell to target for the generated output + #[clap(value_enum, value_parser)] + shell: ClapCompleteShell, + }, +} + +/// Subcommands for `distant manager`. +#[derive(Debug, PartialEq, Eq, Subcommand, IsVariant)] +pub enum ManagerSubcommand { + /// Select the active connection + Select { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Connection to use, otherwise will prompt to select + connection: Option, + + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + #[clap(flatten)] + network: NetworkSettings, + }, + + /// Interact with a manager being run by a service management platform + #[clap(subcommand)] + Service(ManagerServiceSubcommand), + + /// Listen for incoming requests as a manager + Listen { + /// Type of access to apply to created unix socket or windows pipe + #[clap(long, value_enum)] + access: Option, + + /// If specified, will fork the process to run as a standalone daemon + #[clap(long)] + daemon: bool, + + /// If specified, will listen on a user-local unix socket or local windows named pipe + #[clap(long)] + user: bool, + + #[clap(flatten)] + network: NetworkSettings, + }, + + /// Retrieve a list of capabilities that the manager supports + Capabilities { + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + #[clap(flatten)] + network: NetworkSettings, + }, + + /// Retrieve information about a specific connection + Info { + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + id: ConnectionId, + + #[clap(flatten)] + network: NetworkSettings, + }, + + /// List information about all connections + List { + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + #[clap(flatten)] + network: NetworkSettings, + + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + }, + + /// Kill a specific connection + Kill { + #[clap(short, long, default_value_t, value_enum)] + format: Format, + + #[clap(flatten)] + network: NetworkSettings, + + id: ConnectionId, + }, +} + +/// Subcommands for `distant manager service`. +#[derive(Debug, PartialEq, Eq, Subcommand, IsVariant)] +pub enum ManagerServiceSubcommand { + /// Start the manager as a service + Start { + /// Type of service manager used to run this service, defaulting to platform native + #[clap(long, value_enum)] + kind: Option, + + /// If specified, starts as a user-level service + #[clap(long)] + user: bool, + }, + + /// Stop the manager as a service + Stop { + #[clap(long, value_enum)] + kind: Option, + + /// If specified, stops a user-level service + #[clap(long)] + user: bool, + }, + + /// Install the manager as a service + Install { + #[clap(long, value_enum)] + kind: Option, + + /// If specified, installs as a user-level service + #[clap(long)] + user: bool, + }, + + /// Uninstall the manager as a service + Uninstall { + #[clap(long, value_enum)] + kind: Option, + + /// If specified, uninstalls a user-level service + #[clap(long)] + user: bool, + }, +} + +/// Subcommands for `distant server`. +#[derive(Debug, PartialEq, Subcommand, IsVariant)] +pub enum ServerSubcommand { + /// Listen for incoming requests as a server + Listen { + /// Control the IP address that the distant binds to + /// + /// There are three options here: + /// + /// 1. `ssh`: the server will reply from the IP address that the SSH + /// connection came from (as found in the SSH_CONNECTION environment variable). This is + /// useful for multihomed servers. + /// + /// 2. `any`: the server will reply on the default interface and will not bind to + /// a particular IP address. This can be useful if the connection is made through ssh or + /// another tool that makes the SSH connection appear to come from localhost. + /// + /// 3. `IP`: the server will attempt to bind to the specified IP address. + #[clap(long, value_name = "ssh|any|IP", default_value_t = Value::Default(BindAddress::Any))] + host: Value, + + /// Set the port(s) that the server will attempt to bind to + /// + /// This can be in the form of PORT1 or PORT1:PORTN to provide a range of ports. + /// With `--port 0`, the server will let the operating system pick an available TCP port. + /// + /// Please note that this option does not affect the server-side port used by SSH + #[clap(long, value_name = "PORT[:PORT2]", default_value_t = Value::Default(PortRange::EPHEMERAL))] + port: Value, + + /// If specified, will bind to the ipv6 interface if host is "any" instead of ipv4 + #[clap(short = '6', long)] + use_ipv6: bool, + + /// Logic to apply to server when determining when to shutdown automatically + /// + /// 1. "never" means the server will never automatically shut down + /// 2. "after=" means the server will shut down after N seconds + /// 3. "lonely=" means the server will shut down after N seconds with no connections + /// + /// Default is to never shut down + #[clap(long, default_value_t = Value::Default(Shutdown::Never))] + shutdown: Value, + + /// Changes the current working directory (cwd) to the specified directory + #[clap(long)] + current_dir: Option, + + /// If specified, will fork the process to run as a standalone daemon + #[clap(long)] + daemon: bool, + + /// If specified, the server will not generate a key but instead listen on stdin for the next + /// 32 bytes that it will use as the key instead. Receiving less than 32 bytes before stdin + /// is closed is considered an error and any bytes after the first 32 are not used for the key + #[clap(long)] + key_from_stdin: bool, + + /// If specified, will send output to the specified named pipe (internal usage) + #[clap(long, help = None, long_help = None)] + output_to_local_pipe: Option, + }, +} + +/// Represents the format to use for output from a command. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "snake_case")] +pub enum Format { + /// Sends and receives data in JSON format. + Json, + + /// Commands are traditional shell commands and output responses are inline with what is + /// expected of a program's output in a shell. + Shell, +} + +impl Format { + /// Returns true if json format + pub fn is_json(self) -> bool { + matches!(self, Self::Json) + } +} + +impl Default for Format { + fn default() -> Self { + Self::Shell + } +} + +#[cfg(test)] +mod tests { + use super::*; + use distant_core::net::common::Host; + use distant_core::net::map; + use std::time::Duration; + + #[test] + fn distant_api_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Api { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + timeout: None, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + api: ClientApiConfig { timeout: Some(5.0) }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Api { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + timeout: Some(5.0), + }), + } + ); + } + + #[test] + fn distant_api_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Api { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + timeout: Some(99.0), + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + api: ClientApiConfig { timeout: Some(5.0) }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Api { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + timeout: Some(99.0), + }), + } + ); + } + + #[test] + fn distant_capabilities_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Capabilities { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + format: Format::Json, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + connect: ClientConnectConfig { + options: map!("hello" -> "world"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Capabilities { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + format: Format::Json, + }), + } + ); + } + + #[test] + fn distant_capabilities_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Capabilities { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + connect: ClientConnectConfig { + options: map!("hello" -> "world", "config" -> "value"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Capabilities { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + }), + } + ); + } + + #[test] + fn distant_connect_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Connect { + cache: PathBuf::new(), + options: map!(), + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + connect: ClientConnectConfig { + options: map!("hello" -> "world"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Connect { + cache: PathBuf::new(), + options: map!("hello" -> "world"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + } + ); + } + + #[test] + fn distant_connect_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Connect { + cache: PathBuf::new(), + options: map!("hello" -> "test", "cli" -> "value"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + connect: ClientConnectConfig { + options: map!("hello" -> "world", "config" -> "value"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Connect { + cache: PathBuf::new(), + options: map!("hello" -> "test", "cli" -> "value", "config" -> "value"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + } + ); + } + + #[test] + fn distant_launch_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Launch { + cache: PathBuf::new(), + distant_bin: None, + distant_bind_server: None, + distant_args: None, + options: map!(), + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + launch: ClientLaunchConfig { + distant: ClientLaunchDistantConfig { + args: Some(String::from("config-args")), + bin: Some(String::from("config-bin")), + bind_server: Some(BindAddress::Host(Host::Name(String::from( + "config-host", + )))), + }, + options: map!("hello" -> "world"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Launch { + cache: PathBuf::new(), + distant_args: Some(String::from("config-args")), + distant_bin: Some(String::from("config-bin")), + distant_bind_server: Some(BindAddress::Host(Host::Name(String::from( + "config-host", + )))), + options: map!("hello" -> "world"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + } + ); + } + + #[test] + fn distant_launch_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Launch { + cache: PathBuf::new(), + distant_args: Some(String::from("cli-args")), + distant_bin: Some(String::from("cli-bin")), + distant_bind_server: Some(BindAddress::Host(Host::Name(String::from("cli-host")))), + options: map!("hello" -> "test", "cli" -> "value"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + launch: ClientLaunchConfig { + distant: ClientLaunchDistantConfig { + args: Some(String::from("config-args")), + bin: Some(String::from("config-bin")), + bind_server: Some(BindAddress::Host(Host::Name(String::from( + "config-host", + )))), + }, + options: map!("hello" -> "world", "config" -> "value"), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Launch { + cache: PathBuf::new(), + distant_args: Some(String::from("cli-args")), + distant_bin: Some(String::from("cli-bin")), + distant_bind_server: Some(BindAddress::Host(Host::Name(String::from( + "cli-host", + )))), + options: map!("hello" -> "test", "config" -> "value", "cli" -> "value"), + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + format: Format::Json, + destination: Box::new("test://destination".parse().unwrap()), + }), + } + ); + } + + #[test] + fn distant_shell_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Shell { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + current_dir: None, + environment: map!(), + cmd: None, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Shell { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + current_dir: None, + environment: map!(), + cmd: None, + }), + } + ); + } + + #[test] + fn distant_shell_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Shell { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + current_dir: None, + environment: map!(), + cmd: None, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Shell { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + current_dir: None, + environment: map!(), + cmd: None, + }), + } + ); + } + + #[test] + fn distant_spawn_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::Spawn { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + current_dir: None, + environment: map!(), + lsp: true, + pty: true, + cmd: vec![String::from("cmd")], + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::Spawn { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + current_dir: None, + environment: map!(), + lsp: true, + pty: true, + cmd: vec![String::from("cmd")], + }), + } + ); + } + + #[test] + fn distant_spawn_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Spawn { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + current_dir: None, + environment: map!(), + lsp: true, + pty: true, + cmd: vec![String::from("cmd")], + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::Spawn { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + current_dir: None, + environment: map!(), + lsp: true, + pty: true, + cmd: vec![String::from("cmd")], + }), + } + ); + } + + #[test] + fn distant_system_info_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::SystemInfo { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::SystemInfo { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_system_info_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::SystemInfo { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::SystemInfo { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_fs_copy_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Copy { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Copy { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + } + )), + } + ); + } + + #[test] + fn distant_fs_copy_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Copy { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Copy { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + } + )), + } + ); + } + + #[test] + fn distant_fs_exists_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Exists { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Exists { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_exists_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Exists { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Exists { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_makedir_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::MakeDir { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + path: PathBuf::from("path"), + all: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::MakeDir { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + path: PathBuf::from("path"), + all: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_makedir_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::MakeDir { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + all: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::MakeDir { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + all: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_metadata_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Metadata { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + canonicalize: true, + resolve_file_type: true, + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Metadata { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + canonicalize: true, + resolve_file_type: true, + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_metadata_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Metadata { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + canonicalize: true, + resolve_file_type: true, + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Metadata { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + canonicalize: true, + resolve_file_type: true, + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_read_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Read { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + path: PathBuf::from("path"), + depth: 1, + absolute: true, + canonicalize: true, + include_root: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Read { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + path: PathBuf::from("path"), + depth: 1, + absolute: true, + canonicalize: true, + include_root: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_read_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Read { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + depth: 1, + absolute: true, + canonicalize: true, + include_root: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Read { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + depth: 1, + absolute: true, + canonicalize: true, + include_root: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_remove_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Remove { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + path: PathBuf::from("path"), + force: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Remove { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + path: PathBuf::from("path"), + force: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_remove_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Remove { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + force: true, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Remove { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + path: PathBuf::from("path"), + force: true, + } + )), + } + ); + } + + #[test] + fn distant_fs_rename_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Rename { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Rename { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + } + )), + } + ); + } + + #[test] + fn distant_fs_rename_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Rename { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Rename { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + src: PathBuf::from("src"), + dst: PathBuf::from("dst"), + } + )), + } + ); + } + + #[test] + fn distant_fs_search_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Search { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + target: CliSearchQueryTarget::Contents, + condition: CliSearchQueryCondition::regex(".*"), + options: Default::default(), + paths: vec![PathBuf::from(".")], + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Search { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + target: CliSearchQueryTarget::Contents, + condition: CliSearchQueryCondition::regex(".*"), + options: Default::default(), + paths: vec![PathBuf::from(".")], + } + )), + } + ); + } + + #[test] + fn distant_fs_search_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Search { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + target: CliSearchQueryTarget::Contents, + condition: CliSearchQueryCondition::regex(".*"), + options: Default::default(), + paths: vec![PathBuf::from(".")], + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Search { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + target: CliSearchQueryTarget::Contents, + condition: CliSearchQueryCondition::regex(".*"), + options: Default::default(), + paths: vec![PathBuf::from(".")], + } + )), + } + ); + } + + #[test] + fn distant_fs_watch_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Watch { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + recursive: true, + only: ChangeKind::all(), + except: ChangeKind::all(), + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Watch { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + recursive: true, + only: ChangeKind::all(), + except: ChangeKind::all(), + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_watch_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Watch { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + recursive: true, + only: ChangeKind::all(), + except: ChangeKind::all(), + path: PathBuf::from("path"), + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Watch { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + recursive: true, + only: ChangeKind::all(), + except: ChangeKind::all(), + path: PathBuf::from("path"), + } + )), + } + ); + } + + #[test] + fn distant_fs_write_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Write { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + append: false, + path: PathBuf::from("path"), + data: None, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Write { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + append: false, + path: PathBuf::from("path"), + data: None, + } + )), + } + ); + } + + #[test] + fn distant_fs_write_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Write { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + append: false, + path: PathBuf::from("path"), + data: None, + }, + )), + }; + + options.merge(Config { + client: ClientConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Client(ClientSubcommand::FileSystem( + ClientFileSystemSubcommand::Write { + cache: PathBuf::new(), + connection: None, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + append: false, + path: PathBuf::from("path"), + data: None, + } + )), + } + ); + } + + #[test] + fn distant_generate_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Generate(GenerateSubcommand::Completion { + file: None, + shell: ClapCompleteShell::Bash, + }), + }; + + options.merge(Config { + generate: GenerateConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Generate(GenerateSubcommand::Completion { + file: None, + shell: ClapCompleteShell::Bash, + }), + } + ); + } + + #[test] + fn distant_generate_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Generate(GenerateSubcommand::Completion { + file: None, + shell: ClapCompleteShell::Bash, + }), + }; + + options.merge(Config { + generate: GenerateConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Generate(GenerateSubcommand::Completion { + file: None, + shell: ClapCompleteShell::Bash, + }), + } + ); + } + + #[test] + fn distant_manager_capabilities_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Capabilities { + format: Format::Json, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Capabilities { + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_capabilities_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Capabilities { + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Capabilities { + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_info_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Info { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Info { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_info_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Info { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Info { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_kill_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Kill { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Kill { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_kill_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Kill { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Kill { + id: 0, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_list_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::List { + cache: PathBuf::new(), + format: Format::Json, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::List { + cache: PathBuf::new(), + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_list_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::List { + cache: PathBuf::new(), + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::List { + cache: PathBuf::new(), + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_listen_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Listen { + access: None, + daemon: false, + user: false, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + access: Some(AccessControl::Group), + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Listen { + access: Some(AccessControl::Group), + daemon: false, + user: false, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_listen_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Listen { + access: Some(AccessControl::Owner), + daemon: false, + user: false, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + access: Some(AccessControl::Group), + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Listen { + access: Some(AccessControl::Owner), + daemon: false, + user: false, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_select_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Select { + cache: PathBuf::new(), + connection: None, + format: Format::Json, + network: NetworkSettings { + unix_socket: None, + windows_pipe: None, + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Select { + cache: PathBuf::new(), + connection: None, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_select_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Select { + cache: PathBuf::new(), + connection: None, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("config-unix-socket")), + windows_pipe: Some(String::from("config-windows-pipe")), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Select { + cache: PathBuf::new(), + connection: None, + format: Format::Json, + network: NetworkSettings { + unix_socket: Some(PathBuf::from("cli-unix-socket")), + windows_pipe: Some(String::from("cli-windows-pipe")), + }, + }), + } + ); + } + + #[test] + fn distant_manager_service_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Service( + ManagerServiceSubcommand::Install { + kind: None, + user: false, + }, + )), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Service( + ManagerServiceSubcommand::Install { + kind: None, + user: false, + }, + )), + } + ); + } + + #[test] + fn distant_manager_service_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Service( + ManagerServiceSubcommand::Install { + kind: None, + user: false, + }, + )), + }; + + options.merge(Config { + manager: ManagerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Manager(ManagerSubcommand::Service( + ManagerServiceSubcommand::Install { + kind: None, + user: false, + }, + )), + } + ); + } + + #[test] + fn distant_server_listen_should_support_merging_with_config() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: None, + log_level: None, + }, + command: DistantSubcommand::Server(ServerSubcommand::Listen { + host: Value::Default(BindAddress::Any), + port: Value::Default(PortRange::single(123)), + use_ipv6: false, + shutdown: Value::Default(Shutdown::After(Duration::from_secs(123))), + current_dir: None, + daemon: false, + key_from_stdin: false, + output_to_local_pipe: None, + }), + }; + + options.merge(Config { + server: ServerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + listen: ServerListenConfig { + host: Some(BindAddress::Ssh), + port: Some(PortRange::single(456)), + use_ipv6: true, + shutdown: Some(Shutdown::Lonely(Duration::from_secs(456))), + current_dir: Some(PathBuf::from("config-dir")), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + command: DistantSubcommand::Server(ServerSubcommand::Listen { + host: Value::Explicit(BindAddress::Ssh), + port: Value::Explicit(PortRange::single(456)), + use_ipv6: true, + shutdown: Value::Explicit(Shutdown::Lonely(Duration::from_secs(456))), + current_dir: Some(PathBuf::from("config-dir")), + daemon: false, + key_from_stdin: false, + output_to_local_pipe: None, + }), + } + ); + } + + #[test] + fn distant_server_listen_should_prioritize_explicit_cli_options_when_merging() { + let mut options = Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Server(ServerSubcommand::Listen { + host: Value::Explicit(BindAddress::Any), + port: Value::Explicit(PortRange::single(123)), + use_ipv6: true, + shutdown: Value::Explicit(Shutdown::After(Duration::from_secs(123))), + current_dir: Some(PathBuf::from("cli-dir")), + daemon: false, + key_from_stdin: false, + output_to_local_pipe: None, + }), + }; + + options.merge(Config { + server: ServerConfig { + logging: LoggingSettings { + log_file: Some(PathBuf::from("config-log-file")), + log_level: Some(LogLevel::Trace), + }, + listen: ServerListenConfig { + host: Some(BindAddress::Ssh), + port: Some(PortRange::single(456)), + use_ipv6: false, + shutdown: Some(Shutdown::Lonely(Duration::from_secs(456))), + current_dir: Some(PathBuf::from("config-dir")), + }, + }, + ..Default::default() + }); + + assert_eq!( + options, + Options { + config_path: None, + logging: LoggingSettings { + log_file: Some(PathBuf::from("cli-log-file")), + log_level: Some(LogLevel::Info), + }, + command: DistantSubcommand::Server(ServerSubcommand::Listen { + host: Value::Explicit(BindAddress::Any), + port: Value::Explicit(PortRange::single(123)), + use_ipv6: true, + shutdown: Value::Explicit(Shutdown::After(Duration::from_secs(123))), + current_dir: Some(PathBuf::from("cli-dir")), + daemon: false, + key_from_stdin: false, + output_to_local_pipe: None, + }), + } + ); + } +} diff --git a/src/options/common.rs b/src/options/common.rs new file mode 100644 index 0000000..2251fbf --- /dev/null +++ b/src/options/common.rs @@ -0,0 +1,13 @@ +mod address; +mod cmd; +mod logging; +mod network; +mod search; +mod value; + +pub use address::*; +pub use cmd::*; +pub use logging::*; +pub use network::*; +pub use search::*; +pub use value::*; diff --git a/src/config/server/listen.rs b/src/options/common/address.rs similarity index 67% rename from src/config/server/listen.rs rename to src/options/common/address.rs index 6fdc22d..55621a0 100644 --- a/src/config/server/listen.rs +++ b/src/options/common/address.rs @@ -1,109 +1,11 @@ use anyhow::Context; -use clap::Args; -use distant_core::net::common::{Host, HostParseError, Map, PortRange}; -use distant_core::net::server::Shutdown; +use distant_core::net::common::{Host, HostParseError}; use serde::{Deserialize, Serialize}; -use std::{ - env, fmt, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - path::PathBuf, - str::FromStr, -}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; +use std::{env, fmt}; -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServerListenConfig { - /// Control the IP address that the distant binds to - /// - /// There are three options here: - /// - /// 1. `ssh`: the server will reply from the IP address that the SSH - /// connection came from (as found in the SSH_CONNECTION environment variable). This is - /// useful for multihomed servers. - /// - /// 2. `any`: the server will reply on the default interface and will not bind to - /// a particular IP address. This can be useful if the connection is made through ssh or - /// another tool that makes the SSH connection appear to come from localhost. - /// - /// 3. `IP`: the server will attempt to bind to the specified IP address. - #[clap(long, value_name = "ssh|any|IP")] - pub host: Option, - - /// Set the port(s) that the server will attempt to bind to - /// - /// This can be in the form of PORT1 or PORT1:PORTN to provide a range of ports. - /// With `--port 0`, the server will let the operating system pick an available TCP port. - /// - /// Please note that this option does not affect the server-side port used by SSH - #[clap(long, value_name = "PORT[:PORT2]")] - pub port: Option, - - /// If specified, will bind to the ipv6 interface if host is "any" instead of ipv4 - #[clap(short = '6', long)] - pub use_ipv6: bool, - - /// Logic to apply to server when determining when to shutdown automatically - /// - /// 1. "never" means the server will never automatically shut down - /// 2. "after=" means the server will shut down after N seconds - /// 3. "lonely=" means the server will shut down after N seconds with no connections - /// - /// Default is to never shut down - #[clap(long)] - pub shutdown: Option, - - /// Changes the current working directory (cwd) to the specified directory - #[clap(long)] - pub current_dir: Option, -} - -impl From for ServerListenConfig { - fn from(mut map: Map) -> Self { - Self { - host: map - .remove("host") - .and_then(|x| x.parse::().ok()), - port: map.remove("port").and_then(|x| x.parse::().ok()), - use_ipv6: map - .remove("use_ipv6") - .and_then(|x| x.parse::().ok()) - .unwrap_or_default(), - shutdown: map - .remove("shutdown") - .and_then(|x| x.parse::().ok()), - current_dir: map - .remove("current_dir") - .and_then(|x| x.parse::().ok()), - } - } -} - -impl From for Map { - fn from(config: ServerListenConfig) -> Self { - let mut this = Self::new(); - - if let Some(x) = config.host { - this.insert("host".to_string(), x.to_string()); - } - - if let Some(x) = config.port { - this.insert("port".to_string(), x.to_string()); - } - - this.insert("use_ipv6".to_string(), config.use_ipv6.to_string()); - - if let Some(x) = config.shutdown { - this.insert("shutdown".to_string(), x.to_string()); - } - - if let Some(x) = config.current_dir { - this.insert("current_dir".to_string(), x.to_string_lossy().to_string()); - } - - this - } -} - -/// Represents options for binding a server to an IP address +/// Represents options for binding a server to an IP address. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BindAddress { /// Should read address from `SSH_CONNECTION` environment variable, which contains four @@ -115,11 +17,11 @@ pub enum BindAddress { /// * server port number Ssh, - /// Should bind to `0.0.0.0` or `::` depending on ipv6 flag + /// Should bind to `0.0.0.0` or `::` depending on ipv6 flag. Any, /// Should bind to the specified host, which could be `example.com`, `localhost`, or an IP - /// address like `203.0.113.1` or `2001:DB8::1` + /// address like `203.0.113.1` or `2001:DB8::1`. Host(Host), } @@ -149,6 +51,7 @@ impl FromStr for BindAddress { } impl Serialize for BindAddress { + /// Will store the address as a string. fn serialize(&self, serializer: S) -> Result where S: serde::ser::Serializer, @@ -158,6 +61,7 @@ impl Serialize for BindAddress { } impl<'de> Deserialize<'de> for BindAddress { + /// Will parse a string into an address. fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, diff --git a/src/options/common/cmd.rs b/src/options/common/cmd.rs new file mode 100644 index 0000000..78c39c1 --- /dev/null +++ b/src/options/common/cmd.rs @@ -0,0 +1,146 @@ +use clap::Args; +use std::fmt; +use std::str::FromStr; + +/// Represents some command with arguments to execute. +/// +/// NOTE: Must be derived with `#[clap(flatten)]` to properly take effect. +#[derive(Args, Clone, Debug, PartialEq, Eq)] +pub struct Cmd { + /// The command to execute. + #[clap(name = "CMD")] + cmd: String, + + /// Arguments to provide to the command. + #[clap(name = "ARGS")] + args: Vec, +} + +impl Cmd { + /// Creates a new command from the given `cmd`. + pub fn new(cmd: C, args: I) -> Self + where + C: Into, + I: Iterator, + A: Into, + { + Self { + cmd: cmd.into(), + args: args.map(Into::into).collect(), + } + } +} + +impl From for String { + fn from(cmd: Cmd) -> Self { + cmd.to_string() + } +} + +impl fmt::Display for Cmd { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.cmd)?; + for arg in self.args.iter() { + write!(f, " {arg}")?; + } + Ok(()) + } +} + +impl<'a> From<&'a str> for Cmd { + /// Parses `s` into [`Cmd`], or panics if unable to parse. + fn from(s: &'a str) -> Self { + s.parse().expect("Failed to parse into cmd") + } +} + +impl FromStr for Cmd { + type Err = Box; + + fn from_str(s: &str) -> Result { + let tokens = if cfg!(unix) { + shell_words::split(s)? + } else if cfg!(windows) { + winsplit::split(s) + } else { + unreachable!( + "FromStr: Unsupported operating system outside Unix and Windows families!" + ); + }; + + // If we get nothing, then we want an empty command + if tokens.is_empty() { + return Ok(Self { + cmd: String::new(), + args: Vec::new(), + }); + } + + let mut it = tokens.into_iter(); + Ok(Self { + cmd: it.next().unwrap(), + args: it.collect(), + }) + } +} + +/* +impl FromArgMatches for Cmd { + fn from_arg_matches(matches: &ArgMatches) -> Result { + let mut matches = matches.clone(); + Self::from_arg_matches_mut(&mut matches) + } + fn from_arg_matches_mut(matches: &mut ArgMatches) -> Result { + let cmd = matches.get_one::("cmd").ok_or_else(|| { + Error::raw( + ErrorKind::MissingRequiredArgument, + "program must be specified", + ) + })?; + let args: Vec = matches + .get_many::("arg") + .unwrap_or_default() + .map(ToString::to_string) + .collect(); + Ok(Self::new(format!("{cmd} {}", args.join(" ")))) + } + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> { + let mut matches = matches.clone(); + self.update_from_arg_matches_mut(&mut matches) + } + fn update_from_arg_matches_mut(&mut self, _matches: &mut ArgMatches) -> Result<(), Error> { + Ok(()) + } +} + +impl Args for Cmd { + fn augment_args(cmd: Command) -> Command { + cmd.arg( + Arg::new("cmd") + .required(true) + .value_name("CMD") + .help("") + .action(ArgAction::Set), + ) + .trailing_var_arg(true) + .arg( + Arg::new("arg") + .value_name("ARGS") + .num_args(1..) + .action(ArgAction::Append), + ) + } + fn augment_args_for_update(cmd: Command) -> Command { + Self::augment_args(cmd) + } +} */ + +/* #[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_cmd() { + Cmd::augment_args(Command::new("distant")).debug_assert(); + } +} */ diff --git a/src/config/common.rs b/src/options/common/logging.rs similarity index 89% rename from src/config/common.rs rename to src/options/common/logging.rs index d7341ab..d653b1f 100644 --- a/src/config/common.rs +++ b/src/options/common/logging.rs @@ -2,6 +2,25 @@ use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Contains settings are associated with logging. +#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoggingSettings { + /// Log level to use throughout the application + #[clap(long, global = true, value_enum)] + pub log_level: Option, + + /// Path to file to use for logging + #[clap(long, global = true)] + pub log_file: Option, +} + +impl LoggingSettings { + pub fn log_level_or_default(&self) -> LogLevel { + self.log_level.as_ref().copied().unwrap_or_default() + } +} + +/// Represents the level associated with logging. #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] #[clap(rename_all = "snake_case")] #[serde(rename_all = "snake_case")] @@ -32,21 +51,3 @@ impl Default for LogLevel { Self::Info } } - -/// Contains options that are common across subcommands -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct CommonConfig { - /// Log level to use throughout the application - #[clap(long, global = true, value_enum)] - pub log_level: Option, - - /// Path to file to use for logging - #[clap(long, global = true)] - pub log_file: Option, -} - -impl CommonConfig { - pub fn log_level_or_default(&self) -> LogLevel { - self.log_level.as_ref().copied().unwrap_or_default() - } -} diff --git a/src/config/network.rs b/src/options/common/network.rs similarity index 79% rename from src/config/network.rs rename to src/options/common/network.rs index 8f5c330..a26879d 100644 --- a/src/config/network.rs +++ b/src/options/common/network.rs @@ -1,3 +1,4 @@ +use crate::constants; use clap::Args; use serde::{Deserialize, Serialize}; @@ -36,7 +37,7 @@ impl Default for AccessControl { /// Represents common networking configuration #[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct NetworkConfig { +pub struct NetworkSettings { /// Override the path to the Unix socket used by the manager (unix-only) #[clap(long)] pub unix_socket: Option, @@ -46,12 +47,12 @@ pub struct NetworkConfig { pub windows_pipe: Option, } -impl NetworkConfig { - pub fn merge(self, other: Self) -> Self { - Self { - unix_socket: self.unix_socket.or(other.unix_socket), - windows_pipe: self.windows_pipe.or(other.windows_pipe), - } +impl NetworkSettings { + /// Merge these settings with the `other` settings. These settings take priority + /// over the `other` settings. + pub fn merge(&mut self, other: Self) { + self.unix_socket = self.unix_socket.take().or(other.unix_socket); + self.windows_pipe = self.windows_pipe.take().or(other.windows_pipe); } /// Returns option containing reference to unix path if configured @@ -70,8 +71,8 @@ impl NetworkConfig { match self.unix_socket.as_deref() { Some(path) => vec![path], None => vec![ - crate::paths::user::UNIX_SOCKET_PATH.as_path(), - crate::paths::global::UNIX_SOCKET_PATH.as_path(), + constants::user::UNIX_SOCKET_PATH.as_path(), + constants::global::UNIX_SOCKET_PATH.as_path(), ], } } @@ -82,8 +83,8 @@ impl NetworkConfig { match self.windows_pipe.as_deref() { Some(name) => vec![name], None => vec![ - crate::paths::user::WINDOWS_PIPE_NAME.as_str(), - crate::paths::global::WINDOWS_PIPE_NAME.as_str(), + constants::user::WINDOWS_PIPE_NAME.as_str(), + constants::global::WINDOWS_PIPE_NAME.as_str(), ], } } diff --git a/src/options/common/search.rs b/src/options/common/search.rs new file mode 100644 index 0000000..23adb08 --- /dev/null +++ b/src/options/common/search.rs @@ -0,0 +1,81 @@ +use clap::{Args, ValueEnum}; +use distant_core::data::FileType; +use distant_core::data::{SearchQueryOptions, SearchQueryTarget}; +use std::collections::HashSet; + +pub use distant_core::data::SearchQueryCondition as CliSearchQueryCondition; + +/// Options to customize the search results. +#[derive(Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct CliSearchQueryOptions { + /// Restrict search to only these file types (otherwise all are allowed) + #[clap(skip)] + pub allowed_file_types: HashSet, + + /// Regex to use to filter paths being searched to only those that match the include condition + #[clap(long)] + pub include: Option, + + /// Regex to use to filter paths being searched to only those that do not match the exclude + /// condition + #[clap(long)] + pub exclude: Option, + + /// Search should follow symbolic links + #[clap(long)] + pub follow_symbolic_links: bool, + + /// Maximum results to return before stopping the query + #[clap(long)] + pub limit: Option, + + /// Maximum depth (directories) to search + /// + /// The smallest depth is 0 and always corresponds to the path given to the new function on + /// this type. Its direct descendents have depth 1, and their descendents have depth 2, and so + /// on. + /// + /// Note that this will not simply filter the entries of the iterator, but it will actually + /// avoid descending into directories when the depth is exceeded. + #[clap(long)] + pub max_depth: Option, + + /// Amount of results to batch before sending back excluding final submission that will always + /// include the remaining results even if less than pagination request + #[clap(long)] + pub pagination: Option, +} + +impl From for SearchQueryOptions { + fn from(x: CliSearchQueryOptions) -> Self { + Self { + allowed_file_types: x.allowed_file_types, + include: x.include, + exclude: x.exclude, + follow_symbolic_links: x.follow_symbolic_links, + limit: x.limit, + max_depth: x.max_depth, + pagination: x.pagination, + } + } +} + +/// Kind of data to examine using conditions +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "snake_case")] +pub enum CliSearchQueryTarget { + /// Checks path of file, directory, or symlink + Path, + + /// Checks contents of files + Contents, +} + +impl From for SearchQueryTarget { + fn from(x: CliSearchQueryTarget) -> Self { + match x { + CliSearchQueryTarget::Contents => Self::Contents, + CliSearchQueryTarget::Path => Self::Path, + } + } +} diff --git a/src/options/common/value.rs b/src/options/common/value.rs new file mode 100644 index 0000000..25ee7c0 --- /dev/null +++ b/src/options/common/value.rs @@ -0,0 +1,124 @@ +use derive_more::{Display, IsVariant}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; + +/// Represents a value for some CLI option or config. This exists to support optional values that +/// have a default value so we can distinguish if a CLI value was a default or explicitly defined. +#[derive(Copy, Clone, Debug, Display, IsVariant)] +pub enum Value { + /// Value is a default representation. + Default(T), + + /// Value is explicitly defined by the user. + Explicit(T), +} + +impl Value { + pub fn into_inner(self) -> T { + match self { + Self::Default(x) => x, + Self::Explicit(x) => x, + } + } +} + +impl AsRef for Value { + fn as_ref(&self) -> &T { + match self { + Value::Default(x) => x, + Value::Explicit(x) => x, + } + } +} + +impl AsMut for Value { + fn as_mut(&mut self) -> &mut T { + match self { + Value::Default(x) => x, + Value::Explicit(x) => x, + } + } +} + +impl Deref for Value { + type Target = T; + + fn deref(&self) -> &Self::Target { + AsRef::as_ref(self) + } +} + +impl DerefMut for Value { + fn deref_mut(&mut self) -> &mut Self::Target { + AsMut::as_mut(self) + } +} +/* +impl Into for Value { + fn into(self) -> T { + match self { + Self::Default(x) => x, + Self::Explicit(x) => x, + } + } +} */ + +impl PartialEq for Value +where + T: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + AsRef::as_ref(self) == AsRef::as_ref(other) + } +} + +impl PartialEq for Value +where + T: PartialEq, +{ + fn eq(&self, other: &T) -> bool { + AsRef::as_ref(self) == other + } +} + +impl FromStr for Value +where + T: FromStr, +{ + type Err = T::Err; + + /// Parses `s` into [Value], placing the result into the explicit variant. + fn from_str(s: &str) -> Result { + Ok(Self::Explicit(T::from_str(s)?)) + } +} + +impl Serialize for Value +where + T: Serialize, +{ + /// Serializes the underlying data within [Value]. The origin of the value (default vs + /// explicit) is not stored as config files using serialization are all explicitly set. + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + T::serialize(self, serializer) + } +} + +impl<'de, T> Deserialize<'de> for Value +where + T: Deserialize<'de>, +{ + /// Deserializes into an explicit variant of [Value]. It is assumed that any value coming from + /// a format like a config.toml is explicitly defined and not a default, even though we have a + /// default config.toml available. + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self::Explicit(T::deserialize(deserializer)?)) + } +} diff --git a/src/config.rs b/src/options/config.rs similarity index 73% rename from src/config.rs rename to src/options/config.rs index 02aec93..204525c 100644 --- a/src/config.rs +++ b/src/options/config.rs @@ -1,25 +1,18 @@ -use crate::paths; +use super::common; +use crate::constants; use anyhow::Context; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::{ - io, - path::{Path, PathBuf}, -}; -use toml_edit::Document; +use std::path::PathBuf; mod client; -mod common; mod generate; mod manager; -mod network; mod server; pub use client::*; -pub use common::*; pub use generate::*; pub use manager::*; -pub use network::*; pub use server::*; const DEFAULT_RAW_STR: &str = include_str!("config.toml"); @@ -53,8 +46,8 @@ impl Config { } None => { let paths = vec![ - paths::global::CONFIG_FILE_PATH.as_path(), - paths::user::CONFIG_FILE_PATH.as_path(), + constants::global::CONFIG_FILE_PATH.as_path(), + constants::user::CONFIG_FILE_PATH.as_path(), ]; match (paths[0].exists(), paths[1].exists()) { @@ -77,60 +70,13 @@ impl Config { } /// Loads the specified `path` as a [`Config`] - pub async fn load(path: impl AsRef) -> anyhow::Result { + #[cfg(test)] + pub async fn load(path: impl AsRef) -> anyhow::Result { let bytes = tokio::fs::read(path.as_ref()) .await .with_context(|| format!("Failed to read config file {:?}", path.as_ref()))?; toml_edit::de::from_slice(&bytes).context("Failed to parse config") } - - /// Like `edit` but will succeed without invoking `f` if the path is not found - pub async fn edit_if_exists( - path: impl AsRef, - f: impl FnOnce(&mut Document) -> io::Result<()>, - ) -> io::Result<()> { - Self::edit(path, f).await.or_else(|x| { - if x.kind() == io::ErrorKind::NotFound { - Ok(()) - } else { - Err(x) - } - }) - } - - /// Loads the specified `path` as a [`Document`], performs changes to the document using `f`, - /// and overwrites the `path` with the updated [`Document`] - pub async fn edit( - path: impl AsRef, - f: impl FnOnce(&mut Document) -> io::Result<()>, - ) -> io::Result<()> { - let mut document = tokio::fs::read_to_string(path.as_ref()) - .await? - .parse::() - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; - f(&mut document)?; - tokio::fs::write(path, document.to_string()).await - } - - /// Saves the [`Config`] to the specified `path` only if the path points to no file - pub async fn save_if_not_found(&self, path: impl AsRef) -> io::Result<()> { - use tokio::io::AsyncWriteExt; - let text = toml_edit::ser::to_string_pretty(self) - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; - tokio::fs::OpenOptions::new() - .create_new(true) - .open(path) - .await? - .write_all(text.as_bytes()) - .await - } - - /// Saves the [`Config`] to the specified `path`, overwriting the file if it exists - pub async fn save(&self, path: impl AsRef) -> io::Result<()> { - let text = toml_edit::ser::to_string_pretty(self) - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; - tokio::fs::write(path, text).await - } } impl Default for Config { @@ -147,6 +93,7 @@ impl Default for Config { #[cfg(test)] mod tests { use super::*; + use common::*; use distant_core::net::common::{Host, Map, PortRange}; use distant_core::net::map; use distant_core::net::server::Shutdown; @@ -161,11 +108,7 @@ mod tests { config, Config { client: ClientConfig { - action: ClientActionConfig { timeout: Some(0.) }, - common: CommonConfig { - log_level: Some(LogLevel::Info), - log_file: None - }, + api: ClientApiConfig { timeout: Some(0.) }, connect: ClientConnectConfig { options: Map::new() }, @@ -177,34 +120,33 @@ mod tests { }, options: Map::new(), }, - network: NetworkConfig { + logging: LoggingSettings { + log_level: Some(LogLevel::Info), + log_file: None + }, + network: NetworkSettings { unix_socket: None, windows_pipe: None }, - repl: ClientReplConfig { timeout: Some(0.) }, }, generate: GenerateConfig { - common: CommonConfig { + logging: LoggingSettings { log_level: Some(LogLevel::Info), log_file: None }, }, manager: ManagerConfig { access: Some(AccessControl::Owner), - common: CommonConfig { + logging: LoggingSettings { log_level: Some(LogLevel::Info), log_file: None }, - network: NetworkConfig { + network: NetworkSettings { unix_socket: None, windows_pipe: None }, }, server: ServerConfig { - common: CommonConfig { - log_level: Some(LogLevel::Info), - log_file: None - }, listen: ServerListenConfig { host: Some(BindAddress::Any), port: Some(0.into()), @@ -212,13 +154,17 @@ mod tests { shutdown: Some(Shutdown::Never), current_dir: None, }, + logging: LoggingSettings { + log_level: Some(LogLevel::Info), + log_file: None + }, }, } ); } #[test(tokio::test)] - async fn default_should_parse_config_from_specified_file() { + async fn load_should_parse_config_from_specified_file() { use assert_fs::prelude::*; let config_file = assert_fs::NamedTempFile::new("config.toml").unwrap(); config_file @@ -230,8 +176,8 @@ log_level = "trace" unix_socket = "client-unix-socket" windows_pipe = "client-windows-pipe" -[client.action] -timeout = 123 +[client.api] +timeout = 456 [client.connect] options = "key=\"value\",key2=\"value2\"" @@ -242,9 +188,6 @@ bind_server = "any" args = "a b c" options = "key3=\"value3\",key4=\"value4\"" -[client.repl] -timeout = 456 - [generate] log_file = "generate-log-file" log_level = "debug" @@ -275,12 +218,8 @@ current_dir = "server-current-dir" config, Config { client: ClientConfig { - action: ClientActionConfig { - timeout: Some(123.) - }, - common: CommonConfig { - log_level: Some(LogLevel::Trace), - log_file: Some(PathBuf::from("client-log-file")), + api: ClientApiConfig { + timeout: Some(456.) }, connect: ClientConnectConfig { options: map!("key" -> "value", "key2" -> "value2"), @@ -293,36 +232,33 @@ current_dir = "server-current-dir" }, options: map!("key3" -> "value3", "key4" -> "value4"), }, - network: NetworkConfig { + logging: LoggingSettings { + log_level: Some(LogLevel::Trace), + log_file: Some(PathBuf::from("client-log-file")), + }, + network: NetworkSettings { unix_socket: Some(PathBuf::from("client-unix-socket")), windows_pipe: Some(String::from("client-windows-pipe")) }, - repl: ClientReplConfig { - timeout: Some(456.) - }, }, generate: GenerateConfig { - common: CommonConfig { + logging: LoggingSettings { log_level: Some(LogLevel::Debug), log_file: Some(PathBuf::from("generate-log-file")) }, }, manager: ManagerConfig { access: Some(AccessControl::Anyone), - common: CommonConfig { + logging: LoggingSettings { log_level: Some(LogLevel::Warn), log_file: Some(PathBuf::from("manager-log-file")) }, - network: NetworkConfig { + network: NetworkSettings { unix_socket: Some(PathBuf::from("manager-unix-socket")), windows_pipe: Some(String::from("manager-windows-pipe")), }, }, server: ServerConfig { - common: CommonConfig { - log_level: Some(LogLevel::Error), - log_file: Some(PathBuf::from("server-log-file")), - }, listen: ServerListenConfig { host: Some(BindAddress::Host(Host::Ipv4(Ipv4Addr::new(127, 0, 0, 1)))), port: Some(PortRange { @@ -333,6 +269,10 @@ current_dir = "server-current-dir" shutdown: Some(Shutdown::After(Duration::from_secs(123))), current_dir: Some(PathBuf::from("server-current-dir")), }, + logging: LoggingSettings { + log_level: Some(LogLevel::Error), + log_file: Some(PathBuf::from("server-log-file")), + }, }, } ); diff --git a/src/config.toml b/src/options/config.toml similarity index 94% rename from src/config.toml rename to src/options/config.toml index 4a43136..2fb8d46 100644 --- a/src/config.toml +++ b/src/options/config.toml @@ -15,6 +15,13 @@ # The default setting is info log_level = "info" +# Configuration related to the client's api command +[client.api] + +# Maximum time (in seconds) to wait for a network request before timing out +# where 0 indicates no timeout will occur +timeout = 0 + # Alternative unix domain socket to connect to when using a manger (Unix only) # unix_socket = "path/to/socket" @@ -22,13 +29,6 @@ log_level = "info" # manager (Windows only) # windows_pipe = "some_name" -# Configuration related to the client's action command -[client.action] - -# Maximum time (in seconds) to wait for a network request before timing out -# where 0 indicates no timeout will occur -timeout = 0 - # Configuration related to the client's connect command [client.connect] @@ -72,13 +72,6 @@ args = "" # E.g. `key="value",key2="value2"` options = "" -# Configuration related to the client's repl command -[client.repl] - -# Maximum time (in seconds) to wait for a network request before timing out -# where 0 indicates no timeout will occur -timeout = 0 - ############################################################################### # All configuration specific to the distant generate option will be found under # this heading @@ -115,7 +108,7 @@ log_level = "info" # # * "owner": equates to `0o600` on Unix (read & write for owner). # * "group": equates to `0o660` on Unix (read & write for owner and group). -# # "anyone": equates to `0o666` on Unix (read & write for owner, group, and other). +# * "anyone": equates to `0o666` on Unix (read & write for owner, group, and other). access = "owner" # Alternative unix domain socket to listen on (Unix only) diff --git a/src/config/client.rs b/src/options/config/client.rs similarity index 62% rename from src/config/client.rs rename to src/options/config/client.rs index 47eccec..cb8963c 100644 --- a/src/config/client.rs +++ b/src/options/config/client.rs @@ -1,27 +1,24 @@ -use super::{CommonConfig, NetworkConfig}; +use super::common::{self, LoggingSettings, NetworkSettings}; use serde::{Deserialize, Serialize}; -mod action; +mod api; mod connect; mod launch; -mod repl; -pub use action::*; +pub use api::*; pub use connect::*; pub use launch::*; -pub use repl::*; /// Represents configuration settings for the distant client #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ClientConfig { #[serde(flatten)] - pub common: CommonConfig, + pub logging: LoggingSettings, - pub action: ClientActionConfig, + #[serde(flatten)] + pub network: NetworkSettings, + + pub api: ClientApiConfig, pub connect: ClientConnectConfig, pub launch: ClientLaunchConfig, - pub repl: ClientReplConfig, - - #[serde(flatten)] - pub network: NetworkConfig, } diff --git a/src/options/config/client/api.rs b/src/options/config/client/api.rs new file mode 100644 index 0000000..34c1566 --- /dev/null +++ b/src/options/config/client/api.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ClientApiConfig { + pub timeout: Option, +} diff --git a/src/config/client/connect.rs b/src/options/config/client/connect.rs similarity index 54% rename from src/config/client/connect.rs rename to src/options/config/client/connect.rs index fc63cc1..039082d 100644 --- a/src/config/client/connect.rs +++ b/src/options/config/client/connect.rs @@ -1,14 +1,8 @@ -use clap::Args; use distant_core::net::common::Map; use serde::{Deserialize, Serialize}; -#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientConnectConfig { - /// Additional options to provide, typically forwarded to the handler within the manager - /// facilitating the connection. Options are key-value pairs separated by comma. - /// - /// E.g. `key="value",key2="value2"` - #[clap(long, default_value_t)] pub options: Map, } diff --git a/src/options/config/client/launch.rs b/src/options/config/client/launch.rs new file mode 100644 index 0000000..8e22b10 --- /dev/null +++ b/src/options/config/client/launch.rs @@ -0,0 +1,54 @@ +use super::common::BindAddress; +use distant_core::net::common::Map; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientLaunchConfig { + #[serde(flatten)] + pub distant: ClientLaunchDistantConfig, + pub options: Map, +} + +impl From for ClientLaunchConfig { + fn from(mut map: Map) -> Self { + Self { + distant: ClientLaunchDistantConfig { + bin: map.remove("distant.bin"), + bind_server: map + .remove("distant.bind_server") + .and_then(|x| x.parse::().ok()), + args: map.remove("distant.args"), + }, + options: map, + } + } +} + +impl From for Map { + fn from(config: ClientLaunchConfig) -> Self { + let mut this = Self::new(); + + if let Some(x) = config.distant.bin { + this.insert("distant.bin".to_string(), x); + } + + if let Some(x) = config.distant.bind_server { + this.insert("distant.bind_server".to_string(), x.to_string()); + } + + if let Some(x) = config.distant.args { + this.insert("distant.args".to_string(), x); + } + + this.extend(config.options); + + this + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientLaunchDistantConfig { + pub bin: Option, + pub bind_server: Option, + pub args: Option, +} diff --git a/src/config/generate.rs b/src/options/config/generate.rs similarity index 76% rename from src/config/generate.rs rename to src/options/config/generate.rs index f876ec4..23602dd 100644 --- a/src/config/generate.rs +++ b/src/options/config/generate.rs @@ -1,9 +1,9 @@ -use super::CommonConfig; +use super::common::LoggingSettings; use serde::{Deserialize, Serialize}; /// Represents configuration settings for the distant generate #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GenerateConfig { #[serde(flatten)] - pub common: CommonConfig, + pub logging: LoggingSettings, } diff --git a/src/options/config/manager.rs b/src/options/config/manager.rs new file mode 100644 index 0000000..1b78846 --- /dev/null +++ b/src/options/config/manager.rs @@ -0,0 +1,14 @@ +use super::common::{AccessControl, LoggingSettings, NetworkSettings}; +use serde::{Deserialize, Serialize}; + +/// Represents configuration settings for the distant manager +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagerConfig { + #[serde(flatten)] + pub logging: LoggingSettings, + + #[serde(flatten)] + pub network: NetworkSettings, + + pub access: Option, +} diff --git a/src/config/server.rs b/src/options/config/server.rs similarity index 80% rename from src/config/server.rs rename to src/options/config/server.rs index 660001c..416baf9 100644 --- a/src/config/server.rs +++ b/src/options/config/server.rs @@ -1,4 +1,4 @@ -use super::CommonConfig; +use super::common::LoggingSettings; use serde::{Deserialize, Serialize}; mod listen; @@ -8,7 +8,7 @@ pub use listen::*; #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerConfig { #[serde(flatten)] - pub common: CommonConfig, + pub logging: LoggingSettings, pub listen: ServerListenConfig, } diff --git a/src/options/config/server/listen.rs b/src/options/config/server/listen.rs new file mode 100644 index 0000000..75db704 --- /dev/null +++ b/src/options/config/server/listen.rs @@ -0,0 +1,61 @@ +use crate::options::BindAddress; +use distant_core::net::common::{Map, PortRange}; +use distant_core::net::server::Shutdown; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerListenConfig { + pub host: Option, + pub port: Option, + pub use_ipv6: bool, + pub shutdown: Option, + pub current_dir: Option, +} + +impl From for ServerListenConfig { + fn from(mut map: Map) -> Self { + Self { + host: map + .remove("host") + .and_then(|x| x.parse::().ok()), + port: map.remove("port").and_then(|x| x.parse::().ok()), + use_ipv6: map + .remove("use_ipv6") + .and_then(|x| x.parse::().ok()) + .unwrap_or_default(), + shutdown: map + .remove("shutdown") + .and_then(|x| x.parse::().ok()), + current_dir: map + .remove("current_dir") + .and_then(|x| x.parse::().ok()), + } + } +} + +impl From for Map { + fn from(config: ServerListenConfig) -> Self { + let mut this = Self::new(); + + if let Some(x) = config.host { + this.insert("host".to_string(), x.to_string()); + } + + if let Some(x) = config.port { + this.insert("port".to_string(), x.to_string()); + } + + this.insert("use_ipv6".to_string(), config.use_ipv6.to_string()); + + if let Some(x) = config.shutdown { + this.insert("shutdown".to_string(), x.to_string()); + } + + if let Some(x) = config.current_dir { + this.insert("current_dir".to_string(), x.to_string_lossy().to_string()); + } + + this + } +} diff --git a/src/paths.rs b/src/paths.rs deleted file mode 100644 index c51a7e5..0000000 --- a/src/paths.rs +++ /dev/null @@ -1,107 +0,0 @@ -use directories::ProjectDirs; -use once_cell::sync::Lazy; -use std::path::PathBuf; - -const SOCKET_FILE_STR: &str = "distant.sock"; - -/// User-oriented paths -pub mod user { - use super::*; - - /// Root project directory used to calculate other paths - static PROJECT_DIR: Lazy = Lazy::new(|| { - ProjectDirs::from("", "", "distant").expect("Could not determine valid $HOME path") - }); - - /// Path to configuration settings for distant client/manager/server - pub static CONFIG_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.config_dir().join("config.toml")); - - /// Path to cache file used for arbitrary CLI data - pub static CACHE_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.cache_dir().join("cache.toml")); - - pub static CACHE_FILE_PATH_STR: Lazy = - Lazy::new(|| CACHE_FILE_PATH.to_string_lossy().to_string()); - - /// Path to log file for distant client - pub static CLIENT_LOG_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.cache_dir().join("client.log")); - - /// Path to log file for distant manager - pub static MANAGER_LOG_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.cache_dir().join("manager.log")); - - /// Path to log file for distant server - pub static SERVER_LOG_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.cache_dir().join("server.log")); - - /// Path to log file for distant generate - pub static GENERATE_LOG_FILE_PATH: Lazy = - Lazy::new(|| PROJECT_DIR.cache_dir().join("generate.log")); - - /// For Linux & BSD, this uses the runtime path. For Mac, this uses the tmp path - /// - /// * `/run/user/1001/distant/{user}.distant.sock` on Linux - /// * `/var/run/{user}.distant.sock` on BSD - /// * `/tmp/{user}.distant.dock` on MacOS - pub static UNIX_SOCKET_PATH: Lazy = Lazy::new(|| { - // Form of {user}.distant.sock - let mut file_name = whoami::username_os(); - file_name.push("."); - file_name.push(SOCKET_FILE_STR); - - PROJECT_DIR - .runtime_dir() - .map(std::path::Path::to_path_buf) - .unwrap_or_else(std::env::temp_dir) - .join(file_name) - }); - - /// Name of the pipe used by Windows in the form of `{user}.distant` - pub static WINDOWS_PIPE_NAME: Lazy = - Lazy::new(|| format!("{}.distant", whoami::username())); -} - -/// Global paths -pub mod global { - use super::*; - - /// Windows ProgramData directory from from the %ProgramData% environment variable - #[cfg(windows)] - static PROGRAM_DATA_DIR: Lazy = Lazy::new(|| { - PathBuf::from(std::env::var("ProgramData").expect("Could not determine %ProgramData%")) - }); - - #[cfg(windows)] - static CONFIG_DIR: Lazy = Lazy::new(|| PROGRAM_DATA_DIR.join("distant")); - - #[cfg(unix)] - static CONFIG_DIR: Lazy = Lazy::new(|| PathBuf::from("/etc").join("distant")); - - /// Path to configuration settings for distant client/manager/server - pub static CONFIG_FILE_PATH: Lazy = Lazy::new(|| CONFIG_DIR.join("config.toml")); - - /// For Linux & BSD, this uses the runtime path. For Mac, this uses the tmp path - /// - /// * `/run/distant.sock` on Linux - /// * `/var/run/distant.sock` on BSD - /// * `/tmp/distant.dock` on MacOS - pub static UNIX_SOCKET_PATH: Lazy = Lazy::new(|| { - if cfg!(target_os = "macos") { - std::env::temp_dir().join(SOCKET_FILE_STR) - } else if cfg!(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "openbsd", - target_os = "netbsd" - )) { - PathBuf::from("/var").join("run").join(SOCKET_FILE_STR) - } else { - PathBuf::from("/run").join(SOCKET_FILE_STR) - } - }); - - /// Name of the pipe used by Windows - pub static WINDOWS_PIPE_NAME: Lazy = Lazy::new(|| "distant".to_string()); -} diff --git a/tests/cli/action/file_append.rs b/tests/cli/action/file_append.rs deleted file mode 100644 index 8750825..0000000 --- a/tests/cli/action/file_append.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -const APPENDED_FILE_CONTENTS: &str = r#" -even more -file contents -"#; - -#[rstest] -#[test_log::test] -fn should_report_ok_when_done(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - file.write_str(FILE_CONTENTS).unwrap(); - - // distant action file-append {path} -- {contents} - action_cmd - .args([ - "file-append", - file.to_str().unwrap(), - "--", - APPENDED_FILE_CONTENTS, - ]) - .assert() - .success() - .stdout("") - .stderr(""); - - // NOTE: We wait a little bit to give the OS time to fully write to file - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Because we're talking to a local server, we can verify locally - file.assert(format!("{}{}", FILE_CONTENTS, APPENDED_FILE_CONTENTS)); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-dir").child("missing-file"); - - // distant action file-append {path} -- {contents} - action_cmd - .args([ - "file-append", - file.to_str().unwrap(), - "--", - APPENDED_FILE_CONTENTS, - ]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); - - // Because we're talking to a local server, we can verify locally - file.assert(predicates::path::missing()); -} diff --git a/tests/cli/action/file_append_text.rs b/tests/cli/action/file_append_text.rs deleted file mode 100644 index b993be4..0000000 --- a/tests/cli/action/file_append_text.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -const APPENDED_FILE_CONTENTS: &str = r#" -even more -file contents -"#; - -#[rstest] -#[test_log::test] -fn should_report_ok_when_done(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - file.write_str(FILE_CONTENTS).unwrap(); - - // distant action file-append-text {path} -- {contents} - action_cmd - .args([ - "file-append-text", - file.to_str().unwrap(), - "--", - APPENDED_FILE_CONTENTS, - ]) - .assert() - .success() - .stdout("") - .stderr(""); - - // NOTE: We wait a little bit to give the OS time to fully write to file - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Because we're talking to a local server, we can verify locally - file.assert(format!("{}{}", FILE_CONTENTS, APPENDED_FILE_CONTENTS)); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-dir").child("missing-file"); - - // distant action file-append-text {path} -- {contents} - action_cmd - .args([ - "file-append-text", - file.to_str().unwrap(), - "--", - APPENDED_FILE_CONTENTS, - ]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); - - // Because we're talking to a local server, we can verify locally - file.assert(predicates::path::missing()); -} diff --git a/tests/cli/action/file_read.rs b/tests/cli/action/file_read.rs deleted file mode 100644 index 260f21e..0000000 --- a/tests/cli/action/file_read.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -#[rstest] -#[test_log::test] -fn should_print_out_file_contents(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - file.write_str(FILE_CONTENTS).unwrap(); - - // distant action file-read {path} - action_cmd - .args(["file-read", file.to_str().unwrap()]) - .assert() - .success() - .stdout(format!("{}\n", FILE_CONTENTS)) - .stderr(""); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-file"); - - // distant action file-read {path} - action_cmd - .args(["file-read", file.to_str().unwrap()]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); -} diff --git a/tests/cli/action/file_read_text.rs b/tests/cli/action/file_read_text.rs deleted file mode 100644 index eda0f34..0000000 --- a/tests/cli/action/file_read_text.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -#[rstest] -#[test_log::test] -fn should_print_out_file_contents(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - file.write_str(FILE_CONTENTS).unwrap(); - - // distant action file-read-text {path} - action_cmd - .args(["file-read-text", file.to_str().unwrap()]) - .assert() - .success() - .stdout(format!("{}\n", FILE_CONTENTS)) - .stderr(""); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-file"); - - // distant action file-read-text {path} - action_cmd - .args(["file-read-text", file.to_str().unwrap()]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); -} diff --git a/tests/cli/action/file_write.rs b/tests/cli/action/file_write.rs deleted file mode 100644 index 34385e5..0000000 --- a/tests/cli/action/file_write.rs +++ /dev/null @@ -1,49 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -#[rstest] -#[test_log::test] -fn should_report_ok_when_done(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - - // distant action file-write {path} -- {contents} - action_cmd - .args(["file-write", file.to_str().unwrap(), "--", FILE_CONTENTS]) - .assert() - .success() - .stdout("") - .stderr(""); - - // NOTE: We wait a little bit to give the OS time to fully write to file - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Because we're talking to a local server, we can verify locally - file.assert(FILE_CONTENTS); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-dir").child("missing-file"); - - // distant action file-write {path} -- {contents} - action_cmd - .args(["file-write", file.to_str().unwrap(), "--", FILE_CONTENTS]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); - - // Because we're talking to a local server, we can verify locally - file.assert(predicates::path::missing()); -} diff --git a/tests/cli/action/file_write_text.rs b/tests/cli/action/file_write_text.rs deleted file mode 100644 index 53476b4..0000000 --- a/tests/cli/action/file_write_text.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; -use assert_fs::prelude::*; -use rstest::*; - -const FILE_CONTENTS: &str = r#" -some text -on multiple lines -that is a file's contents -"#; - -#[rstest] -#[test_log::test] -fn should_report_ok_when_done(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("test-file"); - - // distant action file-write-text {path} -- {contents} - action_cmd - .args([ - "file-write-text", - file.to_str().unwrap(), - "--", - FILE_CONTENTS, - ]) - .assert() - .success() - .stdout("") - .stderr(""); - - // NOTE: We wait a little bit to give the OS time to fully write to file - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Because we're talking to a local server, we can verify locally - file.assert(FILE_CONTENTS); -} - -#[rstest] -#[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - let temp = assert_fs::TempDir::new().unwrap(); - let file = temp.child("missing-dir").child("missing-file"); - - // distant action file-write {path} -- {contents} - action_cmd - .args([ - "file-write-text", - file.to_str().unwrap(), - "--", - FILE_CONTENTS, - ]) - .assert() - .code(1) - .stdout("") - .stderr(FAILURE_LINE.clone()); - - // Because we're talking to a local server, we can verify locally - file.assert(predicates::path::missing()); -} diff --git a/tests/cli/repl/capabilities.rs b/tests/cli/api/capabilities.rs similarity index 81% rename from tests/cli/repl/capabilities.rs rename to tests/cli/api/capabilities.rs index e379aec..f697d5a 100644 --- a/tests/cli/repl/capabilities.rs +++ b/tests/cli/api/capabilities.rs @@ -6,8 +6,8 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_capabilities(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_capabilities(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let id = rand::random::().to_string(); let req = json!({ @@ -15,7 +15,7 @@ async fn should_support_json_capabilities(mut json_repl: CtxCommand) { "payload": { "type": "capabilities" }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "capabilities", "JSON: {res}"); diff --git a/tests/cli/repl/copy.rs b/tests/cli/api/copy.rs similarity index 78% rename from tests/cli/repl/copy.rs rename to tests/cli/api/copy.rs index bcd205f..d026bb7 100644 --- a/tests/cli/repl/copy.rs +++ b/tests/cli/api/copy.rs @@ -13,8 +13,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_copying_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_copying_file(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -33,7 +33,7 @@ async fn should_support_json_copying_file(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -50,8 +50,8 @@ async fn should_support_json_copying_file(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_copying_nonempty_directory(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_copying_nonempty_directory(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -74,7 +74,7 @@ async fn should_support_json_copying_nonempty_directory(mut json_repl: CtxComman }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -91,8 +91,8 @@ async fn should_support_json_copying_nonempty_directory(mut json_repl: CtxComman #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -109,7 +109,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/dir_create.rs b/tests/cli/api/dir_create.rs similarity index 77% rename from tests/cli/repl/dir_create.rs rename to tests/cli/api/dir_create.rs index 6a8fc52..74d33a0 100644 --- a/tests/cli/repl/dir_create.rs +++ b/tests/cli/api/dir_create.rs @@ -7,8 +7,8 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir"); @@ -23,7 +23,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -41,9 +41,9 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] async fn should_support_json_creating_missing_parent_directories_if_specified( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir1").child("dir2"); @@ -58,7 +58,7 @@ async fn should_support_json_creating_missing_parent_directories_if_specified( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -75,8 +75,8 @@ async fn should_support_json_creating_missing_parent_directories_if_specified( #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("missing-dir").child("dir"); @@ -91,7 +91,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/dir_read.rs b/tests/cli/api/dir_read.rs similarity index 89% rename from tests/cli/repl/dir_read.rs rename to tests/cli/api/dir_read.rs index a718768..9e2a0c8 100644 --- a/tests/cli/repl/dir_read.rs +++ b/tests/cli/api/dir_read.rs @@ -71,8 +71,8 @@ fn make_directory() -> assert_fs::TempDir { #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = make_directory(); @@ -89,7 +89,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -111,9 +111,9 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] async fn should_support_json_returning_absolute_paths_if_specified( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = make_directory(); @@ -134,7 +134,7 @@ async fn should_support_json_returning_absolute_paths_if_specified( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -156,9 +156,9 @@ async fn should_support_json_returning_absolute_paths_if_specified( #[rstest] #[test(tokio::test)] async fn should_support_json_returning_all_files_and_directories_if_depth_is_0( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = make_directory(); @@ -175,7 +175,7 @@ async fn should_support_json_returning_all_files_and_directories_if_depth_is_0( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -207,9 +207,9 @@ async fn should_support_json_returning_all_files_and_directories_if_depth_is_0( #[rstest] #[test(tokio::test)] async fn should_support_json_including_root_directory_if_specified( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = make_directory(); @@ -230,7 +230,7 @@ async fn should_support_json_including_root_directory_if_specified( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -252,8 +252,8 @@ async fn should_support_json_including_root_directory_if_specified( #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = make_directory(); let dir = temp.child("missing-dir"); @@ -271,7 +271,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/exists.rs b/tests/cli/api/exists.rs similarity index 74% rename from tests/cli/repl/exists.rs rename to tests/cli/api/exists.rs index 80da487..f3a2492 100644 --- a/tests/cli/repl/exists.rs +++ b/tests/cli/api/exists.rs @@ -6,8 +6,8 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_true_if_exists(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_true_if_exists(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -24,7 +24,7 @@ async fn should_support_json_true_if_exists(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -39,8 +39,8 @@ async fn should_support_json_true_if_exists(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_false_if_not_exists(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_false_if_not_exists(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -56,7 +56,7 @@ async fn should_support_json_false_if_not_exists(mut json_repl: CtxCommand }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( diff --git a/tests/cli/repl/file_append.rs b/tests/cli/api/file_append.rs similarity index 81% rename from tests/cli/repl/file_append.rs rename to tests/cli/api/file_append.rs index 6c7360b..ad01d4b 100644 --- a/tests/cli/repl/file_append.rs +++ b/tests/cli/api/file_append.rs @@ -17,8 +17,8 @@ file contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -34,7 +34,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -54,8 +54,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-dir").child("missing-file"); @@ -70,7 +70,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/file_append_text.rs b/tests/cli/api/file_append_text.rs similarity index 81% rename from tests/cli/repl/file_append_text.rs rename to tests/cli/api/file_append_text.rs index f2d2ac3..035a153 100644 --- a/tests/cli/repl/file_append_text.rs +++ b/tests/cli/api/file_append_text.rs @@ -17,8 +17,8 @@ file contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -34,7 +34,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -54,8 +54,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-dir").child("missing-file"); @@ -70,7 +70,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/file_read.rs b/tests/cli/api/file_read.rs similarity index 75% rename from tests/cli/repl/file_read.rs rename to tests/cli/api/file_read.rs index 6504e93..aefa57e 100644 --- a/tests/cli/repl/file_read.rs +++ b/tests/cli/api/file_read.rs @@ -12,8 +12,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -28,7 +28,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -43,8 +43,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-file"); @@ -58,7 +58,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/file_read_text.rs b/tests/cli/api/file_read_text.rs similarity index 75% rename from tests/cli/repl/file_read_text.rs rename to tests/cli/api/file_read_text.rs index 78bc13f..2ff5315 100644 --- a/tests/cli/repl/file_read_text.rs +++ b/tests/cli/api/file_read_text.rs @@ -12,8 +12,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -28,7 +28,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -43,8 +43,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-file"); @@ -58,7 +58,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/file_write.rs b/tests/cli/api/file_write.rs similarity index 80% rename from tests/cli/repl/file_write.rs rename to tests/cli/api/file_write.rs index 07eafd0..fd0c388 100644 --- a/tests/cli/repl/file_write.rs +++ b/tests/cli/api/file_write.rs @@ -12,8 +12,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -28,7 +28,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -48,8 +48,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-dir").child("missing-file"); @@ -64,7 +64,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/file_write_text.rs b/tests/cli/api/file_write_text.rs similarity index 80% rename from tests/cli/repl/file_write_text.rs rename to tests/cli/api/file_write_text.rs index 8c75bc3..76b834e 100644 --- a/tests/cli/repl/file_write_text.rs +++ b/tests/cli/api/file_write_text.rs @@ -12,8 +12,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_output(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("test-file"); @@ -28,7 +28,7 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -48,8 +48,8 @@ async fn should_support_json_output(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("missing-dir").child("missing-file"); @@ -64,7 +64,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/metadata.rs b/tests/cli/api/metadata.rs similarity index 80% rename from tests/cli/repl/metadata.rs rename to tests/cli/api/metadata.rs index 6fcb61c..1bc6369 100644 --- a/tests/cli/repl/metadata.rs +++ b/tests/cli/api/metadata.rs @@ -12,8 +12,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_metadata_for_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_metadata_for_file(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -31,7 +31,7 @@ async fn should_support_json_metadata_for_file(mut json_repl: CtxCommand) }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "metadata", "JSON: {res}"); @@ -46,8 +46,8 @@ async fn should_support_json_metadata_for_file(mut json_repl: CtxCommand) #[rstest] #[test(tokio::test)] -async fn should_support_json_metadata_for_directory(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_metadata_for_directory(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -65,7 +65,7 @@ async fn should_support_json_metadata_for_directory(mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -104,7 +104,7 @@ async fn should_support_json_metadata_for_including_a_canonicalized_path( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "metadata", "JSON: {res}"); @@ -120,9 +120,9 @@ async fn should_support_json_metadata_for_including_a_canonicalized_path( #[rstest] #[test(tokio::test)] async fn should_support_json_metadata_for_resolving_file_type_of_symlink( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -143,7 +143,7 @@ async fn should_support_json_metadata_for_resolving_file_type_of_symlink( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "metadata", "JSON: {res}"); @@ -152,8 +152,8 @@ async fn should_support_json_metadata_for_resolving_file_type_of_symlink( #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -171,7 +171,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/action/mod.rs b/tests/cli/api/mod.rs similarity index 100% rename from tests/cli/action/mod.rs rename to tests/cli/api/mod.rs diff --git a/tests/cli/repl/proc_spawn.rs b/tests/cli/api/proc_spawn.rs similarity index 80% rename from tests/cli/repl/proc_spawn.rs rename to tests/cli/api/proc_spawn.rs index 4ad0f0b..1ec5b99 100644 --- a/tests/cli/repl/proc_spawn.rs +++ b/tests/cli/api/proc_spawn.rs @@ -72,9 +72,9 @@ fn check_value_as_str(value: &serde_json::Value, other: &str) { #[rstest] #[test(tokio::test)] async fn should_support_json_to_execute_program_and_return_exit_status( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let cmd = make_cmd(vec![ECHO_ARGS_TO_STDOUT.to_str().unwrap()]); @@ -88,7 +88,7 @@ async fn should_support_json_to_execute_program_and_return_exit_status( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_spawned", "JSON: {res}"); @@ -96,8 +96,8 @@ async fn should_support_json_to_execute_program_and_return_exit_status( #[rstest] #[test(tokio::test)] -async fn should_support_json_to_capture_and_print_stdout(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_to_capture_and_print_stdout(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let cmd = make_cmd(vec![ECHO_ARGS_TO_STDOUT.to_str().unwrap(), "some output"]); @@ -112,20 +112,20 @@ async fn should_support_json_to_capture_and_print_stdout(mut json_repl: CtxComma }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_spawned", "JSON: {res}"); // Wait for output to show up (for stderr) - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_stdout", "JSON: {res}"); check_value_as_str(&res["payload"]["data"], "some output"); // Now we wait for the process to complete - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_done", "JSON: {res}"); @@ -134,8 +134,8 @@ async fn should_support_json_to_capture_and_print_stdout(mut json_repl: CtxComma #[rstest] #[test(tokio::test)] -async fn should_support_json_to_capture_and_print_stderr(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_to_capture_and_print_stderr(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let cmd = make_cmd(vec![ECHO_ARGS_TO_STDERR.to_str().unwrap(), "some output"]); @@ -150,20 +150,20 @@ async fn should_support_json_to_capture_and_print_stderr(mut json_repl: CtxComma }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_spawned", "JSON: {res}"); // Wait for output to show up (for stderr) - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_stderr", "JSON: {res}"); check_value_as_str(&res["payload"]["data"], "some output"); // Now we wait for the process to complete - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_done", "JSON: {res}"); @@ -172,8 +172,10 @@ async fn should_support_json_to_capture_and_print_stderr(mut json_repl: CtxComma #[rstest] #[test(tokio::test)] -async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_to_forward_stdin_to_remote_process( + mut api_process: CtxCommand, +) { + validate_authentication(&mut api_process).await; let cmd = make_cmd(vec![ECHO_STDIN_TO_STDOUT.to_str().unwrap()]); @@ -188,7 +190,7 @@ async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: C }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_spawned", "JSON: {res}"); @@ -207,12 +209,12 @@ async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: C }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "ok", "JSON: {res}"); - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], origin_id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "proc_stdout", "JSON: {res}"); @@ -220,7 +222,7 @@ async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: C // Now kill the process and wait for it to complete let id = rand::random::().to_string(); - let res = json_repl + let res = api_process .write_and_read_json(json!({ "id": id, "payload": { @@ -237,7 +239,7 @@ async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: C // // NOTE: The above is a situation in Windows, but I've not seen it happen with Mac/Linux. if res["payload"]["type"] == "ok" { - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!( res["payload"]["type"], "proc_done", "Did not receive proc_done from killed process: {res}" @@ -249,8 +251,8 @@ async fn should_support_json_to_forward_stdin_to_remote_process(mut json_repl: C #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let id = rand::random::().to_string(); let req = json!({ @@ -262,7 +264,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/remove.rs b/tests/cli/api/remove.rs similarity index 77% rename from tests/cli/repl/remove.rs rename to tests/cli/api/remove.rs index ddfa17f..a6f56ca 100644 --- a/tests/cli/repl/remove.rs +++ b/tests/cli/api/remove.rs @@ -7,8 +7,8 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_removing_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_removing_file(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -25,7 +25,7 @@ async fn should_support_json_removing_file(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -41,8 +41,8 @@ async fn should_support_json_removing_file(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_removing_empty_directory(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_removing_empty_directory(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -60,7 +60,7 @@ async fn should_support_json_removing_empty_directory(mut json_repl: CtxCommand< }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -77,9 +77,9 @@ async fn should_support_json_removing_empty_directory(mut json_repl: CtxCommand< #[rstest] #[test(tokio::test)] async fn should_support_json_removing_nonempty_directory_if_force_specified( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -97,7 +97,7 @@ async fn should_support_json_removing_nonempty_directory_if_force_specified( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -113,8 +113,8 @@ async fn should_support_json_removing_nonempty_directory_if_force_specified( #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -133,7 +133,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/rename.rs b/tests/cli/api/rename.rs similarity index 79% rename from tests/cli/repl/rename.rs rename to tests/cli/api/rename.rs index 994bf94..adf23d8 100644 --- a/tests/cli/repl/rename.rs +++ b/tests/cli/api/rename.rs @@ -13,8 +13,8 @@ that is a file's contents #[rstest] #[test(tokio::test)] -async fn should_support_json_renaming_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_renaming_file(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -33,7 +33,7 @@ async fn should_support_json_renaming_file(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -50,8 +50,8 @@ async fn should_support_json_renaming_file(mut json_repl: CtxCommand) { #[rstest] #[test(tokio::test)] -async fn should_support_json_renaming_nonempty_directory(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_renaming_nonempty_directory(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -74,7 +74,7 @@ async fn should_support_json_renaming_nonempty_directory(mut json_repl: CtxComma }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -94,8 +94,8 @@ async fn should_support_json_renaming_nonempty_directory(mut json_repl: CtxComma #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -112,7 +112,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "error", "JSON: {res}"); diff --git a/tests/cli/repl/search.rs b/tests/cli/api/search.rs similarity index 86% rename from tests/cli/repl/search.rs rename to tests/cli/api/search.rs index 47adc3d..1dfe9fa 100644 --- a/tests/cli/repl/search.rs +++ b/tests/cli/api/search.rs @@ -6,8 +6,10 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_search_filesystem_using_query(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_search_filesystem_using_query( + mut api_process: CtxCommand, +) { + validate_authentication(&mut api_process).await; let root = assert_fs::TempDir::new().unwrap(); root.child("file1.txt").write_str("some file text").unwrap(); @@ -30,7 +32,7 @@ async fn should_support_json_search_filesystem_using_query(mut json_repl: CtxCom }); // Submit search request and get back started confirmation - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); // Get id from started confirmation assert_eq!(res["origin_id"], id, "JSON: {res}"); @@ -40,7 +42,7 @@ async fn should_support_json_search_filesystem_using_query(mut json_repl: CtxCom .expect("id missing or not number"); // Get search results back - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( res["payload"], @@ -74,7 +76,7 @@ async fn should_support_json_search_filesystem_using_query(mut json_repl: CtxCom ); // Get search completion confirmation - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( res["payload"], diff --git a/tests/cli/repl/system_info.rs b/tests/cli/api/system_info.rs similarity index 83% rename from tests/cli/repl/system_info.rs rename to tests/cli/api/system_info.rs index 4e135b8..0e597e7 100644 --- a/tests/cli/repl/system_info.rs +++ b/tests/cli/api/system_info.rs @@ -6,8 +6,8 @@ use test_log::test; #[rstest] #[test(tokio::test)] -async fn should_support_json_system_info(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_system_info(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let id = rand::random::().to_string(); let req = json!({ @@ -15,7 +15,7 @@ async fn should_support_json_system_info(mut json_repl: CtxCommand) { "payload": { "type": "system_info" }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( diff --git a/tests/cli/repl/watch.rs b/tests/cli/api/watch.rs similarity index 83% rename from tests/cli/repl/watch.rs rename to tests/cli/api/watch.rs index 4048d72..e71c786 100644 --- a/tests/cli/repl/watch.rs +++ b/tests/cli/api/watch.rs @@ -19,8 +19,8 @@ async fn wait_millis(millis: u64) { #[rstest] #[test(tokio::test)] -async fn should_support_json_watching_single_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_watching_single_file(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -37,7 +37,7 @@ async fn should_support_json_watching_single_file(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_watching_directory_recursively( + mut api_process: CtxCommand, +) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -91,7 +93,7 @@ async fn should_support_json_watching_directory_recursively(mut json_repl: CtxCo }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!( @@ -112,7 +114,7 @@ async fn should_support_json_watching_directory_recursively(mut json_repl: CtxCo // Get the response and verify the change // NOTE: Don't bother checking the kind as it can vary by platform - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "changed", "JSON: {res}"); @@ -128,7 +130,7 @@ async fn should_support_json_watching_directory_recursively(mut json_repl: CtxCo // Get the response and verify the change // NOTE: Don't bother checking the kind as it can vary by platform - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id, "JSON: {res}"); assert_eq!(res["payload"]["type"], "changed", "JSON: {res}"); @@ -142,9 +144,9 @@ async fn should_support_json_watching_directory_recursively(mut json_repl: CtxCo #[rstest] #[test(tokio::test)] async fn should_support_json_reporting_changes_using_correct_request_id( - mut json_repl: CtxCommand, + mut api_process: CtxCommand, ) { - validate_authentication(&mut json_repl).await; + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); @@ -164,7 +166,7 @@ async fn should_support_json_reporting_changes_using_correct_request_id( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id_1, "JSON: {res}"); assert_eq!( @@ -185,7 +187,7 @@ async fn should_support_json_reporting_changes_using_correct_request_id( }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); assert_eq!(res["origin_id"], id_2, "JSON: {res}"); assert_eq!( @@ -204,7 +206,7 @@ async fn should_support_json_reporting_changes_using_correct_request_id( // Get the response and verify the change // NOTE: Don't bother checking the kind as it can vary by platform - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id_1, "JSON: {res}"); assert_eq!(res["payload"]["type"], "changed", "JSON: {res}"); @@ -219,7 +221,7 @@ async fn should_support_json_reporting_changes_using_correct_request_id( // Sleep a bit to give time to get all changes happening wait_a_bit().await; - if json_repl + if api_process .try_read_line_from_stdout() .expect("stdout closed unexpectedly") .is_none() @@ -236,7 +238,7 @@ async fn should_support_json_reporting_changes_using_correct_request_id( // Get the response and verify the change // NOTE: Don't bother checking the kind as it can vary by platform - let res = json_repl.read_json_from_stdout().await.unwrap().unwrap(); + let res = api_process.read_json_from_stdout().await.unwrap().unwrap(); assert_eq!(res["origin_id"], id_2, "JSON: {res}"); assert_eq!(res["payload"]["type"], "changed", "JSON: {res}"); @@ -249,8 +251,8 @@ async fn should_support_json_reporting_changes_using_correct_request_id( #[rstest] #[test(tokio::test)] -async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { - validate_authentication(&mut json_repl).await; +async fn should_support_json_output_for_error(mut api_process: CtxCommand) { + validate_authentication(&mut api_process).await; let temp = assert_fs::TempDir::new().unwrap(); let path = temp.to_path_buf().join("missing"); @@ -265,7 +267,7 @@ async fn should_support_json_output_for_error(mut json_repl: CtxCommand) { }, }); - let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + let res = api_process.write_and_read_json(req).await.unwrap().unwrap(); // Ensure we got an acknowledgement of watching that failed assert_eq!(res["origin_id"], id, "JSON: {res}"); diff --git a/tests/cli/action/capabilities.rs b/tests/cli/client/capabilities.rs similarity index 96% rename from tests/cli/action/capabilities.rs rename to tests/cli/client/capabilities.rs index db13d45..1996b99 100644 --- a/tests/cli/action/capabilities.rs +++ b/tests/cli/client/capabilities.rs @@ -1,5 +1,4 @@ use crate::cli::fixtures::*; -use assert_cmd::Command; use indoc::indoc; use rstest::*; @@ -57,10 +56,8 @@ const EXPECTED_TABLE: &str = indoc! {" #[rstest] #[test_log::test] -fn should_output_capabilities(mut action_cmd: CtxCommand) { - // distant action capabilities - action_cmd - .arg("capabilities") +fn should_output_capabilities(ctx: DistantManagerCtx) { + ctx.cmd("capabilities") .assert() .success() .stdout(EXPECTED_TABLE) diff --git a/tests/cli/action/copy.rs b/tests/cli/client/fs_copy.rs similarity index 65% rename from tests/cli/action/copy.rs rename to tests/cli/client/fs_copy.rs index 01970d5..a7b0344 100644 --- a/tests/cli/action/copy.rs +++ b/tests/cli/client/fs_copy.rs @@ -1,5 +1,4 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; +use crate::cli::fixtures::*; use assert_fs::prelude::*; use predicates::prelude::*; use rstest::*; @@ -12,7 +11,7 @@ that is a file's contents #[rstest] #[test_log::test] -fn should_support_copying_file(mut action_cmd: CtxCommand) { +fn should_support_copying_file(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let src = temp.child("file"); @@ -20,9 +19,9 @@ fn should_support_copying_file(mut action_cmd: CtxCommand) { let dst = temp.child("file2"); - // distant action copy {src} {dst} - action_cmd - .args(["copy", src.to_str().unwrap(), dst.to_str().unwrap()]) + // distant fs copy {src} {dst} + ctx.new_assert_cmd(["fs", "copy"]) + .args([src.to_str().unwrap(), dst.to_str().unwrap()]) .assert() .success() .stdout("") @@ -34,7 +33,7 @@ fn should_support_copying_file(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_support_copying_nonempty_directory(mut action_cmd: CtxCommand) { +fn should_support_copying_nonempty_directory(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Make a non-empty directory @@ -46,9 +45,9 @@ fn should_support_copying_nonempty_directory(mut action_cmd: CtxCommand let dst = temp.child("dir2"); let dst_file = dst.child("file"); - // distant action copy {src} {dst} - action_cmd - .args(["copy", src.to_str().unwrap(), dst.to_str().unwrap()]) + // distant fs copy {src} {dst} + ctx.new_assert_cmd(["fs", "copy"]) + .args([src.to_str().unwrap(), dst.to_str().unwrap()]) .assert() .success() .stdout("") @@ -60,19 +59,19 @@ fn should_support_copying_nonempty_directory(mut action_cmd: CtxCommand #[rstest] #[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let src = temp.child("dir"); let dst = temp.child("dir2"); - // distant action copy {src} {dst} - action_cmd - .args(["copy", src.to_str().unwrap(), dst.to_str().unwrap()]) + // distant fs copy {src} {dst} + ctx.new_assert_cmd(["fs", "copy"]) + .args([src.to_str().unwrap(), dst.to_str().unwrap()]) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); src.assert(predicate::path::missing()); dst.assert(predicate::path::missing()); diff --git a/tests/cli/action/exists.rs b/tests/cli/client/fs_exists.rs similarity index 59% rename from tests/cli/action/exists.rs rename to tests/cli/client/fs_exists.rs index d43c63d..8ff9c58 100644 --- a/tests/cli/action/exists.rs +++ b/tests/cli/client/fs_exists.rs @@ -1,20 +1,19 @@ use crate::cli::fixtures::*; -use assert_cmd::Command; use assert_fs::prelude::*; use rstest::*; #[rstest] #[test_log::test] -fn should_output_true_if_exists(mut action_cmd: CtxCommand) { +fn should_output_true_if_exists(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Create file let file = temp.child("file"); file.touch().unwrap(); - // distant action exists {path} - action_cmd - .args(["exists", file.to_str().unwrap()]) + // distant fs exists {path} + ctx.new_assert_cmd(["fs", "exists"]) + .arg(file.to_str().unwrap()) .assert() .success() .stdout("true\n") @@ -23,15 +22,15 @@ fn should_output_true_if_exists(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_output_false_if_not_exists(mut action_cmd: CtxCommand) { +fn should_output_false_if_not_exists(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Don't create file let file = temp.child("file"); - // distant action exists {path} - action_cmd - .args(["exists", file.to_str().unwrap()]) + // distant fs exists {path} + ctx.new_assert_cmd(["fs", "exists"]) + .arg(file.to_str().unwrap()) .assert() .success() .stdout("false\n") diff --git a/tests/cli/action/dir_create.rs b/tests/cli/client/fs_make_dir.rs similarity index 67% rename from tests/cli/action/dir_create.rs rename to tests/cli/client/fs_make_dir.rs index 4a0d38d..a1cb72f 100644 --- a/tests/cli/action/dir_create.rs +++ b/tests/cli/client/fs_make_dir.rs @@ -1,18 +1,17 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; +use crate::cli::fixtures::*; use assert_fs::prelude::*; use predicates::prelude::*; use rstest::*; #[rstest] #[test_log::test] -fn should_report_ok_when_done(mut action_cmd: CtxCommand) { +fn should_report_ok_when_done(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir"); // distant action dir-create {path} - action_cmd - .args(["dir-create", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "make-dir"]) + .args([dir.to_str().unwrap()]) .assert() .success() .stdout("") @@ -24,15 +23,13 @@ fn should_report_ok_when_done(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_support_creating_missing_parent_directories_if_specified( - mut action_cmd: CtxCommand, -) { +fn should_support_creating_missing_parent_directories_if_specified(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir1").child("dir2"); // distant action dir-create {path} - action_cmd - .args(["dir-create", "--all", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "make-dir"]) + .args(["--all", dir.to_str().unwrap()]) .assert() .success() .stdout("") @@ -44,17 +41,17 @@ fn should_support_creating_missing_parent_directories_if_specified( #[rstest] #[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("missing-dir").child("dir"); // distant action dir-create {path} - action_cmd - .args(["dir-create", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "make-dir"]) + .args([dir.to_str().unwrap()]) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); dir.assert(predicate::path::missing()); } diff --git a/tests/cli/action/metadata.rs b/tests/cli/client/fs_metadata.rs similarity index 69% rename from tests/cli/action/metadata.rs rename to tests/cli/client/fs_metadata.rs index fb4d3a3..d09fa89 100644 --- a/tests/cli/action/metadata.rs +++ b/tests/cli/client/fs_metadata.rs @@ -1,9 +1,6 @@ -use crate::cli::{ - fixtures::*, - utils::{regex_pred, FAILURE_LINE}, -}; -use assert_cmd::Command; +use crate::cli::{fixtures::*, utils::regex_pred}; use assert_fs::prelude::*; +use predicates::prelude::*; use rstest::*; const FILE_CONTENTS: &str = r#" @@ -14,15 +11,15 @@ that is a file's contents #[rstest] #[test_log::test] -fn should_output_metadata_for_file(mut action_cmd: CtxCommand) { +fn should_output_metadata_for_file(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("file"); file.write_str(FILE_CONTENTS).unwrap(); - // distant action metadata {path} - action_cmd - .args(["metadata", file.to_str().unwrap()]) + // distant fs metadata {path} + ctx.new_assert_cmd(["fs", "metadata"]) + .arg(file.to_str().unwrap()) .assert() .success() .stdout(regex_pred(concat!( @@ -38,15 +35,15 @@ fn should_output_metadata_for_file(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_output_metadata_for_directory(mut action_cmd: CtxCommand) { +fn should_output_metadata_for_directory(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir"); dir.create_dir_all().unwrap(); - // distant action metadata {path} - action_cmd - .args(["metadata", dir.to_str().unwrap()]) + // distant fs metadata {path} + ctx.new_assert_cmd(["fs", "metadata"]) + .arg(dir.to_str().unwrap()) .assert() .success() .stdout(regex_pred(concat!( @@ -64,7 +61,7 @@ fn should_output_metadata_for_directory(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] #[cfg_attr(windows, ignore)] -fn should_support_including_a_canonicalized_path(mut action_cmd: CtxCommand) { +fn should_support_including_a_canonicalized_path(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("file"); @@ -73,9 +70,9 @@ fn should_support_including_a_canonicalized_path(mut action_cmd: CtxCommand) { +fn should_support_resolving_file_type_of_symlink(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("file"); @@ -104,9 +101,9 @@ fn should_support_resolving_file_type_of_symlink(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Don't create file let file = temp.child("file"); - // distant action metadata {path} - action_cmd - .args(["metadata", file.to_str().unwrap()]) + // distant fs metadata {path} + ctx.new_assert_cmd(["fs", "metadata"]) + .arg(file.to_str().unwrap()) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); } diff --git a/tests/cli/action/dir_read.rs b/tests/cli/client/fs_read_directory.rs similarity index 82% rename from tests/cli/action/dir_read.rs rename to tests/cli/client/fs_read_directory.rs index 3906225..8f65314 100644 --- a/tests/cli/action/dir_read.rs +++ b/tests/cli/client/fs_read_directory.rs @@ -1,9 +1,6 @@ -use crate::cli::{ - fixtures::*, - utils::{regex_pred, FAILURE_LINE}, -}; -use assert_cmd::Command; +use crate::cli::{fixtures::*, utils::regex_pred}; use assert_fs::prelude::*; +use predicates::prelude::*; use rstest::*; use std::path::Path; @@ -89,7 +86,7 @@ fn regex_line(ty: &str, path: &str) -> String { #[rstest] #[test_log::test] -fn should_print_immediate_files_and_directories_by_default(mut action_cmd: CtxCommand) { +fn should_print_immediate_files_and_directories_by_default(ctx: DistantManagerCtx) { let temp = make_directory(); let expected = regex_pred(®ex_stdout(vec![ @@ -99,9 +96,9 @@ fn should_print_immediate_files_and_directories_by_default(mut action_cmd: CtxCo ("", "file2"), ])); - // distant action dir-read {path} - action_cmd - .args(["dir-read", temp.to_str().unwrap()]) + // distant fs read {path} + ctx.new_assert_cmd(["fs", "read"]) + .args([temp.to_str().unwrap()]) .assert() .success() .stdout(expected) @@ -112,7 +109,7 @@ fn should_print_immediate_files_and_directories_by_default(mut action_cmd: CtxCo #[rstest] #[test_log::test] #[cfg_attr(windows, ignore)] -fn should_use_absolute_paths_if_specified(mut action_cmd: CtxCommand) { +fn should_use_absolute_paths_if_specified(ctx: DistantManagerCtx) { let temp = make_directory(); // NOTE: Our root path is always canonicalized, so the absolute path @@ -126,9 +123,9 @@ fn should_use_absolute_paths_if_specified(mut action_cmd: CtxCommand) { ("", root_path.join("file2").to_str().unwrap()), ])); - // distant action dir-read --absolute {path} - action_cmd - .args(["dir-read", "--absolute", temp.to_str().unwrap()]) + // distant fs read --absolute {path} + ctx.new_assert_cmd(["fs", "read"]) + .args(["--absolute", temp.to_str().unwrap()]) .assert() .success() .stdout(expected) @@ -139,7 +136,7 @@ fn should_use_absolute_paths_if_specified(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] #[cfg_attr(windows, ignore)] -fn should_print_all_files_and_directories_if_depth_is_0(mut action_cmd: CtxCommand) { +fn should_print_all_files_and_directories_if_depth_is_0(ctx: DistantManagerCtx) { let temp = make_directory(); let expected = regex_pred(®ex_stdout(vec![ @@ -173,9 +170,9 @@ fn should_print_all_files_and_directories_if_depth_is_0(mut action_cmd: CtxComma ("", Path::new("file2").to_str().unwrap()), ])); - // distant action dir-read --depth 0 {path} - action_cmd - .args(["dir-read", "--depth", "0", temp.to_str().unwrap()]) + // distant fs read --depth 0 {path} + ctx.new_assert_cmd(["fs", "read"]) + .args(["--depth", "0", temp.to_str().unwrap()]) .assert() .success() .stdout(expected) @@ -186,7 +183,7 @@ fn should_print_all_files_and_directories_if_depth_is_0(mut action_cmd: CtxComma #[rstest] #[test_log::test] #[cfg_attr(windows, ignore)] -fn should_include_root_directory_if_specified(mut action_cmd: CtxCommand) { +fn should_include_root_directory_if_specified(ctx: DistantManagerCtx) { let temp = make_directory(); // NOTE: Our root path is always canonicalized, so yielded entry @@ -201,9 +198,9 @@ fn should_include_root_directory_if_specified(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = make_directory(); let dir = temp.child("missing-dir"); - // distant action dir-read {path} - action_cmd - .args(["dir-read", dir.to_str().unwrap()]) + // distant fs read {path} + ctx.new_assert_cmd(["fs", "read"]) + .args([dir.to_str().unwrap()]) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); } diff --git a/tests/cli/client/fs_read_file.rs b/tests/cli/client/fs_read_file.rs new file mode 100644 index 0000000..931481e --- /dev/null +++ b/tests/cli/client/fs_read_file.rs @@ -0,0 +1,42 @@ +use crate::cli::fixtures::*; +use assert_fs::prelude::*; +use indoc::indoc; +use predicates::prelude::*; +use rstest::*; + +const FILE_CONTENTS: &str = indoc! {r#" + some text + on multiple lines + that is a file's contents +"#}; + +#[rstest] +#[test_log::test] +fn should_print_out_file_contents(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str(FILE_CONTENTS).unwrap(); + + // distant fs read {path} + ctx.new_assert_cmd(["fs", "read"]) + .args([file.to_str().unwrap()]) + .assert() + .success() + .stdout(FILE_CONTENTS) + .stderr(""); +} + +#[rstest] +#[test_log::test] +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + + // distant fs read {path} + ctx.new_assert_cmd(["fs", "read"]) + .args([file.to_str().unwrap()]) + .assert() + .code(1) + .stdout("") + .stderr(predicates::str::is_empty().not()); +} diff --git a/tests/cli/action/remove.rs b/tests/cli/client/fs_remove.rs similarity index 70% rename from tests/cli/action/remove.rs rename to tests/cli/client/fs_remove.rs index 6b3323e..e68c3e5 100644 --- a/tests/cli/action/remove.rs +++ b/tests/cli/client/fs_remove.rs @@ -1,19 +1,18 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; +use crate::cli::fixtures::*; use assert_fs::prelude::*; use predicates::prelude::*; use rstest::*; #[rstest] #[test_log::test] -fn should_support_removing_file(mut action_cmd: CtxCommand) { +fn should_support_removing_file(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("file"); file.touch().unwrap(); // distant action remove {path} - action_cmd - .args(["remove", file.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "remove"]) + .args([file.to_str().unwrap()]) .assert() .success() .stdout("") @@ -24,7 +23,7 @@ fn should_support_removing_file(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_support_removing_empty_directory(mut action_cmd: CtxCommand) { +fn should_support_removing_empty_directory(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Make an empty directory @@ -32,8 +31,8 @@ fn should_support_removing_empty_directory(mut action_cmd: CtxCommand) dir.create_dir_all().unwrap(); // distant action remove {path} - action_cmd - .args(["remove", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "remove"]) + .args([dir.to_str().unwrap()]) .assert() .success() .stdout("") @@ -44,9 +43,7 @@ fn should_support_removing_empty_directory(mut action_cmd: CtxCommand) #[rstest] #[test_log::test] -fn should_support_removing_nonempty_directory_if_force_specified( - mut action_cmd: CtxCommand, -) { +fn should_support_removing_nonempty_directory_if_force_specified(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Make a non-empty directory @@ -55,8 +52,8 @@ fn should_support_removing_nonempty_directory_if_force_specified( dir.child("file").touch().unwrap(); // distant action remove --force {path} - action_cmd - .args(["remove", "--force", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "remove"]) + .args(["--force", dir.to_str().unwrap()]) .assert() .success() .stdout("") @@ -67,7 +64,7 @@ fn should_support_removing_nonempty_directory_if_force_specified( #[rstest] #[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Make a non-empty directory @@ -76,12 +73,12 @@ fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { dir.child("file").touch().unwrap(); // distant action remove {path} - action_cmd - .args(["remove", dir.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "remove"]) + .args([dir.to_str().unwrap()]) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); dir.assert(predicate::path::exists()); dir.assert(predicate::path::is_dir()); diff --git a/tests/cli/action/rename.rs b/tests/cli/client/fs_rename.rs similarity index 71% rename from tests/cli/action/rename.rs rename to tests/cli/client/fs_rename.rs index 1db1cfe..9ba8e80 100644 --- a/tests/cli/action/rename.rs +++ b/tests/cli/client/fs_rename.rs @@ -1,5 +1,4 @@ -use crate::cli::{fixtures::*, utils::FAILURE_LINE}; -use assert_cmd::Command; +use crate::cli::fixtures::*; use assert_fs::prelude::*; use predicates::prelude::*; use rstest::*; @@ -12,7 +11,7 @@ that is a file's contents #[rstest] #[test_log::test] -fn should_support_renaming_file(mut action_cmd: CtxCommand) { +fn should_support_renaming_file(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let src = temp.child("file"); @@ -21,8 +20,8 @@ fn should_support_renaming_file(mut action_cmd: CtxCommand) { let dst = temp.child("file2"); // distant action rename {src} {dst} - action_cmd - .args(["rename", src.to_str().unwrap(), dst.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "rename"]) + .args([src.to_str().unwrap(), dst.to_str().unwrap()]) .assert() .success() .stdout("") @@ -34,7 +33,7 @@ fn should_support_renaming_file(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_support_renaming_nonempty_directory(mut action_cmd: CtxCommand) { +fn should_support_renaming_nonempty_directory(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); // Make a non-empty directory @@ -47,8 +46,8 @@ fn should_support_renaming_nonempty_directory(mut action_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let src = temp.child("dir"); let dst = temp.child("dir2"); // distant action rename {src} {dst} - action_cmd - .args(["rename", src.to_str().unwrap(), dst.to_str().unwrap()]) + ctx.new_assert_cmd(["fs", "rename"]) + .args([src.to_str().unwrap(), dst.to_str().unwrap()]) .assert() .code(1) .stdout("") - .stderr(FAILURE_LINE.clone()); + .stderr(predicates::str::is_empty().not()); src.assert(predicate::path::missing()); dst.assert(predicate::path::missing()); diff --git a/tests/cli/action/search.rs b/tests/cli/client/fs_search.rs similarity index 90% rename from tests/cli/action/search.rs rename to tests/cli/client/fs_search.rs index cda5af5..6a62e14 100644 --- a/tests/cli/action/search.rs +++ b/tests/cli/client/fs_search.rs @@ -1,5 +1,4 @@ use crate::cli::fixtures::*; -use assert_cmd::Command; use assert_fs::prelude::*; use indoc::indoc; use predicates::Predicate; @@ -18,7 +17,7 @@ const SEARCH_RESULTS_REGEX: &str = indoc! {r" #[rstest] #[test_log::test] -fn should_search_filesystem_using_query(mut action_cmd: CtxCommand) { +fn should_search_filesystem_using_query(ctx: DistantManagerCtx) { let root = assert_fs::TempDir::new().unwrap(); root.child("file1.txt").write_str("some file text").unwrap(); root.child("file2.txt") @@ -46,8 +45,7 @@ fn should_search_filesystem_using_query(mut action_cmd: CtxCommand) { }); // distant action search - action_cmd - .arg("search") + ctx.new_assert_cmd(["fs", "search"]) .arg("te[a-z]*\\b") .arg(root.path()) .assert() diff --git a/tests/cli/action/watch.rs b/tests/cli/client/fs_watch.rs similarity index 84% rename from tests/cli/action/watch.rs rename to tests/cli/client/fs_watch.rs index 3fd8252..3e632c8 100644 --- a/tests/cli/action/watch.rs +++ b/tests/cli/client/fs_watch.rs @@ -1,7 +1,7 @@ use crate::cli::{fixtures::*, utils::ThreadedReader}; use assert_fs::prelude::*; use rstest::*; -use std::{process::Command, thread, time::Duration}; +use std::{thread, time::Duration}; fn wait_a_bit() { wait_millis(250); @@ -17,14 +17,15 @@ fn wait_millis(millis: u64) { #[rstest] #[test_log::test] -fn should_support_watching_a_single_file(mut action_std_cmd: CtxCommand) { +fn should_support_watching_a_single_file(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let file = temp.child("file"); file.touch().unwrap(); - // distant action watch {path} - let mut child = action_std_cmd - .args(["watch", file.to_str().unwrap()]) + // distant fs watch {path} + let mut child = ctx + .new_std_cmd(["fs", "watch"]) + .arg(file.to_str().unwrap()) .spawn() .expect("Failed to execute"); @@ -68,7 +69,7 @@ fn should_support_watching_a_single_file(mut action_std_cmd: CtxCommand #[rstest] #[test_log::test] -fn should_support_watching_a_directory_recursively(mut action_std_cmd: CtxCommand) { +fn should_support_watching_a_directory_recursively(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let dir = temp.child("dir"); @@ -77,9 +78,10 @@ fn should_support_watching_a_directory_recursively(mut action_std_cmd: CtxComman let file = dir.child("file"); file.touch().unwrap(); - // distant action watch {path} - let mut child = action_std_cmd - .args(["watch", "--recursive", temp.to_str().unwrap()]) + // distant fs watch {path} + let mut child = ctx + .new_std_cmd(["fs", "watch"]) + .args(["--recursive", temp.to_str().unwrap()]) .spawn() .expect("Failed to execute"); @@ -123,13 +125,14 @@ fn should_support_watching_a_directory_recursively(mut action_std_cmd: CtxComman #[rstest] #[test_log::test] -fn yield_an_error_when_fails(mut action_std_cmd: CtxCommand) { +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { let temp = assert_fs::TempDir::new().unwrap(); let invalid_path = temp.to_path_buf().join("missing"); - // distant action watch {path} - let child = action_std_cmd - .args(["watch", invalid_path.to_str().unwrap()]) + // distant fs watch {path} + let child = ctx + .new_std_cmd(["fs", "watch"]) + .arg(invalid_path.to_str().unwrap()) .spawn() .expect("Failed to execute"); diff --git a/tests/cli/client/fs_write.rs b/tests/cli/client/fs_write.rs new file mode 100644 index 0000000..5af4356 --- /dev/null +++ b/tests/cli/client/fs_write.rs @@ -0,0 +1,125 @@ +use crate::cli::fixtures::*; +use assert_fs::prelude::*; +use indoc::indoc; +use predicates::prelude::*; +use rstest::*; + +const FILE_CONTENTS: &str = indoc! {r#" + some text + on multiple lines + that is a file's contents +"#}; + +const APPENDED_FILE_CONTENTS: &str = indoc! {r#" + even more + file contents +"#}; + +#[rstest] +#[test_log::test] +fn should_support_writing_stdin_to_file(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + + // distant action file-write {path} -- {contents} + ctx.new_assert_cmd(["fs", "write"]) + .args([file.to_str().unwrap()]) + .write_stdin(FILE_CONTENTS) + .assert() + .success() + .stdout("") + .stderr(""); + + // NOTE: We wait a little bit to give the OS time to fully write to file + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Because we're talking to a local server, we can verify locally + file.assert(FILE_CONTENTS); +} + +#[rstest] +#[test_log::test] +fn should_support_appending_stdin_to_file(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str(FILE_CONTENTS).unwrap(); + + // distant action file-write {path} -- {contents} + ctx.new_assert_cmd(["fs", "write"]) + .args(["--append", file.to_str().unwrap()]) + .write_stdin(APPENDED_FILE_CONTENTS) + .assert() + .success() + .stdout("") + .stderr(""); + + // NOTE: We wait a little bit to give the OS time to fully write to file + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Because we're talking to a local server, we can verify locally + file.assert(format!("{}{}", FILE_CONTENTS, APPENDED_FILE_CONTENTS)); +} + +#[rstest] +#[test_log::test] +fn should_support_writing_argument_to_file(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + + // distant action file-write {path} -- {contents} + ctx.new_assert_cmd(["fs", "write"]) + .args([file.to_str().unwrap(), "--"]) + .arg(FILE_CONTENTS) + .assert() + .success() + .stdout("") + .stderr(""); + + // NOTE: We wait a little bit to give the OS time to fully write to file + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Because we're talking to a local server, we can verify locally + file.assert(FILE_CONTENTS); +} + +#[rstest] +#[test_log::test] +fn should_support_appending_argument_to_file(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str(FILE_CONTENTS).unwrap(); + + // distant action file-write {path} -- {contents} + ctx.new_assert_cmd(["fs", "write"]) + .args(["--append", file.to_str().unwrap(), "--"]) + .arg(APPENDED_FILE_CONTENTS) + .assert() + .success() + .stdout("") + .stderr(""); + + // NOTE: We wait a little bit to give the OS time to fully write to file + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Because we're talking to a local server, we can verify locally + file.assert(format!("{}{}", FILE_CONTENTS, APPENDED_FILE_CONTENTS)); +} + +#[rstest] +#[test_log::test] +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-dir").child("missing-file"); + + // distant action file-write {path} -- {contents} + ctx.new_assert_cmd(["fs", "write"]) + .args([file.to_str().unwrap(), "--"]) + .arg(FILE_CONTENTS) + .assert() + .code(1) + .stdout("") + .stderr(predicates::str::is_empty().not()); + + // Because we're talking to a local server, we can verify locally + file.assert(predicates::path::missing()); +} diff --git a/tests/cli/client/mod.rs b/tests/cli/client/mod.rs new file mode 100644 index 0000000..9db5023 --- /dev/null +++ b/tests/cli/client/mod.rs @@ -0,0 +1,14 @@ +mod capabilities; +mod fs_copy; +mod fs_exists; +mod fs_make_dir; +mod fs_metadata; +mod fs_read_directory; +mod fs_read_file; +mod fs_remove; +mod fs_rename; +mod fs_search; +mod fs_watch; +mod fs_write; +mod spawn; +mod system_info; diff --git a/tests/cli/action/proc_spawn.rs b/tests/cli/client/spawn.rs similarity index 73% rename from tests/cli/action/proc_spawn.rs rename to tests/cli/client/spawn.rs index e3a27d8..c5b86b1 100644 --- a/tests/cli/action/proc_spawn.rs +++ b/tests/cli/client/spawn.rs @@ -1,20 +1,18 @@ use crate::cli::{fixtures::*, scripts::*, utils::regex_pred}; -use assert_cmd::Command; use rstest::*; -use std::process::Command as StdCommand; #[rstest] #[test_log::test] -fn should_execute_program_and_return_exit_status(mut action_cmd: CtxCommand) { +fn should_execute_program_and_return_exit_status(ctx: DistantManagerCtx) { // Windows prints out a message whereas unix prints nothing #[cfg(windows)] let stdout = regex_pred(".+"); #[cfg(unix)] let stdout = ""; - // distant action proc-spawn -- {cmd} [args] - action_cmd - .args(["proc-spawn", "--"]) + // distant spawn -- {cmd} [args] + ctx.cmd("spawn") + .arg("--") .arg(SCRIPT_RUNNER.as_str()) .arg(SCRIPT_RUNNER_ARG.as_str()) .arg(EXIT_CODE.to_str().unwrap()) @@ -27,10 +25,10 @@ fn should_execute_program_and_return_exit_status(mut action_cmd: CtxCommand) { - // distant action proc-spawn {cmd} [args] - action_cmd - .args(["proc-spawn", "--"]) +fn should_capture_and_print_stdout(ctx: DistantManagerCtx) { + // distant spawn -- {cmd} [args] + ctx.cmd("spawn") + .arg("--") .arg(SCRIPT_RUNNER.as_str()) .arg(SCRIPT_RUNNER_ARG.as_str()) .arg(ECHO_ARGS_TO_STDOUT.to_str().unwrap()) @@ -47,10 +45,10 @@ fn should_capture_and_print_stdout(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn should_capture_and_print_stderr(mut action_cmd: CtxCommand) { - // distant action proc-spawn {cmd} [args] - action_cmd - .args(["proc-spawn", "--"]) +fn should_capture_and_print_stderr(ctx: DistantManagerCtx) { + // distant spawn -- {cmd} [args] + ctx.cmd("spawn") + .arg("--") .arg(SCRIPT_RUNNER.as_str()) .arg(SCRIPT_RUNNER_ARG.as_str()) .arg(ECHO_ARGS_TO_STDERR.to_str().unwrap()) @@ -71,12 +69,13 @@ fn should_capture_and_print_stderr(mut action_cmd: CtxCommand) { // refactor and should be revisited some day. #[rstest] #[test_log::test] -fn should_forward_stdin_to_remote_process(mut action_std_cmd: CtxCommand) { +fn should_forward_stdin_to_remote_process(ctx: DistantManagerCtx) { use std::io::{BufRead, BufReader, Write}; // distant action proc-spawn {cmd} [args] - let mut child = action_std_cmd - .args(["proc-spawn", "--"]) + let mut child = ctx + .new_std_cmd(["spawn"]) + .arg("--") .arg(SCRIPT_RUNNER.as_str()) .arg(SCRIPT_RUNNER_ARG.as_str()) .arg(ECHO_STDIN_TO_STDOUT.to_str().unwrap()) @@ -111,16 +110,16 @@ fn should_forward_stdin_to_remote_process(mut action_std_cmd: CtxCommand) { +fn reflect_the_exit_code_of_the_process(ctx: DistantManagerCtx) { // Windows prints out a message whereas unix prints nothing #[cfg(windows)] let stdout = regex_pred(".+"); #[cfg(unix)] let stdout = ""; - // distant action proc-spawn {cmd} [args] - action_cmd - .args(["proc-spawn", "--"]) + // distant spawn -- {cmd} [args] + ctx.cmd("spawn") + .arg("--") .arg(SCRIPT_RUNNER.as_str()) .arg(SCRIPT_RUNNER_ARG.as_str()) .arg(EXIT_CODE.to_str().unwrap()) @@ -133,10 +132,10 @@ fn reflect_the_exit_code_of_the_process(mut action_cmd: CtxCommand) { #[rstest] #[test_log::test] -fn yield_an_error_when_fails(mut action_cmd: CtxCommand) { - // distant action proc-spawn {cmd} [args] - action_cmd - .args(["proc-spawn", "--"]) +fn yield_an_error_when_fails(ctx: DistantManagerCtx) { + // distant spawn -- {cmd} [args] + ctx.cmd("spawn") + .arg("--") .arg(DOES_NOT_EXIST_BIN.to_str().unwrap()) .assert() .code(1) diff --git a/tests/cli/action/system_info.rs b/tests/cli/client/system_info.rs similarity index 81% rename from tests/cli/action/system_info.rs rename to tests/cli/client/system_info.rs index f82e8b1..cf44c46 100644 --- a/tests/cli/action/system_info.rs +++ b/tests/cli/client/system_info.rs @@ -1,14 +1,11 @@ use crate::cli::fixtures::*; -use assert_cmd::Command; use rstest::*; use std::env; #[rstest] #[test_log::test] -fn should_output_system_info(mut action_cmd: CtxCommand) { - // distant action system-info - action_cmd - .arg("system-info") +fn should_output_system_info(ctx: DistantManagerCtx) { + ctx.cmd("system-info") .assert() .success() .stdout(format!( @@ -19,7 +16,7 @@ fn should_output_system_info(mut action_cmd: CtxCommand) { "Cwd: {:?}\n", "Path Sep: {:?}\n", "Username: {:?}\n", - "Shell: {:?}\n", + "Shell: {:?}", ), env::consts::FAMILY.to_string(), env::consts::OS.to_string(), diff --git a/tests/cli/fixtures.rs b/tests/cli/fixtures.rs index aa833fc..03f91af 100644 --- a/tests/cli/fixtures.rs +++ b/tests/cli/fixtures.rs @@ -13,8 +13,8 @@ use std::{ time::{Duration, Instant}, }; -mod repl; -pub use repl::Repl; +mod api; +pub use api::ApiProcess; static ROOT_LOG_DIR: Lazy = Lazy::new(|| std::env::temp_dir().join("distant")); static SESSION_RANDOM: Lazy = Lazy::new(rand::random); @@ -173,7 +173,6 @@ impl DistantManagerCtx { // Connect manager to server let mut connect_cmd = StdCommand::new(bin_path()); connect_cmd - .arg("client") .arg("connect") .arg("--log-file") .arg(random_log_file("connect")) @@ -235,6 +234,13 @@ impl DistantManagerCtx { } } + /// Produces a new test command configured with a singular subcommand. Useful for root-level + /// subcommands. + #[inline] + pub fn cmd(&self, subcommand: &'static str) -> Command { + self.new_assert_cmd(vec![subcommand]) + } + /// Produces a new test command that configures some distant command /// configured with an environment that can talk to a remote distant server pub fn new_assert_cmd(&self, subcommands: impl IntoIterator) -> Command { @@ -254,6 +260,7 @@ impl DistantManagerCtx { cmd.arg("--unix-socket").arg(self.socket_or_pipe.as_str()); } + eprintln!("new_assert_cmd: {cmd:?}"); cmd } @@ -281,6 +288,7 @@ impl DistantManagerCtx { .stdout(Stdio::piped()) .stderr(Stdio::piped()); + eprintln!("new_std_cmd: {cmd:?}"); cmd } } @@ -316,40 +324,32 @@ pub fn ctx() -> DistantManagerCtx { #[fixture] pub fn lsp_cmd(ctx: DistantManagerCtx) -> CtxCommand { - let cmd = ctx.new_assert_cmd(vec!["client", "lsp"]); - CtxCommand { ctx, cmd } -} - -#[fixture] -pub fn action_cmd(ctx: DistantManagerCtx) -> CtxCommand { - let cmd = ctx.new_assert_cmd(vec!["client", "action"]); + let cmd = ctx.new_assert_cmd(vec!["lsp"]); CtxCommand { ctx, cmd } } #[fixture] pub fn action_std_cmd(ctx: DistantManagerCtx) -> CtxCommand { - let cmd = ctx.new_std_cmd(vec!["client", "action"]); + let cmd = ctx.new_std_cmd(vec!["action"]); CtxCommand { ctx, cmd } } #[fixture] -pub fn json_repl(ctx: DistantManagerCtx) -> CtxCommand { +pub fn api_process(ctx: DistantManagerCtx) -> CtxCommand { let child = ctx - .new_std_cmd(vec!["client", "repl"]) - .arg("--format") - .arg("json") + .new_std_cmd(vec!["api"]) .spawn() - .expect("Failed to start distant repl with json format"); - let cmd = Repl::new(child, TIMEOUT); + .expect("Failed to start distant api with json format"); + let cmd = ApiProcess::new(child, TIMEOUT); CtxCommand { ctx, cmd } } -pub async fn validate_authentication(repl: &mut Repl) { +pub async fn validate_authentication(proc: &mut ApiProcess) { // NOTE: We have to handle receiving authentication messages, as we will get // an authentication initialization of with method "none", and then // a finish authentication status before we can do anything else. - let json = repl + let json = proc .read_json_from_stdout() .await .unwrap() @@ -359,7 +359,7 @@ pub async fn validate_authentication(repl: &mut Repl) { json!({"type": "auth_initialization", "methods": ["none"]}) ); - let json = repl + let json = proc .write_and_read_json(json!({ "type": "auth_initialization_response", "methods": ["none"] @@ -369,7 +369,7 @@ pub async fn validate_authentication(repl: &mut Repl) { .expect("Missing authentication method"); assert_eq!(json, json!({"type": "auth_start_method", "method": "none"})); - let json = repl + let json = proc .read_json_from_stdout() .await .unwrap() diff --git a/tests/cli/fixtures/repl.rs b/tests/cli/fixtures/api.rs similarity index 96% rename from tests/cli/fixtures/repl.rs rename to tests/cli/fixtures/api.rs index ad4b18f..fd9e451 100644 --- a/tests/cli/fixtures/repl.rs +++ b/tests/cli/fixtures/api.rs @@ -9,7 +9,7 @@ use tokio::sync::mpsc; const CHANNEL_BUFFER: usize = 100; -pub struct Repl { +pub struct ApiProcess { child: Child, stdin: mpsc::Sender, stdout: mpsc::Receiver, @@ -17,8 +17,8 @@ pub struct Repl { timeout: Option, } -impl Repl { - /// Create a new [`Repl`] wrapping around a [`Child`] +impl ApiProcess { + /// Create a new [`ApiProcess`] wrapping around a [`Child`] pub fn new(mut child: Child, timeout: impl Into>) -> Self { let mut stdin = BufWriter::new(child.stdin.take().expect("Child missing stdin")); let mut stdout = BufReader::new(child.stdout.take().expect("Child missing stdout")); @@ -80,7 +80,7 @@ impl Repl { } } - /// Writes json to the repl over stdin and then waits for json to be received over stdout, + /// Writes json to the api over stdin and then waits for json to be received over stdout, /// failing if either operation exceeds timeout if set or if the output to stdout is not json, /// and returns none if stdout channel has closed pub async fn write_and_read_json( @@ -200,13 +200,13 @@ impl Repl { stderr } - /// Kills the repl by sending a signal to the process + /// Kills the api by sending a signal to the process pub fn kill(&mut self) -> io::Result<()> { self.child.kill() } } -impl Drop for Repl { +impl Drop for ApiProcess { fn drop(&mut self) { let _ = self.kill(); } diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index f65369f..2c17ef9 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -1,6 +1,6 @@ -mod action; +mod api; +mod client; mod fixtures; mod manager; -mod repl; mod scripts; mod utils; diff --git a/tests/cli/repl/mod.rs b/tests/cli/repl/mod.rs deleted file mode 100644 index b44cc65..0000000 --- a/tests/cli/repl/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod capabilities; -mod copy; -mod dir_create; -mod dir_read; -mod exists; -mod file_append; -mod file_append_text; -mod file_read; -mod file_read_text; -mod file_write; -mod file_write_text; -mod metadata; -mod proc_spawn; -mod remove; -mod rename; -mod search; -mod system_info; -mod watch; diff --git a/tests/cli/utils.rs b/tests/cli/utils.rs index dbe038c..9f5d279 100644 --- a/tests/cli/utils.rs +++ b/tests/cli/utils.rs @@ -1,13 +1,8 @@ -use once_cell::sync::Lazy; use predicates::prelude::*; mod reader; pub use reader::ThreadedReader; -/// Predicate that checks for a single line that is a failure -pub static FAILURE_LINE: Lazy = - Lazy::new(|| regex_pred(r"^.*\n$")); - /// Produces a regex predicate using the given string pub fn regex_pred(s: &str) -> predicates::str::RegexPredicate { predicate::str::is_match(s).unwrap()