Compare commits

...

23 Commits

Author SHA1 Message Date
82fe053ba8 [WIP] Integration of TestEnvironment 2026-08-26 18:28:44 +02:00
67800bdb28 [WIP] Integration of TestEnvironment 2026-08-26 18:28:07 +02:00
0b0ff84c30 Revert Environment needing to be ShellInterface 2026-08-26 18:28:07 +02:00
d64e9d735c Require Environment to be ShellInterface 2026-08-26 18:28:07 +02:00
2aae341416 Rework how EnvCommand and ShellInterface interact with each other 2026-08-26 18:28:07 +02:00
43d1b29aa8 Fix test_parse_server_configuration 2026-08-26 18:28:07 +02:00
be79af3be3 Remove old command module 2026-08-26 18:28:06 +02:00
2991dfc9e5 Use new ShellCommand interface in main application 2026-08-26 18:27:48 +02:00
f054f76302 Implement ShellCommand to Command conversion 2026-08-26 18:27:48 +02:00
3b068c000a Remove minor code duplication 2026-08-26 18:27:48 +02:00
76469eb207 Implement ShellInterface for EnvCommand 2026-08-26 18:27:48 +02:00
75923404a4 Make environment in EnvCommand mutable 2026-08-26 18:27:48 +02:00
3f62bb3190 Make into_result_with_error_logging available for every MaybeCast<Output> type 2026-08-26 18:27:48 +02:00
00a9fe8643 Fix ServerReferences using invalid parser 2026-08-26 18:27:48 +02:00
8797fd4936 Handle input in environment 2026-08-26 18:27:45 +02:00
b6f2558075 Add shell_interface and environment and prepare main application for those changes 2026-08-26 18:27:16 +02:00
09992a9114 Merge pull request '39-y-alias-for-no-confirm' (#45) from 39-y-alias-for-no-confirm into master
Reviewed-on: #45
2026-08-26 15:23:13 +02:00
Leonard Steppy
b107231e46 Fix warnings about elided lifetimes 2026-08-26 15:22:10 +02:00
Leonard Steppy
0583eecf81 Add -y as alias to --no-confirm 2026-08-26 15:18:40 +02:00
24fec093d2 Merge pull request 'Fix nix evaluation warning' (#40) from fix-nix-eval-warning into master
Reviewed-on: #40
2026-03-06 14:34:38 +01:00
Leonard Steppy
7b64d979f8 Fix nix evaluation warning 2026-03-06 14:33:43 +01:00
f6de003bbd Merge pull request 'Add flake' (#38) from flake into master
Reviewed-on: #38
2025-10-05 22:03:01 +02:00
Leonard Steppy
5343ce30a2 Add flake 2025-10-05 22:02:13 +02:00
12 changed files with 2042 additions and 812 deletions

444
Cargo.lock generated Normal file
View File

@ -0,0 +1,444 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "homedir"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
dependencies = [
"cfg-if",
"nix",
"widestring",
"windows",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "lazy-regex"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4994ba703f78b083e2f7946dac9251abd83fd43a0365f030e99b69be5b4b9ef9"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd97232314824e6dbef1918a871bb93f51070455e3715bf26e19a6d01aa977a0"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.119",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "multi-ssh"
version = "0.2.0"
dependencies = [
"clap",
"homedir",
"lazy-regex",
"shell-words",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core",
"windows-link 0.1.3",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "multi-ssh" name = "multi-ssh"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@ -84,7 +84,3 @@ Once you have that installed, just run
cargo build --release cargo build --release
``` ```
and you will find an executable in `target/release`. and you will find an executable in `target/release`.
### Unit tests
In order for the unit tests to pass, you will need `python3`.

62
flake.lock Normal file
View File

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1759580034,
"narHash": "sha256-YWo57PL7mGZU7D4WeKFMiW4ex/O6ZolUS6UNBHTZfkI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3bcc93c5f7a4b30335d31f21e2f1281cba68c318",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1759631821,
"narHash": "sha256-V8A1L0FaU/aSXZ1QNJScxC12uP4hANeRBgI4YdhHeRM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1d7cbdaad90f8a5255a89a6eddd8af24dc89cafe",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

116
flake.nix Normal file
View File

@ -0,0 +1,116 @@
{
description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, rust-overlay, ... }:
let
systems = nixpkgs.lib.systems.flakeExposed;
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (system:
let
overlays = [ rust-overlay.overlays.default ];
pkgs = import nixpkgs { inherit system; overlays = overlays; };
rustToolchain = pkgs.rust-bin.stable."1.90.0".minimal;
rustPlatform = pkgs.makeRustPlatform { cargo = rustToolchain; rustc = rustToolchain; };
in {
multiSsh = rustPlatform.buildRustPackage {
pname = "multi-ssh";
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeCheckInputs = [ pkgs.python3 ];
release = true;
};
default = self.packages.${system}.multiSsh;
}
);
apps = forAllSystems (system:
let
pkg = self.packages.${system}.multiSsh;
in {
default = {
type = "app";
program = "${pkg}/bin/multi-ssh";
};
}
);
nixosModules.multiSsh = { lib, pkgs, config, ... }: {
options = {
programs.multiSsh = {
enable = lib.mkEnableOption "multi-ssh";
editor = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Editor to forward to multi-ssh (MSSH_EDITOR). If null, the variable is not set.";
default = null;
};
package = lib.mkOption {
type = lib.types.package;
description = "Derivation to install for multi-ssh";
default = self.packages.${pkgs.stdenv.hostPlatform.system}.multiSsh;
};
servers = {
local = {
dir = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''Local server directory, relative to the user's HOME. If set, a local entry ".:<dir>" is added to MSSH_SERVERS.'';
};
};
server = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: {
options = {
dir = lib.mkOption {
type = lib.types.str;
description = "Server directory for ${name} (remote ssh host), as a string.";
};
};
}));
default = {};
description = "Attribute set of servers where each attribute name is the ssh host and its value provides a 'dir' string.";
};
};
msshAlias = lib.mkOption {
type = lib.types.bool;
default = true;
description = "If true, install an 'mssh' wrapper (alias) that calls 'multi-ssh' and sets env vars.";
};
};
};
config = lib.mkIf config.programs.multiSsh.enable (let
# Build MSSH_SERVERS from options
serverEntries = lib.mapAttrsToList (name: value: "${name}:${value.dir}") config.programs.multiSsh.servers.server;
localEntry = lib.optional (config.programs.multiSsh.servers.local.dir != null) ".:${config.programs.multiSsh.servers.local.dir}";
msshServers = lib.concatStringsSep "," (localEntry ++ serverEntries);
# wrapper that sets env and execs the real binary (installed as 'multi-ssh')
multiSshWrapper = pkgs.writeShellScriptBin "multi-ssh" ''
#!${pkgs.runtimeShell}
${lib.optionalString (config.programs.multiSsh.editor != null) ''export MSSH_EDITOR="${config.programs.multiSsh.editor}"''}
${lib.optionalString (msshServers != "") ''export MSSH_SERVERS="${msshServers}"''}
exec ${config.programs.multiSsh.package}/bin/multi-ssh "$@"
'';
# optional 'mssh' alias that delegates to the wrapped 'multi-ssh'
msshWrapper = pkgs.writeShellScriptBin "mssh" ''
#!${pkgs.runtimeShell}
exec ${multiSshWrapper}/bin/multi-ssh "$@"
'';
in {
environment.systemPackages = [
multiSshWrapper
] ++ lib.optional config.programs.multiSsh.msshAlias msshWrapper;
});
};
};
}

View File

