1use std::collections::HashMap;
12
13use crate::extensions::tasks::{TaskEvent, TaskKind};
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct TaskState {
18 pub id: String,
19 pub label: String,
20 pub kind: TaskKind,
21 pub current: Option<u64>,
22 pub total: Option<u64>,
23 pub message: Option<String>,
24 pub recent_logs: Vec<String>,
26 pub done: bool,
27 pub error: Option<String>,
28}
29
30const MAX_RECENT_LOGS: usize = 8;
31
32impl TaskState {
33 pub fn new(id: String, label: String, kind: TaskKind) -> Self {
34 Self {
35 id,
36 label,
37 kind,
38 current: None,
39 total: None,
40 message: None,
41 recent_logs: Vec::new(),
42 done: false,
43 error: None,
44 }
45 }
46
47 pub fn fraction(&self) -> Option<f32> {
48 match (self.current, self.total) {
49 (Some(c), Some(t)) if t > 0 => Some((c as f32 / t as f32).clamp(0.0, 1.0)),
50 _ => None,
51 }
52 }
53}
54
55#[derive(Debug, Default, Clone)]
57pub struct ActiveTasks {
58 map: HashMap<String, TaskState>,
59 order: Vec<String>,
62}
63
64impl ActiveTasks {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn len(&self) -> usize {
70 self.map.len()
71 }
72
73 pub fn is_empty(&self) -> bool {
74 self.map.is_empty()
75 }
76
77 pub fn get(&self, id: &str) -> Option<&TaskState> {
78 self.map.get(id)
79 }
80
81 pub fn iter(&self) -> impl Iterator<Item = &TaskState> {
83 self.order.iter().filter_map(|id| self.map.get(id))
84 }
85
86 pub fn apply(&mut self, event: TaskEvent) {
90 match event {
91 TaskEvent::Start { id, label, kind } => {
92 self.map
93 .entry(id.clone())
94 .and_modify(|s| {
95 s.label = label.clone();
96 s.kind = kind;
97 s.done = false;
98 s.error = None;
99 })
100 .or_insert_with(|| TaskState::new(id.clone(), label, kind));
101 if !self.order.iter().any(|x| x == &id) {
102 self.order.push(id);
103 }
104 }
105 TaskEvent::Update {
106 id,
107 current,
108 total,
109 message,
110 } => {
111 if let Some(s) = self.map.get_mut(&id) {
112 if current.is_some() {
113 s.current = current;
114 }
115 if total.is_some() {
116 s.total = total;
117 }
118 if message.is_some() {
119 s.message = message;
120 }
121 }
122 }
123 TaskEvent::Log { id, line } => {
124 if let Some(s) = self.map.get_mut(&id) {
125 s.recent_logs.push(line);
126 if s.recent_logs.len() > MAX_RECENT_LOGS {
127 let drop = s.recent_logs.len() - MAX_RECENT_LOGS;
128 s.recent_logs.drain(0..drop);
129 }
130 }
131 }
132 TaskEvent::Done { id, error } => {
133 if let Some(s) = self.map.get_mut(&id) {
134 s.done = true;
135 s.error = error;
136 }
137 }
138 }
139 }
140
141 pub fn prune(&mut self, id: &str) -> bool {
143 let removed = self.map.remove(id).is_some();
144 self.order.retain(|x| x != id);
145 removed
146 }
147
148 pub fn prune_completed(&mut self) {
150 let to_drop: Vec<String> = self
151 .order
152 .iter()
153 .filter(|id| self.map.get(*id).map(|s| s.done).unwrap_or(false))
154 .cloned()
155 .collect();
156 for id in to_drop {
157 self.prune(&id);
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn ev_start(id: &str, label: &str, kind: TaskKind) -> TaskEvent {
167 TaskEvent::Start {
168 id: id.into(),
169 label: label.into(),
170 kind,
171 }
172 }
173
174 #[test]
175 fn start_update_done_lifecycle() {
176 let mut t = ActiveTasks::new();
177 t.apply(ev_start("dl", "Downloading", TaskKind::Download));
178 assert_eq!(t.len(), 1);
179 let s = t.get("dl").unwrap();
180 assert_eq!(s.label, "Downloading");
181 assert_eq!(s.kind, TaskKind::Download);
182 assert!(!s.done);
183
184 t.apply(TaskEvent::Update {
185 id: "dl".into(),
186 current: Some(50),
187 total: Some(100),
188 message: Some("connecting".into()),
189 });
190 let s = t.get("dl").unwrap();
191 assert_eq!(s.current, Some(50));
192 assert_eq!(s.total, Some(100));
193 assert_eq!(s.message.as_deref(), Some("connecting"));
194 assert!((s.fraction().unwrap() - 0.5).abs() < 1e-6);
195
196 t.apply(TaskEvent::Done {
197 id: "dl".into(),
198 error: None,
199 });
200 assert!(t.get("dl").unwrap().done);
201
202 t.prune_completed();
203 assert!(t.is_empty());
204 }
205
206 #[test]
207 fn update_for_unknown_id_is_noop() {
208 let mut t = ActiveTasks::new();
209 t.apply(TaskEvent::Update {
210 id: "nope".into(),
211 current: Some(1),
212 total: Some(2),
213 message: None,
214 });
215 assert!(t.is_empty());
216 }
217
218 #[test]
219 fn log_lines_are_bounded() {
220 let mut t = ActiveTasks::new();
221 t.apply(ev_start("rb", "Rebuilding", TaskKind::Rebuild));
222 for i in 0..20 {
223 t.apply(TaskEvent::Log {
224 id: "rb".into(),
225 line: format!("line {i}"),
226 });
227 }
228 let s = t.get("rb").unwrap();
229 assert_eq!(s.recent_logs.len(), MAX_RECENT_LOGS);
230 assert_eq!(s.recent_logs.last().unwrap(), "line 19");
231 assert_eq!(s.recent_logs.first().unwrap(), &format!("line {}", 20 - MAX_RECENT_LOGS));
232 }
233
234 #[test]
235 fn iteration_preserves_start_order() {
236 let mut t = ActiveTasks::new();
237 t.apply(ev_start("a", "A", TaskKind::Generic));
238 t.apply(ev_start("b", "B", TaskKind::Generic));
239 t.apply(ev_start("c", "C", TaskKind::Generic));
240 let labels: Vec<_> = t.iter().map(|s| s.label.clone()).collect();
241 assert_eq!(labels, vec!["A", "B", "C"]);
242 }
243
244 #[test]
245 fn restart_resets_done_and_error() {
246 let mut t = ActiveTasks::new();
247 t.apply(ev_start("x", "X", TaskKind::Generic));
248 t.apply(TaskEvent::Done {
249 id: "x".into(),
250 error: Some("boom".into()),
251 });
252 assert!(t.get("x").unwrap().done);
253 t.apply(ev_start("x", "X2", TaskKind::Generic));
255 let s = t.get("x").unwrap();
256 assert!(!s.done);
257 assert!(s.error.is_none());
258 assert_eq!(s.label, "X2");
259 assert_eq!(t.len(), 1);
260 }
261
262 #[test]
263 fn fraction_handles_zero_and_missing_total() {
264 let s = TaskState::new("a".into(), "a".into(), TaskKind::Generic);
265 assert!(s.fraction().is_none());
266 let s2 = TaskState {
267 current: Some(5),
268 total: Some(0),
269 ..s.clone()
270 };
271 assert!(s2.fraction().is_none());
272 }
273}