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

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