Skip to main content

teamcraft/
solversummary.rs

1//! A short summary of solver output files (Fluent transcripts and report
2//! files, Nastran `.f06` and decks, STAR-CCM+ macros and batch logs): the
3//! solver and its version, how the run ended, the last iteration or time
4//! step, and counts of `FATAL` / `WARNING` messages.
5//!
6//! Only the **head and the tail** of a file are read, at most
7//! [`WINDOW`] (64 KiB) each, never the middle of a large file. A count
8//! therefore covers the two windows only and is labelled that way
9//! ([`Summary::line`]). See `docs/src/research/solver-files.md`.
10//!
11//! **The rules are hypotheses.** They follow public documentation and typical
12//! output; no real Fluent, Nastran or STAR-CCM+ output was available. A
13//! summary is a hint taken from a user-written file, never a verified
14//! result: the UI shows it as a line of text and the MCP tools hand it out
15//! as untrusted data.
16//!
17//! The text is cut and cleaned (printable characters, bounded length), so a
18//! hostile file cannot make a summary large or inject markup.
19#![deny(
20    clippy::unwrap_used,
21    clippy::expect_used,
22    clippy::indexing_slicing,
23    clippy::panic,
24    clippy::unreachable
25)]
26
27use std::sync::OnceLock;
28
29use regex::Regex;
30use serde::{Deserialize, Serialize};
31
32/// Bytes of the head and of the tail that are looked at.
33pub const WINDOW: usize = 64 * 1024;
34
35/// Longest text of one field of a summary (characters).
36const MAX_FIELD: usize = 48;
37
38/// Format ids (see `cadformat`) a summary is made for.
39const FORMATS: [&str; 6] = [
40    "fluent-transcript",
41    "fluent-report",
42    "nastran-f06",
43    "nastran-bulk",
44    "starccm-macro",
45    "starccm-log",
46];
47
48/// How a run ended, as far as the output tells.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Status {
52    /// The solver reported convergence or a satisfied stopping criterion.
53    Converged,
54    /// The run reached its end marker (Nastran `END OF JOB`).
55    Completed,
56    /// A fatal message in the tail.
57    Fatal,
58}
59
60impl Status {
61    fn label(self) -> &'static str {
62        match self {
63            Status::Converged => "converged",
64            Status::Completed => "END OF JOB",
65            Status::Fatal => "fatal",
66        }
67    }
68}
69
70/// The summary of one file version; stored as JSON in
71/// `file_versions.solver_summary`.
72#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Summary {
74    /// Schema version of the stored JSON.
75    pub v: u8,
76    /// `Fluent`, `Simcenter Nastran`, `STAR-CCM+` …
77    pub solver: String,
78    /// What the file is: `transcript`, `report file`, `print output`, `deck`, `macro`, `log`.
79    pub kind: String,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub version: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub status: Option<Status>,
84    /// `iteration 312`, `time step 40` …
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub last_step: Option<String>,
87    /// `FATAL` messages in the scanned windows (not in the whole file).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub fatal: Option<u32>,
90    /// `WARNING` messages in the scanned windows (not in the whole file).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub warning: Option<u32>,
93    /// Further facts (column names, `SOL 101`, macro name).
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub notes: Vec<String>,
96}
97
98impl Summary {
99    /// One line for the file row, e.g.
100    /// `Fluent 2023 R1 · transcript · converged · iteration 312 · 3 WARNING (scanned windows only)`.
101    pub fn line(&self) -> String {
102        let mut parts = vec![match &self.version {
103            Some(v) => format!("{} {v}", self.solver),
104            None => self.solver.clone(),
105        }];
106        parts.push(self.kind.clone());
107        if let Some(s) = self.status {
108            parts.push(s.label().to_string());
109        }
110        if let Some(s) = &self.last_step {
111            parts.push(s.clone());
112        }
113        parts.extend(self.notes.iter().cloned());
114        let (f, w) = (self.fatal.unwrap_or(0), self.warning.unwrap_or(0));
115        if f > 0 || w > 0 {
116            parts.push(format!(
117                "{f} FATAL, {w} WARNING (in the first and last 64 KiB only)"
118            ));
119        }
120        parts.join(" · ")
121    }
122
123    /// The summary as stored JSON.
124    pub fn to_json(&self) -> serde_json::Value {
125        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
126    }
127
128    /// Reads stored JSON back; `None` for anything that does not fit.
129    pub fn from_json(value: &serde_json::Value) -> Option<Summary> {
130        serde_json::from_value(value.clone()).ok()
131    }
132}
133
134/// Whether [`summarize`] has rules for a file: its format id, or none for an
135/// unclassified or plain-text `.log` / `.txt` (a STAR-CCM+ batch log has no format).
136pub fn applies(format_id: Option<&str>, filename: &str) -> bool {
137    match format_id {
138        Some(id) if id != "text" => FORMATS.contains(&id),
139        _ => {
140            let lower = filename.to_ascii_lowercase();
141            lower.ends_with(".log") || lower.ends_with(".txt")
142        }
143    }
144}
145
146/// The two windows of a file: the first [`WINDOW`] bytes of `head` and the
147/// last [`WINDOW`] bytes of the file's end, starting at a line. `tail` is the
148/// end of a larger file (empty when `head` holds the whole file); then the
149/// part of `head` behind the first window serves as the end, so the two
150/// windows never overlap.
151pub fn windows(head: &[u8], tail: &[u8]) -> (Vec<u8>, Vec<u8>) {
152    let first = head.get(..WINDOW).unwrap_or(head);
153    let from_head = tail.is_empty();
154    let rest = if from_head {
155        head.get(WINDOW..).unwrap_or_default()
156    } else {
157        tail
158    };
159    let cut = rest.len().saturating_sub(WINDOW);
160    let mut end = rest.get(cut..).unwrap_or_default();
161    if cut > 0 || from_head {
162        // the window starts inside a line: start at the next one
163        end = match end.iter().position(|b| *b == b'\n') {
164            Some(at) => end.get(at + 1..).unwrap_or_default(),
165            None => &[],
166        };
167    }
168    (first.to_vec(), end.to_vec())
169}
170
171/// Summarises a solver file from its head and tail (each cut to
172/// [`WINDOW`]); `None` when the content does not look like the format.
173/// `format_id` is the `cadformat` id; `""` for an unclassified text file.
174pub fn summarize(format_id: &str, head: &[u8], tail: &[u8]) -> Option<Summary> {
175    let head = head.get(..WINDOW).unwrap_or(head);
176    let tail = tail
177        .get(tail.len().saturating_sub(WINDOW)..)
178        .unwrap_or(tail);
179    if head.contains(&0) || tail.contains(&0) {
180        return None;
181    }
182    let r = rules()?;
183    let h = String::from_utf8_lossy(head);
184    let t = String::from_utf8_lossy(tail);
185    let mut s = match format_id {
186        "fluent-transcript" => fluent_transcript(r, &h, &t),
187        "fluent-report" => fluent_report(r, &h, &t),
188        "nastran-f06" => nastran_f06(r, &h, &t),
189        "nastran-bulk" => nastran_deck(r, &h),
190        "starccm-macro" => starccm_macro(r, &h),
191        "starccm-log" | "" => starccm_log(r, &h, &t),
192        _ => None,
193    }?;
194    s.v = 1;
195    Some(s)
196}
197
198// -- rules ----------------------------------------------------------------
199
200/// The compiled patterns; built once. A pattern that fails to compile (a
201/// bug the tests catch) makes every summary `None` instead of panicking.
202struct Rules {
203    fluent_version: Regex,
204    quoted: Regex,
205    f06_banner: Regex,
206    deck_sol: Regex,
207    deck_version: Regex,
208    macro_name: Regex,
209    star_version: Regex,
210    time_step: Regex,
211    iteration_row: Regex,
212    iteration_label: Regex,
213}
214
215fn rules() -> Option<&'static Rules> {
216    static RULES: OnceLock<Option<Rules>> = OnceLock::new();
217    RULES
218        .get_or_init(|| {
219            Some(Rules {
220                fluent_version: Regex::new(
221                    r"(?i)\bfluent\s+(?:version\s*:?\s*)?(\d{4}\s*R\d|\d{1,2}\.\d(?:\.\d+)?)\b",
222                )
223                .ok()?,
224                quoted: Regex::new(r#""([^"]*)""#).ok()?,
225                f06_banner: Regex::new(
226                    r"(?i)\b(MSC|NX|Simcenter|NEi)?[ .]*NASTRAN\s+(?:version\s+)?(V?\d{4}(?:\.\d+)?|V?\d+\.\d+(?:\.\d+)?)\b",
227                )
228                .ok()?,
229                deck_sol: Regex::new(r"(?im)^\s*SOL\s+(\d{1,4}|[A-Za-z][A-Za-z0-9]{0,15})\b").ok()?,
230                deck_version: Regex::new(
231                    r"(?i)nastran\s+(?:version\s+)?(V?\d{4}(?:\.\d+)?)\b",
232                )
233                .ok()?,
234                macro_name: Regex::new(
235                    r"(?im)^\s*//\s*(?:Simcenter\s+)?STAR-CCM\+\s+macro:\s*(\S[^\r\n]*)",
236                )
237                .ok()?,
238                star_version: Regex::new(
239                    r"(?i)STAR-CCM\+\s+(\d{4}(?:\.\d+)?|\d{1,2}\.\d+(?:\.\d+)?)(?:\s+build\s+([\d.]+))?",
240                )
241                .ok()?,
242                time_step: Regex::new(r"(?i)time[ -]step\s*[=:]?\s*(\d+)").ok()?,
243                iteration_row: Regex::new(r"^\s*(\d+)\s+[-+]?\d+\.\d+").ok()?,
244                iteration_label: Regex::new(r"(?i)^\s*iteration\s*[:=]?\s*(\d+)\s*$").ok()?,
245            })
246        })
247        .as_ref()
248}
249
250fn fluent_transcript(r: &Rules, head: &str, tail: &str) -> Option<Summary> {
251    let version = first_capture(head, &r.fluent_version);
252    let (fatal, warning) = count_markers(head, tail);
253    let end = end_of(head, tail);
254    let status = if tail_has_fatal(end) {
255        Some(Status::Fatal)
256    } else if end
257        .lines()
258        .rev()
259        .take(TAIL_LINES)
260        .any(|l| contains_ci(l, "solution is converged"))
261    {
262        Some(Status::Converged)
263    } else {
264        None
265    };
266    let step = last_step(r, end);
267    if version.is_none() && status.is_none() && step.is_none() {
268        return None;
269    }
270    Some(Summary {
271        solver: "Fluent".into(),
272        kind: "transcript".into(),
273        version,
274        status,
275        last_step: step,
276        fatal: Some(fatal),
277        warning: Some(warning),
278        ..Summary::default()
279    })
280}
281
282fn fluent_report(r: &Rules, head: &str, tail: &str) -> Option<Summary> {
283    let mut lines = head.lines().filter(|l| !l.trim().is_empty());
284    lines.next().filter(|l| l.trim_start().starts_with('"'))?;
285    let header = lines.next().filter(|l| l.trim_start().starts_with('"'))?;
286    let names: Vec<&str> = r
287        .quoted
288        .captures_iter(header)
289        .filter_map(|c| c.get(1).map(|m| m.as_str()))
290        .collect();
291    let source = if tail.trim().is_empty() { head } else { tail };
292    let last = source
293        .lines()
294        .rev()
295        .find(|l| !l.trim().is_empty() && !l.trim_start().starts_with(['"', '(']))?;
296    let step = last.split_whitespace().next()?;
297    let mut notes = vec![format!("{} columns", names.len())];
298    if let Some(name) = names.get(1) {
299        notes.push(format!("first value {}", clean(name)));
300    }
301    Some(Summary {
302        solver: "Fluent".into(),
303        kind: "report file".into(),
304        last_step: Some(format!("last row {}", clean(step))),
305        notes,
306        ..Summary::default()
307    })
308}
309
310fn nastran_f06(r: &Rules, head: &str, tail: &str) -> Option<Summary> {
311    let banner = head
312        .lines()
313        .take(200)
314        .find_map(|l| r.f06_banner.captures(l));
315    let product = banner.as_ref().and_then(|c| c.get(1)).map(|m| m.as_str());
316    let version = banner
317        .as_ref()
318        .and_then(|c| c.get(2))
319        .map(|m| clean(m.as_str()));
320    let named = head.lines().take(200).any(|l| contains_ci(l, "nastran"));
321    let last = end_of(head, tail);
322    let end = last
323        .lines()
324        .rev()
325        .take(TAIL_LINES)
326        .any(|l| l.contains("END OF JOB"));
327    let status = if tail_has_fatal(last) {
328        Some(Status::Fatal)
329    } else if end {
330        Some(Status::Completed)
331    } else {
332        None
333    };
334    if !named && status.is_none() {
335        return None;
336    }
337    let (fatal, warning) = count_markers(head, tail);
338    let solver = match product {
339        Some(p) => format!("{} Nastran", clean(p)),
340        None => "Nastran".into(),
341    };
342    Some(Summary {
343        solver,
344        kind: "print output".into(),
345        version,
346        status,
347        fatal: Some(fatal),
348        warning: Some(warning),
349        ..Summary::default()
350    })
351}
352
353fn nastran_deck(r: &Rules, head: &str) -> Option<Summary> {
354    let sol = first_capture(head, &r.deck_sol);
355    let version = head
356        .lines()
357        .take(100)
358        .filter(|l| l.trim_start().starts_with('$'))
359        .find_map(|l| first_capture(l, &r.deck_version));
360    if sol.is_none() && version.is_none() {
361        return None;
362    }
363    Some(Summary {
364        solver: "Nastran".into(),
365        kind: "deck".into(),
366        version,
367        notes: sol.map(|n| format!("SOL {n}")).into_iter().collect(),
368        ..Summary::default()
369    })
370}
371
372fn starccm_macro(r: &Rules, head: &str) -> Option<Summary> {
373    let (version, build) = starccm_version(r, head);
374    let name = first_capture(head, &r.macro_name);
375    if version.is_none() && name.is_none() {
376        return None;
377    }
378    let mut notes = Vec::new();
379    if let Some(n) = name {
380        notes.push(format!("macro {n}"));
381    }
382    if let Some(b) = build {
383        notes.push(format!("build {b}"));
384    }
385    Some(Summary {
386        solver: "STAR-CCM+".into(),
387        kind: "macro".into(),
388        version,
389        notes,
390        ..Summary::default()
391    })
392}
393
394fn starccm_log(r: &Rules, head: &str, tail: &str) -> Option<Summary> {
395    if !contains_ci(head, "star-ccm+") {
396        return None;
397    }
398    let (version, _) = starccm_version(r, head);
399    let (fatal, warning) = count_markers(head, tail);
400    let end = end_of(head, tail);
401    let criterion = end
402        .lines()
403        .rev()
404        .take(TAIL_LINES)
405        .find(|l| contains_ci(l, "stopping criterion") || contains_ci(l, "stop criterion"))
406        .map(|l| clean(l.trim()));
407    let satisfied = criterion
408        .as_deref()
409        .is_some_and(|l| contains_ci(l, "satisfied") || contains_ci(l, "reached"));
410    let status = if tail_has_fatal(end) {
411        Some(Status::Fatal)
412    } else if satisfied {
413        Some(Status::Converged)
414    } else {
415        None
416    };
417    Some(Summary {
418        solver: "STAR-CCM+".into(),
419        kind: "log".into(),
420        version,
421        status,
422        last_step: last_step(r, end),
423        fatal: Some(fatal),
424        warning: Some(warning),
425        notes: criterion.into_iter().collect(),
426        ..Summary::default()
427    })
428}
429
430// -- helpers --------------------------------------------------------------
431
432/// The end of a run: the tail, or the head when the head holds the whole
433/// (small) file and there is no tail.
434fn end_of<'a>(head: &'a str, tail: &'a str) -> &'a str {
435    if tail.is_empty() {
436        head
437    } else {
438        tail
439    }
440}
441
442/// Lines at the end of the tail that decide how a run ended.
443const TAIL_LINES: usize = 400;
444
445/// Lines at the end of the tail searched for the last iteration.
446const STEP_LINES: usize = 2000;
447
448fn starccm_version(r: &Rules, head: &str) -> (Option<String>, Option<String>) {
449    match r.star_version.captures(head) {
450        Some(c) => (
451            c.get(1).map(|m| clean(m.as_str())),
452            c.get(2).map(|m| clean(m.as_str())),
453        ),
454        None => (None, None),
455    }
456}
457
458/// The first capture group of the first match, cleaned.
459fn first_capture(text: &str, re: &Regex) -> Option<String> {
460    re.captures(text)
461        .and_then(|c| c.get(1))
462        .map(|m| clean(m.as_str()))
463        .filter(|s| !s.is_empty())
464}
465
466/// Printable characters only, bounded; whitespace runs become one space.
467fn clean(text: &str) -> String {
468    let mut out = String::new();
469    for word in text.split_whitespace() {
470        if !out.is_empty() {
471            out.push(' ');
472        }
473        out.extend(word.chars().filter(|c| !c.is_control()));
474        if out.chars().count() >= MAX_FIELD {
475            break;
476        }
477    }
478    out.chars().take(MAX_FIELD).collect()
479}
480
481fn contains_ci(haystack: &str, needle_lower: &str) -> bool {
482    haystack.to_lowercase().contains(needle_lower)
483}
484
485/// Lines with `fatal` and with `warning` in the two windows.
486fn count_markers(head: &str, tail: &str) -> (u32, u32) {
487    let mut fatal = 0u32;
488    let mut warning = 0u32;
489    for line in head.lines().chain(tail.lines()) {
490        let lower = line.to_lowercase();
491        if lower.contains("fatal") {
492            fatal = fatal.saturating_add(1);
493        }
494        if lower.contains("warning") {
495            warning = warning.saturating_add(1);
496        }
497    }
498    (fatal, warning)
499}
500
501fn tail_has_fatal(tail: &str) -> bool {
502    tail.lines()
503        .rev()
504        .take(TAIL_LINES)
505        .any(|l| contains_ci(l, "fatal"))
506}
507
508/// The last iteration or time step in the tail (the last one wins).
509fn last_step(r: &Rules, tail: &str) -> Option<String> {
510    for line in tail.lines().rev().take(STEP_LINES) {
511        if let Some(n) = first_capture(line, &r.time_step) {
512            return Some(format!("time step {n}"));
513        }
514        if let Some(n) = first_capture(line, &r.iteration_row)
515            .or_else(|| first_capture(line, &r.iteration_label))
516        {
517            return Some(format!("iteration {n}"));
518        }
519    }
520    None
521}
522
523#[cfg(test)]
524#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
525mod tests {
526    use super::*;
527
528    fn run(id: &str, head: &str, tail: &str) -> Option<Summary> {
529        summarize(id, head.as_bytes(), tail.as_bytes())
530    }
531
532    #[test]
533    fn patterns_compile() {
534        assert!(rules().is_some());
535    }
536
537    const TRN_HEAD: &str = "Welcome to ANSYS Fluent 2023 R1\n\nBuild Time: Oct 2022\n/file/read-case x.cas\nWarning: something\n";
538
539    #[test]
540    fn fluent_transcript_converged() {
541        let tail = "  310  1.2e-04 3.4e-05 1.0e-03   0:00:12  190\n  311  1.1e-04 3.4e-05 9.0e-04   0:00:12  189\n! 311 solution is converged\n";
542        let s = run("fluent-transcript", TRN_HEAD, tail).unwrap();
543        assert_eq!(s.solver, "Fluent");
544        assert_eq!(s.version.as_deref(), Some("2023 R1"));
545        assert_eq!(s.status, Some(Status::Converged));
546        assert_eq!(s.last_step.as_deref(), Some("iteration 311"));
547        assert_eq!(s.warning, Some(1));
548        assert_eq!(s.fatal, Some(0));
549        assert!(s.line().contains("1 WARNING"));
550        assert!(s.line().contains("first and last 64 KiB"));
551    }
552
553    #[test]
554    fn fluent_transcript_fatal_and_time_step() {
555        let tail = "Flow time = 0.01s, time step = 40\nFatal error: floating point exception\n";
556        let s = run("fluent-transcript", TRN_HEAD, tail).unwrap();
557        assert_eq!(s.status, Some(Status::Fatal));
558        assert_eq!(s.last_step.as_deref(), Some("time step 40"));
559    }
560
561    #[test]
562    fn fluent_transcript_without_markers_is_none() {
563        assert!(run("fluent-transcript", "hello\nworld\n", "").is_none());
564    }
565
566    #[test]
567    fn fluent_report_last_row() {
568        let head = "\"Convergence history of report-def-0\"\n\"Iteration\" \"report-def-0\" \"report-def-1\"\n1 0.5 0.7\n";
569        let tail = "99 1.5 1.7\n100 1.6 1.8\n";
570        let s = run("fluent-report", head, tail).unwrap();
571        assert_eq!(s.kind, "report file");
572        assert_eq!(s.last_step.as_deref(), Some("last row 100"));
573        assert!(s.notes.contains(&"3 columns".to_string()));
574        let s = run("fluent-report", head, "").unwrap();
575        assert_eq!(s.last_step.as_deref(), Some("last row 1"));
576        assert!(run("fluent-report", "1 2 3\n4 5 6\n", "").is_none());
577    }
578
579    const F06_HEAD: &str =
580        "1    Simcenter Nastran 2212  JAN 1, 2023  PAGE 1\n*** USER WARNING MESSAGE 2 (X)\n";
581
582    #[test]
583    fn nastran_f06_end_of_job() {
584        let tail = "*** USER WARNING MESSAGE 3\n * * * END OF JOB * * *\n";
585        let s = run("nastran-f06", F06_HEAD, tail).unwrap();
586        assert_eq!(s.solver, "Simcenter Nastran");
587        assert_eq!(s.version.as_deref(), Some("2212"));
588        assert_eq!(s.status, Some(Status::Completed));
589        assert_eq!(s.warning, Some(2));
590        assert_eq!(s.fatal, Some(0));
591    }
592
593    #[test]
594    fn nastran_f06_fatal_wins() {
595        let tail = "*** USER FATAL MESSAGE 9050\n * * * END OF JOB * * *\n";
596        let s = run("nastran-f06", F06_HEAD, tail).unwrap();
597        assert_eq!(s.status, Some(Status::Fatal));
598        assert_eq!(s.fatal, Some(1));
599        assert!(s.line().contains("fatal"));
600    }
601
602    #[test]
603    fn nastran_f06_running_has_no_status() {
604        let s = run("nastran-f06", F06_HEAD, "  stress table\n").unwrap();
605        assert_eq!(s.status, None);
606        assert!(run("nastran-f06", "just text\n", "more text\n").is_none());
607    }
608
609    #[test]
610    fn nastran_deck_sol() {
611        let head = "$ Simcenter Nastran 2212 translator\nSOL 101\nCEND\nBEGIN BULK\n";
612        let s = run("nastran-bulk", head, "").unwrap();
613        assert_eq!(s.notes, vec!["SOL 101".to_string()]);
614        assert_eq!(s.version.as_deref(), Some("2212"));
615        assert!(run("nastran-bulk", "GRID,1\n", "").is_none());
616    }
617
618    #[test]
619    fn starccm_macro_header() {
620        let head = "// Simcenter STAR-CCM+ macro: RunCase.java\n// Written by Simcenter STAR-CCM+ 2310 build 18.06.006\npackage macro;\n";
621        let s = run("starccm-macro", head, "").unwrap();
622        assert_eq!(s.version.as_deref(), Some("2310"));
623        assert!(s.notes.contains(&"macro RunCase.java".to_string()));
624        assert!(s.notes.contains(&"build 18.06.006".to_string()));
625        assert!(run("starccm-macro", "class A {}\n", "").is_none());
626    }
627
628    #[test]
629    fn starccm_log_stopping_criterion() {
630        let head = "Simcenter STAR-CCM+ 2310 (18.06.006)\n";
631        let tail =
632            "Iteration  Continuity\n  120  1.2e-03\nStopping criterion Maximum Steps satisfied\n";
633        let s = run("", head, tail).unwrap();
634        assert_eq!(s.solver, "STAR-CCM+");
635        assert_eq!(s.version.as_deref(), Some("2310"));
636        assert_eq!(s.status, Some(Status::Converged));
637        assert_eq!(s.last_step.as_deref(), Some("iteration 120"));
638        assert!(run("", "plain log\n", "").is_none());
639    }
640
641    #[test]
642    fn a_small_file_in_the_head_alone_has_its_end_there() {
643        let f06 = format!("{F06_HEAD} * * * END OF JOB * * *\n");
644        let s = run("nastran-f06", &f06, "").unwrap();
645        assert_eq!(s.status, Some(Status::Completed));
646        let trn = format!("{TRN_HEAD}  7  1.0e-03 2.0\nsolution is converged\n");
647        let s = run("fluent-transcript", &trn, "").unwrap();
648        assert_eq!(s.status, Some(Status::Converged));
649        assert_eq!(s.last_step.as_deref(), Some("iteration 7"));
650    }
651
652    #[test]
653    fn binary_and_unknown_are_ignored() {
654        assert!(summarize("fluent-transcript", b"Fluent 2023 R1\0\x01", b"").is_none());
655        assert!(run("step", "ISO-10303-21;", "").is_none());
656    }
657
658    #[test]
659    fn fields_are_bounded_and_clean() {
660        let long = "x".repeat(500);
661        let head = format!("// STAR-CCM+ macro: {long}\u{7}\u{1b}[31m\n");
662        let s = run("starccm-macro", &head, "").unwrap();
663        let note = s.notes.first().unwrap();
664        assert!(note.chars().count() <= MAX_FIELD + 6);
665        assert!(!note.chars().any(char::is_control));
666    }
667
668    #[test]
669    fn windows_do_not_overlap_and_start_at_a_line() {
670        let line = "abcdefghi\n";
671        let big = line.repeat(20_000);
672        let (h, t) = windows(big.as_bytes(), b"");
673        assert_eq!(h.len(), WINDOW);
674        assert!(t.len() <= WINDOW);
675        assert!(t.starts_with(b"abcdefghi\n") && t.ends_with(b"\n"));
676        let (h2, t2) = windows(b"head\n", big.as_bytes());
677        assert_eq!(h2, b"head\n");
678        assert!(t2.len() <= WINDOW && t2.ends_with(b"\n"));
679        let (h3, t3) = windows(b"a\nb\n", b"");
680        assert_eq!(h3, b"a\nb\n");
681        assert!(t3.is_empty());
682        let mid = line.repeat(10_000);
683        let (h4, t4) = windows(mid.as_bytes(), b"");
684        assert_eq!(h4.len(), WINDOW);
685        assert!(h4.len() + t4.len() <= mid.len());
686    }
687
688    #[test]
689    fn counts_cover_the_windows_only() {
690        let head = "Fluent 2023 R1\n".to_string() + &"Warning: x\n".repeat(10);
691        let s = run("fluent-transcript", &head, "Warning: y\n  5  1.0e-3 2.0\n").unwrap();
692        assert_eq!(s.warning, Some(11));
693    }
694
695    #[test]
696    fn json_round_trip() {
697        let s = run("nastran-f06", F06_HEAD, " * * * END OF JOB * * *\n").unwrap();
698        let back = Summary::from_json(&s.to_json()).unwrap();
699        assert_eq!(back, s);
700        assert!(Summary::from_json(&serde_json::json!({"x": 1})).is_none());
701        assert_eq!(s.to_json()["status"], "completed");
702    }
703
704    #[test]
705    fn applies_to_known_formats_and_plain_logs() {
706        assert!(applies(Some("fluent-transcript"), "a.trn"));
707        assert!(applies(None, "run.LOG"));
708        assert!(applies(Some("text"), "run.txt"));
709        assert!(!applies(Some("text"), "notes.md"));
710        assert!(!applies(None, "a.step"));
711        assert!(!applies(Some("step"), "a.step"));
712    }
713}