modbot 0.5.3

Discord bot for https://mod.io. ModBot provides commands to search for mods and notifications about added & edited mods.
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::fmt::{Display, Write};

use futures_util::stream::FuturesUnordered;
use futures_util::{future, TryStreamExt};
use modio::filter::prelude::*;
use modio::games::{ApiAccessOptions, Game};
use modio::mods::Mod;
use modio::Modio;
use twilight_model::application::command::{Command, CommandType};
use twilight_model::application::interaction::application_command::{
    CommandData, CommandDataOption, CommandOptionValue,
};
use twilight_model::application::interaction::Interaction;
use twilight_model::guild::Permissions;
use twilight_util::builder::command::{
    CommandBuilder, IntegerBuilder, StringBuilder, SubCommandBuilder, SubCommandGroupBuilder,
};
use twilight_util::builder::embed::{EmbedBuilder, EmbedFieldBuilder};

use super::{
    autocomplete_games, create_response, defer_ephemeral, search_game, update_response_content,
    update_response_from_content, AutocompleteExt, EphemeralMessage, SubCommandExt,
};
use crate::bot::Context;
use crate::db::types::{ChannelId, GameId, GuildId, ModId};
use crate::db::{Events, Tags};
use crate::error::Error;
use crate::util::{ContentBuilder, IntoFilter};

pub fn commands() -> Vec<Command> {
    vec![CommandBuilder::new(
        "subs",
        "Manage subscriptions in the current channel to mod updates of a game.",
        CommandType::ChatInput,
    )
    .option(SubCommandBuilder::new(
        "overview",
        "Show an overview of the current setup of this server.",
    ))
    .option(SubCommandBuilder::new("list", "List subscriptions"))
    .option(
        SubCommandBuilder::new(
            "add",
            "Subscribe the current channel to mod update of a game.",
        )
        .option(
            StringBuilder::new("game", "ID or search")
                .required(true)
                .autocomplete(true),
        )
        .option(StringBuilder::new("tags", "Comma-separated list of tags"))
        .option(
            IntegerBuilder::new("type", "Type of the mod updates.").choices([
                ("New mods", i64::from(Events::NEW.bits())),
                ("Updated mods", i64::from(Events::UPD.bits())),
                ("All", i64::from(Events::ALL.bits())),
            ]),
        ),
    )
    .option(
        SubCommandBuilder::new(
            "rm",
            "Unsubscribe the current channel from mod update of a game.",
        )
        .option(
            StringBuilder::new("game", "ID or search")
                .required(true)
                .autocomplete(true),
        )
        .option(StringBuilder::new("tags", "Comma-separated list of tags"))
        .option(
            IntegerBuilder::new("type", "Type of the mod updates.").choices([
                ("New mods", i64::from(Events::NEW.bits())),
                ("Updated mods", i64::from(Events::UPD.bits())),
                ("All", i64::from(Events::ALL.bits())),
            ]),
        ),
    )
    .option(
        SubCommandGroupBuilder::new("mods", "Mute update notifications for a mod.").subcommands([
            SubCommandBuilder::new("muted", "List muted mods"),
            SubCommandBuilder::new("mute", "Mute update notifications for a mod.")
                .option(
                    StringBuilder::new("game", "ID or search")
                        .required(true)
                        .autocomplete(true),
                )
                .option(StringBuilder::new("mod", "ID or search").required(true)),
            SubCommandBuilder::new("unmute", "Unmute update notifications for a mod.")
                .option(
                    StringBuilder::new("game", "ID or search")
                        .required(true)
                        .autocomplete(true),
                )
                .option(StringBuilder::new("mod", "ID or search").required(true)),
        ]),
    )
    .option(
        SubCommandGroupBuilder::new("users", "Mute update notifications for mods of a user.")
            .subcommands([
                SubCommandBuilder::new("muted", "List muted user"),
                SubCommandBuilder::new("mute", "Mute update notifications for mods of a user.")
                    .option(
                        StringBuilder::new("game", "ID or search")
                            .required(true)
                            .autocomplete(true),
                    )
                    .option(StringBuilder::new("name", "username").required(true)),
                SubCommandBuilder::new("unmute", "Unmute update notifications for mods of a user.")
                    .option(
                        StringBuilder::new("game", "ID or search")
                            .required(true)
                            .autocomplete(true),
                    )
                    .option(StringBuilder::new("name", "username").required(true)),
            ]),
    )
    .dm_permission(false)
    .default_member_permissions(Permissions::MANAGE_CHANNELS)
    .build()]
}