@ -1,200 +0,0 @@
use crate::log;
use crate::logger::{LogLevel, Logger};
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::io;
use std::iter::once;
use std::process::{Command, ExitStatus, Output};
pub trait LogRunnable {
fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError<ExecutionError>>;
fn collect_output(&mut self) -> Result<Output, CommandSpecificError<ExecutionError>>;
fn collect_full_output(&mut self) -> Result<Output, CommandSpecificError<StartError>>;
}
impl LogRunnable for Command {
fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError<ExecutionError>> {
run(self, logger).map_err(|error| CommandSpecificError {
command: self,
error,
})
}
fn collect_output(&mut self) -> Result<Output, CommandSpecificError<ExecutionError>> {
collect_output(self, None).map_err(|error| CommandSpecificError {
command: self,
error,
})
}
fn collect_full_output(&mut self) -> Result<Output, CommandSpecificError<StartError>> {
collect_full_output(self).map_err(|error| CommandSpecificError {
command: self,
error,
})
}
}
fn run(command: &mut Command, logger: &Logger) -> Result<(), ExecutionError> {
match logger.level {
LogLevel::Debug | LogLevel::Info => {
let status = command.status()?;
if !status.success() {
Err(status)?;
}
}
LogLevel::Error => {
collect_output(command, Some(logger))?;
}
}
Ok(())
}
fn collect_output(
command: &mut Command,
logger: Option<&Logger>,
) -> Result<Output, ExecutionError> {
let output = collect_full_output(command)?;
if !output.status.success() {
if let Some(logger) = logger {
log!(logger, error, "{}", String::from_utf8_lossy(&output.stdout));
log!(logger, error, "{}", String::from_utf8_lossy(&output.stderr));
}
Err(output.status)?;
}
Ok(output)
}
fn collect_full_output(command: &mut Command) -> Result<Output, StartError> {
Ok(command.output()?)
}
#[derive(Debug)]
pub struct CommandSpecificError<'a, E> {
pub command: &'a Command,
pub error: E,
}
impl<E> Display for CommandSpecificError<'_, E>
where
E: Display,
{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Failed to execute command '{}': {}",
command_to_string(self.command),
self.error
)
}
}
fn command_to_string(command: &Command) -> String {
once(command.get_program().to_string_lossy().to_string())
.chain(command.get_args().map(|arg| {
let arg_str = arg.to_string_lossy();
if arg_str.contains(' ') {
format!("\"{arg_str}\"")
} else {
arg_str.to_string()
}
}))
.collect::<Vec<_>>()
.join(" ")
}
impl<E> Error for CommandSpecificError<'_, E> where E: Debug + Display {}
#[derive(Debug)]
pub struct StartError(io::Error);
impl From<io::Error> for StartError {
fn from(value: io::Error) -> Self {
StartError(value)
}
}
impl Display for StartError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed to start command: {}", self.0)
}
}
impl Error for StartError {}
#[derive(Debug)]
pub enum ExecutionError {
StartError(StartError),
BadExitStatus(ExitStatus),
}
impl From<io::Error> for ExecutionError {
fn from(value: io::Error) -> Self {
Self::StartError(StartError(value))
}
}
impl From<StartError> for ExecutionError {
fn from(value: StartError) -> Self {
Self::StartError(value)
}
}
impl From<ExitStatus> for ExecutionError {
fn from(value: ExitStatus) -> Self {
Self::BadExitStatus(value)
}
}
impl Display for ExecutionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ExecutionError::StartError(e) => Display::fmt(e, f),
ExecutionError::BadExitStatus(status) => write!(f, "Command failed with {}", status),
}
}
}
impl Error for ExecutionError {}
#[cfg(test)]
mod test {
use crate::command::{CommandSpecificError, ExecutionError, LogRunnable};
use crate::logger::Logger;
use std::path::PathBuf;
use std::process::Command;
#[test]
fn test_unknown_command() {
let mut command = Command::new("python7");
let Err(
e @ CommandSpecificError {
error: ExecutionError::StartError(_),
..
},
) = command
.args([PathBuf::from("test-ressources/python/exit_1.py")])
.run(&Logger::default())
else {
panic!("command shouldn't exist");
};
assert_eq!(e.to_string(), "Failed to execute command 'python7': Failed to start command: No such file or directory (os error 2)");
}
#[test]
fn test_error() {
let mut command = Command::new("python3");
let Err(
e @ CommandSpecificError {
error: ExecutionError::BadExitStatus(_),
..
},
) = command
.arg("test-ressources/python/exit_1.py")
.run(&Logger::default())
else {
panic!("command should return exit-code 1")
};
assert_eq!(e.to_string(), "Failed to execute command 'python3 test-ressources/python/exit_1.py': Command failed with exit status: 1");
}
}

96
src/environment.rs Normal file
View File

@ -0,0 +1,96 @@
use crate::shell_interface::{
build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand,
ShellInterface, StartError,
};
use std::env::VarError;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::{env, io};
pub trait Environment {
fn args_os(&self) -> Vec<OsString>;
fn var_os<K>(&self, key: K) -> Option<OsString>
where
K: AsRef<OsStr>;
fn var<K>(&self, key: K) -> Result<String, VarError>
where
K: AsRef<OsStr>,
{
self
.var_os(key)
.ok_or(VarError::NotPresent)
.and_then(|s| s.into_string().map_err(VarError::NotUnicode))
}
fn set_var<K, V>(&mut self, key: K, value: V)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>;
fn get_home_directory(&self) -> Option<PathBuf>;
fn read_line(&mut self) -> Result<String, io::Error>;
}
#[derive(Debug, Default)]
pub struct Prod;
impl Environment for Prod {
fn args_os(&self) -> Vec<OsString> {
env::args_os().collect()
}
fn var_os<K>(&self, key: K) -> Option<OsString>
where
K: AsRef<OsStr>
{
env::var_os(key)
}
fn var<K>(&self, key: K) -> Result<String, VarError>
where
K: AsRef<OsStr>,
{
env::var(key)
}
fn set_var<K, V>(&mut self, key: K, value: V)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
env::set_var(key, value);
}
fn get_home_directory(&self) -> Option<PathBuf> {
homedir::my_home().ok().flatten()
}
fn read_line(&mut self) -> Result<String, io::Error> {
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(buffer.trim().to_string())
}
}
impl ShellInterface for Prod {
fn run_command(&mut self, command: ShellCommand) -> CommandResult<ExitStatus, StartError> {
CommandResult {
result: build_command_from_shell_command(&command)
.status()
.map(ExitStatus::from)
.map_err(StartError::from),
command,
}
}
fn collect_command_output(
&mut self,
command: ShellCommand,
) -> CommandResult<CommandOutput, StartError> {
CommandResult {
result: build_command_from_shell_command(&command)
.output()
.map(CommandOutput::from)
.map_err(StartError::from),
command,
}
}
}

90
src/integration_test.rs Normal file
View File

