catbox 0.8.2

Implementation of Catbox.moe's API
Documentation
use std::{env, error::Error, path::Path};

use args::{
    Album, AlbumAdd, AlbumCommand, AlbumCreate, AlbumDelete, AlbumEdit, AlbumRemove, CatboxArgs,
    CatboxCommand, Delete, Litter, Upload,
};

use clap::Parser;
use futures::stream::{FuturesUnordered, StreamExt};
use regex::Regex;
use url::Url;

#[cfg(not(test))]
use catbox::{album, file, litter};
#[cfg(test)]
mod test;
#[cfg(test)]
use test::catbox::{album, file, litter};

mod args;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    match CatboxArgs::try_parse() {
        Ok(args) => match args.command {
            CatboxCommand::Upload(sub_args) => upload(sub_args).await,
            CatboxCommand::Delete(sub_args) => delete_file(sub_args).await,
            CatboxCommand::Album(sub_args) => parse_album(sub_args).await,
            CatboxCommand::Litter(sub_args) => litter(sub_args).await,
        },
        Err(args) => {
            println!("{}", args);
            Ok(())
        },
    }
}

fn user_hash_from_env() -> Option<String> {
    env::var("CATBOX_USER_HASH").ok()
}

fn catbox_url_to_image_name<'a>(url: &'a str) -> String {
    let re = Regex::new(r"^(http[s]?://)?files.catbox.moe/.+").unwrap();
    match re.is_match(url) {
        true => url.split("/").last().unwrap().to_string(),
        false => url.to_string(),
    }
}

fn album_url_to_short<'a>(url: &'a str) -> String {
    let re = Regex::new(r"^(http[s]?://)?catbox.moe/c/.+").unwrap();
    match re.is_match(url) {
        true => url.split("/").last().unwrap().to_string(),
        false => url.to_string(),
    }
}

async fn parse_album(album_args: Album) -> Result<(), Box<dyn Error>> {
    match album_args.album_command {
        AlbumCommand::Create(sub_args) => create_album(sub_args).await,
        AlbumCommand::Delete(sub_args) => delete_album(sub_args).await,
        AlbumCommand::Edit(sub_args) => edit_album(sub_args).await,
        AlbumCommand::Add(sub_args) => add_to_album(sub_args).await,
        AlbumCommand::Remove(sub_args) => remove_from_album(sub_args).await,
    }
}

async fn upload(upload_args: Upload) -> Result<(), Box<dyn Error>> {
    let (files, rest): (Vec<_>, _) = upload_args
        .files
        .into_iter()
        .partition(|uri| Path::new(&uri).exists());
    let (urls, rest): (Vec<_>, _) = rest.iter().partition(|uri| Url::parse(uri).is_ok());
    let env_user = user_hash_from_env();
    let user = upload_args.user_hash.or(env_user);
    let print_result = |res| async move { println!("{}", res) };

    tokio::join!(
        rest.into_iter()
            .map(|uri| invalid_uri(uri.to_string()))
            .collect::<FuturesUnordered<_>>()
            .for_each_concurrent(10, print_result),
        urls.into_iter()
            .map(|url| upload_url(url.to_string(), &user))
            .collect::<FuturesUnordered<_>>()
            .for_each_concurrent(10, print_result),
        files
            .into_iter()
            .map(|file| upload_file(file, &user))
            .collect::<FuturesUnordered<_>>()
            .for_each_concurrent(10, print_result),
    );
    Ok(())
}

async fn invalid_uri(uri: String) -> String {
    format!("Ignoring {}: Not a file or valid URL", uri)
}

async fn upload_file(file: String, user_hash: &Option<String>) -> String {
    match file::from_file(file.clone(), user_hash.as_ref().cloned()).await {
        Ok(res) => res,
        Err(_) => format!("Uploading {} failed.", &file),
    }
}

async fn upload_url(url: String, user_hash: &Option<String>) -> String {
    match file::from_url(&url, user_hash.clone().as_ref()).await {
        Ok(res) => res,
        Err(_) => format!("Uploading {} failed.", url),
    }
}

async fn upload_to_litter(file_path: String, time: u8) -> String {
    match litter::upload(&file_path, time).await {
        Ok(res) => res,
        Err(_) => format!("Uploading {} failed.", file_path),
    }
}