pub async fn handle_command(
    ctx: &Context,
    interaction: &Interaction,
    command: &CommandData,
) -> Result<(), Error> {
    if let Some(("game", value)) = command.autocomplete() {
        return autocomplete_games(ctx, interaction, value).await;
    }

    match command.subcommand() {
        Some(("overview", _)) => overview(ctx, interaction).await,
        Some(("list", _)) => list(ctx, interaction).await,
        Some(("add", opts)) => subscribe(ctx, interaction, opts).await,
        Some(("rm", opts)) => unsubscribe(ctx, interaction, opts).await,
        Some(("mods", opts)) => mods(ctx, interaction, opts).await,
        Some(("users", opts)) => users(ctx, interaction, opts).await,
        _ => Ok(()),
    }
}

async fn overview(ctx: &Context, interaction: &Interaction) -> Result<(), Error> {
    let guild_id = interaction.guild_id.map(GuildId).unwrap();

    let (subs, excluded_mods, excluded_users) = ctx.subscriptions.list_for_overview(guild_id)?;

    if subs.is_empty() && excluded_mods.is_empty() && excluded_users.is_empty() {
        let data = "No subscriptions found.".into_ephemeral();
        return create_response(ctx, interaction, data).await;
    }

    defer_ephemeral(ctx, interaction).await?;

    // Collect all game ids to fetch from modio.
    let mut game_ids = subs
        .values()
        .flatten()
        .map(|(g, _, _)| g)
        .chain(excluded_mods.keys().map(|(g, _)| g))
        .chain(excluded_users.keys().map(|(g, _)| g))
        .collect::<Vec<_>>();

    game_ids.sort_unstable();
    game_ids.dedup();

    let filter = Id::_in(game_ids);
    let list = ctx.modio.games().search(filter).collect().await?;
    let games = list
        .into_iter()
        .map(|g| (g.id, g.name))
        .collect::<HashMap<_, _>>();

    let mut embed = EmbedBuilder::new().title("Subscriptions");

    let mut content = String::new();
    for (channel_id, subs) in subs {
        let _ = writeln!(&mut content, "__Channel:__ <#{channel_id}>");
        for (game_id, tags, evts) in subs {
            if let Some(game) = games.get(&game_id) {
                let _ = write!(&mut content, "{game_id}. {game}");
            } else {
                let _ = write!(&mut content, "{game_id}");
            }
            content.push_str(evts.to_suffix());

            if !tags.is_empty() {
                content.push_str(" | Tags: ");
                push_tags(&mut content, tags.iter());
            }
            content.push('\n');
        }
        content.push('\n');
    }
    embed = embed.description(content);

    if !excluded_mods.is_empty() {
        embed = embed.field(EmbedFieldBuilder::new(
            "Muted mods",
            to_content(&games, excluded_mods),
        ));
    }

    if !excluded_users.is_empty() {
        embed = embed.field(EmbedFieldBuilder::new(
            "Muted users",
            to_content(&games, excluded_users),
        ));
    }
    ctx.interaction()
        .update_response(&interaction.token)
        .embeds(Some(&[embed.build()]))?
        .await?;

    Ok(())
}

