From 0fc07089d2aba09619c7918a2dc14ca01e056988 Mon Sep 17 00:00:00 2001
From: radj307 <radj307@gmail.com>
Date: Sat, 2 Mar 2024 19:33:37 -0500
Subject: [PATCH] update help doc & change commandline syntax

CLI syntax is still mostly backwards-compatible; all the same-
 options still exist, but some of their functions have changed.

commandline echo is now off by default
---
 ARRCON/ARRCON.cpp          | 134 +++++++++++++++++++++++--------------
 ARRCON/CMakeLists.txt      |  39 ++++++-----
 ARRCON/config.hpp          |   1 +
 ARRCON/net/rcon.hpp        |  31 ++++-----
 ARRCON/net/target_info.hpp |   5 ++
 5 files changed, 125 insertions(+), 85 deletions(-)

diff --git a/ARRCON/ARRCON.cpp b/ARRCON/ARRCON.cpp
index 5b01763..113bbd8 100644
--- a/ARRCON/ARRCON.cpp
+++ b/ARRCON/ARRCON.cpp
@@ -19,6 +19,10 @@
 #include <filesystem>	//< for std::filesystem
 #include <iostream>		//< for standard io streams
 
+// Global defaults
+static constexpr char const* const DEFAULT_TARGET_HOST{ "127.0.0.1" };
+static constexpr char const* const DEFAULT_TARGET_PORT{ "27015" };
+
 struct print_help {
 	std::string exeName;
 
@@ -27,7 +31,7 @@ struct print_help {
 	friend std::ostream& operator<<(std::ostream& os, const print_help& h)
 	{
 		return os << h.exeName << " v" << ARRCON_VERSION_EXTENDED << " (" << ARRCON_COPYRIGHT << ")\n"
-			<< "  A robust & powerful commandline Remote-CONsole (RCON) client designed for use with the Source RCON Protocol.\n"
+			<< "  A Robust Remote-CONsole (RCON) client designed for use with the Source RCON Protocol.\n"
 			<< "  It is also compatible with similar protocols such as the one used by Minecraft.\n"
 			<< '\n'
 			<< "  Report compatibility issues here: https://github.com/radj307/ARRCON/issues/new?template=support-request.md\n"
@@ -36,26 +40,27 @@ struct print_help {
 			<< "  " << h.exeName << " [OPTIONS] [COMMANDS]\n"
 			<< '\n'
 			<< "  Some arguments take additional inputs, labeled with <angle brackets>." << '\n'
-			<< "  Arguments that contain spaces must be enclosed with single (\') or double(\") quotation marks." << '\n'
+			<< "  Inputs that contain spaces must be enclosed with single (\') or double(\") quotation marks." << '\n'
 			<< '\n'
 			<< "TARGET SPECIFIER OPTIONS:\n"
-			<< "  -H, --host  <Host>          RCON Server IP/Hostname." << '\n'//"  (Default: \"" /*<< Global.DEFAULT_TARGET.hostname*/ << "\")" << '\n'
-			<< "  -P, --port  <Port>          RCON Server Port." << '\n'//"         (Default: \"" /*<< Global.DEFAULT_TARGET.port*/ << "\")" << '\n'
-			<< "  -p, --pass  <Pass>          RCON Server Password." << '\n'
-			<< "  -S, --saved <Host>          Use a saved host's connection information, if it isn't overridden by arguments." << '\n'
-			<< "      --save-host <H>         Create a new saved host named \"<H>\" using the current [Host/Port/Pass] value(s)." << '\n'
-			<< "      --remove-host <H>       Remove an existing saved host named \"<H>\" from the list, then exit." << '\n'
-			<< "  -l, --list-hosts            Show a list of all saved hosts, then exit." << '\n'
+			<< "  -H, --host <Host>           RCON Server IP/Hostname.  (Default: \"" << DEFAULT_TARGET_HOST << "\")" << '\n'
+			<< "  -P, --port <Port>           RCON Server Port.         (Default: \"" << DEFAULT_TARGET_PORT << "\")" << '\n'
+			<< "  -p, --pass <Pass>           RCON Server Password.     (Default: \"\")" << '\n'
+			<< "  -R, --recall <Name>         Recalls saved [Host|Port|Pass] values from the hosts file." << '\n'
+			<< "      --save   <Name>         Saves the specified [Host|Port|Pass] as \"<Name>\" in the hosts file." << '\n'
+			<< "      --remove <Name>         Removes an entry from the hosts file." << '\n'
+			<< "  -l, --list                  Lists the servers currently saved in the host file." << '\n'
 			<< '\n'
 			<< "OPTIONS:\n"
-			<< "  -h, --help                  Show this help display, then exits." << '\n'
+			<< "  -h, --help                  Shows this help display, then exits." << '\n'
 			<< "  -v, --version               Prints the current version number, then exits." << '\n'
-			<< "  -q, --quiet                 Silent/Quiet mode; prevents or minimizes console output." << '\n'
+			<< "  -q, --quiet                 Silent/Quiet mode; prevents or minimizes console output. Use \"-qn\" for scripts." << '\n'
 			<< "  -i, --interactive           Starts an interactive command shell after sending any scripted commands." << '\n'
+			<< "  -e, --echo                  Enables command echo in oneshot mode." << '\n'
 			<< "  -w, --wait <ms>             Sets the number of milliseconds to wait between sending each queued command. Default: 0" << '\n'
 			<< "  -t, --timeout <ms>          Sets the number of milliseconds to wait for a response before timing out. Default: 3000" << '\n'
 			<< "  -n, --no-color              Disables colorized console output." << '\n'
-			<< "  -Q, --no-prompt             Disables the prompt in interactive mode and/or command echo in commandline mode." << '\n'
+			<< "  -Q, --no-prompt             Disables the prompt in interactive mode." << '\n'
 			<< "      --no-exit               Disables handling the \"exit\" keyword in interactive mode." << '\n'
 			<< "      --allow-empty           Enables sending empty (whitespace-only) commands to the server in interactive mode." << '\n'
 			//	<< "      --print-env             Prints all recognized environment variables, their values, and descriptions." << '\n'
@@ -93,23 +98,19 @@ int main(const int argc, char** argv)
 	}
 }
 
-static constexpr char const* const DEFAULT_TARGET_HOST{ "127.0.0.1" };
-static constexpr char const* const DEFAULT_TARGET_PORT{ "27015" };
-static constexpr char const* const DEFAULT_TARGET_PASS{ "" };
-
 int main_impl(const int argc, char** argv)
 {
 	const opt3::ArgManager args{ argc, argv,
 		// define capturing args:
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'H', "host", "hostname"),
-		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'S', "saved"),
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'P', "port"),
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'p', "pass", "password"),
+		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'S', 'R', "saved", "recall"),
+		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, "save", "save-host"),
+		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, "rm", "remove", "rm-host" "remove-host"),
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'w', "wait"),
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 't', "timeout"),
 		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, 'f', "file"),
