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:
@@ -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 }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
13
crates/domain/src/errors/mod.rs
Normal file
13
crates/domain/src/errors/mod.rs
Normal 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),
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
3
crates/domain/src/models/mod.rs
Normal file
3
crates/domain/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod song;
|
||||
|
||||
pub use song::*;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
5
crates/domain/src/ports/mod.rs
Normal file
5
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod repository;
|
||||
pub mod tab_source;
|
||||
|
||||
pub use repository::*;
|
||||
pub use tab_source::*;
|
||||
35
crates/domain/src/ports/repository.rs
Normal file
35
crates/domain/src/ports/repository.rs
Normal 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>;
|
||||
}
|
||||
37
crates/domain/src/ports/tab_source.rs
Normal file
37
crates/domain/src/ports/tab_source.rs
Normal 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>;
|
||||
}
|
||||
3
crates/domain/src/services/mod.rs
Normal file
3
crates/domain/src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod transposer;
|
||||
|
||||
pub use transposer::*;
|
||||
86
crates/domain/src/services/transposer.rs
Normal file
86
crates/domain/src/services/transposer.rs
Normal 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;
|
||||
47
crates/domain/src/tests/chord.rs
Normal file
47
crates/domain/src/tests/chord.rs
Normal 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"));
|
||||
}
|
||||
35
crates/domain/src/tests/note.rs
Normal file
35
crates/domain/src/tests/note.rs
Normal 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);
|
||||
}
|
||||
41
crates/domain/src/tests/song.rs
Normal file
41
crates/domain/src/tests/song.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
68
crates/domain/src/tests/transposer.rs
Normal file
68
crates/domain/src/tests/transposer.rs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
50
crates/domain/src/value_objects/chord.rs
Normal file
50
crates/domain/src/value_objects/chord.rs
Normal 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;
|
||||
7
crates/domain/src/value_objects/mod.rs
Normal file
7
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod chord;
|
||||
mod note;
|
||||
mod sorting;
|
||||
|
||||
pub use chord::*;
|
||||
pub use note::*;
|
||||
pub use sorting::*;
|
||||
129
crates/domain/src/value_objects/note.rs
Normal file
129
crates/domain/src/value_objects/note.rs
Normal 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;
|
||||
14
crates/domain/src/value_objects/sorting.rs
Normal file
14
crates/domain/src/value_objects/sorting.rs
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user