fn to_content<I, E, D>(games: &HashMap<u32, String>, excluded: I) -> String
where
    I: IntoIterator<Item = ((GameId, ChannelId), E)>,
    E: IntoIterator<Item = D>,
    D: Display,
{
    let excluded = excluded
        .into_iter()
        .map(|((game_id, channel_id), items)| (channel_id, (game_id, items)))
        .fold(BTreeMap::<_, Vec<_>>::new(), |mut map, (key, value)| {
            map.entry(key).or_default().push(value);
            map
        });

    let mut content = String::new();
    for (channel_id, entries) in excluded {
        let _ = writeln!(&mut content, "__Channel:__ <#{channel_id}>");
        for (game_id, items) in entries {
            if let Some(game) = games.get(&game_id) {
                let _ = write!(&mut content, "{game_id}. {game}: ");
            } else {
                let _ = write!(&mut content, "{game_id}: ");
            }
            let mut it = items.into_iter().peekable();
            while let Some(item) = it.next() {
                let _ = write!(&mut content, "`{item}`");
                if it.peek().is_some() {
                    content.push_str(", ");
                }
            }
            content.push('\n');
        }
    }
    content
}

async fn list(ctx: &Context, interaction: &Interaction) -> Result<(), Error> {
    let channel_id = interaction.channel_id.map(ChannelId).unwrap();
    let subs = ctx.subscriptions.list_for_channel(channel_id)?;

    if subs.is_empty() {
        let data = "No subscriptions found.".into_ephemeral();
        return create_response(ctx, interaction, data).await;
    }

    defer_ephemeral(ctx, interaction).await?;

    let filter = Id::_in(subs.iter().map(|s| s.0).collect::<Vec<_>>());
    let list = ctx.modio.games().search(filter).collect().await?;
    let games = list
        .into_iter()
        .map(|g| (g.id, g.name))
        .collect::<HashMap<_, _>>();

    let mut content = String::new();
    for (game_id, tags, evts) in subs {
        let Some(name) = games.get(&game_id) else {
            continue;
        };
        let _ = write!(&mut content, "{game_id}. {name}");

        content.push_str(evts.to_suffix());

        if !tags.is_empty() {
            content.push_str(" | Tags: ");
            push_tags(&mut content, tags.iter());
        }
        content.push('\n');
    }

    let embed = EmbedBuilder::new()
        .title("Subscriptions")
        .description(content)
        .build();

    ctx.interaction()
        .update_response(&interaction.token)
        .embeds(Some(&[embed]))?
        .await?;

    Ok(())
}

async fn subscribe(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game = None;
    let mut tags = None;
    let mut evts = Events::ALL;

    defer_ephemeral(ctx, interaction).await?;

    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game = search_game(ctx, s).await?;

                if game.is_none() {
                    let content = "Game not found.";
                    return update_response_content(ctx, interaction, content).await;
                }
            }
            CommandOptionValue::String(s) if opt.name == "tags" => {
                tags = Some(s.as_str());
            }
            CommandOptionValue::Integer(v) if opt.name == "type" => {
                evts = if (1..=3).contains(v) {
                    #[allow(clippy::cast_possible_truncation)]
                    Events::from_bits_truncate(*v as i32)
                } else {
                    Events::ALL
                };
            }
            _ => {}
        }
    }

    let game = game.expect("required option");
    if !game
        .api_access_options
        .contains(ApiAccessOptions::ALLOW_THIRD_PARTY)
    {
        let content = format!(
            ":no_entry: Third party API access is disabled for '{}' but is required for subscriptions.",
            game.name
        );
        return update_response_content(ctx, interaction, &content).await;
    }

    let channel_id = interaction.channel_id.map(ChannelId).unwrap();
    let guild_id = interaction.guild_id.map(GuildId).unwrap();

    let game_tags = game
        .tag_options
        .into_iter()
        .flat_map(|opt| opt.tags)
        .collect::<Tags>();

    let (hidden, mut sub_tags) = tags
        .map(|s| Tags::from_csv(s).partition())
        .unwrap_or_default();

    if !sub_tags.is_subset(&game_tags) {
        let mut content = format!("Failed to subscribe to '{}'.\n", game.name);
        content.push_str("Invalid tag(s): ");
        push_tags(&mut content, sub_tags.difference(&game_tags));

        content.push_str("\nAvailable tags: ");
        push_tags(&mut content, game_tags.iter());

        return update_response_content(ctx, interaction, &content).await;
    }
    sub_tags.extend(hidden);

    let ret = ctx
        .subscriptions
        .add(GameId(game.id), channel_id, sub_tags, guild_id, evts);

    let content: Cow<'_, str> = match ret {
        Ok(_) => format!("Subscribed to '{}'.", game.name).into(),
        Err(e) => {
            tracing::error!("{e}");

            "Failed to add subscription.".into()
        }
    };

    update_response_content(ctx, interaction, &content).await
}

