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,
80 pub retries: i64,
82 pub lease_timeout_seconds: i64,
87 pub attempt_workers: Vec<String>,
92 pub last_cause: Vec<NewTaskMessage>,
97 pub run_start: usize,
102}
103impl TaskProgress {
104 pub fn expected_at(&self) -> i64 {
109 self.created_at + ((self.retries + 1) * self.lease_timeout_seconds)
110 }
111}
112
113pub const NEVER_COMPLETED_WHAT: &str = "never_completed_with_retries";
116
117#[derive(Clone, Debug)]
118pub struct TaskReport {
120 pub task: Task,
122 pub status: TaskStatus,
124 pub messages: Vec<NewTaskMessage>,
126 pub stand_in: bool,
130}
131
132pub const RETRYABLE_TAG: (&str, &str) = ("cortex", "retryable");
136
137impl TaskReport {
138 pub fn is_retryable(&self) -> bool {
145 if self.status != TaskStatus::Fatal {
146 return false;
147 }
148 let Some(last_fatal) = self
149 .messages
150 .iter()
151 .rposition(|message| matches!(message, NewTaskMessage::Fatal(_)))
152 else {
153 return false;
154 };
155 let (Some(NewTaskMessage::Info(tag)), NewTaskMessage::Fatal(fatal)) = (
156 last_fatal.checked_sub(1).and_then(|i| self.messages.get(i)),
157 &self.messages[last_fatal],
158 ) else {
159 return false;
160 };
161 (tag.category.as_str(), tag.what.as_str()) == RETRYABLE_TAG
162 && tag.details.trim() == format!("{}:{}", fatal.category, fatal.what)
163 }
164
165 pub fn into_cause(self) -> Vec<NewTaskMessage> {
168 self
169 .messages
170 .into_iter()
171 .filter(|message| {
172 !matches!(message, NewTaskMessage::Info(info)
173 if (info.category.as_str(), info.what.as_str()) == RETRYABLE_TAG)
174 })
175 .collect()
176 }
177}
178
179#[derive(Clone, Debug)]
180pub enum TaskMessage {
183 Info(LogInfo),
185 Warning(LogWarning),
187 Error(LogError),
189 Fatal(LogFatal),
191 Invalid(LogInvalid),
193}
194impl LogRecord for TaskMessage {
195 fn task_id(&self) -> i64 {
196 use crate::helpers::TaskMessage::*;
197 match *self {
198 Info(ref record) => record.task_id(),
199 Warning(ref record) => record.task_id(),
200 Error(ref record) => record.task_id(),
201 Fatal(ref record) => record.task_id(),
202 Invalid(ref record) => record.task_id(),
203 }
204 }
205 fn category(&self) -> &str {
206 use crate::helpers::TaskMessage::*;
207 match *self {
208 Info(ref record) => record.category(),
209 Warning(ref record) => record.category(),
210 Error(ref record) => record.category(),
211 Fatal(ref record) => record.category(),
212 Invalid(ref record) => record.category(),
213 }
214 }
215 fn what(&self) -> &str {
216 use crate::helpers::TaskMessage::*;
217 match *self {
218 Info(ref record) => record.what(),
219 Warning(ref record) => record.what(),
220 Error(ref record) => record.what(),
221 Fatal(ref record) => record.what(),
222 Invalid(ref record) => record.what(),
223 }
224 }
225 fn details(&self) -> &str {
226 use crate::helpers::TaskMessage::*;
227 match *self {
228 Info(ref record) => record.details(),
229 Warning(ref record) => record.details(),
230 Error(ref record) => record.details(),
231 Fatal(ref record) => record.details(),
232 Invalid(ref record) => record.details(),
233 }
234 }
235 fn set_details(&mut self, new_details: String) {
236 use crate::helpers::TaskMessage::*;
237 match *self {
238 Info(ref mut record) => record.set_details(new_details),
239 Warning(ref mut record) => record.set_details(new_details),
240 Error(ref mut record) => record.set_details(new_details),
241 Fatal(ref mut record) => record.set_details(new_details),
242 Invalid(ref mut record) => record.set_details(new_details),
243 }
244 }
245 fn severity(&self) -> &str {
246 use crate::helpers::TaskMessage::*;
247 match *self {
248 Info(ref record) => record.severity(),
249 Warning(ref record) => record.severity(),
250 Error(ref record) => record.severity(),
251 Fatal(ref record) => record.severity(),
252 Invalid(ref record) => record.severity(),
253 }
254 }
255}
256
257impl TaskStatus {
258 pub fn raw(&self) -> i32 {
260 match *self {
261 TaskStatus::TODO => 0,
262 TaskStatus::NoProblem => -1,
263 TaskStatus::Warning => -2,
264 TaskStatus::Error => -3,
265 TaskStatus::Fatal => -4,
266 TaskStatus::Invalid => -5,
267 TaskStatus::Blocked(x) | TaskStatus::Queued(x) => x,
268 }
269 }
270 pub fn to_key(&self) -> String {
272 match *self {
273 TaskStatus::NoProblem => "no_problem",
274 TaskStatus::Warning => "warning",
275 TaskStatus::Error => "error",
276 TaskStatus::Fatal => "fatal",
277 TaskStatus::TODO => "todo",
278 TaskStatus::Invalid => "invalid",
279 TaskStatus::Blocked(_) => "blocked",
280 TaskStatus::Queued(_) => "queued",
281 }
282 .to_string()
283 }
284 pub fn to_table(&self) -> String {
287 match *self {
288 TaskStatus::Warning => "log_warnings",
289 TaskStatus::Error => "log_errors",
290 TaskStatus::Fatal => "log_fatals",
291 TaskStatus::Invalid => "log_invalids",
292 _ => "log_infos",
293 }
294 .to_string()
295 }
296 pub fn from_raw(num: i32) -> Self {
298 match num {
299 0 => TaskStatus::TODO,
300 -1 => TaskStatus::NoProblem,
301 -2 => TaskStatus::Warning,
302 -3 => TaskStatus::Error,
303 -4 => TaskStatus::Fatal,
304 -5 => TaskStatus::Invalid,
305 num if num < -5 => TaskStatus::Blocked(num),
306 _ => TaskStatus::Queued(num),
307 }
308 }
309 pub fn from_key(key: &str) -> Option<Self> {
311 match key.to_lowercase().as_str() {
312 "no_problem" => Some(TaskStatus::NoProblem),
313 "warning" => Some(TaskStatus::Warning),
314 "error" => Some(TaskStatus::Error),
315 "todo" => Some(TaskStatus::TODO),
316 "in_progress" => Some(TaskStatus::TODO),
317 "invalid" => Some(TaskStatus::Invalid),
318 "blocked" => Some(TaskStatus::Blocked(-6)),
319 "queued" => Some(TaskStatus::Queued(1)),
320 "fatal" => Some(TaskStatus::Fatal),
321 _ => None,
322 }
323 }
324 pub fn keys() -> Vec<String> {
326 [
327 "no_problem",
328 "warning",
329 "error",
330 "fatal",
331 "invalid",
332 "todo",
333 "blocked",
334 "queued",
335 ]
336 .iter()
337 .map(|&x| x.to_string())
338 .collect::<Vec<_>>()
339 }
340}
341
342#[derive(Clone, Debug)]
343pub enum NewTaskMessage {
346 Info(NewLogInfo),
348 Warning(NewLogWarning),
350 Error(NewLogError),
352 Fatal(NewLogFatal),
354 Invalid(NewLogInvalid),
356}
357impl LogRecord for NewTaskMessage {
358 fn task_id(&self) -> i64 {
359 use crate::helpers::NewTaskMessage::*;
360 match *self {
361 Info(ref record) => record.task_id(),
362 Warning(ref record) => record.task_id(),
363 Error(ref record) => record.task_id(),
364 Fatal(ref record) => record.task_id(),
365 Invalid(ref record) => record.task_id(),
366 }
367 }
368 fn category(&self) -> &str {
369 use crate::helpers::NewTaskMessage::*;
370 match *self {
371 Info(ref record) => record.category(),
372 Warning(ref record) => record.category(),
373 Error(ref record) => record.category(),
374 Fatal(ref record) => record.category(),
375 Invalid(ref record) => record.category(),
376 }
377 }
378 fn what(&self) -> &str {
379 use crate::helpers::NewTaskMessage::*;
380 match *self {
381 Info(ref record) => record.what(),
382 Warning(ref record) => record.what(),
383 Error(ref record) => record.what(),
384 Fatal(ref record) => record.what(),
385 Invalid(ref record) => record.what(),
386 }
387 }
388 fn details(&self) -> &str {
389 use crate::helpers::NewTaskMessage::*;
390 match *self {
391 Info(ref record) => record.details(),
392 Warning(ref record) => record.details(),
393 Error(ref record) => record.details(),
394 Fatal(ref record) => record.details(),
395 Invalid(ref record) => record.details(),
396 }
397 }
398 fn set_details(&mut self, new_details: String) {
399 use crate::helpers::NewTaskMessage::*;
400 match *self {
401 Info(ref mut record) => record.set_details(new_details),
402 Warning(ref mut record) => record.set_details(new_details),
403 Error(ref mut record) => record.set_details(new_details),
404 Fatal(ref mut record) => record.set_details(new_details),
405 Invalid(ref mut record) => record.set_details(new_details),
406 }
407 }
408
409 fn severity(&self) -> &str {
410 use crate::helpers::NewTaskMessage::*;
411 match *self {
412 Info(ref record) => record.severity(),
413 Warning(ref record) => record.severity(),
414 Error(ref record) => record.severity(),
415 Fatal(ref record) => record.severity(),
416 Invalid(ref record) => record.severity(),
417 }
418 }
419}
420impl CortexInsertable for NewTaskMessage {
421 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
422 use crate::helpers::NewTaskMessage::*;
423 match *self {
424 Info(ref record) => record.create(connection),
425 Warning(ref record) => record.create(connection),
426 Error(ref record) => record.create(connection),
427 Fatal(ref record) => record.create(connection),
428 Invalid(ref record) => record.create(connection),
429 }
430 }
431}
432
433impl NewTaskMessage {
434 pub fn new(
436 task_id: i64,
437 severity: &str,
438 category: String,
439 what: String,
440 details: String,
441 ) -> NewTaskMessage {
442 match severity.to_lowercase().as_str() {
443 "warning" | "warn" => NewTaskMessage::Warning(NewLogWarning {
447 task_id,
448 category,
449 what,
450 details,
451 }),
452 "error" => NewTaskMessage::Error(NewLogError {
453 task_id,
454 category,
455 what,
456 details,
457 }),
458 "fatal" if category == "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
465 task_id,
466 category,
467 what,
468 details,
469 }),
470 "fatal" => NewTaskMessage::Fatal(NewLogFatal {
471 category,
472 task_id,
473 what,
474 details,
475 }),
476 "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
477 task_id,
478 category,
479 what,
480 details,
481 }),
482 _ => NewTaskMessage::Info(NewLogInfo {
483 task_id,
484 category,
485 what,
486 details,
487 }), }
489 }
490}
491
492pub fn parse_log(task_id: i64, log: &str) -> Vec<NewTaskMessage> {
495 let mut messages: Vec<NewTaskMessage> = Vec::new();
496 let mut in_details_mode = false;
497
498 for line in log.lines() {
499 if line.is_empty() {
501 continue;
502 }
503 if in_details_mode {
505 if line.starts_with('\t') {
507 if let Some(mut last_message) = messages.pop() {
513 let mut truncated_details = last_message.details().to_string() + "\n" + line;
514 utf_truncate(&mut truncated_details, 2000);
515 last_message.set_details(truncated_details);
516 messages.push(last_message);
517 }
518 continue; } else {
520 in_details_mode = false;
522 if in_details_mode {} }
524 }
525 if let Some(cap) = MESSAGE_LINE_REGEX.captures(line) {
527 let mut truncated_severity = cap
531 .get(1)
532 .map_or("", |m| m.as_str())
533 .to_string()
534 .to_lowercase();
535 utf_truncate(&mut truncated_severity, 50);
536 let mut truncated_category = cap.get(2).map_or("", |m| m.as_str()).to_string();
537 utf_truncate(&mut truncated_category, 100);
538 let mut truncated_what = cap.get(3).map_or("", |m| m.as_str()).to_string();
539 utf_truncate(&mut truncated_what, 200);
543 let mut truncated_details = cap.get(5).map_or("", |m| m.as_str()).to_string();
544 utf_truncate(&mut truncated_details, 2000);
545
546 if truncated_severity == "fatal" && truncated_category == "invalid" {
547 truncated_severity = "invalid".to_string();
548 truncated_category = truncated_what;
549 truncated_what = "all".to_string();
550 utf_truncate(&mut truncated_category, 100);
553 }
554
555 let message = NewTaskMessage::new(
556 task_id,
557 &truncated_severity,
558 truncated_category,
559 truncated_what,
560 truncated_details,
561 );
562 in_details_mode = true;
564 messages.push(message);
566 } else {
567 in_details_mode = false; if let Some(cap) = LOADING_LINE_REGEX.captures(line) {
569 let mut filepath = cap.get(1).map_or("", |m| m.as_str()).to_string();
571 let mut filename = cap.get(2).map_or("", |m| m.as_str()).to_string();
572 utf_truncate(&mut filename, 100);
578 filepath += &filename;
579 utf_truncate(&mut filepath, 2000);
580 messages.push(NewTaskMessage::new(
581 task_id,
582 "info",
583 "loaded_file".to_string(),
584 filename,
585 filepath,
586 ));
587 } else {
588 }
590 }
591 }
592 messages
593}
594
595fn decode_worker_log(raw: &[u8]) -> String {
602 match str::from_utf8(raw) {
603 Ok(valid) => valid.to_string(),
604 Err(_) => {
605 let mut lossy = String::from_utf8_lossy(raw).into_owned();
606 lossy.push_str(
607 "\nWarning:cortex:non_utf8_log the worker log was not valid UTF-8; decoded lossily\n",
608 );
609 lossy
610 },
611 }
612}
613
614fn read_cortex_log(result: &Path) -> Result<String, String> {
622 let file = File::open(result).map_err(|e| format!("cannot open result archive: {e}"))?;
623 let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a readable zip: {e}"))?;
624 let mut entry = archive
625 .by_name("cortex.log")
626 .map_err(|e| format!("no cortex.log entry: {e}"))?;
627 let mut raw = Vec::new();
628 entry
629 .read_to_end(&mut raw)
630 .map_err(|e| format!("reading cortex.log failed: {e}"))?;
631 Ok(decode_worker_log(&raw))
632}
633
634pub fn generate_report(task: Task, result: &Path) -> Option<TaskReport> {
644 let log_string = match read_cortex_log(result) {
645 Ok(log_string) => log_string,
646 Err(reason) => {
647 println!("-- generate_report: {reason} (result {result:?}); deferring to the reaper (D-18)");
649 return None;
650 },
651 };
652 let mut messages = Vec::new();
653 let mut status = TaskStatus::Fatal; for message in parse_log(task.id, &log_string).into_iter() {
656 let mut skip_message = false;
659 match message {
660 NewTaskMessage::Invalid(ref _log_invalid) => {
661 status = TaskStatus::Invalid;
662 },
663 NewTaskMessage::Info(ref _log_info) => {
664 let message_what = message.what();
665 if message.category() == "conversion" && !message_what.is_empty() {
666 let latexml_scheme_status = match message_what.parse::<i32>() {
668 Ok(num) => num,
669 Err(e) => {
670 println!(
671 "-- generate_report: failed to parse conversion status {message_what:?}: {e:?}"
672 );
673 3 },
675 };
676 let cortex_scheme_status = -(latexml_scheme_status + 1);
677 if status != TaskStatus::Invalid {
678 status = TaskStatus::from_raw(cortex_scheme_status);
680 }
681 skip_message = true; }
683 },
684 _ => {},
685 };
686 if !skip_message {
687 messages.push(message);
688 }
689 }
690
691 Some(TaskReport {
692 stand_in: false,
693 task,
694 status,
695 messages,
696 })
697}
698
699pub fn prepare_input_stream(task: &Task) -> Result<File, io::Error> {
701 let entry_path = Path::new(&task.entry);
702 File::open(entry_path)
703}
704
705pub fn result_archive_path(
720 entry: &str,
721 service_name: &str,
722 sandbox_id: Option<i32>,
723) -> Option<PathBuf> {
724 let dir = Path::new(entry.trim_end())
725 .parent()
726 .and_then(Path::to_str)?;
727 let stem = match sandbox_id {
728 Some(id) => format!("{service_name}.sandbox-{id}"),
729 None => service_name.to_string(),
730 };
731 Some(PathBuf::from(format!("{dir}/{stem}.zip")))
732}
733
734pub fn utf_truncate(input: &mut String, maxsize: usize) {
736 let mut utf_maxsize = input.len();
737 if utf_maxsize >= maxsize {
738 {
739 let mut char_iter = input.char_indices();
740 while utf_maxsize >= maxsize {
741 utf_maxsize = match char_iter.next_back() {
742 Some((index, _)) => index,
743 _ => 0,
744 };
745 }
746 } input.truncate(utf_maxsize);
748 }
749 *input = input.replace('\x00', "");
751}
752
753pub fn random_mark() -> i32 {
755 let mut rng = rand::rng();
756 let mark_rng: u16 = rng.random();
757 i32::from(mark_rng)
758}
759
760pub fn rerun_mark() -> i32 {
771 const LEASE_CEILING: i32 = u16::MAX as i32 + 1;
773 LEASE_CEILING + 1 + i32::from(rand_in_range(0, u16::MAX))
774}
775
776pub fn rand_in_range(from: u16, to: u16) -> u16 {
778 let mut rng = rand::rng();
779 let mark_rng: u16 = rng.random_range(from..=to);
780 mark_rng
781}
782
783#[cfg(test)]
784mod log_decode_tests {
785 use super::{decode_worker_log, parse_log};
788 use crate::models::LogRecord;
789
790 #[test]
791 fn valid_utf8_passes_through_unchanged() {
792 let valid = "Warning:math:undefined hello\nStatus:conversion:1\n";
793 assert_eq!(decode_worker_log(valid.as_bytes()), valid);
794 assert!(
795 !decode_worker_log(valid.as_bytes()).contains("non_utf8_log"),
796 "no spurious warning is added to a clean log"
797 );
798 }
799
800 #[test]
801 fn non_utf8_decodes_lossily_not_fatal() {
802 let raw = b"Warning:math:undefined bad \xFF byte\nStatus:conversion:1\n";
804 let decoded = decode_worker_log(raw);
805 assert!(
808 decoded.contains("Status:conversion:1"),
809 "the real conversion status survives lossy decoding"
810 );
811 assert!(decoded.contains("Warning:math:undefined"));
812 assert!(
813 decoded.contains('\u{FFFD}'),
814 "the invalid byte became the Unicode replacement char"
815 );
816 assert!(decoded.contains("Warning:cortex:non_utf8_log"));
818 assert!(
820 parse_log(1, &decoded).len() >= 2,
821 "real messages are preserved, not collapsed into one fatal"
822 );
823 }
824
825 #[test]
826 fn warn_abbreviation_is_recognized_as_warning() {
827 use super::NewTaskMessage;
832 let abbrev = parse_log(42, "Warn:missing_file:rotfloat.sty stubbed\n");
833 assert_eq!(abbrev.len(), 1);
834 assert!(
835 matches!(abbrev[0], NewTaskMessage::Warning(_)),
836 "abbreviated `Warn:` is filed as a Warning, not defaulted to Info"
837 );
838 let canonical = parse_log(42, "Warning:missing_file:x y\n");
839 assert!(matches!(canonical[0], NewTaskMessage::Warning(_)));
840 }
841
842 #[test]
843 fn runtime_line_parses_into_the_fields_mark_done_keys_on() {
844 use super::NewTaskMessage;
845 let new = parse_log(7, "Info:runtime_ms:12345\n");
848 assert_eq!(new.len(), 1);
849 let m = &new[0];
850 assert!(matches!(m, NewTaskMessage::Info(_)));
851 assert_eq!(m.category(), "runtime_ms");
852 assert_eq!(m.what(), "12345");
853 assert_eq!(m.details(), "");
854 let old = parse_log(7, "Info:cortex:runtime_ms 12345\n");
857 assert_eq!(old.len(), 1);
858 let m = &old[0];
859 assert_eq!(m.category(), "cortex");
860 assert_eq!(m.what(), "runtime_ms");
861 assert_eq!(m.details(), "12345");
862 }
863
864 #[test]
865 fn fatal_invalid_category_is_separated_into_invalid() {
866 use super::NewTaskMessage;
871 let m = parse_log(
872 7,
873 "Fatal:invalid:no_tex_source no .tex files found in archive\n",
874 );
875 assert_eq!(m.len(), 1);
876 assert!(
877 matches!(m[0], NewTaskMessage::Invalid(_)),
878 "a Fatal message in the `invalid` category is filed as Invalid, not Fatal"
879 );
880 let f = parse_log(7, "Fatal:conversion:caught boom\n");
882 assert!(matches!(f[0], NewTaskMessage::Fatal(_)));
883 let i = parse_log(7, "Invalid:size:too_big result exceeds cap\n");
885 assert!(matches!(i[0], NewTaskMessage::Invalid(_)));
886 }
887
888 #[test]
889 fn read_cortex_log_extracts_from_zip_and_errors_gracefully() {
890 use super::read_cortex_log;
891 use std::fs::File;
892 use std::io::Write;
893 let zip_path = std::env::temp_dir().join("cortex_read_log_unit_test.zip");
894 {
895 let mut zw = zip::ZipWriter::new(File::create(&zip_path).unwrap());
896 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
897 zw.start_file("html/index.html", opts).unwrap();
899 zw.write_all(&vec![b'x'; 200_000]).unwrap();
900 zw.start_file("cortex.log", opts).unwrap();
901 zw.write_all(b"Info:cortex:hello a worker log line\n")
902 .unwrap();
903 zw.finish().unwrap();
904 }
905 let log = read_cortex_log(&zip_path).expect("reads cortex.log out of the zip");
906 assert!(
907 log.contains("hello a worker log line"),
908 "by_name extracted the cortex.log content, got: {log:?}"
909 );
910 assert!(read_cortex_log(std::path::Path::new("/nonexistent/x.zip")).is_err());
912 std::fs::remove_file(&zip_path).ok();
913 }
914
915 #[test]
916 fn generate_report_defers_unreadable_archives_to_the_reaper() {
917 use super::{Task, TaskStatus, generate_report};
918 use std::fs::File;
919 use std::io::Write;
920 let task = || Task {
921 id: 42,
922 service_id: 3,
923 corpus_id: 2,
924 status: 0,
925 entry: "/d/x.zip".to_string(),
926 };
927 let empty = std::env::temp_dir().join("cortex_d18_empty_result.zip");
930 File::create(&empty).unwrap(); assert!(
932 generate_report(task(), &empty).is_none(),
933 "a 0-byte result archive defers to the reaper (None), never a fabricated Fatal"
934 );
935 assert!(
936 generate_report(task(), std::path::Path::new("/nonexistent/x.zip")).is_none(),
937 "a missing result archive defers to the reaper (None)"
938 );
939 let good = std::env::temp_dir().join("cortex_d18_good_result.zip");
942 {
943 let mut zw = zip::ZipWriter::new(File::create(&good).unwrap());
944 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
945 zw.start_file("cortex.log", opts).unwrap();
946 zw.write_all(b"Info:conversion:0 clean run\n").unwrap();
947 zw.finish().unwrap();
948 }
949 let report =
950 generate_report(task(), &good).expect("a readable cortex.log yields a verdict (Some)");
951 assert_eq!(
952 report.status,
953 TaskStatus::NoProblem,
954 "conversion:0 status line -> NoProblem"
955 );
956 std::fs::remove_file(&empty).ok();
957 std::fs::remove_file(&good).ok();
958 }
959
960 #[test]
961 fn tab_indented_details_fold_into_the_preceding_message() {
962 let log = "Error:math:bad first detail\n\tsecond line\n\tthird line\n";
965 let messages = parse_log(7, log);
966 assert_eq!(messages.len(), 1, "one message, the details folded into it");
967 let details = messages[0].details();
968 assert!(details.contains("first detail"), "got: {details:?}");
969 assert!(details.contains("second line"), "got: {details:?}");
970 assert!(details.contains("third line"), "got: {details:?}");
971 }
972
973 #[test]
974 fn orphan_details_line_does_not_panic() {
975 let messages = parse_log(
979 7,
980 "\torphan detail with no header\nInfo:cortex:hi a real line\n",
981 );
982 assert_eq!(
983 messages.len(),
984 1,
985 "the orphan line is ignored; the real message survives"
986 );
987 }
988
989 #[test]
990 fn loaded_file_details_keep_the_full_source_path() {
991 use super::NewTaskMessage;
992 let messages = parse_log(
996 7,
997 "(Loading /usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty...\n",
998 );
999 assert_eq!(messages.len(), 1, "the Loading line parses to one message");
1000 let m = &messages[0];
1001 assert!(matches!(m, NewTaskMessage::Info(_)));
1002 assert_eq!(m.category(), "loaded_file");
1003 assert_eq!(m.what(), "tcolorbox.sty", "the filename stays groupable");
1004 assert_eq!(
1005 m.details(),
1006 "/usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty",
1007 "details keeps the whole path, filename included"
1008 );
1009 }
1010
1011 #[test]
1012 fn result_archive_path_scopes_sandbox_outputs() {
1013 use super::result_archive_path;
1014 let entry = "/data/arxiv/1234/5678/source/5678.zip";
1015 assert_eq!(
1017 result_archive_path(entry, "tex_to_html", None).unwrap(),
1018 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
1019 );
1020 assert_eq!(
1023 result_archive_path(entry, "tex_to_html", Some(42)).unwrap(),
1024 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.sandbox-42.zip")
1025 );
1026 assert_eq!(
1028 result_archive_path(&format!("{entry}\n"), "tex_to_html", None).unwrap(),
1029 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
1030 );
1031 assert!(result_archive_path("", "tex_to_html", None).is_none());
1034 assert!(result_archive_path("/", "tex_to_html", None).is_none());
1035 }
1036}
1037
1038#[cfg(test)]
1039mod rerun_mark_tests {
1040 use super::rerun_mark;
1041
1042 #[test]
1043 fn rerun_mark_stays_above_the_lease_space() {
1044 for _ in 0..10_000 {
1048 let mark = rerun_mark();
1049 assert!(
1050 mark > i32::from(u16::MAX) + 1,
1051 "rerun mark {mark} overlaps the lease space [1, 65536]"
1052 );
1053 assert!(mark > 0, "temp marks must be positive");
1054 }
1055 }
1056}
1057
1058#[cfg(test)]
1059mod entry_name_tests {
1060 use super::entry_document_name;
1061
1062 #[test]
1063 fn extracts_short_document_name() {
1064 assert_eq!(
1066 entry_document_name("/data/arxmliv/0811/0811.0417/0811.0417.zip"),
1067 "0811.0417"
1068 );
1069 assert_eq!(
1071 entry_document_name("/data/foo/2105.13573.tar"),
1072 "2105.13573"
1073 );
1074 assert_eq!(entry_document_name("/d/bar.tex "), "bar");
1076 assert_eq!(entry_document_name("noslash"), "noslash");
1078 }
1079}
1080
1081#[cfg(test)]
1082mod retryable_tag_tests {
1083 use super::{TaskReport, TaskStatus, parse_log};
1084 use crate::models::Task;
1085
1086 fn report(log: &str, status: TaskStatus) -> TaskReport {
1087 TaskReport {
1088 task: Task {
1089 id: 1,
1090 service_id: 3,
1091 corpus_id: 1,
1092 status: 7,
1093 entry: String::new(),
1094 },
1095 status,
1096 messages: parse_log(1, log),
1097 stand_in: false,
1098 }
1099 }
1100
1101 #[test]
1102 fn the_harness_tag_marks_a_retryable_cause() {
1103 let log = "Warning:x:y z\nInfo:cortex:retryable timeout:wallclock\nFatal:timeout:wallclock \
1104 the watchdog ended it\nStatus:conversion:3\n";
1105 let cause = report(log, TaskStatus::Fatal);
1106 assert!(cause.is_retryable());
1107 let kept: Vec<String> = cause
1108 .into_cause()
1109 .iter()
1110 .map(|m| {
1111 use crate::models::LogRecord;
1112 format!("{}:{}", m.category(), m.what())
1113 })
1114 .collect();
1115 assert!(!kept.contains(&"cortex:retryable".to_string()), "{kept:?}");
1116 assert!(kept.contains(&"timeout:wallclock".to_string()), "{kept:?}");
1117 }
1118
1119 #[test]
1120 fn a_documents_own_tag_line_is_not_a_retryable_cause() {
1121 for (log, status) in [
1124 (
1125 "Info:cortex:retryable timeout:wallclock\nWarning:x:y z\nStatus:conversion:1\n",
1126 TaskStatus::Warning,
1127 ),
1128 (
1129 "Info:cortex:retryable timeout:wallclock\nError:a:b c\nFatal:Timeout:Recursion \
1130 x\nStatus:conversion:3\n",
1131 TaskStatus::Fatal,
1132 ),
1133 (
1134 "Info:cortex:retryable timeout:wallclock\nFatal:Stomach:Recursion x\nStatus:conversion:3\n",
1135 TaskStatus::Fatal,
1136 ),
1137 (
1138 "Info:cortex:retryable timeout:wallclock\nFatal:timeout:wallclock x\nWarning:p:q \
1139 r\nFatal:Mouth:EoF y\nStatus:conversion:3\n",
1140 TaskStatus::Fatal,
1141 ),
1142 ] {
1143 assert!(!report(log, status).is_retryable(), "{log}");
1144 }
1145 }
1146}