perma 0.1.0

CLI for editing redirects in permalink service
use std::{io::Write, path::Path, process::ExitCode};

use clap::{Parser, Subcommand};
use directories::ProjectDirs;
use edit::edit;
use perma_core::{Redirect, RedirectParseError, Uri};
use serde::{Deserialize, Serialize};

#[derive(Parser, Debug, Clone)]
struct Cli {
    #[clap(subcommand)]
    command: Command,
}

#[derive(Subcommand, Debug, Clone)]
enum Command {
    /// Authenticate to a perma worker
    Auth {
        #[clap(short, long)]
        server: Uri,
    },

    /// Edit remote `_redirects`
    Edit,
}

#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct Config {
    #[serde(default)]
    server: Option<Uri>,

    #[serde(default)]
    psk: Option<String>,
}

fn read_config(config_dir: &Path) -> Option<Config> {
    toml::from_str(&std::fs::read_to_string(config_dir.join("config.toml")).ok()?).ok()
}

fn write_config(config_dir: &Path, config: &Config) -> std::io::Result<()> {
    std::fs::write(
        config_dir.join("config.toml"),
        toml::to_string_pretty(config).unwrap(),
    )
}

fn parse_redirects(raw: &str) -> Result<Vec<Redirect>, (&str, RedirectParseError)> {
    raw.lines()
        .map(|l| l.trim())
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(|l| l.parse::<Redirect>().map_err(|e| (l, e)))
        .collect::<Result<Vec<_>, _>>()
}

struct Client {
    base: Uri,
    psk: String,
    agent: ureq::Agent,
}

impl Client {
    fn new(base: Uri, psk: String) -> Self {
        Self {
            base,
            psk,
            agent: ureq::Agent::new_with_defaults(),
        }
    }

    fn get_raw_redirects(&self) -> Result<Option<String>, ureq::Error> {
        let file_url = self.base.join("/api/v1/_redirects").unwrap();

        let result = self
            .agent
            .get(file_url.as_str())
            .header("Authorization", format!("Bearer sk-{}", self.psk))
            .header("Accept", "text/redirects")
            .call();

        match result {
            Ok(mut res) => Ok(Some(res.body_mut().read_to_string()?)),
            Err(err) => match err {
                ureq::Error::StatusCode(code) if code == 404 => Ok(None),
                _ => Err(err),
            },
        }
    }

    fn put_raw_redirects(&self, s: impl AsRef<str>) -> Result<(), ureq::Error> {
        let file_url = self.base.join("/api/v1/_redirects").unwrap();

        self.agent
            .put(file_url.as_str())
            .header("Authorization", format!("Bearer sk-{}", self.psk))
            .content_type("text/redirects")
            .send(s.as_ref().as_bytes())?;

        Ok(())
    }
}

fn main() -> ExitCode {
    let cli = Cli::parse();

    let project_dirs = ProjectDirs::from("dev.workers", "perma", "perma")
        .expect("cannot initialize project directories");
    let config_dir = project_dirs.config_local_dir();
    let _ = std::fs::create_dir_all(config_dir);
    let config = read_config(&config_dir).unwrap_or_default();

    match cli.command {
        Command::Auth { server } => {
            let mut config = config;
            config.server = Some(server);

            let mut psk = String::new();
            print!("PSK: ");
            let _ = std::io::stdout().flush();
            let _ = std::io::stdin().read_line(&mut psk);

            config.psk = Some(psk.trim().to_string());

            match write_config(&config_dir, &config) {
                Ok(_) => {
                    eprintln!("Server config saved!");
                    ExitCode::SUCCESS
                }
                Err(e) => {
                    eprintln!("Cannot save config: {}", e);
                    ExitCode::FAILURE
                }
            }
        }
        Command::Edit => {
            if let (Some(server), Some(psk)) = (config.server, config.psk) {
                let client = Client::new(server, psk);
                match client.get_raw_redirects() {
                    Ok(res) => {
                        let redirects = res.unwrap_or_default();
                        match edit(&redirects) {
                            Ok(edited) => {
                                if let Ok(redirects) = parse_redirects(&edited) {
                                    println!("New redirects:");
                                    println!(
                                        "{}",
                                        redirects
                                            .iter()
                                            .map(|r| {
                                                format!(
                                                    "{} -> {} [{}]",
                                                    r.from(),
                                                    r.to(),
                                                    r.code().as_u16()
                                                )
                                            })
                                            .collect::<Vec<_>>()
                                            .join("\n")
                                    );
                                    print!("\nConfirm? [Y/n] ");
                                    let _ = std::io::stdout().flush();

                                    let mut answer = String::new();
                                    std::io::stdin().read_line(&mut answer).unwrap();
                                    let answer = answer.trim().to_string();
                                    if answer.is_empty() || answer.eq_ignore_ascii_case("y") {
                                        match client.put_raw_redirects(&edited) {
                                            Ok(_) => {
                                                println!("\nUpdate redirects successfully");
                                                ExitCode::SUCCESS
                                            }
                                            Err(e) => {
                                                eprintln!("\nCannot update redirects: {}", e);
                                                ExitCode::FAILURE
                                            }
                                        }
                                    } else {
                                        eprintln!("\nUser cancelled");
                                        ExitCode::SUCCESS
                                    }
                                } else {
                                    ExitCode::FAILURE
                                }
                            }
                            Err(e) => {
                                eprintln!("Edit failed: {}", e);
                                ExitCode::FAILURE
                            }
                        }
                    }
                    Err(e) => {
                        eprintln!("Cannot get redirects: {}", e);
                        ExitCode::FAILURE
                    }
                }
            } else {
                eprintln!(
                    "Please configure CLI with `{} auth` first.",
                    std::env::args().next().unwrap()
                );
                ExitCode::FAILURE
            }
        }
    }
}