async fn unsubscribe(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game = None;
    let mut tags = None;
    let mut evts = Events::ALL;

    defer_ephemeral(ctx, interaction).await?;

    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game = search_game(ctx, s).await?;

                if game.is_none() {
                    let content = "Game not found.";
                    return update_response_content(ctx, interaction, content).await;
                }
            }
            CommandOptionValue::String(s) if opt.name == "tags" => {
                tags = Some(s.as_str());
            }
            CommandOptionValue::Integer(v) if opt.name == "type" => {
                evts = if (1..=3).contains(v) {
                    #[allow(clippy::cast_possible_truncation)]
                    Events::from_bits_truncate(*v as i32)
                } else {
                    Events::ALL
                };
            }
            _ => {}
        }
    }

    let game = game.expect("required option");
    let channel_id = interaction.channel_id.map(ChannelId).unwrap();

    let game_tags = game
        .tag_options
        .into_iter()
        .flat_map(|opt| opt.tags)
        .collect::<Tags>();

    let (hidden, mut sub_tags) = tags
        .map(|s| Tags::from_csv(s).partition())
        .unwrap_or_default();

    if !sub_tags.is_subset(&game_tags) {
        let mut content = format!("Failed to unsubscribe from '{}'.\n", game.name);
        content.push_str("Invalid tag(s): ");
        push_tags(&mut content, sub_tags.difference(&game_tags));

        content.push_str("\nAvailable tags: ");
        push_tags(&mut content, game_tags.iter());

        return update_response_content(ctx, interaction, &content).await;
    }
    sub_tags.extend(hidden);

    let ret = ctx
        .subscriptions
        .remove(GameId(game.id), channel_id, sub_tags, evts);

    let content: Cow<'_, str> = match ret {
        Ok(_) => format!("Unsubscribed from '{}'.", game.name).into(),
        Err(e) => {
            tracing::error!("{e}");

            "Failed to remove subscription.".into()
        }
    };

    update_response_content(ctx, interaction, &content).await
}

/// `/subs mods`
async fn mods(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    defer_ephemeral(ctx, interaction).await?;

    match opts.subcommand() {
        Some(("muted", opts)) => mods_muted(ctx, interaction, opts).await,
        Some(("mute", opts)) => mods_mute(ctx, interaction, opts).await,
        Some(("unmute", opts)) => mods_unmute(ctx, interaction, opts).await,
        _ => Ok(()),
    }
}

/// `/subs mods muted`
async fn mods_muted(
    ctx: &Context,
    interaction: &Interaction,
    _opts: &[CommandDataOption],
) -> Result<(), Error> {
    let channel_id = interaction.channel_id.map(ChannelId).unwrap();
    let excluded = ctx.subscriptions.list_excluded_mods(channel_id)?;

    let muted = match excluded.len() {
        0 => {
            let content = "No mod is muted.";
            return update_response_content(ctx, interaction, content).await;
        }
        1 => {
            let (GameId(game), mods) = excluded.into_iter().next().unwrap();
            let filter = Id::_in(mods.into_iter().collect::<Vec<_>>());
            ctx.modio
                .game(game)
                .mods()
                .search(filter)
                .iter()
                .await?
                .try_fold(ContentBuilder::new(4000), |mut buf, m| {
                    let _ = writeln!(&mut buf, "{}. {}", m.id, m.name);
                    async { Ok(buf) }
                })
                .await?
        }
        _ => {
            excluded
                .into_iter()
                .map(|(GameId(game), mods)| {
                    let filter = Id::_in(mods.into_iter().collect::<Vec<_>>());
                    future::try_join(
                        ctx.modio.game(game).get(),
                        ctx.modio.game(game).mods().search(filter).collect(),
                    )
                })
                .collect::<FuturesUnordered<_>>()
                .try_fold(ContentBuilder::new(4000), |mut buf, (game, mods)| {
                    let _ = writeln!(&mut buf, "**{}**", game.name);
                    for m in mods {
                        let _ = writeln!(&mut buf, "{}. {}", m.id, m.name);
                    }
                    let _ = writeln!(&mut buf);
                    async { Ok(buf) }
                })
                .await?
        }
    };

    update_response_from_content(ctx, interaction, "Muted Mods", &muted.content).await
}

