sneakerweb 1.2.0

A parallel web transported by physical media
use anyhow::{Context, Result, bail};
use base32::Alphabet;
use colored::{Color, Colorize};
use smol::{Unblock, fs::*, io::AsyncWriteExt, unblock};
use std::{
    env::{home_dir, var_os},
    io::{Error, ErrorKind, Write, stderr, stdin, stdout},
    path::PathBuf,
};
use willow25::prelude::*;

pub const SNEAKERWEB_NAMESPACE_ID_BYTES: [u8; NAMESPACE_ID_WIDTH] = [
    159, 196, 204, 134, 202, 217, 77, 17, 2, 90, 252, 247, 94, 13, 171, 36, 188, 60, 108, 145, 240,
    205, 146, 251, 224, 202, 87, 77, 70, 156, 104, 30,
];

pub const _SNEAKERWEB_SERVICE_NAME: &str = "org.worm-blossom.sneakerweb";

pub const BASE32_ALPHABET: Alphabet = Alphabet::Rfc4648Lower { padding: false };

/// Requests input from the user, displaying a message and optionally styling the prompt for reply.
pub fn user_input<F>(message: &str, prompt_style: Option<F>) -> Result<Option<String>, Error>
where
    F: Fn(&str) -> String,
{
    println!("{}", message);
    match prompt_style {
        Some(style) => print!("{} ", style(">")),
        None => print!("{} ", ">".bold()),
    }
    stdout().flush()?;
    stdin().lines().next().transpose()
}

/// Retrieves the path where a sneakerweb collection's configuration and data are located.
pub async fn sneakerweb_dir(collection: Option<&PathBuf>) -> Result<PathBuf, Error> {
    if let Some(dir) = collection {
        create_dir_all(&dir).await?;
        return Ok(dir.into());
    }

    if let Some(default) = unblock(|| var_os("DEFAULT_SNEAKERWEB_COLLECTION")).await {
        create_dir_all(&default).await?;
        return Ok(default.into());
    }

    let mut dir = home_dir().ok_or(Error::new(
        ErrorKind::NotFound,
        "could not determine user home directory",
    ))?;
    dir.push(".sneakerweb");
    create_dir_all(&dir).await?;
    Ok(dir)
}

/// Styles text using the conventions for sneakerweb domains.
pub fn domain_style(message: &str) -> String {
    format!("{}", message.bold().color(Color::Green))
}

/// Styles text using the conventions for sneakerweb secrets.
pub fn secret_style(message: &str) -> String {
    format!("{}", message.bold().color(Color::Magenta))
}

/// Styles text celebratorily.
pub fn yay_style(message: &str) -> String {
    format!("{}", message.bold().color(Color::Green))
}

/// Styles text commiseratively.
pub fn oh_no_style(message: &str) -> String {
    format!("{}", message.bold().color(Color::Magenta))
}

/// Styles text contemplatively.
pub fn hmm_style(message: &str) -> String {
    format!("{}", message.bold().color(Color::Yellow))
}

/// Styles text emphatically.
pub fn emph_style(message: &str) -> String {
    format!("{}", message.bold())
}

/// Celebrate something with the user.
pub async fn yay(message: &str) {
    let mut stdout = Unblock::new(stdout());
    let _ = stdout
        .write_all(format!("{} {}\n", yay_style("Yay:"), message).as_bytes())
        .await;
    let _ = stdout.flush().await;
}

/// Commiserate something with the user.
pub async fn oh_no(message: &str) {
    let mut stderr = Unblock::new(stderr());
    let _ = stderr
        .write_all(format!("{} {}\n", oh_no_style("Oh No:"), message).as_bytes())
        .await;
    let _ = stderr.flush().await;
}

/// Contemplate something with the user.
pub async fn hmm(message: &str) {
    let mut stdout = Unblock::new(stdout());
    let _ = stdout
        .write_all(format!("{} {}\n", hmm_style("Hmm:"), message).as_bytes())
        .await;
    let _ = stdout.flush().await;
}

/// Decodes a [`SubspaceId`] "domain" from a provided base-16 or base-32 string.
pub fn parse_domain(encoded: &str) -> Result<(SubspaceId, String)> {
    let decoded = decode_encoded(encoded).context("invalid domain")?;
    Ok((SubspaceId::from_bytes(&decoded), encoded.to_owned()))
}

/// Decodes a [`SubspaceId`] "domain" from a provided base-16 or base-32 string, or requests such a string from the user if none is provided.
pub fn get_domain(encoded: Option<&str>, action: &str) -> Result<(SubspaceId, String)> {
    let domain = match encoded {
        Some(domain) => domain.trim().to_owned(),
        None => user_input(
            &format!(
                "Which {} do you want to {}?",
                domain_style("domain"),
                action
            ),
            Some(domain_style),
        )
        .context("failed to retrieve user input")?
        .unwrap_or_default(),
    };

    parse_domain(&domain)
}

/// Decodes a [`SubspaceSecret`] for some `domain` from a provided base-16 or base-32 string, or requests such a string from the user if none is provided.
pub fn get_secret(encoded: Option<&str>, domain: &str) -> Result<SubspaceSecret> {
    let secret_string = match encoded {
        Some(secret) => secret.to_owned(),
        None => {
            // TODO: Should we bring in, e.g., the `rpassword` crate to hide the secret key?
            user_input(
                &format!(
                    "Please provide the {} for {}:",
                    secret_style("secret key"),
                    domain_style(domain)
                ),
                Some(secret_style),
            )
            .context("failed to retrieve user input")?
            .unwrap_or_default()
        }
    };

    let decoded = decode_encoded(&secret_string).context("invalid secret")?;

    Ok(SubspaceSecret::from_bytes(&decoded))
}

fn decode_encoded(s: &str) -> Result<[u8; 32]> {
    let b32_alphabet = base32::Alphabet::Rfc4648Lower { padding: false };
    let Some(decoded) = (match s.len() {
        64 => base16::decode(s).ok(),
        52 => base32::decode(b32_alphabet, s),
        _ => None,
    }) else {
        bail!("invalid encoding")
    };
    if decoded.len() != 32 {
        bail!("invalid encoding")
    }

    Ok(*decoded.as_array().expect("expected 32 bytes"))
}

pub async fn warn_deprecated_domain_encoding(encoding: &str, domain_id: &SubspaceId) {
    if encoding.len() == 64 {
        let updated_encoding = base32::encode(BASE32_ALPHABET, domain_id.as_bytes());
        hmm(&format!("deprecated domain name {encoding}, consider replacing with the equivalent {updated_encoding}")).await
    }
}