neo 0.3.3

API wrapper & CLI tool for Neocities
Documentation
use anyhow::Context;
use clap::{Arg, ArgAction, Command};
use log::{debug, info, trace, warn};
use std::path::{Path, PathBuf};

// Note that this is different than the error enum in lib.rs
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error(transparent)]
    Reporable(#[from] anyhow::Error),
}

fn main() -> Result<(), Error> {
    pretty_env_logger::init();

    trace!("main() [neo]");

    let app_config = config::Config::build().context("build config")?;
    let default_site = app_config.default_site;

    debug!("defualt site: {:?}", default_site);

    let matches = Command::new(env!("CARGO_PKG_NAME"))
        .version(env!("CARGO_PKG_VERSION"))
        .author(env!("CARGO_PKG_AUTHORS"))
        .about("CLI interface for managing Neocities websites (https://neocities.org)")
        .arg(
            Arg::new("site")
                .short('s')
                .help("Set site name explicitly")
                .required(false),
        )
        .arg(
            Arg::new("user")
                .short('u')
                .help("Set a username different from site name")
                .required(false),
        )
        .arg(
            Arg::new("password")
                .short('p')
                .help("Provide password explicitly (will prompt if omitted)")
                .required(false),
        )
        .arg(
            Arg::new("verbose")
                .short('v')
                .action(ArgAction::Count)
                .help("Sets the level of verbosity (max 4)"),
        )
        .arg(
            Arg::new("no-interactive")
                .short('n')
                .action(ArgAction::SetTrue)
                .help("Don't attempt to prompt for user or password, just fail"),
        )
        .subcommand(Command::new("info").about("Fetch site info"))
        .subcommand(Command::new("list").about("List site files").alias("ls"))
        .subcommand(
            Command::new("upload")
                .about("Upload file to site")
                .arg(
                    Arg::new("FILE")
                        .help("The local file to upload")
                        .required(true)
                        .index(1),
                )
                .arg(
                    Arg::new("PATH")
                        .help("The remote path where the file is to be placed")
                        .required(false)
                        .index(2),
                ),
        )
        .subcommand(
            Command::new("delete")
                .about("Delete file from site")
                .alias("rm")
                .arg(
                    Arg::new("PATH")
                        .help("The path of the remote file to delete")
                        .required(true)
                        .index(1),
                ),
        )
        .subcommand_required(true)
        .get_matches();

    // TODO: Set verbosity manually
    match matches.get_count("verbose") {
        0 => warn!("Verbosity: WARN"),
        1 => info!("Verbosity: INFO"),
        2 => debug!("Verbosity: DEBUG"),
        3 => trace!("Verbosity: TRACE"),
        _ => println!("Don't be crazy"),
    }

    let no_interactive = matches.get_flag("no-interactive");

    let site = if let Some(site) = matches.get_one::<String>("site") {
        site.to_string()
    } else if let Some(site) = default_site {
        site
    } else if no_interactive {
        panic!("no site")
    } else if let Ok(site) = rprompt::prompt_reply("site: ") {
        site
    } else {
        panic!("no site")
    };
    debug!("site: {}", site);

    let auth = if let Some(password) = matches.get_one::<String>("password") {
        neo::site::Auth::Password(neo::site::Password {
            user: site,
            password: password.to_string(),
        })
    } else if let Some(auth) = app_config.sites.get(&site) {
        match auth {
            config::Auth::Password(ref password) => {
                neo::site::Auth::Password(neo::site::Password {
                    user: site.clone(),
                    password: password.password.to_owned(),
                })
            }
            config::Auth::Key(ref key) => neo::site::Auth::Key(neo::site::Key {
                key: key.key.to_owned(),
            }),
        }
    } else if no_interactive {
        panic!("no password")
    } else {
        panic!("unimplemented");
        //debug!("will prompt for password");
        //if let Ok(password) = rpassword::prompt_password_stdout("password: *typing not shown*") {
        //    debug!("got {}", password);
        //    Ok(password)
        //} else {
        //    Err("needs password")
        //}
    };
    debug!("auth: {:?}", auth);

    let site = neo::Site::new(auth);

    match matches.subcommand() {
        Some(("info", _)) => {
            let info = site.info().context("list")?;
            println!("{:?}", info);
        }
        Some(("list", _)) => {
            let list = site.list().context("list")?;
            println!("{:?}", list);
        }
        Some(("upload", matches)) => {
            let root_path = { app_config.site_root };

            let file_str = matches
                .get_one::<String>("FILE")
                .expect("the required paramter FILE was somehow none");
            let path_str = match matches.get_one::<String>("PATH") {
                Some(s) => s.to_string(),
                None => match root_path {
                    Some(root_path) => {
                        let rel_path = to_root_relative_path(root_path.as_str(), file_str)?;
                        rel_path
                            .to_str()
                            .map(|s| s.to_string())
                            .context("invalid filename")?
                    }
                    None => file_str.to_string(),
                },
            };

            debug!("upload: {} to {}", file_str, path_str);
            site.upload(path_str, file_str.into()).context("upload")?;
        }
        Some(("delete", matches)) => {
            let root_path = { app_config.site_root };

            let path_str = matches
                .get_one::<String>("PATH")
                .expect("the required paramter PATH was somehow none");

            info!("delete: {}", path_str);

            let final_path = {
                if path_str.starts_with(':') {
                    // explicit path
                    path_str.get(1..).unwrap_or("").to_owned()
                } else {
                    match root_path {
                        Some(root_path) => {
                            let rel_path = to_root_relative_path(root_path, path_str)?;
                            rel_path
                                .to_str()
                                .map(|s| s.to_string())
                                .context("invalid filename")?
                        }
                        None => path_str.to_string(),
                    }
                }
            };

            info!("delete: {}", final_path);
            site.delete(vec![final_path]).context("delete")?;
        }
        _ => {
            unreachable!("clap allowed a subcommand we weren't able to handle");
        }
    }

    Ok(())
}