/// `/subs mods mute <game> <mod>`
async fn mods_mute(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game_filter = None;
    let mut mod_filter = None;
    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game_filter = Some(s.into_filter());
            }
            CommandOptionValue::String(s) if opt.name == "mod" => {
                mod_filter = Some(s.into_filter());
            }
            _ => {}
        }
    }

    let game_filter = game_filter.expect("required option");
    let mod_filter = mod_filter.expect("required option");

    let game_mod = find_game_mod(&ctx.modio, game_filter, mod_filter).await?;

    let content: Cow<'_, str> = match game_mod {
        (None, _) => "Game not found.".into(),
        (_, None) => "Mod not found.".into(),
        (Some(game), Some(mod_)) => {
            let channel_id = interaction.channel_id.map(ChannelId).unwrap();
            let guild_id = interaction.guild_id.map(GuildId).unwrap();

            let ret =
                ctx.subscriptions
                    .mute_mod(GameId(game.id), channel_id, guild_id, ModId(mod_.id));

            let content = if let Err(e) = ret {
                tracing::error!("{e}");

                format!("Failed to mute '{}'.", mod_.name)
            } else {
                format!("The mod '{}' is now muted.", mod_.name)
            };

            content.into()
        }
    };

    update_response_content(ctx, interaction, &content).await
}

/// `/subs mods unmute <game> <mod>`
async fn mods_unmute(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game_filter = None;
    let mut mod_filter = None;
    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game_filter = Some(s.into_filter());
            }
            CommandOptionValue::String(s) if opt.name == "mod" => {
                mod_filter = Some(s.into_filter());
            }
            _ => {}
        }
    }

    let game_filter = game_filter.expect("required option");
    let mod_filter = mod_filter.expect("required option");

    let game_mod = find_game_mod(&ctx.modio, game_filter, mod_filter).await?;

    let content: Cow<'_, str> = match game_mod {
        (None, _) => "Game not found.".into(),
        (_, None) => "Mod not found.".into(),
        (Some(game), Some(mod_)) => {
            let channel_id = interaction.channel_id.map(ChannelId).unwrap();

            let ret = ctx
                .subscriptions
                .unmute_mod(GameId(game.id), channel_id, ModId(mod_.id));

            let content = if let Err(e) = ret {
                tracing::error!("{e}");

                format!("Failed to unmute '{}'.", mod_.name)
            } else {
                format!("The mod '{}' is now unmuted.", mod_.name)
            };

            content.into()
        }
    };

    update_response_content(ctx, interaction, &content).await
}

/// `/subs users`
async fn users(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    defer_ephemeral(ctx, interaction).await?;

    match opts.subcommand() {
        Some(("muted", opts)) => users_muted(ctx, interaction, opts).await,
        Some(("mute", opts)) => users_mute(ctx, interaction, opts).await,
        Some(("unmute", opts)) => users_unmute(ctx, interaction, opts).await,
        _ => Ok(()),
    }
}

/// `/subs users muted`
async fn users_muted(
    ctx: &Context,
    interaction: &Interaction,
    _opts: &[CommandDataOption],
) -> Result<(), Error> {
    let channel_id = interaction.channel_id.map(ChannelId).unwrap();
    let excluded = ctx.subscriptions.list_excluded_users(channel_id)?;

    let muted = match excluded.len() {
        0 => {
            let content = "No user is muted.";
            return update_response_content(ctx, interaction, content).await;
        }
        1 => {
            let (_, users) = excluded.into_iter().next().unwrap();

            let mut muted = ContentBuilder::new(4000);
            for (i, name) in users.iter().enumerate() {
                let _ = writeln!(&mut muted, "{}. {name}", i + 1);
            }
            muted
        }
        _ => {
            excluded
                .into_iter()
                .map(|(GameId(game), users)| {
                    future::try_join(ctx.modio.game(game).get(), async { Ok(users) })
                })
                .collect::<FuturesUnordered<_>>()
                .try_fold(ContentBuilder::new(4000), |mut buf, (game, users)| {
                    let _ = writeln!(&mut buf, "**{}**", game.name);
                    for (i, name) in users.iter().enumerate() {
                        let _ = writeln!(&mut buf, "{}. {name}", i + 1);
                    }
                    let _ = writeln!(&mut buf);
                    async { Ok(buf) }
                })
                .await?
        }
    };

    update_response_from_content(ctx, interaction, "Muted Users", &muted.content).await
}

