refactor: DDD/CQRS architecture, unified crate layout

- 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)
This commit is contained in:
2026-07-11 21:02:10 +02:00
parent a520251dab
commit d13df586dd
74 changed files with 1493 additions and 1076 deletions

136
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"]

28
Makefile Normal file
View File

@@ -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

View File

@@ -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 }

View File

@@ -0,0 +1,5 @@
pub mod repository;
mod row;
mod search;
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};

View File

@@ -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<StoredSong, RepositoryError> {
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError> {
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<Vec<SongSummary>, RepositoryError> {
async fn list(
&self,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, 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<Option<Song>, RepositoryError> {
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<SongSummary, RepositoryError> {
) -> Result<SongSummary, 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()))?
.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<String> = 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,

View File

@@ -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<SongSummary, RepositoryError> {
let id = Uuid::parse_str(&row.id)
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
let id =
Uuid::parse_str(&row.id).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let preview_chords: Vec<String> = 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 {

View File

@@ -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<Vec<SongSummary>, RepositoryError> {
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
async fn search(
&self,
query: &str,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, 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()
}

View File

@@ -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 }

View File

@@ -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<String, FetchError> {
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;

View File

@@ -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<Vec<ChordPosition>> {
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<ChordPosition> {
@@ -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;

View File

@@ -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\""));
}

View File

@@ -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());
}

View File

@@ -0,0 +1,7 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }

View File

@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct ParseRequest {
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Deserialize)]
pub struct ListQuery {
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateSongRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}
#[derive(Deserialize)]
pub struct GetSongQuery {
pub apply_capo: Option<bool>,
}

View File

@@ -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" }

View File

@@ -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<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
pub async fn create_song(
State(state): State<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}
pub async fn update_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
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<bool>,
}
pub async fn get_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<GetSongQuery>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
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() }))),
}
}

View File

@@ -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<dyn TabFetcherPort>,
pub parser: Box<dyn TabParserPort>,
pub songs: common::SongService,
pub search: common::SongSearchService,
}
#[derive(Deserialize)]
pub struct ParseRequest {
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result<String, String> {
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<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
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))
}

View File

@@ -0,0 +1,8 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { workspace = true }
domain = { workspace = true }

View File

@@ -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<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}

View File

@@ -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
}

View File

@@ -0,0 +1,11 @@
use domain::ports::{SongRepositoryPort, SongSearchPort};
use std::sync::Arc;
pub struct SongCommandDeps {
pub repo: Arc<dyn SongRepositoryPort>,
}
pub struct SongQueryDeps {
pub repo: Arc<dyn SongRepositoryPort>,
pub search: Arc<dyn SongSearchPort>,
}

View File

@@ -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<Option<Song>, DomainError> {
deps.repo.get(query.id).await
}

View File

@@ -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<Vec<SongSummary>, DomainError> {
deps.repo.list(query.sort, query.order).await
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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<StoredSong, DomainError> {
deps.repo.save(&cmd.song).await
}

View File

@@ -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<Vec<SongSummary>, DomainError> {
deps.search
.search(&query.query, query.sort, query.order)
.await
}

View File

@@ -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<SongSummary, DomainError> {
deps.repo
.update_meta(
cmd.id,
cmd.title.as_deref(),
cmd.artist.as_deref(),
cmd.original_key.as_deref(),
)
.await
}

View File

@@ -0,0 +1,4 @@
pub struct ParseTabCommand {
pub source: Option<String>,
pub html: Option<String>,
}

View File

@@ -0,0 +1,7 @@
use domain::ports::{TabFetcherPort, TabParserPort};
use std::sync::Arc;
pub struct ParseTabDeps {
pub fetcher: Arc<dyn TabFetcherPort>,
pub parser: Arc<dyn TabParserPort>,
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod deps;
pub mod parse_tab;

View File

@@ -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<Song, DomainError> {
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()))
}

View File

@@ -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" }

