Skip to main content

teamcraft/
resultscan.rs

1//! Result values from small, structured files at check-in – never from big
2//! result files (those are stored, not parsed).
3//!
4//! Two ways, both bounded and pure (no database, no I/O here):
5//!
6//! * the **exchange file** `teamcraft-results.json` / `teamcraft-results.csv`
7//!   checked in with a simulation revision (at most [`EXCHANGE_MAX_BYTES`]),
8//!   see [`parse_exchange`] for the schema;
9//! * **extraction templates** of a project group ([`ResultTemplate`]): a file
10//!   name pattern plus a regular expression (one capture group), a CSV
11//!   column or a column of a whitespace table with quoted headers (Fluent
12//!   report files) that finds one number in a *text* file; only the first
13//!   and – for `pick = last` – the last [`SCAN_MAX_BYTES`] of a file are
14//!   looked at, see [`extract_with_tail`].
15//!
16//! Regular expressions are compiled with the `regex` crate's size limits and
17//! run in time linear in the input (no backtracking), so a hostile pattern or
18//! file can neither hang nor exhaust the server.
19#![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
34/// Largest exchange file; a bigger one is refused with the check-in.
35pub const EXCHANGE_MAX_BYTES: u64 = 1024 * 1024;
36
37/// Bytes of a text file that templates look at (the beginning).
38pub const SCAN_MAX_BYTES: u64 = 2 * 1024 * 1024;
39
40/// Lines (rows) looked at per file.
41pub const SCAN_MAX_LINES: usize = 200_000;
42
43/// Longest line a pattern is applied to (longer lines are cut).
44const MAX_LINE_CHARS: usize = 8 * 1024;
45
46/// Templates of one project group.
47pub const MAX_TEMPLATES: usize = 100;
48
49/// Longest regular expression of a template.
50pub const MAX_PATTERN_LEN: usize = 500;
51
52/// Compiled size of a template's regular expression.
53const REGEX_SIZE_LIMIT: usize = 256 * 1024;
54
55/// The exchange files, by (lower case) name.
56pub const EXCHANGE_JSON: &str = "teamcraft-results.json";
57pub const EXCHANGE_CSV: &str = "teamcraft-results.csv";
58
59/// Whether a file name is one of the exchange files.
60pub fn is_exchange_name(filename: &str) -> bool {
61    let lower = filename.to_ascii_lowercase();
62    lower == EXCHANGE_JSON || lower == EXCHANGE_CSV
63}
64
65/// Text files that templates may scan (by extension).
66pub 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            // solver logs, print output and scripts of Fluent, STAR-CCM+, Nastran
87            | "trn"
88            | "f06"
89            | "f04"
90            | "pch"
91            | "jou"
92            | "java"
93    )
94}
95
96// ---------------------------------------------------------------------------
97// The exchange file
98// ---------------------------------------------------------------------------
99
100/// The keys of a result in `teamcraft-results.json`.
101const 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
116/// Reads `teamcraft-results.json` or `teamcraft-results.csv`.
117///
118/// **JSON:** an array of results, or an object `{"results": [...]}`. Every
119/// result has `name` (text) and `value` (number); optional `unit`,
120/// `limit_min`, `limit_max` (numbers or `null`), `load_case`, `solver`,
121/// `solver_version` and `note`. Unknown keys are refused.
122///
123/// **CSV:** a header row with `name,value,unit,limit_min,limit_max,load_case`
124/// (further columns `solver`, `solver_version`, `note`), one result per row.
125///
126/// A file that is too large, malformed or has a wrong field is an error with
127/// the place of the problem (JSON path or row); nothing is half-read.
128pub 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// ---------------------------------------------------------------------------
256// Extraction templates
257// ---------------------------------------------------------------------------
258
259/// How a template finds its number.
260#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
261#[serde(rename_all = "lowercase")]
262pub enum Method {
263    Regex,
264    Csv,
265    /// Whitespace-separated table with quoted column names (Fluent report
266    /// files); uses the `csv_*` fields for the column and the row key.
267    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/// An extraction template of a project group (a row of `result_templates`).
290#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
291pub struct ResultTemplate {
292    pub id: Uuid,
293    pub project_id: Uuid,
294    /// The result value's name.
295    pub name: String,
296    /// File names it applies to: `*` and `?`, case-insensitive.
297    pub file_pattern: String,
298    /// `regex`, `csv` or `table`.
299    pub method: String,
300    /// `regex`: the expression; its first capture group is the number.
301    pub pattern: String,
302    /// `csv` / `table`: the column (header name) that holds the number.
303    pub csv_column: String,
304    /// `csv`: optional – only the row whose `csv_key_column` is `csv_key_value`.
305    pub csv_key_column: String,
306    pub csv_key_value: String,
307    /// More than one match: `first` or `last`.
308    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/// A template as entered (web form, REST API).
320#[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    /// Checks and normalises the input: lengths, the method's fields and a
355    /// regular expression that compiles within the limits and has exactly
356    /// one capture group.
357    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
426/// The pattern as a regular expression: bounded in size, exactly one capture
427/// group (the number).
428fn 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
445/// Whether `name` matches `pattern` (`*` any text, `?` one character; no
446/// case distinction).
447pub 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    // iterative wildcard match, linear in practice and never recursive
451    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
476/// Text lines of a file for the patterns. An HTML report arrives as its
477/// extracted text already (see [`extract`]), so nothing is stripped here.
478fn 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
486/// The last [`SCAN_MAX_LINES`] lines of a file's end (see
487/// [`crate::textindex::read_tail`]: it starts at a line).
488fn 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/// Where a value was found: a line of the beginning (1-based) or a line
498/// counted from the end of the file (1 = the last line).
499#[derive(Clone, Copy, Debug, PartialEq, Eq)]
500enum Place {
501    Line(usize),
502    FromEnd(usize),
503}
504
505/// The values the templates find in one file (`bytes` = its beginning; for
506/// an HTML report the text `htmltext` extracted in the isolated child, with
507/// table rows as lines and cells separated by ` | ` – the server never
508/// parses HTML itself).
509/// A template that does not match yields nothing; each value carries its
510/// source `file:line`.
511pub fn extract(filename: &str, bytes: &[u8], templates: &[ResultTemplate]) -> Vec<ResultInput> {
512    extract_with_tail(filename, bytes, &[], templates)
513}
514
515/// [`extract`] for a file larger than [`SCAN_MAX_BYTES`]: `head` is its
516/// beginning, `tail` its end (at most [`SCAN_MAX_BYTES`] each, the tail
517/// starting at a line; empty when the head holds the whole file). A
518/// template with `pick = last` looks at the tail first – a solver writes its
519/// final values at the end – and falls back to the head; table headers
520/// (`csv`, `table`) always come from the head. `pick = first` reads the
521/// head only. A tail holding a NUL (zero padding) is ignored. For a file
522/// below twice [`SCAN_MAX_BYTES`] head and tail overlap: a value found in
523/// the overlap is labelled by its line from the end, not its line number.
524pub 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    // a NUL in the end only (zero padding of a crashed or preallocated log):
534    // the templates fall back to the head
535    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
578/// One template over the head, or over the tail rows (`tail`) with the
579/// table header from the head: the value and the 0-based index of its line
580/// in the lines searched. `html`: the text of an HTML report (see
581/// [`find_csv`]).
582fn 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
613/// The delimiter of a CSV text: the one that occurs most often in the first
614/// lines (the text of an HTML report always uses `|`, see [`find_csv`]).
615fn 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
630/// A header name or cell as compared: trimmed, without quotes, lower case.
631fn norm(s: &str) -> String {
632    s.trim().trim_matches('"').trim().to_lowercase()
633}
634
635/// The cells of a line of a table.
636type Cells = fn(&str, u8) -> Vec<String>;
637
638fn csv_cells(line: &str, delim: u8) -> Vec<String> {
639    // one row per line (a quoted cell with a line break is not supported)
640    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
653/// The cells of a table row in the text of an HTML report: always `|`,
654/// quotes are plain characters.
655fn 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
670/// The tokens of a whitespace-separated line; a `"…"` token may hold
671/// blanks (Fluent's quoted column names such as `"Time Step"`). An outer
672/// pair of parentheses – Fluent repeats the header as `("Iteration" …)` –
673/// is dropped.
674fn 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
697/// The header of a table in `head`: its line index, the value column and
698/// the key column (when the template has one).
699fn 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
722/// The rows after a header (in the head) or all rows of the tail, filtered
723/// by the key column, with the number of the value column.
724fn 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
751/// A CSV template on a table. For an HTML report (`html`) the text has one
752/// table row per line with the cells separated by ` | `, so `|` is the
753/// delimiter whatever the title and paragraphs above the table contain, and
754/// quotes are plain characters (a `"` in a cell never joins cells).
755fn 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
765/// `table`: a whitespace-separated table with a header of quoted column
766/// names (Fluent's `report-file-*.out`: a quoted title line, the header,
767/// optionally the header again in parentheses, then one row per iteration
768/// or time step). The header is the first line of only quoted names that
769/// holds the column; a row's cells are split at white space.
770fn 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        // "a" "b" … : an even number of quotes, nothing outside them
781        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        // at least two names: a quoted title line alone is no header
792        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        // a bare array is fine as well
836        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        // limits the wrong way round are refused with the result's number
866        let both = e(r#"[{"name":"a","value":1,"limit_min":5,"limit_max":1}]"#);
867        assert!(both.contains("result 1"), "{both}");
868        // 1e999 is not a JSON number a solver may write
869        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        // trailing stars match nothing as well (found by cargo mutants)
899        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        // pathological patterns stay fast
904        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        // other file name, no match, disabled, binary
929        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        // a huge repetition does not compile within the size limit
958        assert!(ok(r"(a{1000}){1000}").is_err());
959        assert!(ok(&"a".repeat(MAX_PATTERN_LEN + 1)).is_err());
960        // linear-time matching: no catastrophic backtracking
961        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        // semicolons and decimal commas
988        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        // a numeric column name is a header, not the first value (found by
995        // cargo mutants)
996        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        // no such column
1006        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        // the caller hands over the text htmltext extracted (in the child)
1015        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        // a CSV template on the table: the cells are separated by " | "
1020        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        // a real report: a title and a paragraph with commas above the table,
1029        // an empty top-left header cell, a quote in a cell
1030        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    /// What `validated` returns is what gets stored: trimmed text, the
1074    /// method's canonical name and the default pick – not the raw form input
1075    /// (found by cargo-mutants: deleting any of these fields from the result,
1076    /// so that `..self` passed the raw value through, went unnoticed).
1077    #[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    /// A Fluent report file as written by the report definitions.
1103    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        // the first row
1128        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        // a row by key; header names case-insensitive, quoted names with blanks
1135        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        // unknown column, rows without numbers, no header: nothing
1151        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        // a quoted title alone is no header of two columns: the title is not matched
1165        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        // the head: the start of a long transcript, the value only at the end
1183        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        // the last line is named as such
1190        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        // not in the tail: the head is searched
1199        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        // pick = first never looks at the tail
1206        t.pick = "first".into();
1207        assert!(extract_with_tail("run.trn", head.as_bytes(), tail.as_bytes(), &[t]).is_empty());
1208        // a table: the header from the head, the last row from the tail
1209        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        // CSV likewise; a key that is only in the head falls back to it
1218        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        // NUL in the tail (zero padding): the tail is ignored, the head used
1233        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        // NUL in the head: a binary file, nothing
1252        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        // what files.rs hands over for a 5 MB file: the first and the last
1258        // SCAN_MAX_BYTES, the tail starting at a line
1259        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    /// Found by cargo mutants: the most frequent delimiter wins, a tie keeps
1294    /// the earlier one of `, ; tab |`, and no delimiter means a comma.
1295    #[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    /// Found by cargo mutants: a template sent without `enabled` (REST API)
1314    /// is enabled.
1315    #[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    /// Found by cargo mutants: `pick` is `last` by default, `first` stays,
1330    /// anything else is refused.
1331    #[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    /// Found by cargo mutants: the checks of `TemplateInput::validated`.
1349    #[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        // limits: finite, and the lower one not above the upper one
1360        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        // a regular expression with exactly one capture group
1377        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        // file names, not paths
1385        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        // CSV: a column; key column and key value together or not at all
1393        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    /// Found by cargo mutants: both limits of an exchange file are inclusive
1416    /// and hold beyond the first value past them.
1417    #[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        // exactly EXCHANGE_MAX_BYTES (padded with white space) is read
1437        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    /// The documented limits (found by cargo mutants: behavioural tests
1447    /// would have to read megabytes).
1448    #[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}