/// `/subs users mute <game> <username>`
async fn users_mute(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game_filter = None;
    let mut name = None;
    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game_filter = Some(s.into_filter());
            }
            CommandOptionValue::String(s) if opt.name == "name" => {
                name = Some(s);
            }
            _ => {}
        }
    }

    let game_filter = game_filter.expect("required option");
    let name = name.expect("required option");

    let game = ctx.modio.games().search(game_filter).first().await?;
    let content: Cow<'_, str> = match game {
        Some(game) => {
            let guild_id = interaction.guild_id.map(GuildId).unwrap();
            let channel_id = interaction.channel_id.map(ChannelId).unwrap();

            let ret = ctx
                .subscriptions
                .mute_user(GameId(game.id), channel_id, guild_id, name);

            let content = if let Err(e) = ret {
                tracing::error!("{e}");

                format!("Failed to mute '{name}'.")
            } else {
                format!("The user '{name}' is now muted for '{}'.", game.name)
            };

            content.into()
        }
        None => "Game not found.".into(),
    };

    update_response_content(ctx, interaction, &content).await
}

/// `/subs users unmute <game> <username>`
async fn users_unmute(
    ctx: &Context,
    interaction: &Interaction,
    opts: &[CommandDataOption],
) -> Result<(), Error> {
    let mut game_filter = None;
    let mut name = None;
    for opt in opts {
        match &opt.value {
            CommandOptionValue::String(s) if opt.name == "game" => {
                game_filter = Some(s.into_filter());
            }
            CommandOptionValue::String(s) if opt.name == "name" => {
                name = Some(s);
            }
            _ => {}
        }
    }

    let game_filter = game_filter.expect("required option");
    let name = name.expect("required option");

    let game = ctx.modio.games().search(game_filter).first().await?;
    let content: Cow<'_, str> = match game {
        Some(game) => {
            let channel_id = interaction.channel_id.map(ChannelId).unwrap();

            let ret = ctx
                .subscriptions
                .unmute_user(GameId(game.id), channel_id, name);

            let content = if let Err(e) = ret {
                tracing::error!("{e}");

                format!("Failed to unmute '{name}'.")
            } else {
                format!("The user '{name}' is now unmuted for '{}'.", game.name)
            };

            content.into()
        }
        None => "Game not found.".into(),
    };

    update_response_content(ctx, interaction, &content).await
}

async fn find_game_mod(
    modio: &Modio,
    game_filter: Filter,
    mod_filter: Filter,
) -> Result<(Option<Game>, Option<Mod>), Error> {
    let Some(game) = modio.games().search(game_filter).first().await? else {
        return Ok((None, None));
    };

    let mod_ = modio
        .game(game.id)
        .mods()
        .search(mod_filter)
        .first()
        .await?;

    Ok((Some(game), mod_))
}

impl Events {
    const fn to_suffix(self) -> &'static str {
        match (self.contains(Events::NEW), self.contains(Events::UPD)) {
            (true, true) | (false, false) => " (+Δ)",
            (true, false) => " (+)",
            (false, true) => " (Δ)",
        }
    }
}

fn push_tags<'a, I>(s: &mut String, iter: I)
where
    I: std::iter::Iterator<Item = &'a String>,
{
    let mut iter = iter.peekable();
    while let Some(t) = iter.next() {
        s.push('`');
        s.push_str(t);
        s.push('`');
        if iter.peek().is_some() {
            s.push_str(", ");
        }
    }
}