solti_model/resource/
run.rs1use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9use crate::TaskPhase;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct TaskRun {
36 pub attempt: u32,
38 pub phase: TaskPhase,
40 #[serde(with = "super::metadata::time_serde")]
42 pub started_at: SystemTime,
43 #[serde(
45 skip_serializing_if = "Option::is_none",
46 with = "option_time_serde",
47 default
48 )]
49 pub finished_at: Option<SystemTime>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub error: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub exit_code: Option<i32>,
56}
57
58impl TaskRun {
59 pub fn starting(attempt: u32) -> Self {
61 Self {
62 attempt,
63 phase: TaskPhase::Running,
64 started_at: SystemTime::now(),
65 finished_at: None,
66 error: None,
67 exit_code: None,
68 }
69 }
70
71 pub fn finish(&mut self, phase: TaskPhase, error: Option<String>, exit_code: Option<i32>) {
73 self.finished_at = Some(SystemTime::now());
74 self.phase = phase;
75 self.error = error;
76 self.exit_code = exit_code;
77 }
78
79 pub fn is_active(&self) -> bool {
81 self.finished_at.is_none()
82 }
83}
84
85mod option_time_serde {
86 use serde::{Deserialize, Deserializer, Serialize, Serializer};
87 use std::time::{SystemTime, UNIX_EPOCH};
88
89 pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
90 where
91 S: Serializer,
92 {
93 match time {
94 Some(t) => {
95 let since_epoch = t
96 .duration_since(UNIX_EPOCH)
97 .map_err(serde::ser::Error::custom)?;
98 let ms = since_epoch.as_secs() * 1_000 + u64::from(since_epoch.subsec_millis());
99 ms.serialize(serializer)
100 }
101 None => serializer.serialize_none(),
102 }
103 }
104
105 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
106 where
107 D: Deserializer<'de>,
108 {
109 let opt: Option<u64> = Option::deserialize(deserializer)?;
110 Ok(opt.map(|ms| UNIX_EPOCH + std::time::Duration::from_millis(ms)))
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn starting_creates_running_run() {
120 let run = TaskRun::starting(1);
121 assert_eq!(run.attempt, 1);
122 assert_eq!(run.phase, TaskPhase::Running);
123 assert!(run.is_active());
124 assert!(run.finished_at.is_none());
125 assert!(run.error.is_none());
126 assert!(run.exit_code.is_none());
127 }
128
129 #[test]
130 fn finish_closes_run() {
131 let mut run = TaskRun::starting(2);
132 run.finish(TaskPhase::Failed, Some("boom".into()), Some(1));
133
134 assert!(!run.is_active());
135 assert!(run.finished_at.is_some());
136 assert_eq!(run.phase, TaskPhase::Failed);
137 assert_eq!(run.error.as_deref(), Some("boom"));
138 assert_eq!(run.exit_code, Some(1));
139 }
140
141 #[test]
142 fn finish_succeeded_no_error() {
143 let mut run = TaskRun::starting(1);
144 run.finish(TaskPhase::Succeeded, None, None);
145
146 assert!(!run.is_active());
147 assert_eq!(run.phase, TaskPhase::Succeeded);
148 assert!(run.error.is_none());
149 assert!(run.exit_code.is_none());
150 }
151
152 #[test]
153 fn serde_roundtrip_active() {
154 let run = TaskRun::starting(3);
155 let json = serde_json::to_string(&run).unwrap();
156 let back: TaskRun = serde_json::from_str(&json).unwrap();
157
158 assert_eq!(back.attempt, 3);
159 assert_eq!(back.phase, TaskPhase::Running);
160 assert!(back.finished_at.is_none());
161 }
162
163 #[test]
164 fn serde_roundtrip_finished() {
165 let mut run = TaskRun::starting(1);
166 run.finish(TaskPhase::Timeout, Some("timeout".into()), None);
167
168 let json = serde_json::to_string(&run).unwrap();
169 let back: TaskRun = serde_json::from_str(&json).unwrap();
170
171 assert_eq!(back.phase, TaskPhase::Timeout);
172 assert!(back.finished_at.is_some());
173 assert_eq!(back.error.as_deref(), Some("timeout"));
174 }
175
176 #[test]
177 fn serde_skips_none_fields() {
178 let run = TaskRun::starting(1);
179 let json = serde_json::to_string(&run).unwrap();
180 assert!(!json.contains("finishedAt"));
181 assert!(!json.contains("error"));
182 assert!(!json.contains("exitCode"));
183 }
184}