multi-ssh/src/main.rs

814 lines
29 KiB
Rust
Raw Normal View History

2024-12-13 00:03:25 +01:00
mod action;
mod command;
mod environment;
2024-12-12 19:45:45 +01:00
mod file;
mod logger;
mod os_str_extension;
mod os_string_builder;
2024-12-13 00:03:25 +01:00
mod server;
mod shell_interface;
2024-12-13 00:03:25 +01:00
use crate::action::{Action, FileAction, ServerActions};
use crate::command::LogRunnable;
use crate::environment::{Environment, Prod};
2024-12-13 00:03:25 +01:00
use crate::file::{FileMatcher, FileNameInfo};
use crate::logger::{LogLevel, Logger};
use crate::os_str_extension::OsStrExtension;
2024-12-15 14:34:19 +01:00
use crate::os_string_builder::ReplaceWithOsStr;
use crate::server::{RelativeLocalPathAnker, ServerAddress};
2024-12-11 14:13:32 +01:00
use clap::{Parser, Subcommand, ValueEnum};
use lazy_regex::{lazy_regex, Lazy, Regex};
use server::{Server, ServerReference};
2024-12-11 21:27:13 +01:00
use std::cell::LazyCell;
use std::ffi::OsString;
use std::hash::Hash;
2024-12-13 00:03:25 +01:00
use std::io::Write;
use std::iter::once;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{env, fs, io};
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
const SERVERS_ENV_VAR: &str = "MSSH_SERVERS";
2024-12-15 14:34:19 +01:00
const EDITOR_ENV_VAR: &str = "MSSH_EDITOR";
const FILE_PLACEHOLDER: &str = "<file>";
type ShellCmd = std::process::Command;
2024-12-11 10:42:44 +01:00
2024-12-11 14:13:32 +01:00
/// Uploads a file or executes a command on multiple configured servers
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// Servers must either be configured via environment variable or denote their server directory with
/// a double colon: crea:home/crea.
2024-12-11 10:42:44 +01:00
///
2024-12-11 14:13:32 +01:00
/// --- Configuration via environment variable ---
2024-12-11 10:42:44 +01:00
///
2024-12-15 14:34:19 +01:00
/// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers.
2024-12-11 10:42:44 +01:00
#[derive(Parser, Debug)]
#[command(version, about, long_about)]
pub struct Args {
2024-12-11 14:13:32 +01:00
/// The action to perform
#[command(subcommand)]
command: Command,
/// The ssh names and optionally home directories of the servers to perform the action on
//TODO from_str always uses Prod environment -> handwrite that section
2025-02-03 16:08:36 +01:00
#[arg(num_args = 0.., value_parser = ServerReference::from_str)]
servers: Vec<ServerReference>,
/// How verbose logging output should be
#[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])]
log_level: LogLevel,
/// Only log errors
#[arg(short, long, default_value = "false", conflicts_with_all = ["info"])]
quiet: bool,
/// Log additional debugging info
#[arg(short = 'v', long, default_value = "false")]
info: bool,
2024-12-11 14:13:32 +01:00
}
#[derive(Subcommand, Debug)]
enum Command {
/// Upload a file to the servers
#[command(visible_short_flag_alias = 'u')]
Upload {
2025-02-02 22:04:59 +01:00
/// The files to upload
files: Vec<PathBuf>,
/// The ssh server to get the file from.
///
/// 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.
#[arg(short = 'S', long)]
file_server: Option<ServerReference>,
2024-12-11 14:13:32 +01:00
/// How to handle older versions of the file
2024-12-13 00:46:35 +01:00
#[arg(short = 'a', long, default_value = "delete", default_missing_value = "archive", num_args = 0..=1)]
2024-12-11 14:13:32 +01:00
old_version_policy: OldVersionPolicy,
2024-12-12 12:07:07 +01:00
/// The directory where to upload to, relative to the server directory
2025-02-03 15:36:16 +01:00
#[arg(short = 'd', long, default_value = "plugins")]
2024-12-12 09:50:32 +01:00
upload_directory: PathBuf,
/// Skip the confirmation dialog
2024-12-13 00:46:35 +01:00
#[arg(long, default_value = "false")]
no_confirm: bool,
2024-12-12 12:07:07 +01:00
/// The prefix of the name of older versions of the file, which should be replaced or deleted
#[arg(short, long)]
file_name: Option<String>,
2025-02-03 15:36:16 +01:00
/// Only upload files which are not present yet on the target server
#[arg(short, long, default_value = "false")]
pure: bool,
2025-02-03 16:08:36 +01:00
/// The prefixes of the names of files not to upload
#[arg(short, long, num_args = 0..)]
exclude: Vec<String>,
2024-12-11 14:13:32 +01:00
},
/// Execute a command on the servers
#[command(visible_short_flag_alias = 'c')]
2024-12-15 14:34:19 +01:00
#[allow(clippy::enum_variant_names)]
2024-12-11 14:13:32 +01:00
Command {
/// The command to execute
command: String,
},
2024-12-15 14:34:19 +01:00
/// Open a file in your local editor and upload it afterward.
///
/// The editor will be opened for every server in the order provided by you.
/// The command to start the editor can be configured via environment variable `MSSH_EDITOR`:
///
/// `export MSSH_EDITOR="nano <file>"`
///
/// File is a placeholder that will be replaced with the actual name of the file.
#[command(visible_short_flag_alias = 'e')]
Editor {
/// A path to the file to edit, relative to the server directory
file: PathBuf,
/// The command to start the editor. Supports the placeholder `<file>`, e.g. "nano <file>".
///
/// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`.
#[arg(short, long)]
editor: Option<String>,
/// The directory where to save the file to.
///
/// Default directory is `~/Downloads`
#[arg(short = 'd', long)]
download_directory: Option<PathBuf>,
2024-12-15 14:34:19 +01:00
/// Override existing files in the working directory
#[arg(short = 'f', long = "override", default_value = "false")]
override_existing: bool,
},
2024-12-11 14:13:32 +01:00
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum)]
enum OldVersionPolicy {
/// Ignore the existence of older versions
Ignore,
/// Rename older versions: foo.jar -> foo.jarr
Archive,
/// Delete older versions
#[default]
Delete,
2024-12-11 10:42:44 +01:00
}
#[derive(Debug, Default)]
pub struct Application<E> {
pub environment: E,
}
2024-12-11 10:42:44 +01:00
impl<E> Application<E>
where
E: Environment,
{
2025-02-04 23:09:02 +01:00
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)
}
2025-02-04 23:09:02 +01:00
pub fn run_with_args(&mut self, args: Args) -> Result<(), String> {
let env = &mut self.environment;
let logger = Logger {
//all the below options are conflicting with each other so an if else is fine
level: if args.quiet {
LogLevel::Error
} else if args.info {
LogLevel::Debug
} else {
args.log_level
},
};
let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env());
let servers = args
.servers
.iter()
.map(|server_reference| {
let server_identifier = server_reference.get_identifier();
server_reference
.clone()
.try_resolve_lazy(&mut configured_servers)
.map_err(|msg| format!("Can't resolve server directory for '{server_identifier}': {msg}"))
.and_then(|opt_server| {
opt_server.ok_or(format!(
"no server directory has been configured for server '{server_identifier}'"
))
})
})
.collect::<Result<Vec<_>, _>>()?;
match args.command {
Command::Upload {
mut files,
file_server,
old_version_policy,
mut upload_directory,
no_confirm,
file_name,
pure,
exclude,
} => {
Self::require_non_empty_servers(&servers)?;
Self::require_non_empty(&files, "files to upload")?;
//resolve file server
let file_server = match file_server {
Some(server_reference) => {
let file_server_identifier = server_reference.get_identifier().to_string();
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}"))?
.ok_or_else(|| format!("no server directory has been configured for file-server '{file_server_identifier}'"))?;
Some(server)
2025-02-02 22:04:59 +01:00
}
None => None,
};
2025-02-04 23:09:02 +01:00
self.start_ssh_agent(&logger)?;
//make sure files exist
match &file_server {
Some(file_server) => match &file_server.address {
ServerAddress::Ssh { ssh_address } => {
//canonicalize remote files -> also makes sure they exist
files = files
.iter()
.map(|file| {
let output = ShellCmd::new("ssh")
.arg(ssh_address)
.arg(osf!("realpath -e ") + file_server.server_directory_path.join(file))
.collect_full_output()
.map_err(|e| format!("Failed to canonicalize files: {e}"))?;
if !output.status.success() {
Err(format!(
"Path doesn't match any files on file-server: {}",
file.to_string_lossy()
))?;
}
let denoted_files = osstring_from_ssh_output(output.stdout)
.split(b'\n') //split at line breaks
.into_iter()
.filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines
.map(PathBuf::from)
.collect::<Vec<_>>();
2024-12-13 00:03:25 +01:00
Ok(denoted_files)
})
.collect::<Result<Vec<_>, String>>()?
.into_iter()
.flatten()
.collect();
}
ServerAddress::Localhost => files
.iter()
.map(|file| file_server.server_directory_path.join(file))
.try_for_each(Self::check_local_file_exists)?,
},
None => files.iter().try_for_each(Self::check_local_file_exists)?,
}
let file_details = files
.into_iter()
.map(|file| {
FileNameInfo::try_from(file.clone())
.map(|info| (file.clone(), info))
.map_err(|e| format!("Bad file '{}': {e}", file.to_string_lossy()))
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter(|(_, info)| {
!exclude
.iter()
.any(|exclude| info.to_full_file_name().starts_with(exclude))
})
.collect::<Vec<_>>();
log!(logger, debug, "Files to upload: ");
for (file, _) in &file_details {
log!(logger, debug, "- {}", file.to_string_lossy());
}
//create overview of what has to be done on each server
let actions = servers
.iter()
.map(|server| {
//on local server canonicalize upload_directory
if let ServerAddress::Localhost = &server.address {
//create upload directory if it doesn't exist
fs::create_dir_all(&upload_directory)
.map_err(|e| format!("Failed to create upload-directory: {e}"))?;
upload_directory = fs::canonicalize(&upload_directory)
.map_err(|e| format!("failed to resolve upload-directory: {e}"))?;
}
let working_directory = server.server_directory_path.join(&upload_directory);
Ok(ServerActions {
server,
actions: {
let present_file_names: Vec<OsString> = match &server.address {
ServerAddress::Ssh { ssh_address } => osstring_from_ssh_output(
ShellCmd::new("ssh")
.arg(ssh_address)
.arg(osf!("ls ") + &working_directory)
.collect_output()
.map_err(|e| {
format!(
"Failed to query present files on server {}: {e}",
server.get_name()
)
})?
.stdout,
)
.split(b'\n')
.into_iter()
.map(OsString::from)
.collect(),
ServerAddress::Localhost => fs::read_dir(&working_directory)
.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}"))
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|entry| {
if entry.path().is_file() {
Some(entry.file_name())
} else {
None
}
})
.collect(),
};
file_details
.iter()
.flat_map(|(file, file_name_info)| {
let mut file_matcher = FileMatcher::from(
file_name
.as_ref()
.map(OsString::from)
.unwrap_or(file_name_info.name.to_os_string()),
);
if let Some(extension) = file_name_info.extension.as_ref() {
file_matcher = file_matcher.and_extension(extension);
}
2024-12-13 00:03:25 +01:00
let file_name = file_name_info.to_full_file_name();
2024-12-13 00:03:25 +01:00
let add_action =
FileAction::new(file, Action::Add).expect("path points to file");
2024-12-13 00:46:35 +01:00
if pure && present_file_names.iter().any(|file| *file == file_name) {
log!(
logger,
debug,
"file is already present on {}: {}",
server.get_name(),
file_name.to_string_lossy()
);
return vec![]; //ignore that file, since it is already present
}
2025-02-03 15:36:16 +01:00
match old_version_policy {
OldVersionPolicy::Ignore => {
if !present_file_names.iter().any(|file| *file == file_name) {
vec![add_action] //file doesn't exist yet
} else {
vec![FileAction::new(file, Action::Replace).expect("path points to file")]
}
2025-02-03 15:36:16 +01:00
}
OldVersionPolicy::Archive => present_file_names
.iter()
2025-02-03 18:01:54 +01:00
.filter(|file| file_matcher.matches(file))
.map(|file| {
FileAction::new(
file,
Action::rename(
osf!(file)
+ file
.to_string_lossy()
.chars()
.last()
.unwrap_or('1')
.to_string(),
),
)
.expect("path points to file")
2025-02-02 22:04:59 +01:00
})
.chain(once(add_action))
.collect(),
OldVersionPolicy::Delete => {
let mut actions = present_file_names
.iter()
.filter(|file| file_matcher.matches(file))
.map(|present_file| {
//special case -> file has the same name as current file, then we just need to replace it
if *present_file == file_name {
FileAction::new(file, Action::Replace).expect("path points to file")
} else {
FileAction::new(present_file, Action::Delete)
.expect("path points to file")
}
})
.collect::<Vec<_>>();
if !actions.iter().any(|action| action.kind == Action::Replace) {
actions.push(add_action);
}
actions
}
2025-02-02 22:04:59 +01:00
}
})
.collect()
},
working_directory,
})
2024-12-13 00:03:25 +01:00
})
.collect::<Result<Vec<_>, String>>()?
.into_iter()
.filter(|server_actions| !server_actions.actions.is_empty())
.collect::<Vec<_>>();
2024-12-13 00:03:25 +01:00
if actions.is_empty() {
log!(logger, "Nothing to be done, everything is up to date");
return Ok(());
}
2024-12-13 00:03:25 +01:00
log!(logger, "The following actions will be performed: ");
for server_actions in &actions {
log!(logger, "{server_actions}");
log!(logger, debug, "Detailed file actions: ");
for file_action in &server_actions.actions {
log!(logger, debug, "{file_action:?}");
2024-12-13 00:03:25 +01:00
}
}
2025-02-04 23:09:02 +01:00
if !no_confirm && !self.confirm("Continue?", true) {
log!(logger, "Aborting...");
return Ok(());
}
for server_actions in actions {
let server = server_actions.server;
log!(logger, "Performing actions on {}...", server.get_name());
for file_action in server_actions.actions {
match file_action.kind {
Action::Add | Action::Replace => {
let scp_source = match &file_server {
Some(file_server) => {
osf!(match &file_server.address {
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"),
ServerAddress::Localhost => "".to_string(),
}) + file_server.server_directory_path.join(&file_action.file)
}
None => osf!(&file_action.file),
};
let scp_target = osf!(match &server.address {
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"),
ServerAddress::Localhost => "".to_string(),
}) + &server_actions.working_directory;
ShellCmd::new("scp")
.arg(scp_source)
.arg(scp_target)
2025-02-02 02:23:25 +01:00
.run(&logger)
.map_err(|e| format!("upload failure: {e}"))?;
2025-02-02 02:23:25 +01:00
}
Action::Delete => match &server.address {
ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh")
.arg(ssh_address)
.arg(osf!("rm ") + server_actions.working_directory.join(&file_action.file))
.run(&logger)
.map_err(|e| format!("failed to delete old version: {e}"))?;
}
ServerAddress::Localhost => {
fs::remove_file(server_actions.working_directory.join(&file_action.file))
.map_err(|e| format!("failed to delete old version: {e}"))?;
}
},
Action::Rename { new_name } => match &server.address {
ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh")
.arg(ssh_address)
.arg(
osf!("mv ")
+ server_actions.working_directory.join(&file_action.file)
+ " "
+ server_actions.working_directory.join(&new_name),
)
.run(&logger)
.map_err(|e| format!("failed to rename: {e}"))?;
}
ServerAddress::Localhost => {
let dir = &server_actions.working_directory;
fs::rename(dir.join(&file_action.file), dir.join(&new_name))
.map_err(|e| format!("failed to rename: {e}"))?;
}
},
}
2024-12-13 00:03:25 +01:00
}
}
log!(logger, "Done!");
}
Command::Command { command } => {
self.start_ssh_agent(&logger)?;
Self::require_non_empty_servers(&servers)?;
for server in servers {
log!(logger, "Running command on '{}'...", server.get_name());
match &server.address {
ServerAddress::Ssh { ssh_address } => {
ShellCmd::new("ssh")
.arg(ssh_address)
.arg(osf!("cd ") + server.server_directory_path + "; " + &command)
.run(&logger)
.map_err(|e| format!("{e}"))?;
}
ServerAddress::Localhost => {
let mut command_args = shell_words::split(&command)
.map_err(|e| format!("failed to parse command: {e}"))?;
ShellCmd::new(command_args.remove(0))
.args(&command_args)
.current_dir(&server.server_directory_path)
.run(&logger)
.map_err(|e| format!("{e}"))?;
}
2025-02-02 02:23:25 +01:00
}
}
log!(logger, "Done!");
}
Command::Editor {
file,
editor,
download_directory,
override_existing,
} => {
//determine download directory
let download_directory = match download_directory {
Some(download_directory) => download_directory,
None => {
2025-02-04 23:09:02 +01:00
let home_dir = self
.get_home_directory()
.map_err(|e| format!("Missing download-directory: {e}"))?;
home_dir.join("Downloads")
}
};
//get editor
let editor = editor.ok_or(()).or_else(|_| env::var(EDITOR_ENV_VAR).map_err(|e| format!("You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e}")))?;
fs::create_dir_all(&download_directory)
.map_err(|e| format!("failed to create working directory: {e}"))?;
//make sure file doesn't exist in working directory yet, or it will be overridden
let file_name = file
.file_name()
.ok_or("can only edit files, not directories")?;
'duplicate_check: {
if !override_existing
&& fs::read_dir(&download_directory)
.map_err(|e| format!("failed to open working directory: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("error while querying working directory contents: {e}"))?
.iter()
.any(|entry| entry.file_name() == file_name)
{
let duplication_notification = format!(
"A file with the name {} already exists in {}",
file_name.to_string_lossy(),
download_directory.to_string_lossy()
);
2025-02-04 23:09:02 +01:00
if !args.quiet && self.confirm(format!("{duplication_notification}. Do you want to replace it?"), false) {
break 'duplicate_check;
}
return Err(format!(
"{duplication_notification}. You can override it with --override or -f"
));
}
}
2024-12-15 14:34:19 +01:00
Self::require_non_empty_servers(&servers)?;
self.start_ssh_agent(&logger)?;
for server in servers {
log!(logger, "Getting file from {}...", server.get_name());
let file_source = osf!(match &server.address {
ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"),
ServerAddress::Localhost => "".to_string(),
}) + server.server_directory_path.join(&file);
ShellCmd::new("scp")
.arg(&file_source)
.arg(&download_directory)
.run(&logger)
.map_err(|e| format!("download failure: {e}"))?;
//open file in editor
let mut editor_command_args = shell_words::split(&editor)
.map_err(|e| format!("failed to parse editor command: {e}"))?
.into_iter()
.map(|part| {
part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name))
})
.collect::<Vec<_>>();
let command = editor_command_args.remove(0);
ShellCmd::new(command)
.args(editor_command_args)
.run(&logger)
.map_err(|e| format!("failed to open file in editor: {e}"))?;
//upload file again
ShellCmd::new("scp")
.arg(download_directory.join(file_name))
.arg(&file_source)
.run(&logger)
.map_err(|e| format!("failed to re-upload file: {e}"))?;
}
2024-12-15 14:34:19 +01:00
log!(logger, "Done!");
2024-12-15 14:34:19 +01:00
}
}
Ok(())
}
fn check_local_file_exists<P>(path: P) -> Result<(), String>
where
P: AsRef<Path>,
{
let path = path.as_ref();
if !path.is_file() {
return Err(format!(
"{} does not point to a file",
path.to_string_lossy()
));
}
Ok(())
}
fn require_non_empty_servers<T>(servers: &[T]) -> Result<(), String> {
Self::require_non_empty(servers, "servers for this operation")
}
fn require_non_empty<T>(slice: &[T], slice_name: &str) -> Result<(), String> {
if slice.is_empty() {
Err(format!(
"You did not provide any {slice_name}. Please see --help"
))?
}
Ok(())
2025-02-02 22:04:59 +01:00
}
2025-02-04 23:09:02 +01:00
fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> {
let env = &mut self.environment;
//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());
}
2025-02-02 02:23:25 +01:00
//set the env vars from the agent
static ENV_VAR_REGEX: Lazy<Regex> = lazy_regex!("(.+?)=(.+?);");
for capture in ENV_VAR_REGEX.captures_iter(&agent_stdout) {
let (_, [env_var, value]) = capture.extract();
env.set_var(env_var, value);
}
//add the ssh key
ShellCmd::new("ssh-add")
.run(logger)
.map_err(|e| format!("failed to add ssh-key: {e}"))?;
Ok(())
2024-12-17 06:02:25 +01:00
}
fn parse_server_configuration_from_env(&self) -> Result<Vec<Server>, String> {
self
.environment
.var(SERVERS_ENV_VAR)
.map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR))
.and_then(|value| parse_server_configuration(&value, || self.get_home_directory()))
}
2025-02-04 23:09:02 +01:00
fn get_home_directory(&self) -> Result<PathBuf, String> {
2025-02-04 23:09:02 +01:00
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 main() -> Result<(), String> {
Application::<Prod>::default().run()
2024-12-11 10:42:44 +01:00
}
//This will be moved into the shell-interface
fn osstring_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())
}
2024-12-11 10:42:44 +01:00
}
2025-02-04 23:09:02 +01:00
fn parse_server_configuration<F>(
config_str: &str,
get_home_directory: F,
) -> Result<Vec<Server>, String>
where
F: Fn() -> Result<PathBuf, String>,
{
config_str
.split(',')
.map(|server_entry| {
2025-02-04 23:09:02 +01:00
Server::from_str(
server_entry,
RelativeLocalPathAnker::Home,
&get_home_directory,
)
.map_err(|e| format!("Invalid server entry '{server_entry}': {e}"))
2024-12-11 10:42:44 +01:00
})
.collect()
2024-12-11 10:42:44 +01:00
}
#[cfg(test)]
mod test {
use crate::parse_server_configuration;
2025-02-02 02:23:25 +01:00
use crate::server::{Server, ServerAddress};
use std::fs;
2024-12-11 10:42:44 +01:00
use std::path::PathBuf;
#[test]
fn test_parse_server_configuration() {
2025-02-04 23:09:02 +01:00
let servers = parse_server_configuration("foo:bar,.:fizz/buzz", || Ok(PathBuf::from("/test")))
.expect("valid server configuration");
2024-12-11 14:13:32 +01:00
assert_eq!(
vec![
Server {
2025-02-02 02:23:25 +01:00
address: ServerAddress::Ssh {
ssh_address: "foo".to_string()
},
2024-12-11 14:13:32 +01:00
server_directory_path: PathBuf::from("bar"),
},
Server {
2025-02-02 02:23:25 +01:00
address: ServerAddress::Localhost,
server_directory_path: PathBuf::from("/test/fizz/buzz"),
2024-12-11 14:13:32 +01:00
}
],
servers
);
2024-12-11 10:42:44 +01:00
}
2025-02-02 02:23:25 +01:00
/// When we join an absolute path to a relative path, it becomes a relative path
#[test]
fn path_experiment() {
let server_dir = PathBuf::from("steptech");
let upload_dir = PathBuf::from("/home"); //absolute path
let joined = server_dir.join(upload_dir);
assert_eq!(PathBuf::from("/home"), joined);
}
2025-02-02 02:23:25 +01:00
/// When renaming a file in a folder, the folder is relevant in the new name
2025-02-02 02:23:25 +01:00
#[test]
fn rename_experiment() {
fs::rename("test-ressources/files/test", "test-ressources/files/test1")
.expect("failed to rename test file");
fs::rename("test-ressources/files/test1", "test-ressources/files/test")
.expect("failed to rename test1 file back to test");
}
#[test]
fn mkdir_experiment() {
fs::create_dir_all("./test-ressources/files/../python")
.expect("failed to create directory with relative path");
}
2024-12-11 10:42:44 +01:00
}