1#[cfg(test)]
18use crate::output_spec::ArbitraryOutputSpec;
19use crate::{
20 config::{
21 elements::{JunitFlakyFailStatus, ReportSkipPolicy},
22 scripts::ScriptId,
23 },
24 list::OwnedTestInstanceId,
25 output_spec::{LiveSpec, OutputSpec, SerializableOutputSpec},
26 reporter::{
27 TestOutputDisplay,
28 events::{
29 CancelReason, ExecuteStatus, ExecutionStatuses, RetryData, RunFinishedStats, RunStats,
30 SetupScriptExecuteStatus, StressIndex, StressProgress, TestEvent, TestEventKind,
31 TestSlotAssignment,
32 },
33 },
34 run_mode::NextestRunMode,
35 runner::StressCondition,
36};
37use chrono::{DateTime, FixedOffset};
38use nextest_metadata::MismatchReason;
39use quick_junit::ReportUuid;
40use serde::{Deserialize, Serialize};
41use std::{fmt, num::NonZero, time::Duration};
42
43#[derive(Clone, Debug, Default, Deserialize, Serialize)]
52#[serde(rename_all = "kebab-case")]
53#[non_exhaustive]
54pub struct RecordOpts {
55 #[serde(default)]
57 pub run_mode: NextestRunMode,
58}
59
60impl RecordOpts {
61 pub fn new(run_mode: NextestRunMode) -> Self {
63 Self { run_mode }
64 }
65}
66
67#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
76#[derive(Deserialize, Serialize)]
77#[serde(
78 rename_all = "kebab-case",
79 bound(
80 serialize = "S: SerializableOutputSpec",
81 deserialize = "S: SerializableOutputSpec"
82 )
83)]
84#[cfg_attr(
85 test,
86 derive(test_strategy::Arbitrary),
87 arbitrary(bound(S: ArbitraryOutputSpec))
88)]
89pub struct TestEventSummary<S: OutputSpec> {
90 #[cfg_attr(
92 test,
93 strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
94 )]
95 pub timestamp: DateTime<FixedOffset>,
96
97 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
99 pub elapsed: Duration,
100
101 pub kind: TestEventKindSummary<S>,
103}
104
105impl TestEventSummary<LiveSpec> {
106 pub(crate) fn from_test_event(event: TestEvent<'_>) -> Option<Self> {
111 let kind = TestEventKindSummary::from_test_event_kind(event.kind)?;
112 Some(Self {
113 timestamp: event.timestamp,
114 elapsed: event.elapsed,
115 kind,
116 })
117 }
118}
119
120#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
130#[derive(Deserialize, Serialize)]
131#[serde(
132 tag = "type",
133 rename_all = "kebab-case",
134 bound(
135 serialize = "S: SerializableOutputSpec",
136 deserialize = "S: SerializableOutputSpec"
137 )
138)]
139#[cfg_attr(
140 test,
141 derive(test_strategy::Arbitrary),
142 arbitrary(bound(S: ArbitraryOutputSpec))
143)]
144pub enum TestEventKindSummary<S: OutputSpec> {
145 Core(CoreEventKind),
147 Output(OutputEventKind<S>),
149}
150
151#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
157#[serde(tag = "kind", rename_all = "kebab-case")]
158#[cfg_attr(test, derive(test_strategy::Arbitrary))]
159pub enum CoreEventKind {
160 #[serde(rename_all = "kebab-case")]
162 RunStarted {
163 run_id: ReportUuid,
165 profile_name: String,
167 cli_args: Vec<String>,
169 stress_condition: Option<StressConditionSummary>,
171 },
172
173 #[serde(rename_all = "kebab-case")]
175 StressSubRunStarted {
176 progress: StressProgress,
178 },
179
180 #[serde(rename_all = "kebab-case")]
182 SetupScriptStarted {
183 stress_index: Option<StressIndexSummary>,
185 index: usize,
187 total: usize,
189 script_id: ScriptId,
191 program: String,
193 args: Vec<String>,
195 no_capture: bool,
197 },
198
199 #[serde(rename_all = "kebab-case")]
201 SetupScriptSlow {
202 stress_index: Option<StressIndexSummary>,
204 script_id: ScriptId,
206 program: String,
208 args: Vec<String>,
210 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
212 elapsed: Duration,
213 will_terminate: bool,
215 },
216
217 #[serde(rename_all = "kebab-case")]
219 TestStarted {
220 stress_index: Option<StressIndexSummary>,
222 test_instance: OwnedTestInstanceId,
224 slot_assignment: TestSlotAssignment,
226 current_stats: RunStats,
228 running: usize,
230 command_line: Vec<String>,
232 },
233
234 #[serde(rename_all = "kebab-case")]
236 TestSlow {
237 stress_index: Option<StressIndexSummary>,
239 test_instance: OwnedTestInstanceId,
241 retry_data: RetryData,
243 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
245 elapsed: Duration,
246 will_terminate: bool,
248 },
249
250 #[serde(rename_all = "kebab-case")]
252 TestRetryStarted {
253 stress_index: Option<StressIndexSummary>,
255 test_instance: OwnedTestInstanceId,
257 slot_assignment: TestSlotAssignment,
259 retry_data: RetryData,
261 running: usize,
263 command_line: Vec<String>,
265 },
266
267 #[serde(rename_all = "kebab-case")]
269 TestSkipped {
270 stress_index: Option<StressIndexSummary>,
272 test_instance: OwnedTestInstanceId,
274 reason: MismatchReason,
276 #[serde(default)]
279 junit_report_skipped: ReportSkipPolicy,
280 },
281
282 #[serde(rename_all = "kebab-case")]
284 RunBeginCancel {
285 setup_scripts_running: usize,
287 running: usize,
289 reason: CancelReason,
291 },
292
293 #[serde(rename_all = "kebab-case")]
295 RunPaused {
296 setup_scripts_running: usize,
298 running: usize,
300 },
301
302 #[serde(rename_all = "kebab-case")]
304 RunContinued {
305 setup_scripts_running: usize,
307 running: usize,
309 },
310
311 #[serde(rename_all = "kebab-case")]
313 StressSubRunFinished {
314 progress: StressProgress,
316 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
318 sub_elapsed: Duration,
319 sub_stats: RunStats,
321 },
322
323 #[serde(rename_all = "kebab-case")]
325 RunFinished {
326 run_id: ReportUuid,
328 #[cfg_attr(
330 test,
331 strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
332 )]
333 start_time: DateTime<FixedOffset>,
334 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
336 elapsed: Duration,
337 run_stats: RunFinishedStats,
339 outstanding_not_seen: Option<TestsNotSeenSummary>,
341 },
342}
343
344#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
346#[serde(rename_all = "kebab-case")]
347#[cfg_attr(test, derive(test_strategy::Arbitrary))]
348pub struct TestsNotSeenSummary {
349 pub not_seen: Vec<OwnedTestInstanceId>,
351 pub total_not_seen: usize,
353}
354
355#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
364#[derive(Deserialize, Serialize)]
365#[serde(
366 tag = "kind",
367 rename_all = "kebab-case",
368 bound(
369 serialize = "S: SerializableOutputSpec",
370 deserialize = "S: SerializableOutputSpec"
371 )
372)]
373#[cfg_attr(
374 test,
375 derive(test_strategy::Arbitrary),
376 arbitrary(bound(S: ArbitraryOutputSpec))
377)]
378pub enum OutputEventKind<S: OutputSpec> {
379 #[serde(rename_all = "kebab-case")]
381 SetupScriptFinished {
382 stress_index: Option<StressIndexSummary>,
384 index: usize,
386 total: usize,
388 script_id: ScriptId,
390 program: String,
392 args: Vec<String>,
394 no_capture: bool,
396 run_status: SetupScriptExecuteStatus<S>,
398 },
399
400 #[serde(rename_all = "kebab-case")]
402 TestAttemptFailedWillRetry {
403 stress_index: Option<StressIndexSummary>,
405 test_instance: OwnedTestInstanceId,
407 run_status: ExecuteStatus<S>,
409 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
411 delay_before_next_attempt: Duration,
412 failure_output: TestOutputDisplay,
414 running: usize,
416 },
417
418 #[serde(rename_all = "kebab-case")]
420 TestFinished {
421 stress_index: Option<StressIndexSummary>,
423 test_instance: OwnedTestInstanceId,
425 success_output: TestOutputDisplay,
427 failure_output: TestOutputDisplay,
429 junit_store_success_output: bool,
431 junit_store_failure_output: bool,
433 #[serde(default)]
435 junit_flaky_fail_status: JunitFlakyFailStatus,
436 run_statuses: ExecutionStatuses<S>,
438 current_stats: RunStats,
440 running: usize,
442 },
443}
444
445impl TestEventKindSummary<LiveSpec> {
446 fn from_test_event_kind(kind: TestEventKind<'_>) -> Option<Self> {
447 Some(match kind {
448 TestEventKind::RunStarted {
449 run_id,
450 test_list: _,
451 profile_name,
452 cli_args,
453 stress_condition,
454 } => Self::Core(CoreEventKind::RunStarted {
455 run_id,
456 profile_name,
457 cli_args,
458 stress_condition: stress_condition.map(StressConditionSummary::from),
459 }),
460 TestEventKind::StressSubRunStarted { progress } => {
461 Self::Core(CoreEventKind::StressSubRunStarted { progress })
462 }
463 TestEventKind::SetupScriptStarted {
464 stress_index,
465 index,
466 total,
467 script_id,
468 program,
469 args,
470 no_capture,
471 } => Self::Core(CoreEventKind::SetupScriptStarted {
472 stress_index: stress_index.map(StressIndexSummary::from),
473 index,
474 total,
475 script_id,
476 program,
477 args: args.to_vec(),
478 no_capture,
479 }),
480 TestEventKind::SetupScriptSlow {
481 stress_index,
482 script_id,
483 program,
484 args,
485 elapsed,
486 will_terminate,
487 } => Self::Core(CoreEventKind::SetupScriptSlow {
488 stress_index: stress_index.map(StressIndexSummary::from),
489 script_id,
490 program,
491 args: args.to_vec(),
492 elapsed,
493 will_terminate,
494 }),
495 TestEventKind::TestStarted {
496 stress_index,
497 test_instance,
498 slot_assignment,
499 current_stats,
500 running,
501 command_line,
502 } => Self::Core(CoreEventKind::TestStarted {
503 stress_index: stress_index.map(StressIndexSummary::from),
504 test_instance: test_instance.to_owned(),
505 slot_assignment,
506 current_stats,
507 running,
508 command_line,
509 }),
510 TestEventKind::TestSlow {
511 stress_index,
512 test_instance,
513 retry_data,
514 elapsed,
515 will_terminate,
516 } => Self::Core(CoreEventKind::TestSlow {
517 stress_index: stress_index.map(StressIndexSummary::from),
518 test_instance: test_instance.to_owned(),
519 retry_data,
520 elapsed,
521 will_terminate,
522 }),
523 TestEventKind::TestRetryStarted {
524 stress_index,
525 test_instance,
526 slot_assignment,
527 retry_data,
528 running,
529 command_line,
530 } => Self::Core(CoreEventKind::TestRetryStarted {
531 stress_index: stress_index.map(StressIndexSummary::from),
532 test_instance: test_instance.to_owned(),
533 slot_assignment,
534 retry_data,
535 running,
536 command_line,
537 }),
538 TestEventKind::TestSkipped {
539 stress_index,
540 test_instance,
541 reason,
542 junit_report_skipped,
543 } => Self::Core(CoreEventKind::TestSkipped {
544 stress_index: stress_index.map(StressIndexSummary::from),
545 test_instance: test_instance.to_owned(),
546 reason,
547 junit_report_skipped,
548 }),
549 TestEventKind::RunBeginCancel {
550 setup_scripts_running,
551 current_stats,
552 running,
553 } => Self::Core(CoreEventKind::RunBeginCancel {
554 setup_scripts_running,
555 running,
556 reason: current_stats
557 .cancel_reason
558 .expect("RunBeginCancel event has cancel reason"),
559 }),
560 TestEventKind::RunPaused {
561 setup_scripts_running,
562 running,
563 } => Self::Core(CoreEventKind::RunPaused {
564 setup_scripts_running,
565 running,
566 }),
567 TestEventKind::RunContinued {
568 setup_scripts_running,
569 running,
570 } => Self::Core(CoreEventKind::RunContinued {
571 setup_scripts_running,
572 running,
573 }),
574 TestEventKind::StressSubRunFinished {
575 progress,
576 sub_elapsed,
577 sub_stats,
578 } => Self::Core(CoreEventKind::StressSubRunFinished {
579 progress,
580 sub_elapsed,
581 sub_stats,
582 }),
583 TestEventKind::RunFinished {
584 run_id,
585 start_time,
586 elapsed,
587 run_stats,
588 outstanding_not_seen,
589 } => Self::Core(CoreEventKind::RunFinished {
590 run_id,
591 start_time,
592 elapsed,
593 run_stats,
594 outstanding_not_seen: outstanding_not_seen.map(|t| TestsNotSeenSummary {
595 not_seen: t.not_seen,
596 total_not_seen: t.total_not_seen,
597 }),
598 }),
599
600 TestEventKind::SetupScriptFinished {
601 stress_index,
602 index,
603 total,
604 script_id,
605 program,
606 args,
607 junit_store_success_output: _,
608 junit_store_failure_output: _,
609 no_capture,
610 run_status,
611 } => Self::Output(OutputEventKind::SetupScriptFinished {
612 stress_index: stress_index.map(StressIndexSummary::from),
613 index,
614 total,
615 script_id,
616 program,
617 args: args.to_vec(),
618 no_capture,
619 run_status,
620 }),
621 TestEventKind::TestAttemptFailedWillRetry {
622 stress_index,
623 test_instance,
624 run_status,
625 delay_before_next_attempt,
626 failure_output,
627 running,
628 } => Self::Output(OutputEventKind::TestAttemptFailedWillRetry {
629 stress_index: stress_index.map(StressIndexSummary::from),
630 test_instance: test_instance.to_owned(),
631 run_status,
632 delay_before_next_attempt,
633 failure_output,
634 running,
635 }),
636 TestEventKind::TestFinished {
637 stress_index,
638 test_instance,
639 success_output,
640 failure_output,
641 junit_store_success_output,
642 junit_store_failure_output,
643 junit_flaky_fail_status,
644 run_statuses,
645 current_stats,
646 running,
647 } => Self::Output(OutputEventKind::TestFinished {
648 stress_index: stress_index.map(StressIndexSummary::from),
649 test_instance: test_instance.to_owned(),
650 success_output,
651 failure_output,
652 junit_store_success_output,
653 junit_store_failure_output,
654 junit_flaky_fail_status,
655 run_statuses,
656 current_stats,
657 running,
658 }),
659
660 TestEventKind::InfoStarted { .. }
661 | TestEventKind::InfoResponse { .. }
662 | TestEventKind::InfoFinished { .. }
663 | TestEventKind::InputEnter { .. }
664 | TestEventKind::RunBeginKill { .. } => return None,
665 })
666 }
667}
668
669#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
671#[serde(rename_all = "kebab-case")]
672#[cfg_attr(test, derive(test_strategy::Arbitrary))]
673pub struct StressIndexSummary {
674 pub current: u32,
676 pub total: Option<NonZero<u32>>,
678}
679
680impl From<StressIndex> for StressIndexSummary {
681 fn from(index: StressIndex) -> Self {
682 Self {
683 current: index.current,
684 total: index.total,
685 }
686 }
687}
688
689#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
691#[serde(tag = "type", rename_all = "kebab-case")]
692#[cfg_attr(test, derive(test_strategy::Arbitrary))]
693pub enum StressConditionSummary {
694 Count {
696 count: Option<u32>,
698 },
699 Duration {
701 #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
703 duration: Duration,
704 },
705}
706
707impl From<StressCondition> for StressConditionSummary {
708 fn from(condition: StressCondition) -> Self {
709 use crate::runner::StressCount;
710 match condition {
711 StressCondition::Count(count) => Self::Count {
712 count: match count {
713 StressCount::Count { count: n } => Some(n.get()),
714 StressCount::Infinite => None,
715 },
716 },
717 StressCondition::Duration(duration) => Self::Duration { duration },
718 }
719 }
720}
721
722#[derive(Clone, Copy, Debug, PartialEq, Eq)]
727pub(crate) enum OutputKind {
728 Stdout,
730 Stderr,
732 Combined,
734}
735
736impl OutputKind {
737 pub(crate) fn as_str(self) -> &'static str {
739 match self {
740 Self::Stdout => "stdout",
741 Self::Stderr => "stderr",
742 Self::Combined => "combined",
743 }
744 }
745}
746
747#[derive(Clone, Debug, PartialEq, Eq)]
758pub struct OutputFileName(String);
759
760impl OutputFileName {
761 pub(crate) fn from_content(content: &[u8], kind: OutputKind) -> Self {
766 let hash = xxhash_rust::xxh3::xxh3_64(content);
767 Self(format!("{hash:016x}-{}", kind.as_str()))
768 }
769
770 pub fn as_str(&self) -> &str {
772 &self.0
773 }
774
775 fn validate(s: &str) -> bool {
779 if s.contains('/') || s.contains('\\') || s.contains("..") {
780 return false;
781 }
782
783 let valid_suffixes = ["-stdout", "-stderr", "-combined"];
784 for suffix in valid_suffixes {
785 if let Some(hash_part) = s.strip_suffix(suffix)
786 && hash_part.len() == 16
787 && hash_part
788 .chars()
789 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
790 {
791 return true;
792 }
793 }
794
795 false
796 }
797}
798
799impl fmt::Display for OutputFileName {
800 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801 f.write_str(&self.0)
802 }
803}
804
805impl AsRef<str> for OutputFileName {
806 fn as_ref(&self) -> &str {
807 &self.0
808 }
809}
810
811impl Serialize for OutputFileName {
812 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
813 where
814 S: serde::Serializer,
815 {
816 self.0.serialize(serializer)
817 }
818}
819
820impl<'de> Deserialize<'de> for OutputFileName {
821 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
822 where
823 D: serde::Deserializer<'de>,
824 {
825 let s = String::deserialize(deserializer)?;
826 if Self::validate(&s) {
827 Ok(Self(s))
828 } else {
829 Err(serde::de::Error::custom(format!(
830 "invalid output file name: {s}"
831 )))
832 }
833 }
834}
835
836#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
838#[serde(tag = "status", rename_all = "kebab-case")]
839pub enum ZipStoreOutput {
840 Empty,
842
843 #[serde(rename_all = "kebab-case")]
845 Full {
846 file_name: OutputFileName,
848 },
849
850 #[serde(rename_all = "kebab-case")]
852 Truncated {
853 file_name: OutputFileName,
855 original_size: u64,
857 },
858}
859
860impl ZipStoreOutput {
861 pub fn file_name(&self) -> Option<&OutputFileName> {
863 match self {
864 ZipStoreOutput::Empty => None,
865 ZipStoreOutput::Full { file_name } | ZipStoreOutput::Truncated { file_name, .. } => {
866 Some(file_name)
867 }
868 }
869 }
870}
871
872#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(tag = "kind", rename_all = "kebab-case")]
881#[cfg_attr(test, derive(test_strategy::Arbitrary))]
882pub enum ZipStoreOutputDescription {
883 Split {
885 stdout: Option<ZipStoreOutput>,
887 stderr: Option<ZipStoreOutput>,
889 },
890
891 Combined {
893 output: ZipStoreOutput,
895 },
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use crate::output_spec::RecordingSpec;
902 use nextest_metadata::{RustBinaryId, TestCaseName};
903 use test_strategy::proptest;
904
905 #[proptest]
906 fn test_event_summary_roundtrips(value: TestEventSummary<RecordingSpec>) {
907 let json = serde_json::to_string(&value).expect("serialization succeeds");
908 let roundtrip: TestEventSummary<RecordingSpec> =
909 serde_json::from_str(&json).expect("deserialization succeeds");
910 proptest::prop_assert_eq!(value, roundtrip);
911 }
912
913 #[test]
914 fn test_skipped_report_skipped_defaults_when_absent() {
915 let event = CoreEventKind::TestSkipped {
916 stress_index: None,
917 test_instance: OwnedTestInstanceId {
918 binary_id: RustBinaryId::new("my-crate::my-bin"),
919 test_name: TestCaseName::new("tests::my_test"),
920 },
921 reason: MismatchReason::Ignored,
922 junit_report_skipped: ReportSkipPolicy::All,
923 };
924
925 let mut value = serde_json::to_value(&event).expect("serialization succeeds");
926 let removed = value
927 .as_object_mut()
928 .expect("serialized event is a JSON object")
929 .remove("junit-report-skipped");
930 assert!(
931 removed.is_some(),
932 "junit-report-skipped field is present before removal"
933 );
934
935 let deserialized: CoreEventKind =
936 serde_json::from_value(value).expect("deserialization without the field succeeds");
937 match deserialized {
938 CoreEventKind::TestSkipped {
939 junit_report_skipped,
940 ..
941 } => {
942 assert_eq!(
943 junit_report_skipped,
944 ReportSkipPolicy::None,
945 "a missing junit-report-skipped field defaults to None"
946 );
947 }
948 other => panic!("expected TestSkipped, got {other:?}"),
949 }
950 }
951
952 #[test]
953 fn test_output_file_name_from_content_stdout() {
954 let content = b"hello world";
955 let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
956
957 let s = file_name.as_str();
958 assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
959 assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stdout'");
960
961 let hash_part = &s[..16];
962 assert!(
963 hash_part.chars().all(|c| c.is_ascii_hexdigit()),
964 "hash portion should be hex: {hash_part}"
965 );
966 }
967
968 #[test]
969 fn test_output_file_name_from_content_stderr() {
970 let content = b"error message";
971 let file_name = OutputFileName::from_content(content, OutputKind::Stderr);
972
973 let s = file_name.as_str();
974 assert!(s.ends_with("-stderr"), "should end with -stderr: {s}");
975 assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stderr'");
976 }
977
978 #[test]
979 fn test_output_file_name_from_content_combined() {
980 let content = b"combined output";
981 let file_name = OutputFileName::from_content(content, OutputKind::Combined);
982
983 let s = file_name.as_str();
984 assert!(s.ends_with("-combined"), "should end with -combined: {s}");
985 assert_eq!(
986 s.len(),
987 16 + 1 + 8,
988 "should be 16 hex + hyphen + 'combined'"
989 );
990 }
991
992 #[test]
993 fn test_output_file_name_deterministic() {
994 let content = b"deterministic content";
995 let name1 = OutputFileName::from_content(content, OutputKind::Stdout);
996 let name2 = OutputFileName::from_content(content, OutputKind::Stdout);
997 assert_eq!(name1.as_str(), name2.as_str());
998 }
999
1000 #[test]
1001 fn test_output_file_name_different_content_different_hash() {
1002 let content1 = b"content one";
1003 let content2 = b"content two";
1004 let name1 = OutputFileName::from_content(content1, OutputKind::Stdout);
1005 let name2 = OutputFileName::from_content(content2, OutputKind::Stdout);
1006 assert_ne!(name1.as_str(), name2.as_str());
1007 }
1008
1009 #[test]
1010 fn test_output_file_name_same_content_different_kind() {
1011 let content = b"same content";
1012 let stdout = OutputFileName::from_content(content, OutputKind::Stdout);
1013 let stderr = OutputFileName::from_content(content, OutputKind::Stderr);
1014 assert_ne!(stdout.as_str(), stderr.as_str());
1015
1016 let stdout_hash = &stdout.as_str()[..16];
1017 let stderr_hash = &stderr.as_str()[..16];
1018 assert_eq!(stdout_hash, stderr_hash);
1019 }
1020
1021 #[test]
1022 fn test_output_file_name_empty_content() {
1023 let file_name = OutputFileName::from_content(b"", OutputKind::Stdout);
1024 let s = file_name.as_str();
1025 assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
1026 assert!(OutputFileName::validate(s), "should be valid: {s}");
1027 }
1028
1029 #[test]
1030 fn test_output_file_name_validate_valid_content_addressed() {
1031 assert!(OutputFileName::validate("0123456789abcdef-stdout"));
1033 assert!(OutputFileName::validate("fedcba9876543210-stderr"));
1034 assert!(OutputFileName::validate("aaaaaaaaaaaaaaaa-combined"));
1035 assert!(OutputFileName::validate("0000000000000000-stdout"));
1036 assert!(OutputFileName::validate("ffffffffffffffff-stderr"));
1037 }
1038
1039 #[test]
1040 fn test_output_file_name_validate_invalid_patterns() {
1041 assert!(!OutputFileName::validate("0123456789abcde-stdout"));
1043 assert!(!OutputFileName::validate("abc-stdout"));
1044
1045 assert!(!OutputFileName::validate("0123456789abcdef0-stdout"));
1047
1048 assert!(!OutputFileName::validate("0123456789abcdef-unknown"));
1050 assert!(!OutputFileName::validate("0123456789abcdef-out"));
1051 assert!(!OutputFileName::validate("0123456789abcdef"));
1052
1053 assert!(!OutputFileName::validate("0123456789abcdeg-stdout"));
1055 assert!(!OutputFileName::validate("0123456789ABCDEF-stdout")); assert!(!OutputFileName::validate("../0123456789abcdef-stdout"));
1059 assert!(!OutputFileName::validate("0123456789abcdef-stdout/"));
1060 assert!(!OutputFileName::validate("foo/0123456789abcdef-stdout"));
1061 assert!(!OutputFileName::validate("..\\0123456789abcdef-stdout"));
1062 }
1063
1064 #[test]
1065 fn test_output_file_name_validate_rejects_old_format() {
1066 assert!(!OutputFileName::validate("test-abc123-1-stdout"));
1068 assert!(!OutputFileName::validate("test-abc123-s5-1-stderr"));
1069 assert!(!OutputFileName::validate("script-def456-stdout"));
1070 assert!(!OutputFileName::validate("script-def456-s3-stderr"));
1071 }
1072
1073 #[test]
1074 fn test_output_file_name_serde_round_trip() {
1075 let content = b"test content for serde";
1076 let original = OutputFileName::from_content(content, OutputKind::Stdout);
1077
1078 let json = serde_json::to_string(&original).expect("serialization failed");
1079 let deserialized: OutputFileName =
1080 serde_json::from_str(&json).expect("deserialization failed");
1081
1082 assert_eq!(original.as_str(), deserialized.as_str());
1083 }
1084
1085 #[test]
1086 fn test_output_file_name_deserialize_invalid() {
1087 let json = r#""invalid-file-name""#;
1089 let result: Result<OutputFileName, _> = serde_json::from_str(json);
1090 assert!(
1091 result.is_err(),
1092 "should fail to deserialize invalid pattern"
1093 );
1094
1095 let json = r#""test-abc123-1-stdout""#; let result: Result<OutputFileName, _> = serde_json::from_str(json);
1097 assert!(result.is_err(), "should reject old format");
1098 }
1099
1100 #[test]
1101 fn test_zip_store_output_file_name() {
1102 let content = b"some output";
1103 let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
1104
1105 let empty = ZipStoreOutput::Empty;
1106 assert!(empty.file_name().is_none());
1107
1108 let full = ZipStoreOutput::Full {
1109 file_name: file_name.clone(),
1110 };
1111 assert_eq!(
1112 full.file_name().map(|f| f.as_str()),
1113 Some(file_name.as_str())
1114 );
1115
1116 let truncated = ZipStoreOutput::Truncated {
1117 file_name: file_name.clone(),
1118 original_size: 1000,
1119 };
1120 assert_eq!(
1121 truncated.file_name().map(|f| f.as_str()),
1122 Some(file_name.as_str())
1123 );
1124 }
1125}