1use rand::RngExt;
10use regex::Regex;
11use std::fs::File;
12use std::io;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::str;
16use std::sync::LazyLock;
17
18use diesel::pg::PgConnection;
19use diesel::result::Error;
20
21use crate::concerns::CortexInsertable;
22use crate::models::{
23 LogError, LogFatal, LogInfo, LogInvalid, LogRecord, LogWarning, NewLogError, NewLogFatal,
24 NewLogInfo, NewLogInvalid, NewLogWarning, Task,
25};
26
27static MESSAGE_LINE_REGEX: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r"^([^ :]+):([^ :]+):([^ ]+)(\s(.*))?$").unwrap());
29pub static LOADING_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
31 Regex::new(r"^\((?:Loading|Processing definitions)\s(.+/)?([^/]+[^.])\.\.\.(\s|$)").unwrap()
32});
33static ENTRY_DOCUMENT_NAME_REGEX: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"^.+/(.+)\..+$").unwrap());
36
37pub fn entry_document_name(entry: &str) -> String {
42 let trimmed = entry.trim_end();
43 ENTRY_DOCUMENT_NAME_REGEX.replace(trimmed, "$1").to_string()
44}
45
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum TaskStatus {
51 TODO,
53 NoProblem,
55 Warning,
57 Error,
59 Fatal,
61 Invalid,
63 Blocked(i32),
65 Queued(i32),
67}
68
69#[derive(Clone, Debug)]
70pub struct TaskProgress {
72 pub task: Task,
74 pub created_at: i64,
76 pub retries: i64,
78 pub lease_timeout_seconds: i64,
83}
84impl TaskProgress {
85 pub fn expected_at(&self) -> i64 {
90 self.created_at + ((self.retries + 1) * self.lease_timeout_seconds)
91 }
92}
93
94#[derive(Clone, Debug)]
95pub struct TaskReport {
97 pub task: Task,
99 pub status: TaskStatus,
101 pub messages: Vec<NewTaskMessage>,
103}
104
105#[derive(Clone, Debug)]
106pub enum TaskMessage {
109 Info(LogInfo),
111 Warning(LogWarning),
113 Error(LogError),
115 Fatal(LogFatal),
117 Invalid(LogInvalid),
119}
120impl LogRecord for TaskMessage {
121 fn task_id(&self) -> i64 {
122 use crate::helpers::TaskMessage::*;
123 match *self {
124 Info(ref record) => record.task_id(),
125 Warning(ref record) => record.task_id(),
126 Error(ref record) => record.task_id(),
127 Fatal(ref record) => record.task_id(),
128 Invalid(ref record) => record.task_id(),
129 }
130 }
131 fn category(&self) -> &str {
132 use crate::helpers::TaskMessage::*;
133 match *self {
134 Info(ref record) => record.category(),
135 Warning(ref record) => record.category(),
136 Error(ref record) => record.category(),
137 Fatal(ref record) => record.category(),
138 Invalid(ref record) => record.category(),
139 }
140 }
141 fn what(&self) -> &str {
142 use crate::helpers::TaskMessage::*;
143 match *self {
144 Info(ref record) => record.what(),
145 Warning(ref record) => record.what(),
146 Error(ref record) => record.what(),
147 Fatal(ref record) => record.what(),
148 Invalid(ref record) => record.what(),
149 }
150 }
151 fn details(&self) -> &str {
152 use crate::helpers::TaskMessage::*;
153 match *self {
154 Info(ref record) => record.details(),
155 Warning(ref record) => record.details(),
156 Error(ref record) => record.details(),
157 Fatal(ref record) => record.details(),
158 Invalid(ref record) => record.details(),
159 }
160 }
161 fn set_details(&mut self, new_details: String) {
162 use crate::helpers::TaskMessage::*;
163 match *self {
164 Info(ref mut record) => record.set_details(new_details),
165 Warning(ref mut record) => record.set_details(new_details),
166 Error(ref mut record) => record.set_details(new_details),
167 Fatal(ref mut record) => record.set_details(new_details),
168 Invalid(ref mut record) => record.set_details(new_details),
169 }
170 }
171 fn severity(&self) -> &str {
172 use crate::helpers::TaskMessage::*;
173 match *self {
174 Info(ref record) => record.severity(),
175 Warning(ref record) => record.severity(),
176 Error(ref record) => record.severity(),
177 Fatal(ref record) => record.severity(),
178 Invalid(ref record) => record.severity(),
179 }
180 }
181}
182
183impl TaskStatus {
184 pub fn raw(&self) -> i32 {
186 match *self {
187 TaskStatus::TODO => 0,
188 TaskStatus::NoProblem => -1,
189 TaskStatus::Warning => -2,
190 TaskStatus::Error => -3,
191 TaskStatus::Fatal => -4,
192 TaskStatus::Invalid => -5,
193 TaskStatus::Blocked(x) | TaskStatus::Queued(x) => x,
194 }
195 }
196 pub fn to_key(&self) -> String {
198 match *self {
199 TaskStatus::NoProblem => "no_problem",
200 TaskStatus::Warning => "warning",
201 TaskStatus::Error => "error",
202 TaskStatus::Fatal => "fatal",
203 TaskStatus::TODO => "todo",
204 TaskStatus::Invalid => "invalid",
205 TaskStatus::Blocked(_) => "blocked",
206 TaskStatus::Queued(_) => "queued",
207 }
208 .to_string()
209 }
210 pub fn to_table(&self) -> String {
213 match *self {
214 TaskStatus::Warning => "log_warnings",
215 TaskStatus::Error => "log_errors",
216 TaskStatus::Fatal => "log_fatals",
217 TaskStatus::Invalid => "log_invalids",
218 _ => "log_infos",
219 }
220 .to_string()
221 }
222 pub fn from_raw(num: i32) -> Self {
224 match num {
225 0 => TaskStatus::TODO,
226 -1 => TaskStatus::NoProblem,
227 -2 => TaskStatus::Warning,
228 -3 => TaskStatus::Error,
229 -4 => TaskStatus::Fatal,
230 -5 => TaskStatus::Invalid,
231 num if num < -5 => TaskStatus::Blocked(num),
232 _ => TaskStatus::Queued(num),
233 }
234 }
235 pub fn from_key(key: &str) -> Option<Self> {
237 match key.to_lowercase().as_str() {
238 "no_problem" => Some(TaskStatus::NoProblem),
239 "warning" => Some(TaskStatus::Warning),
240 "error" => Some(TaskStatus::Error),
241 "todo" => Some(TaskStatus::TODO),
242 "in_progress" => Some(TaskStatus::TODO),
243 "invalid" => Some(TaskStatus::Invalid),
244 "blocked" => Some(TaskStatus::Blocked(-6)),
245 "queued" => Some(TaskStatus::Queued(1)),
246 "fatal" => Some(TaskStatus::Fatal),
247 _ => None,
248 }
249 }
250 pub fn keys() -> Vec<String> {
252 [
253 "no_problem",
254 "warning",
255 "error",
256 "fatal",
257 "invalid",
258 "todo",
259 "blocked",
260 "queued",
261 ]
262 .iter()
263 .map(|&x| x.to_string())
264 .collect::<Vec<_>>()
265 }
266}
267
268#[derive(Clone, Debug)]
269pub enum NewTaskMessage {
272 Info(NewLogInfo),
274 Warning(NewLogWarning),
276 Error(NewLogError),
278 Fatal(NewLogFatal),
280 Invalid(NewLogInvalid),
282}
283impl LogRecord for NewTaskMessage {
284 fn task_id(&self) -> i64 {
285 use crate::helpers::NewTaskMessage::*;
286 match *self {
287 Info(ref record) => record.task_id(),
288 Warning(ref record) => record.task_id(),
289 Error(ref record) => record.task_id(),
290 Fatal(ref record) => record.task_id(),
291 Invalid(ref record) => record.task_id(),
292 }
293 }
294 fn category(&self) -> &str {
295 use crate::helpers::NewTaskMessage::*;
296 match *self {
297 Info(ref record) => record.category(),
298 Warning(ref record) => record.category(),
299 Error(ref record) => record.category(),
300 Fatal(ref record) => record.category(),
301 Invalid(ref record) => record.category(),
302 }
303 }
304 fn what(&self) -> &str {
305 use crate::helpers::NewTaskMessage::*;
306 match *self {
307 Info(ref record) => record.what(),
308 Warning(ref record) => record.what(),
309 Error(ref record) => record.what(),
310 Fatal(ref record) => record.what(),
311 Invalid(ref record) => record.what(),
312 }
313 }
314 fn details(&self) -> &str {
315 use crate::helpers::NewTaskMessage::*;
316 match *self {
317 Info(ref record) => record.details(),
318 Warning(ref record) => record.details(),
319 Error(ref record) => record.details(),
320 Fatal(ref record) => record.details(),
321 Invalid(ref record) => record.details(),
322 }
323 }
324 fn set_details(&mut self, new_details: String) {
325 use crate::helpers::NewTaskMessage::*;
326 match *self {
327 Info(ref mut record) => record.set_details(new_details),
328 Warning(ref mut record) => record.set_details(new_details),
329 Error(ref mut record) => record.set_details(new_details),
330 Fatal(ref mut record) => record.set_details(new_details),
331 Invalid(ref mut record) => record.set_details(new_details),
332 }
333 }
334
335 fn severity(&self) -> &str {
336 use crate::helpers::NewTaskMessage::*;
337 match *self {
338 Info(ref record) => record.severity(),
339 Warning(ref record) => record.severity(),
340 Error(ref record) => record.severity(),
341 Fatal(ref record) => record.severity(),
342 Invalid(ref record) => record.severity(),
343 }
344 }
345}
346impl CortexInsertable for NewTaskMessage {
347 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
348 use crate::helpers::NewTaskMessage::*;
349 match *self {
350 Info(ref record) => record.create(connection),
351 Warning(ref record) => record.create(connection),
352 Error(ref record) => record.create(connection),
353 Fatal(ref record) => record.create(connection),
354 Invalid(ref record) => record.create(connection),
355 }
356 }
357}
358
359impl NewTaskMessage {
360 pub fn new(
362 task_id: i64,
363 severity: &str,
364 category: String,
365 what: String,
366 details: String,
367 ) -> NewTaskMessage {
368 match severity.to_lowercase().as_str() {
369 "warning" | "warn" => NewTaskMessage::Warning(NewLogWarning {
373 task_id,
374 category,
375 what,
376 details,
377 }),
378 "error" => NewTaskMessage::Error(NewLogError {
379 task_id,
380 category,
381 what,
382 details,
383 }),
384 "fatal" if category == "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
391 task_id,
392 category,
393 what,
394 details,
395 }),
396 "fatal" => NewTaskMessage::Fatal(NewLogFatal {
397 category,
398 task_id,
399 what,
400 details,
401 }),
402 "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
403 task_id,
404 category,
405 what,
406 details,
407 }),
408 _ => NewTaskMessage::Info(NewLogInfo {
409 task_id,
410 category,
411 what,
412 details,
413 }), }
415 }
416}
417
418pub fn parse_log(task_id: i64, log: &str) -> Vec<NewTaskMessage> {
421 let mut messages: Vec<NewTaskMessage> = Vec::new();
422 let mut in_details_mode = false;
423
424 for line in log.lines() {
425 if line.is_empty() {
427 continue;
428 }
429 if in_details_mode {
431 if line.starts_with('\t') {
433 if let Some(mut last_message) = messages.pop() {
439 let mut truncated_details = last_message.details().to_string() + "\n" + line;
440 utf_truncate(&mut truncated_details, 2000);
441 last_message.set_details(truncated_details);
442 messages.push(last_message);
443 }
444 continue; } else {
446 in_details_mode = false;
448 if in_details_mode {} }
450 }
451 if let Some(cap) = MESSAGE_LINE_REGEX.captures(line) {
453 let mut truncated_severity = cap
457 .get(1)
458 .map_or("", |m| m.as_str())
459 .to_string()
460 .to_lowercase();
461 utf_truncate(&mut truncated_severity, 50);
462 let mut truncated_category = cap.get(2).map_or("", |m| m.as_str()).to_string();
463 utf_truncate(&mut truncated_category, 100);
464 let mut truncated_what = cap.get(3).map_or("", |m| m.as_str()).to_string();
465 utf_truncate(&mut truncated_what, 200);
469 let mut truncated_details = cap.get(5).map_or("", |m| m.as_str()).to_string();
470 utf_truncate(&mut truncated_details, 2000);
471
472 if truncated_severity == "fatal" && truncated_category == "invalid" {
473 truncated_severity = "invalid".to_string();
474 truncated_category = truncated_what;
475 truncated_what = "all".to_string();
476 utf_truncate(&mut truncated_category, 100);
479 }
480
481 let message = NewTaskMessage::new(
482 task_id,
483 &truncated_severity,
484 truncated_category,
485 truncated_what,
486 truncated_details,
487 );
488 in_details_mode = true;
490 messages.push(message);
492 } else {
493 in_details_mode = false; if let Some(cap) = LOADING_LINE_REGEX.captures(line) {
495 let mut filepath = cap.get(1).map_or("", |m| m.as_str()).to_string();
497 let mut filename = cap.get(2).map_or("", |m| m.as_str()).to_string();
498 utf_truncate(&mut filename, 100);
504 filepath += &filename;
505 utf_truncate(&mut filepath, 2000);
506 messages.push(NewTaskMessage::new(
507 task_id,
508 "info",
509 "loaded_file".to_string(),
510 filename,
511 filepath,
512 ));
513 } else {
514 }
516 }
517 }
518 messages
519}
520
521fn decode_worker_log(raw: &[u8]) -> String {
528 match str::from_utf8(raw) {
529 Ok(valid) => valid.to_string(),
530 Err(_) => {
531 let mut lossy = String::from_utf8_lossy(raw).into_owned();
532 lossy.push_str(
533 "\nWarning:cortex:non_utf8_log the worker log was not valid UTF-8; decoded lossily\n",
534 );
535 lossy
536 },
537 }
538}
539
540fn read_cortex_log(result: &Path) -> Result<String, String> {
548 let file = File::open(result).map_err(|e| format!("cannot open result archive: {e}"))?;
549 let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a readable zip: {e}"))?;
550 let mut entry = archive
551 .by_name("cortex.log")
552 .map_err(|e| format!("no cortex.log entry: {e}"))?;
553 let mut raw = Vec::new();
554 entry
555 .read_to_end(&mut raw)
556 .map_err(|e| format!("reading cortex.log failed: {e}"))?;
557 Ok(decode_worker_log(&raw))
558}
559
560pub fn generate_report(task: Task, result: &Path) -> Option<TaskReport> {
570 let log_string = match read_cortex_log(result) {
571 Ok(log_string) => log_string,
572 Err(reason) => {
573 println!("-- generate_report: {reason} (result {result:?}); deferring to the reaper (D-18)");
575 return None;
576 },
577 };
578 let mut messages = Vec::new();
579 let mut status = TaskStatus::Fatal; for message in parse_log(task.id, &log_string).into_iter() {
582 let mut skip_message = false;
585 match message {
586 NewTaskMessage::Invalid(ref _log_invalid) => {
587 status = TaskStatus::Invalid;
588 },
589 NewTaskMessage::Info(ref _log_info) => {
590 let message_what = message.what();
591 if message.category() == "conversion" && !message_what.is_empty() {
592 let latexml_scheme_status = match message_what.parse::<i32>() {
594 Ok(num) => num,
595 Err(e) => {
596 println!(
597 "-- generate_report: failed to parse conversion status {message_what:?}: {e:?}"
598 );
599 3 },
601 };
602 let cortex_scheme_status = -(latexml_scheme_status + 1);
603 if status != TaskStatus::Invalid {
604 status = TaskStatus::from_raw(cortex_scheme_status);
606 }
607 skip_message = true; }
609 },
610 _ => {},
611 };
612 if !skip_message {
613 messages.push(message);
614 }
615 }
616
617 Some(TaskReport {
618 task,
619 status,
620 messages,
621 })
622}
623
624pub fn prepare_input_stream(task: &Task) -> Result<File, io::Error> {
626 let entry_path = Path::new(&task.entry);
627 File::open(entry_path)
628}
629
630pub fn result_archive_path(
645 entry: &str,
646 service_name: &str,
647 sandbox_id: Option<i32>,
648) -> Option<PathBuf> {
649 let dir = Path::new(entry.trim_end())
650 .parent()
651 .and_then(Path::to_str)?;
652 let stem = match sandbox_id {
653 Some(id) => format!("{service_name}.sandbox-{id}"),
654 None => service_name.to_string(),
655 };
656 Some(PathBuf::from(format!("{dir}/{stem}.zip")))
657}
658
659pub fn utf_truncate(input: &mut String, maxsize: usize) {
661 let mut utf_maxsize = input.len();
662 if utf_maxsize >= maxsize {
663 {
664 let mut char_iter = input.char_indices();
665 while utf_maxsize >= maxsize {
666 utf_maxsize = match char_iter.next_back() {
667 Some((index, _)) => index,
668 _ => 0,
669 };
670 }
671 } input.truncate(utf_maxsize);
673 }
674 *input = input.replace('\x00', "");
676}
677
678pub fn random_mark() -> i32 {
680 let mut rng = rand::rng();
681 let mark_rng: u16 = rng.random();
682 i32::from(mark_rng)
683}
684
685pub fn rerun_mark() -> i32 {
696 const LEASE_CEILING: i32 = u16::MAX as i32 + 1;
698 LEASE_CEILING + 1 + i32::from(rand_in_range(0, u16::MAX))
699}
700
701pub fn rand_in_range(from: u16, to: u16) -> u16 {
703 let mut rng = rand::rng();
704 let mark_rng: u16 = rng.random_range(from..=to);
705 mark_rng
706}
707
708#[cfg(test)]
709mod log_decode_tests {
710 use super::{decode_worker_log, parse_log};
713 use crate::models::LogRecord;
714
715 #[test]
716 fn valid_utf8_passes_through_unchanged() {
717 let valid = "Warning:math:undefined hello\nStatus:conversion:1\n";
718 assert_eq!(decode_worker_log(valid.as_bytes()), valid);
719 assert!(
720 !decode_worker_log(valid.as_bytes()).contains("non_utf8_log"),
721 "no spurious warning is added to a clean log"
722 );
723 }
724
725 #[test]
726 fn non_utf8_decodes_lossily_not_fatal() {
727 let raw = b"Warning:math:undefined bad \xFF byte\nStatus:conversion:1\n";
729 let decoded = decode_worker_log(raw);
730 assert!(
733 decoded.contains("Status:conversion:1"),
734 "the real conversion status survives lossy decoding"
735 );
736 assert!(decoded.contains("Warning:math:undefined"));
737 assert!(
738 decoded.contains('\u{FFFD}'),
739 "the invalid byte became the Unicode replacement char"
740 );
741 assert!(decoded.contains("Warning:cortex:non_utf8_log"));
743 assert!(
745 parse_log(1, &decoded).len() >= 2,
746 "real messages are preserved, not collapsed into one fatal"
747 );
748 }
749
750 #[test]
751 fn warn_abbreviation_is_recognized_as_warning() {
752 use super::NewTaskMessage;
757 let abbrev = parse_log(42, "Warn:missing_file:rotfloat.sty stubbed\n");
758 assert_eq!(abbrev.len(), 1);
759 assert!(
760 matches!(abbrev[0], NewTaskMessage::Warning(_)),
761 "abbreviated `Warn:` is filed as a Warning, not defaulted to Info"
762 );
763 let canonical = parse_log(42, "Warning:missing_file:x y\n");
764 assert!(matches!(canonical[0], NewTaskMessage::Warning(_)));
765 }
766
767 #[test]
768 fn runtime_line_parses_into_the_fields_mark_done_keys_on() {
769 use super::NewTaskMessage;
770 let new = parse_log(7, "Info:runtime_ms:12345\n");
773 assert_eq!(new.len(), 1);
774 let m = &new[0];
775 assert!(matches!(m, NewTaskMessage::Info(_)));
776 assert_eq!(m.category(), "runtime_ms");
777 assert_eq!(m.what(), "12345");
778 assert_eq!(m.details(), "");
779 let old = parse_log(7, "Info:cortex:runtime_ms 12345\n");
782 assert_eq!(old.len(), 1);
783 let m = &old[0];
784 assert_eq!(m.category(), "cortex");
785 assert_eq!(m.what(), "runtime_ms");
786 assert_eq!(m.details(), "12345");
787 }
788
789 #[test]
790 fn fatal_invalid_category_is_separated_into_invalid() {
791 use super::NewTaskMessage;
796 let m = parse_log(
797 7,
798 "Fatal:invalid:no_tex_source no .tex files found in archive\n",
799 );
800 assert_eq!(m.len(), 1);
801 assert!(
802 matches!(m[0], NewTaskMessage::Invalid(_)),
803 "a Fatal message in the `invalid` category is filed as Invalid, not Fatal"
804 );
805 let f = parse_log(7, "Fatal:conversion:caught boom\n");
807 assert!(matches!(f[0], NewTaskMessage::Fatal(_)));
808 let i = parse_log(7, "Invalid:size:too_big result exceeds cap\n");
810 assert!(matches!(i[0], NewTaskMessage::Invalid(_)));
811 }
812
813 #[test]
814 fn read_cortex_log_extracts_from_zip_and_errors_gracefully() {
815 use super::read_cortex_log;
816 use std::fs::File;
817 use std::io::Write;
818 let zip_path = std::env::temp_dir().join("cortex_read_log_unit_test.zip");
819 {
820 let mut zw = zip::ZipWriter::new(File::create(&zip_path).unwrap());
821 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
822 zw.start_file("html/index.html", opts).unwrap();
824 zw.write_all(&vec![b'x'; 200_000]).unwrap();
825 zw.start_file("cortex.log", opts).unwrap();
826 zw.write_all(b"Info:cortex:hello a worker log line\n")
827 .unwrap();
828 zw.finish().unwrap();
829 }
830 let log = read_cortex_log(&zip_path).expect("reads cortex.log out of the zip");
831 assert!(
832 log.contains("hello a worker log line"),
833 "by_name extracted the cortex.log content, got: {log:?}"
834 );
835 assert!(read_cortex_log(std::path::Path::new("/nonexistent/x.zip")).is_err());
837 std::fs::remove_file(&zip_path).ok();
838 }
839
840 #[test]
841 fn generate_report_defers_unreadable_archives_to_the_reaper() {
842 use super::{Task, TaskStatus, generate_report};
843 use std::fs::File;
844 use std::io::Write;
845 let task = || Task {
846 id: 42,
847 service_id: 3,
848 corpus_id: 2,
849 status: 0,
850 entry: "/d/x.zip".to_string(),
851 };
852 let empty = std::env::temp_dir().join("cortex_d18_empty_result.zip");
855 File::create(&empty).unwrap(); assert!(
857 generate_report(task(), &empty).is_none(),
858 "a 0-byte result archive defers to the reaper (None), never a fabricated Fatal"
859 );
860 assert!(
861 generate_report(task(), std::path::Path::new("/nonexistent/x.zip")).is_none(),
862 "a missing result archive defers to the reaper (None)"
863 );
864 let good = std::env::temp_dir().join("cortex_d18_good_result.zip");
867 {
868 let mut zw = zip::ZipWriter::new(File::create(&good).unwrap());
869 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
870 zw.start_file("cortex.log", opts).unwrap();
871 zw.write_all(b"Info:conversion:0 clean run\n").unwrap();
872 zw.finish().unwrap();
873 }
874 let report =
875 generate_report(task(), &good).expect("a readable cortex.log yields a verdict (Some)");
876 assert_eq!(
877 report.status,
878 TaskStatus::NoProblem,
879 "conversion:0 status line -> NoProblem"
880 );
881 std::fs::remove_file(&empty).ok();
882 std::fs::remove_file(&good).ok();
883 }
884
885 #[test]
886 fn tab_indented_details_fold_into_the_preceding_message() {
887 let log = "Error:math:bad first detail\n\tsecond line\n\tthird line\n";
890 let messages = parse_log(7, log);
891 assert_eq!(messages.len(), 1, "one message, the details folded into it");
892 let details = messages[0].details();
893 assert!(details.contains("first detail"), "got: {details:?}");
894 assert!(details.contains("second line"), "got: {details:?}");
895 assert!(details.contains("third line"), "got: {details:?}");
896 }
897
898 #[test]
899 fn orphan_details_line_does_not_panic() {
900 let messages = parse_log(
904 7,
905 "\torphan detail with no header\nInfo:cortex:hi a real line\n",
906 );
907 assert_eq!(
908 messages.len(),
909 1,
910 "the orphan line is ignored; the real message survives"
911 );
912 }
913
914 #[test]
915 fn loaded_file_details_keep_the_full_source_path() {
916 use super::NewTaskMessage;
917 let messages = parse_log(
921 7,
922 "(Loading /usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty...\n",
923 );
924 assert_eq!(messages.len(), 1, "the Loading line parses to one message");
925 let m = &messages[0];
926 assert!(matches!(m, NewTaskMessage::Info(_)));
927 assert_eq!(m.category(), "loaded_file");
928 assert_eq!(m.what(), "tcolorbox.sty", "the filename stays groupable");
929 assert_eq!(
930 m.details(),
931 "/usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty",
932 "details keeps the whole path, filename included"
933 );
934 }
935
936 #[test]
937 fn result_archive_path_scopes_sandbox_outputs() {
938 use super::result_archive_path;
939 let entry = "/data/arxiv/1234/5678/source/5678.zip";
940 assert_eq!(
942 result_archive_path(entry, "tex_to_html", None).unwrap(),
943 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
944 );
945 assert_eq!(
948 result_archive_path(entry, "tex_to_html", Some(42)).unwrap(),
949 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.sandbox-42.zip")
950 );
951 assert_eq!(
953 result_archive_path(&format!("{entry}\n"), "tex_to_html", None).unwrap(),
954 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
955 );
956 assert!(result_archive_path("", "tex_to_html", None).is_none());
959 assert!(result_archive_path("/", "tex_to_html", None).is_none());
960 }
961}
962
963#[cfg(test)]
964mod rerun_mark_tests {
965 use super::rerun_mark;
966
967 #[test]
968 fn rerun_mark_stays_above_the_lease_space() {
969 for _ in 0..10_000 {
973 let mark = rerun_mark();
974 assert!(
975 mark > i32::from(u16::MAX) + 1,
976 "rerun mark {mark} overlaps the lease space [1, 65536]"
977 );
978 assert!(mark > 0, "temp marks must be positive");
979 }
980 }
981}
982
983#[cfg(test)]
984mod entry_name_tests {
985 use super::entry_document_name;
986
987 #[test]
988 fn extracts_short_document_name() {
989 assert_eq!(
991 entry_document_name("/data/arxmliv/0811/0811.0417/0811.0417.zip"),
992 "0811.0417"
993 );
994 assert_eq!(
996 entry_document_name("/data/foo/2105.13573.tar"),
997 "2105.13573"
998 );
999 assert_eq!(entry_document_name("/d/bar.tex "), "bar");
1001 assert_eq!(entry_document_name("noslash"), "noslash");
1003 }
1004}