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