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

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

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

@@ -0,0 +1,5 @@
pub mod fetcher;
pub mod parser;
pub use fetcher::UgTabFetcher;
pub use parser::UgHtmlParser;

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

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