Skip to main content

toon/decode/
validation.rs

1use crate::decode::parser::ArrayHeaderInfo;
2use crate::decode::scanner::{BlankLineInfo, Depth, ParsedLine};
3use crate::error::{Result, ToonError};
4use crate::shared::constants::{COLON, LIST_ITEM_PREFIX};
5use crate::shared::string_utils::find_unquoted_char;
6
7/// Assert the expected count in strict mode.
8///
9/// # Errors
10///
11/// Returns an error when strict mode is enabled and counts differ.
12pub fn assert_expected_count(
13    actual: usize,
14    expected: usize,
15    item_type: &str,
16    strict: bool,
17) -> Result<()> {
18    if strict && actual != expected {
19        return Err(ToonError::message(format!(
20            "Expected {expected} {item_type}, but got {actual}"
21        )));
22    }
23    Ok(())
24}
25
26/// Validate that there are no extra list items beyond the expected count.
27///
28/// # Errors
29///
30/// Returns an error in strict mode when extra list items are found.
31pub fn validate_no_extra_list_items(
32    next_line: Option<&ParsedLine>,
33    item_depth: Depth,
34    expected_count: usize,
35    strict: bool,
36) -> Result<()> {
37    if strict
38        && let Some(line) = next_line
39        && line.depth == item_depth
40        && line.content.starts_with(LIST_ITEM_PREFIX)
41    {
42        return Err(ToonError::message(format!(
43            "Expected {expected_count} list array items, but found more"
44        )));
45    }
46    Ok(())
47}
48
49/// Validate that there are no extra tabular rows beyond the expected count.
50///
51/// # Errors
52///
53/// Returns an error in strict mode when extra tabular rows are found.
54pub fn validate_no_extra_tabular_rows(
55    next_line: Option<&ParsedLine>,
56    row_depth: Depth,
57    header: &ArrayHeaderInfo,
58    strict: bool,
59) -> Result<()> {
60    if strict
61        && let Some(line) = next_line
62        && line.depth == row_depth
63        && !line.content.starts_with(LIST_ITEM_PREFIX)
64        && is_data_row(&line.content, header.delimiter)
65    {
66        return Err(ToonError::message(format!(
67            "Expected {} tabular rows, but found more",
68            header.length
69        )));
70    }
71    Ok(())
72}
73
74/// Validate that no blank lines appear within the specified range.
75///
76/// # Errors
77///
78/// Returns an error in strict mode when blank lines appear within the range.
79pub fn validate_no_blank_lines_in_range(
80    start_line: usize,
81    end_line: usize,
82    blank_lines: &[BlankLineInfo],
83    strict: bool,
84    context: &str,
85) -> Result<()> {
86    if !strict {
87        return Ok(());
88    }
89
90    if let Some(first_blank) = blank_lines
91        .iter()
92        .find(|blank| blank.line_number > start_line && blank.line_number < end_line)
93    {
94        return Err(ToonError::message(format!(
95            "Line {}: Blank lines inside {context} are not allowed in strict mode",
96            first_blank.line_number
97        )));
98    }
99
100    Ok(())
101}
102
103fn is_data_row(content: &str, delimiter: char) -> bool {
104    // Find first unquoted colon and delimiter to properly handle quoted strings
105    let colon_pos = find_unquoted_char(content, COLON, 0);
106    let delimiter_pos = find_unquoted_char(content, delimiter, 0);
107
108    // If no unquoted colon, it's definitely a data row
109    if colon_pos.is_none() {
110        return true;
111    }
112
113    // If delimiter comes before colon (outside quotes), it's a data row
114    if let Some(delimiter_pos) = delimiter_pos
115        && let Some(colon_pos) = colon_pos
116    {
117        return delimiter_pos < colon_pos;
118    }
119
120    false
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn make_line(content: &str, depth: usize, line_number: usize) -> ParsedLine {
128        ParsedLine {
129            raw: content.to_string(),
130            indent: depth * 2,
131            content: content.to_string(),
132            depth,
133            line_number,
134        }
135    }
136
137    #[test]
138    fn assert_expected_count_matches_is_ok() {
139        assert!(assert_expected_count(3, 3, "items", true).is_ok());
140    }
141
142    #[test]
143    fn assert_expected_count_mismatch_strict_errors() {
144        assert!(assert_expected_count(2, 3, "items", true).is_err());
145    }
146
147    #[test]
148    fn assert_expected_count_mismatch_lax_ok() {
149        assert!(assert_expected_count(2, 3, "items", false).is_ok());
150    }
151
152    #[test]
153    fn validate_no_extra_list_items_no_next_is_ok() {
154        assert!(validate_no_extra_list_items(None, 1, 3, true).is_ok());
155    }
156
157    #[test]
158    fn validate_no_extra_list_items_wrong_depth_is_ok() {
159        let line = make_line("- one", 0, 5);
160        assert!(validate_no_extra_list_items(Some(&line), 1, 3, true).is_ok());
161    }
162
163    #[test]
164    fn validate_no_extra_list_items_same_depth_strict_errors() {
165        let line = make_line("- extra", 1, 10);
166        assert!(validate_no_extra_list_items(Some(&line), 1, 3, true).is_err());
167    }
168
169    #[test]
170    fn validate_no_extra_list_items_same_depth_lax_ok() {
171        let line = make_line("- extra", 1, 10);
172        assert!(validate_no_extra_list_items(Some(&line), 1, 3, false).is_ok());
173    }
174
175    #[test]
176    fn validate_no_extra_list_items_non_list_content_ok() {
177        let line = make_line("k: v", 1, 10);
178        assert!(validate_no_extra_list_items(Some(&line), 1, 3, true).is_ok());
179    }
180
181    fn make_tabular_header(delimiter: char) -> ArrayHeaderInfo {
182        ArrayHeaderInfo {
183            key: None,
184            key_was_quoted: false,
185            length: 2,
186            delimiter,
187            fields: Some(vec![]),
188        }
189    }
190
191    #[test]
192    fn validate_no_extra_tabular_rows_accepts_non_data_row() {
193        let header = make_tabular_header(',');
194        let line = make_line("k: v", 1, 10);
195        assert!(validate_no_extra_tabular_rows(Some(&line), 1, &header, true).is_ok());
196    }
197
198    #[test]
199    fn validate_no_extra_tabular_rows_rejects_data_row_strict() {
200        let header = make_tabular_header(',');
201        let line = make_line("extra,data", 1, 10);
202        assert!(validate_no_extra_tabular_rows(Some(&line), 1, &header, true).is_err());
203    }
204
205    #[test]
206    fn validate_no_extra_tabular_rows_wrong_depth_ok() {
207        let header = make_tabular_header(',');
208        let line = make_line("extra,data", 0, 10);
209        assert!(validate_no_extra_tabular_rows(Some(&line), 1, &header, true).is_ok());
210    }
211
212    #[test]
213    fn validate_no_extra_tabular_rows_list_item_ok() {
214        let header = make_tabular_header(',');
215        let line = make_line("- extra", 1, 10);
216        assert!(validate_no_extra_tabular_rows(Some(&line), 1, &header, true).is_ok());
217    }
218
219    #[test]
220    fn validate_no_blank_lines_in_range_none_is_ok() {
221        let blanks: Vec<BlankLineInfo> = Vec::new();
222        assert!(validate_no_blank_lines_in_range(1, 5, &blanks, true, "ctx").is_ok());
223    }
224
225    #[test]
226    fn validate_no_blank_lines_in_range_outside_window_ok() {
227        let blanks = vec![BlankLineInfo {
228            line_number: 10,
229            indent: 0,
230            depth: 0,
231        }];
232        assert!(validate_no_blank_lines_in_range(1, 5, &blanks, true, "ctx").is_ok());
233    }
234
235    #[test]
236    fn validate_no_blank_lines_in_range_inside_window_errors_strict() {
237        let blanks = vec![BlankLineInfo {
238            line_number: 3,
239            indent: 0,
240            depth: 0,
241        }];
242        let err =
243            validate_no_blank_lines_in_range(1, 5, &blanks, true, "tabular array").unwrap_err();
244        let msg = format!("{err}");
245        assert!(msg.contains("Line 3"));
246        assert!(msg.contains("tabular array"));
247    }
248
249    #[test]
250    fn validate_no_blank_lines_in_range_lax_ok() {
251        let blanks = vec![BlankLineInfo {
252            line_number: 3,
253            indent: 0,
254            depth: 0,
255        }];
256        assert!(validate_no_blank_lines_in_range(1, 5, &blanks, false, "ctx").is_ok());
257    }
258
259    #[test]
260    fn is_data_row_without_colon() {
261        assert!(is_data_row("a,b,c", ','));
262    }
263
264    #[test]
265    fn is_data_row_with_delimiter_before_colon() {
266        assert!(is_data_row("a,b:c", ','));
267    }
268
269    #[test]
270    fn is_data_row_with_colon_before_delimiter_is_key_value() {
271        assert!(!is_data_row("a:b,c", ','));
272    }
273
274    #[test]
275    fn is_data_row_with_quoted_colon_inside_string() {
276        // The colon is inside quotes so the first unquoted delimiter wins.
277        assert!(is_data_row("\"a:b\",c", ','));
278    }
279}