Skip to main content

chordsketch_core/
chord.rs

1//! Chord notation parser for structured chord analysis.
2//!
3//! This module parses chord strings like `"Am"`, `"C#m7"`, `"G/B"`, and
4//! `"Dsus4"` into structured components: root note, accidental, quality,
5//! extensions, and bass note.
6//!
7//! Parsing is best-effort: if a chord string cannot be parsed structurally,
8//! [`parse_chord`] returns `None` and callers should fall back to storing
9//! the raw string only.
10
11// ---------------------------------------------------------------------------
12// Note
13// ---------------------------------------------------------------------------
14
15/// A musical note name (A through G).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Note {
18    /// The note C.
19    C,
20    /// The note D.
21    D,
22    /// The note E.
23    E,
24    /// The note F.
25    F,
26    /// The note G.
27    G,
28    /// The note A.
29    A,
30    /// The note B.
31    B,
32}
33
34impl Note {
35    /// Parses a single character into a `Note`, if valid.
36    fn from_char(c: char) -> Option<Self> {
37        match c {
38            'C' => Some(Self::C),
39            'D' => Some(Self::D),
40            'E' => Some(Self::E),
41            'F' => Some(Self::F),
42            'G' => Some(Self::G),
43            'A' => Some(Self::A),
44            'B' => Some(Self::B),
45            _ => None,
46        }
47    }
48}
49
50impl core::fmt::Display for Note {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        let s = match self {
53            Self::C => "C",
54            Self::D => "D",
55            Self::E => "E",
56            Self::F => "F",
57            Self::G => "G",
58            Self::A => "A",
59            Self::B => "B",
60        };
61        f.write_str(s)
62    }
63}
64
65// ---------------------------------------------------------------------------
66// Accidental
67// ---------------------------------------------------------------------------
68
69/// A sharp or flat modifier on a note.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum Accidental {
72    /// Sharp (`#`).
73    Sharp,
74    /// Flat (`b`).
75    Flat,
76}
77
78impl core::fmt::Display for Accidental {
79    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        match self {
81            Self::Sharp => f.write_str("#"),
82            Self::Flat => f.write_str("b"),
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// ChordQuality
89// ---------------------------------------------------------------------------
90
91/// The quality (type) of a chord.
92///
93/// This enum captures the most common chord qualities found in popular music
94/// notation. Extended or unusual qualities are handled via the `extension`
95/// field on [`ChordDetail`].
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum ChordQuality {
98    /// Major chord (default when no quality marker is present).
99    Major,
100    /// Minor chord (`m` or `min`).
101    Minor,
102    /// Diminished chord (`dim` or `°`).
103    Diminished,
104    /// Augmented chord (`aug` or `+`).
105    Augmented,
106}
107
108impl core::fmt::Display for ChordQuality {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        match self {
111            Self::Major => Ok(()),
112            Self::Minor => f.write_str("m"),
113            Self::Diminished => f.write_str("dim"),
114            Self::Augmented => f.write_str("aug"),
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// ChordDetail
121// ---------------------------------------------------------------------------
122
123/// Structured representation of a parsed chord.
124///
125/// This contains the individual components extracted from a chord string.
126/// Not all chords can be parsed into this structure; for unparseable chords
127/// the AST [`Chord`](crate::ast::Chord) falls back to storing only the raw
128/// string.
129///
130/// # Examples
131///
132/// ```
133/// use chordsketch_core::chord::{ChordDetail, Note, Accidental, ChordQuality, parse_chord};
134///
135/// let detail = parse_chord("C#m7").unwrap();
136/// assert_eq!(detail.root, Note::C);
137/// assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
138/// assert_eq!(detail.quality, ChordQuality::Minor);
139/// assert_eq!(detail.extension.as_deref(), Some("7"));
140/// assert!(detail.bass_note.is_none());
141/// ```
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143pub struct ChordDetail {
144    /// The root note of the chord (e.g., C, D, E).
145    pub root: Note,
146    /// An optional sharp or flat on the root note.
147    pub root_accidental: Option<Accidental>,
148    /// The chord quality (major, minor, diminished, augmented).
149    pub quality: ChordQuality,
150    /// Optional extension string (e.g., `"7"`, `"maj7"`, `"9"`, `"sus4"`,
151    /// `"add9"`, `"7sus4"`).
152    ///
153    /// Extensions are stored as-is from the source rather than being parsed
154    /// into a sub-structure, since the variety of extensions in real-world
155    /// chord charts is vast.
156    pub extension: Option<String>,
157    /// The bass note for slash chords (e.g., `B` in `G/B`).
158    pub bass_note: Option<(Note, Option<Accidental>)>,
159}
160
161impl core::fmt::Display for ChordDetail {
162    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
163        write!(f, "{}", self.root)?;
164        if let Some(ref acc) = self.root_accidental {
165            write!(f, "{acc}")?;
166        }
167        write!(f, "{}", self.quality)?;
168        if let Some(ref ext) = self.extension {
169            f.write_str(ext)?;
170        }
171        if let Some((ref bass, ref bass_acc)) = self.bass_note {
172            write!(f, "/{bass}")?;
173            if let Some(acc) = bass_acc {
174                write!(f, "{acc}")?;
175            }
176        }
177        Ok(())
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Parser
183// ---------------------------------------------------------------------------
184
185/// Parses a chord string into a [`ChordDetail`].
186///
187/// Returns `None` if the string cannot be recognized as a valid chord
188/// notation. This function is intentionally lenient: it extracts as much
189/// structure as it can, and stores remaining text as the extension.
190///
191/// # Supported formats
192///
193/// - Basic chords: `C`, `Am`, `G`
194/// - Accidentals: `C#`, `Db`, `F#m`
195/// - Extended chords: `Cmaj7`, `Am7`, `G9`, `Dsus4`, `Cadd9`
196/// - Slash chords: `G/B`, `C/E`, `Am7/G`
197/// - Diminished/augmented: `Bdim`, `Cdim7`, `Faug`, `C+`
198///
199/// # Examples
200///
201/// ```
202/// use chordsketch_core::chord::{parse_chord, Note, ChordQuality};
203///
204/// let detail = parse_chord("Am").unwrap();
205/// assert_eq!(detail.root, Note::A);
206/// assert_eq!(detail.quality, ChordQuality::Minor);
207///
208/// // Unparseable strings return None
209/// assert!(parse_chord("").is_none());
210/// assert!(parse_chord("xyz").is_none());
211/// ```
212#[must_use]
213pub fn parse_chord(input: &str) -> Option<ChordDetail> {
214    let mut chars = input.chars().peekable();
215
216    // --- Root note ---
217    let root = Note::from_char(*chars.peek()?)?;
218    chars.next();
219
220    // --- Root accidental ---
221    let root_accidental = match chars.peek() {
222        Some('#') => {
223            chars.next();
224            Some(Accidental::Sharp)
225        }
226        Some('b') => {
227            // Distinguish 'b' as flat from 'b' starting a quality/extension.
228            // 'b' is flat only when it is NOT the start of a recognized
229            // quality or extension token that begins with 'b'. In practice,
230            // after a root note, 'b' is always flat because quality markers
231            // don't start with 'b' (minor = 'm', dim = 'd', aug = 'a').
232            // However, the note 'B' followed by 'b' (like in "Bb") is flat.
233            chars.next();
234            Some(Accidental::Flat)
235        }
236        _ => None,
237    };
238
239    // Collect the remaining characters (before any slash for bass note).
240    let rest: String = chars.collect();
241
242    // --- Split off bass note (slash chord) ---
243    let (quality_ext_str, bass_str) = if let Some(slash_pos) = rest.find('/') {
244        let (before, after) = rest.split_at(slash_pos);
245        // `after` starts with '/', skip it.
246        (before, Some(&after[1..]))
247    } else {
248        (rest.as_str(), None)
249    };
250
251    // --- Parse bass note ---
252    let bass_note = if let Some(bass) = bass_str {
253        parse_note_with_accidental(bass)
254    } else {
255        None
256    };
257
258    // If a bass string was given but couldn't be parsed, the chord is invalid.
259    if bass_str.is_some() && bass_note.is_none() {
260        // Could be something like "G/unknown" — treat as unparseable.
261        // Exception: empty bass string (trailing slash) is also invalid.
262        return None;
263    }
264
265    // --- Parse quality and extension from the remaining string ---
266    let (quality, extension) = parse_quality_and_extension(quality_ext_str);
267
268    Some(ChordDetail {
269        root,
270        root_accidental,
271        quality,
272        extension,
273        bass_note,
274    })
275}
276
277/// Parses a note letter optionally followed by `#` or `b`.
278fn parse_note_with_accidental(s: &str) -> Option<(Note, Option<Accidental>)> {
279    let mut chars = s.chars();
280    let note = Note::from_char(chars.next()?)?;
281    let accidental = match chars.next() {
282        Some('#') => Some(Accidental::Sharp),
283        Some('b') => Some(Accidental::Flat),
284        Some(_) => return None, // unexpected character after note
285        None => None,
286    };
287    // There should be nothing left after note + optional accidental.
288    if chars.next().is_some() {
289        return None;
290    }
291    Some((note, accidental))
292}
293
294/// Parses the quality and extension from the portion of a chord string after
295/// the root note and accidental, but before any slash.
296///
297/// Returns `(quality, extension)`.
298fn parse_quality_and_extension(s: &str) -> (ChordQuality, Option<String>) {
299    if s.is_empty() {
300        return (ChordQuality::Major, None);
301    }
302
303    // Try to match quality prefixes in order of specificity (longest first).
304    // Note: we must be careful with "m" — it can be the start of "maj" or "min"
305    // which have different meanings.
306
307    // Diminished
308    if let Some(rest) = s.strip_prefix("dim") {
309        let ext = non_empty_string(rest);
310        return (ChordQuality::Diminished, ext);
311    }
312
313    // Augmented (word form)
314    if let Some(rest) = s.strip_prefix("aug") {
315        let ext = non_empty_string(rest);
316        return (ChordQuality::Augmented, ext);
317    }
318
319    // Augmented (symbol form: "+")
320    if let Some(rest) = s.strip_prefix('+') {
321        let ext = non_empty_string(rest);
322        return (ChordQuality::Augmented, ext);
323    }
324
325    // "min" — minor (before "m" to avoid premature match)
326    if let Some(rest) = s.strip_prefix("min") {
327        let ext = non_empty_string(rest);
328        return (ChordQuality::Minor, ext);
329    }
330
331    // "maj" — major with an extension (e.g., "maj7")
332    if let Some(rest) = s.strip_prefix("maj") {
333        // "maj" by itself or "maj7", "maj9", etc.
334        let ext = if rest.is_empty() {
335            Some("maj".to_string())
336        } else {
337            Some(format!("maj{rest}"))
338        };
339        return (ChordQuality::Major, ext);
340    }
341
342    // "m" — minor (must come after "maj" and "min" checks)
343    if let Some(rest) = s.strip_prefix('m') {
344        // Make sure 'm' is not followed by something that indicates it's not
345        // a minor marker. For chord notation, 'm' is always minor.
346        let ext = non_empty_string(rest);
347        return (ChordQuality::Minor, ext);
348    }
349
350    // "sus" — treated as major with sus extension
351    if s.starts_with("sus") {
352        return (ChordQuality::Major, Some(s.to_string()));
353    }
354
355    // "add" — treated as major with add extension
356    if s.starts_with("add") {
357        return (ChordQuality::Major, Some(s.to_string()));
358    }
359
360    // Numeric extension on a major chord (e.g., "7", "9", "11", "13", "6")
361    if s.starts_with(|c: char| c.is_ascii_digit()) {
362        return (ChordQuality::Major, Some(s.to_string()));
363    }
364
365    // "°" — diminished symbol
366    if let Some(rest) = s.strip_prefix('°') {
367        let ext = non_empty_string(rest);
368        return (ChordQuality::Diminished, ext);
369    }
370
371    // If nothing matched, store the entire remaining string as extension
372    // on a major chord. This handles unusual notations gracefully.
373    (ChordQuality::Major, Some(s.to_string()))
374}
375
376/// Returns `Some(s.to_string())` if `s` is non-empty, otherwise `None`.
377fn non_empty_string(s: &str) -> Option<String> {
378    if s.is_empty() {
379        None
380    } else {
381        Some(s.to_string())
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    // -- Helper --------------------------------------------------------------
394
395    /// Shorthand: parse and unwrap.
396    fn pd(input: &str) -> ChordDetail {
397        parse_chord(input).unwrap_or_else(|| panic!("expected Some for chord '{input}'"))
398    }
399
400    // -- Basic major chords --------------------------------------------------
401
402    #[test]
403    fn basic_major_chords() {
404        for (input, expected_root) in [
405            ("C", Note::C),
406            ("D", Note::D),
407            ("E", Note::E),
408            ("F", Note::F),
409            ("G", Note::G),
410            ("A", Note::A),
411            ("B", Note::B),
412        ] {
413            let detail = pd(input);
414            assert_eq!(detail.root, expected_root, "root for '{input}'");
415            assert_eq!(detail.root_accidental, None, "accidental for '{input}'");
416            assert_eq!(detail.quality, ChordQuality::Major, "quality for '{input}'");
417            assert_eq!(detail.extension, None, "extension for '{input}'");
418            assert_eq!(detail.bass_note, None, "bass for '{input}'");
419        }
420    }
421
422    // -- Minor chords --------------------------------------------------------
423
424    #[test]
425    fn minor_chords() {
426        let detail = pd("Am");
427        assert_eq!(detail.root, Note::A);
428        assert_eq!(detail.quality, ChordQuality::Minor);
429        assert_eq!(detail.extension, None);
430
431        let detail = pd("Em");
432        assert_eq!(detail.root, Note::E);
433        assert_eq!(detail.quality, ChordQuality::Minor);
434
435        let detail = pd("Dm");
436        assert_eq!(detail.root, Note::D);
437        assert_eq!(detail.quality, ChordQuality::Minor);
438    }
439
440    #[test]
441    fn minor_with_min_suffix() {
442        let detail = pd("Amin");
443        assert_eq!(detail.root, Note::A);
444        assert_eq!(detail.quality, ChordQuality::Minor);
445        assert_eq!(detail.extension, None);
446    }
447
448    // -- Accidentals ---------------------------------------------------------
449
450    #[test]
451    fn sharp_major() {
452        let detail = pd("C#");
453        assert_eq!(detail.root, Note::C);
454        assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
455        assert_eq!(detail.quality, ChordQuality::Major);
456    }
457
458    #[test]
459    fn flat_major() {
460        let detail = pd("Db");
461        assert_eq!(detail.root, Note::D);
462        assert_eq!(detail.root_accidental, Some(Accidental::Flat));
463        assert_eq!(detail.quality, ChordQuality::Major);
464    }
465
466    #[test]
467    fn sharp_minor() {
468        let detail = pd("F#m");
469        assert_eq!(detail.root, Note::F);
470        assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
471        assert_eq!(detail.quality, ChordQuality::Minor);
472    }
473
474    #[test]
475    fn flat_minor() {
476        let detail = pd("Bbm");
477        assert_eq!(detail.root, Note::B);
478        assert_eq!(detail.root_accidental, Some(Accidental::Flat));
479        assert_eq!(detail.quality, ChordQuality::Minor);
480    }
481
482    #[test]
483    fn bb_flat() {
484        let detail = pd("Bb");
485        assert_eq!(detail.root, Note::B);
486        assert_eq!(detail.root_accidental, Some(Accidental::Flat));
487        assert_eq!(detail.quality, ChordQuality::Major);
488    }
489
490    // -- Extended chords -----------------------------------------------------
491
492    #[test]
493    fn major_seventh() {
494        let detail = pd("Cmaj7");
495        assert_eq!(detail.root, Note::C);
496        assert_eq!(detail.quality, ChordQuality::Major);
497        assert_eq!(detail.extension.as_deref(), Some("maj7"));
498    }
499
500    #[test]
501    fn minor_seventh() {
502        let detail = pd("Am7");
503        assert_eq!(detail.root, Note::A);
504        assert_eq!(detail.quality, ChordQuality::Minor);
505        assert_eq!(detail.extension.as_deref(), Some("7"));
506    }
507
508    #[test]
509    fn dominant_seventh() {
510        let detail = pd("G7");
511        assert_eq!(detail.root, Note::G);
512        assert_eq!(detail.quality, ChordQuality::Major);
513        assert_eq!(detail.extension.as_deref(), Some("7"));
514    }
515
516    #[test]
517    fn ninth_chord() {
518        let detail = pd("G9");
519        assert_eq!(detail.root, Note::G);
520        assert_eq!(detail.quality, ChordQuality::Major);
521        assert_eq!(detail.extension.as_deref(), Some("9"));
522    }
523
524    #[test]
525    fn sus4() {
526        let detail = pd("Dsus4");
527        assert_eq!(detail.root, Note::D);
528        assert_eq!(detail.quality, ChordQuality::Major);
529        assert_eq!(detail.extension.as_deref(), Some("sus4"));
530    }
531
532    #[test]
533    fn sus2() {
534        let detail = pd("Asus2");
535        assert_eq!(detail.root, Note::A);
536        assert_eq!(detail.quality, ChordQuality::Major);
537        assert_eq!(detail.extension.as_deref(), Some("sus2"));
538    }
539
540    #[test]
541    fn add9() {
542        let detail = pd("Cadd9");
543        assert_eq!(detail.root, Note::C);
544        assert_eq!(detail.quality, ChordQuality::Major);
545        assert_eq!(detail.extension.as_deref(), Some("add9"));
546    }
547
548    #[test]
549    fn minor_major_seventh() {
550        // Cm followed by "maj7" — the 'm' is consumed as minor, then "aj7"
551        // becomes the extension. This is a known limitation.
552        // Actually let's check what happens:
553        let detail = pd("Cmmaj7");
554        assert_eq!(detail.root, Note::C);
555        assert_eq!(detail.quality, ChordQuality::Minor);
556        // After stripping 'm', rest is "maj7"
557        assert_eq!(detail.extension.as_deref(), Some("maj7"));
558    }
559
560    #[test]
561    fn seventh_sus4() {
562        let detail = pd("G7sus4");
563        assert_eq!(detail.root, Note::G);
564        assert_eq!(detail.quality, ChordQuality::Major);
565        assert_eq!(detail.extension.as_deref(), Some("7sus4"));
566    }
567
568    #[test]
569    fn sixth_chord() {
570        let detail = pd("C6");
571        assert_eq!(detail.root, Note::C);
572        assert_eq!(detail.quality, ChordQuality::Major);
573        assert_eq!(detail.extension.as_deref(), Some("6"));
574    }
575
576    #[test]
577    fn minor_sixth() {
578        let detail = pd("Am6");
579        assert_eq!(detail.root, Note::A);
580        assert_eq!(detail.quality, ChordQuality::Minor);
581        assert_eq!(detail.extension.as_deref(), Some("6"));
582    }
583
584    #[test]
585    fn eleventh_chord() {
586        let detail = pd("G11");
587        assert_eq!(detail.root, Note::G);
588        assert_eq!(detail.quality, ChordQuality::Major);
589        assert_eq!(detail.extension.as_deref(), Some("11"));
590    }
591
592    #[test]
593    fn thirteenth_chord() {
594        let detail = pd("C13");
595        assert_eq!(detail.root, Note::C);
596        assert_eq!(detail.quality, ChordQuality::Major);
597        assert_eq!(detail.extension.as_deref(), Some("13"));
598    }
599
600    // -- Diminished and augmented --------------------------------------------
601
602    #[test]
603    fn diminished() {
604        let detail = pd("Bdim");
605        assert_eq!(detail.root, Note::B);
606        assert_eq!(detail.quality, ChordQuality::Diminished);
607        assert_eq!(detail.extension, None);
608    }
609
610    #[test]
611    fn diminished_seventh() {
612        let detail = pd("Cdim7");
613        assert_eq!(detail.root, Note::C);
614        assert_eq!(detail.quality, ChordQuality::Diminished);
615        assert_eq!(detail.extension.as_deref(), Some("7"));
616    }
617
618    #[test]
619    fn diminished_symbol() {
620        let detail = pd("B°");
621        assert_eq!(detail.root, Note::B);
622        assert_eq!(detail.quality, ChordQuality::Diminished);
623        assert_eq!(detail.extension, None);
624    }
625
626    #[test]
627    fn augmented() {
628        let detail = pd("Faug");
629        assert_eq!(detail.root, Note::F);
630        assert_eq!(detail.quality, ChordQuality::Augmented);
631        assert_eq!(detail.extension, None);
632    }
633
634    #[test]
635    fn augmented_plus_symbol() {
636        let detail = pd("C+");
637        assert_eq!(detail.root, Note::C);
638        assert_eq!(detail.quality, ChordQuality::Augmented);
639        assert_eq!(detail.extension, None);
640    }
641
642    #[test]
643    fn augmented_seventh() {
644        let detail = pd("Caug7");
645        assert_eq!(detail.root, Note::C);
646        assert_eq!(detail.quality, ChordQuality::Augmented);
647        assert_eq!(detail.extension.as_deref(), Some("7"));
648    }
649
650    // -- Slash chords --------------------------------------------------------
651
652    #[test]
653    fn slash_chord_simple() {
654        let detail = pd("G/B");
655        assert_eq!(detail.root, Note::G);
656        assert_eq!(detail.quality, ChordQuality::Major);
657        assert_eq!(detail.bass_note, Some((Note::B, None)));
658    }
659
660    #[test]
661    fn slash_chord_minor() {
662        let detail = pd("Am/E");
663        assert_eq!(detail.root, Note::A);
664        assert_eq!(detail.quality, ChordQuality::Minor);
665        assert_eq!(detail.bass_note, Some((Note::E, None)));
666    }
667
668    #[test]
669    fn slash_chord_with_accidental_bass() {
670        let detail = pd("C/Bb");
671        assert_eq!(detail.root, Note::C);
672        assert_eq!(detail.bass_note, Some((Note::B, Some(Accidental::Flat))));
673    }
674
675    #[test]
676    fn slash_chord_with_sharp_bass() {
677        let detail = pd("Am/G#");
678        assert_eq!(detail.root, Note::A);
679        assert_eq!(detail.quality, ChordQuality::Minor);
680        assert_eq!(detail.bass_note, Some((Note::G, Some(Accidental::Sharp))));
681    }
682
683    #[test]
684    fn slash_chord_extended() {
685        let detail = pd("Am7/G");
686        assert_eq!(detail.root, Note::A);
687        assert_eq!(detail.quality, ChordQuality::Minor);
688        assert_eq!(detail.extension.as_deref(), Some("7"));
689        assert_eq!(detail.bass_note, Some((Note::G, None)));
690    }
691
692    #[test]
693    fn slash_chord_sharp_root() {
694        let detail = pd("F#m/E");
695        assert_eq!(detail.root, Note::F);
696        assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
697        assert_eq!(detail.quality, ChordQuality::Minor);
698        assert_eq!(detail.bass_note, Some((Note::E, None)));
699    }
700
701    // -- Invalid / unparseable -----------------------------------------------
702
703    #[test]
704    fn empty_string() {
705        assert!(parse_chord("").is_none());
706    }
707
708    #[test]
709    fn lowercase_root() {
710        // Chord roots must be uppercase.
711        assert!(parse_chord("am").is_none());
712    }
713
714    #[test]
715    fn non_note_root() {
716        assert!(parse_chord("Hm").is_none());
717        assert!(parse_chord("X").is_none());
718    }
719
720    #[test]
721    fn numeric_only() {
722        assert!(parse_chord("7").is_none());
723    }
724
725    #[test]
726    fn slash_with_invalid_bass() {
727        assert!(parse_chord("G/X").is_none());
728        assert!(parse_chord("G/").is_none());
729    }
730
731    #[test]
732    fn slash_bass_too_long() {
733        // Bass should be just a note + optional accidental.
734        assert!(parse_chord("G/Bm").is_none());
735    }
736
737    #[test]
738    fn multi_slash_is_invalid() {
739        // Multiple slashes: split on first slash so bass becomes "D/E",
740        // which is not a valid note+accidental. The chord is unparseable.
741        assert!(parse_chord("C/D/E").is_none());
742    }
743
744    // -- Display (round-trip) ------------------------------------------------
745
746    #[test]
747    fn display_basic_major() {
748        assert_eq!(pd("C").to_string(), "C");
749    }
750
751    #[test]
752    fn display_minor() {
753        assert_eq!(pd("Am").to_string(), "Am");
754    }
755
756    #[test]
757    fn display_sharp_minor_seventh() {
758        assert_eq!(pd("C#m7").to_string(), "C#m7");
759    }
760
761    #[test]
762    fn display_slash_chord() {
763        assert_eq!(pd("G/B").to_string(), "G/B");
764    }
765
766    #[test]
767    fn display_flat_chord() {
768        assert_eq!(pd("Bb").to_string(), "Bb");
769    }
770
771    #[test]
772    fn display_diminished() {
773        assert_eq!(pd("Bdim").to_string(), "Bdim");
774    }
775
776    #[test]
777    fn display_augmented() {
778        assert_eq!(pd("Faug").to_string(), "Faug");
779    }
780
781    #[test]
782    fn display_sus4() {
783        assert_eq!(pd("Dsus4").to_string(), "Dsus4");
784    }
785
786    #[test]
787    fn display_slash_with_accidental() {
788        assert_eq!(pd("C/Bb").to_string(), "C/Bb");
789    }
790
791    #[test]
792    fn display_complex_chord() {
793        assert_eq!(pd("F#m7/E").to_string(), "F#m7/E");
794    }
795
796    // -- Edge cases ----------------------------------------------------------
797
798    #[test]
799    fn maj_alone() {
800        // "Cmaj" — major quality with "maj" as extension
801        let detail = pd("Cmaj");
802        assert_eq!(detail.root, Note::C);
803        assert_eq!(detail.quality, ChordQuality::Major);
804        assert_eq!(detail.extension.as_deref(), Some("maj"));
805    }
806
807    #[test]
808    fn maj9() {
809        let detail = pd("Cmaj9");
810        assert_eq!(detail.root, Note::C);
811        assert_eq!(detail.quality, ChordQuality::Major);
812        assert_eq!(detail.extension.as_deref(), Some("maj9"));
813    }
814
815    #[test]
816    fn sharp_augmented() {
817        let detail = pd("G#+");
818        assert_eq!(detail.root, Note::G);
819        assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
820        assert_eq!(detail.quality, ChordQuality::Augmented);
821    }
822
823    #[test]
824    fn flat_diminished() {
825        let detail = pd("Ebdim");
826        assert_eq!(detail.root, Note::E);
827        assert_eq!(detail.root_accidental, Some(Accidental::Flat));
828        assert_eq!(detail.quality, ChordQuality::Diminished);
829    }
830
831    #[test]
832    fn minor_add9() {
833        let detail = pd("Amadd9");
834        assert_eq!(detail.root, Note::A);
835        assert_eq!(detail.quality, ChordQuality::Minor);
836        // After stripping 'm', rest is "add9"
837        assert_eq!(detail.extension.as_deref(), Some("add9"));
838    }
839
840    #[test]
841    fn empty_bass_string_is_invalid() {
842        // A trailing slash with no bass note should be rejected.
843        assert!(parse_chord("G/").is_none());
844    }
845}