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:
11
crates/adapters/sqlite/Cargo.toml
Normal file
11
crates/adapters/sqlite/Cargo.toml
Normal 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 }
|
||||
9
crates/adapters/sqlite/migrations/001_songs.sql
Normal file
9
crates/adapters/sqlite/migrations/001_songs.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
original_key TEXT,
|
||||
preview_chords TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod repository;
|
||||
mod row;
|
||||
mod search;
|
||||
|
||||
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||
172
crates/adapters/sqlite/src/repository.rs
Normal file
172
crates/adapters/sqlite/src/repository.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
|
||||
song_preview_chords,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteSongRepository {
|
||||
pub(crate) pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSongRepository {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SongRepositoryPort for SqliteSongRepository {
|
||||
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| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let preview = song_preview_chords(song);
|
||||
let preview_json = serde_json::to_string(&preview)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let original_key = song.meta.original_key.as_deref();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&song.meta.title)
|
||||
.bind(&song.meta.artist)
|
||||
.bind(original_key)
|
||||
.bind(&preview_json)
|
||||
.bind(&body)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(StoredSong {
|
||||
id,
|
||||
song: song.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.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>, 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 = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.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| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Some(song))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Err(DomainError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_meta(
|
||||
&self,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> 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 = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.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| 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| 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 = ?",
|
||||
)
|
||||
.bind(new_title)
|
||||
.bind(new_artist)
|
||||
.bind(new_key)
|
||||
.bind(&new_body)
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
meta: song.meta,
|
||||
preview_chords,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteRepositoryFactory;
|
||||
|
||||
impl SqliteRepositoryFactory {
|
||||
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||
SqliteSongRepository::new(database_url).await
|
||||
}
|
||||
}
|
||||
42
crates/adapters/sqlite/src/row.rs
Normal file
42
crates/adapters/sqlite/src/row.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct SongRow {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) artist: String,
|
||||
pub(crate) original_key: Option<String>,
|
||||
pub(crate) preview_chords: String,
|
||||
pub(crate) body: String,
|
||||
}
|
||||
|
||||
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::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",
|
||||
}
|
||||
}
|
||||
|
||||
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| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
meta: SongMeta {
|
||||
title: row.title,
|
||||
artist: row.artist,
|
||||
original_key: row.original_key,
|
||||
capo: None,
|
||||
tuning: None,
|
||||
tempo: None,
|
||||
},
|
||||
preview_chords,
|
||||
})
|
||||
}
|
||||
34
crates/adapters/sqlite/src/search.rs
Normal file
34
crates/adapters/sqlite/src/search.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{DomainError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||
|
||||
use crate::repository::SqliteSongRepository;
|
||||
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>, 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 \
|
||||
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
|
||||
sort_clause(sort, order)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
}
|
||||
11
crates/adapters/ug-parser/Cargo.toml
Normal file
11
crates/adapters/ug-parser/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "ug-parser"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
scraper = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
52
crates/adapters/ug-parser/src/fetcher.rs
Normal 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;
|
||||
5
crates/adapters/ug-parser/src/lib.rs
Normal file
5
crates/adapters/ug-parser/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod fetcher;
|
||||
pub mod parser;
|
||||
|
||||
pub use fetcher::UgTabFetcher;
|
||||
pub use parser::UgHtmlParser;
|
||||
235
crates/adapters/ug-parser/src/parser.rs
Normal file
235
crates/adapters/ug-parser/src/parser.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use domain::{
|
||||
Chord, ChordPosition, LyricLine, ParseError, Section, SectionKind, Song, SongMeta,
|
||||
TabParserPort,
|
||||
};
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
pub struct UgHtmlParser;
|
||||
|
||||
impl UgHtmlParser {
|
||||
fn parse_meta(document: &Html) -> Result<SongMeta, ParseError> {
|
||||
let title_sel = Selector::parse("title").unwrap();
|
||||
let raw_title = document
|
||||
.select(&title_sel)
|
||||
.next()
|
||||
.map(|el| el.text().collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let raw_title = raw_title.trim();
|
||||
|
||||
let (title, artist) = if let Some(by_pos) = raw_title.rfind(" by ") {
|
||||
let after_by = &raw_title[by_pos + 4..];
|
||||
let artist = after_by
|
||||
.split(" @ ")
|
||||
.next()
|
||||
.unwrap_or(after_by)
|
||||
.trim()
|
||||
.to_string();
|
||||
let title_part = raw_title[..by_pos].trim();
|
||||
// Strip leading "(N) "
|
||||
let title_part = title_part
|
||||
.trim_start_matches(|c: char| c == '(' || c.is_numeric() || c == ')' || c == ' ');
|
||||
// Strip " CHORDS (ver N)" suffix
|
||||
let title_part = title_part
|
||||
.split(" CHORDS")
|
||||
.next()
|
||||
.unwrap_or(title_part)
|
||||
.trim();
|
||||
let title_cased = title_part
|
||||
.split_whitespace()
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().to_string() + &c.as_str().to_lowercase(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(title_cased, artist)
|
||||
} else {
|
||||
(raw_title.to_string(), String::new())
|
||||
};
|
||||
|
||||
let span_sel = Selector::parse("span").unwrap();
|
||||
let mut capo: Option<u8> = None;
|
||||
let mut found_capo_label = false;
|
||||
for span in document.select(&span_sel) {
|
||||
let text = span.text().collect::<String>();
|
||||
let text = text.trim().to_string();
|
||||
if text == "Capo: " || text == "Capo:" {
|
||||
found_capo_label = true;
|
||||
} else if found_capo_label {
|
||||
if text != "No capo" && !text.is_empty() {
|
||||
capo = text.parse::<u8>().ok();
|
||||
}
|
||||
found_capo_label = false;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SongMeta {
|
||||
title,
|
||||
artist,
|
||||
capo,
|
||||
original_key: None,
|
||||
tuning: None,
|
||||
tempo: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sections(document: &Html) -> Result<Vec<Section>, ParseError> {
|
||||
let pre_sel = Selector::parse("pre").unwrap();
|
||||
let pre = document
|
||||
.select(&pre_sel)
|
||||
.next()
|
||||
.ok_or(ParseError::MissingContent)?;
|
||||
|
||||
let inner_html = pre.inner_html();
|
||||
let raw_lines: Vec<&str> = inner_html.split('\n').collect();
|
||||
|
||||
let mut sections: Vec<Section> = Vec::new();
|
||||
let mut current_section: Option<Section> = None;
|
||||
let mut pending_chords: Vec<ChordPosition> = Vec::new();
|
||||
|
||||
for raw_line in &raw_lines {
|
||||
let text_only = Self::strip_html(raw_line);
|
||||
let trimmed = text_only.trim();
|
||||
|
||||
// Section header: "[Chorus]", "[Verse 1]", etc.
|
||||
if let Some(label) = Self::extract_section_label(trimmed) {
|
||||
if let Some(sec) = current_section.take() {
|
||||
sections.push(sec);
|
||||
}
|
||||
current_section = Some(Section {
|
||||
kind: SectionKind::from_label(&label),
|
||||
label: Some(label),
|
||||
lines: Vec::new(),
|
||||
});
|
||||
pending_chords.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line — flush pending chords if any (chord line with no following lyric)
|
||||
if trimmed.is_empty() {
|
||||
if !pending_chords.is_empty()
|
||||
&& let Some(sec) = current_section.as_mut()
|
||||
{
|
||||
sec.lines.push(LyricLine {
|
||||
text: String::new(),
|
||||
chords: std::mem::take(&mut pending_chords),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chord line: contains span elements
|
||||
if raw_line.contains("<span") {
|
||||
pending_chords = Self::parse_chord_line(raw_line);
|
||||
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 {
|
||||
text: trimmed.to_string(),
|
||||
chords: std::mem::take(&mut pending_chords),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sec) = current_section {
|
||||
sections.push(sec);
|
||||
}
|
||||
|
||||
Ok(sections)
|
||||
}
|
||||
|
||||
/// Strip all HTML tags, returning plain text.
|
||||
fn strip_html(s: &str) -> String {
|
||||
let frag = Html::parse_fragment(s);
|
||||
frag.root_element().text().collect()
|
||||
}
|
||||
|
||||
/// If `s` matches `[Label]`, return `Label`. Else None.
|
||||
fn extract_section_label(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
if s.starts_with('[') && s.ends_with(']') && s.len() > 2 {
|
||||
Some(s[1..s.len() - 1].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let frag = Html::parse_fragment(line_html);
|
||||
let root = frag.root_element();
|
||||
let mut chords = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
|
||||
for child in root.children() {
|
||||
use scraper::node::Node;
|
||||
match child.value() {
|
||||
Node::Text(text) => {
|
||||
offset += text.chars().count();
|
||||
}
|
||||
Node::Element(el) => {
|
||||
if el.name() == "span"
|
||||
&& let Some(chord_name) = el.attr("data-name")
|
||||
&& let Some(chord) = Chord::parse(chord_name)
|
||||
{
|
||||
chords.push(ChordPosition { offset, chord });
|
||||
offset += chord_name.chars().count();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
chords
|
||||
}
|
||||
}
|
||||
|
||||
impl TabParserPort for UgHtmlParser {
|
||||
fn parse(&self, html: &str) -> Result<Song, ParseError> {
|
||||
let document = Html::parse_document(html);
|
||||
let meta = Self::parse_meta(&document)?;
|
||||
let sections = Self::parse_sections(&document)?;
|
||||
Ok(Song { meta, sections })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/parser.rs"]
|
||||
mod tests;
|
||||
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal 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\""));
|
||||
}
|
||||
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user