Skip to main content

synaps_cli/extensions/
tasks.rs

1//! Plugin long-running task notification types and parser.
2//!
3//! Phase B Phase 3 contract — see
4//! `docs/plans/2026-05-03-extension-contracts-for-rich-plugins.md`.
5//!
6//! Plugins push spontaneous JSON-RPC notifications for long-running tasks
7//! (downloads, rebuilds, indexing) outside the slash-command request/response
8//! cycle. Method names: `task.start`, `task.update`, `task.log`, `task.done`.
9//!
10//! Wire shapes (params per method):
11//!
12//! - `task.start`:  `{ id, label, kind: "download"|"rebuild"|"generic" }`
13//! - `task.update`: `{ id, current?: u64, total?: u64, message?: string }`
14//! - `task.log`:    `{ id, line }`
15//! - `task.done`:   `{ id, error?: string|null }`
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20/// Coarse classification of a task; affects how it's rendered.
21#[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/// Parsed task notification.
36#[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
70/// Returns true if `method` is one of the recognised task notifications.
71pub fn is_task_method(method: &str) -> bool {
72    matches!(method, "task.start" | "task.update" | "task.log" | "task.done")
73}
74
75/// Parse a `task.*` notification given the JSON-RPC method and params.
76pub 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}