@ -0,0 +1,90 @@
use crate::environment::Environment;
use crate::shell_interface::{
CommandOutput, CommandResult, ExitStatus, ShellCommand, ShellInterface, StartError,
};
use std::collections::{HashMap, VecDeque};
use std::ffi::{OsStr, OsString};
use std::io::Error;
use std::path::PathBuf;
#[derive(Debug)]
pub struct TestEnvironment {
/// passed command line arguments
args_os: Vec<OsString>,
/// set environment variables - we assume a pure environment by default
env_vars: HashMap<OsString, OsString>,
/// home directory, relative to the target/test folder
home_dir: PathBuf,
/// pending lines of std input
stdin: VecDeque<String>,
/// whether an ssh agent has been started successfully
ssh_agent_started: bool,
// TODO ssh servers and local server
}
#[derive(Debug)]
pub struct SshServer {
pub name: String,
pub home_dir: FsEntry,
}
#[derive(Debug)]
pub struct FsEntry {
pub name: OsString,
pub kind: FsEntryKind,
}
#[derive(Debug)]
pub enum FsEntryKind {
Directory(Dir),
File {
contents: String,
}
}
#[derive(Debug)]
pub struct Dir {
pub contents: Vec<FsEntry>,
}
impl Environment for TestEnvironment {
fn args_os(&self) -> Vec<OsString> {
self.args_os.clone()
}
fn var_os<K>(&self, key: K) -> Option<OsString>
where
K: AsRef<OsStr>
{
self.env_vars.get(key.as_ref()).map(|s| s.into())
}
fn set_var<K, V>(&mut self, key: K, value: V)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.env_vars.insert(key.as_ref().into(), value.as_ref().into());
}
fn get_home_directory(&self) -> Option<PathBuf> {
PathBuf::from("target/integration_test").join(&self.home_dir).into()
}
fn read_line(&mut self) -> Result<String, Error> {
self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared"))
}
}
impl ShellInterface for TestEnvironment {
fn run_command(&mut self, command: ShellCommand) -> CommandResult<ExitStatus, StartError> {
todo!()
}
fn collect_command_output(
&mut self,
command: ShellCommand,
) -> CommandResult<CommandOutput, StartError> {
todo!()
}
}

View File

