1#![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
32pub const WINDOW: usize = 64 * 1024;
34
35const MAX_FIELD: usize = 48;
37
38const FORMATS: [&str; 6] = [
40 "fluent-transcript",
41 "fluent-report",
42 "nastran-f06",
43 "nastran-bulk",
44 "starccm-macro",
45 "starccm-log",
46];
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Status {
52 Converged,
54 Completed,
56 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Summary {
74 pub v: u8,
76 pub solver: String,
78 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 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub last_step: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub fatal: Option<u32>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub warning: Option<u32>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub notes: Vec<String>,
96}
97
98impl Summary {
99 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 pub fn to_json(&self) -> serde_json::Value {
125 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
126 }
127
128 pub fn from_json(value: &serde_json::Value) -> Option<Summary> {
130 serde_json::from_value(value.clone()).ok()
131 }
132}
133
134pub 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
146pub 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 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
171pub 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
198struct 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
430fn 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
442const TAIL_LINES: usize = 400;
444
445const 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
458fn 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
466fn 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
485fn 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
508fn 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}