async fn delete_file(delete_args: Delete) -> Result<(), Box<dyn Error>> {
    let res = file::delete(
        delete_args
            .user_hash
            .unwrap_or(user_hash_from_env().as_deref().unwrap_or("").to_string()),
        delete_args
            .files
            .into_iter()
            .map(|file| catbox_url_to_image_name(&file))
            .collect(),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

async fn litter(litter_args: Litter) -> Result<(), Box<dyn Error>> {
    let (files, rest): (Vec<_>, _) = litter_args
        .files
        .into_iter()
        .partition(|path| Path::new(&path).exists());
    let print_res = |res| async move { println!("{}", res) };
    tokio::join!(
        rest.into_iter()
            .map(|uri| invalid_uri(uri))
            .collect::<FuturesUnordered<_>>()
            .for_each_concurrent(10, print_res),
        files
            .into_iter()
            .map(|file| upload_to_litter(file, litter_args.time.unwrap_or(1)))
            .collect::<FuturesUnordered<_>>()
            .for_each_concurrent(10, print_res),
    );
    Ok(())
}

async fn create_album(album_create_args: AlbumCreate) -> Result<(), Box<dyn Error>> {
    let res = album::create(
        album_create_args.title,
        album_create_args.description.unwrap_or_default(),
        album_create_args.user_hash.or(user_hash_from_env()),
        album_create_args
            .files
            .into_iter()
            .map(|file| catbox_url_to_image_name(&file))
            .collect(),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

async fn delete_album(album_delete_args: AlbumDelete) -> Result<(), Box<dyn Error>> {
    let res = album::delete(
        album_url_to_short(&album_delete_args.short),
        album_delete_args
            .user_hash
            .unwrap_or(user_hash_from_env().unwrap_or(String::new())),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

async fn edit_album(album_edit_args: AlbumEdit) -> Result<(), Box<dyn Error>> {
    let res = album::edit(
        album_url_to_short(&album_edit_args.short),
        album_edit_args.title,
        album_edit_args.description.unwrap_or_default(),
        album_edit_args
            .user_hash
            .unwrap_or(user_hash_from_env().unwrap_or(String::new())),
        album_edit_args
            .files
            .into_iter()
            .map(|file| catbox_url_to_image_name(&file))
            .collect(),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

async fn add_to_album(album_add_args: AlbumAdd) -> Result<(), Box<dyn Error>> {
    let res = album::add_files(
        album_url_to_short(&album_add_args.short),
        album_add_args
            .user_hash
            .unwrap_or(user_hash_from_env().unwrap_or(String::new())),
        album_add_args
            .files
            .into_iter()
            .map(|file| catbox_url_to_image_name(&file))
            .collect(),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

async fn remove_from_album(album_remove_args: AlbumRemove) -> Result<(), Box<dyn Error>> {
    let res = album::remove_files(
        album_url_to_short(&album_remove_args.short),
        album_remove_args
            .user_hash
            .unwrap_or(user_hash_from_env().unwrap_or(String::new())),
        album_remove_args
            .files
            .into_iter()
            .map(|file| catbox_url_to_image_name(&file))
            .collect(),
    )
    .await?;
    println!("{}", res);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::Builder;

    static FILE_URL: &str =
        "https://file-examples.com/wp-content/uploads/2017/10/file_example_JPG_100kB.jpg";

    #[tokio::test]
    async fn upload_file() -> Result<(), Box<dyn Error>> {
        let mut file = Builder::new().suffix(".txt").tempfile().unwrap();
        write!(file, "content").unwrap();

        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "upload",
            "--user",
            "123345",
            file.path().to_str().unwrap(),
        ]);

        if let CatboxCommand::Upload(upload_args) = args.command {
            upload(upload_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn upload_url() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec!["catbox", "upload", "--user", "123345", FILE_URL]);

        if let CatboxCommand::Upload(upload_args) = args.command {
            upload(upload_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn nonexistant() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "upload",
            "--user",
            "123345",
            "This is not a file or url",
        ]);

        if let CatboxCommand::Upload(upload_args) = args.command {
            upload(upload_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn upload_multi() -> Result<(), Box<dyn Error>> {
        let mut file = Builder::new().suffix(".txt").tempfile().unwrap();
        write!(file, "content").unwrap();

        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "upload",
            "--user",
            "123345",
            file.path().to_str().unwrap(),
            FILE_URL,
            "Something",
            "Something else",
        ]);

        if let CatboxCommand::Upload(upload_args) = args.command {
            upload(upload_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn delete_files() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "delete",
            "--user",
            "123345",
            "file.png",
            "another.jpg",
        ]);

        if let CatboxCommand::Delete(delete_args) = args.command {
            delete_file(delete_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn album_create() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "album",
            "create",
            "--desc",
            "A description",
            "--title",
            "My album",
            "--user",
            "123345",
            "file.png",
            "another.jpg",
        ]);

        if let CatboxCommand::Album(album_args) = args.command {
            parse_album(album_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn album_add() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox", "album", "add", "--user", "123345", "--short", "123asd", "file.png",
        ]);

        if let CatboxCommand::Album(album_args) = args.command {
            parse_album(album_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn album_remove() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox", "album", "remove", "--user", "123345", "--short", "123asd", "file.png",
        ]);

        if let CatboxCommand::Album(album_args) = args.command {
            parse_album(album_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn album_delete() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox", "album", "delete", "--user", "123345", "asd123",
        ]);

        if let CatboxCommand::Album(album_args) = args.command {
            parse_album(album_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn album_edit() -> Result<(), Box<dyn Error>> {
        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "album",
            "edit",
            "--desc",
            "A description",
            "--title",
            "My Album",
            "--user",
            "123345",
            "--short",
            "asd123",
            "file.png",
            "another.jpg",
        ]);

        if let CatboxCommand::Album(album_args) = args.command {
            parse_album(album_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    async fn upload_litter() -> Result<(), Box<dyn Error>> {
        let mut file = Builder::new().suffix(".txt").tempfile().unwrap();
        write!(file, "content").unwrap();

        let args = CatboxArgs::parse_from(vec![
            "catbox",
            "litter",
            "--time",
            "1",
            file.path().to_str().unwrap(),
        ]);

        if let CatboxCommand::Litter(litter_args) = args.command {
            litter(litter_args).await?;
        } else {
            panic!("Invalid subcommand");
        }

        Ok(())
    }

    #[tokio::test]
    #[should_panic]
    async fn invalid_command() {
        match CatboxArgs::try_parse() {
            Ok(args) => {
                if let CatboxCommand::Album(album_args) = args.command {
                    let _ = parse_album(album_args).await;
                }
            },
            Err(_) => panic!("Invalid subcommand"),
        }
    }
}