@ -1,18 +1,22 @@
mod action; mod action;
mod command; mod environment;
mod file; mod file;
#[cfg(test)]
mod integration_test;
mod logger; mod logger;
mod os_string_builder;
mod os_str_extension; mod os_str_extension;
mod os_string_builder;
mod server; mod server;
mod shell_interface;
use crate::action::{Action, FileAction, ServerActions}; use crate::action::{Action, FileAction, ServerActions};
use crate::command::{CommandSpecificError, ExecutionError, LogRunnable}; use crate::environment::{Environment, Prod};
use crate::file::{FileMatcher, FileNameInfo}; use crate::file::{FileMatcher, FileNameInfo};
use crate::logger::{LogLevel, Logger}; use crate::logger::{LogLevel, Logger};
use crate::os_str_extension::OsStrExtension; use crate::os_str_extension::OsStrExtension;
use crate::os_string_builder::ReplaceWithOsStr; use crate::os_string_builder::ReplaceWithOsStr;
use crate::server::{RelativeLocalPathAnker, ServerAddress}; use crate::server::{RelativeLocalPathAnker, ServerAddress};
use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface};
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use lazy_regex::{lazy_regex, Lazy, Regex}; use lazy_regex::{lazy_regex, Lazy, Regex};
use server::{Server, ServerReference}; use server::{Server, ServerReference};
@ -22,15 +26,12 @@ use std::hash::Hash;
use std::io::Write; use std::io::Write;
use std::iter::once; use std::iter::once;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{env, fs, io}; use std::{env, fs, io};
const SERVERS_ENV_VAR: &str = "MSSH_SERVERS"; const SERVERS_ENV_VAR: &str = "MSSH_SERVERS";
const EDITOR_ENV_VAR: &str = "MSSH_EDITOR"; const EDITOR_ENV_VAR: &str = "MSSH_EDITOR";
const FILE_PLACEHOLDER: &str = "<file>"; const FILE_PLACEHOLDER: &str = "<file>";
type ShellCmd = std::process::Command;
/// Uploads a file or executes a command on multiple configured servers /// Uploads a file or executes a command on multiple configured servers
/// ///
/// Servers must either be configured via environment variable or denote their server directory with /// Servers must either be configured via environment variable or denote their server directory with
@ -41,13 +42,13 @@ type ShellCmd = std::process::Command;
/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers. /// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about)] #[command(version, about, long_about)]
struct Args { pub struct Args {
/// The action to perform /// The action to perform
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
/// The ssh names and optionally home directories of the servers to perform the action on /// The ssh names and optionally home directories of the servers to perform the action on
#[arg(num_args = 0.., value_parser = ServerReference::from_str)] #[arg(num_args = 0..)]
servers: Vec<ServerReference>, servers: Vec<String>,
/// How verbose logging output should be /// How verbose logging output should be
#[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])] #[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])]
log_level: LogLevel, log_level: LogLevel,
@ -71,7 +72,7 @@ enum Command {
/// When this option is set, the file path must be absolute, or relative to the server directory. /// When this option is set, the file path must be absolute, or relative to the server directory.
/// The upload-directory has no influence on where the file will be taken from. /// The upload-directory has no influence on where the file will be taken from.
#[arg(short = 'S', long)] #[arg(short = 'S', long)]
file_server: Option<ServerReference>, file_server: Option<String>,
/// How to handle older versions of the file /// How to handle older versions of the file
#[arg(short = 'a', long, default_value = "delete", default_missing_value = "archive", num_args = 0..=1)] #[arg(short = 'a', long, default_value = "delete", default_missing_value = "archive", num_args = 0..=1)]
old_version_policy: OldVersionPolicy, old_version_policy: OldVersionPolicy,
@ -79,7 +80,7 @@ enum Command {
#[arg(short = 'd', long, default_value = "plugins")] #[arg(short = 'd', long, default_value = "plugins")]
upload_directory: PathBuf, upload_directory: PathBuf,
/// Skip the confirmation dialog /// Skip the confirmation dialog
#[arg(long, default_value = "false")] #[arg(short = 'y', long, default_value = "false")]
no_confirm: bool, no_confirm: bool,
/// The prefix of the name of older versions of the file, which should be replaced or deleted /// The prefix of the name of older versions of the file, which should be replaced or deleted
#[arg(short, long)] #[arg(short, long)]
@ -137,24 +138,26 @@ enum OldVersionPolicy {
Delete, Delete,
} }
#[macro_export] #[derive(Debug, Default)]
macro_rules! input { pub struct Application<E> {
($prompt: tt) => {{ pub environment: E,
print!($prompt);
io::stdout().flush().expect("failed to flush stdout");
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.expect("failed to read stdin");
buf.trim().to_string()
}};
() => {
input!()
};
} }
fn main() -> Result<(), String> { impl<E> Application<E>
let args = Args::parse(); where
E: Environment + ShellInterface,
{
pub fn run(&mut self) -> Result<(), String> {
let args = Args::try_parse_from(self.environment.args_os()).map_err(|e| e.to_string())?;
self.run_with_args(args)
}
pub fn run_with_args(&mut self, args: Args) -> Result<(), String> {
macro_rules! env {
() => {
&mut self.environment
};
}
let logger = Logger { let logger = Logger {
//all the below options are conflicting with each other so an if else is fine //all the below options are conflicting with each other so an if else is fine
@ -167,10 +170,16 @@ fn main() -> Result<(), String> {
}, },
}; };
let mut configured_servers = LazyCell::new(parse_server_configuration_from_env); let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env());
let servers = args let servers = args
.servers .servers
.iter() .into_iter()
.map(|ref_str| {
ServerReference::from_str(&ref_str, || self.get_home_directory())
.map_err(|e| format!("Invalid server reference '{ref_str}': {e}"))
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|server_reference| { .map(|server_reference| {
let server_identifier = server_reference.get_identifier(); let server_identifier = server_reference.get_identifier();
server_reference server_reference
@ -196,13 +205,15 @@ fn main() -> Result<(), String> {
pure, pure,
exclude, exclude,
} => { } => {
require_non_empty_servers(&servers)?; Self::require_non_empty_servers(&servers)?;
require_non_empty(&files, "files to upload")?; Self::require_non_empty(&files, "files to upload")?;
start_ssh_agent(&logger)?;
//resolve file server //resolve file server
let file_server = match file_server { let file_server = match file_server {
Some(server_reference) => { Some(ref_str) => {
let server_reference =
ServerReference::from_str(&ref_str, || self.get_home_directory())
.map_err(|e| format!("Invalid file-server reference '{ref_str}': {e}"))?;
let file_server_identifier = server_reference.get_identifier().to_string(); let file_server_identifier = server_reference.get_identifier().to_string();
let server = server_reference.try_resolve_lazy(&mut configured_servers) let server = server_reference.try_resolve_lazy(&mut configured_servers)
.map_err(|e| format!("Can't resolve server directory for file-server '{file_server_identifier}': {e}"))? .map_err(|e| format!("Can't resolve server directory for file-server '{file_server_identifier}': {e}"))?
@ -212,6 +223,8 @@ fn main() -> Result<(), String> {
None => None, None => None,
}; };
self.start_ssh_agent(&logger)?;
//make sure files exist //make sure files exist
match &file_server { match &file_server {
Some(file_server) => match &file_server.address { Some(file_server) => match &file_server.address {
@ -220,23 +233,29 @@ fn main() -> Result<(), String> {
files = files files = files
.iter() .iter()
.map(|file| { .map(|file| {
let output = ShellCmd::new("ssh") let output = ShellCommand::Ssh {
.arg(ssh_address) address: ssh_address.to_string(),
.arg(osf!("realpath -e ") + file_server.server_directory_path.join(file)) server_command: ServerCommand::Realpath {
.collect_full_output() path: file_server.server_directory_path.join(file),
},
}
.in_env(env!())
.output()
.into_result()
.map_err(|e| format!("Failed to canonicalize files: {e}"))?; .map_err(|e| format!("Failed to canonicalize files: {e}"))?;
if !output.status.success() { if !output.status.success {
Err(format!( Err(format!(
"Path doesn't match any files on file-server: {}", "Path doesn't match any files on file-server: {}",
file.to_string_lossy() file.to_string_lossy()
))?; ))?;
} }
let denoted_files = osstring_from_ssh_output(output.stdout) let denoted_files = output
.stdout
.split(b'\n') //split at line breaks .split(b'\n') //split at line breaks
.into_iter() .into_iter()
.filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines .filter(|file_name| !file_name.is_empty()) //needed since realpath sometimes gives us empty lines
.map(PathBuf::from) .map(PathBuf::from)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -250,9 +269,9 @@ fn main() -> Result<(), String> {
ServerAddress::Localhost => files ServerAddress::Localhost => files
.iter() .iter()
.map(|file| file_server.server_directory_path.join(file)) .map(|file| file_server.server_directory_path.join(file))
.try_for_each(check_local_file_exists)?, .try_for_each(Self::check_local_file_exists)?,
}, },
None => files.iter().try_for_each(check_local_file_exists)?, None => files.iter().try_for_each(Self::check_local_file_exists)?,
} }
let file_details = files let file_details = files
@ -294,26 +313,32 @@ fn main() -> Result<(), String> {
server, server,
actions: { actions: {
let present_file_names: Vec<OsString> = match &server.address { let present_file_names: Vec<OsString> = match &server.address {
ServerAddress::Ssh { ssh_address } => osstring_from_ssh_output( ServerAddress::Ssh { ssh_address } => ShellCommand::Ssh {
ShellCmd::new("ssh") address: ssh_address.to_string(),
.arg(ssh_address) server_command: ServerCommand::Ls {
.arg(osf!("ls ") + &working_directory) dir: working_directory.clone(),
.collect_output() },
}
.in_env(env!())
.output()
.and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| { .map_err(|e| {
format!( format!(
"Failed to query present files on server {}: {e}", "Failed to query present files on server {}: {e}",
server.get_name() server.get_name()
) )
})? })?
.stdout, .stdout
)
.split(b'\n') .split(b'\n')
.into_iter() .into_iter()
.map(OsString::from) .map(OsString::from)
.collect(), .collect(),
ServerAddress::Localhost => fs::read_dir(&working_directory) ServerAddress::Localhost => fs::read_dir(&working_directory)
.map_err(|e| format!("Failed to get files in working directory: {e}"))? .map_err(|e| format!("Failed to get files in working directory: {e}"))?
.map(|entry| entry.map_err(|e| format!("Failed to access directory entry: {e}"))) .map(|entry| {
entry.map_err(|e| format!("Failed to access directory entry: {e}"))
})
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
.into_iter() .into_iter()
.filter_map(|entry| { .filter_map(|entry| {
@ -341,7 +366,8 @@ fn main() -> Result<(), String> {
let file_name = file_name_info.to_full_file_name(); let file_name = file_name_info.to_full_file_name();
let add_action = FileAction::new(file, Action::Add).expect("path points to file"); let add_action =
FileAction::new(file, Action::Add).expect("path points to file");
if pure && present_file_names.iter().any(|file| *file == file_name) { if pure && present_file_names.iter().any(|file| *file == file_name) {
log!( log!(
@ -359,8 +385,7 @@ fn main() -> Result<(), String> {
if !present_file_names.iter().any(|file| *file == file_name) { if !present_file_names.iter().any(|file| *file == file_name) {
vec![add_action] //file doesn't exist yet vec![add_action] //file doesn't exist yet
} else { } else {
vec![FileAction::new(file, Action::Replace) vec![FileAction::new(file, Action::Replace).expect("path points to file")]
.expect("path points to file")]
} }
} }
OldVersionPolicy::Archive => present_file_names OldVersionPolicy::Archive => present_file_names
@ -392,7 +417,8 @@ fn main() -> Result<(), String> {
if *present_file == file_name { if *present_file == file_name {
FileAction::new(file, Action::Replace).expect("path points to file") FileAction::new(file, Action::Replace).expect("path points to file")
} else { } else {
FileAction::new(present_file, Action::Delete).expect("path points to file") FileAction::new(present_file, Action::Delete)
.expect("path points to file")
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -427,15 +453,10 @@ fn main() -> Result<(), String> {
} }
} }
if !no_confirm { if !no_confirm && !self.confirm("Continue?", true) {
match input!("Continue? [Y|n] ").to_lowercase().as_str() {
"n" | "no" => {
log!(logger, "Aborting..."); log!(logger, "Aborting...");
return Ok(()); return Ok(());
} }
_ => {}
}
}
for server_actions in actions { for server_actions in actions {
let server = server_actions.server; let server = server_actions.server;
@ -443,31 +464,36 @@ fn main() -> Result<(), String> {
for file_action in server_actions.actions { for file_action in server_actions.actions {
match file_action.kind { match file_action.kind {
Action::Add | Action::Replace => { Action::Add | Action::Replace => {
let scp_source = match &file_server { let source = match &file_server {
Some(file_server) => { Some(file_server) => ScpParam::from((
osf!(match &file_server.address { file_server,
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), file_server.server_directory_path.join(&file_action.file),
ServerAddress::Localhost => "".to_string(), )),
}) + file_server.server_directory_path.join(&file_action.file) None => ScpParam::from(file_action.file.as_path()),
}
None => osf!(&file_action.file),
}; };
let scp_target = osf!(match &server.address { let destination = ScpParam::from((server, &server_actions.working_directory));
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), ShellCommand::Scp {
ServerAddress::Localhost => "".to_string(), source,
}) + &server_actions.working_directory; destination,
ShellCmd::new("scp") }
.arg(scp_source) .in_env(env!())
.arg(scp_target) .run_logged(&logger)
.run(&logger) .and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("upload failure: {e}"))?; .map_err(|e| format!("upload failure: {e}"))?;
} }
Action::Delete => match &server.address { Action::Delete => match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh") ShellCommand::Ssh {
.arg(ssh_address) address: ssh_address.to_string(),
.arg(osf!("rm ") + server_actions.working_directory.join(&file_action.file)) server_command: ServerCommand::Rm {
.run(&logger) file: server_actions.working_directory.join(&file_action.file),
},
}
.in_env(env!())
.run_logged(&logger)
.and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to delete old version: {e}"))?; .map_err(|e| format!("failed to delete old version: {e}"))?;
} }
ServerAddress::Localhost => { ServerAddress::Localhost => {
@ -477,15 +503,17 @@ fn main() -> Result<(), String> {
}, },
Action::Rename { new_name } => match &server.address { Action::Rename { new_name } => match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh") ShellCommand::Ssh {
.arg(ssh_address) address: ssh_address.to_string(),
.arg( server_command: ServerCommand::Mv {
osf!("mv ") source: server_actions.working_directory.join(&file_action.file),
+ server_actions.working_directory.join(&file_action.file) destination: server_actions.working_directory.join(&new_name),
+ " " },
+ server_actions.working_directory.join(&new_name), }
) .in_env(env!())
.run(&logger) .run_logged(&logger)
.and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to rename: {e}"))?; .map_err(|e| format!("failed to rename: {e}"))?;
} }
ServerAddress::Localhost => { ServerAddress::Localhost => {
@ -501,26 +529,39 @@ fn main() -> Result<(), String> {
log!(logger, "Done!"); log!(logger, "Done!");
} }
Command::Command { command } => { Command::Command { command } => {
start_ssh_agent(&logger)?; self.start_ssh_agent(&logger)?;
require_non_empty_servers(&servers)?; Self::require_non_empty_servers(&servers)?;
for server in servers { for server in servers {
log!(logger, "Running command on '{}'...", server.get_name()); log!(logger, "Running command on '{}'...", server.get_name());
match &server.address { match &server.address {
ServerAddress::Ssh { ssh_address } => { ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh") ShellCommand::Ssh {
.arg(ssh_address) address: ssh_address.to_string(),
.arg(osf!("cd ") + server.server_directory_path + "; " + &command) server_command: ServerCommand::Execute {
.run(&logger) working_directory: server.server_directory_path.clone(),
command: OsString::from(&command),
},
}
.in_env(env!())
.run_logged(&logger)
.and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("{e}"))?; .map_err(|e| format!("{e}"))?;
} }
ServerAddress::Localhost => { ServerAddress::Localhost => {
let mut command_args = let command = shell_words::split(&command)
shell_words::split(&command).map_err(|e| format!("failed to parse command: {e}"))?; .map_err(|e| format!("failed to parse command: {e}"))?
ShellCmd::new(command_args.remove(0)) .into_iter()
.args(&command_args) .map(OsString::from)
.current_dir(&server.server_directory_path) .collect();
.run(&logger) ShellCommand::Execute {
.map_err(|e| format!("{e}"))?; working_directory: server.server_directory_path.clone(),
command,
}
.in_env(env!())
.run_logged(&logger)
.and_expect_success()
.into_result_with_error_logging(&logger)?;
} }
} }
} }
@ -536,8 +577,9 @@ fn main() -> Result<(), String> {
let download_directory = match download_directory { let download_directory = match download_directory {
Some(download_directory) => download_directory, Some(download_directory) => download_directory,
None => { None => {
let home_dir = let home_dir = self
get_home_directory().map_err(|e| format!("Can't determine download directory: {e}"))?; .get_home_directory()
.map_err(|e| format!("Missing download-directory: {e}"))?;
home_dir.join("Downloads") home_dir.join("Downloads")
} }
}; };
@ -567,14 +609,13 @@ fn main() -> Result<(), String> {
download_directory.to_string_lossy() download_directory.to_string_lossy()
); );
if !args.quiet { if !args.quiet
match input!("{duplication_notification}. Do you want to replace it? [N|y] ") && self.confirm(
.to_lowercase() format!("{duplication_notification}. Do you want to replace it?"),
.as_str() false,
)
{ {
"y" | "yes" => break 'duplicate_check, break 'duplicate_check;
_ => {}
}
} }
return Err(format!( return Err(format!(
@ -583,23 +624,24 @@ fn main() -> Result<(), String> {
} }
} }
require_non_empty_servers(&servers)?; Self::require_non_empty_servers(&servers)?;
start_ssh_agent(&logger)?; self.start_ssh_agent(&logger)?;
for server in servers { for server in servers {
log!(logger, "Getting file from {}...", server.get_name()); log!(logger, "Getting file from {}...", server.get_name());
let file_source = osf!(match &server.address { let source = ScpParam::from((&server, server.server_directory_path.join(&file)));
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), ShellCommand::Scp {
ServerAddress::Localhost => "".to_string(), source: source.clone(),
}) + server.server_directory_path.join(&file); destination: ScpParam::from(download_directory.as_path()),
ShellCmd::new("scp") }
.arg(&file_source) .in_env(env!())
.arg(&download_directory) .run_logged(&logger)
.run(&logger) .and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("download failure: {e}"))?; .map_err(|e| format!("download failure: {e}"))?;
//open file in editor //open file in editor
let mut editor_command_args = shell_words::split(&editor) let editor_command = shell_words::split(&editor)
.map_err(|e| format!("failed to parse editor command: {e}"))? .map_err(|e| format!("failed to parse editor command: {e}"))?
.into_iter() .into_iter()
.map(|part| { .map(|part| {
@ -607,17 +649,22 @@ fn main() -> Result<(), String> {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let command = editor_command_args.remove(0); ShellCommand::Editor(editor_command)
ShellCmd::new(command) .in_env(env!())
.args(editor_command_args) .run_logged(&logger)
.run(&logger) .and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to open file in editor: {e}"))?; .map_err(|e| format!("failed to open file in editor: {e}"))?;
//upload file again //upload file again
ShellCmd::new("scp") ShellCommand::Scp {
.arg(download_directory.join(file_name)) source: ScpParam::from(download_directory.join(file_name).as_path()),
.arg(&file_source) destination: source,
.run(&logger) }
.in_env(env!())
.run_logged(&logger)
.and_expect_success()
.into_result_with_error_logging(&logger)
.map_err(|e| format!("failed to re-upload file: {e}"))?; .map_err(|e| format!("failed to re-upload file: {e}"))?;
} }
@ -626,26 +673,12 @@ fn main() -> Result<(), String> {
} }
Ok(()) Ok(())
}
fn osstring_from_ssh_output(output: Vec<u8>) -> OsString {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(output)
} }
#[cfg(windows)] fn check_local_file_exists<P>(path: P) -> Result<(), String>
{ where
use std::os::windows::ffi::OsStringExt;
OsString::from_wide(output.iter().map(|&b| b as u16).collect())
}
}
fn check_local_file_exists<P>(path: P) -> Result<(), String>
where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let path = path.as_ref(); let path = path.as_ref();
if !path.is_file() { if !path.is_file() {
return Err(format!( return Err(format!(
@ -655,94 +688,112 @@ where
} }
Ok(()) Ok(())
}
#[allow(dead_code)]
fn check_file_exists_on_server<P, S, D>(
path: P,
ssh_address: S,
server_directory: D,
) -> Result<(), String>
where
P: AsRef<Path>,
S: AsRef<str>,
D: AsRef<Path>,
{
let full_path = server_directory.as_ref().join(path);
match &ShellCmd::new("ssh")
.arg(ssh_address.as_ref())
.arg(osf!("test -f ") + &full_path)
.collect_output()
{
Ok(_) => Ok(()), //file exists on file server
Err(CommandSpecificError {
error: ExecutionError::BadExitStatus(_), //test failed
..
}) => Err(format!(
"File '{}' doesn't exist on file-server",
full_path.to_string_lossy()
)),
Err(e) => Err(format!(
"Failed to check whether file exists on file-server: {e}"
)),
} }
}
fn get_home_directory() -> Result<PathBuf, String> { fn require_non_empty_servers<T>(servers: &[T]) -> Result<(), String> {
homedir::my_home() Self::require_non_empty(servers, "servers for this operation")
.map_err(|e| format!("Failed to determine home directory: {e}")) }
.and_then(|home_dir| home_dir.ok_or("Failed to find home directory".to_string()))
}
fn require_non_empty_servers<T>(servers: &[T]) -> Result<(), String> { fn require_non_empty<T>(slice: &[T], slice_name: &str) -> Result<(), String> {
require_non_empty(servers, "servers for this operation")
}
fn require_non_empty<T>(slice: &[T], slice_name: &str) -> Result<(), String> {
if slice.is_empty() { if slice.is_empty() {
Err(format!( Err(format!(
"You did not provide any {slice_name}. Please see --help" "You did not provide any {slice_name}. Please see --help"
))? ))?
} }
Ok(()) Ok(())
}
fn start_ssh_agent(logger: &Logger) -> Result<(), String> {
//start the ssh agent
let agent_output = ShellCmd::new("ssh-agent")
.arg("-s")
.collect_output()
.map_err(|e| format!("failed to start ssh agent: {e}"))?;
let agent_stdout = String::from_utf8_lossy(&agent_output.stdout);
if !agent_output.status.success() {
return Err("failed to start ssh agent; maybe try to run ssh-agent manually?".to_string());
} }
fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> {
let env = &mut self.environment;
//start the ssh agent
let agent_output = ShellCommand::SshAgent
.in_env(env)
.output()
.and_expect_success()
.into_result_with_error_logging(logger)
.map_err(|e| format!("Failed to start ssh agent: {e}"))?;
let agent_stdout = &agent_output
.stdout
.into_string()
.map_err(|_| "ssh-agent returned invalid utf-8 - how did this even happen?")?;
//set the env vars from the agent //set the env vars from the agent
static ENV_VAR_REGEX: Lazy<Regex> = lazy_regex!("(.+?)=(.+?);"); static ENV_VAR_REGEX: Lazy<Regex> = lazy_regex!("(.+?)=(.+?);");
for capture in ENV_VAR_REGEX.captures_iter(&agent_stdout) { for capture in ENV_VAR_REGEX.captures_iter(agent_stdout) {
let (_, [env_var, value]) = capture.extract(); let (_, [env_var, value]) = capture.extract();
env::set_var(env_var, value); env.set_var(env_var, value);
} }
//add the ssh key //add the ssh key
ShellCmd::new("ssh-add") ShellCommand::ShhAdd
.run(logger) .in_env(env)
.run_logged(logger)
.and_expect_success()
.into_result_with_error_logging(logger)
.map_err(|e| format!("failed to add ssh-key: {e}"))?; .map_err(|e| format!("failed to add ssh-key: {e}"))?;
Ok(()) Ok(())
} }
fn parse_server_configuration_from_env() -> Result<Vec<Server>, String> { fn parse_server_configuration_from_env(&self) -> Result<Vec<Server>, String> {
env::var(SERVERS_ENV_VAR) self
.environment
.var(SERVERS_ENV_VAR)
.map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR)) .map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR))
.and_then(|value| parse_server_configuration(&value)) .and_then(|value| parse_server_configuration(&value, || self.get_home_directory()))
}
fn get_home_directory(&self) -> Result<PathBuf, String> {
self
.environment
.get_home_directory()
.ok_or("Failed to find your home directory".to_string())
}
fn confirm<S>(&mut self, prompt: S, default_value: bool) -> bool
where
S: ToString,
{
loop {
print!(
"{}[{}]",
prompt.to_string(),
if default_value { "Y|n" } else { "y|N" }
);
io::stdout().flush().expect("failed to flush stdout");
let line = self
.environment
.read_line()
.expect("Failed to read console input");
match line.to_lowercase().as_str() {
"" => return default_value,
"y" | "yes" => return true,
"n" | "no" => return false,
_ => println!("Invalid input, please choose one of the provided options"),
}
}
}
} }
fn parse_server_configuration(config_str: &str) -> Result<Vec<Server>, String> { fn main() -> Result<(), String> {
Application::<Prod>::default().run()
}
fn parse_server_configuration<F>(
config_str: &str,
get_home_directory: F,
) -> Result<Vec<Server>, String>
where
F: Fn() -> Result<PathBuf, String>,
{
config_str config_str
.split(',') .split(',')
.map(|server_entry| { .map(|server_entry| {
Server::from_str(server_entry, RelativeLocalPathAnker::Home) Server::from_str(
server_entry,
RelativeLocalPathAnker::Home,
&get_home_directory,
)
.map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) .map_err(|e| format!("Invalid server entry '{server_entry}': {e}"))
}) })
.collect() .collect()
@ -757,8 +808,15 @@ mod test {
#[test] #[test]
fn test_parse_server_configuration() { fn test_parse_server_configuration() {
let servers = //setup directory structure for test
parse_server_configuration("foo:bar,.:fizz/buzz").expect("valid server configuration"); let home_dir = PathBuf::from("target/test");
const LOCAL_SERVER_DIR: &str = "fizz/buzz";
fs::create_dir_all(home_dir.join(LOCAL_SERVER_DIR)).expect("failed to create server directory");
let servers = parse_server_configuration(&format!("foo:bar,.:{LOCAL_SERVER_DIR}"), || {
Ok(home_dir.clone())
})
.expect("valid server configuration");
assert_eq!( assert_eq!(
vec![ vec![
Server { Server {
@ -769,7 +827,10 @@ mod test {
}, },
Server { Server {
address: ServerAddress::Localhost, address: ServerAddress::Localhost,
server_directory_path: PathBuf::from("fizz/buzz"), server_directory_path: home_dir
.join(LOCAL_SERVER_DIR)
.canonicalize()
.expect("home dir exists"),
} }
], ],
servers servers

View File

@ -1,4 +1,3 @@
use crate::get_home_directory;
use std::cell::LazyCell; use std::cell::LazyCell;
use std::error::Error; use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
@ -6,7 +5,6 @@ use std::fs;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::ops::Deref; use std::ops::Deref;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ServerReference { pub enum ServerReference {
@ -15,6 +13,19 @@ pub enum ServerReference {
} }
impl ServerReference { impl ServerReference {
pub fn from_str<F>(s: &str, get_home_directory: F) -> Result<Self, ServerReferenceParseError>
where
F: FnOnce() -> Result<PathBuf, String>,
{
Server::from_str(
s,
RelativeLocalPathAnker::CurrentDirectory,
get_home_directory,
)
.map(Self::Resolved)
.or_else(|_| Ok(Self::Identifier(s.to_string())))
}
pub fn get_identifier(&self) -> &str { pub fn get_identifier(&self) -> &str {
match self { match self {
ServerReference::Resolved(server) => server.address.identifier(), ServerReference::Resolved(server) => server.address.identifier(),
@ -68,16 +79,6 @@ impl ServerReference {
} }
} }
impl FromStr for ServerReference {
type Err = ServerReferenceParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Server::from_str(s, RelativeLocalPathAnker::CurrentDirectory)
.map(Self::Resolved)
.or_else(|_| Ok(Self::Identifier(s.to_string())))
}
}
impl PartialEq for ServerReference { impl PartialEq for ServerReference {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.get_identifier() == other.get_identifier() self.get_identifier() == other.get_identifier()
@ -126,10 +127,14 @@ impl Server {
} }
} }
pub fn from_str( pub fn from_str<F>(
s: &str, s: &str,
relative_local_path_anker: RelativeLocalPathAnker, relative_local_path_anker: RelativeLocalPathAnker,
) -> Result<Self, ServerParseError> { get_home_directory: F,
) -> Result<Self, ServerParseError>
where
F: FnOnce() -> Result<PathBuf, String>,
{
s.split_once(':') s.split_once(':')
.ok_or(ServerParseError::MissingServerDirectory) .ok_or(ServerParseError::MissingServerDirectory)
.and_then(|(identifier, server_directory)| { .and_then(|(identifier, server_directory)| {
@ -245,13 +250,12 @@ impl Error for ServerParseError {}
mod test_server_reference { mod test_server_reference {
use crate::server::{Server, ServerAddress, ServerReference}; use crate::server::{Server, ServerAddress, ServerReference};
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
#[test] #[test]
fn test_from_str() { fn test_from_str() {
assert_eq!( assert_eq!(
ServerReference::Identifier("foo".to_string()), ServerReference::Identifier("foo".to_string()),
ServerReference::from_str("foo").unwrap() ServerReference::from_str("foo", || panic!("shouldn't be called")).unwrap()
); );
assert_eq!( assert_eq!(
ServerReference::Resolved(Server { ServerReference::Resolved(Server {
@ -260,7 +264,7 @@ mod test_server_reference {
}, },
server_directory_path: PathBuf::from("server/creative2") server_directory_path: PathBuf::from("server/creative2")
}), }),
ServerReference::from_str("crea:server/creative2").unwrap() ServerReference::from_str("crea:server/creative2", || panic!("shouldn't be called")).unwrap()
); );
} }
} }

562
src/shell_interface.rs Normal file
View File

@ -0,0 +1,562 @@
use crate::logger::{LogLevel, Logger};
use crate::server::{Server, ServerAddress};
use crate::{log, osf};
use std::error::Error;
use std::ffi::OsString;
use std::fmt::{Debug, Display, Formatter};
use std::iter::once;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::{io, process};
#[derive(Debug)]
pub struct EnvCommand<'a, E> {
command: ShellCommand,
environment: &'a mut E,
}
impl<E> EnvCommand<'_, E>
where
E: ShellInterface,
{
pub fn run(self) -> CommandResult<ExitStatus, StartError> {
self.environment.run_command(self.command)
}
pub fn output(self) -> CommandResult<CommandOutput, StartError> {
self.environment.collect_command_output(self.command)
}
pub fn run_logged(self, logger: &Logger) -> CommandResult<LoggedRunOutput, StartError>
where
Self: Sized,
{
match logger.level {
LogLevel::Debug | LogLevel::Info => {
let res = self.run();
CommandResult {
result: res.result.map(LoggedRunOutput::from),
command: res.command,
}
}
LogLevel::Error => {
let res = self.output();
CommandResult {
result: res.result.map(LoggedRunOutput::from),
command: res.command,
}
}
}
}
}
#[derive(Debug, Clone)]
pub enum ShellCommand {
Ssh {
address: String,
server_command: ServerCommand,
},
Scp {
source: ScpParam,
destination: ScpParam,
},
SshAgent,
ShhAdd,
Editor(Vec<OsString>),
Execute {
working_directory: PathBuf,
command: Vec<OsString>,
},
}
impl ShellCommand {
pub fn in_env<E>(self, environment: &mut E) -> EnvCommand<E> {
EnvCommand {
command: self,
environment,
}
}
}
#[derive(Debug, Clone)]
pub enum ServerCommand {
Realpath {
path: PathBuf,
},
Ls {
dir: PathBuf,
},
Rm {
file: PathBuf,
},
Mv {
source: PathBuf,
destination: PathBuf,
},
Execute {
working_directory: PathBuf,
command: OsString,
},
}
#[derive(Debug, Clone)]
pub struct ScpParam {
pub server: Option<String>,
pub path: PathBuf,
}
impl<P> From<(&Server, P)> for ScpParam
where
P: AsRef<Path>,
{
fn from((server, path): (&Server, P)) -> Self {
Self {
server: match &server.address {
ServerAddress::Ssh { ssh_address } => Some(ssh_address.into()),
ServerAddress::Localhost => None,
},
path: PathBuf::from(path.as_ref()),
}
}
}
impl From<&Path> for ScpParam {
fn from(value: &Path) -> Self {
Self {
server: None,
path: PathBuf::from(value),
}
}
}
impl From<&ScpParam> for OsString {
fn from(value: &ScpParam) -> Self {
let mut builder = osf!();
if let Some(server) = &value.server {
builder += format!("{server}:");
}
builder += &value.path;
builder.build()
}
}
impl Display for ShellCommand {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
command_to_string(&build_command_from_shell_command(self))
)
}
}
fn command_to_string(command: &Command) -> String {
once(command.get_program().to_string_lossy().to_string())
.chain(command.get_args().map(|arg| {
let arg_str = arg.to_string_lossy();
if arg_str.contains(' ') {
format!("\"{arg_str}\"")
} else {
arg_str.to_string()
}
}))
.collect::<Vec<_>>()
.join(" ")
}
macro_rules! cmd {
($programm: expr $(, $arg:expr )*)=> {{
#[allow(unused_mut)]
let mut cmd = Command::new($programm);
$( cmd.arg($arg); )*
cmd
}};
}
pub fn build_command_from_shell_command(shell_command: &ShellCommand) -> Command {
match shell_command {
ShellCommand::Ssh {
address,
server_command,
} => cmd!(
"ssh",
address,
match server_command {
ServerCommand::Realpath { path } => osf!("realpath -e ") + path,
ServerCommand::Ls { dir } => osf!("ls ") + dir,
ServerCommand::Rm { file } => osf!("rm ") + file,
ServerCommand::Mv {
source,
destination,
} => osf!("mv ") + source + " " + destination,
ServerCommand::Execute {
working_directory,
command,
} => osf!("cd ") + working_directory + "; " + command,
}
),
ShellCommand::Scp {
source,
destination,
} => cmd!("scp", OsString::from(source), OsString::from(destination)),
ShellCommand::SshAgent => cmd!("ssh-agent", "-s"),
ShellCommand::ShhAdd => cmd!("ssh-add"),
ShellCommand::Editor(args) => {
let mut args = args.clone();
let mut cmd = cmd!(args.remove(0));
cmd.args(args);
cmd
}
ShellCommand::Execute {
working_directory,
command,
} => {
let mut args = command.clone();
let mut cmd = cmd!(args.remove(0));
cmd.args(args).current_dir(working_directory);
cmd
}
}
}
pub trait ShellInterface {
fn run_command(&mut self, command: ShellCommand) -> CommandResult<ExitStatus, StartError>;
fn collect_command_output(
&mut self,
command: ShellCommand,
) -> CommandResult<CommandOutput, StartError>;
}
pub trait MaybeCast<T> {
fn maybe_cast(&self) -> Option<&T>;
}
impl<T> MaybeCast<T> for T {
fn maybe_cast(&self) -> Option<&T> {
Some(self)
}
}
#[derive(Debug)]
pub enum LoggedRunOutput {
ExitStatus(ExitStatus),
CommandOutput(CommandOutput),
}
impl From<ExitStatus> for LoggedRunOutput {
fn from(value: ExitStatus) -> Self {
Self::ExitStatus(value)
}
}
impl From<CommandOutput> for LoggedRunOutput {
fn from(value: CommandOutput) -> Self {
Self::CommandOutput(value)
}
}
impl AsRef<ExitStatus> for LoggedRunOutput {
fn as_ref(&self) -> &ExitStatus {
match self {
LoggedRunOutput::ExitStatus(status) => status,
LoggedRunOutput::CommandOutput(output) => output.as_ref(),
}
}
}
impl MaybeCast<CommandOutput> for LoggedRunOutput {
fn maybe_cast(&self) -> Option<&CommandOutput> {
match self {
LoggedRunOutput::ExitStatus(_) => None,
LoggedRunOutput::CommandOutput(output) => Some(output),
}
}
}
#[derive(Debug)]
pub struct CommandResult<T, E> {
pub command: ShellCommand,
pub result: Result<T, E>,
}
impl<T, E> CommandResult<T, E> {
pub fn into_result(self) -> Result<T, CommandError<E>> {
self.result.map_err(|error| CommandError {
command: self.command,
error,
})
}
}
impl<T> CommandResult<T, StartError> {
pub fn and_expect_success(self) -> CommandResult<T, ExecutionError<T>>
where
T: AsRef<ExitStatus>,
{
CommandResult {
result: self.result.map_err(ExecutionError::from).and_then(|t| {
if t.as_ref().success {
Ok(t)
} else {
Err(ExecutionError::BadExitStatus(t))
}
}),
command: self.command,
}
}
}
impl<T> CommandResult<T, ExecutionError<T>> {
pub fn into_result_with_error_logging(
self,
logger: &Logger,
) -> Result<T, CommandError<ExecutionError<T>>>
where
T: MaybeCast<CommandOutput>,
{
self.result.map_err(|error| {
if let ExecutionError::BadExitStatus(t) = &error {
if let Some(output) = t.maybe_cast() {
log!(logger, error, "{}", output.stdout.to_string_lossy());
log!(logger, error, "{}", output.stderr.to_string_lossy());
}
}
CommandError {
command: self.command,
error,
}
})
}
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub stdout: OsString,
pub stderr: OsString,
pub status: ExitStatus,
}
impl From<Output> for CommandOutput {
fn from(value: Output) -> Self {
Self {
stdout: os_string_from_ssh_output(value.stdout),
stderr: os_string_from_ssh_output(value.stderr),
status: value.status.into(),
}
}
}
pub fn os_string_from_ssh_output(output: Vec<u8>) -> OsString {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(output)
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
OsString::from_wide(output.iter().map(|&b| b as u16).collect())
}
}
impl AsRef<ExitStatus> for CommandOutput {
fn as_ref(&self) -> &ExitStatus {
&self.status
}
}
#[derive(Debug, Clone)]
pub struct ExitStatus {
pub success: bool,
pub string_form: String,
#[allow(dead_code)]
pub code: Option<i32>,
}
impl From<process::ExitStatus> for ExitStatus {
fn from(value: process::ExitStatus) -> Self {
Self {
success: value.success(),
string_form: value.to_string(),
code: value.code(),
}
}
}
impl AsRef<ExitStatus> for ExitStatus {
fn as_ref(&self) -> &ExitStatus {
self
}
}
impl Display for ExitStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.string_form, f)
}
}
#[derive(Debug)]
pub struct CommandError<E> {
pub command: ShellCommand,
pub error: E,
}
impl<E> From<CommandError<E>> for String
where
E: Display,
{
fn from(value: CommandError<E>) -> Self {
value.to_string()
}
}
impl<E> Display for CommandError<E>
where
E: Display,
{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Error while running command '{}': {}",
self.command, self.error
)
}
}
impl<E> Error for CommandError<E> where E: Error {}
#[derive(Debug)]
pub struct StartError(io::Error);
impl From<io::Error> for StartError {
fn from(value: io::Error) -> Self {
StartError(value)
}
}
impl Display for StartError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed to run command: {}", self.0)
}
}
impl Error for StartError {}
#[derive(Debug)]
pub enum ExecutionError<T> {
StartError(StartError),
BadExitStatus(T),
}
impl<T> From<StartError> for ExecutionError<T> {
fn from(value: StartError) -> Self {
Self::StartError(value)
}
}
impl<T> Display for ExecutionError<T>
where
T: AsRef<ExitStatus>,
{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ExecutionError::StartError(e) => Display::fmt(e, f),
ExecutionError::BadExitStatus(status) => {
write!(f, "execution failed with {}", status.as_ref())
}
}
}
}
impl<T> Error for ExecutionError<T> where T: AsRef<ExitStatus> + Debug {}
#[cfg(test)]
mod test_commands {
use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand};
use std::path::PathBuf;
#[test]
fn test_to_string() {
assert_eq!(
ShellCommand::Ssh {
address: "crea".to_string(),
server_command: ServerCommand::Realpath {
path: PathBuf::from("plugins/*.jar")
}
}
.to_string(),
r#"ssh crea "realpath -e plugins/*.jar""#
);
assert_eq!(
ShellCommand::Ssh {
address: "crea".to_string(),
server_command: ServerCommand::Ls {
dir: PathBuf::from("creative/plugins")
}
}
.to_string(),
r#"ssh crea "ls creative/plugins""#
);
assert_eq!(
ShellCommand::Ssh {
address: "crea".to_string(),
server_command: ServerCommand::Rm {
file: PathBuf::from("foo.txt")
},
}
.to_string(),
r#"ssh crea "rm foo.txt""#
);
assert_eq!(
ShellCommand::Ssh {
address: "crea".to_string(),
server_command: ServerCommand::Mv {
source: PathBuf::from("foo"),
destination: PathBuf::from("bar")
}
}
.to_string(),
r#"ssh crea "mv foo bar""#
);
assert_eq!(
ShellCommand::Ssh {
address: "crea".to_string(),
server_command: ServerCommand::Execute {
working_directory: PathBuf::from(".."),
command: "sudo rm -rf *".into(),
}
}
.to_string(),
r#"ssh crea "cd ..; sudo rm -rf *""#
);
assert_eq!(
ShellCommand::Scp {
source: ScpParam {
server: None,
path: PathBuf::from("target/mssh")
},
destination: ScpParam {
server: Some("crea".into()),
path: PathBuf::from("/usr/bin")
},
}
.to_string(),
r#"scp target/mssh crea:/usr/bin"#
);
assert_eq!(ShellCommand::SshAgent.to_string(), r#"ssh-agent -s"#);
assert_eq!(ShellCommand::ShhAdd.to_string(), r#"ssh-add"#);
assert_eq!(
ShellCommand::Editor(vec!["kate".into(), "-b".into(), "test.txt".into()]).to_string(),
r#"kate -b test.txt"#
);
assert_eq!(
ShellCommand::Execute {
working_directory: PathBuf::from("/home/me/server"),
command: vec!["java".into(), "-jar".into(), "paper.jar".into()]
}
.to_string(),
r#"java -jar paper.jar"#
);
}
}

View File

@ -1 +0,0 @@
exit(1)