wepub 0.6.0

CLI to publish browser extensions to Chrome Web Store, Firefox Add-ons, and Edge Add-ons
use std::collections::HashMap;
use std::path::Path;

use anyhow::{Context, Result, bail};
use wepub_core::firefox::{
    Application, Channel, Client, Compatibility, Credentials, PublishOptions,
};

use crate::cli::{FirefoxApplicationArg, FirefoxArgs, FirefoxChannelArg};
use crate::commands::common::read_text_input;

pub async fn run(args: FirefoxArgs) -> Result<()> {
    if is_stdin_path(args.release_notes_file.as_deref())
        && is_stdin_path(args.approval_notes_file.as_deref())
    {
        bail!(
            "--release-notes-file and --approval-notes-file cannot both read from stdin (\"-\"); \
             stdin is a single stream"
        );
    }

    let zip = tokio::fs::read(&args.zip)
        .await
        .with_context(|| format!("failed to read archive from {}", args.zip.display()))?;

    let release_notes = load_release_notes(&args).await?;
    let approval_notes = load_approval_notes(&args).await?;
    let source = match &args.source {
        Some(path) => Some(
            tokio::fs::read(path)
                .await
                .with_context(|| format!("failed to read source from {}", path.display()))?,
        ),
        None => None,
    };

    let mut client = Client::new(
        args.addon_id,
        Credentials {
            api_key: args.api_key,
            api_secret: args.api_secret,
        },
    )?;
    if let Some(root_url) = args.internal_root_url {
        client = client.with_root_url(root_url.as_str())?;
    }

    let options = PublishOptions {
        compatibility: build_compatibility(&args.compatibility),
        release_notes,
        approval_notes,
        source,
    };

    client
        .publish(zip, args.channel.into(), options)
        .await
        .context("Firefox Add-ons publish failed")?;
    Ok(())
}

fn is_stdin_path(path: Option<&Path>) -> bool {
    path.is_some_and(|p| p.as_os_str() == "-")
}

async fn load_release_notes(args: &FirefoxArgs) -> Result<Option<HashMap<String, String>>> {
    let text =
        match (&args.release_notes, &args.release_notes_file) {
            (Some(text), _) => Some(text.clone()),
            (_, Some(path)) => Some(read_text_input(path).await.with_context(|| {
                format!("failed to read release notes from {}", path.display())
            })?),
            _ => None,
        };
    Ok(text.map(|t| HashMap::from([(args.release_notes_lang.clone(), t)])))
}

async fn load_approval_notes(args: &FirefoxArgs) -> Result<Option<String>> {
    match (&args.approval_notes, &args.approval_notes_file) {
        (Some(text), _) => Ok(Some(text.clone())),
        (_, Some(path)) => Ok(Some(read_text_input(path).await.with_context(|| {
            format!("failed to read approval notes from {}", path.display())
        })?)),
        _ => Ok(None),
    }
}

fn build_compatibility(apps: &[FirefoxApplicationArg]) -> Option<Compatibility> {
    if apps.is_empty() {
        return None;
    }
    let mut seen = std::collections::HashSet::new();
    let unique: Vec<Application> = apps
        .iter()
        .copied()
        .map(Into::into)
        .filter(|app| seen.insert(*app))
        .collect();
    Some(Compatibility::Shorthand(unique))
}

impl From<FirefoxChannelArg> for Channel {
    fn from(value: FirefoxChannelArg) -> Self {
        match value {
            FirefoxChannelArg::Listed => Channel::Listed,
            FirefoxChannelArg::Unlisted => Channel::Unlisted,
        }
    }
}

impl From<FirefoxApplicationArg> for Application {
    fn from(value: FirefoxApplicationArg) -> Self {
        match value {
            FirefoxApplicationArg::Firefox => Application::Firefox,
            FirefoxApplicationArg::Android => Application::Android,
        }
    }
}