From d13df586dd334b595004303660d9f02efde58b12 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 11 Jul 2026 21:02:10 +0200 Subject: [PATCH] refactor: DDD/CQRS architecture, unified crate layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crates: common→application, api→presentation, infrastructure/*→adapters/* - new crates: api-types, infra-wiring - domain: errors/, models/, value_objects/, ports/, services/ - application: CQRS use cases (songs/, tabs/) w/ commands, queries, deps - unified DomainError replaces RepositoryError - workspace deps, unused dep cleanup - fix: parse plain-text chord lines (UG drops spans mid-song) - tests extracted to separate modules (tests/ dirs) --- Cargo.lock | 136 ++++-------- Cargo.toml | 28 ++- Dockerfile | 8 +- Makefile | 28 +++ crates/adapters/sqlite/Cargo.toml | 11 + .../sqlite}/migrations/001_songs.sql | 0 crates/adapters/sqlite/src/lib.rs | 5 + .../sqlite}/src/repository.rs | 71 +++--- .../sqlite}/src/row.rs | 20 +- .../sqlite}/src/search.rs | 18 +- .../ug-parser/Cargo.toml | 4 +- crates/adapters/ug-parser/src/fetcher.rs | 52 +++++ .../ug-parser/src/lib.rs | 0 .../ug-parser/src/parser.rs | 96 +++------ .../adapters/ug-parser/src/tests/fetcher.rs | 19 ++ crates/adapters/ug-parser/src/tests/parser.rs | 77 +++++++ crates/api-types/Cargo.toml | 7 + crates/api-types/src/lib.rs | 31 +++ crates/api/Cargo.toml | 27 --- crates/api/src/routes/songs.rs | 142 ------------ crates/api/src/routes/tabs.rs | 53 ----- crates/{common => application}/.gitignore | 0 crates/application/Cargo.toml | 8 + .../routes/mod.rs => application/src/lib.rs} | 0 crates/application/src/songs/commands.rs | 17 ++ crates/application/src/songs/delete_song.rs | 8 + crates/application/src/songs/deps.rs | 11 + crates/application/src/songs/get_song.rs | 12 ++ crates/application/src/songs/list_songs.rs | 12 ++ crates/application/src/songs/mod.rs | 9 + crates/application/src/songs/queries.rs | 17 ++ crates/application/src/songs/save_song.rs | 12 ++ crates/application/src/songs/search_songs.rs | 14 ++ crates/application/src/songs/update_meta.rs | 19 ++ crates/application/src/tabs/commands.rs | 4 + crates/application/src/tabs/deps.rs | 7 + crates/application/src/tabs/mod.rs | 3 + crates/application/src/tabs/parse_tab.rs | 33 +++ crates/common/Cargo.toml | 17 -- crates/common/src/lib.rs | 52 ----- crates/domain/Cargo.toml | 3 - crates/domain/src/chord.rs | 92 -------- crates/domain/src/errors/mod.rs | 13 ++ crates/domain/src/lib.rs | 26 ++- crates/domain/src/models/mod.rs | 3 + crates/domain/src/{ => models}/song.rs | 43 ++-- crates/domain/src/note.rs | 111 ---------- crates/domain/src/ports.rs | 83 ------- crates/domain/src/ports/mod.rs | 5 + crates/domain/src/ports/repository.rs | 35 +++ crates/domain/src/ports/tab_source.rs | 37 ++++ crates/domain/src/services/mod.rs | 3 + crates/domain/src/services/transposer.rs | 86 ++++++++ crates/domain/src/tests/chord.rs | 47 ++++ crates/domain/src/tests/note.rs | 35 +++ crates/domain/src/tests/song.rs | 41 ++++ crates/domain/src/tests/transposer.rs | 68 ++++++ crates/domain/src/transposer.rs | 133 ------------ crates/domain/src/value_objects/chord.rs | 50 +++++ crates/domain/src/value_objects/mod.rs | 7 + crates/domain/src/value_objects/note.rs | 129 +++++++++++ crates/domain/src/value_objects/sorting.rs | 14 ++ crates/infra-wiring/Cargo.toml | 6 + crates/{api => infra-wiring}/src/config.rs | 18 +- crates/infra-wiring/src/lib.rs | 3 + crates/infrastructure/persistence/Cargo.toml | 19 -- crates/infrastructure/persistence/src/lib.rs | 5 - .../infrastructure/ug-parser/src/fetcher.rs | 59 ----- crates/{api => presentation}/.gitignore | 0 crates/presentation/Cargo.toml | 19 ++ crates/{api => presentation}/src/main.rs | 49 +++-- crates/presentation/src/routes/mod.rs | 2 + crates/presentation/src/routes/songs.rs | 203 ++++++++++++++++++ crates/presentation/src/routes/tabs.rs | 34 +++ 74 files changed, 1493 insertions(+), 1076 deletions(-) create mode 100644 Makefile create mode 100644 crates/adapters/sqlite/Cargo.toml rename crates/{infrastructure/persistence => adapters/sqlite}/migrations/001_songs.sql (100%) create mode 100644 crates/adapters/sqlite/src/lib.rs rename crates/{infrastructure/persistence => adapters/sqlite}/src/repository.rs (63%) rename crates/{infrastructure/persistence => adapters/sqlite}/src/row.rs (58%) rename crates/{infrastructure/persistence => adapters/sqlite}/src/search.rs (56%) rename crates/{infrastructure => adapters}/ug-parser/Cargo.toml (67%) create mode 100644 crates/adapters/ug-parser/src/fetcher.rs rename crates/{infrastructure => adapters}/ug-parser/src/lib.rs (100%) rename crates/{infrastructure => adapters}/ug-parser/src/parser.rs (77%) create mode 100644 crates/adapters/ug-parser/src/tests/fetcher.rs create mode 100644 crates/adapters/ug-parser/src/tests/parser.rs create mode 100644 crates/api-types/Cargo.toml create mode 100644 crates/api-types/src/lib.rs delete mode 100644 crates/api/Cargo.toml delete mode 100644 crates/api/src/routes/songs.rs delete mode 100644 crates/api/src/routes/tabs.rs rename crates/{common => application}/.gitignore (100%) create mode 100644 crates/application/Cargo.toml rename crates/{api/src/routes/mod.rs => application/src/lib.rs} (100%) create mode 100644 crates/application/src/songs/commands.rs create mode 100644 crates/application/src/songs/delete_song.rs create mode 100644 crates/application/src/songs/deps.rs create mode 100644 crates/application/src/songs/get_song.rs create mode 100644 crates/application/src/songs/list_songs.rs create mode 100644 crates/application/src/songs/mod.rs create mode 100644 crates/application/src/songs/queries.rs create mode 100644 crates/application/src/songs/save_song.rs create mode 100644 crates/application/src/songs/search_songs.rs create mode 100644 crates/application/src/songs/update_meta.rs create mode 100644 crates/application/src/tabs/commands.rs create mode 100644 crates/application/src/tabs/deps.rs create mode 100644 crates/application/src/tabs/mod.rs create mode 100644 crates/application/src/tabs/parse_tab.rs delete mode 100644 crates/common/Cargo.toml delete mode 100644 crates/common/src/lib.rs delete mode 100644 crates/domain/src/chord.rs create mode 100644 crates/domain/src/errors/mod.rs create mode 100644 crates/domain/src/models/mod.rs rename crates/domain/src/{ => models}/song.rs (69%) delete mode 100644 crates/domain/src/note.rs delete mode 100644 crates/domain/src/ports.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/repository.rs create mode 100644 crates/domain/src/ports/tab_source.rs create mode 100644 crates/domain/src/services/mod.rs create mode 100644 crates/domain/src/services/transposer.rs create mode 100644 crates/domain/src/tests/chord.rs create mode 100644 crates/domain/src/tests/note.rs create mode 100644 crates/domain/src/tests/song.rs create mode 100644 crates/domain/src/tests/transposer.rs delete mode 100644 crates/domain/src/transposer.rs create mode 100644 crates/domain/src/value_objects/chord.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/note.rs create mode 100644 crates/domain/src/value_objects/sorting.rs create mode 100644 crates/infra-wiring/Cargo.toml rename crates/{api => infra-wiring}/src/config.rs (72%) create mode 100644 crates/infra-wiring/src/lib.rs delete mode 100644 crates/infrastructure/persistence/Cargo.toml delete mode 100644 crates/infrastructure/persistence/src/lib.rs delete mode 100644 crates/infrastructure/ug-parser/src/fetcher.rs rename crates/{api => presentation}/.gitignore (100%) create mode 100644 crates/presentation/Cargo.toml rename crates/{api => presentation}/src/main.rs (58%) create mode 100644 crates/presentation/src/routes/mod.rs create mode 100644 crates/presentation/src/routes/songs.rs create mode 100644 crates/presentation/src/routes/tabs.rs diff --git a/Cargo.lock b/Cargo.lock index 3398ac8..f7fdbe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,23 +24,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "api" +name = "api-types" version = "0.1.0" dependencies = [ - "anyhow", - "axum", - "common", - "domain", - "persistence", - "rand 0.10.0", "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", - "ug-parser", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "domain", "uuid", ] @@ -240,17 +234,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", -] - [[package]] name = "cmake" version = "0.1.58" @@ -270,23 +253,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "common" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "domain", - "rand 0.10.0", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -337,15 +303,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc" version = "3.4.0" @@ -458,12 +415,9 @@ dependencies = [ name = "domain" version = "0.1.0" dependencies = [ - "anyhow", "async-trait", - "rand 0.10.0", "serde", "thiserror 2.0.18", - "tracing", "uuid", ] @@ -745,7 +699,6 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -1081,6 +1034,10 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infra-wiring" +version = "0.1.0" + [[package]] name = "ipnet" version = "2.12.0" @@ -1452,23 +1409,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "persistence" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "common", - "domain", - "rand 0.10.0", - "serde_json", - "sqlx", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "phf" version = "0.11.3" @@ -1584,6 +1524,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "presentation" +version = "0.1.0" +dependencies = [ + "api-types", + "application", + "axum", + "domain", + "infra-wiring", + "sqlite", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "ug-parser", + "uuid", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1701,17 +1659,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.0", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -1750,12 +1697,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2126,7 +2067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2137,7 +2078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2226,6 +2167,17 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "serde_json", + "sqlx", + "uuid", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -2801,12 +2753,10 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" name = "ug-parser" version = "0.1.0" dependencies = [ - "anyhow", "async-trait", "domain", "reqwest", "scraper", - "thiserror 2.0.18", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 4bf9a51..4bcf2a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,37 @@ [workspace] members = [ - "crates/api", - "crates/common", + "crates/adapters/sqlite", + "crates/adapters/ug-parser", + "crates/api-types", + "crates/application", "crates/domain", - "crates/infrastructure/persistence", - "crates/infrastructure/ug-parser", + "crates/infra-wiring", + "crates/presentation", ] resolver = "2" [workspace.dependencies] -anyhow = "1.0.102" -reqwest = "0.13.2" +tokio = { version = "1.51.0", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +anyhow = "1.0.102" thiserror = "2.0.18" -tokio = { version = "1.51.0", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +async-trait = "0.1.89" uuid = { version = "1.23.0", features = ["v4", "serde"] } rand = "0.10.0" -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] } -async-trait = "0.1.89" +reqwest = "0.13.2" scraper = "0.23" +sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] } +axum = { version = "0.8.8", features = ["macros"] } + +domain = { path = "crates/domain" } +application = { path = "crates/application" } +api-types = { path = "crates/api-types" } +infra-wiring = { path = "crates/infra-wiring" } +sqlite = { path = "crates/adapters/sqlite" } +ug-parser = { path = "crates/adapters/ug-parser" } [profile.release] strip = true diff --git a/Dockerfile b/Dockerfile index e08400c..f10b015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM rust:1.92 AS builder +FROM rust:1.97 AS builder WORKDIR /app COPY . . # Build the release binary -RUN cargo build --release -p api +RUN cargo build --release -p presentation FROM debian:trixie-slim @@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsqlite3-0 \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/api . +COPY --from=builder /app/target/release/presentation . # Create data directory for SQLite @@ -27,4 +27,4 @@ ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db EXPOSE 8000 -CMD ["./api"] +CMD ["./presentation"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ff5573 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.DEFAULT_GOAL := check + +# Run the full local check suite — same order as CI would. +check: fmt-check clippy test + @echo "✅ All checks passed" + +# Apply rustfmt to all files. +fmt: + cargo fmt + +# Check formatting without modifying files (CI-safe). +fmt-check: + cargo fmt --check + +# Run Clippy and treat warnings as errors. +clippy: + cargo clippy -- -D warnings + +# Run the test suite. +test: + cargo test + +# Apply fmt + clippy auto-fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +.PHONY: check fmt fmt-check clippy test fix publish diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml new file mode 100644 index 0000000..11f3c02 --- /dev/null +++ b/crates/adapters/sqlite/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "sqlite" +version = "0.1.0" +edition = "2024" + +[dependencies] +sqlx = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } +serde_json = { workspace = true } +domain = { workspace = true } diff --git a/crates/infrastructure/persistence/migrations/001_songs.sql b/crates/adapters/sqlite/migrations/001_songs.sql similarity index 100% rename from crates/infrastructure/persistence/migrations/001_songs.sql rename to crates/adapters/sqlite/migrations/001_songs.sql diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs new file mode 100644 index 0000000..46e900c --- /dev/null +++ b/crates/adapters/sqlite/src/lib.rs @@ -0,0 +1,5 @@ +pub mod repository; +mod row; +mod search; + +pub use repository::{SqliteRepositoryFactory, SqliteSongRepository}; diff --git a/crates/infrastructure/persistence/src/repository.rs b/crates/adapters/sqlite/src/repository.rs similarity index 63% rename from crates/infrastructure/persistence/src/repository.rs rename to crates/adapters/sqlite/src/repository.rs index e8e52af..9fe4402 100644 --- a/crates/infrastructure/persistence/src/repository.rs +++ b/crates/adapters/sqlite/src/repository.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; use domain::{ - RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong, - SortField, SortOrder, song_preview_chords, + DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong, + song_preview_chords, }; use sqlx::SqlitePool; use uuid::Uuid; -use crate::row::{SongRow, sort_clause, row_to_summary}; +use crate::row::{SongRow, row_to_summary, sort_clause}; #[derive(Clone)] pub struct SqliteSongRepository { @@ -23,14 +23,14 @@ impl SqliteSongRepository { #[async_trait] impl SongRepositoryPort for SqliteSongRepository { - async fn save(&self, song: &Song) -> Result { + async fn save(&self, song: &Song) -> Result { let id = Uuid::new_v4(); let id_str = id.to_string(); let body = serde_json::to_string(song) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let preview = song_preview_chords(song); let preview_json = serde_json::to_string(&preview) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let original_key = song.meta.original_key.as_deref(); sqlx::query( @@ -44,12 +44,19 @@ impl SongRepositoryPort for SqliteSongRepository { .bind(&body) .execute(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(StoredSong { id, song: song.clone() }) + Ok(StoredSong { + id, + song: song.clone(), + }) } - async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { + async fn list( + &self, + sort: SortField, + order: SortOrder, + ) -> Result, DomainError> { let sql = format!( "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", sort_clause(sort, order) @@ -57,41 +64,41 @@ impl SongRepositoryPort for SqliteSongRepository { let rows = sqlx::query_as::<_, SongRow>(&sql) .fetch_all(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() } - async fn get(&self, id: Uuid) -> Result, RepositoryError> { + async fn get(&self, id: Uuid) -> Result, DomainError> { let id_str = id.to_string(); let row = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" + "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?", ) .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; match row { None => Ok(None), Some(r) => { let song: Song = serde_json::from_str(&r.body) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(Some(song)) } } } - async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { + async fn delete(&self, id: Uuid) -> Result<(), DomainError> { let id_str = id.to_string(); let result = sqlx::query("DELETE FROM songs WHERE id = ?") .bind(&id_str) .execute(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; if result.rows_affected() == 0 { - Err(RepositoryError::NotFound) + Err(DomainError::NotFound) } else { Ok(()) } @@ -103,32 +110,38 @@ impl SongRepositoryPort for SqliteSongRepository { title: Option<&str>, artist: Option<&str>, original_key: Option<&str>, - ) -> Result { + ) -> Result { let id_str = id.to_string(); let row = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" + "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?", ) .bind(&id_str) .fetch_optional(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))? - .ok_or(RepositoryError::NotFound)?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .ok_or(DomainError::NotFound)?; let mut song: Song = serde_json::from_str(&row.body) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - if let Some(t) = title { song.meta.title = t.to_string(); } - if let Some(a) = artist { song.meta.artist = a.to_string(); } - if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + if let Some(t) = title { + song.meta.title = t.to_string(); + } + if let Some(a) = artist { + song.meta.artist = a.to_string(); + } + if let Some(k) = original_key { + song.meta.original_key = Some(k.to_string()); + } let new_body = serde_json::to_string(&song) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let new_title = title.unwrap_or(&row.title); let new_artist = artist.unwrap_or(&row.artist); let new_key: Option<&str> = original_key.or(row.original_key.as_deref()); sqlx::query( - "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" + "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?", ) .bind(new_title) .bind(new_artist) @@ -137,10 +150,10 @@ impl SongRepositoryPort for SqliteSongRepository { .bind(&id_str) .execute(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(SongSummary { id, diff --git a/crates/infrastructure/persistence/src/row.rs b/crates/adapters/sqlite/src/row.rs similarity index 58% rename from crates/infrastructure/persistence/src/row.rs rename to crates/adapters/sqlite/src/row.rs index fd3dfdb..b54ebe4 100644 --- a/crates/infrastructure/persistence/src/row.rs +++ b/crates/adapters/sqlite/src/row.rs @@ -1,4 +1,4 @@ -use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder}; +use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder}; use uuid::Uuid; #[derive(sqlx::FromRow)] @@ -13,20 +13,20 @@ pub(crate) struct SongRow { pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str { match (field, order) { - (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", - (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", - (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", + (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", + (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", + (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", - (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", - (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", + (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", + (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", } } -pub(crate) fn row_to_summary(row: SongRow) -> Result { - let id = Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; +pub(crate) fn row_to_summary(row: SongRow) -> Result { + let id = + Uuid::parse_str(&row.id).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(SongSummary { id, meta: SongMeta { diff --git a/crates/infrastructure/persistence/src/search.rs b/crates/adapters/sqlite/src/search.rs similarity index 56% rename from crates/infrastructure/persistence/src/search.rs rename to crates/adapters/sqlite/src/search.rs index 4a79be8..190c1a4 100644 --- a/crates/infrastructure/persistence/src/search.rs +++ b/crates/adapters/sqlite/src/search.rs @@ -1,13 +1,21 @@ use async_trait::async_trait; -use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder}; +use domain::{DomainError, SongSearchPort, SongSummary, SortField, SortOrder}; use crate::repository::SqliteSongRepository; -use crate::row::{SongRow, sort_clause, row_to_summary}; +use crate::row::{SongRow, row_to_summary, sort_clause}; #[async_trait] impl SongSearchPort for SqliteSongRepository { - async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError> { - let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + async fn search( + &self, + query: &str, + sort: SortField, + order: SortOrder, + ) -> Result, DomainError> { + let escaped = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); let pattern = format!("%{}%", escaped); let sql = format!( "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ @@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository { .bind(&pattern) .fetch_all(&self.pool) .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() } diff --git a/crates/infrastructure/ug-parser/Cargo.toml b/crates/adapters/ug-parser/Cargo.toml similarity index 67% rename from crates/infrastructure/ug-parser/Cargo.toml rename to crates/adapters/ug-parser/Cargo.toml index c3a7769..5f09a13 100644 --- a/crates/infrastructure/ug-parser/Cargo.toml +++ b/crates/adapters/ug-parser/Cargo.toml @@ -4,9 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -domain = { path = "../../domain" } -anyhow = { workspace = true } -thiserror = { workspace = true } +domain = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } scraper = { workspace = true } diff --git a/crates/adapters/ug-parser/src/fetcher.rs b/crates/adapters/ug-parser/src/fetcher.rs new file mode 100644 index 0000000..a398cfa --- /dev/null +++ b/crates/adapters/ug-parser/src/fetcher.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use domain::{FetchError, TabFetcherPort, TabSource}; + +pub struct UgTabFetcher { + client: reqwest::Client, +} + +impl UgTabFetcher { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for UgTabFetcher { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl TabFetcherPort for UgTabFetcher { + async fn fetch(&self, source: TabSource) -> Result { + match source { + TabSource::File(path) => Ok(tokio::fs::read_to_string(&path).await?), + TabSource::Url(url) => { + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| FetchError::Network(e.to_string()))?; + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !content_type.contains("text/html") { + return Err(FetchError::InvalidContentType); + } + resp.text() + .await + .map_err(|e| FetchError::Network(e.to_string())) + } + } + } +} + +#[cfg(test)] +#[path = "tests/fetcher.rs"] +mod tests; diff --git a/crates/infrastructure/ug-parser/src/lib.rs b/crates/adapters/ug-parser/src/lib.rs similarity index 100% rename from crates/infrastructure/ug-parser/src/lib.rs rename to crates/adapters/ug-parser/src/lib.rs diff --git a/crates/infrastructure/ug-parser/src/parser.rs b/crates/adapters/ug-parser/src/parser.rs similarity index 77% rename from crates/infrastructure/ug-parser/src/parser.rs rename to crates/adapters/ug-parser/src/parser.rs index f3cb898..e34bc28 100644 --- a/crates/infrastructure/ug-parser/src/parser.rs +++ b/crates/adapters/ug-parser/src/parser.rs @@ -127,6 +127,14 @@ impl UgHtmlParser { continue; } + // Plain-text chord line (UG sometimes drops spans for later sections) + if let Some(parsed) = Self::try_parse_plain_chord_line(trimmed) + && !parsed.is_empty() + { + pending_chords = parsed; + continue; + } + // Lyric line if let Some(sec) = current_section.as_mut() { sec.lines.push(LyricLine { @@ -159,6 +167,30 @@ impl UgHtmlParser { } } + /// Detect a plain-text chord line: every non-whitespace token must be a valid chord. + /// Returns None if the line contains non-chord words (i.e. it's a lyric line). + pub(crate) fn try_parse_plain_chord_line(line: &str) -> Option> { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + let mut chords = Vec::new(); + let mut pos = 0; + + for token in &tokens { + let token_start = line[pos..].find(token).map(|i| pos + i)?; + pos = token_start + token.len(); + + let chord = Chord::parse(token)?; + chords.push(ChordPosition { + offset: token_start, + chord, + }); + } + + Some(chords) + } + /// Parse a chord line (raw HTML) into chord positions. /// Walks text nodes and span[data-name] elements in order to compute offsets. fn parse_chord_line(line_html: &str) -> Vec { @@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser { } #[cfg(test)] -mod tests { - use super::*; - use domain::TabParserPort; - - fn sample_html(name: &str) -> String { - let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("samples/{}", name)); - std::fs::read_to_string(path).unwrap() - } - - #[test] - fn parses_artist_and_title() { - let parser = UgHtmlParser; - let html = sample_html("A DROP IN THE OCEAN.html"); - let song = parser.parse(&html).unwrap(); - assert_eq!(song.meta.artist, "Ron Pope"); - assert_eq!(song.meta.title, "A Drop In The Ocean"); - } - - #[test] - fn capo_is_none_when_no_capo() { - let parser = UgHtmlParser; - let html = sample_html("A DROP IN THE OCEAN.html"); - let song = parser.parse(&html).unwrap(); - assert_eq!(song.meta.capo, None); - } - - #[test] - fn parses_sections() { - let parser = UgHtmlParser; - let html = sample_html("A DROP IN THE OCEAN.html"); - let song = parser.parse(&html).unwrap(); - assert!( - song.sections.len() >= 3, - "expected >=3 sections, got {}", - song.sections.len() - ); - assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus); - } - - #[test] - fn parses_chord_positions() { - let parser = UgHtmlParser; - let html = sample_html("A DROP IN THE OCEAN.html"); - let song = parser.parse(&html).unwrap(); - // First section, first line: "A drop in the ocean," - // Chord "Em" should be at offset 0 (or small offset from leading whitespace) - let first_line = &song.sections[0].lines[0]; - assert_eq!(first_line.text, "A drop in the ocean,"); - assert!( - first_line.chords[0].chord.name(true) == "Em", - "expected Em chord, got {}", - first_line.chords[0].chord.name(true) - ); - } -} +#[path = "tests/parser.rs"] +mod tests; diff --git a/crates/adapters/ug-parser/src/tests/fetcher.rs b/crates/adapters/ug-parser/src/tests/fetcher.rs new file mode 100644 index 0000000..bcdde18 --- /dev/null +++ b/crates/adapters/ug-parser/src/tests/fetcher.rs @@ -0,0 +1,19 @@ +use super::*; +use domain::TabSource; +use std::path::PathBuf; + +#[tokio::test] +async fn fetch_local_file() { + let fetcher = UgTabFetcher::new(); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("samples/drop_in_the_ocean.html"); + let html = fetcher.fetch(TabSource::File(path)).await.unwrap(); + assert!(html.contains("[Chorus]")); + assert!(html.contains("data-name=\"Em\"")); +} diff --git a/crates/adapters/ug-parser/src/tests/parser.rs b/crates/adapters/ug-parser/src/tests/parser.rs new file mode 100644 index 0000000..f760eea --- /dev/null +++ b/crates/adapters/ug-parser/src/tests/parser.rs @@ -0,0 +1,77 @@ +use super::*; +use domain::TabParserPort; + +fn sample_html(name: &str) -> String { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join(format!("samples/{}", name)); + std::fs::read_to_string(path).unwrap() +} + +#[test] +fn parses_artist_and_title() { + let parser = UgHtmlParser; + let html = sample_html("drop_in_the_ocean.html"); + let song = parser.parse(&html).unwrap(); + assert_eq!(song.meta.artist, "Ron Pope"); + assert_eq!(song.meta.title, "A Drop In The Ocean"); +} + +#[test] +fn capo_is_none_when_no_capo() { + let parser = UgHtmlParser; + let html = sample_html("drop_in_the_ocean.html"); + let song = parser.parse(&html).unwrap(); + assert_eq!(song.meta.capo, None); +} + +#[test] +fn parses_sections() { + let parser = UgHtmlParser; + let html = sample_html("drop_in_the_ocean.html"); + let song = parser.parse(&html).unwrap(); + assert!( + song.sections.len() >= 3, + "expected >=3 sections, got {}", + song.sections.len() + ); + assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus); +} + +#[test] +fn parses_chord_positions() { + let parser = UgHtmlParser; + let html = sample_html("drop_in_the_ocean.html"); + let song = parser.parse(&html).unwrap(); + let first_line = &song.sections[0].lines[0]; + assert_eq!(first_line.text, "A drop in the ocean,"); + assert!( + first_line.chords[0].chord.name(true) == "Em", + "expected Em chord, got {}", + first_line.chords[0].chord.name(true) + ); +} + +#[test] +fn parses_plain_text_chord_lines() { + let parser = UgHtmlParser; + let html = sample_html("drop_in_the_ocean.html"); + let song = parser.parse(&html).unwrap(); + let last_section = song.sections.last().unwrap(); + let has_chords = last_section.lines.iter().any(|l| !l.chords.is_empty()); + assert!( + has_chords, + "last section should have chords parsed from plain text" + ); +} + +#[test] +fn plain_chord_detection_does_not_eat_lyrics() { + assert!(UgHtmlParser::try_parse_plain_chord_line("A drop in the ocean").is_none()); + assert!(UgHtmlParser::try_parse_plain_chord_line("Am G D").is_some()); +} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml new file mode 100644 index 0000000..44283e8 --- /dev/null +++ b/crates/api-types/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "api-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs new file mode 100644 index 0000000..dd96d39 --- /dev/null +++ b/crates/api-types/src/lib.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct ParseRequest { + pub source: Option, + pub html: Option, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Deserialize)] +pub struct ListQuery { + pub q: Option, + pub sort: Option, + pub order: Option, +} + +#[derive(Deserialize)] +pub struct UpdateSongRequest { + pub title: Option, + pub artist: Option, + pub original_key: Option, +} + +#[derive(Deserialize)] +pub struct GetSongQuery { + pub apply_capo: Option, +} diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml deleted file mode 100644 index d72aad6..0000000 --- a/crates/api/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "api" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = { workspace = true } -axum = { version = "0.8.8", features = ["macros"] } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tower-http = { version = "0.6.8", features = [ - "cors", - "fs", - "trace", - "tracing", -] } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -uuid = { workspace = true } -rand = { workspace = true } - -persistence = { path = "../infrastructure/persistence" } -common = { path = "../common" } -domain = { path = "../domain" } -ug-parser = { path = "../infrastructure/ug-parser" } diff --git a/crates/api/src/routes/songs.rs b/crates/api/src/routes/songs.rs deleted file mode 100644 index 369669d..0000000 --- a/crates/api/src/routes/songs.rs +++ /dev/null @@ -1,142 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - Json, -}; -use domain::{ChordTransposer, RepositoryError, SortField, SortOrder}; -use serde::Deserialize; -use std::sync::Arc; -use uuid::Uuid; - -#[derive(Deserialize)] -pub struct ListQuery { - pub q: Option, - pub sort: Option, - pub order: Option, -} - -use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html}; - -pub async fn create_song( - State(state): State>, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let html = resolve_html(&state, body).await.map_err(|e| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })) - })?; - - let song = state.parser.parse(&html).map_err(|e| { - (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() })) - })?; - - let stored = state.songs.save(&song).await.map_err(|e| { - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })) - })?; - - Ok(Json(stored)) -} - -pub async fn list_songs( - State(state): State>, - Query(params): Query, -) -> Result>, (StatusCode, Json)> { - let sort = match params.sort.as_deref() { - Some("title") => SortField::Title, - Some("artist") => SortField::Artist, - _ => SortField::Date, - }; - let order = match params.order.as_deref() { - Some("asc") => SortOrder::Asc, - _ => SortOrder::Desc, - }; - let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { - state.search.search(&q, sort, order).await - } else { - state.songs.list(sort, order).await - }; - result - .map(Json) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))) -} - -#[derive(serde::Deserialize)] -pub struct UpdateSongRequest { - pub title: Option, - pub artist: Option, - pub original_key: Option, -} - -pub async fn update_song( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let uuid = Uuid::parse_str(&id).map_err(|_| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) - })?; - - state.songs - .update_meta( - uuid, - body.title.as_deref(), - body.artist.as_deref(), - body.original_key.as_deref(), - ) - .await - .map(Json) - .map_err(|e| match e { - domain::RepositoryError::NotFound => - (StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })), - e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })), - }) -} - -#[derive(Deserialize)] -pub struct GetSongQuery { - pub apply_capo: Option, -} - -pub async fn get_song( - State(state): State>, - Path(id): Path, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let uuid = Uuid::parse_str(&id).map_err(|_| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) - })?; - - let song = match state.songs.get(uuid).await { - Ok(Some(s)) => s, - Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))), - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))), - }; - - let song = if params.apply_capo.unwrap_or(false) { - if let Some(capo) = song.meta.capo { - ChordTransposer.transpose_song(&song, capo as i8) - } else { - song - } - } else { - song - }; - - Ok(Json(song)) -} - -pub async fn delete_song( - State(state): State>, - Path(id): Path, -) -> Result)> { - let uuid = Uuid::parse_str(&id).map_err(|_| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) - })?; - - match state.songs.delete(uuid).await { - Ok(()) => Ok(StatusCode::NO_CONTENT), - Err(RepositoryError::NotFound) => { - Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))) - } - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))), - } -} diff --git a/crates/api/src/routes/tabs.rs b/crates/api/src/routes/tabs.rs deleted file mode 100644 index badb637..0000000 --- a/crates/api/src/routes/tabs.rs +++ /dev/null @@ -1,53 +0,0 @@ -use axum::{extract::State, http::StatusCode, Json}; -use domain::{TabFetcherPort, TabParserPort, TabSource}; -use serde::{Deserialize, Serialize}; -use std::{path::PathBuf, sync::Arc}; - -pub struct AppState { - pub fetcher: Box, - pub parser: Box, - pub songs: common::SongService, - pub search: common::SongSearchService, -} - -#[derive(Deserialize)] -pub struct ParseRequest { - pub source: Option, - pub html: Option, -} - -#[derive(Serialize)] -pub struct ErrorResponse { - pub error: String, -} - -pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result { - if let Some(raw_html) = body.html { - Ok(raw_html) - } else if let Some(source) = body.source { - let tab_source = if source.starts_with("file://") { - let path = source.trim_start_matches("file://"); - TabSource::File(PathBuf::from(path)) - } else { - TabSource::Url(source) - }; - state.fetcher.fetch(tab_source).await.map_err(|e| e.to_string()) - } else { - Err("Provide either 'source' or 'html'".into()) - } -} - -pub async fn parse_tab( - State(state): State>, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let html = resolve_html(&state, body).await.map_err(|e| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })) - })?; - - let song = state.parser.parse(&html).map_err(|e| { - (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() })) - })?; - - Ok(Json(song)) -} diff --git a/crates/common/.gitignore b/crates/application/.gitignore similarity index 100% rename from crates/common/.gitignore rename to crates/application/.gitignore diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..cba3f0c --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +uuid = { workspace = true } +domain = { workspace = true } diff --git a/crates/api/src/routes/mod.rs b/crates/application/src/lib.rs similarity index 100% rename from crates/api/src/routes/mod.rs rename to crates/application/src/lib.rs diff --git a/crates/application/src/songs/commands.rs b/crates/application/src/songs/commands.rs new file mode 100644 index 0000000..94bbc20 --- /dev/null +++ b/crates/application/src/songs/commands.rs @@ -0,0 +1,17 @@ +use domain::models::Song; +use uuid::Uuid; + +pub struct SaveSongCommand { + pub song: Song, +} + +pub struct DeleteSongCommand { + pub id: Uuid, +} + +pub struct UpdateSongMetaCommand { + pub id: Uuid, + pub title: Option, + pub artist: Option, + pub original_key: Option, +} diff --git a/crates/application/src/songs/delete_song.rs b/crates/application/src/songs/delete_song.rs new file mode 100644 index 0000000..0ede33a --- /dev/null +++ b/crates/application/src/songs/delete_song.rs @@ -0,0 +1,8 @@ +use domain::errors::DomainError; + +use super::commands::DeleteSongCommand; +use super::deps::SongCommandDeps; + +pub async fn execute(deps: &SongCommandDeps, cmd: DeleteSongCommand) -> Result<(), DomainError> { + deps.repo.delete(cmd.id).await +} diff --git a/crates/application/src/songs/deps.rs b/crates/application/src/songs/deps.rs new file mode 100644 index 0000000..42eda02 --- /dev/null +++ b/crates/application/src/songs/deps.rs @@ -0,0 +1,11 @@ +use domain::ports::{SongRepositoryPort, SongSearchPort}; +use std::sync::Arc; + +pub struct SongCommandDeps { + pub repo: Arc, +} + +pub struct SongQueryDeps { + pub repo: Arc, + pub search: Arc, +} diff --git a/crates/application/src/songs/get_song.rs b/crates/application/src/songs/get_song.rs new file mode 100644 index 0000000..f252b83 --- /dev/null +++ b/crates/application/src/songs/get_song.rs @@ -0,0 +1,12 @@ +use domain::errors::DomainError; +use domain::models::Song; + +use super::deps::SongQueryDeps; +use super::queries::GetSongQuery; + +pub async fn execute( + deps: &SongQueryDeps, + query: GetSongQuery, +) -> Result, DomainError> { + deps.repo.get(query.id).await +} diff --git a/crates/application/src/songs/list_songs.rs b/crates/application/src/songs/list_songs.rs new file mode 100644 index 0000000..283870c --- /dev/null +++ b/crates/application/src/songs/list_songs.rs @@ -0,0 +1,12 @@ +use domain::errors::DomainError; +use domain::models::SongSummary; + +use super::deps::SongQueryDeps; +use super::queries::ListSongsQuery; + +pub async fn execute( + deps: &SongQueryDeps, + query: ListSongsQuery, +) -> Result, DomainError> { + deps.repo.list(query.sort, query.order).await +} diff --git a/crates/application/src/songs/mod.rs b/crates/application/src/songs/mod.rs new file mode 100644 index 0000000..8ae5b64 --- /dev/null +++ b/crates/application/src/songs/mod.rs @@ -0,0 +1,9 @@ +pub mod commands; +pub mod delete_song; +pub mod deps; +pub mod get_song; +pub mod list_songs; +pub mod queries; +pub mod save_song; +pub mod search_songs; +pub mod update_meta; diff --git a/crates/application/src/songs/queries.rs b/crates/application/src/songs/queries.rs new file mode 100644 index 0000000..a0268a7 --- /dev/null +++ b/crates/application/src/songs/queries.rs @@ -0,0 +1,17 @@ +use domain::value_objects::{SortField, SortOrder}; +use uuid::Uuid; + +pub struct ListSongsQuery { + pub sort: SortField, + pub order: SortOrder, +} + +pub struct GetSongQuery { + pub id: Uuid, +} + +pub struct SearchSongsQuery { + pub query: String, + pub sort: SortField, + pub order: SortOrder, +} diff --git a/crates/application/src/songs/save_song.rs b/crates/application/src/songs/save_song.rs new file mode 100644 index 0000000..33cffd2 --- /dev/null +++ b/crates/application/src/songs/save_song.rs @@ -0,0 +1,12 @@ +use domain::errors::DomainError; +use domain::models::StoredSong; + +use super::commands::SaveSongCommand; +use super::deps::SongCommandDeps; + +pub async fn execute( + deps: &SongCommandDeps, + cmd: SaveSongCommand, +) -> Result { + deps.repo.save(&cmd.song).await +} diff --git a/crates/application/src/songs/search_songs.rs b/crates/application/src/songs/search_songs.rs new file mode 100644 index 0000000..09fccbf --- /dev/null +++ b/crates/application/src/songs/search_songs.rs @@ -0,0 +1,14 @@ +use domain::errors::DomainError; +use domain::models::SongSummary; + +use super::deps::SongQueryDeps; +use super::queries::SearchSongsQuery; + +pub async fn execute( + deps: &SongQueryDeps, + query: SearchSongsQuery, +) -> Result, DomainError> { + deps.search + .search(&query.query, query.sort, query.order) + .await +} diff --git a/crates/application/src/songs/update_meta.rs b/crates/application/src/songs/update_meta.rs new file mode 100644 index 0000000..3683c7f --- /dev/null +++ b/crates/application/src/songs/update_meta.rs @@ -0,0 +1,19 @@ +use domain::errors::DomainError; +use domain::models::SongSummary; + +use super::commands::UpdateSongMetaCommand; +use super::deps::SongCommandDeps; + +pub async fn execute( + deps: &SongCommandDeps, + cmd: UpdateSongMetaCommand, +) -> Result { + deps.repo + .update_meta( + cmd.id, + cmd.title.as_deref(), + cmd.artist.as_deref(), + cmd.original_key.as_deref(), + ) + .await +} diff --git a/crates/application/src/tabs/commands.rs b/crates/application/src/tabs/commands.rs new file mode 100644 index 0000000..5ebbadb --- /dev/null +++ b/crates/application/src/tabs/commands.rs @@ -0,0 +1,4 @@ +pub struct ParseTabCommand { + pub source: Option, + pub html: Option, +} diff --git a/crates/application/src/tabs/deps.rs b/crates/application/src/tabs/deps.rs new file mode 100644 index 0000000..68dd7ec --- /dev/null +++ b/crates/application/src/tabs/deps.rs @@ -0,0 +1,7 @@ +use domain::ports::{TabFetcherPort, TabParserPort}; +use std::sync::Arc; + +pub struct ParseTabDeps { + pub fetcher: Arc, + pub parser: Arc, +} diff --git a/crates/application/src/tabs/mod.rs b/crates/application/src/tabs/mod.rs new file mode 100644 index 0000000..ad4ce70 --- /dev/null +++ b/crates/application/src/tabs/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod deps; +pub mod parse_tab; diff --git a/crates/application/src/tabs/parse_tab.rs b/crates/application/src/tabs/parse_tab.rs new file mode 100644 index 0000000..78e0aa1 --- /dev/null +++ b/crates/application/src/tabs/parse_tab.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +use domain::errors::DomainError; +use domain::models::Song; +use domain::ports::TabSource; + +use super::commands::ParseTabCommand; +use super::deps::ParseTabDeps; + +pub async fn execute(deps: &ParseTabDeps, cmd: ParseTabCommand) -> Result { + let html = if let Some(raw_html) = cmd.html { + Ok(raw_html) + } else if let Some(source) = cmd.source { + let tab_source = if source.starts_with("file://") { + let path = source.trim_start_matches("file://"); + TabSource::File(PathBuf::from(path)) + } else { + TabSource::Url(source) + }; + deps.fetcher + .fetch(tab_source) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } else { + Err(DomainError::ValidationError( + "Provide either 'source' or 'html'".into(), + )) + }?; + + deps.parser + .parse(&html) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml deleted file mode 100644 index 6650ce8..0000000 --- a/crates/common/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "common" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -uuid = { workspace = true } -rand = { workspace = true } -async-trait = { workspace = true } -domain = { path = "../domain" } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs deleted file mode 100644 index d514a6a..0000000 --- a/crates/common/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder}; -use uuid::Uuid; - -pub struct SongService { - repo: Box, -} - -impl SongService { - pub fn new(repo: Box) -> Self { - Self { repo } - } - - pub async fn save(&self, song: &Song) -> Result { - self.repo.save(song).await - } - - pub async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { - self.repo.list(sort, order).await - } - - pub async fn get(&self, id: Uuid) -> Result, RepositoryError> { - self.repo.get(id).await - } - - pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { - self.repo.delete(id).await - } - - pub async fn update_meta( - &self, - id: Uuid, - title: Option<&str>, - artist: Option<&str>, - original_key: Option<&str>, - ) -> Result { - self.repo.update_meta(id, title, artist, original_key).await - } -} - -pub struct SongSearchService { - search: Box, -} - -impl SongSearchService { - pub fn new(search: Box) -> Self { - Self { search } - } - - pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, domain::RepositoryError> { - self.search.search(query, sort, order).await - } -} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 5cff72b..ce3f275 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -4,10 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -anyhow = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } uuid = { workspace = true } -rand = { workspace = true } serde = { workspace = true } async-trait = { workspace = true } diff --git a/crates/domain/src/chord.rs b/crates/domain/src/chord.rs deleted file mode 100644 index 1a4a63d..0000000 --- a/crates/domain/src/chord.rs +++ /dev/null @@ -1,92 +0,0 @@ -use serde::{Deserialize, Serialize}; -use crate::Note; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(into = "String", try_from = "String")] -pub struct Chord { - pub root: Note, - pub descriptor: Option, -} - -impl Chord { - pub fn parse(s: &str) -> Option { - let (root, consumed) = Note::parse_prefix(s)?; - let descriptor = if consumed < s.len() { - Some(s[consumed..].to_string()) - } else { - None - }; - Some(Chord { root, descriptor }) - } - - /// Display chord name. use_sharps=true → "F#m", false → "Gbm". - pub fn name(&self, use_sharps: bool) -> String { - let root_str = if use_sharps { - self.root.to_sharp_str() - } else { - self.root.to_flat_str() - }; - match &self.descriptor { - Some(d) => format!("{}{}", root_str, d), - None => root_str.to_string(), - } - } -} - -impl From for String { - fn from(c: Chord) -> String { - c.name(true) - } -} - -impl TryFrom for Chord { - type Error = String; - fn try_from(s: String) -> Result { - Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_simple() { - let c = Chord::parse("Em").unwrap(); - assert_eq!(c.root, crate::Note::E); - assert_eq!(c.descriptor.as_deref(), Some("m")); - } - - #[test] - fn parse_no_descriptor() { - let c = Chord::parse("G").unwrap(); - assert_eq!(c.root, crate::Note::G); - assert!(c.descriptor.is_none()); - } - - #[test] - fn parse_flat_root() { - let c = Chord::parse("Bb").unwrap(); - assert_eq!(c.root, crate::Note::ASharpBFlat); - assert!(c.descriptor.is_none()); - } - - #[test] - fn name_sharp() { - let c = Chord { root: crate::Note::FSharpGFlat, descriptor: Some("m".into()) }; - assert_eq!(c.name(true), "F#m"); - } - - #[test] - fn name_flat() { - let c = Chord { root: crate::Note::ASharpBFlat, descriptor: None }; - assert_eq!(c.name(false), "Bb"); - } - - #[test] - fn parse_flat_with_descriptor() { - let c = Chord::parse("Bbm").unwrap(); - assert_eq!(c.root, crate::Note::ASharpBFlat); - assert_eq!(c.descriptor.as_deref(), Some("m")); - } -} diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs new file mode 100644 index 0000000..d2025ca --- /dev/null +++ b/crates/domain/src/errors/mod.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DomainError { + #[error("Entity not found")] + NotFound, + + #[error("Business rule violation: {0}")] + ValidationError(String), + + #[error("Infrastructure failure: {0}")] + InfrastructureError(String), +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 72e426b..bfb540b 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,13 +1,17 @@ -pub mod note; -pub mod chord; -pub mod song; +pub mod errors; +pub mod models; pub mod ports; -pub mod transposer; +pub mod services; +pub mod value_objects; -pub use note::Note; -pub use chord::Chord; -pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; -pub use song::{song_preview_chords, StoredSong, SongSummary}; -pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; -pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder}; -pub use transposer::{ChordTransposer, TransposeError}; +pub use errors::DomainError; +pub use models::{ + ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong, + song_preview_chords, +}; +pub use ports::{ + FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, + TabSource, +}; +pub use services::{ChordTransposer, TransposeError}; +pub use value_objects::{Chord, Note, SortField, SortOrder}; diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs new file mode 100644 index 0000000..e96c71c --- /dev/null +++ b/crates/domain/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod song; + +pub use song::*; diff --git a/crates/domain/src/song.rs b/crates/domain/src/models/song.rs similarity index 69% rename from crates/domain/src/song.rs rename to crates/domain/src/models/song.rs index 7eaaba4..4db6551 100644 --- a/crates/domain/src/song.rs +++ b/crates/domain/src/models/song.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; -use crate::Chord; +use uuid::Uuid; + +use crate::value_objects::Chord; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChordPosition { @@ -16,8 +18,14 @@ pub struct LyricLine { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SectionKind { - Verse, Chorus, Bridge, PreChorus, - Intro, Outro, Break, Tab, + Verse, + Chorus, + Bridge, + PreChorus, + Intro, + Outro, + Break, + Tab, Other(String), } @@ -60,8 +68,6 @@ pub struct Song { pub sections: Vec
, } -use uuid::Uuid; - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredSong { pub id: Uuid, @@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Chord, Note}; - - #[test] - fn lyric_line_chord_positions() { - let line = LyricLine { - text: "A drop in the ocean".into(), - chords: vec![ - ChordPosition { offset: 0, chord: Chord { root: Note::E, descriptor: Some("m".into()) } }, - ChordPosition { offset: 8, chord: Chord { root: Note::C, descriptor: None } }, - ], - }; - assert_eq!(line.chords[0].offset, 0); - assert_eq!(line.chords[1].offset, 8); - } - - #[test] - fn section_kind_from_label() { - assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus); - assert_eq!(SectionKind::from_label("Pre-Chorus"), SectionKind::PreChorus); - assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab); - assert_eq!(SectionKind::from_label("Riff"), SectionKind::Other("Riff".into())); - } -} +#[path = "../tests/song.rs"] +mod tests; diff --git a/crates/domain/src/note.rs b/crates/domain/src/note.rs deleted file mode 100644 index 9f0b9b9..0000000 --- a/crates/domain/src/note.rs +++ /dev/null @@ -1,111 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Note { - A, ASharpBFlat, B, C, CSharpDFlat, D, - DSharpEFlat, E, F, FSharpGFlat, G, GSharpAFlat, -} - -impl Note { - pub fn semitone(&self) -> u8 { - match self { - Note::C => 0, Note::CSharpDFlat => 1, Note::D => 2, - Note::DSharpEFlat => 3, Note::E => 4, Note::F => 5, - Note::FSharpGFlat => 6, Note::G => 7, Note::GSharpAFlat => 8, - Note::A => 9, Note::ASharpBFlat => 10, Note::B => 11, - } - } - - pub fn from_semitone(s: u8) -> Note { - match s % 12 { - 0 => Note::C, 1 => Note::CSharpDFlat, 2 => Note::D, - 3 => Note::DSharpEFlat, 4 => Note::E, 5 => Note::F, - 6 => Note::FSharpGFlat, 7 => Note::G, 8 => Note::GSharpAFlat, - 9 => Note::A, 10 => Note::ASharpBFlat, 11 => Note::B, - _ => unreachable!(), - } - } - - pub fn to_sharp_str(&self) -> &'static str { - match self { - Note::C => "C", Note::CSharpDFlat => "C#", Note::D => "D", - Note::DSharpEFlat => "D#", Note::E => "E", Note::F => "F", - Note::FSharpGFlat => "F#", Note::G => "G", Note::GSharpAFlat => "G#", - Note::A => "A", Note::ASharpBFlat => "A#", Note::B => "B", - } - } - - pub fn to_flat_str(&self) -> &'static str { - match self { - Note::C => "C", Note::CSharpDFlat => "Db", Note::D => "D", - Note::DSharpEFlat => "Eb", Note::E => "E", Note::F => "F", - Note::FSharpGFlat => "Gb", Note::G => "G", Note::GSharpAFlat => "Ab", - Note::A => "A", Note::ASharpBFlat => "Bb", Note::B => "B", - } - } - - /// Parse just the note portion from the start of a string. - /// Returns (Note, chars_consumed) or None. - pub fn parse_prefix(s: &str) -> Option<(Note, usize)> { - let mut chars = s.chars(); - let root = match chars.next()? { - 'A' => Note::A, 'B' => Note::B, 'C' => Note::C, 'D' => Note::D, - 'E' => Note::E, 'F' => Note::F, 'G' => Note::G, _ => return None, - }; - match chars.next() { - Some('#') => Some((Self::sharp_of(root), 2)), - Some('b') if s.len() > 1 => { - let flatted = Self::flat_of(root)?; - Some((flatted, 2)) - } - _ => Some((root, 1)), - } - } - - pub fn parse(s: &str) -> Option { - let (note, consumed) = Self::parse_prefix(s)?; - if consumed == s.len() { Some(note) } else { None } - } - - fn sharp_of(root: Note) -> Note { - Note::from_semitone((root.semitone() + 1) % 12) - } - - fn flat_of(root: Note) -> Option { - Some(Note::from_semitone((root.semitone() + 11) % 12)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn semitone_roundtrip() { - assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A); - assert_eq!(Note::from_semitone(Note::FSharpGFlat.semitone()), Note::FSharpGFlat); - } - - #[test] - fn parse_cb_enharmonic() { - assert_eq!(Note::parse("Cb"), Some(Note::B)); - } - - #[test] - fn sharp_display() { - assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#"); - assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#"); - } - - #[test] - fn flat_display() { - assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb"); - assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db"); - } - - #[test] - fn parse_note() { - assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat)); - assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat)); - assert_eq!(Note::parse("A"), Some(Note::A)); - assert_eq!(Note::parse("X"), None); - } -} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs deleted file mode 100644 index 27fd8d3..0000000 --- a/crates/domain/src/ports.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::path::PathBuf; -use thiserror::Error; -use async_trait::async_trait; -use crate::song::Song; - -#[derive(Debug, Clone)] -pub enum TabSource { - File(PathBuf), - Url(String), -} - -#[derive(Debug, Error)] -pub enum FetchError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Network error: {0}")] - Network(String), - #[error("Response is not HTML")] - InvalidContentType, -} - -#[derive(Debug, Error)] -pub enum ParseError { - #[error("Tab content not found in HTML")] - MissingContent, - #[error("Malformed HTML: {0}")] - MalformedHtml(String), -} - -#[async_trait] -pub trait TabFetcherPort: Send + Sync { - async fn fetch(&self, source: TabSource) -> Result; -} - -pub trait TabParserPort: Send + Sync { - fn parse(&self, html: &str) -> Result; -} - -use uuid::Uuid; -use crate::song::{StoredSong, SongSummary}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SortField { - #[default] - Date, - Title, - Artist, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SortOrder { - #[default] - Desc, - Asc, -} - -#[derive(Debug, Error)] -pub enum RepositoryError { - #[error("Song not found")] - NotFound, - #[error("Database error: {0}")] - Internal(String), -} - -#[async_trait] -pub trait SongRepositoryPort: Send + Sync { - async fn save(&self, song: &Song) -> Result; - async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError>; - async fn get(&self, id: Uuid) -> Result, RepositoryError>; - async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; - async fn update_meta( - &self, - id: Uuid, - title: Option<&str>, - artist: Option<&str>, - original_key: Option<&str>, - ) -> Result; -} - -#[async_trait] -pub trait SongSearchPort: Send + Sync { - async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError>; -} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..80e2efc --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,5 @@ +pub mod repository; +pub mod tab_source; + +pub use repository::*; +pub use tab_source::*; diff --git a/crates/domain/src/ports/repository.rs b/crates/domain/src/ports/repository.rs new file mode 100644 index 0000000..b4b3866 --- /dev/null +++ b/crates/domain/src/ports/repository.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::errors::DomainError; +use crate::models::{Song, SongSummary, StoredSong}; +use crate::value_objects::{SortField, SortOrder}; + +#[async_trait] +pub trait SongRepositoryPort: Send + Sync { + async fn save(&self, song: &Song) -> Result; + async fn list( + &self, + sort: SortField, + order: SortOrder, + ) -> Result, DomainError>; + async fn get(&self, id: Uuid) -> Result, DomainError>; + async fn delete(&self, id: Uuid) -> Result<(), DomainError>; + async fn update_meta( + &self, + id: Uuid, + title: Option<&str>, + artist: Option<&str>, + original_key: Option<&str>, + ) -> Result; +} + +#[async_trait] +pub trait SongSearchPort: Send + Sync { + async fn search( + &self, + query: &str, + sort: SortField, + order: SortOrder, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/tab_source.rs b/crates/domain/src/ports/tab_source.rs new file mode 100644 index 0000000..2a21efc --- /dev/null +++ b/crates/domain/src/ports/tab_source.rs @@ -0,0 +1,37 @@ +use crate::models::Song; +use async_trait::async_trait; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub enum TabSource { + File(PathBuf), + Url(String), +} + +#[derive(Debug, Error)] +pub enum FetchError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Network error: {0}")] + Network(String), + #[error("Response is not HTML")] + InvalidContentType, +} + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("Tab content not found in HTML")] + MissingContent, + #[error("Malformed HTML: {0}")] + MalformedHtml(String), +} + +#[async_trait] +pub trait TabFetcherPort: Send + Sync { + async fn fetch(&self, source: TabSource) -> Result; +} + +pub trait TabParserPort: Send + Sync { + fn parse(&self, html: &str) -> Result; +} diff --git a/crates/domain/src/services/mod.rs b/crates/domain/src/services/mod.rs new file mode 100644 index 0000000..7d1f42d --- /dev/null +++ b/crates/domain/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod transposer; + +pub use transposer::*; diff --git a/crates/domain/src/services/transposer.rs b/crates/domain/src/services/transposer.rs new file mode 100644 index 0000000..48b9b54 --- /dev/null +++ b/crates/domain/src/services/transposer.rs @@ -0,0 +1,86 @@ +use crate::models::{ChordPosition, LyricLine, Section, Song}; +use crate::value_objects::{Chord, Note}; +use thiserror::Error; + +pub struct ChordTransposer; + +#[derive(Debug, Error)] +pub enum TransposeError { + #[error("Song has no original_key set")] + MissingOriginalKey, + #[error("Unrecognized key: {0}")] + UnrecognizedKey(String), +} + +impl ChordTransposer { + pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord { + let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8; + Chord { + root: Note::from_semitone(new_semitone), + descriptor: chord.descriptor.clone(), + } + } + + pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song { + Song { + meta: song.meta.clone(), + sections: song + .sections + .iter() + .map(|s| self.transpose_section(s, semitones)) + .collect(), + } + } + + pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result { + let original = song + .meta + .original_key + .as_deref() + .ok_or(TransposeError::MissingOriginalKey)?; + let from = Note::parse(Self::root_of(original)) + .ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?; + let to = Note::parse(Self::root_of(target_key)) + .ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?; + let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8; + Ok(self.transpose_song(song, semitones)) + } + + fn root_of(key: &str) -> &str { + if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') { + &key[..2] + } else { + &key[..1] + } + } + + fn transpose_section(&self, section: &Section, semitones: i8) -> Section { + Section { + kind: section.kind.clone(), + label: section.label.clone(), + lines: section + .lines + .iter() + .map(|l| self.transpose_line(l, semitones)) + .collect(), + } + } + + fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine { + LyricLine { + text: line.text.clone(), + chords: line + .chords + .iter() + .map(|cp| ChordPosition { + offset: cp.offset, + chord: self.transpose_chord(&cp.chord, semitones), + }) + .collect(), + } + } +} + +#[cfg(test)] +#[path = "../tests/transposer.rs"] +mod tests; diff --git a/crates/domain/src/tests/chord.rs b/crates/domain/src/tests/chord.rs new file mode 100644 index 0000000..9b45306 --- /dev/null +++ b/crates/domain/src/tests/chord.rs @@ -0,0 +1,47 @@ +use super::*; + +#[test] +fn parse_simple() { + let c = Chord::parse("Em").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::E); + assert_eq!(c.descriptor.as_deref(), Some("m")); +} + +#[test] +fn parse_no_descriptor() { + let c = Chord::parse("G").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::G); + assert!(c.descriptor.is_none()); +} + +#[test] +fn parse_flat_root() { + let c = Chord::parse("Bb").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat); + assert!(c.descriptor.is_none()); +} + +#[test] +fn name_sharp() { + let c = Chord { + root: crate::value_objects::Note::FSharpGFlat, + descriptor: Some("m".into()), + }; + assert_eq!(c.name(true), "F#m"); +} + +#[test] +fn name_flat() { + let c = Chord { + root: crate::value_objects::Note::ASharpBFlat, + descriptor: None, + }; + assert_eq!(c.name(false), "Bb"); +} + +#[test] +fn parse_flat_with_descriptor() { + let c = Chord::parse("Bbm").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat); + assert_eq!(c.descriptor.as_deref(), Some("m")); +} diff --git a/crates/domain/src/tests/note.rs b/crates/domain/src/tests/note.rs new file mode 100644 index 0000000..cf64753 --- /dev/null +++ b/crates/domain/src/tests/note.rs @@ -0,0 +1,35 @@ +use super::*; + +#[test] +fn semitone_roundtrip() { + assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A); + assert_eq!( + Note::from_semitone(Note::FSharpGFlat.semitone()), + Note::FSharpGFlat + ); +} + +#[test] +fn parse_cb_enharmonic() { + assert_eq!(Note::parse("Cb"), Some(Note::B)); +} + +#[test] +fn sharp_display() { + assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#"); + assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#"); +} + +#[test] +fn flat_display() { + assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb"); + assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db"); +} + +#[test] +fn parse_note() { + assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat)); + assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat)); + assert_eq!(Note::parse("A"), Some(Note::A)); + assert_eq!(Note::parse("X"), None); +} diff --git a/crates/domain/src/tests/song.rs b/crates/domain/src/tests/song.rs new file mode 100644 index 0000000..1c09a9c --- /dev/null +++ b/crates/domain/src/tests/song.rs @@ -0,0 +1,41 @@ +use super::*; +use crate::value_objects::{Chord, Note}; + +#[test] +fn lyric_line_chord_positions() { + let line = LyricLine { + text: "A drop in the ocean".into(), + chords: vec![ + ChordPosition { + offset: 0, + chord: Chord { + root: Note::E, + descriptor: Some("m".into()), + }, + }, + ChordPosition { + offset: 8, + chord: Chord { + root: Note::C, + descriptor: None, + }, + }, + ], + }; + assert_eq!(line.chords[0].offset, 0); + assert_eq!(line.chords[1].offset, 8); +} + +#[test] +fn section_kind_from_label() { + assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus); + assert_eq!( + SectionKind::from_label("Pre-Chorus"), + SectionKind::PreChorus + ); + assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab); + assert_eq!( + SectionKind::from_label("Riff"), + SectionKind::Other("Riff".into()) + ); +} diff --git a/crates/domain/src/tests/transposer.rs b/crates/domain/src/tests/transposer.rs new file mode 100644 index 0000000..ae7d7ec --- /dev/null +++ b/crates/domain/src/tests/transposer.rs @@ -0,0 +1,68 @@ +use super::*; +use crate::value_objects::{Chord, Note}; + +fn chord(s: &str) -> Chord { + Chord::parse(s).unwrap() +} + +#[test] +fn up_two_semitones() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Em"), 2); + assert_eq!(result.name(true), "F#m"); +} + +#[test] +fn down_two_semitones() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Em"), -2); + assert_eq!(result.name(false), "Dm"); +} + +#[test] +fn up_prefers_sharps() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("G"), 1); + assert_eq!(result.name(true), "G#"); +} + +#[test] +fn down_prefers_flats() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("G"), -1); + assert_eq!(result.name(false), "Gb"); +} + +#[test] +fn zero_unchanged() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Am7"), 0); + assert_eq!(result.descriptor.as_deref(), Some("m7")); + assert_eq!(result.root, Note::A); +} + +#[test] +fn wraps_octave() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("B"), 1); + assert_eq!(result.name(true), "C"); +} + +#[test] +fn transpose_to_key() { + let t = ChordTransposer; + let meta = crate::models::SongMeta { + title: "Test".into(), + artist: "Test".into(), + capo: None, + original_key: Some("G".into()), + tuning: None, + tempo: None, + }; + let song = crate::models::Song { + meta, + sections: vec![], + }; + let result = t.transpose_to_key(&song, "A").unwrap(); + assert_eq!(result.sections.len(), 0); +} diff --git a/crates/domain/src/transposer.rs b/crates/domain/src/transposer.rs deleted file mode 100644 index 4f80c3b..0000000 --- a/crates/domain/src/transposer.rs +++ /dev/null @@ -1,133 +0,0 @@ -use thiserror::Error; -use crate::{Chord, Note, Song, Section, LyricLine, ChordPosition}; - -pub struct ChordTransposer; - -#[derive(Debug, Error)] -pub enum TransposeError { - #[error("Song has no original_key set")] - MissingOriginalKey, - #[error("Unrecognized key: {0}")] - UnrecognizedKey(String), -} - -impl ChordTransposer { - pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord { - let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8; - Chord { - root: Note::from_semitone(new_semitone), - descriptor: chord.descriptor.clone(), - } - } - - pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song { - Song { - meta: song.meta.clone(), - sections: song.sections.iter().map(|s| self.transpose_section(s, semitones)).collect(), - } - } - - pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result { - let original = song.meta.original_key.as_deref() - .ok_or(TransposeError::MissingOriginalKey)?; - let from = Note::parse(Self::root_of(original)) - .ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?; - let to = Note::parse(Self::root_of(target_key)) - .ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?; - let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8; - Ok(self.transpose_song(song, semitones)) - } - - fn root_of(key: &str) -> &str { - if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') { - &key[..2] - } else { - &key[..1] - } - } - - fn transpose_section(&self, section: &Section, semitones: i8) -> Section { - Section { - kind: section.kind.clone(), - label: section.label.clone(), - lines: section.lines.iter().map(|l| self.transpose_line(l, semitones)).collect(), - } - } - - fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine { - LyricLine { - text: line.text.clone(), - chords: line.chords.iter().map(|cp| ChordPosition { - offset: cp.offset, - chord: self.transpose_chord(&cp.chord, semitones), - }).collect(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Chord, Note}; - - fn chord(s: &str) -> Chord { Chord::parse(s).unwrap() } - - #[test] - fn up_two_semitones() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("Em"), 2); - assert_eq!(result.name(true), "F#m"); - } - - #[test] - fn down_two_semitones() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("Em"), -2); - assert_eq!(result.name(false), "Dm"); - } - - #[test] - fn up_prefers_sharps() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("G"), 1); - assert_eq!(result.name(true), "G#"); - } - - #[test] - fn down_prefers_flats() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("G"), -1); - assert_eq!(result.name(false), "Gb"); - } - - #[test] - fn zero_unchanged() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("Am7"), 0); - assert_eq!(result.descriptor.as_deref(), Some("m7")); - assert_eq!(result.root, Note::A); - } - - #[test] - fn wraps_octave() { - let t = ChordTransposer; - let result = t.transpose_chord(&chord("B"), 1); - assert_eq!(result.name(true), "C"); - } - - #[test] - fn transpose_to_key() { - let t = ChordTransposer; - let meta = crate::SongMeta { - title: "Test".into(), - artist: "Test".into(), - capo: None, - original_key: Some("G".into()), - tuning: None, - tempo: None, - }; - let song = crate::Song { meta, sections: vec![] }; - let result = t.transpose_to_key(&song, "A").unwrap(); - assert_eq!(result.sections.len(), 0); - } -} diff --git a/crates/domain/src/value_objects/chord.rs b/crates/domain/src/value_objects/chord.rs new file mode 100644 index 0000000..49a320d --- /dev/null +++ b/crates/domain/src/value_objects/chord.rs @@ -0,0 +1,50 @@ +use super::Note; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub struct Chord { + pub root: Note, + pub descriptor: Option, +} + +impl Chord { + pub fn parse(s: &str) -> Option { + let (root, consumed) = Note::parse_prefix(s)?; + let descriptor = if consumed < s.len() { + Some(s[consumed..].to_string()) + } else { + None + }; + Some(Chord { root, descriptor }) + } + + pub fn name(&self, use_sharps: bool) -> String { + let root_str = if use_sharps { + self.root.to_sharp_str() + } else { + self.root.to_flat_str() + }; + match &self.descriptor { + Some(d) => format!("{}{}", root_str, d), + None => root_str.to_string(), + } + } +} + +impl From for String { + fn from(c: Chord) -> String { + c.name(true) + } +} + +impl TryFrom for Chord { + type Error = String; + fn try_from(s: String) -> Result { + Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s)) + } +} + +#[cfg(test)] +#[path = "../tests/chord.rs"] +mod tests; diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..00b7144 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,7 @@ +mod chord; +mod note; +mod sorting; + +pub use chord::*; +pub use note::*; +pub use sorting::*; diff --git a/crates/domain/src/value_objects/note.rs b/crates/domain/src/value_objects/note.rs new file mode 100644 index 0000000..e21327a --- /dev/null +++ b/crates/domain/src/value_objects/note.rs @@ -0,0 +1,129 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Note { + A, + ASharpBFlat, + B, + C, + CSharpDFlat, + D, + DSharpEFlat, + E, + F, + FSharpGFlat, + G, + GSharpAFlat, +} + +impl Note { + pub fn semitone(&self) -> u8 { + match self { + Note::C => 0, + Note::CSharpDFlat => 1, + Note::D => 2, + Note::DSharpEFlat => 3, + Note::E => 4, + Note::F => 5, + Note::FSharpGFlat => 6, + Note::G => 7, + Note::GSharpAFlat => 8, + Note::A => 9, + Note::ASharpBFlat => 10, + Note::B => 11, + } + } + + pub fn from_semitone(s: u8) -> Note { + match s % 12 { + 0 => Note::C, + 1 => Note::CSharpDFlat, + 2 => Note::D, + 3 => Note::DSharpEFlat, + 4 => Note::E, + 5 => Note::F, + 6 => Note::FSharpGFlat, + 7 => Note::G, + 8 => Note::GSharpAFlat, + 9 => Note::A, + 10 => Note::ASharpBFlat, + 11 => Note::B, + _ => unreachable!(), + } + } + + pub fn to_sharp_str(&self) -> &'static str { + match self { + Note::C => "C", + Note::CSharpDFlat => "C#", + Note::D => "D", + Note::DSharpEFlat => "D#", + Note::E => "E", + Note::F => "F", + Note::FSharpGFlat => "F#", + Note::G => "G", + Note::GSharpAFlat => "G#", + Note::A => "A", + Note::ASharpBFlat => "A#", + Note::B => "B", + } + } + + pub fn to_flat_str(&self) -> &'static str { + match self { + Note::C => "C", + Note::CSharpDFlat => "Db", + Note::D => "D", + Note::DSharpEFlat => "Eb", + Note::E => "E", + Note::F => "F", + Note::FSharpGFlat => "Gb", + Note::G => "G", + Note::GSharpAFlat => "Ab", + Note::A => "A", + Note::ASharpBFlat => "Bb", + Note::B => "B", + } + } + + pub fn parse_prefix(s: &str) -> Option<(Note, usize)> { + let mut chars = s.chars(); + let root = match chars.next()? { + 'A' => Note::A, + 'B' => Note::B, + 'C' => Note::C, + 'D' => Note::D, + 'E' => Note::E, + 'F' => Note::F, + 'G' => Note::G, + _ => return None, + }; + match chars.next() { + Some('#') => Some((Self::sharp_of(root), 2)), + Some('b') if s.len() > 1 => { + let flatted = Self::flat_of(root)?; + Some((flatted, 2)) + } + _ => Some((root, 1)), + } + } + + pub fn parse(s: &str) -> Option { + let (note, consumed) = Self::parse_prefix(s)?; + if consumed == s.len() { + Some(note) + } else { + None + } + } + + fn sharp_of(root: Note) -> Note { + Note::from_semitone((root.semitone() + 1) % 12) + } + + fn flat_of(root: Note) -> Option { + Some(Note::from_semitone((root.semitone() + 11) % 12)) + } +} + +#[cfg(test)] +#[path = "../tests/note.rs"] +mod tests; diff --git a/crates/domain/src/value_objects/sorting.rs b/crates/domain/src/value_objects/sorting.rs new file mode 100644 index 0000000..ea20aa7 --- /dev/null +++ b/crates/domain/src/value_objects/sorting.rs @@ -0,0 +1,14 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortField { + #[default] + Date, + Title, + Artist, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortOrder { + #[default] + Desc, + Asc, +} diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml new file mode 100644 index 0000000..8c334ab --- /dev/null +++ b/crates/infra-wiring/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "infra-wiring" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/api/src/config.rs b/crates/infra-wiring/src/config.rs similarity index 72% rename from crates/api/src/config.rs rename to crates/infra-wiring/src/config.rs index 650dc9b..3838c0d 100644 --- a/crates/api/src/config.rs +++ b/crates/infra-wiring/src/config.rs @@ -1,26 +1,23 @@ use std::env; #[derive(Debug)] -pub struct Config { +pub struct AppConfig { pub database_url: String, pub host: String, pub port: u16, - /// Parsed CORS origin policy pub cors_origins: CorsOrigins, } #[derive(Debug)] pub enum CorsOrigins { - /// Allow any origin (`CORS_ALLOWED_ORIGINS=*`) Any, - /// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`) List(Vec), } -impl Config { +impl AppConfig { pub fn from_env() -> Self { - let database_url = env::var("DATABASE_URL") - .unwrap_or_else(|_| "sqlite://./pocket-chords.db".into()); + let database_url = + env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./pocket-chords.db".into()); let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()); @@ -43,7 +40,12 @@ impl Config { ), }; - Self { database_url, host, port, cors_origins } + Self { + database_url, + host, + port, + cors_origins, + } } pub fn bind_addr(&self) -> String { diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs new file mode 100644 index 0000000..49be5c1 --- /dev/null +++ b/crates/infra-wiring/src/lib.rs @@ -0,0 +1,3 @@ +pub mod config; + +pub use config::{AppConfig, CorsOrigins}; diff --git a/crates/infrastructure/persistence/Cargo.toml b/crates/infrastructure/persistence/Cargo.toml deleted file mode 100644 index 643a0dd..0000000 --- a/crates/infrastructure/persistence/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "persistence" -version = "0.1.0" -edition = "2024" - -[dependencies] -sqlx = { workspace = true } -anyhow = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -uuid = { workspace = true } -rand = { workspace = true } -async-trait = { workspace = true } - -serde_json = { workspace = true } - -domain = { path = "../../domain" } -common = { path = "../../common" } diff --git a/crates/infrastructure/persistence/src/lib.rs b/crates/infrastructure/persistence/src/lib.rs deleted file mode 100644 index bf41227..0000000 --- a/crates/infrastructure/persistence/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod row; -pub mod repository; -mod search; - -pub use repository::{SqliteSongRepository, SqliteRepositoryFactory}; diff --git a/crates/infrastructure/ug-parser/src/fetcher.rs b/crates/infrastructure/ug-parser/src/fetcher.rs deleted file mode 100644 index ce2fb5e..0000000 --- a/crates/infrastructure/ug-parser/src/fetcher.rs +++ /dev/null @@ -1,59 +0,0 @@ -use async_trait::async_trait; -use domain::{FetchError, TabFetcherPort, TabSource}; - -pub struct UgTabFetcher { - client: reqwest::Client, -} - -impl UgTabFetcher { - pub fn new() -> Self { - Self { client: reqwest::Client::new() } - } -} - -impl Default for UgTabFetcher { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl TabFetcherPort for UgTabFetcher { - async fn fetch(&self, source: TabSource) -> Result { - match source { - TabSource::File(path) => { - Ok(tokio::fs::read_to_string(&path).await?) - } - TabSource::Url(url) => { - let resp = self.client.get(&url).send().await - .map_err(|e| FetchError::Network(e.to_string()))?; - let content_type = resp.headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if !content_type.contains("text/html") { - return Err(FetchError::InvalidContentType); - } - resp.text().await.map_err(|e| FetchError::Network(e.to_string())) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::TabSource; - use std::path::PathBuf; - - #[tokio::test] - async fn fetch_local_file() { - let fetcher = UgTabFetcher::new(); - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap().parent().unwrap().parent().unwrap() - .join("samples/A DROP IN THE OCEAN.html"); - let html = fetcher.fetch(TabSource::File(path)).await.unwrap(); - assert!(html.contains("[Chorus]")); - assert!(html.contains("data-name=\"Em\"")); - } -} diff --git a/crates/api/.gitignore b/crates/presentation/.gitignore similarity index 100% rename from crates/api/.gitignore rename to crates/presentation/.gitignore diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..9b5142c --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "presentation" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } +tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] } + +api-types = { workspace = true } +application = { workspace = true } +domain = { workspace = true } +infra-wiring = { workspace = true } +sqlite = { workspace = true } +ug-parser = { workspace = true } diff --git a/crates/api/src/main.rs b/crates/presentation/src/main.rs similarity index 58% rename from crates/api/src/main.rs rename to crates/presentation/src/main.rs index b0c8b9e..2fc533b 100644 --- a/crates/api/src/main.rs +++ b/crates/presentation/src/main.rs @@ -1,13 +1,18 @@ -mod config; mod routes; -use axum::{Router, http::HeaderValue, routing::{get, post}}; -use common::{SongSearchService, SongService}; -use config::{Config, CorsOrigins}; -use persistence::SqliteRepositoryFactory; +use std::sync::Arc; + +use application::songs::deps::{SongCommandDeps, SongQueryDeps}; +use application::tabs::deps::ParseTabDeps; +use axum::{ + Router, + http::HeaderValue, + routing::{get, post}, +}; +use infra_wiring::{AppConfig, CorsOrigins}; use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; use routes::tabs::{AppState, parse_tab}; -use std::sync::Arc; +use sqlite::SqliteRepositoryFactory; use tower_http::cors::{Any, CorsLayer}; use ug_parser::{UgHtmlParser, UgTabFetcher}; @@ -15,20 +20,25 @@ use ug_parser::{UgHtmlParser, UgTabFetcher}; async fn main() { tracing_subscriber::fmt::init(); - let config = Config::from_env(); + let config = AppConfig::from_env(); tracing::info!(?config, "starting with config"); let repo = SqliteRepositoryFactory::create(&config.database_url) .await .expect("failed to connect to database"); - let songs = SongService::new(Box::new(repo.clone())); - let search = SongSearchService::new(Box::new(repo)); + + let repo = Arc::new(repo); let state = Arc::new(AppState { - fetcher: Box::new(UgTabFetcher::new()), - parser: Box::new(UgHtmlParser), - songs, - search, + song_commands: SongCommandDeps { repo: repo.clone() }, + song_queries: SongQueryDeps { + repo: repo.clone(), + search: repo.clone(), + }, + tab_parser: ParseTabDeps { + fetcher: Arc::new(UgTabFetcher::new()), + parser: Arc::new(UgHtmlParser), + }, }); let cors = match config.cors_origins { @@ -39,7 +49,10 @@ async fn main() { CorsOrigins::List(ref origins) => { let parsed: Vec = origins .iter() - .map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}"))) + .map(|o| { + o.parse() + .unwrap_or_else(|_| panic!("invalid CORS origin: {o}")) + }) .collect(); CorsLayer::new() .allow_origin(parsed) @@ -51,12 +64,16 @@ async fn main() { let app = Router::new() .route("/tabs/parse", post(parse_tab)) .route("/songs", post(create_song).get(list_songs)) - .route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song)) + .route( + "/songs/{id}", + get(get_song).delete(delete_song).patch(update_song), + ) .layer(cors) .with_state(state); let addr = config.bind_addr(); - let listener = tokio::net::TcpListener::bind(&addr).await + let listener = tokio::net::TcpListener::bind(&addr) + .await .unwrap_or_else(|e| panic!("failed to bind {addr}: {e}")); tracing::info!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); diff --git a/crates/presentation/src/routes/mod.rs b/crates/presentation/src/routes/mod.rs new file mode 100644 index 0000000..6876366 --- /dev/null +++ b/crates/presentation/src/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod songs; +pub mod tabs; diff --git a/crates/presentation/src/routes/songs.rs b/crates/presentation/src/routes/songs.rs new file mode 100644 index 0000000..9b231ee --- /dev/null +++ b/crates/presentation/src/routes/songs.rs @@ -0,0 +1,203 @@ +use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest}; +use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand}; +use application::songs::queries::{ListSongsQuery, SearchSongsQuery}; +use application::tabs::commands::ParseTabCommand; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use domain::{ChordTransposer, DomainError, SortField, SortOrder}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::routes::tabs::AppState; + +pub async fn create_song( + State(state): State>, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let cmd = ParseTabCommand { + source: body.source, + html: body.html, + }; + + let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd) + .await + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + let cmd = SaveSongCommand { song }; + application::songs::save_song::execute(&state.song_commands, cmd) + .await + .map(Json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + }) +} + +pub async fn list_songs( + State(state): State>, + Query(params): Query, +) -> Result>, (StatusCode, Json)> { + let sort = match params.sort.as_deref() { + Some("title") => SortField::Title, + Some("artist") => SortField::Artist, + _ => SortField::Date, + }; + let order = match params.order.as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }; + + let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { + let query = SearchSongsQuery { + query: q, + sort, + order, + }; + application::songs::search_songs::execute(&state.song_queries, query).await + } else { + let query = ListSongsQuery { sort, order }; + application::songs::list_songs::execute(&state.song_queries, query).await + }; + + result.map(Json).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + }) +} + +pub async fn update_song( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let uuid = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid ID".into(), + }), + ) + })?; + + let cmd = UpdateSongMetaCommand { + id: uuid, + title: body.title, + artist: body.artist, + original_key: body.original_key, + }; + + application::songs::update_meta::execute(&state.song_commands, cmd) + .await + .map(Json) + .map_err(|e| match e { + DomainError::NotFound => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Not found".into(), + }), + ), + e => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ), + }) +} + +pub async fn get_song( + State(state): State>, + Path(id): Path, + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let uuid = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid ID".into(), + }), + ) + })?; + + let query = application::songs::queries::GetSongQuery { id: uuid }; + let song = match application::songs::get_song::execute(&state.song_queries, query).await { + Ok(Some(s)) => s, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Not found".into(), + }), + )); + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + )); + } + }; + + let song = if params.apply_capo.unwrap_or(false) { + if let Some(capo) = song.meta.capo { + ChordTransposer.transpose_song(&song, capo as i8) + } else { + song + } + } else { + song + }; + + Ok(Json(song)) +} + +pub async fn delete_song( + State(state): State>, + Path(id): Path, +) -> Result)> { + let uuid = Uuid::parse_str(&id).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid ID".into(), + }), + ) + })?; + + let cmd = DeleteSongCommand { id: uuid }; + match application::songs::delete_song::execute(&state.song_commands, cmd).await { + Ok(()) => Ok(StatusCode::NO_CONTENT), + Err(DomainError::NotFound) => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Not found".into(), + }), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + )), + } +} diff --git a/crates/presentation/src/routes/tabs.rs b/crates/presentation/src/routes/tabs.rs new file mode 100644 index 0000000..f2a2305 --- /dev/null +++ b/crates/presentation/src/routes/tabs.rs @@ -0,0 +1,34 @@ +use api_types::{ErrorResponse, ParseRequest}; +use application::songs::deps::{SongCommandDeps, SongQueryDeps}; +use application::tabs::commands::ParseTabCommand; +use application::tabs::deps::ParseTabDeps; +use axum::{Json, extract::State, http::StatusCode}; +use std::sync::Arc; + +pub struct AppState { + pub song_commands: SongCommandDeps, + pub song_queries: SongQueryDeps, + pub tab_parser: ParseTabDeps, +} + +pub async fn parse_tab( + State(state): State>, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let cmd = ParseTabCommand { + source: body.source, + html: body.html, + }; + + application::tabs::parse_tab::execute(&state.tab_parser, cmd) + .await + .map(Json) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + }) +}