chord_progression_parser/model/
chord_detailed.rs1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4use typeshare::typeshare;
5
6use crate::error_code::{ErrorCode, ErrorInfo};
7
8use super::{accidental::Accidental, base::Base, chord_type::ChordType, extension::Extension};
9
10#[typeshare]
11#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ChordDetailed {
14 pub base: Base,
15 pub accidental: Option<Accidental>,
16 pub chord_type: ChordType,
17 pub extensions: Vec<Extension>,
18}
19
20impl ChordDetailed {
21 pub(crate) fn from_head(value: &str) -> Result<Self, ErrorInfo> {
23 let Some(base_character) = value.chars().next() else {
24 return Err(chord_error(ErrorCode::Bs1, None));
25 };
26
27 let base = match base_character {
28 'A' => Base::A,
29 'B' => Base::B,
30 'C' => Base::C,
31 'D' => Base::D,
32 'E' => Base::E,
33 'F' => Base::F,
34 'G' => Base::G,
35 _ => return Err(chord_error(ErrorCode::Bs1, Some(value.to_string()))),
36 };
37
38 let remainder = &value[base_character.len_utf8()..];
39 let (accidental, remainder) = match remainder.chars().next() {
40 Some('#') => (Some(Accidental::Sharp), &remainder[1..]),
41 Some('b') => (Some(Accidental::Flat), &remainder[1..]),
42 _ => (None, remainder),
43 };
44
45 let chord_type = match remainder {
46 "" | "M" => ChordType::Major,
47 "m" => ChordType::Minor,
48 "aug" => ChordType::Augmented,
49 "dim" => ChordType::Diminished,
50 _ => return Err(chord_error(ErrorCode::Cho1, Some(value.to_string()))),
51 };
52
53 Ok(Self {
54 base,
55 accidental,
56 chord_type,
57 extensions: Vec::new(),
58 })
59 }
60}
61
62impl FromStr for ChordDetailed {
63 type Err = ErrorInfo;
64
65 fn from_str(value: &str) -> Result<Self, Self::Err> {
67 let Some(opening_index) = value.find('(') else {
68 if value.contains(')') {
69 return Err(chord_error(ErrorCode::Ext3, Some(value.to_string())));
70 }
71 return Self::from_head(value);
72 };
73
74 if !value.ends_with(')') {
75 return Err(chord_error(ErrorCode::Ext3, Some(value.to_string())));
76 }
77
78 let head = &value[..opening_index];
79 let extension_source = &value[opening_index + 1..value.len() - 1];
80 if extension_source
81 .chars()
82 .any(|character| matches!(character, '(' | ')'))
83 {
84 return Err(chord_error(ErrorCode::Ext4, Some(value.to_string())));
85 }
86 if extension_source.is_empty() {
87 return Err(chord_error(ErrorCode::Ext2, None));
88 }
89
90 let mut detailed = Self::from_head(head)?;
91 detailed.extensions = extension_source
92 .split(',')
93 .map(|extension| {
94 if extension.is_empty() {
95 return Err(chord_error(ErrorCode::Ext2, None));
96 }
97 Extension::from_str(extension)
98 .map_err(|_| chord_error(ErrorCode::Ext1, Some(extension.to_string())))
99 })
100 .collect::<Result<Vec<_>, _>>()?;
101
102 Ok(detailed)
103 }
104}
105
106fn chord_error(code: ErrorCode, additional_info: Option<String>) -> ErrorInfo {
108 ErrorInfo {
109 code,
110 additional_info,
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use std::str::FromStr;
117
118 use strum::VariantNames;
119
120 use super::ChordDetailed;
121 use crate::model::{base::Base, chord_type::ChordType, extension::Extension};
122
123 #[test]
125 fn parses_every_extension_exactly() {
126 for extension_source in Extension::VARIANTS {
127 let chord = ChordDetailed::from_str(&format!("C({extension_source})"))
128 .expect("a declared extension must parse");
129
130 assert_eq!(chord.base, Base::C);
131 assert_eq!(chord.chord_type, ChordType::Major);
132 assert_eq!(chord.extensions.len(), 1);
133 assert_eq!(chord.extensions[0].to_string(), *extension_source);
134 }
135 }
136
137 #[test]
139 fn rejects_ambiguous_extension_text() {
140 for input in ["C(111)", "C()", "C(7)(9)", "C(7,)"] {
141 assert!(
142 ChordDetailed::from_str(input).is_err(),
143 "unexpectedly accepted {input:?}"
144 );
145 }
146 }
147
148 #[test]
150 fn parses_chord_heads_by_complete_match() {
151 assert_eq!(
152 ChordDetailed::from_str("C#m")
153 .expect("minor chord must parse")
154 .chord_type,
155 ChordType::Minor
156 );
157 assert!(ChordDetailed::from_str("Cminor").is_err());
158 }
159}