Skip to main content

guitar_tab_generator/
lib.rs

1#![deny(clippy::correctness)]
2#![doc = include_str!("../README.md")]
3
4use serde::{Deserialize, Serialize};
5use std::num::NonZeroU8;
6use tsify_next::Tsify;
7use wasm_bindgen::prelude::*;
8
9pub(crate) mod arrangement;
10pub(crate) mod error;
11pub(crate) mod guitar;
12pub(crate) mod parser;
13pub(crate) mod pitch;
14pub(crate) mod renderer;
15pub(crate) mod string_number;
16
17/// `Arrangement` is re-exported for direct Rust consumers. The canonical 2.x access path
18/// for per-arrangement metadata is `ArrangementSet::difficulty(i)` and
19/// `ArrangementSet::max_fret_span(i)`; direct construction of `Arrangement` values is internal.
20pub use arrangement::{Arrangement, BeatVec, Line, create_arrangements};
21pub use error::{ParseError, TabError, UnplayablePitch};
22pub use guitar::{Guitar, PitchFingering, create_string_tuning};
23pub use parser::{TuningName, get_tuning_names, parse_lines};
24pub use pitch::Pitch;
25pub use renderer::render_tab;
26pub use string_number::StringNumber;
27
28/// Bench-only escape hatches the crate exposes for criterion benchmarks.
29///
30/// Two unrelated concerns share this namespace:
31///
32/// 1. **`memoize` bypasses.** `memoized_original_*` variants of `create_arrangements`
33///    and `parse_lines` skip the memoize cache so benchmarks measure the underlying
34///    work, not cache lookup cost.
35/// 2. **Internal tuning-offset helpers.** `parse_tuning` and `create_string_tuning_offset`
36///    are the `[i8; 6]` offset machinery the preset-tuning path uses; benches reach for them
37///    to construct fixtures without going through the full WASM boundary.
38///
39/// Not part of the stable 2.x API. May be removed without a major version bump.
40#[doc(hidden)]
41pub mod __bench_internals {
42    pub use crate::arrangement::memoized_original_create_arrangements;
43    pub use crate::parser::{
44        create_string_tuning_offset, memoized_original_parse_lines, parse_tuning,
45    };
46}
47
48/// Configuration bundle for one tab-generation request.
49///
50/// Crosses the WASM boundary via `tsify_next`; JS sees a camelCase interface generated
51/// alongside the `.wasm`. `num_arrangements` must be in `1..=NumArrangements::MAX`; the value is validated
52/// at the boundary and a [`TabError::NumArrangementsOutOfRange`] is thrown when out of range.
53#[derive(Debug, Clone, Deserialize, Tsify)]
54#[tsify(from_wasm_abi)]
55#[serde(rename_all = "camelCase")]
56#[non_exhaustive]
57pub struct TabInput {
58    pub input: String,
59    /// Name of the tuning preset. Accepts the case-insensitive literal `"standard"` for
60    /// standard tuning, or any variant of `TuningName` (case-insensitive, camelCase on the
61    /// wire: `"openG"`, `"dropD"`, etc.). Other strings (including the empty string) are
62    /// rejected with [`TabError::TuningNameUnknown`].
63    pub tuning_name: String,
64    pub guitar_num_frets: u8,
65    pub guitar_capo: u8,
66    pub num_arrangements: u8,
67    /// Upper bound on per-beat fret span. An aggressive value can drop the set to zero
68    /// arrangements; callers receive `Ok(set)` with `set.len == 0`, not `Err`.
69    #[tsify(optional)]
70    pub max_fret_span_filter: Option<u8>,
71}
72
73impl TabInput {
74    /// Builds a `TabInput` with `max_fret_span_filter` defaulted to `None`.
75    ///
76    /// External callers use this instead of a struct literal. Set the optional fret-span
77    /// filter with [`TabInput::with_max_fret_span_filter`].
78    #[must_use]
79    pub fn new(
80        input: impl Into<String>,
81        tuning_name: impl Into<String>,
82        guitar_num_frets: u8,
83        guitar_capo: u8,
84        num_arrangements: u8,
85    ) -> Self {
86        Self {
87            input: input.into(),
88            tuning_name: tuning_name.into(),
89            guitar_num_frets,
90            guitar_capo,
91            num_arrangements,
92            max_fret_span_filter: None,
93        }
94    }
95
96    /// Sets `max_fret_span_filter` to `Some(filter)`.
97    #[must_use]
98    pub fn with_max_fret_span_filter(mut self, filter: u8) -> Self {
99        self.max_fret_span_filter = Some(filter);
100        self
101    }
102}
103
104/// Validated count of arrangements to compute. Construction enforces `1..=NumArrangements::MAX`.
105///
106/// Constructed at the boundary by `generate_arrangements` from `TabInput::num_arrangements`.
107/// `create_arrangements` accepts this newtype rather than a raw `u8`, so the validation lives
108/// in exactly one place.
109#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
110pub struct NumArrangements(NonZeroU8);
111
112impl NumArrangements {
113    /// Upper bound enforced by `try_new`.
114    pub const MAX: u8 = 20;
115
116    /// Validates `n` is in `1..=MAX` and returns a `NumArrangements`.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`TabError::NumArrangementsOutOfRange`] for `n == 0` or `n > MAX`.
121    pub fn try_new(n: u8) -> Result<Self, TabError> {
122        match NonZeroU8::new(n) {
123            Some(nz) if n <= Self::MAX => Ok(Self(nz)),
124            _ => Err(TabError::NumArrangementsOutOfRange {
125                value: n,
126                max: Self::MAX,
127            }),
128        }
129    }
130
131    /// Returns the underlying `u8` in `1..=MAX`.
132    #[inline]
133    #[must_use]
134    pub fn get(self) -> u8 {
135        self.0.get()
136    }
137}
138
139impl From<NumArrangements> for u8 {
140    fn from(n: NumArrangements) -> Self {
141        n.get()
142    }
143}
144
145/// One beat in the normalized input echoed back from `ArrangementSet::normalized_input`.
146///
147/// Serialized as a discriminated union tagged by `kind`, so JS code can `switch (b.kind)`
148/// instead of comparing strings.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Tsify)]
150#[tsify(into_wasm_abi)]
151#[serde(tag = "kind", rename_all = "camelCase")]
152pub enum NormalizedBeat {
153    Playable { pitches: Vec<String> },
154    Rest,
155    MeasureBreak,
156}
157
158/// Opaque handle holding the result of one `generate_arrangements` call.
159///
160/// Owns the arrangements, the guitar configuration, and the normalized input shared across
161/// arrangements. Per-arrangement metadata (`difficulty`, `max_fret_span`) and the rendered
162/// tab string are reached by index through methods on the handle.
163#[derive(Debug)]
164#[wasm_bindgen]
165pub struct ArrangementSet {
166    arrangements: Vec<arrangement::Arrangement>,
167    guitar: Guitar,
168    normalized_input: Vec<NormalizedBeat>,
169}
170
171/// `ArrangementSet` indexed accessors return [`TabError::IndexOutOfBounds`] when
172/// `index >= self.len`. This is a programmer-side bounds error (the demo clamps before
173/// calling); downstream callers can branch on the typed variant to surface it differently
174/// from user-facing errors like [`TabError::TuningNameUnknown`] or
175/// [`TabError::NumArrangementsOutOfRange`].
176#[wasm_bindgen]
177impl ArrangementSet {
178    /// Number of arrangements in the set. Equal to the requested `num_arrangements`, possibly
179    /// reduced by `max_fret_span_filter` when filtering would otherwise drop below the count.
180    #[wasm_bindgen(getter)]
181    #[must_use]
182    pub fn len(&self) -> usize {
183        self.arrangements.len()
184    }
185
186    /// Returns true when `len == 0`.
187    #[wasm_bindgen(getter, js_name = "isEmpty")]
188    #[must_use]
189    pub fn is_empty(&self) -> bool {
190        self.arrangements.is_empty()
191    }
192
193    /// The per-beat input echoed back as a sequence of tagged `NormalizedBeat` variants.
194    /// Shared across all arrangements; lives once on the set.
195    ///
196    /// Returns a fresh `Vec` on each call; cache on the JS side if reading repeatedly.
197    /// `examples/wasm.html` caches the result on `state.normalizedInput` and reads from that
198    /// cache in the rerender path; that pattern is the intended consumer shape.
199    #[wasm_bindgen(getter, js_name = "normalizedInput")]
200    #[must_use]
201    pub fn normalized_input(&self) -> Vec<NormalizedBeat> {
202        self.normalized_input.clone()
203    }
204
205    /// Largest non-zero fret span across any beat in the arrangement at `index`.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`TabError::IndexOutOfBounds`] when `index >= self.len`.
210    #[wasm_bindgen(js_name = "maxFretSpan")]
211    pub fn max_fret_span(&self, index: usize) -> Result<u8, TabError> {
212        self.arrangements
213            .get(index)
214            .map(|a| a.max_fret_span())
215            .ok_or(TabError::IndexOutOfBounds {
216                index,
217                len: self.arrangements.len(),
218            })
219    }
220
221    /// Difficulty score for the arrangement at `index`. Lower is easier.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`TabError::IndexOutOfBounds`] when `index >= self.len`.
226    pub fn difficulty(&self, index: usize) -> Result<i32, TabError> {
227        self.arrangements
228            .get(index)
229            .map(|a| a.difficulty())
230            .ok_or(TabError::IndexOutOfBounds {
231                index,
232                len: self.arrangements.len(),
233            })
234    }
235
236    /// Renders the arrangement at `index` at the supplied `width`, `padding`, and optional
237    /// `playback` beat indicator. Cheap to call repeatedly with different render parameters
238    /// -- pathfinding does not re-run.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`TabError::RenderWidthTooSmall`] when `width` is below the minimum needed to
243    /// lay out one beat at the given `padding` (`min_render_width(padding)`), in addition to the
244    /// [`TabError::IndexOutOfBounds`] shared by every indexed accessor.
245    pub fn render(
246        &self,
247        index: usize,
248        width: u16,
249        padding: u8,
250        playback: Option<u16>,
251    ) -> Result<String, TabError> {
252        let arrangement = self
253            .arrangements
254            .get(index)
255            .ok_or(TabError::IndexOutOfBounds {
256                index,
257                len: self.arrangements.len(),
258            })?;
259        let min = renderer::min_render_width(padding);
260        if width < min {
261            return Err(TabError::RenderWidthTooSmall { width, min });
262        }
263        Ok(renderer::render_tab(
264            &arrangement.lines,
265            &self.guitar,
266            width,
267            padding,
268            playback,
269        ))
270    }
271}
272
273/// Generates an `ArrangementSet` from a `TabInput`. Single entry point for both Rust callers
274/// and the WASM boundary; JS sees this as `generateArrangements`.
275///
276/// # Errors
277///
278/// Returns the typed [`TabError`] variant for each failure mode reachable from this entry point:
279///
280/// - Input-shape validation: [`TabError::NumArrangementsOutOfRange`], [`TabError::TuningNameUnknown`],
281///   [`TabError::NumFretsTooHigh`], [`TabError::CapoTooHigh`], [`TabError::CapoExceedsFrets`].
282/// - Parser: [`TabError::Parse`] (carries `Vec<ParseError>` with line/text per unparseable substring),
283///   [`TabError::InputTooManyLines`] (input exceeds the 65,535-line cap).
284/// - Pathfinding: [`TabError::UnplayablePitches`] (one or more pitches reach no string),
285///   [`TabError::NoArrangementsFound`] (every pitch reaches the guitar but no valid combination exists,
286///   for example duplicate pitches in a single beat that the no-duplicate-strings constraint filters away).
287///
288/// [`TabError::OpenPitchOutOfRange`], [`TabError::StringNumberOutOfRange`], and
289/// [`TabError::FretRangeExceedsPitchRange`] are members of the enum and live on the [`Guitar::new`] path
290/// this function calls, but no `TabInput` reachable today can trip them: the preset tunings and fixed
291/// 1..=6 string numbering keep every open-string pitch and fret range well inside the supported `Pitch`
292/// range. They fire only when constructed directly through the lower-level Rust API ([`Guitar::new`],
293/// [`create_string_tuning`]) with out-of-range inputs, such as a custom tuning (deferred to a later
294/// release).
295///
296/// # Validation order
297///
298/// Input-shape errors (currently `numArrangements` range) are reported before `parse_lines`
299/// runs. The ordering is deliberate: shape checks are O(1) and unambiguous, while parse errors
300/// depend on the full input. When both are present the shape error wins because the parser's
301/// output would be discarded anyway.
302///
303/// Guitar-configuration errors (`TuningNameUnknown`, `NumFretsTooHigh`, `CapoTooHigh`,
304/// `CapoExceedsFrets`) are checked before the normalized input is built, so an invalid guitar
305/// config does not pay for the per-beat allocation. `parse_lines` still runs first, so a `Parse`
306/// error outranks a guitar-config error.
307///
308/// # Performance
309///
310/// `tab_input.input` is cloned once per call because `parse_lines` is `#[memoize]`d on owned
311/// `String`. Memoization makes a repeat call with the same input cheap, but the clone runs
312/// on every call (including cache hits). Hot loops over `generate_arrangements` should expect
313/// one `String::clone` per invocation in addition to the boundary deserialization cost.
314#[wasm_bindgen(js_name = "generateArrangements")]
315pub fn generate_arrangements(tab_input: TabInput) -> Result<ArrangementSet, TabError> {
316    let num_arrangements = NumArrangements::try_new(tab_input.num_arrangements)?;
317
318    let input_lines = parser::parse_lines(tab_input.input.clone())?;
319
320    // Validate the guitar configuration before materializing the normalized input, so a
321    // request with a valid pitch list but a bad tuning name or out-of-range fret/capo fails
322    // before allocating the per-beat `normalized_input` vector. `parse_lines` still runs
323    // first, so a `Parse` error keeps precedence over a guitar-config error.
324    let tuning = parser::create_string_tuning_offset(parser::parse_tuning(&tab_input.tuning_name)?);
325    let guitar = Guitar::new(tuning, tab_input.guitar_num_frets, tab_input.guitar_capo)?;
326
327    let first_playable_index = arrangement::first_playable_index(&input_lines);
328
329    let normalized_input: Vec<NormalizedBeat> = input_lines
330        .iter()
331        .skip(first_playable_index)
332        .map(|line| match line {
333            arrangement::Line::Playable(pitches) => NormalizedBeat::Playable {
334                pitches: pitches.iter().map(|p| p.plain_text().to_owned()).collect(),
335            },
336            arrangement::Line::Rest => NormalizedBeat::Rest,
337            arrangement::Line::MeasureBreak => NormalizedBeat::MeasureBreak,
338        })
339        .collect();
340
341    let arrangements = arrangement::create_arrangements(
342        guitar.clone(),
343        input_lines,
344        num_arrangements,
345        tab_input.max_fret_span_filter,
346    )?;
347
348    Ok(ArrangementSet {
349        arrangements,
350        guitar,
351        normalized_input,
352    })
353}
354
355#[cfg(test)]
356mod test_generate_arrangements_and_render {
357    use super::*;
358
359    #[test]
360    fn valid_input() {
361        let tab_input = TabInput {
362            input: "E2\nA2\nD3\n\nG3\nB3\n---\nE4".to_owned(),
363            tuning_name: "standard".to_owned(),
364            guitar_num_frets: 20,
365            guitar_capo: 0,
366            num_arrangements: 1,
367            max_fret_span_filter: None,
368        };
369        let set = generate_arrangements(tab_input).unwrap();
370
371        assert_eq!(set.len(), 1);
372        assert_eq!(set.max_fret_span(0).unwrap(), 0);
373
374        let tab = set.render(0, 30, 2, Some(3)).unwrap();
375        assert_eq!(
376            tab,
377            "           \u{25bc}\n--------------------|--0------\n-----------------0--|---------\n--------------0-----|---------\n--------0-----------|---------\n-----0--------------|---------\n--0-----------------|---------\n           \u{25b2}\n"
378        );
379
380        let beats = set.normalized_input();
381        assert_eq!(beats.len(), 8);
382        assert!(
383            matches!(beats[0], NormalizedBeat::Playable { ref pitches } if pitches == &["E2".to_owned()])
384        );
385        assert!(matches!(beats[3], NormalizedBeat::Rest));
386        assert!(matches!(beats[6], NormalizedBeat::MeasureBreak));
387    }
388
389    #[test]
390    fn empty_input_returns_set_with_requested_count() {
391        let tab_input = TabInput {
392            input: "\n\n\n---\n \n".to_owned(),
393            tuning_name: "standard".to_owned(),
394            guitar_num_frets: 20,
395            guitar_capo: 0,
396            num_arrangements: 2,
397            max_fret_span_filter: None,
398        };
399        let set = generate_arrangements(tab_input).unwrap();
400        assert_eq!(set.len(), 2);
401        assert_eq!(set.render(0, 30, 2, Some(3)).unwrap(), "");
402        assert_eq!(set.render(1, 30, 2, Some(3)).unwrap(), "");
403
404        // Pins the current behaviour: when no `Playable` line exists, `first_playable_index`
405        // falls back to 0 and `normalized_input` echoes every input line (the trailing
406        // `MeasureBreak` from `---` and the leading blank rests). Empty / all-rest input
407        // returns Ok(set) by design (see docs/adr/0006-empty-input-returns-empty-set.md);
408        // interactive UIs lean on this to avoid bouncing into an error pane during edits.
409        let beats = set.normalized_input();
410        assert!(
411            beats
412                .iter()
413                .all(|b| matches!(b, NormalizedBeat::Rest | NormalizedBeat::MeasureBreak))
414        );
415        assert!(
416            beats
417                .iter()
418                .any(|b| matches!(b, NormalizedBeat::MeasureBreak))
419        );
420    }
421
422    #[test]
423    fn invalid_input_returns_parse_error() {
424        let tab_input = TabInput {
425            input: "E2\nA2\nD3\n???\nG3\nB3\nE4".to_owned(),
426            tuning_name: "standard".to_owned(),
427            guitar_num_frets: 20,
428            guitar_capo: 0,
429            num_arrangements: 1,
430            max_fret_span_filter: None,
431        };
432        let err = generate_arrangements(tab_input).unwrap_err();
433        match err {
434            TabError::Parse { errors } => {
435                assert_eq!(errors.len(), 1);
436                assert_eq!(errors[0].line, 4);
437                assert_eq!(errors[0].text, "???");
438            }
439            other => panic!("expected Parse, got {other:?}"),
440        }
441    }
442
443    #[test]
444    fn num_arrangements_zero_is_invalid() {
445        let tab_input = TabInput {
446            input: "E2".to_owned(),
447            tuning_name: "standard".to_owned(),
448            guitar_num_frets: 20,
449            guitar_capo: 0,
450            num_arrangements: 0,
451            max_fret_span_filter: None,
452        };
453        let err = generate_arrangements(tab_input).unwrap_err();
454        match err {
455            TabError::NumArrangementsOutOfRange { value, max } => {
456                assert_eq!(value, 0);
457                assert_eq!(max, 20);
458            }
459            other => panic!("expected NumArrangementsOutOfRange, got {other:?}"),
460        }
461    }
462
463    #[test]
464    fn num_arrangements_above_cap_is_invalid() {
465        let tab_input = TabInput {
466            input: "E2".to_owned(),
467            tuning_name: "standard".to_owned(),
468            guitar_num_frets: 20,
469            guitar_capo: 0,
470            num_arrangements: 21,
471            max_fret_span_filter: None,
472        };
473        let err = generate_arrangements(tab_input).unwrap_err();
474        match err {
475            TabError::NumArrangementsOutOfRange { value, max } => {
476                assert_eq!(value, 21);
477                assert_eq!(max, 20);
478            }
479            other => panic!("expected NumArrangementsOutOfRange, got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn invalid_guitar_config_returns_num_frets_too_high() {
485        let tab_input = TabInput {
486            input: "E2".to_owned(),
487            tuning_name: "standard".to_owned(),
488            guitar_num_frets: 31, // exceeds Guitar::MAX_NUM_FRETS = 30
489            guitar_capo: 0,
490            num_arrangements: 1,
491            max_fret_span_filter: None,
492        };
493        let err = generate_arrangements(tab_input).unwrap_err();
494        match err {
495            TabError::NumFretsTooHigh { num_frets, max } => {
496                assert_eq!(num_frets, 31);
497                assert_eq!(max, 30);
498            }
499            other => panic!("expected NumFretsTooHigh, got {other:?}"),
500        }
501    }
502
503    #[test]
504    fn unreachable_pitch_returns_unplayable_pitches() {
505        let tab_input = TabInput {
506            input: "A1".to_owned(), // below standard tuning's low E2; unplayable on any string
507            tuning_name: "standard".to_owned(),
508            guitar_num_frets: 20,
509            guitar_capo: 0,
510            num_arrangements: 1,
511            max_fret_span_filter: None,
512        };
513        let err = generate_arrangements(tab_input).unwrap_err();
514        match err {
515            TabError::UnplayablePitches { pitches } => {
516                assert_eq!(pitches.len(), 1);
517                assert_eq!(pitches[0].value, "A1");
518                assert_eq!(pitches[0].line, 1);
519            }
520            other => panic!("expected UnplayablePitches, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn unplayable_pitch_line_accounts_for_leading_rests() {
526        // Two leading blank lines (rests) precede an unplayable pitch on input line 3.
527        // The reported line must be the 1-indexed input line (3), not the position within
528        // the post-leading-rest beat sequence (which would be 1).
529        let tab_input = TabInput {
530            input: "\n\nA1".to_owned(),
531            tuning_name: "standard".to_owned(),
532            guitar_num_frets: 20,
533            guitar_capo: 0,
534            num_arrangements: 1,
535            max_fret_span_filter: None,
536        };
537        let err = generate_arrangements(tab_input).unwrap_err();
538        match err {
539            TabError::UnplayablePitches { pitches } => {
540                assert_eq!(pitches.len(), 1);
541                assert_eq!(pitches[0].value, "A1");
542                assert_eq!(pitches[0].line, 3);
543            }
544            other => panic!("expected UnplayablePitches, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn render_at_two_widths_produces_different_outputs() {
550        let tab_input = TabInput {
551            input: "E2\nA2\nD3".to_owned(),
552            tuning_name: "standard".to_owned(),
553            guitar_num_frets: 20,
554            guitar_capo: 0,
555            num_arrangements: 1,
556            max_fret_span_filter: None,
557        };
558        let set = generate_arrangements(tab_input).unwrap();
559        let narrow = set.render(0, 12, 1, None).unwrap();
560        let wide = set.render(0, 100, 1, None).unwrap();
561        assert_ne!(narrow, wide);
562    }
563}
564
565#[cfg(test)]
566mod test_boundary_types {
567    use super::*;
568
569    #[test]
570    fn arrangement_set_len_matches_num_arrangements() {
571        let set = arrangement_set_fixture(2);
572        assert_eq!(set.len(), 2);
573    }
574
575    #[test]
576    fn arrangement_set_normalized_input_is_tagged_variants() {
577        let set = arrangement_set_fixture(1);
578        let beats = set.normalized_input();
579        assert!(matches!(beats[0], NormalizedBeat::Playable { .. }));
580    }
581
582    #[test]
583    fn arrangement_set_render_returns_string_for_in_bounds_index() {
584        let set = arrangement_set_fixture(1);
585        let tab = set.render(0, 30, 2, None).unwrap();
586        assert!(!tab.is_empty());
587    }
588
589    #[test]
590    fn arrangement_set_render_rejects_out_of_bounds_index() {
591        let set = arrangement_set_fixture(1);
592        let err = set.render(99, 30, 2, None).unwrap_err();
593        match err {
594            TabError::IndexOutOfBounds { .. } => {}
595            other => panic!("expected IndexOutOfBounds, got {other:?}"),
596        }
597    }
598
599    #[test]
600    fn arrangement_set_render_rejects_width_below_minimum() {
601        let set = arrangement_set_fixture(1);
602        // padding 1 -> min width = 2 * 1 + 2 + 1 = 5; width 3 is below it.
603        let err = set.render(0, 3, 1, None).unwrap_err();
604        assert_eq!(err, TabError::RenderWidthTooSmall { width: 3, min: 5 });
605    }
606
607    #[test]
608    fn arrangement_set_max_fret_span_returns_value_for_in_bounds_index() {
609        let set = arrangement_set_fixture(1);
610        let span = set.max_fret_span(0).unwrap();
611        assert_eq!(span, 0);
612    }
613
614    #[test]
615    fn arrangement_set_max_fret_span_rejects_out_of_bounds_index() {
616        let set = arrangement_set_fixture(1);
617        let err = set.max_fret_span(99).unwrap_err();
618        match err {
619            TabError::IndexOutOfBounds { .. } => {}
620            other => panic!("expected IndexOutOfBounds, got {other:?}"),
621        }
622    }
623
624    #[test]
625    fn arrangement_set_difficulty_returns_value_for_in_bounds_index() {
626        let set = arrangement_set_fixture(1);
627        // The fixture is all open strings (E2/A2/D3 in standard tuning), so the optimal
628        // arrangement has difficulty 0. Pin the value, not just that the call succeeds.
629        assert_eq!(set.difficulty(0).unwrap(), 0);
630    }
631
632    #[test]
633    fn arrangement_set_difficulty_rejects_out_of_bounds_index() {
634        let set = arrangement_set_fixture(1);
635        let err = set.difficulty(99).unwrap_err();
636        match err {
637            TabError::IndexOutOfBounds { .. } => {}
638            other => panic!("expected IndexOutOfBounds, got {other:?}"),
639        }
640    }
641
642    #[test]
643    fn arrangement_set_is_empty_returns_false_for_non_empty_set() {
644        let set = arrangement_set_fixture(1);
645        assert!(!set.is_empty());
646        assert_eq!(set.len(), 1);
647    }
648
649    #[test]
650    fn arrangement_set_is_empty_returns_true_when_filter_drops_every_candidate() {
651        // C3E3 forces both notes onto fretted positions in standard tuning, so every
652        // candidate arrangement has a non-zero fret span. max_fret_span_filter = Some(0)
653        // drops all of them. See src/arrangement.rs::max_fret_span_filter_can_produce_empty_set
654        // for the analogous test at the internal layer.
655        let tab_input = TabInput {
656            input: "C3E3".to_owned(),
657            tuning_name: "standard".to_owned(),
658            guitar_num_frets: 20,
659            guitar_capo: 0,
660            num_arrangements: 5,
661            max_fret_span_filter: Some(0),
662        };
663        let set = generate_arrangements(tab_input).unwrap();
664        assert!(set.is_empty());
665        assert_eq!(set.len(), 0);
666    }
667
668    #[test]
669    fn arrangement_set_render_rejects_index_when_filter_empties_the_set() {
670        // C3E3 forces both notes onto fretted positions, so max_fret_span_filter = Some(0)
671        // drops every candidate and leaves an empty set. render(0, ..) must report
672        // IndexOutOfBounds rather than reaching the renderer's non-empty guard.
673        let tab_input = TabInput {
674            input: "C3E3".to_owned(),
675            tuning_name: "standard".to_owned(),
676            guitar_num_frets: 20,
677            guitar_capo: 0,
678            num_arrangements: 5,
679            max_fret_span_filter: Some(0),
680        };
681        let set = generate_arrangements(tab_input).unwrap();
682        assert!(set.is_empty());
683        let err = set.render(0, 30, 2, None).unwrap_err();
684        assert!(
685            matches!(err, TabError::IndexOutOfBounds { index: 0, len: 0 }),
686            "got {err:?}"
687        );
688    }
689
690    fn arrangement_set_fixture(num_arrangements: u8) -> ArrangementSet {
691        let tab_input = TabInput {
692            input: "E2\nA2\nD3".to_owned(),
693            tuning_name: "standard".to_owned(),
694            guitar_num_frets: 20,
695            guitar_capo: 0,
696            num_arrangements,
697            max_fret_span_filter: None,
698        };
699        generate_arrangements(tab_input).unwrap()
700    }
701
702    #[test]
703    fn tab_input_deserializes_from_camelcase_json() {
704        let json = r#"{
705            "input": "E2\nA2",
706            "tuningName": "standard",
707            "guitarNumFrets": 18,
708            "guitarCapo": 0,
709            "numArrangements": 1,
710            "maxFretSpanFilter": null
711        }"#;
712        let input: TabInput = serde_json::from_str(json).unwrap();
713        assert_eq!(input.input, "E2\nA2");
714        assert_eq!(input.tuning_name, "standard");
715        assert_eq!(input.guitar_num_frets, 18);
716        assert_eq!(input.num_arrangements, 1);
717        assert!(input.max_fret_span_filter.is_none());
718    }
719
720    #[test]
721    fn normalized_beat_serializes_with_kind_discriminant() {
722        let playable = NormalizedBeat::Playable {
723            pitches: vec!["E2".to_owned()],
724        };
725        let json = serde_json::to_string(&playable).unwrap();
726        assert_eq!(json, r#"{"kind":"playable","pitches":["E2"]}"#);
727
728        let rest = NormalizedBeat::Rest;
729        let json = serde_json::to_string(&rest).unwrap();
730        assert_eq!(json, r#"{"kind":"rest"}"#);
731
732        let mb = NormalizedBeat::MeasureBreak;
733        let json = serde_json::to_string(&mb).unwrap();
734        assert_eq!(json, r#"{"kind":"measureBreak"}"#);
735    }
736}
737
738#[cfg(test)]
739mod test_num_arrangements {
740    use super::*;
741
742    #[test]
743    fn try_new_accepts_one_through_max() {
744        for n in 1u8..=NumArrangements::MAX {
745            assert!(NumArrangements::try_new(n).is_ok(), "n={n} must be Ok");
746        }
747    }
748
749    #[test]
750    fn try_new_rejects_zero_with_typed_variant() {
751        let err = NumArrangements::try_new(0).unwrap_err();
752        match err {
753            TabError::NumArrangementsOutOfRange { value, max } => {
754                assert_eq!(value, 0);
755                assert_eq!(max, 20);
756            }
757            other => panic!("expected NumArrangementsOutOfRange, got {other:?}"),
758        }
759    }
760
761    #[test]
762    fn try_new_rejects_above_max_with_typed_variant() {
763        let err = NumArrangements::try_new(NumArrangements::MAX + 1).unwrap_err();
764        match err {
765            TabError::NumArrangementsOutOfRange { value, max } => {
766                assert_eq!(value, 21);
767                assert_eq!(max, 20);
768            }
769            other => panic!("expected NumArrangementsOutOfRange, got {other:?}"),
770        }
771    }
772
773    #[test]
774    fn get_returns_inner_value() {
775        let n = NumArrangements::try_new(7).unwrap();
776        assert_eq!(n.get(), 7);
777    }
778}
779
780#[cfg(test)]
781mod test_tab_input {
782    use super::*;
783
784    #[test]
785    fn new_defaults_max_fret_span_filter_to_none() {
786        let input = TabInput::new("E2\nA2", "standard", 18, 0, 1);
787        assert_eq!(input.input, "E2\nA2");
788        assert_eq!(input.tuning_name, "standard");
789        assert_eq!(input.guitar_num_frets, 18);
790        assert_eq!(input.guitar_capo, 0);
791        assert_eq!(input.num_arrangements, 1);
792        assert_eq!(input.max_fret_span_filter, None);
793    }
794
795    #[test]
796    fn with_max_fret_span_filter_sets_some() {
797        let input = TabInput::new("E2", "standard", 18, 0, 1).with_max_fret_span_filter(5);
798        assert_eq!(input.max_fret_span_filter, Some(5));
799    }
800}