istamon 0.1.0

Simple Desktop application and cli to display the service and host states of an Icinga instance.
use std::convert::TryFrom;

use structopt::clap::{App, Arg, ArgGroup, ArgMatches, SubCommand};

#[derive(Debug, PartialEq)]
pub enum Method {
    Get,
    Post,
}

impl TryFrom<&str> for Method {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        match value {
            "GET" => Ok(Method::Get),
            "POST" => Ok(Method::Post),
            _ => Err(format!("Method not supported: {}", value)),
        }
    }
}

#[derive(Debug, PartialEq)]
pub enum AckTarget {
    Host(String),
    Service(String),
}

impl AckTarget {
    pub fn query(&self) -> Vec<(&'static str, String)> {
        match self {
            AckTarget::Host(host_name) => {
                vec![("host", host_name.to_string())]
            }
            AckTarget::Service(service_name) => {
                vec![("service", service_name.to_string())]
            }
        }
    }

    pub fn object_type(&self) -> String {
        match self {
            AckTarget::Host(_) => "Host".to_string(),
            AckTarget::Service(_) => "Service".to_string(),
        }
    }
}

#[derive(Debug, PartialEq)]
pub struct Options {
    pub url_string: Option<String>,
    pub command: Command,
}

#[derive(Debug, PartialEq)]
pub enum ClientCommand {
    RestApi {
        method: Method,
        read_body: bool,
        path: String,
    },
    Status {
        verbose: bool,
    },
    Ack {
        target: AckTarget,
        until: Option<u64>,
        comment: Option<String>,
    },
    HostNotification {
        host: String,
        comment: String,
    },
}

#[derive(Debug, PartialEq)]
pub enum KWalletCommand {
    WritePassword,
    ReadPassword,
    RemovePassword,
}

#[derive(Debug, PartialEq)]
pub enum Command {
    Client(ClientCommand),
    KWallet(KWalletCommand),
}

impl From<KWalletCommand> for Command {
    fn from(c: KWalletCommand) -> Self {
        Command::KWallet(c)
    }
}

impl From<ClientCommand> for Command {
    fn from(c: ClientCommand) -> Self {
        Command::Client(c)
    }
}

pub fn parse_args(app: App) -> Result<Options, String> {
    let args = add_options(app).get_matches();
    parse_args_from(args)
}

fn parse_args_from(args: ArgMatches) -> Result<Options, String> {
    let url_string_value = args.value_of("url");

    Ok(Options {
        url_string: url_string_value.map(ToString::to_string),
        command: match args.subcommand() {
            ("rest-api", Some(args)) => parse_rest_api(args)?,
            ("status", Some(args)) => parse_status(args),
            ("ack", Some(args)) => parse_ack(args),
            ("host-notification", Some(args)) => parse_host_notification(args),
            ("kwallet", Some(args)) => match args.subcommand() {
                ("write-password", _) => KWalletCommand::WritePassword.into(),
                ("read-password", _) => KWalletCommand::ReadPassword.into(),
                ("remove-password", _) => KWalletCommand::RemovePassword.into(),
                _ => {
                    Err("kwallet subcommand required. Try --help for more information".to_string())?
                }
            },
            _ => Err("Subcommand required. Try --help for more information".to_string())?,
        },
    })
}

fn parse_rest_api(args: &ArgMatches) -> Result<Command, String> {
    let method = Method::try_from(args.value_of("method").unwrap())?;
    let read_body = args.is_present("body");
    let path = args.value_of("path").unwrap().to_owned();
    Ok(ClientCommand::RestApi {
        method,
        read_body,
        path,
    }
    .into())
}

fn parse_status(args: &ArgMatches) -> Command {
    let verbose = args.is_present("verbose");
    ClientCommand::Status { verbose }.into()
}