-		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, "save-host"),
-		opt3::make_template(opt3::CaptureStyle::Required, opt3::ConflictStyle::Conflict, "remove-host"),
 	};
 
 	// get the executable's location & name
@@ -121,6 +122,15 @@ int main_impl(const int argc, char** argv)
 	std::ofstream logfs{ locator.from_extension(".log") };
 	// log manager object
 	Logger logManager{ logfs.rdbuf() };
+	logManager.print_header();
+	// write commandline to log
+	{
+		const auto argVec{ opt3::vectorize(argc, argv) };
+		std::clog
+			<< MessageHeader(LogLevel::Debug) << "Commandline Arguments: \""
+			<< str::stringify_join(argVec.begin(), argVec.end(), ' ') << '\"'
+			<< std::endl;
+	}
 
 	// -h|--help
 	if (args.empty() || args.check_any<opt3::Flag, opt3::Option>('h', "help")) {
@@ -143,23 +153,23 @@ int main_impl(const int argc, char** argv)
 	// -n|--no-color
 	csync.setEnabled(!args.check_any<opt3::Flag, opt3::Option>('n', "no-color"));
 
-	/// Select a target server & operate on the hosts file
+	/// determine the target server info & operate on the hosts file
 	const auto hostsfile_path{ locator.from_extension(".hosts") };
 	std::optional<config::SavedHosts> hostsfile;
 
-	// --rm-host|--remove-host
-	if (const auto& arg_removeHost{ args.getv_any<opt3::Option>("rm-host", "remove-host") }; arg_removeHost.has_value()) {
+	// --remove|--rm|--rm-host|--remove-host
+	if (const auto& arg_removeHost{ args.getv_any<opt3::Option>("rm", "remove", "rm-host", "remove-host") }; arg_removeHost.has_value()) {
 		if (!std::filesystem::exists(hostsfile_path))
-			throw make_exception("The hosts file hasn't been created yet. (Use \"--save-host\" to create one)");
+			throw make_exception("The hosts file hasn't been created yet. (Use \"--save\" to create one)");
 
-		// load the hosts file
+		// load the hosts file directly
 		ini::INI ini(hostsfile_path);
 
 		// remove the specified entry
 		if (const auto it{ ini.find(arg_removeHost.value()) }; it != ini.end())
 			ini.erase(it);
 		else
-			throw make_exception("The specified saved host \"", arg_removeHost.value(), "\" doesn't exist! (Use \"--list-hosts\" to see a list of saved hosts.)");
+			throw make_exception("The specified saved host \"", arg_removeHost.value(), "\" doesn't exist! (Use \"--list\" to see a list of saved hosts.)");
 
 		// save the hosts file
 		if (ini.write(hostsfile_path)) {
@@ -168,8 +178,9 @@ int main_impl(const int argc, char** argv)
 		}
 		else throw make_exception("Failed to save hosts file to ", hostsfile_path, '!');
 	}
-	// --list-hosts
-	else if (args.check_any<opt3::Option>("list-hosts", "list-host")) {
+
+	// --list|--list-hosts
+	if (args.check_any<opt3::Flag, opt3::Option>('l', "list", "list-hosts", "list-host")) {
 		if (!std::filesystem::exists(hostsfile_path))
 			throw make_exception("The hosts file hasn't been created yet. (Use \"--save-host\" to create one)");
 
@@ -211,12 +222,12 @@ int main_impl(const int argc, char** argv)
 	}
 
 	/// get the target connection info:
-	net::rcon::target_info target{ DEFAULT_TARGET_HOST, DEFAULT_TARGET_PORT, DEFAULT_TARGET_PASS };
+	net::rcon::target_info target{ DEFAULT_TARGET_HOST, DEFAULT_TARGET_PORT, "" };
 
-	// -S|--saved|--server
-	if (const auto& arg_saved{ args.getv_any<opt3::Flag, opt3::Option>('S', "saved", "server") }; arg_saved.has_value()) {
+	// -S|-R|--saved|--recall
+	if (const auto& arg_saved{ args.getv_any<opt3::Flag, opt3::Option>('S', 'R', "saved", "recall") }; arg_saved.has_value()) {
 		if (!std::filesystem::exists(hostsfile_path))
-			throw make_exception("The hosts file hasn't been created yet. (Use \"--save-host\" to create one)");
+			throw make_exception("The hosts file hasn't been created yet. (Use \"--save\" to create one)");
 
 		// load the hosts file
 		if (!hostsfile.has_value())
@@ -226,7 +237,9 @@ int main_impl(const int argc, char** argv)
 		if (const auto savedTarget{ hostsfile->get_host(arg_saved.value()) }; savedTarget.has_value()) {
 			target = savedTarget.value();
 		}
-		else throw make_exception("The specified saved host \"", arg_saved.value(), "\" doesn't exist! (Use \"--list-hosts\" to see a list of saved hosts.)");
+		else throw make_exception("The specified saved host \"", arg_saved.value(), "\" doesn't exist! (Use \"--list\" to see a list of saved hosts)");
+
+		std::clog << MessageHeader(LogLevel::Debug) << "Recalled saved host information for \"" << arg_saved.value() << "\": " << target << std::endl;
 	}
 	// -H|--host|--hostname
 	if (const auto& arg_hostname{ args.getv_any<opt3::Flag, opt3::Option>('H', "host", "hostname") }; arg_hostname.has_value())
@@ -238,8 +251,8 @@ int main_impl(const int argc, char** argv)
 	if (const auto& arg_password{ args.getv_any<opt3::Flag, opt3::Option>('p', "pass", "password") }; arg_password.has_value())
 		target.pass = arg_password.value();
 
-	// --save-host
-	if (const auto& arg_saveHost{ args.getv_any<opt3::Option>("save-host") }; arg_saveHost.has_value()) {
+	// --save|--save-host
+	if (const auto& arg_saveHost{ args.getv_any<opt3::Option>("save", "save-host") }; arg_saveHost.has_value()) {
 		// load the hosts file
 		if (!hostsfile.has_value()) {
 			hostsfile = std::filesystem::exists(hostsfile_path)
@@ -247,52 +260,69 @@ int main_impl(const int argc, char** argv)
 				: config::SavedHosts();
 		}
 
-		// TODO: Improve feedback when target already exists, maybe prompt the user if changes are going to be made
+		const bool exists{ hostsfile->contains(arg_saveHost.value()) };
+		auto& entry{ (*hostsfile)[arg_saveHost.value()] };
+
+		// break early if no changes will be made
+		if (exists && entry == target) {
+			std::cout << "Host \"" << csync(color::yellow) << arg_saveHost.value() << csync() << "\" was already saved with the specified server info.\n";
+			return 0;
+		}
+
 		// set the target
-		(*hostsfile)[arg_saveHost.value()] = target;
+		entry = target;
+
+		// create directory structure
+		if (!std::filesystem::exists(hostsfile_path))
+			std::filesystem::create_directories(hostsfile_path.parent_path());
 
 		// write to disk
 		ini::INI ini;
 		hostsfile->export_to(ini);
 		if (ini.write(hostsfile_path)) {
-			std::cout << "Successfully added \"" << csync(color::yellow) << arg_saveHost.value() << csync() << "\" to the hosts list.\n";
+			std::cout
+				<< "Host \"" << csync(color::yellow) << arg_saveHost.value() << csync() << "\" was " << (exists ? "updated" : "created") << " with the specified server info.\n"
+				<< "Saved hosts file to " << hostsfile_path << '\n'
+				;
 			return 0;
 		}
 		else throw make_exception("Failed to save hosts file to ", hostsfile_path, '!');
 	}
 
 	// validate & log the target host information
-	std::clog << MessageHeader(LogLevel::Info) << "Target Host: \"" << target.host << ':' << target.port << '\"' << std::endl;
-
-	// TODO: add a check for blank passwords
+	std::clog << MessageHeader(LogLevel::Info) << "Target Server Info: \"" << target.host << ':' << target.port << '\"' << std::endl;
 
 	// initialize and connect the client
-	net::rcon::RconClient client{ target.host, target.port };
+	net::rcon::RconClient client;
+
+	// connect to the server and authenticate
+	// TODO: Add better error output when this fails:
+	client.connect(target.host, target.port);
 
 	// -t|--timeout
 	client.set_timeout(args.castgetv_any<int, opt3::Flag, opt3::Option>([](auto&& arg) { return str::stoi(std::forward<decltype(arg)>(arg)); }, 't', "timeout").value_or(3000));
+	// ^ this needs to be set after connecting
 
-	// authenticate with the server
+	// TODO: Add better error output when this fails:
 	if (!client.authenticate(target.pass)) {
 		throw make_exception("Authentication failed due to incorrect password!");
 	}
 
-	// get the list of commands from the commandline
+	// get commands from STDIN & the commandline
 	std::vector<std::string> commands;
-
 	if (hasPendingDataSTDIN()) {
 		// get commands from STDIN
 		for (std::string buf; std::getline(std::cin, buf);) {
 			commands.emplace_back(buf);
 		}
 	}
-	// get commands from the commandline
 	if (const auto parameters{ args.getv_all<opt3::Parameter>() };
 		!parameters.empty()) {
 		commands.insert(commands.end(), parameters.begin(), parameters.end());
 	}
 
-	const bool disablePromptAndEcho{ args.check_any<opt3::Flag, opt3::Option>('Q', "no-prompt") };
+	const bool noPrompt{ args.check_any<opt3::Flag, opt3::Option>('Q', "no-prompt")};
+	const bool echoCommands{ args.check_any<opt3::Flag, opt3::Option>('e', "echo") };
 
 	// Oneshot Mode
 	if (!commands.empty()) {
@@ -313,8 +343,8 @@ int main_impl(const int argc, char** argv)
 				else std::this_thread::sleep_for(commandDelay);
 			}
 
-			if (!quiet) {
-				if (!disablePromptAndEcho) // print the shell prompt
+			if (echoCommands) {
+				if (!noPrompt) // print the shell prompt
 					print_input_prompt(std::cout, target.host, csync);
 				// echo the command
 				std::cout << command << '\n';
@@ -325,12 +355,12 @@ int main_impl(const int argc, char** argv)
 		}
 	}
 
-	bool disableExitKeyword{ args.check_any<opt3::Option>("no-exit") };
-	bool allowEmptyCommands{ args.check_any<opt3::Option>("allow-empty") };
+	const bool disableExitKeyword{ args.check_any<opt3::Option>("no-exit") };
+	const bool allowEmptyCommands{ args.check_any<opt3::Option>("allow-empty") };
 
 	// Interactive mode
 	if (commands.empty() || args.check_any<opt3::Flag, opt3::Option>('i', "interactive")) {
-		if (!disablePromptAndEcho) {
+		if (!noPrompt) {
 			std::cout << "Authentication Successful.\nUse <Ctrl + C>";
 			if (!disableExitKeyword) std::cout << " or type \"exit\"";
 			std::cout << " to quit.\n";
@@ -338,7 +368,7 @@ int main_impl(const int argc, char** argv)
 
 		// interactive mode input loop
 		while (true) {
-			if (!quiet && !disablePromptAndEcho) // print the shell prompt
+			if (!quiet && !noPrompt) // print the shell prompt
 				print_input_prompt(std::cout, target.host, csync);
 
 			// get user input
diff --git a/ARRCON/CMakeLists.txt b/ARRCON/CMakeLists.txt
index 5e1e25e..6370c6b 100644
--- a/ARRCON/CMakeLists.txt
+++ b/ARRCON/CMakeLists.txt
@@ -52,30 +52,37 @@ target_include_directories(ARRCON PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/rc")
 
 target_sources(ARRCON PRIVATE "${HEADERS}")
 
-# Setup Boost
-include(FetchContent)
-FetchContent_Declare(
-	Boost
-	GIT_REPOSITORY	https://github.com/boostorg/boost.git
-	GIT_TAG			boost-1.84.0
-)
-#set(BOOST_INCLUDE_LIBRARIES asio stacktrace)
-FetchContent_MakeAvailable(Boost)
-
-if (APPLE)
-	# Fix Boost::Stacktrace
-	target_compile_options(ARRCON PRIVATE "-D_GNU_SOURCE")
+## Setup Boost:
+# Try to find an existing Boost 1.84.0 package
+find_package(Boost 1.84.0 COMPONENTS asio stacktrace)
+# Fallback to FetchContent if not found
+if (NOT Boost_FOUND)
+	include(FetchContent)
+	FetchContent_Declare(
+		Boost
+		GIT_REPOSITORY	https://github.com/boostorg/boost.git
+		GIT_TAG			boost-1.84.0
+	)
+	FetchContent_MakeAvailable(Boost)
 endif()
 
-target_link_libraries(ARRCON PRIVATE TermAPI filelib Boost::asio)
+target_link_libraries(ARRCON PRIVATE
+	TermAPI
+	filelib
+	Boost::asio
+)
 
 # Link Boost::Stacktrace
-if(ARRCON_DISABLE_STACKTRACE)
+# see https://www.boost.org/doc/libs/1_81_0/doc/html/stacktrace/configuration_and_build.html
+if(ARRCON_DISABLE_STACKTRACE OR CMAKE_BUILD_TYPE STREQUAL "Release")
 	# Disable stacktrace
-	target_link_libraries(ARRCON PRIVATE boost_stacktrace_noop)	
+	target_link_libraries(ARRCON PRIVATE boost_stacktrace_noop)
 elseif(MSVC)
 	# Use COM stacktrace
 	target_link_libraries(ARRCON PRIVATE boost_stacktrace_windbg ole32 dbgeng)
+elseif(APPLE)
+	# Use addr2line
+	target_link_libraries(ARRCON PRIVATE boost_stacktrace_basic libdl)
 else()
 	# Use POSIX backtrace
 	target_link_libraries(ARRCON PRIVATE boost_stacktrace_backtrace dl backtrace)
diff --git a/ARRCON/config.hpp b/ARRCON/config.hpp
index f74c4cc..bcd6e00 100644
--- a/ARRCON/config.hpp
+++ b/ARRCON/config.hpp
@@ -32,6 +32,7 @@ namespace config {
 		auto end() const { return hosts.end(); }
 		bool empty() const noexcept { return hosts.empty(); }
 		size_t size() const noexcept { return hosts.size(); }
+		bool contains(std::string const& name) const { return hosts.contains(name); }
 
 		void import_from(ini::INI const& ini)
 		{
diff --git a/ARRCON/net/rcon.hpp b/ARRCON/net/rcon.hpp
index 4d646ae..7dae1b5 100644
--- a/ARRCON/net/rcon.hpp
+++ b/ARRCON/net/rcon.hpp
@@ -143,7 +143,7 @@ namespace net {
 				packet_header header{};
 				boost::asio::mutable_buffer buf(&header, sizeof(packet_header));
 				boost::asio::read(socket, buf, ec); //< TODO: validate received byte count
-
+				
 				// check for errors
 				if (ec) {
 					const auto error_message{ str::stringify("Failed to read packet header due to error: \"", ec.what(), "\"! Flushing the buffer.") };
@@ -176,6 +176,19 @@ namespace net {
 				return std::make_pair(header, body_buffer);
 			}
 
+		public:
+			/**
+			 * @brief			Creates a new RconClient instance and connects it to the specified endpoint.
+			 * @param host    -	The hostname of the target endpoint.
+			 * @param port	  -	The port of the target endpoint.
+			 */
+			RconClient() : socket{ ioContext } {}
+			~RconClient()
+			{
+				ioContext.run(); //< wait for async operations to finish
+				socket.close(); //< close the socket
+			}
+
 			/// @brief	Connects the RCON client to the specified endpoint.
 			void connect(std::string_view host, std::string_view port) noexcept(false)
 			{
@@ -213,22 +226,6 @@ namespace net {
 				}
 			}
 
-		public:
-			/**
-			 * @brief			Creates a new RconClient instance and connects it to the specified endpoint.
-			 * @param host    -	The hostname of the target endpoint.
-			 * @param port	  -	The port of the target endpoint.
-			 */
-			RconClient(std::string_view host, std::string_view port) noexcept(false) : socket{ ioContext }
-			{
-				connect(host, port);
-			}
-			~RconClient()
-			{
-				ioContext.run(); //< wait for async operations to finish
-				socket.close(); //< close the socket
-			}
-
 			/**
 			 * @brief				Sends a command to the RCON server and returns the response.
 			 * @param command	  -	The command to send to the RCON server.
diff --git a/ARRCON/net/target_info.hpp b/ARRCON/net/target_info.hpp
index d10d585..e0db80d 100644
--- a/ARRCON/net/target_info.hpp
+++ b/ARRCON/net/target_info.hpp
@@ -7,6 +7,11 @@ namespace net::rcon {
 		std::string port;
 		std::string pass;
 
+		friend bool operator==(target_info const& a, target_info const& b)
+		{
+			return a.host == b.host && a.port == b.port && a.pass == b.pass;
+		}
+
 		friend std::ostream& operator<<(std::ostream& os, const target_info& t)
 		{
 			return os << t.host << ':' << t.port;