fn to_root_relative_path<P1: AsRef<Path>, P2: AsRef<Path>>(
    root_path: P1,
    file_path: P2,
) -> Result<PathBuf, Error> {
    let root_path = root_path
        .as_ref()
        .canonicalize()
        .context("canonicalize root path")?;
    let file_path = file_path
        .as_ref()
        .canonicalize()
        .context("canonicalize root path")?;

    debug!("root: {}", root_path.to_string_lossy());
    debug!("file: {}", file_path.to_string_lossy());

    let rel = file_path
        .strip_prefix(&root_path)
        .map(|p| p.into())
        .context("stip prefix")?;
    debug!("relative path {:?}", rel);
    Ok(rel)
}

pub mod config {
    use directories::ProjectDirs;
    use log::{info, trace};
    use serde::Deserialize;
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    #[derive(Deserialize, Debug)]
    #[serde(untagged)]
    pub enum Auth {
        Key(Key),
        Password(Password),
    }
    #[derive(Deserialize, Debug)]
    pub struct Key {
        pub key: String,
    }
    #[derive(Deserialize, Debug)]
    pub struct Password {
        pub password: String,
    }

    #[derive(Deserialize, Debug)]
    pub struct Config {
        // TODO: Why can't this be PathBuf?
        pub site_root: Option<String>,
        pub default_site: Option<String>,
        pub sites: BTreeMap<String, Auth>,
    }

    impl Config {
        pub fn build() -> Result<Self, config::ConfigError> {
            trace!("Config::build()");

            let mut s = config::Config::builder();

            let global_config_path = ProjectDirs::from("net", "azdle", "neo")
                .expect("build_paths")
                .config_dir()
                .with_file_name("conf.toml");

            s = s.add_source(config::File::from(global_config_path).required(false));

            let mut local_config_path = PathBuf::from(".").canonicalize().unwrap();
            local_config_path.push("Neo.toml"); // push initial filename
            while {
                // this is a "do {} while ()" loop.
                let config_path_attempt =
                    PathBuf::from(&local_config_path).with_file_name("Neo.toml");
                trace!("Checking {}.", config_path_attempt.to_string_lossy());
                if config_path_attempt.exists() {
                    info!(
                        "Found config file at {}",
                        config_path_attempt.to_string_lossy()
                    );
                    s = s.add_source(config::File::from(config_path_attempt).required(false));
                    local_config_path.pop();
                    s = s.set_override(
                        "site_root",
                        Some(local_config_path.to_string_lossy().into_owned()),
                    )?;
                    false // break
                } else {
                    local_config_path.pop()
                }
            } {}

            Ok(s.build()?.try_deserialize().unwrap_or_else(|_| Config {
                site_root: None,
                default_site: None,
                sites: BTreeMap::new(),
            }))
        }
    }
}