fn parse_ack(args: &ArgMatches) -> Command {
    let target = if args.is_present("host") {
        AckTarget::Host(args.value_of("host").unwrap().to_owned())
    } else {
        AckTarget::Service(args.value_of("service").unwrap().to_owned())
    };
    let until = args.value_of("until").map(|v| v.parse().unwrap());
    let comment = args.value_of("comment").map(str::to_owned);
    ClientCommand::Ack {
        target,
        until,
        comment,
    }
    .into()
}

fn parse_host_notification(args: &ArgMatches) -> Command {
    let host = args.value_of("host").unwrap().to_owned();
    let comment = args.value_of("comment").unwrap().to_owned();
    ClientCommand::HostNotification { host, comment }.into()
}

fn add_options<'a, 'b>(app: App<'a, 'b>) -> App<'a, 'b> {
    app.arg(Arg::with_name("url").required(false).index(1))
        .subcommand(
            SubCommand::with_name("rest-api")
                .about("Make a custom request to the REST API and print the result")
                .arg(
                    Arg::with_name("body")
                        .long("--read-body")
                        .required(false)
                        .help("read the request body from stdin"),
                )
                .arg(
                    Arg::with_name("method")
                        .short("X")
                        .required(false)
                        .default_value("GET")
                        .number_of_values(1)
                        .validator(|arg| {
                            if arg == "GET" || arg == "POST" {
                                Ok(())
                            } else {
                                Err(String::from("method should be either GET or POST"))
                            }
                        }),
                )
                .arg(Arg::with_name("path").required(true).index(1)),
        )
        .subcommand(
            SubCommand::with_name("status")
                .about("Summary of the overall monitoring status")
                .arg(Arg::with_name("verbose").short("v").long("--verbose")),
        )
        .subcommand(
            SubCommand::with_name("ack")
                .about("Acknowledge a host or service")
                .arg(Arg::with_name("host").long("--host").number_of_values(1))
                .arg(
                    Arg::with_name("service")
                        .long("--service")
                        .number_of_values(1),
                )
                .group(
                    ArgGroup::with_name("target")
                        .args(&["host", "service"])
                        .required(true),
                )
                .arg(
                    Arg::with_name("until")
                        .long("--until")
                        .number_of_values(1)
                        .validator(|v| {
                            v.parse::<u64>()
                                .map(|_| ())
                                .map_err(|_| "could not parse unix-second timestamp".to_owned())
                        }),
                )
                .arg(
                    Arg::with_name("comment")
                        .long("--comment")
                        .number_of_values(1),
                ),
        )
        .subcommand(
            SubCommand::with_name("host-notification")
                .about("Send a custom notification for a host")
                .arg(
                    Arg::with_name("host")
                        .long("--host")
                        .required(true)
                        .number_of_values(1),
                )
                .arg(
                    Arg::with_name("comment")
                        .long("--comment")
                        .required(true)
                        .number_of_values(1),
                ),
        )
        .subcommand(
            SubCommand::with_name("kwallet")
                .about("edit kwallet passwords for host")
                .subcommand(SubCommand::with_name("read-password"))
                .subcommand(SubCommand::with_name("write-password"))
                .subcommand(SubCommand::with_name("remove-password")),
        )
}

#[cfg(test)]
mod test {
    use super::*;

    const STATUS_COMMAND: Command = Command::Client(ClientCommand::Status { verbose: false });

    #[test]
    fn test_url_present() {
        let options = parse_args_from(
            add_options(App::new("test"))
                .get_matches_from_safe(vec!["icinga-client", "my-server", "status"])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(
            options,
            Options {
                url_string: Some("my-server".to_string()),
                command: STATUS_COMMAND
            }
        )
    }

    #[test]
    fn test_url_absent() {
        let options = parse_args_from(
            add_options(App::new("test"))
                .get_matches_from_safe(vec!["icinga-client", "status"])
                .unwrap(),
        )
        .unwrap();
        assert_eq!(
            options,
            Options {
                url_string: None,
                command: STATUS_COMMAND
            }
        )
    }
}