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, 50);
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, 50);
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, 50);
499 filepath += &filename;
500 utf_truncate(&mut filepath, 50);
501 messages.push(NewTaskMessage::new(
502 task_id,
503 "info",
504 "loaded_file".to_string(),
505 filename,
506 filepath,
507 ));
508 } else {
509 }
511 }
512 }
513 messages
514}
515
516fn decode_worker_log(raw: &[u8]) -> String {
523 match str::from_utf8(raw) {
524 Ok(valid) => valid.to_string(),
525 Err(_) => {
526 let mut lossy = String::from_utf8_lossy(raw).into_owned();
527 lossy.push_str(
528 "\nWarning:cortex:non_utf8_log the worker log was not valid UTF-8; decoded lossily\n",
529 );
530 lossy
531 },
532 }
533}
534
535fn read_cortex_log(result: &Path) -> Result<String, String> {
543 let file = File::open(result).map_err(|e| format!("cannot open result archive: {e}"))?;
544 let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a readable zip: {e}"))?;
545 let mut entry = archive
546 .by_name("cortex.log")
547 .map_err(|e| format!("no cortex.log entry: {e}"))?;
548 let mut raw = Vec::new();
549 entry
550 .read_to_end(&mut raw)
551 .map_err(|e| format!("reading cortex.log failed: {e}"))?;
552 Ok(decode_worker_log(&raw))
553}
554
555pub fn generate_report(task: Task, result: &Path) -> Option<TaskReport> {
565 let log_string = match read_cortex_log(result) {
566 Ok(log_string) => log_string,
567 Err(reason) => {
568 println!("-- generate_report: {reason} (result {result:?}); deferring to the reaper (D-18)");
570 return None;
571 },
572 };
573 let mut messages = Vec::new();
574 let mut status = TaskStatus::Fatal; for message in parse_log(task.id, &log_string).into_iter() {
577 let mut skip_message = false;
580 match message {
581 NewTaskMessage::Invalid(ref _log_invalid) => {
582 status = TaskStatus::Invalid;
583 },
584 NewTaskMessage::Info(ref _log_info) => {
585 let message_what = message.what();
586 if message.category() == "conversion" && !message_what.is_empty() {
587 let latexml_scheme_status = match message_what.parse::<i32>() {
589 Ok(num) => num,
590 Err(e) => {
591 println!(
592 "-- generate_report: failed to parse conversion status {message_what:?}: {e:?}"
593 );
594 3 },
596 };
597 let cortex_scheme_status = -(latexml_scheme_status + 1);
598 if status != TaskStatus::Invalid {
599 status = TaskStatus::from_raw(cortex_scheme_status);
601 }
602 skip_message = true; }
604 },
605 _ => {},
606 };
607 if !skip_message {
608 messages.push(message);
609 }
610 }
611
612 Some(TaskReport {
613 task,
614 status,
615 messages,
616 })
617}
618
619pub fn prepare_input_stream(task: &Task) -> Result<File, io::Error> {
621 let entry_path = Path::new(&task.entry);
622 File::open(entry_path)
623}
624
625pub fn result_archive_path(
640 entry: &str,
641 service_name: &str,
642 sandbox_id: Option<i32>,
643) -> Option<PathBuf> {
644 let dir = Path::new(entry.trim_end())
645 .parent()
646 .and_then(Path::to_str)?;
647 let stem = match sandbox_id {
648 Some(id) => format!("{service_name}.sandbox-{id}"),
649 None => service_name.to_string(),
650 };
651 Some(PathBuf::from(format!("{dir}/{stem}.zip")))
652}
653
654pub fn utf_truncate(input: &mut String, maxsize: usize) {
656 let mut utf_maxsize = input.len();
657 if utf_maxsize >= maxsize {
658 {
659 let mut char_iter = input.char_indices();
660 while utf_maxsize >= maxsize {
661 utf_maxsize = match char_iter.next_back() {
662 Some((index, _)) => index,
663 _ => 0,
664 };
665 }
666 } input.truncate(utf_maxsize);
668 }
669 *input = input.replace('\x00', "");
671}
672
673pub fn random_mark() -> i32 {
675 let mut rng = rand::rng();
676 let mark_rng: u16 = rng.random();
677 i32::from(mark_rng)
678}
679
680pub fn rerun_mark() -> i32 {
691 const LEASE_CEILING: i32 = u16::MAX as i32 + 1;
693 LEASE_CEILING + 1 + i32::from(rand_in_range(0, u16::MAX))
694}
695
696pub fn rand_in_range(from: u16, to: u16) -> u16 {
698 let mut rng = rand::rng();
699 let mark_rng: u16 = rng.random_range(from..=to);
700 mark_rng
701}
702
703#[cfg(test)]
704mod log_decode_tests {
705 use super::{decode_worker_log, parse_log};
708 use crate::models::LogRecord;
709
710 #[test]
711 fn valid_utf8_passes_through_unchanged() {
712 let valid = "Warning:math:undefined hello\nStatus:conversion:1\n";
713 assert_eq!(decode_worker_log(valid.as_bytes()), valid);
714 assert!(
715 !decode_worker_log(valid.as_bytes()).contains("non_utf8_log"),
716 "no spurious warning is added to a clean log"
717 );
718 }
719
720 #[test]
721 fn non_utf8_decodes_lossily_not_fatal() {
722 let raw = b"Warning:math:undefined bad \xFF byte\nStatus:conversion:1\n";
724 let decoded = decode_worker_log(raw);
725 assert!(
728 decoded.contains("Status:conversion:1"),
729 "the real conversion status survives lossy decoding"
730 );
731 assert!(decoded.contains("Warning:math:undefined"));
732 assert!(
733 decoded.contains('\u{FFFD}'),
734 "the invalid byte became the Unicode replacement char"
735 );
736 assert!(decoded.contains("Warning:cortex:non_utf8_log"));
738 assert!(
740 parse_log(1, &decoded).len() >= 2,
741 "real messages are preserved, not collapsed into one fatal"
742 );
743 }
744
745 #[test]
746 fn warn_abbreviation_is_recognized_as_warning() {
747 use super::NewTaskMessage;
752 let abbrev = parse_log(42, "Warn:missing_file:rotfloat.sty stubbed\n");
753 assert_eq!(abbrev.len(), 1);
754 assert!(
755 matches!(abbrev[0], NewTaskMessage::Warning(_)),
756 "abbreviated `Warn:` is filed as a Warning, not defaulted to Info"
757 );
758 let canonical = parse_log(42, "Warning:missing_file:x y\n");
759 assert!(matches!(canonical[0], NewTaskMessage::Warning(_)));
760 }
761
762 #[test]
763 fn runtime_line_parses_into_the_fields_mark_done_keys_on() {
764 use super::NewTaskMessage;
765 let new = parse_log(7, "Info:runtime_ms:12345\n");
768 assert_eq!(new.len(), 1);
769 let m = &new[0];
770 assert!(matches!(m, NewTaskMessage::Info(_)));
771 assert_eq!(m.category(), "runtime_ms");
772 assert_eq!(m.what(), "12345");
773 assert_eq!(m.details(), "");
774 let old = parse_log(7, "Info:cortex:runtime_ms 12345\n");
777 assert_eq!(old.len(), 1);
778 let m = &old[0];
779 assert_eq!(m.category(), "cortex");
780 assert_eq!(m.what(), "runtime_ms");
781 assert_eq!(m.details(), "12345");
782 }
783
784 #[test]
785 fn fatal_invalid_category_is_separated_into_invalid() {
786 use super::NewTaskMessage;
791 let m = parse_log(
792 7,
793 "Fatal:invalid:no_tex_source no .tex files found in archive\n",
794 );
795 assert_eq!(m.len(), 1);
796 assert!(
797 matches!(m[0], NewTaskMessage::Invalid(_)),
798 "a Fatal message in the `invalid` category is filed as Invalid, not Fatal"
799 );
800 let f = parse_log(7, "Fatal:conversion:caught boom\n");
802 assert!(matches!(f[0], NewTaskMessage::Fatal(_)));
803 let i = parse_log(7, "Invalid:size:too_big result exceeds cap\n");
805 assert!(matches!(i[0], NewTaskMessage::Invalid(_)));
806 }
807
808 #[test]
809 fn read_cortex_log_extracts_from_zip_and_errors_gracefully() {
810 use super::read_cortex_log;
811 use std::fs::File;
812 use std::io::Write;
813 let zip_path = std::env::temp_dir().join("cortex_read_log_unit_test.zip");
814 {
815 let mut zw = zip::ZipWriter::new(File::create(&zip_path).unwrap());
816 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
817 zw.start_file("html/index.html", opts).unwrap();
819 zw.write_all(&vec![b'x'; 200_000]).unwrap();
820 zw.start_file("cortex.log", opts).unwrap();
821 zw.write_all(b"Info:cortex:hello a worker log line\n")
822 .unwrap();
823 zw.finish().unwrap();
824 }
825 let log = read_cortex_log(&zip_path).expect("reads cortex.log out of the zip");
826 assert!(
827 log.contains("hello a worker log line"),
828 "by_name extracted the cortex.log content, got: {log:?}"
829 );
830 assert!(read_cortex_log(std::path::Path::new("/nonexistent/x.zip")).is_err());
832 std::fs::remove_file(&zip_path).ok();
833 }
834
835 #[test]
836 fn generate_report_defers_unreadable_archives_to_the_reaper() {
837 use super::{Task, TaskStatus, generate_report};
838 use std::fs::File;
839 use std::io::Write;
840 let task = || Task {
841 id: 42,
842 service_id: 3,
843 corpus_id: 2,
844 status: 0,
845 entry: "/d/x.zip".to_string(),
846 };
847 let empty = std::env::temp_dir().join("cortex_d18_empty_result.zip");
850 File::create(&empty).unwrap(); assert!(
852 generate_report(task(), &empty).is_none(),
853 "a 0-byte result archive defers to the reaper (None), never a fabricated Fatal"
854 );
855 assert!(
856 generate_report(task(), std::path::Path::new("/nonexistent/x.zip")).is_none(),
857 "a missing result archive defers to the reaper (None)"
858 );
859 let good = std::env::temp_dir().join("cortex_d18_good_result.zip");
862 {
863 let mut zw = zip::ZipWriter::new(File::create(&good).unwrap());
864 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
865 zw.start_file("cortex.log", opts).unwrap();
866 zw.write_all(b"Info:conversion:0 clean run\n").unwrap();
867 zw.finish().unwrap();
868 }
869 let report =
870 generate_report(task(), &good).expect("a readable cortex.log yields a verdict (Some)");
871 assert_eq!(
872 report.status,
873 TaskStatus::NoProblem,
874 "conversion:0 status line -> NoProblem"
875 );
876 std::fs::remove_file(&empty).ok();
877 std::fs::remove_file(&good).ok();
878 }
879
880 #[test]
881 fn tab_indented_details_fold_into_the_preceding_message() {
882 let log = "Error:math:bad first detail\n\tsecond line\n\tthird line\n";
885 let messages = parse_log(7, log);
886 assert_eq!(messages.len(), 1, "one message, the details folded into it");
887 let details = messages[0].details();
888 assert!(details.contains("first detail"), "got: {details:?}");
889 assert!(details.contains("second line"), "got: {details:?}");
890 assert!(details.contains("third line"), "got: {details:?}");
891 }
892
893 #[test]
894 fn orphan_details_line_does_not_panic() {
895 let messages = parse_log(
899 7,
900 "\torphan detail with no header\nInfo:cortex:hi a real line\n",
901 );
902 assert_eq!(
903 messages.len(),
904 1,
905 "the orphan line is ignored; the real message survives"
906 );
907 }
908
909 #[test]
910 fn result_archive_path_scopes_sandbox_outputs() {
911 use super::result_archive_path;
912 let entry = "/data/arxiv/1234/5678/source/5678.zip";
913 assert_eq!(
915 result_archive_path(entry, "tex_to_html", None).unwrap(),
916 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
917 );
918 assert_eq!(
921 result_archive_path(entry, "tex_to_html", Some(42)).unwrap(),
922 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.sandbox-42.zip")
923 );
924 assert_eq!(
926 result_archive_path(&format!("{entry}\n"), "tex_to_html", None).unwrap(),
927 std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
928 );
929 assert!(result_archive_path("", "tex_to_html", None).is_none());
932 assert!(result_archive_path("/", "tex_to_html", None).is_none());
933 }
934}
935
936#[cfg(test)]
937mod rerun_mark_tests {
938 use super::rerun_mark;
939
940 #[test]
941 fn rerun_mark_stays_above_the_lease_space() {
942 for _ in 0..10_000 {
946 let mark = rerun_mark();
947 assert!(
948 mark > i32::from(u16::MAX) + 1,
949 "rerun mark {mark} overlaps the lease space [1, 65536]"
950 );
951 assert!(mark > 0, "temp marks must be positive");
952 }
953 }
954}
955
956#[cfg(test)]
957mod entry_name_tests {
958 use super::entry_document_name;
959
960 #[test]
961 fn extracts_short_document_name() {
962 assert_eq!(
964 entry_document_name("/data/arxmliv/0811/0811.0417/0811.0417.zip"),
965 "0811.0417"
966 );
967 assert_eq!(
969 entry_document_name("/data/foo/2105.13573.tar"),
970 "2105.13573"
971 );
972 assert_eq!(entry_document_name("/d/bar.tex "), "bar");
974 assert_eq!(entry_document_name("noslash"), "noslash");
976 }
977}