diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..22f1fc0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: Format & Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libsqlite3-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked + + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libsqlite3-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build workspace + run: cargo build --workspace --locked + + - name: Run tests + run: cargo test --workspace --locked diff --git a/crates/migration/src/m20260411_000000_add_votd_to_server_prefs.rs b/crates/migration/src/m20260411_000000_add_votd_to_server_prefs.rs index 4f1d4de..e126fd4 100644 --- a/crates/migration/src/m20260411_000000_add_votd_to_server_prefs.rs +++ b/crates/migration/src/m20260411_000000_add_votd_to_server_prefs.rs @@ -18,7 +18,11 @@ impl MigrationTrait for Migration { .alter_table( Table::alter() .table(ServerPrefs::Table) - .add_column_if_not_exists(ColumnDef::new(ServerPrefs::VotdChapter).big_integer().null()) + .add_column_if_not_exists( + ColumnDef::new(ServerPrefs::VotdChapter) + .big_integer() + .null(), + ) .to_owned(), ) .await?; @@ -26,7 +30,9 @@ impl MigrationTrait for Migration { .alter_table( Table::alter() .table(ServerPrefs::Table) - .add_column_if_not_exists(ColumnDef::new(ServerPrefs::VotdVerse).big_integer().null()) + .add_column_if_not_exists( + ColumnDef::new(ServerPrefs::VotdVerse).big_integer().null(), + ) .to_owned(), ) .await diff --git a/crates/sb-discord-bot/src/poise_impl/helpers.rs b/crates/sb-discord-bot/src/poise_impl/helpers.rs index 6452ccc..00667e0 100644 --- a/crates/sb-discord-bot/src/poise_impl/helpers.rs +++ b/crates/sb-discord-bot/src/poise_impl/helpers.rs @@ -1,12 +1,9 @@ -use serenity::all::{Colour, CreateEmbed}; use poise::CreateReply; +use serenity::all::{Colour, CreateEmbed}; use crate::{ poise_impl::types::{Context, Error}, - store::{ - contract::BibleBooks, - valid_cache::get_chapter_count, - }, + store::{contract::BibleBooks, valid_cache::get_chapter_count}, util::prefs::{calc_lang, calc_translation}, }; diff --git a/crates/sb-discord-bot/src/poise_impl/slash_commands.rs b/crates/sb-discord-bot/src/poise_impl/slash_commands.rs index 54f5538..c063987 100644 --- a/crates/sb-discord-bot/src/poise_impl/slash_commands.rs +++ b/crates/sb-discord-bot/src/poise_impl/slash_commands.rs @@ -1,24 +1,30 @@ use poise::CreateReply; -use serenity::all::{Colour, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, RoleId}; +use serenity::all::{ + Colour, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, RoleId, +}; use strum::IntoEnumIterator; -use rand::seq::SliceRandom; use rand::Rng; +use rand::seq::SliceRandom; use crate::{ discord_util::user::user_is_admin, poise_impl::{ data::Data, - helpers::{parse_book, resolve_lang, resolve_translation, split_reference, validate_chapter}, + helpers::{ + parse_book, resolve_lang, resolve_translation, split_reference, validate_chapter, + }, types::{Context, Error}, }, store::{ - bibleapi::{get_chapter, ChapterItem}, + bibleapi::{ChapterItem, get_chapter}, contract::{BibleBooks, Translations}, curated::random_curated_verse, valid_cache::{get_all_book_chapters, get_chapter_count, is_valid_translation}, }, - util::format::{format_chapter_content, format_verse_content, get_passage_url, split_into_embed_chunks}, + util::format::{ + format_chapter_content, format_verse_content, get_passage_url, split_into_embed_chunks, + }, }; async fn autocomplete_book<'a>( @@ -294,7 +300,8 @@ pub async fn votd( return Ok(()); }; let store = &ctx.data().store; - let Some((book_3c_id, chapter, verse_num)) = store.get_server_votd(&guild_id.to_string()).await else { + let Some((book_3c_id, chapter, verse_num)) = store.get_server_votd(&guild_id.to_string()).await + else { ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() @@ -348,7 +355,10 @@ pub async fn votd( ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() - .title(format!("Verse of the Day — {} {}:{} ({})", book, chapter, verse_data.number, translation)) + .title(format!( + "Verse of the Day — {} {}:{} ({})", + book, chapter, verse_data.number, translation + )) .description(format_verse_content(&verse_data)) .color(Colour::from_rgb(178, 255, 237)), ], @@ -384,19 +394,28 @@ pub async fn setvotd( .await?; return Ok(()); }; - let Some(book) = parse_book(&ctx, &book).await? else { return Ok(()); }; + let Some(book) = parse_book(&ctx, &book).await? else { + return Ok(()); + }; let translation = resolve_translation(&ctx, None).await; - if !validate_chapter(&ctx, &book, &translation, chapter).await? { return Ok(()); } + if !validate_chapter(&ctx, &book, &translation, chapter).await? { + return Ok(()); + } let response = get_chapter(&translation, &book, chapter).await?; - let verse_exists = response.chapter.content.iter().any(|item| { - matches!(item, ChapterItem::Verse(v) if v.number == verse) - }); + let verse_exists = response + .chapter + .content + .iter() + .any(|item| matches!(item, ChapterItem::Verse(v) if v.number == verse)); if !verse_exists { ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() .title("Verse not found") - .description(format!("{} {} does not have a verse {}.", book, chapter, verse)) + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, verse + )) .color(Colour::new(16730184)), ], ..Default::default() @@ -404,7 +423,10 @@ pub async fn setvotd( .await?; return Ok(()); } - ctx.data().store.set_server_votd(&guild_id.to_string(), book.get_3c_id(), chapter, verse).await; + ctx.data() + .store + .set_server_votd(&guild_id.to_string(), book.get_3c_id(), chapter, verse) + .await; ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() @@ -440,7 +462,10 @@ pub async fn truerandom( embeds: vec![ CreateEmbed::default() .title("No books found") - .description(format!("Could not load books for translation {}.", translation)) + .description(format!( + "Could not load books for translation {}.", + translation + )) .color(Colour::new(16730184)), ], ..Default::default() @@ -450,9 +475,18 @@ pub async fn truerandom( }; let chapter = rand::thread_rng().gen_range(1..=*max_chapters); let response = get_chapter(&translation, book, chapter).await?; - let verses: Vec<_> = response.chapter.content.into_iter().filter_map(|item| { - if let ChapterItem::Verse(v) = item { Some(v) } else { None } - }).collect(); + let verses: Vec<_> = response + .chapter + .content + .into_iter() + .filter_map(|item| { + if let ChapterItem::Verse(v) = item { + Some(v) + } else { + None + } + }) + .collect(); let Some(verse_data) = verses.choose(&mut rand::thread_rng()) else { ctx.send(CreateReply { embeds: vec![ @@ -469,7 +503,10 @@ pub async fn truerandom( ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() - .title(format!("{} {}:{} ({})", book, chapter, verse_data.number, translation)) + .title(format!( + "{} {}:{} ({})", + book, chapter, verse_data.number, translation + )) .description(format_verse_content(verse_data)) .color(Colour::from_rgb(178, 255, 237)), ], @@ -515,7 +552,11 @@ pub async fn random( let response = get_chapter(&translation, &verse_ref.book, verse_ref.chapter).await?; let found = response.chapter.content.into_iter().find_map(|item| { if let ChapterItem::Verse(v) = item { - if v.number == verse_ref.verse { Some(v) } else { None } + if v.number == verse_ref.verse { + Some(v) + } else { + None + } } else { None } @@ -566,9 +607,13 @@ pub async fn chapter( translation: Option, ) -> Result<(), Error> { ctx.defer().await?; - let Some(book) = parse_book(&ctx, &book).await? else { return Ok(()); }; + let Some(book) = parse_book(&ctx, &book).await? else { + return Ok(()); + }; let translation = resolve_translation(&ctx, translation.as_deref()).await; - if !validate_chapter(&ctx, &book, &translation, chapter).await? { return Ok(()); } + if !validate_chapter(&ctx, &book, &translation, chapter).await? { + return Ok(()); + } let response = get_chapter(&translation, &book, chapter).await?; let content = format_chapter_content(&response.chapter); let chunks = split_into_embed_chunks(&content); @@ -630,9 +675,13 @@ pub async fn verse( } } - let Some(book) = parse_book(&ctx, &book).await? else { return Ok(()); }; + let Some(book) = parse_book(&ctx, &book).await? else { + return Ok(()); + }; let translation = resolve_translation(&ctx, translation.as_deref()).await; - if !validate_chapter(&ctx, &book, &translation, chapter).await? { return Ok(()); } + if !validate_chapter(&ctx, &book, &translation, chapter).await? { + return Ok(()); + } let response = get_chapter(&translation, &book, chapter).await?; let Some(end) = end_verse else { @@ -648,7 +697,10 @@ pub async fn verse( embeds: vec![ CreateEmbed::default() .title("Verse not found") - .description(format!("{} {} does not have a verse {}.", book, chapter, verse)) + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, verse + )) .color(Colour::new(16730184)), ], ..Default::default() @@ -659,7 +711,10 @@ pub async fn verse( ctx.send(CreateReply { embeds: vec![ CreateEmbed::default() - .title(format!("{} {}:{} ({})", book, chapter, verse_data.number, translation)) + .title(format!( + "{} {}:{} ({})", + book, chapter, verse_data.number, translation + )) .description(format_verse_content(&verse_data)) .color(Colour::from_rgb(178, 255, 237)), ], @@ -669,9 +724,18 @@ pub async fn verse( return Ok(()); }; - let verses_in_chapter: Vec<_> = response.chapter.content.into_iter().filter_map(|item| { - if let ChapterItem::Verse(v) = item { Some(v) } else { None } - }).collect(); + let verses_in_chapter: Vec<_> = response + .chapter + .content + .into_iter() + .filter_map(|item| { + if let ChapterItem::Verse(v) = item { + Some(v) + } else { + None + } + }) + .collect(); let verse_numbers: Vec = verses_in_chapter.iter().map(|v| v.number).collect(); @@ -680,7 +744,10 @@ pub async fn verse( embeds: vec![ CreateEmbed::default() .title("Verse not found") - .description(format!("{} {} does not have a verse {}.", book, chapter, verse)) + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, verse + )) .color(Colour::new(16730184)), ], ..Default::default() @@ -694,7 +761,10 @@ pub async fn verse( embeds: vec![ CreateEmbed::default() .title("End verse not found") - .description(format!("{} {} does not have a verse {}.", book, chapter, end)) + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, end + )) .color(Colour::new(16730184)), ], ..Default::default() @@ -703,19 +773,30 @@ pub async fn verse( return Ok(()); } - let range_verses: Vec<_> = verses_in_chapter.iter() + let range_verses: Vec<_> = verses_in_chapter + .iter() .filter(|v| v.number >= verse && v.number <= end) .collect(); - let content: String = range_verses.iter().map(|v| { - format!("**{}** {}\n", v.number, format_verse_content(v)) - }).collect(); + let content: String = range_verses + .iter() + .map(|v| format!("**{}** {}\n", v.number, format_verse_content(v))) + .collect(); let chunks = split_into_embed_chunks(&content); let total = chunks.len(); for (i, chunk) in chunks.into_iter().enumerate() { let title = if total > 1 { - format!("{} {}:{}-{} ({}) ({}/{})", book, chapter, verse, end, translation, i + 1, total) + format!( + "{} {}:{}-{} ({}) ({}/{})", + book, + chapter, + verse, + end, + translation, + i + 1, + total + ) } else { format!("{} {}:{}-{} ({})", book, chapter, verse, end, translation) }; diff --git a/crates/sb-discord-bot/src/store/curated.rs b/crates/sb-discord-bot/src/store/curated.rs index b3868ca..c2074fa 100644 --- a/crates/sb-discord-bot/src/store/curated.rs +++ b/crates/sb-discord-bot/src/store/curated.rs @@ -10,7 +10,11 @@ pub struct VerseRef { impl VerseRef { const fn new(book: BibleBooks, chapter: i64, verse: i64) -> Self { - Self { book, chapter, verse } + Self { + book, + chapter, + verse, + } } } diff --git a/crates/sb-discord-bot/src/store/valid_cache.rs b/crates/sb-discord-bot/src/store/valid_cache.rs index 991a7f8..49780ce 100644 --- a/crates/sb-discord-bot/src/store/valid_cache.rs +++ b/crates/sb-discord-bot/src/store/valid_cache.rs @@ -54,7 +54,11 @@ pub fn is_valid_language(language: &str) -> bool { /// cache. Does nothing if it is already cached. Returns `false` on fetch failure. async fn ensure_books_cached(translation: &str) -> bool { { - if book_chapters_cache().lock().unwrap().contains_key(translation) { + if book_chapters_cache() + .lock() + .unwrap() + .contains_key(translation) + { return true; } }