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