Skip to main content

chordsketch_chordpro/
render_result.rs

1//! Structured render result type for capturing warnings during rendering,
2//! plus the canonical warning-accumulation helpers used by every renderer.
3//!
4//! Shared by `chordsketch-render-text`, `chordsketch-render-html`, and
5//! `chordsketch-render-pdf`. Consolidating the helpers here eliminates
6//! three maintenance points for the same logic (issue #1874).
7
8use crate::ast::{CapoValidation, DirectiveKind, Line, Metadata, Song};
9use crate::config::Config;
10
11/// Maximum number of warnings any renderer accumulates for a single
12/// render pass (issue #1833). Without a cap, a pathological input such
13/// as one million malformed `{transpose}` lines would push the warnings
14/// vector to tens of megabytes. `push_warning` refuses to exceed this
15/// limit and appends a single truncation marker the first time the cap
16/// is hit.
17pub const MAX_WARNINGS: usize = 1000;
18
19/// Push a warning into the renderer's accumulator, enforcing
20/// [`MAX_WARNINGS`].
21///
22/// Once the cap is reached the function pushes a single truncation
23/// marker in place of the overflowing warning and silently ignores
24/// every subsequent warning in the same pass. Every renderer
25/// (`render-text`, `render-html`, `render-pdf`) calls this helper so
26/// the cap behaviour is identical across output formats.
27pub fn push_warning(warnings: &mut Vec<String>, message: impl Into<String>) {
28    if warnings.len() < MAX_WARNINGS {
29        warnings.push(message.into());
30    } else if warnings.len() == MAX_WARNINGS {
31        warnings.push(format!(
32            "additional warnings suppressed; MAX_WARNINGS ({MAX_WARNINGS}) reached"
33        ));
34    }
35}
36
37/// Validate the `{capo}` metadata value at the render boundary and push
38/// a warning for any value outside `1..=24` (issue #1834,
39/// `.claude/rules/renderer-parity.md` §Validation Parity).
40///
41/// Renderers call this helper once at the top of their main entry point
42/// so the validation message is byte-identical across output formats —
43/// a user who pipes the same `.cho` file to text, HTML, and PDF now
44/// sees the same warning regardless of which renderer they chose.
45pub fn validate_capo(metadata: &Metadata, warnings: &mut Vec<String>) {
46    match metadata.capo_validated() {
47        CapoValidation::Unset | CapoValidation::Valid(_) => {}
48        CapoValidation::OutOfRange(n) => {
49            push_warning(
50                warnings,
51                format!("{{capo}} value {n} out of range (expected 1..=24); ignored"),
52            );
53        }
54        CapoValidation::NotInteger(raw) => {
55            push_warning(
56                warnings,
57                format!("{{capo}} value {raw:?} is not a valid integer; ignored"),
58            );
59        }
60    }
61}
62
63/// Walk the song and push a warning when more than one `{capo}` directive
64/// is present, mirroring Perl ChordPro's behaviour
65/// (`lib/ChordPro/Song.pm::dir_capo`: `do_warn("Multiple capo settings may
66/// yield surprising results.")`).
67///
68/// `{capo}` is modelled as a song-global last-wins value in the AST
69/// (`Metadata.capo: Option<String>`), matching Perl's `$capo` scalar —
70/// the spec does not define positional capo semantics. This helper exists
71/// so users who do try to write multiple `{capo}` directives get the same
72/// surprised-results warning Perl emits, regardless of which renderer
73/// produced the output.
74///
75/// Counts unselectored `Capo` directives (the same ones
76/// `Parser::populate_metadata` writes into `Metadata.capo`) so the warning
77/// fires exactly when the AST contains a state we cannot faithfully
78/// reproduce in a single capo slot.
79pub fn validate_multiple_capo(song: &Song, warnings: &mut Vec<String>) {
80    let mut count = 0usize;
81    for line in &song.lines {
82        if let Line::Directive(directive) = line {
83            if directive.selector.is_some() {
84                continue;
85            }
86            if directive.value.is_none() {
87                continue;
88            }
89            let is_capo = match directive.kind {
90                DirectiveKind::Capo => true,
91                DirectiveKind::Meta(ref key) => key.eq_ignore_ascii_case("capo"),
92                _ => false,
93            };
94            if is_capo {
95                count += 1;
96                if count >= 2 {
97                    push_warning(
98                        warnings,
99                        "Multiple capo settings may yield surprising results.",
100                    );
101                    return;
102                }
103            }
104        }
105    }
106}
107
108/// Validate strict-mode requirements at the render boundary and push a
109/// warning when `settings.strict` is true and the song does not declare a
110/// `{key}` directive (ChordPro R6.100.0).
111///
112/// Renderers call this helper alongside [`validate_capo`] so the warning
113/// message is byte-identical across output formats — a user who pipes the
114/// same `.cho` file to text, HTML, and PDF sees the same warning regardless
115/// of which renderer they chose.
116pub fn validate_strict_key(metadata: &Metadata, config: &Config, warnings: &mut Vec<String>) {
117    if config.get_path("settings.strict").as_bool() != Some(true) {
118        return;
119    }
120    if metadata.key.is_none() {
121        push_warning(
122            warnings,
123            "song does not declare a {key} directive (settings.strict)",
124        );
125    }
126}
127
128/// Result of a render operation, containing both the rendered output
129/// and any warnings produced during rendering.
130///
131/// Renderers collect warnings (e.g., transpose saturation, chorus recall
132/// limits) into [`warnings`](Self::warnings) instead of printing them
133/// directly. Callers can inspect and display warnings as they see fit.
134#[derive(Debug, Clone)]
135#[must_use]
136pub struct RenderResult<T> {
137    /// The rendered output.
138    pub output: T,
139    /// Warnings emitted during rendering.
140    pub warnings: Vec<String>,
141}
142
143impl<T> RenderResult<T> {
144    /// Create a new `RenderResult` with the given output and no warnings.
145    pub fn new(output: T) -> Self {
146        Self {
147            output,
148            warnings: Vec::new(),
149        }
150    }
151
152    /// Create a new `RenderResult` with the given output and warnings.
153    pub fn with_warnings(output: T, warnings: Vec<String>) -> Self {
154        Self { output, warnings }
155    }
156
157    /// Returns `true` if there are no warnings.
158    #[must_use]
159    pub fn has_warnings(&self) -> bool {
160        !self.warnings.is_empty()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_new_has_no_warnings() {
170        let result = RenderResult::new("hello");
171        assert_eq!(result.output, "hello");
172        assert!(result.warnings.is_empty());
173        assert!(!result.has_warnings());
174    }
175
176    #[test]
177    fn test_with_warnings() {
178        let result = RenderResult::with_warnings("output", vec!["warning 1".to_string()]);
179        assert_eq!(result.output, "output");
180        assert_eq!(result.warnings.len(), 1);
181        assert!(result.has_warnings());
182    }
183
184    #[test]
185    fn test_with_empty_warnings() {
186        let result = RenderResult::with_warnings(42, Vec::new());
187        assert_eq!(result.output, 42);
188        assert!(!result.has_warnings());
189    }
190
191    // -- push_warning cap -------------------------------------------------
192
193    #[test]
194    fn test_push_warning_under_cap_appends() {
195        let mut v: Vec<String> = Vec::new();
196        push_warning(&mut v, "a");
197        push_warning(&mut v, "b");
198        assert_eq!(v, vec!["a".to_string(), "b".to_string()]);
199    }
200
201    #[test]
202    fn test_push_warning_caps_and_truncates_once() {
203        let mut v: Vec<String> = Vec::with_capacity(MAX_WARNINGS + 5);
204        for i in 0..(MAX_WARNINGS + 50) {
205            push_warning(&mut v, format!("w{i}"));
206        }
207        assert_eq!(v.len(), MAX_WARNINGS + 1);
208        assert!(
209            v.last().unwrap().contains("MAX_WARNINGS"),
210            "last entry must be the truncation marker; got {:?}",
211            v.last()
212        );
213    }
214
215    // -- validate_capo uniform messages -----------------------------------
216
217    #[test]
218    fn test_validate_capo_unset_and_valid_emit_nothing() {
219        let mut v = Vec::<String>::new();
220        let md = Metadata::default();
221        validate_capo(&md, &mut v);
222        assert!(v.is_empty());
223
224        let md = Metadata {
225            capo: Some("5".to_string()),
226            ..Metadata::default()
227        };
228        validate_capo(&md, &mut v);
229        assert!(v.is_empty());
230    }
231
232    #[test]
233    fn test_validate_capo_out_of_range_warns_with_value() {
234        let mut v = Vec::<String>::new();
235        let md = Metadata {
236            capo: Some("999".to_string()),
237            ..Metadata::default()
238        };
239        validate_capo(&md, &mut v);
240        assert_eq!(v.len(), 1);
241        assert!(v[0].contains("999") && v[0].contains("out of range"));
242    }
243
244    #[test]
245    fn test_validate_capo_non_integer_warns_with_value() {
246        let mut v = Vec::<String>::new();
247        let md = Metadata {
248            capo: Some("foo".to_string()),
249            ..Metadata::default()
250        };
251        validate_capo(&md, &mut v);
252        assert_eq!(v.len(), 1);
253        assert!(v[0].contains("foo") && v[0].contains("not a valid integer"));
254    }
255
256    // -- validate_strict_key (R6.100.0) -----------------------------------
257
258    fn config_with_strict(strict: bool) -> Config {
259        Config::defaults()
260            .with_define(&format!("settings.strict={strict}"))
261            .expect("defining settings.strict must succeed")
262    }
263
264    #[test]
265    fn test_validate_strict_key_default_off_emits_nothing() {
266        let mut v = Vec::<String>::new();
267        let md = Metadata::default();
268        validate_strict_key(&md, &Config::defaults(), &mut v);
269        assert!(
270            v.is_empty(),
271            "default config has settings.strict=false; no warning expected"
272        );
273    }
274
275    #[test]
276    fn test_validate_strict_key_strict_off_with_missing_key_emits_nothing() {
277        let mut v = Vec::<String>::new();
278        let md = Metadata::default();
279        validate_strict_key(&md, &config_with_strict(false), &mut v);
280        assert!(v.is_empty());
281    }
282
283    #[test]
284    fn test_validate_strict_key_strict_on_with_missing_key_warns() {
285        let mut v = Vec::<String>::new();
286        let md = Metadata::default();
287        validate_strict_key(&md, &config_with_strict(true), &mut v);
288        assert_eq!(v.len(), 1);
289        assert!(v[0].contains("{key}") && v[0].contains("settings.strict"));
290    }
291
292    #[test]
293    fn test_validate_strict_key_strict_on_with_present_key_emits_nothing() {
294        let mut v = Vec::<String>::new();
295        let md = Metadata {
296            key: Some("C".to_string()),
297            ..Metadata::default()
298        };
299        validate_strict_key(&md, &config_with_strict(true), &mut v);
300        assert!(v.is_empty());
301    }
302
303    // -- validate_multiple_capo (Perl parity) ------------------------------
304
305    fn parse_song(input: &str) -> crate::ast::Song {
306        crate::parser::parse(input).expect("parse failed")
307    }
308
309    #[test]
310    fn test_validate_multiple_capo_zero_capos_emits_nothing() {
311        let mut v = Vec::<String>::new();
312        let song = parse_song("{title: T}\n[Am]Hello");
313        validate_multiple_capo(&song, &mut v);
314        assert!(v.is_empty());
315    }
316
317    #[test]
318    fn test_validate_multiple_capo_single_capo_emits_nothing() {
319        let mut v = Vec::<String>::new();
320        let song = parse_song("{capo: 2}\n[Am]Hello");
321        validate_multiple_capo(&song, &mut v);
322        assert!(v.is_empty());
323    }
324
325    #[test]
326    fn test_validate_multiple_capo_two_capos_warns_once() {
327        let mut v = Vec::<String>::new();
328        let song = parse_song("{capo: 2}\n[Am]Hello\n{capo: 4}\n[C]World");
329        validate_multiple_capo(&song, &mut v);
330        assert_eq!(v.len(), 1, "expected exactly one warning");
331        assert!(
332            v[0].contains("Multiple capo settings"),
333            "unexpected message: {:?}",
334            v[0]
335        );
336    }
337
338    #[test]
339    fn test_validate_multiple_capo_three_capos_still_warns_once() {
340        let mut v = Vec::<String>::new();
341        let song = parse_song("{capo: 1}\n{capo: 2}\n{capo: 3}");
342        validate_multiple_capo(&song, &mut v);
343        assert_eq!(v.len(), 1);
344    }
345
346    #[test]
347    fn test_validate_multiple_capo_meta_form_counts() {
348        // `{meta: capo X}` is the long form Perl accepts equivalently.
349        let mut v = Vec::<String>::new();
350        let song = parse_song("{capo: 2}\n{meta: capo 4}");
351        validate_multiple_capo(&song, &mut v);
352        assert_eq!(v.len(), 1);
353    }
354}