Skip to main content

synaps_cli/extensions/
active_tasks.rs

1//! Generic active-task model for plugin-driven long-running work.
2//!
3//! Phase B Phase 3 — see
4//! `docs/plans/2026-05-03-extension-contracts-for-rich-plugins.md`.
5//!
6//! Stored on `App` (or any container) as a `HashMap<String, TaskState>` so the
7//! sticky progress UI can render N concurrent tasks generically without any
8//! plugin-specific knowledge. This module is intentionally decoupled
9//! from `App` so it can be unit-tested standalone.
10
11use std::collections::HashMap;
12
13use crate::extensions::tasks::{TaskEvent, TaskKind};
14
15/// In-progress task aggregate updated from `task.start/update/log/done` events.
16#[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    /// Most recent log line (for `rebuild`-style tasks). Bounded to N entries.
25    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/// Generic task store keyed by task id. Drop-in for `App::active_tasks`.
56#[derive(Debug, Default, Clone)]
57pub struct ActiveTasks {
58    map: HashMap<String, TaskState>,
59    /// Insertion order of currently-tracked tasks. Used by render code so the
60    /// sticky bar shows tasks in the order they were started.
61    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    /// Iterate tasks in insertion (start) order.
82    pub fn iter(&self) -> impl Iterator<Item = &TaskState> {
83        self.order.iter().filter_map(|id| self.map.get(id))
84    }
85
86    /// Apply a parsed `TaskEvent`. Idempotent for repeated `start` (label/kind
87    /// updated). `done` keeps the task in the map so the UI can show a final
88    /// state until callers explicitly `prune`.
89    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    /// Remove a single task by id. Returns true if removed.
142    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    /// Remove all tasks whose `done` flag is set.
149    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        // Re-start with same id resets state.
254        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}