View File

@@ -1,52 +0,0 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
use uuid::Uuid;
pub struct SongService {
repo: Box<dyn SongRepositoryPort>,
}
impl SongService {
pub fn new(repo: Box<dyn SongRepositoryPort>) -> Self {
Self { repo }
}
pub async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
self.repo.save(song).await
}
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
self.repo.list(sort, order).await
}
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<domain::SongSummary, domain::RepositoryError> {
self.repo.update_meta(id, title, artist, original_key).await
}
}
pub struct SongSearchService {
search: Box<dyn SongSearchPort>,
}
impl SongSearchService {
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
Self { search }
}
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query, sort, order).await
}
}

View File

@@ -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 }

View File

@@ -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<String>,
}
impl Chord {
pub fn parse(s: &str) -> Option<Self> {
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<Chord> for String {
fn from(c: Chord) -> String {
c.name(true)
}
}
impl TryFrom<String> for Chord {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
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"));
}
}

View File

@@ -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),
}

View File

@@ -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};

View File

@@ -0,0 +1,3 @@
pub mod song;
pub use song::*;

View File

@@ -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<Section>,
}
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<String> {
}
#[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;

View File

@@ -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<Note> {
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<Note> {
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);
}
}

View File

@@ -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<String, FetchError>;
}
pub trait TabParserPort: Send + Sync {
fn parse(&self, html: &str) -> Result<Song, ParseError>;
}
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<StoredSong, RepositoryError>;
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<SongSummary, RepositoryError>;
}
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
}

View File

@@ -0,0 +1,5 @@
pub mod repository;
pub mod tab_source;
pub use repository::*;
pub use tab_source::*;

View File

@@ -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<StoredSong, DomainError>;
async fn list(
&self,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<SongSummary, DomainError>;
}
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(
&self,
query: &str,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError>;
}

View File

@@ -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<String, FetchError>;
}
pub trait TabParserPort: Send + Sync {
fn parse(&self, html: &str) -> Result<Song, ParseError>;
}

View File

@@ -0,0 +1,3 @@
pub mod transposer;
pub use transposer::*;

View File

@@ -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<Song, TransposeError> {
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;

View File

@@ -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"));
}

View File

@@ -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);
}

View File

@@ -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())
);
}

View File

@@ -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);
}

View File

@@ -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<Song, TransposeError> {
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);
}
}

View File

@@ -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<String>,
}
impl Chord {
pub fn parse(s: &str) -> Option<Self> {
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<Chord> for String {
fn from(c: Chord) -> String {
c.name(true)
}
}
impl TryFrom<String> for Chord {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
}
}
#[cfg(test)]
#[path = "../tests/chord.rs"]
mod tests;

View File

@@ -0,0 +1,7 @@
mod chord;
mod note;
mod sorting;
pub use chord::*;
pub use note::*;
pub use sorting::*;

View File

@@ -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<Note> {
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<Note> {
Some(Note::from_semitone((root.semitone() + 11) % 12))
}
}
#[cfg(test)]
#[path = "../tests/note.rs"]
mod tests;

View File

@@ -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,
}

View File

@@ -0,0 +1,6 @@
[package]
name = "infra-wiring"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -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<String>),
}
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 {

View File

@@ -0,0 +1,3 @@
pub mod config;
pub use config::{AppConfig, CorsOrigins};

View File

@@ -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" }

View File

@@ -1,5 +0,0 @@
mod row;
pub mod repository;
mod search;
pub use repository::{SqliteSongRepository, SqliteRepositoryFactory};

View File

@@ -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<String, FetchError> {
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\""));
}
}

View File

@@ -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 }

View File

@@ -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<HeaderValue> = 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();

View File

@@ -0,0 +1,2 @@
pub mod songs;
pub mod tabs;

View File

@@ -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<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<GetSongQuery>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
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(),
}),
)),
}
}

View File

@@ -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<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
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(),
}),
)
})
}