1#![deny(
20 clippy::unwrap_used,
21 clippy::expect_used,
22 clippy::indexing_slicing,
23 clippy::panic,
24 clippy::unreachable
25)]
26
27use regex::{Regex, RegexBuilder};
28use serde::Serialize;
29use serde_json::Value;
30use uuid::Uuid;
31
32use crate::services::simulations::{table_number, ResultInput, MAX_RESULTS};
33
34pub const EXCHANGE_MAX_BYTES: u64 = 1024 * 1024;
36
37pub const SCAN_MAX_BYTES: u64 = 2 * 1024 * 1024;
39
40pub const SCAN_MAX_LINES: usize = 200_000;
42
43const MAX_LINE_CHARS: usize = 8 * 1024;
45
46pub const MAX_TEMPLATES: usize = 100;
48
49pub const MAX_PATTERN_LEN: usize = 500;
51
52const REGEX_SIZE_LIMIT: usize = 256 * 1024;
54
55pub const EXCHANGE_JSON: &str = "teamcraft-results.json";
57pub const EXCHANGE_CSV: &str = "teamcraft-results.csv";
58
59pub fn is_exchange_name(filename: &str) -> bool {
61 let lower = filename.to_ascii_lowercase();
62 lower == EXCHANGE_JSON || lower == EXCHANGE_CSV
63}
64
65pub fn scannable(filename: &str) -> bool {
67 let ext = filename
68 .rsplit_once('.')
69 .map(|(_, e)| e.to_ascii_lowercase())
70 .unwrap_or_default();
71 matches!(
72 ext.as_str(),
73 "txt"
74 | "csv"
75 | "tsv"
76 | "log"
77 | "out"
78 | "dat"
79 | "rpt"
80 | "res"
81 | "sum"
82 | "html"
83 | "htm"
84 | "json"
85 | "md"
86 | "trn"
88 | "f06"
89 | "f04"
90 | "pch"
91 | "jou"
92 | "java"
93 )
94}
95
96const JSON_KEYS: [&str; 12] = [
102 "name",
103 "value",
104 "unit",
105 "limit_min",
106 "limit_max",
107 "lower_limit",
108 "upper_limit",
109 "limit_low",
110 "limit_high",
111 "load_case",
112 "solver",
113 "note",
114];
115
116pub fn parse_exchange(filename: &str, bytes: &[u8]) -> Result<Vec<ResultInput>, String> {
129 if bytes.len() as u64 > EXCHANGE_MAX_BYTES {
130 return Err(format!(
131 "{filename} is larger than {} MB – the exchange file holds key numbers, not result fields.",
132 EXCHANGE_MAX_BYTES / (1024 * 1024)
133 ));
134 }
135 let rows = if filename.to_ascii_lowercase().ends_with(".csv") {
136 crate::services::simulations::parse_results_table(filename, bytes)?
137 } else {
138 parse_exchange_json(filename, bytes)?
139 };
140 if rows.len() > MAX_RESULTS {
141 return Err(format!(
142 "{filename} has more than {MAX_RESULTS} result values."
143 ));
144 }
145 let mut out = Vec::with_capacity(rows.len());
146 for (i, row) in rows.into_iter().enumerate() {
147 out.push(
148 row.validated()
149 .map_err(|e| format!("{filename}: result {}: {}", i + 1, e.public_message()))?,
150 );
151 }
152 Ok(out)
153}
154
155fn parse_exchange_json(filename: &str, bytes: &[u8]) -> Result<Vec<ResultInput>, String> {
156 let root: Value = serde_json::from_slice(bytes).map_err(|e| {
157 format!(
158 "{filename}: not valid JSON (line {}, column {}): {e}",
159 e.line(),
160 e.column()
161 )
162 })?;
163 let list = match &root {
164 Value::Array(list) => list,
165 Value::Object(map) => match map.get("results") {
166 Some(Value::Array(list)) => {
167 if let Some(key) = map.keys().find(|k| *k != "results") {
168 return Err(format!(
169 "{filename}: unknown key '{key}' (only \"results\" is allowed at the top)."
170 ));
171 }
172 list
173 }
174 _ => {
175 return Err(format!(
176 "{filename}: expected an array of results or {{\"results\": [...]}}."
177 ))
178 }
179 },
180 _ => {
181 return Err(format!(
182 "{filename}: expected an array of results or {{\"results\": [...]}}."
183 ))
184 }
185 };
186 if list.is_empty() {
187 return Err(format!("{filename} contains no result values."));
188 }
189 let mut out = Vec::with_capacity(list.len().min(MAX_RESULTS + 1));
190 for (i, item) in list.iter().enumerate() {
191 let at = format!("{filename}: results[{i}]");
192 let Value::Object(map) = item else {
193 return Err(format!("{at} must be an object."));
194 };
195 for key in map.keys() {
196 if !JSON_KEYS.contains(&key.as_str()) && key != "solver_version" {
197 return Err(format!(
198 "{at}: unknown field '{key}' (allowed: name, value, unit, limit_min, \
199 limit_max, load_case, solver, solver_version, note)."
200 ));
201 }
202 }
203 let text = |keys: &[&str]| -> Result<String, String> {
204 for key in keys {
205 match map.get(*key) {
206 None | Some(Value::Null) => {}
207 Some(Value::String(s)) => return Ok(s.clone()),
208 Some(_) => return Err(format!("{at}.{key} must be text.")),
209 }
210 }
211 Ok(String::new())
212 };
213 let number = |keys: &[&str]| -> Result<Option<f64>, String> {
214 for key in keys {
215 match map.get(*key) {
216 None | Some(Value::Null) => {}
217 Some(Value::Number(n)) => {
218 return n
219 .as_f64()
220 .filter(|v| v.is_finite())
221 .map(Some)
222 .ok_or_else(|| format!("{at}.{key} is not a finite number."))
223 }
224 Some(_) => return Err(format!("{at}.{key} must be a number.")),
225 }
226 }
227 Ok(None)
228 };
229 let name = text(&["name"])?;
230 if name.trim().is_empty() {
231 return Err(format!("{at}.name is missing."));
232 }
233 let value = number(&["value"])?.ok_or_else(|| format!("{at}.value is missing."))?;
234 out.push(ResultInput {
235 name,
236 value,
237 unit: text(&["unit"])?,
238 lower_limit: number(&["limit_min", "lower_limit", "limit_low"])?,
239 upper_limit: number(&["limit_max", "upper_limit", "limit_high"])?,
240 load_case: text(&["load_case"])?,
241 solver: text(&["solver"])?,
242 solver_version: text(&["solver_version"])?,
243 note: text(&["note"])?,
244 source: String::new(),
245 });
246 if out.len() > MAX_RESULTS {
247 return Err(format!(
248 "{filename} has more than {MAX_RESULTS} result values."
249 ));
250 }
251 }
252 Ok(out)
253}
254
255#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
261#[serde(rename_all = "lowercase")]
262pub enum Method {
263 Regex,
264 Csv,
265 Table,
268}
269
270impl Method {
271 pub fn as_str(self) -> &'static str {
272 match self {
273 Method::Regex => "regex",
274 Method::Csv => "csv",
275 Method::Table => "table",
276 }
277 }
278
279 pub fn parse(s: &str) -> Option<Method> {
280 match s {
281 "regex" => Some(Method::Regex),
282 "csv" => Some(Method::Csv),
283 "table" => Some(Method::Table),
284 _ => None,
285 }
286 }
287}
288
289#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
291pub struct ResultTemplate {
292 pub id: Uuid,
293 pub project_id: Uuid,
294 pub name: String,
296 pub file_pattern: String,
298 pub method: String,
300 pub pattern: String,
302 pub csv_column: String,
304 pub csv_key_column: String,
306 pub csv_key_value: String,
307 pub pick: String,
309 pub unit: String,
310 pub lower_limit: Option<f64>,
311 pub upper_limit: Option<f64>,
312 pub load_case: String,
313 pub solver: String,
314 pub enabled: bool,
315 pub created_at: chrono::DateTime<chrono::Utc>,
316 pub updated_at: chrono::DateTime<chrono::Utc>,
317}
318
319#[derive(Clone, Debug, Default, serde::Deserialize)]
321pub struct TemplateInput {
322 pub name: String,
323 pub file_pattern: String,
324 pub method: String,
325 #[serde(default)]
326 pub pattern: String,
327 #[serde(default)]
328 pub csv_column: String,
329 #[serde(default)]
330 pub csv_key_column: String,
331 #[serde(default)]
332 pub csv_key_value: String,
333 #[serde(default)]
334 pub pick: String,
335 #[serde(default)]
336 pub unit: String,
337 #[serde(default, alias = "limit_min", alias = "limit_low")]
338 pub lower_limit: Option<f64>,
339 #[serde(default, alias = "limit_max", alias = "limit_high")]
340 pub upper_limit: Option<f64>,
341 #[serde(default)]
342 pub load_case: String,
343 #[serde(default)]
344 pub solver: String,
345 #[serde(default = "default_enabled")]
346 pub enabled: bool,
347}
348
349fn default_enabled() -> bool {
350 true
351}
352
353impl TemplateInput {
354 pub fn validated(self) -> Result<TemplateInput, String> {
358 use crate::domain::{limit_text, require_text};
359 let msg = |e: crate::error::AppError| e.public_message();
360 let name = require_text("Result name", &self.name, 200).map_err(msg)?;
361 let file_pattern =
362 require_text("File name pattern", &self.file_pattern, 200).map_err(msg)?;
363 let method = Method::parse(self.method.trim())
364 .ok_or_else(|| "The method must be 'regex', 'csv' or 'table'.".to_string())?;
365 let pick = match self.pick.trim() {
366 "" | "last" => "last",
367 "first" => "first",
368 _ => return Err("'pick' must be 'first' or 'last'.".into()),
369 }
370 .to_string();
371 let mut out = TemplateInput {
372 name,
373 file_pattern,
374 method: method.as_str().into(),
375 pick,
376 unit: limit_text("Unit", &self.unit, 32).map_err(msg)?,
377 load_case: limit_text("Load case", &self.load_case, 200).map_err(msg)?,
378 solver: limit_text("Solver", &self.solver, 100).map_err(msg)?,
379 pattern: String::new(),
380 csv_column: String::new(),
381 csv_key_column: String::new(),
382 csv_key_value: String::new(),
383 ..self
384 };
385 for v in [out.lower_limit, out.upper_limit].into_iter().flatten() {
386 if !v.is_finite() {
387 return Err("Limits must be finite numbers.".into());
388 }
389 }
390 if let (Some(lo), Some(hi)) = (out.lower_limit, out.upper_limit) {
391 if lo > hi {
392 return Err("The lower limit is above the upper limit.".into());
393 }
394 }
395 match method {
396 Method::Regex => {
397 out.pattern =
398 require_text("Pattern", &self.pattern, MAX_PATTERN_LEN).map_err(msg)?;
399 compile_regex(&out.pattern)?;
400 }
401 Method::Csv | Method::Table => {
402 out.csv_column = require_text("CSV column", &self.csv_column, 200).map_err(msg)?;
403 out.csv_key_column =
404 limit_text("CSV key column", &self.csv_key_column, 200).map_err(msg)?;
405 out.csv_key_value =
406 limit_text("CSV key value", &self.csv_key_value, 200).map_err(msg)?;
407 if out.csv_key_column.is_empty() != out.csv_key_value.is_empty() {
408 return Err(
409 "Give both the key column and the key value, or neither.".to_string()
410 );
411 }
412 }
413 }
414 glob_check(&out.file_pattern)?;
415 Ok(out)
416 }
417}
418
419fn glob_check(pattern: &str) -> Result<(), String> {
420 if pattern.contains(['/', '\\']) {
421 return Err("The file name pattern matches file names, not paths.".into());
422 }
423 Ok(())
424}
425
426fn compile_regex(pattern: &str) -> Result<Regex, String> {
429 let re = RegexBuilder::new(pattern)
430 .size_limit(REGEX_SIZE_LIMIT)
431 .dfa_size_limit(REGEX_SIZE_LIMIT * 4)
432 .nest_limit(50)
433 .build()
434 .map_err(|e| format!("The pattern is not a valid regular expression: {e}"))?;
435 if re.captures_len() != 2 {
436 return Err(format!(
437 "The pattern needs exactly one capture group around the number, e.g. \
438 Max stress:\\s*([-+0-9.eE]+) – it has {}.",
439 re.captures_len() - 1
440 ));
441 }
442 Ok(re)
443}
444
445pub fn glob_matches(pattern: &str, name: &str) -> bool {
448 let p: Vec<char> = pattern.to_lowercase().chars().collect();
449 let n: Vec<char> = name.to_lowercase().chars().collect();
450 let (mut pi, mut ni) = (0usize, 0usize);
452 let (mut star, mut mark) = (None, 0usize);
453 while let Some(&c) = n.get(ni) {
454 let at = p.get(pi).copied();
455 if at == Some('?') || at == Some(c) {
456 pi += 1;
457 ni += 1;
458 } else if at == Some('*') {
459 star = Some(pi);
460 mark = ni;
461 pi += 1;
462 } else if let Some(s) = star {
463 pi = s + 1;
464 mark += 1;
465 ni = mark;
466 } else {
467 return false;
468 }
469 }
470 while p.get(pi) == Some(&'*') {
471 pi += 1;
472 }
473 pi == p.len()
474}
475
476fn lines_of(bytes: &[u8]) -> Vec<String> {
479 String::from_utf8_lossy(bytes)
480 .lines()
481 .take(SCAN_MAX_LINES)
482 .map(|l| l.chars().take(MAX_LINE_CHARS).collect())
483 .collect()
484}
485
486fn tail_lines_of(bytes: &[u8]) -> Vec<String> {
489 let text = String::from_utf8_lossy(bytes);
490 let count = text.lines().count();
491 text.lines()
492 .skip(count.saturating_sub(SCAN_MAX_LINES))
493 .map(|l| l.chars().take(MAX_LINE_CHARS).collect())
494 .collect()
495}
496
497#[derive(Clone, Copy, Debug, PartialEq, Eq)]
500enum Place {
501 Line(usize),
502 FromEnd(usize),
503}
504
505pub fn extract(filename: &str, bytes: &[u8], templates: &[ResultTemplate]) -> Vec<ResultInput> {
512 extract_with_tail(filename, bytes, &[], templates)
513}
514
515pub fn extract_with_tail(
525 filename: &str,
526 head: &[u8],
527 tail: &[u8],
528 templates: &[ResultTemplate],
529) -> Vec<ResultInput> {
530 if head.contains(&0) {
531 return Vec::new();
532 }
533 let tail: &[u8] = if tail.contains(&0) { &[] } else { tail };
536 let mut applicable: Vec<&ResultTemplate> = templates
537 .iter()
538 .filter(|t| t.enabled && glob_matches(&t.file_pattern, filename))
539 .collect();
540 if applicable.is_empty() {
541 return Vec::new();
542 }
543 applicable.sort_by_key(|t| t.name.to_lowercase());
544 let head_lines = lines_of(head);
545 let tail_lines = tail_lines_of(tail);
546 let html = crate::htmltext::is_html(filename);
547 let mut out = Vec::new();
548 for t in applicable {
549 let from_tail = (t.pick != "first" && !tail_lines.is_empty())
550 .then(|| find(t, &head_lines, Some(&tail_lines), html))
551 .flatten()
552 .map(|(value, i)| (value, Place::FromEnd(tail_lines.len().saturating_sub(i))));
553 let found = from_tail.or_else(|| {
554 find(t, &head_lines, None, html).map(|(value, i)| (value, Place::Line(i + 1)))
555 });
556 if let Some((value, place)) = found {
557 out.push(ResultInput {
558 name: t.name.clone(),
559 value,
560 unit: t.unit.clone(),
561 lower_limit: t.lower_limit,
562 upper_limit: t.upper_limit,
563 load_case: t.load_case.clone(),
564 solver: t.solver.clone(),
565 solver_version: String::new(),
566 note: String::new(),
567 source: match place {
568 Place::Line(line) => format!("{filename}:{line}"),
569 Place::FromEnd(1) => format!("{filename}: last line"),
570 Place::FromEnd(n) => format!("{filename}: line {n} from the end"),
571 },
572 });
573 }
574 }
575 out
576}
577
578fn find(
583 t: &ResultTemplate,
584 head: &[String],
585 tail: Option<&[String]>,
586 html: bool,
587) -> Option<(f64, usize)> {
588 match Method::parse(&t.method)? {
589 Method::Regex => find_regex(tail.unwrap_or(head), t),
590 Method::Csv => find_csv(head, tail, t, html),
591 Method::Table => find_table(head, tail, t),
592 }
593}
594
595fn pick<T>(mut found: impl Iterator<Item = T>, which: &str) -> Option<T> {
596 if which == "first" {
597 found.next()
598 } else {
599 found.last()
600 }
601}
602
603fn find_regex(lines: &[String], t: &ResultTemplate) -> Option<(f64, usize)> {
604 let re = compile_regex(&t.pattern).ok()?;
605 let matches = lines.iter().enumerate().filter_map(|(i, line)| {
606 let caps = re.captures(line)?;
607 let value = table_number(caps.get(1)?.as_str())?;
608 Some((value, i))
609 });
610 pick(matches, &t.pick)
611}
612
613fn delimiter(lines: &[String]) -> u8 {
616 let head = lines.iter().take(20);
617 let mut best = (b',', 0usize);
618 for d in *b",;\t|" {
619 let count: usize = head
620 .clone()
621 .map(|l| l.bytes().filter(|b| *b == d).count())
622 .sum();
623 if count > best.1 {
624 best = (d, count);
625 }
626 }
627 best.0
628}
629
630fn norm(s: &str) -> String {
632 s.trim().trim_matches('"').trim().to_lowercase()
633}
634
635type Cells = fn(&str, u8) -> Vec<String>;
637
638fn csv_cells(line: &str, delim: u8) -> Vec<String> {
639 let mut reader = csv::ReaderBuilder::new()
641 .has_headers(false)
642 .flexible(true)
643 .delimiter(delim)
644 .from_reader(line.as_bytes());
645 reader
646 .records()
647 .next()
648 .and_then(Result::ok)
649 .map(|r| r.iter().map(|c| c.trim().to_string()).collect())
650 .unwrap_or_default()
651}
652
653fn html_cells(line: &str, _delim: u8) -> Vec<String> {
656 let mut reader = csv::ReaderBuilder::new()
657 .has_headers(false)
658 .flexible(true)
659 .delimiter(b'|')
660 .quoting(false)
661 .from_reader(line.as_bytes());
662 reader
663 .records()
664 .next()
665 .and_then(Result::ok)
666 .map(|r| r.iter().map(|c| c.trim().to_string()).collect())
667 .unwrap_or_default()
668}
669
670fn table_cells(line: &str, _delim: u8) -> Vec<String> {
675 let mut rest = line.trim();
676 if let Some(inner) = rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
677 rest = inner;
678 }
679 let mut cells = Vec::new();
680 loop {
681 rest = rest.trim_start();
682 if rest.is_empty() {
683 return cells;
684 }
685 if let Some(quoted) = rest.strip_prefix('"') {
686 let end = quoted.find('"').unwrap_or(quoted.len());
687 cells.push(quoted.get(..end).unwrap_or_default().to_string());
688 rest = quoted.get(end + 1..).unwrap_or_default();
689 } else {
690 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
691 cells.push(rest.get(..end).unwrap_or_default().to_string());
692 rest = rest.get(end..).unwrap_or_default();
693 }
694 }
695}
696
697fn header(
700 head: &[String],
701 cells: Cells,
702 delim: u8,
703 t: &ResultTemplate,
704 is_header: fn(&str) -> bool,
705) -> Option<(usize, usize, Option<usize>)> {
706 let want_col = norm(&t.csv_column);
707 let want_key_col = norm(&t.csv_key_column);
708 head.iter().enumerate().find_map(|(i, line)| {
709 if !is_header(line) {
710 return None;
711 }
712 let row = cells(line, delim);
713 let col = row.iter().position(|c| norm(c) == want_col)?;
714 if want_key_col.is_empty() {
715 return Some((i, col, None));
716 }
717 let key = row.iter().position(|c| norm(c) == want_key_col)?;
718 Some((i, col, Some(key)))
719 })
720}
721
722fn table_value(
725 head: &[String],
726 tail: Option<&[String]>,
727 cells: Cells,
728 is_header: fn(&str) -> bool,
729 t: &ResultTemplate,
730) -> Option<(f64, usize)> {
731 let delim = delimiter(head);
732 let (at, col, key_col) = header(head, cells, delim, t, is_header)?;
733 let want_key = norm(&t.csv_key_value);
734 let (rows, skip) = match tail {
735 Some(tail) => (tail, 0),
736 None => (head, at + 1),
737 };
738 let matching = rows.iter().enumerate().skip(skip).filter_map(|(i, line)| {
739 let row = cells(line, delim);
740 if let Some(k) = key_col {
741 if norm(row.get(k)?) != want_key {
742 return None;
743 }
744 }
745 let value = table_number(row.get(col)?)?;
746 Some((value, i))
747 });
748 pick(matching, &t.pick)
749}
750
751fn find_csv(
756 head: &[String],
757 tail: Option<&[String]>,
758 t: &ResultTemplate,
759 html: bool,
760) -> Option<(f64, usize)> {
761 let cells: Cells = if html { html_cells } else { csv_cells };
762 table_value(head, tail, cells, |_| true, t)
763}
764
765fn find_table(
771 head: &[String],
772 tail: Option<&[String]>,
773 t: &ResultTemplate,
774) -> Option<(f64, usize)> {
775 fn all_quoted(line: &str) -> bool {
776 let mut rest = line.trim();
777 if let Some(inner) = rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
778 rest = inner.trim();
779 }
780 let mut inside = false;
782 let mut quotes = 0;
783 for c in rest.chars() {
784 if c == '"' {
785 inside = !inside;
786 quotes += 1;
787 } else if !inside && !c.is_whitespace() {
788 return false;
789 }
790 }
791 quotes >= 4 && !inside
793 }
794 table_value(head, tail, table_cells, all_quoted, t)
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 fn template(method: &str, file: &str, pattern: &str) -> ResultTemplate {
802 ResultTemplate {
803 id: Uuid::nil(),
804 project_id: Uuid::nil(),
805 name: "Pressure drop".into(),
806 file_pattern: file.into(),
807 method: method.into(),
808 pattern: pattern.into(),
809 csv_column: String::new(),
810 csv_key_column: String::new(),
811 csv_key_value: String::new(),
812 pick: "last".into(),
813 unit: "Pa".into(),
814 lower_limit: None,
815 upper_limit: Some(500.0),
816 load_case: String::new(),
817 solver: "OpenFOAM".into(),
818 enabled: true,
819 created_at: chrono::Utc::now(),
820 updated_at: chrono::Utc::now(),
821 }
822 }
823
824 #[test]
825 fn exchange_json_schema() {
826 let ok = br#"{"results":[
827 {"name":"Max von Mises","value":212.5,"unit":"MPa","limit_max":250,"limit_min":null,"load_case":"LC1","solver":"CalculiX","solver_version":"2.21","note":"n"},
828 {"name":"Mass","value":1.5}]}"#;
829 let rows = parse_exchange(EXCHANGE_JSON, ok).unwrap();
830 assert_eq!(rows.len(), 2);
831 assert_eq!(rows[0].upper_limit, Some(250.0));
832 assert_eq!(rows[0].lower_limit, None);
833 assert_eq!(rows[0].solver_version, "2.21");
834 assert_eq!(rows[1].unit, "");
835 assert_eq!(
837 parse_exchange(EXCHANGE_JSON, br#"[{"name":"a","value":1}]"#)
838 .unwrap()
839 .len(),
840 1
841 );
842 }
843
844 #[test]
845 fn exchange_json_errors_name_the_place() {
846 let e = |s: &str| parse_exchange(EXCHANGE_JSON, s.as_bytes()).unwrap_err();
847 assert!(
848 e("{\"results\": [\n{\"name\": ").contains("line 2"),
849 "{}",
850 e("{\"results\": [\n{\"name\": ")
851 );
852 assert!(e(r#"[{"name":"a","value":"1"}]"#).contains("results[0].value must be a number"));
853 assert!(e(r#"[{"name":"a","value":1},{"value":2}]"#).contains("results[1].name is missing"));
854 assert!(e(r#"[{"name":"a"}]"#).contains("results[0].value is missing"));
855 assert!(e(r#"[{"name":"a","value":1,"colour":"red"}]"#).contains("unknown field 'colour'"));
856 assert!(e(r#"[{"name":"a","value":1,"unit":3}]"#).contains("results[0].unit must be text"));
857 assert!(e(r#"[1]"#).contains("results[0] must be an object"));
858 assert!(e(r#"{"result":[]}"#).contains("expected an array"));
859 assert!(
860 e(r#"{"results":[],"x":1}"#).contains("contains no result values")
861 || e(r#"{"results":[],"x":1}"#).contains("unknown key")
862 );
863 assert!(e(r#"[]"#).contains("no result values"));
864 assert!(e(r#"7"#).contains("expected an array"));
865 let both = e(r#"[{"name":"a","value":1,"limit_min":5,"limit_max":1}]"#);
867 assert!(both.contains("result 1"), "{both}");
868 assert!(parse_exchange(EXCHANGE_JSON, br#"[{"name":"a","value":1e999}]"#).is_err());
870 }
871
872 #[test]
873 fn exchange_csv_and_size_cap() {
874 let csv = "name,value,unit,limit_min,limit_max,load_case\nStress,212.5,MPa,,250,LC1\nMass,1.5,kg,,,\n";
875 let rows = parse_exchange(EXCHANGE_CSV, csv.as_bytes()).unwrap();
876 assert_eq!(rows.len(), 2);
877 assert_eq!(rows[0].upper_limit, Some(250.0));
878 assert_eq!(rows[0].load_case, "LC1");
879 let bad = "name,value\nStress,abc\n";
880 let err = parse_exchange(EXCHANGE_CSV, bad.as_bytes()).unwrap_err();
881 assert!(err.contains("Row 2") && err.contains("'abc'"), "{err}");
882 let big = vec![b' '; EXCHANGE_MAX_BYTES as usize + 1];
883 let err = parse_exchange(EXCHANGE_JSON, &big).unwrap_err();
884 assert!(err.contains("larger than 1 MB"), "{err}");
885 assert!(is_exchange_name("TeamCraft-Results.JSON") && !is_exchange_name("results.json"));
886 }
887
888 #[test]
889 fn globs() {
890 assert!(glob_matches("*.log", "Solver.LOG"));
891 assert!(glob_matches("summary*.txt", "summary_v2.txt"));
892 assert!(glob_matches("run-?.csv", "run-7.csv"));
893 assert!(!glob_matches("run-?.csv", "run-77.csv"));
894 assert!(glob_matches("*", "anything"));
895 assert!(!glob_matches("*.log", "a.logx"));
896 assert!(glob_matches("a*b*c", "aXXbYYc"));
897 assert!(!glob_matches("a*b*c", "aXXbYY"));
898 assert!(glob_matches("summary*", "summary"));
900 assert!(glob_matches("run.log**", "run.log"));
901 assert!(glob_matches("*", ""));
902 assert!(!glob_matches("summary*x", "summary"));
903 let name = "a".repeat(5000);
905 assert!(!glob_matches(
906 &"a*".repeat(200).replace("a*a*", "a*a*b"),
907 &name
908 ));
909 }
910
911 #[test]
912 fn regex_template_finds_the_last_match_with_its_line() {
913 let t = template("regex", "*.log", r"Pressure drop:\s*([-+0-9.eE]+)\s*Pa");
914 let log = "start\nPressure drop: 410.5 Pa\niter\nPressure drop: 388.25 Pa\nend\n";
915 let found = extract("run.log", log.as_bytes(), std::slice::from_ref(&t));
916 assert_eq!(found.len(), 1);
917 assert_eq!(found[0].value, 388.25);
918 assert_eq!(found[0].source, "run.log:4");
919 assert_eq!(found[0].unit, "Pa");
920 assert_eq!(found[0].upper_limit, Some(500.0));
921 let mut first = t.clone();
922 first.pick = "first".into();
923 let found = extract("run.log", log.as_bytes(), &[first]);
924 assert_eq!(
925 (found[0].value, found[0].source.as_str()),
926 (410.5, "run.log:2")
927 );
928 assert!(extract("run.txt", log.as_bytes(), std::slice::from_ref(&t)).is_empty());
930 assert!(extract("run.log", b"nothing here", std::slice::from_ref(&t)).is_empty());
931 let mut off = t.clone();
932 off.enabled = false;
933 assert!(extract("run.log", log.as_bytes(), &[off]).is_empty());
934 assert!(extract("run.log", b"Pressure drop: 1 Pa\0", &[t]).is_empty());
935 }
936
937 #[test]
938 fn regex_template_needs_one_capture_group_and_stays_bounded() {
939 let ok = |p: &str| {
940 TemplateInput {
941 name: "x".into(),
942 file_pattern: "*.log".into(),
943 method: "regex".into(),
944 pattern: p.into(),
945 ..Default::default()
946 }
947 .validated()
948 };
949 assert!(ok(r"a=([0-9.]+)").is_ok());
950 assert!(ok(r"a=[0-9.]+")
951 .unwrap_err()
952 .contains("exactly one capture group"));
953 assert!(ok(r"(a)=([0-9.]+)").unwrap_err().contains("it has 2"));
954 assert!(ok(r"(unclosed")
955 .unwrap_err()
956 .contains("not a valid regular expression"));
957 assert!(ok(r"(a{1000}){1000}").is_err());
959 assert!(ok(&"a".repeat(MAX_PATTERN_LEN + 1)).is_err());
960 let t = template("regex", "*.txt", r"(?:(?:a+)+)b|([0-9]+)");
962 let big = format!("{}\n", "a".repeat(100_000));
963 let start = std::time::Instant::now();
964 let _ = extract("x.txt", big.as_bytes(), &[t]);
965 assert!(start.elapsed().as_secs() < 5);
966 }
967
968 #[test]
969 fn csv_template_by_column_and_row_key() {
970 let mut t = template("csv", "summary.csv", "");
971 t.csv_column = "dp_pa".into();
972 let csv = "case,dp_pa,mass\nA,100,1\nB,250.5,2\n";
973 let found = extract("Summary.csv", csv.as_bytes(), std::slice::from_ref(&t));
974 assert_eq!(
975 (found[0].value, found[0].source.as_str()),
976 (250.5, "Summary.csv:3")
977 );
978 t.csv_key_column = "case".into();
979 t.csv_key_value = "A".into();
980 let found = extract("summary.csv", csv.as_bytes(), std::slice::from_ref(&t));
981 assert_eq!(
982 (found[0].value, found[0].source.as_str()),
983 (100.0, "summary.csv:2")
984 );
985 t.csv_key_value = "Z".into();
986 assert!(extract("summary.csv", csv.as_bytes(), std::slice::from_ref(&t)).is_empty());
987 t.csv_key_value = "B".into();
989 let de = "case;dp_pa\nB;12,5\n";
990 assert_eq!(
991 extract("summary.csv", de.as_bytes(), &[t.clone()])[0].value,
992 12.5
993 );
994 let mut first = template("csv", "summary.csv", "");
997 first.csv_column = "100".into();
998 first.pick = "first".into();
999 let temps = "case,100,200\nA,1.5,2.5\nB,3.5,4.5\n";
1000 let found = extract("summary.csv", temps.as_bytes(), &[first]);
1001 assert_eq!(
1002 (found[0].value, found[0].source.as_str()),
1003 (1.5, "summary.csv:2")
1004 );
1005 t.csv_column = "nope".into();
1007 assert!(extract("summary.csv", csv.as_bytes(), &[t]).is_empty());
1008 }
1009
1010 #[test]
1011 fn html_summaries_are_read_as_text() {
1012 let t = template("regex", "*.html", r"Pressure drop[^0-9-]*([-0-9.]+)");
1013 let html = "<html><body><table><tr><td>Pressure drop</td><td>412.0</td></tr>\n<tr><td>Mass</td><td>3</td></tr></table></body></html>";
1014 let text = crate::htmltext::extract(html.as_bytes()).unwrap();
1016 let found = extract("report.html", text.as_bytes(), &[t]);
1017 assert_eq!(found[0].value, 412.0);
1018 assert_eq!(found[0].source, "report.html:1");
1019 let mut t = template("csv", "*.htm", "");
1021 t.csv_column = "Value".into();
1022 t.csv_key_column = "Result".into();
1023 t.csv_key_value = "Max. stress".into();
1024 let html = "<table><tr><th>Result</th><th>Value</th></tr><tr><td>Mass</td><td>3</td></tr>\
1025 <tr><td>Max. stress</td><td>152.4</td></tr></table>";
1026 let text = crate::htmltext::extract(html.as_bytes()).unwrap();
1027 assert_eq!(extract("r.htm", text.as_bytes(), &[t])[0].value, 152.4);
1028 let mut t = template("csv", "*.html", "");
1031 t.csv_column = "Max".into();
1032 let html = "<h1>Bracket, static, LC1</h1><p>Mesh: 12,345 nodes, 6,789 elements, \
1033 solver: a, b, c, d, e, f, g, h.</p>\
1034 <table><tr><th></th><th>Min</th><th>Max</th></tr>\
1035 <tr><td>Stress \"von Mises</td><td>1.0</td><td>152.4</td></tr></table>";
1036 let text = crate::htmltext::extract(html.as_bytes()).unwrap();
1037 let found = extract("report.html", text.as_bytes(), std::slice::from_ref(&t));
1038 assert_eq!(found.len(), 1, "{text}");
1039 assert_eq!(found[0].value, 152.4, "the Max column, not Min: {text}");
1040 assert_eq!(found[0].source, "report.html:6", "{text}");
1041 t.csv_column = "Min".into();
1042 assert_eq!(extract("report.html", text.as_bytes(), &[t])[0].value, 1.0);
1043 }
1044
1045 #[test]
1046 fn template_input_rules() {
1047 let base = TemplateInput {
1048 name: "dp".into(),
1049 file_pattern: "*.csv".into(),
1050 method: "csv".into(),
1051 csv_column: "dp".into(),
1052 ..Default::default()
1053 };
1054 assert!(base.clone().validated().is_ok());
1055 let mut t = base.clone();
1056 t.csv_key_column = "case".into();
1057 assert!(t.validated().unwrap_err().contains("both the key column"));
1058 let mut t = base.clone();
1059 t.method = "xpath".into();
1060 assert!(t.validated().is_err());
1061 let mut t = base.clone();
1062 t.file_pattern = "dir/*.csv".into();
1063 assert!(t.validated().is_err());
1064 let mut t = base.clone();
1065 t.lower_limit = Some(5.0);
1066 t.upper_limit = Some(1.0);
1067 assert!(t.validated().is_err());
1068 let mut t = base;
1069 t.pick = "middle".into();
1070 assert!(t.validated().is_err());
1071 }
1072
1073 #[test]
1078 fn template_input_is_stored_normalised() {
1079 let t = TemplateInput {
1080 name: " dp ".into(),
1081 file_pattern: " *.csv\t".into(),
1082 method: " csv ".into(),
1083 csv_column: " dp ".into(),
1084 pick: " ".into(),
1085 unit: " bar ".into(),
1086 load_case: " LC1 ".into(),
1087 solver: " Fluent ".into(),
1088 ..Default::default()
1089 }
1090 .validated()
1091 .unwrap();
1092 assert_eq!(t.name, "dp");
1093 assert_eq!(t.file_pattern, "*.csv");
1094 assert_eq!(t.method, "csv");
1095 assert_eq!(t.csv_column, "dp");
1096 assert_eq!(t.pick, "last", "no pick means the last match");
1097 assert_eq!(t.unit, "bar");
1098 assert_eq!(t.load_case, "LC1");
1099 assert_eq!(t.solver, "Fluent");
1100 }
1101
1102 const FLUENT_REPORT: &str = "\"report-def-0-rfile\"\n\
1104 \"Iteration\" \"pressure-drop\" \"flow-time\"\n\
1105 (\"Iteration\" \"pressure-drop\" \"flow-time\")\n\
1106 1 2.53e+02 0.001\n\
1107 2 2.49e+02 0.002\n\
1108 3 2.47e+02 0.003\n";
1109
1110 fn table(column: &str) -> ResultTemplate {
1111 let mut t = template("table", "report-file-*.out", "");
1112 t.csv_column = column.into();
1113 t
1114 }
1115
1116 #[test]
1117 fn table_template_reads_fluent_report_files() {
1118 let t = table("pressure-drop");
1119 let found = extract(
1120 "report-file-0.out",
1121 FLUENT_REPORT.as_bytes(),
1122 std::slice::from_ref(&t),
1123 );
1124 assert_eq!(found.len(), 1);
1125 assert_eq!(found[0].value, 247.0);
1126 assert_eq!(found[0].source, "report-file-0.out:6");
1127 let mut first = t.clone();
1129 first.pick = "first".into();
1130 assert_eq!(
1131 extract("report-file-0.out", FLUENT_REPORT.as_bytes(), &[first])[0].value,
1132 253.0
1133 );
1134 let mut keyed = table("Pressure-Drop");
1136 keyed.csv_key_column = "iteration".into();
1137 keyed.csv_key_value = "2".into();
1138 assert_eq!(
1139 extract("report-file-0.out", FLUENT_REPORT.as_bytes(), &[keyed])[0].value,
1140 249.0
1141 );
1142 let spaced = "\"t\"\n\"Time Step\" \"drag force\"\n10 1.5\n20 1.25\n";
1143 let mut t2 = table("drag force");
1144 t2.csv_key_column = "time step".into();
1145 t2.csv_key_value = "10".into();
1146 assert_eq!(
1147 extract("report-file-1.out", spaced.as_bytes(), &[t2])[0].value,
1148 1.5
1149 );
1150 assert!(extract(
1152 "report-file-0.out",
1153 FLUENT_REPORT.as_bytes(),
1154 &[table("lift")]
1155 )
1156 .is_empty());
1157 assert!(extract(
1158 "report-file-0.out",
1159 b"\"t\"\n\"a\" \"b\"\nx y\n",
1160 &[table("b")]
1161 )
1162 .is_empty());
1163 assert!(extract("report-file-0.out", b"1 2\n3 4\n", &[table("b")]).is_empty());
1164 assert!(extract("report-file-0.out", b"\"b\"\n1\n", &[table("b")]).is_empty());
1166 }
1167
1168 #[test]
1169 fn table_cells_split_quoted_names_and_white_space() {
1170 assert_eq!(
1171 table_cells("(\"Time Step\" \"a\")", 0),
1172 vec!["Time Step".to_string(), "a".to_string()]
1173 );
1174 assert_eq!(table_cells(" 1\t2.5 3 ", 0), vec!["1", "2.5", "3"]);
1175 assert_eq!(table_cells("\"unclosed", 0), vec!["unclosed"]);
1176 assert!(table_cells("", 0).is_empty());
1177 assert!(table_cells("()", 0).is_empty());
1178 }
1179
1180 #[test]
1181 fn pick_last_reads_the_tail_of_a_large_file() {
1182 let head = "Welcome to ANSYS Fluent\n".repeat(10);
1184 let tail = " 499 1.0e-04\n 500 9.0e-05\nPressure drop: 251.5 Pa\nDone.\n";
1185 let mut t = template("regex", "*.trn", r"Pressure drop:\s*([-+0-9.eE]+)");
1186 let found = extract_with_tail("run.trn", head.as_bytes(), tail.as_bytes(), &[t.clone()]);
1187 assert_eq!(found[0].value, 251.5);
1188 assert_eq!(found[0].source, "run.trn: line 2 from the end");
1189 let tail_last = "x\nPressure drop: 9 Pa\n";
1191 let found = extract_with_tail(
1192 "run.trn",
1193 head.as_bytes(),
1194 tail_last.as_bytes(),
1195 &[t.clone()],
1196 );
1197 assert_eq!(found[0].source, "run.trn: last line");
1198 let head2 = "Pressure drop: 1 Pa\nmore\n";
1200 let found = extract_with_tail("run.trn", head2.as_bytes(), b"nothing\n", &[t.clone()]);
1201 assert_eq!(
1202 (found[0].value, found[0].source.as_str()),
1203 (1.0, "run.trn:1")
1204 );
1205 t.pick = "first".into();
1207 assert!(extract_with_tail("run.trn", head.as_bytes(), tail.as_bytes(), &[t]).is_empty());
1208 let found = extract_with_tail(
1210 "report-file-0.out",
1211 FLUENT_REPORT.as_bytes(),
1212 b"9999 1.11e+02 9.9\n10000 1.10e+02 10.0\n",
1213 &[table("pressure-drop")],
1214 );
1215 assert_eq!(found[0].value, 110.0);
1216 assert_eq!(found[0].source, "report-file-0.out: last line");
1217 let mut c = template("csv", "*.csv", "");
1219 c.csv_column = "drag".into();
1220 let csv_head = "iter,drag\n1,5.0\n2,4.0\n";
1221 let found = extract_with_tail(
1222 "m.csv",
1223 csv_head.as_bytes(),
1224 b"700,1.5\n701,1.25\n",
1225 &[c.clone()],
1226 );
1227 assert_eq!(found[0].value, 1.25);
1228 c.csv_key_column = "iter".into();
1229 c.csv_key_value = "2".into();
1230 let found = extract_with_tail("m.csv", csv_head.as_bytes(), b"700,1.5\n701,1.25\n", &[c]);
1231 assert_eq!((found[0].value, found[0].source.as_str()), (4.0, "m.csv:3"));
1232 let t = template("regex", "*.trn", r"Pressure drop:\s*([-+0-9.eE]+)");
1234 assert!(extract_with_tail(
1235 "run.trn",
1236 head.as_bytes(),
1237 b"\0Pressure drop: 1\n",
1238 std::slice::from_ref(&t)
1239 )
1240 .is_empty());
1241 let found = extract_with_tail(
1242 "run.trn",
1243 head2.as_bytes(),
1244 b"Pressure drop: 7 Pa\n\0\0\0\0",
1245 std::slice::from_ref(&t),
1246 );
1247 assert_eq!(
1248 (found[0].value, found[0].source.as_str()),
1249 (1.0, "run.trn:1")
1250 );
1251 assert!(extract_with_tail("run.trn", b"bin\0", b"Pressure drop: 1\n", &[t]).is_empty());
1253 }
1254
1255 #[test]
1256 fn a_five_megabyte_log_with_the_value_at_the_end() {
1257 let line = " iter continuity x-velocity y-velocity 1.0e-03 2.0e-03\n";
1260 let mut file = line.repeat(5 * 1024 * 1024 / line.len());
1261 file.push_str("Pressure drop: 251.5 Pa\n");
1262 let max = usize::try_from(SCAN_MAX_BYTES).unwrap();
1263 let head = &file.as_bytes()[..max];
1264 let raw_tail = &file.as_bytes()[file.len() - max..];
1265 let cut = raw_tail.iter().position(|b| *b == b'\n').unwrap();
1266 let tail = &raw_tail[cut + 1..];
1267 let t = template("regex", "*.log", r"Pressure drop:\s*([-+0-9.eE]+)");
1268 assert!(
1269 extract("run.log", head, std::slice::from_ref(&t)).is_empty(),
1270 "not in the head"
1271 );
1272 let found = extract_with_tail("run.log", head, tail, &[t]);
1273 assert_eq!(found[0].value, 251.5);
1274 }
1275
1276 #[test]
1277 fn table_method_is_accepted_with_a_column() {
1278 let input = TemplateInput {
1279 name: "dp".into(),
1280 file_pattern: "report-file-*.out".into(),
1281 method: "table".into(),
1282 csv_column: "pressure-drop".into(),
1283 ..Default::default()
1284 };
1285 assert_eq!(input.clone().validated().unwrap().method, "table");
1286 let mut no_column = input;
1287 no_column.csv_column = String::new();
1288 assert!(no_column.validated().is_err());
1289 assert!(scannable("run.trn") && scannable("x.F06") && scannable("m.java"));
1290 assert!(!scannable("pipe.cas.h5") && !scannable("run.op2"));
1291 }
1292
1293 #[test]
1296 fn csv_delimiter() {
1297 let lines = |s: &[&str]| s.iter().map(|l| l.to_string()).collect::<Vec<_>>();
1298 assert_eq!(delimiter(&lines(&["a;b;c", "1;2;3"])), b';');
1299 assert_eq!(
1300 delimiter(&lines(&["a\tb", "1,5\t2,5"])),
1301 b',',
1302 "2 commas vs 2 tabs"
1303 );
1304 assert_eq!(
1305 delimiter(&lines(&["a;b", "1,2"])),
1306 b',',
1307 "a tie keeps the comma"
1308 );
1309 assert_eq!(delimiter(&lines(&["a|b|c"])), b'|');
1310 assert_eq!(delimiter(&lines(&["just text"])), b',');
1311 }
1312
1313 #[test]
1316 fn templates_are_enabled_unless_said_otherwise() {
1317 let t: TemplateInput = serde_json::from_str(
1318 r#"{"name":"Max stress","file_pattern":"*.frd","method":"regex"}"#,
1319 )
1320 .unwrap();
1321 assert!(t.enabled);
1322 let t: TemplateInput = serde_json::from_str(
1323 r#"{"name":"Max stress","file_pattern":"*.frd","method":"regex","enabled":false}"#,
1324 )
1325 .unwrap();
1326 assert!(!t.enabled);
1327 }
1328
1329 #[test]
1332 fn template_pick() {
1333 let with = |pick: &str| TemplateInput {
1334 name: "Max stress".into(),
1335 file_pattern: "*.dat".into(),
1336 method: "regex".into(),
1337 pattern: r"max = (\S+)".into(),
1338 pick: pick.into(),
1339 enabled: true,
1340 ..Default::default()
1341 };
1342 assert_eq!(with("").validated().unwrap().pick, "last");
1343 assert_eq!(with("first").validated().unwrap().pick, "first");
1344 assert_eq!(with(" last ").validated().unwrap().pick, "last");
1345 assert!(with("middle").validated().is_err());
1346 }
1347
1348 #[test]
1350 fn template_validation() {
1351 let regex = TemplateInput {
1352 name: "Max stress".into(),
1353 file_pattern: "*.dat".into(),
1354 method: "regex".into(),
1355 pattern: r"max = (\S+)".into(),
1356 enabled: true,
1357 ..Default::default()
1358 };
1359 let limits = |lo: Option<f64>, hi: Option<f64>| {
1361 TemplateInput {
1362 lower_limit: lo,
1363 upper_limit: hi,
1364 ..regex.clone()
1365 }
1366 .validated()
1367 };
1368 assert!(limits(Some(1.0), Some(2.0)).is_ok());
1369 assert!(limits(Some(2.0), Some(2.0)).is_ok(), "equal limits");
1370 assert!(limits(Some(-5.0), None).is_ok());
1371 assert!(limits(Some(3.0), Some(2.0)).unwrap_err().contains("above"));
1372 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1373 assert!(limits(Some(bad), None).unwrap_err().contains("finite"));
1374 assert!(limits(None, Some(bad)).unwrap_err().contains("finite"));
1375 }
1376 for bad in ["max = \\S+", "(max) = (\\S+)", "("] {
1378 let t = TemplateInput {
1379 pattern: bad.into(),
1380 ..regex.clone()
1381 };
1382 assert!(t.validated().is_err(), "{bad}");
1383 }
1384 for bad in ["out/*.dat", "out\\*.dat"] {
1386 let t = TemplateInput {
1387 file_pattern: bad.into(),
1388 ..regex.clone()
1389 };
1390 assert!(t.validated().unwrap_err().contains("not paths"), "{bad}");
1391 }
1392 let csv = |column: &str, key_column: &str, key_value: &str| {
1394 TemplateInput {
1395 method: "csv".into(),
1396 pattern: String::new(),
1397 csv_column: column.into(),
1398 csv_key_column: key_column.into(),
1399 csv_key_value: key_value.into(),
1400 ..regex.clone()
1401 }
1402 .validated()
1403 };
1404 let ok = csv("Stress", "", "").unwrap();
1405 assert_eq!(
1406 (ok.csv_column.as_str(), ok.pattern.as_str()),
1407 ("Stress", "")
1408 );
1409 assert!(csv("Stress", "Load case", "LC1").is_ok());
1410 assert!(csv("", "", "").is_err(), "a column is needed");
1411 assert!(csv("Stress", "Load case", "").unwrap_err().contains("both"));
1412 assert!(csv("Stress", "", "LC1").unwrap_err().contains("both"));
1413 }
1414
1415 #[test]
1418 fn exchange_file_limits() {
1419 use crate::services::simulations::MAX_RESULTS;
1420 let values = |n: usize| {
1421 let rows: Vec<String> = (0..n)
1422 .map(|i| format!(r#"{{"name":"v{i}","value":{i}}}"#))
1423 .collect();
1424 format!("[{}]", rows.join(","))
1425 };
1426 assert_eq!(
1427 parse_exchange(EXCHANGE_JSON, values(MAX_RESULTS).as_bytes())
1428 .unwrap()
1429 .len(),
1430 MAX_RESULTS
1431 );
1432 for n in [MAX_RESULTS + 1, MAX_RESULTS + 2] {
1433 let err = parse_exchange(EXCHANGE_JSON, values(n).as_bytes()).unwrap_err();
1434 assert!(err.contains("more than"), "{n}: {err}");
1435 }
1436 let size = usize::try_from(EXCHANGE_MAX_BYTES).unwrap();
1438 let mut padded = br#"[{"name":"a","value":1}]"#.to_vec();
1439 padded.resize(size, b' ');
1440 assert_eq!(parse_exchange(EXCHANGE_JSON, &padded).unwrap().len(), 1);
1441 padded.push(b' ');
1442 let err = parse_exchange(EXCHANGE_JSON, &padded).unwrap_err();
1443 assert!(err.contains("larger than"), "{err}");
1444 }
1445
1446 #[test]
1449 fn limits() {
1450 assert_eq!(EXCHANGE_MAX_BYTES, 1024 * 1024);
1451 assert_eq!(SCAN_MAX_BYTES, 2 * 1024 * 1024);
1452 assert_eq!(SCAN_MAX_LINES, 200_000);
1453 assert_eq!(MAX_LINE_CHARS, 8 * 1024);
1454 assert_eq!(MAX_TEMPLATES, 100);
1455 assert_eq!(MAX_PATTERN_LEN, 500);
1456 assert_eq!(REGEX_SIZE_LIMIT, 256 * 1024);
1457 }
1458}