1use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TaskKind {
24 Download,
25 Rebuild,
26 Generic,
27}
28
29impl Default for TaskKind {
30 fn default() -> Self {
31 TaskKind::Generic
32 }
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub enum TaskEvent {
38 Start {
39 id: String,
40 label: String,
41 kind: TaskKind,
42 },
43 Update {
44 id: String,
45 current: Option<u64>,
46 total: Option<u64>,
47 message: Option<String>,
48 },
49 Log {
50 id: String,
51 line: String,
52 },
53 Done {
54 id: String,
55 error: Option<String>,
56 },
57}
58
59impl TaskEvent {
60 pub fn id(&self) -> &str {
61 match self {
62 TaskEvent::Start { id, .. }
63 | TaskEvent::Update { id, .. }
64 | TaskEvent::Log { id, .. }
65 | TaskEvent::Done { id, .. } => id,
66 }
67 }
68}
69
70pub fn is_task_method(method: &str) -> bool {
72 matches!(method, "task.start" | "task.update" | "task.log" | "task.done")
73}
74
75pub fn parse_task_event(method: &str, params: &Value) -> Result<TaskEvent, String> {
77 let obj = params
78 .as_object()
79 .ok_or_else(|| format!("{method} params must be a JSON object"))?;
80 let id = obj
81 .get("id")
82 .and_then(Value::as_str)
83 .ok_or_else(|| format!("{method} missing 'id'"))?
84 .to_string();
85 if id.is_empty() {
86 return Err(format!("{method} 'id' must be non-empty"));
87 }
88
89 match method {
90 "task.start" => {
91 let label = obj
92 .get("label")
93 .and_then(Value::as_str)
94 .ok_or_else(|| "task.start missing 'label'".to_string())?
95 .to_string();
96 let kind = match obj.get("kind").and_then(Value::as_str) {
97 None => TaskKind::Generic,
98 Some("download") => TaskKind::Download,
99 Some("rebuild") => TaskKind::Rebuild,
100 Some("generic") | Some("other") => TaskKind::Generic,
101 Some(other) => return Err(format!("task.start unknown kind '{other}'")),
102 };
103 Ok(TaskEvent::Start { id, label, kind })
104 }
105 "task.update" => {
106 let current = obj.get("current").and_then(Value::as_u64);
107 let total = obj.get("total").and_then(Value::as_u64);
108 let message = obj
109 .get("message")
110 .and_then(Value::as_str)
111 .map(str::to_string);
112 Ok(TaskEvent::Update {
113 id,
114 current,
115 total,
116 message,
117 })
118 }
119 "task.log" => {
120 let line = obj
121 .get("line")
122 .and_then(Value::as_str)
123 .ok_or_else(|| "task.log missing 'line'".to_string())?
124 .to_string();
125 Ok(TaskEvent::Log { id, line })
126 }
127 "task.done" => {
128 let error = match obj.get("error") {
129 None | Some(Value::Null) => None,
130 Some(Value::String(s)) => {
131 if s.is_empty() {
132 None
133 } else {
134 Some(s.clone())
135 }
136 }
137 Some(other) => {
138 return Err(format!("task.done 'error' must be string or null, got {other}"));
139 }
140 };
141 Ok(TaskEvent::Done { id, error })
142 }
143 other => Err(format!("not a task method: {other}")),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use serde_json::json;
151
152 #[test]
153 fn parses_task_start_with_kind() {
154 let ev = parse_task_event(
155 "task.start",
156 &json!({"id":"dl","label":"Downloading","kind":"download"}),
157 )
158 .unwrap();
159 assert_eq!(
160 ev,
161 TaskEvent::Start {
162 id: "dl".into(),
163 label: "Downloading".into(),
164 kind: TaskKind::Download
165 }
166 );
167 }
168
169 #[test]
170 fn task_start_defaults_to_generic_kind() {
171 let ev = parse_task_event("task.start", &json!({"id":"x","label":"y"})).unwrap();
172 assert!(matches!(
173 ev,
174 TaskEvent::Start { kind: TaskKind::Generic, .. }
175 ));
176 }
177
178 #[test]
179 fn parses_task_update_partial() {
180 let ev = parse_task_event(
181 "task.update",
182 &json!({"id":"dl","current":50,"total":100}),
183 )
184 .unwrap();
185 assert_eq!(
186 ev,
187 TaskEvent::Update {
188 id: "dl".into(),
189 current: Some(50),
190 total: Some(100),
191 message: None
192 }
193 );
194 }
195
196 #[test]
197 fn parses_task_log() {
198 let ev = parse_task_event("task.log", &json!({"id":"r","line":"compiling..."})).unwrap();
199 assert_eq!(
200 ev,
201 TaskEvent::Log { id: "r".into(), line: "compiling...".into() }
202 );
203 }
204
205 #[test]
206 fn parses_task_done_no_error() {
207 let ev = parse_task_event("task.done", &json!({"id":"r"})).unwrap();
208 assert_eq!(ev, TaskEvent::Done { id: "r".into(), error: None });
209 }
210
211 #[test]
212 fn parses_task_done_with_error() {
213 let ev = parse_task_event("task.done", &json!({"id":"r","error":"boom"})).unwrap();
214 assert_eq!(
215 ev,
216 TaskEvent::Done {
217 id: "r".into(),
218 error: Some("boom".into())
219 }
220 );
221 }
222
223 #[test]
224 fn rejects_missing_id() {
225 assert!(parse_task_event("task.start", &json!({"label":"x"})).is_err());
226 }
227
228 #[test]
229 fn rejects_unknown_kind() {
230 let err = parse_task_event(
231 "task.start",
232 &json!({"id":"x","label":"y","kind":"alien"}),
233 )
234 .unwrap_err();
235 assert!(err.contains("unknown kind"));
236 }
237
238 #[test]
239 fn is_task_method_works() {
240 assert!(is_task_method("task.start"));
241 assert!(is_task_method("task.update"));
242 assert!(is_task_method("task.log"));
243 assert!(is_task_method("task.done"));
244 assert!(!is_task_method("task.unknown"));
245 assert!(!is_task_method("provider.stream.event"));
246 }
247}