Skip to main content

chord_progression_parser/model/
chord_detailed.rs

1use 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    /** Parses a chord head without its optional parenthesized extension list. */
22    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    /** Parses a complete chord using exact chord-type and extension matches. */
66    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
106/** Creates a chord parsing error without assigning a source position. */
107fn 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    /** Parses every supported extension using the public FromStr contract. */
124    #[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    /** Rejects extension prefixes, empty lists, and multiple parenthesis groups. */
138    #[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    /** Parses supported chord heads and rejects trailing text. */
149    #[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}