imgup 4.0.0-alpha.5

Upload images via APIs
mod cli;
mod format;
mod image;
mod upload;
mod util;

use std::path::Path;
use std::process::ExitCode;

use anyhow::{Context, Result};
use clap::Parser;
use futures::stream::{self, StreamExt};
use tracing::level_filters::LevelFilter;
use tracing::{error, info};
use tracing_subscriber::fmt;

use crate::cli::Args;
use crate::format::{Format, LinkPair, format_links};
use crate::upload::Hosting;

#[tokio::main]
async fn main() -> ExitCode {
    let args = Args::parse();
    init_logging(args.verbose);

    let default_env_path = util::get_config_path();
    let env_path = args.env_file.as_ref().or(default_env_path.as_ref());

    if let Some(path) = env_path
        && path.exists()
        && let Err(e) = dotenvy::from_path(path)
    {
        error!("failed to load env file {}: {e}", path.display());
    }

    match run(&args).await {
        Ok(code) => code,
        Err(e) => {
            error!("{e:#}");
            ExitCode::FAILURE
        }
    }
}

/// Upload a single image (and its thumbnail if requested).
async fn upload_single(
    client: &reqwest::Client,
    hosting: Hosting,
    path: &Path,
    font: Option<&image::Font>,
) -> Option<LinkPair> {
    let data = tokio::fs::read(path)
        .await
        .inspect_err(|e| error!("failed to read {}: {e}", path.display()))
        .ok()?;

    let thumb_data = font
        .map(|f| image::make_thumbnail(&data, f))
        .transpose()
        .inspect_err(|e| error!("thumbnail failed for {}: {e:#}", path.display()))
        .ok()?;

    let img_url = upload::upload(client, hosting, data)
        .await
        .inspect_err(|e| error!("upload failed for {}: {e:#}", path.display()))
        .ok()?;
    info!("uploaded \"{}\" -> {img_url}", path.display());

    let thumb_url = match thumb_data {
        Some(td) => Some(
            upload::upload(client, hosting, td)
                .await
                .inspect_err(|e| {
                    error!("thumbnail upload failed for {}: {e:#}", path.display());
                })
                .ok()?,
        ),
        None => None,
    };

    Some((img_url, thumb_url))
}

async fn run(args: &Args) -> Result<ExitCode> {
    let client = reqwest::Client::builder()
        .user_agent(concat!(
            env!("CARGO_PKG_NAME"),
            "/",
            env!("CARGO_PKG_VERSION")
        ))
        .timeout(std::time::Duration::from_secs(120))
        .build()
        .context("failed to build HTTP client")?;

    let font = args.thumbnail.then(image::get_font);

    let results: Vec<_> = stream::iter(&args.images)
        .map(|path| upload_single(&client, args.hosting, path, font.as_ref()))
        .buffered(args.jobs.get())
        .collect()
        .await;

    let total = results.len();
    let links: Vec<LinkPair> = results.into_iter().flatten().collect();
    let has_errors = links.len() < total;

    if !links.is_empty() {
        // Thumbnail mode defaults to bbcode
        let fmt = if args.thumbnail && matches!(args.format, Format::Plain) {
            Format::Bbcode
        } else {
            args.format
        };

        let output = format_links(&links, fmt);
        println!("{output}");

        if !args.no_clipboard {
            util::clipboard_copy(&output);
        }
        if args.notify {
            util::notify_send(&output);
        }
    }

    Ok(if has_errors {
        ExitCode::FAILURE
    } else {
        ExitCode::SUCCESS
    })
}

fn init_logging(verbose: u8) {
    let filter = match verbose {
        0 => LevelFilter::WARN,
        1 => LevelFilter::INFO,
        _ => LevelFilter::DEBUG,
    };

    fmt()
        .with_max_level(filter)
        .with_target(false)
        .with_writer(std::io::stderr)
        .init();
}