use std::path::{Path};
use std::env::{current_dir};
use std::error::Error;
use std::process::exit;
use clap::{Parser, ArgGroup, Subcommand};
use termcolor::{ColorChoice};
use http_types::url::{Url, Host};
use serde::{Serialize};
use boomack::client::config::Config;
use boomack::client::api::{
Response as ApiResponse,
Error as ApiError,
ClientRequest, ClientRequestBody,
send_request, send_request_with_retry,
build_request_url,
};
use self::dry::print_request;
use self::display::{ DisplayCommand, DisplaySubCommandArguments, dispatch_display_command };
use self::eval::{ EvalSubCommandArguments, dispatch_eval_command };
use self::panels::{ PanelCommandArguments, dispatch_panel_command };
use self::slots::{ SlotCommandArguments, dispatch_slot_command };
use self::presets::{ PresetCommandArguments, dispatch_preset_command };
use self::types::{ TypeCommandArguments, dispatch_type_command };
use self::actions::{ ActionCommandArguments, dispatch_action_command };
use self::export::{ ExportCommandArguments, dispatch_export_command };
#[macro_use] mod macros;
fn as_url(url_str: &str) -> Option<Url> {
match Url::parse(url_str) {
Ok(url) => Some(url),
Err(_) => None
}
}
fn is_http_url(url_str: &str) -> bool {
as_url(url_str).map_or(false,
|url| url.scheme() == "http" || url.scheme() == "https")
}
fn host_is_local(url: &Url) -> bool {
url.host().map_or(false,
|host| match host {
Host::Domain(dn) => dn == "localhost",
Host::Ipv4(ipv4) => ipv4.is_loopback(),
Host::Ipv6(ipv6) => ipv6.is_loopback(),
})
}
fn has_local_host(url_str: &str) -> bool {
as_url(url_str).map_or(false, |url| host_is_local(&url))
}
#[derive(Parser, Serialize)]
#[clap(author, version, about)]
#[clap(propagate_version = true)]
#[clap(group(ArgGroup::new("input").args(&["text-input", "url-input", "file-input"])))]
pub struct CommandLineArgs {
#[clap(long = "api",
// help_heading = "GLOBAL",
help = "Base URL of the Boomack server. Defaults to http://localhost:3000")]
pub api_url: Option<String>,
#[clap(long = "config",
help = "One or more paths to configuration files",
num_args = 1..)]
pub config_files: Vec<String>,
#[clap(long = "token",
help = "An authentication token for API access")]
pub token: Option<String>,
#[clap(long = "format",
help = "Preferred format for output",
value_parser = ["text", "json", "html"])]
pub format: Option<String>,
#[clap(long = "dry",
help = "Do not send, but print the HTTP request as plain text",
num_args = 0)]
pub dry: bool,
#[clap(long = "curl",
help = "Do not send, but print the request in cURL syntax",
num_args = 0)]
pub curl: bool,
#[clap(long = "no-color",
help = "Do not use colors, when printing request with --dry or --curl",
num_args = 0)]
pub no_color: bool,
#[clap(long = "verbose",
help = "Display additional output",
num_args = 0)]
pub verbose: bool,
#[clap(subcommand)]
pub command: Option<Commands>,
#[clap(short = 'l', long = "location",
help_heading = "DISPLAY OPTIONS",
help = "Panel and slot: E. g. 'my-panel/slot-a'")]
location: Option<String>,
#[clap(help_heading = "DISPLAY ARGS",
help = "Auto content: URL, filename, or text")]
auto_content: Option<String>,
#[clap(id = "text-input", short = 's', long = "string",
help_heading = "DISPLAY OPTIONS",
help = "Text content")]
text_input: Option<String>,
#[clap(id = "url-input", short = 'u', long = "url",
help_heading = "DISPLAY OPTIONS",
help = "URL content")]
url_input: Option<String>,
#[clap(id = "file-input", short = 'f', long = "file", value_name = "PATH",
help_heading = "DISPLAY OPTIONS",
help = "File content")]
file_input: Option<String>,
#[clap(long = "no-file-url",
help_heading = "DISPLAY OPTIONS",
help = "Prevents passing a file:// URL in case a file is displayed and the server is localhost",
num_args = 0)]
no_file_url: bool,
#[clap(id = "watch", short = 'w', long = "watch",
help_heading = "DISPLAY OPTIONS",
help = "Watch the file and display content on change",
requires = "file-input",
num_args = 0)]
pub watch: bool,
#[clap(short = 't', long = "type", value_name = "MIME_TYPE",
help_heading = "DISPLAY OPTIONS",
help = "MIME type of the content")]
content_type: Option<String>,
#[clap(short = 'x', long = "extend", value_name = "POSITION",
help_heading = "DISPLAY OPTIONS",
help = "Position where to add to existing content in the target slot",
value_parser = ["start", "end"])]
extend: Option<String>,
#[clap(long = "prepend",
help_heading = "DISPLAY OPTIONS",
help = "Same as --extend start",
num_args = 0)]
prepend: bool,
#[clap(long = "append",
help_heading = "DISPLAY OPTIONS",
help = "Same as --extend end",
num_args = 0)]
append: bool,
#[clap(short = 'c', long ="cache", value_name = "MODE",
help_heading = "DISPLAY OPTIONS",
help = "Specifies the intended caching on the server",
value_parser = ["auto", "memory", "file"])]
cache: Option<String>,
#[clap(short = 'e', long = "embed",
help_heading = "DISPLAY OPTIONS",
help = "A switch to state the intention, \
that the media content should be embedded \
into the HTML and not referenced as a resource",
num_args = 0)]
embed : bool,
#[clap(long = "title", value_name = "TITLE",
help_heading = "DISPLAY OPTIONS",
help = "A title for the content")]
pub title: Option<String>,
#[clap(long = "debug",
help_heading = "DISPLAY OPTIONS",
help = "Display debug information instead of the actual media item",
num_args = 0)]
debug: bool,
#[clap(short = 'o', long = "options", value_name="OPTION",
help_heading = "DISPLAY OPTIONS",
help = "Display options as key=value pair(s), or a YAML/JSON map, or a filename, or STDIN",
num_args = 1..)]
options: Vec<String>,
#[clap(short = 'p', long = "presets", value_name = "PRESET",
help_heading = "DISPLAY OPTIONS",
help = "One or more IDs of presets to apply",
num_args = 1..)]
presets: Vec<String>,
}
#[derive(PartialEq, Clone, Serialize)]
pub struct CliConfig {
pub api: Config,
pub dry: bool,
pub curl: bool,
pub no_color: bool,
pub verbose: bool,
}
impl CliConfig {
pub fn is_dry_run(&self) -> bool { self.dry || self.use_curl_syntax() }
pub fn use_curl_syntax(&self) -> bool { self.curl }
pub fn allow_color(&self) -> bool { !self.no_color }
pub fn is_verbose(&self) -> bool { self.verbose }
pub fn color_choice(&self) -> ColorChoice {
if self.allow_color() && atty::is(atty::Stream::Stdout) {
ColorChoice::Auto
} else {
ColorChoice::Never
}
}
}
impl CommandLineArgs {
pub fn build_config(&self) -> CliConfig {
let mut api_cfg = Config::new();
api_cfg.server.url = self.api_url.as_deref().map(String::from);
api_cfg.client.token = self.token.as_deref().map(String::from);
api_cfg.client.format = match self.format.as_deref() {
Some("text") => Some(String::from("text/plain")),
Some("json") => Some(String::from("application/json")),
Some("html") => Some(String::from("text/html")),
Some(format) => Some(String::from(format)),
None => None,
};
api_cfg.fill_gaps_with(&Config::from_env());
if !api_cfg.load_unknown_config_files(&self.config_files) {
exit(1)
}
api_cfg.load_known_config_files();
if self.watch {
api_cfg.client.retry = None;
}
api_cfg.load_default_media_types();
CliConfig {
api: api_cfg,
dry: self.dry,
curl: self.curl,
no_color: self.no_color,
verbose: self.verbose,
}
}
fn get_display_command(&self) -> DisplayCommand<'_> {
DisplayCommand {
location: self.location.as_deref(),
auto_content: self.auto_content.as_deref(),
text_input: self.text_input.as_deref(),
url_input: self.url_input.as_deref(),
file_input: self.file_input.as_deref(),
no_file_url: self.no_file_url,
watch: self.watch,
content_type: self.content_type.as_deref(),
extend: self.extend.as_deref(),
prepend: self.prepend,
append: self.append,
cache: self.cache.as_deref(),
embed: self.embed,
title: self.title.as_deref(),
debug: self.debug,
options: self.options.iter().map(|x| x.as_ref()).collect(),
presets: self.presets.iter().map(|x| x.as_ref()).collect(),
}
}
}
#[derive(Subcommand, Serialize)]
pub enum Commands {
#[clap(about = "Display media content")]
Display(DisplaySubCommandArguments),
#[clap(about = "Evaluate JavaScript code inside a panel")]
Eval(EvalSubCommandArguments),
#[clap(about = "Manage panels")]
Panel(PanelCommandArguments),
#[clap(about = "Clear or export single slots")]
Slot(SlotCommandArguments),
#[clap(about = "Manage preset")]
Preset(PresetCommandArguments),
#[clap(about = "Manage media types")]
Type(TypeCommandArguments),
#[clap(about = "Manage actions")]
Action(ActionCommandArguments),
#[clap(about = "Export to server filesystem")]
Export(ExportCommandArguments),
}
pub fn parse_cli_args() -> CommandLineArgs {
CommandLineArgs::parse()
}
fn absolute_file_path(file_path: &str) -> String {
let path = Path::new(file_path);
if path.is_relative() {
if let Ok(cwd) = current_dir() {
return String::from(
cwd.join(path).to_str().unwrap_or(file_path));
}
}
String::from(file_path)
}
fn file_url(file_path: &str) -> String {
let mut result = String::from("file://");
result += &absolute_file_path(file_path);
result
}
pub fn dispatch_command(args: &CommandLineArgs, cfg: &CliConfig) -> i32 {
if let Some(command) = &args.command {
match command {
Commands::Display(sub_args) => dispatch_display_command(cfg, &sub_args.get_display_command()),
Commands::Eval(sub_args) => dispatch_eval_command(cfg, &sub_args),
Commands::Panel(panel_args) => dispatch_panel_command(cfg, &panel_args),
Commands::Slot(slot_args) => dispatch_slot_command(cfg, &slot_args),
Commands::Preset(preset_args) => dispatch_preset_command(cfg, &preset_args),
Commands::Type(type_args) => dispatch_type_command(cfg, &type_args),
Commands::Action(action_args) => dispatch_action_command(cfg, &action_args),
Commands::Export(export_args) => dispatch_export_command(cfg, &export_args),
}
} else {
dispatch_display_command(cfg, &args.get_display_command())
}
}
fn parse_location(location: Option<&str>) -> (Option<&str>, Option<&str>) {
match location {
Some(location) if location.contains('/') => {
let s = location.find('/').unwrap();
let panel_id = &location[..s];
let slot_id = &location[s+1..];
(if panel_id.is_empty() { None } else { Some(panel_id) },
if slot_id.is_empty() { None } else { Some(slot_id) })
},
Some(slot) => { (None, Some(slot)) },
None => { (None, None) },
}
}
fn source_auto_content_uf<'t>(content: Option<&'t str>, text: &mut Option<&'t str>, url: &mut Option<&'t str>, filename: &mut Option<&'t str>) {
if let Some(content) = content {
if content == "-" { return; }
if is_http_url(content) {
*url = Some(content);
} else {
let file_path = Path::new(&content);
if file_path.is_file() {
*filename = Some(content);
} else {
*text = Some(content);
}
}
}
}
fn source_auto_content_f<'t>(content: Option<&'t str>, text: &mut Option<&'t str>, filename: &mut Option<&'t str>) {
if let Some(content) = content {
if content == "-" { return; }
let file_path = Path::new(&content);
if file_path.is_file() {
*filename = Some(content);
} else {
*text = Some(content);
}
}
}
fn run_request(cfg: &CliConfig, mut client_request: ClientRequest) -> i32 {
client_request.set_header("Accept", cfg.api.client.get_format());
if let Some(token) = cfg.api.client.token.as_deref() {
client_request.set_header("Authorization", format!("Token {}", token));
}
if cfg.is_dry_run() {
client_request.unset_header("User-Agent");
print_request(cfg, client_request).unwrap();
return 0;
}
client_request.set_header("User-Agent", format!("boomack-client-rs/{}", env!("CARGO_PKG_VERSION")));
let request_url = build_request_url(&cfg.api, &client_request.route);
trace!(cfg, "Sending request to {}", request_url);
let attempt_handler = |attempt| trace!(cfg, "Attempt {}", attempt);
let retry_handler = |_| trace!(cfg, "...Retry...");
let result_handler = |res: ApiResponse| {
trace!(cfg, "Received HTTP response [{}] {}", res.status(), res.status_text());
print!("{}", res.into_string().unwrap_or(String::from("<BINARY RESPONSE CONTENT>")));
};
let error_handler = |attempt, err: ApiError| -> i32 {
if cfg.api.client.get_retry() > 0 {
eprintln!("Attempt {} failed", attempt);
}
match err {
ApiError::Status(code, res) => {
trace!(cfg, "Received HTTP response [{}] {}", res.status(), res.status_text());
eprintln!("HTTP Status {}", code);
eprintln!();
print!("{}", res.into_string().unwrap_or(String::from("<BINARY RESPONSE CONTENT>")));
return code as i32;
},
ApiError::Transport(err) => {
trace!(cfg, "Network error");
eprintln!("{}", err.message().unwrap());
if let Some(source) = err.source() {
eprintln!("{}", source);
}
return 1;
},
}
};
if matches!(client_request.body, ClientRequestBody::StdIn) {
match send_request(&cfg.api, &client_request) {
Err(err) => error_handler(1, err),
Ok(res) => {
result_handler(res);
return 0;
},
}
} else {
let mut status_code = 0;
match send_request_with_retry(&cfg.api, &client_request,
Some(attempt_handler),
Some(|attempt, err| { status_code = error_handler(attempt, err); }),
Some(retry_handler))
{
Some(res) => {
result_handler(res);
return 0;
},
None => status_code,
}
}
}
mod structs;
mod dry;
mod display;
mod eval;
mod panels;
mod slots;
mod presets;
mod types;
mod actions;
mod export;