1use serde::Serialize;
27use std::fs::OpenOptions;
28use std::io::Write;
29use std::path::PathBuf;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ToastLevel {
37 #[default]
38 Info,
39 Warn,
40 Error,
41}
42
43impl ToastLevel {
44 fn as_str(self) -> &'static str {
45 match self {
46 ToastLevel::Info => "info",
47 ToastLevel::Warn => "warn",
48 ToastLevel::Error => "error",
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ProgressStatus {
57 Success,
58 Failed,
59 Cancelled,
60}
61
62impl ProgressStatus {
63 fn as_str(self) -> &'static str {
64 match self {
65 ProgressStatus::Success => "success",
66 ProgressStatus::Failed => "failed",
67 ProgressStatus::Cancelled => "cancelled",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum SegmentSide {
76 Left,
77 #[default]
78 Right,
79}
80
81impl SegmentSide {
82 fn as_str(self) -> &'static str {
83 match self {
84 SegmentSide::Left => "left",
85 SegmentSide::Right => "right",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default)]
93pub struct NotifyOpts {
94 pub level: ToastLevel,
95 pub sound: bool,
98 pub source: Option<String>,
101}
102
103pub fn toast(message: impl AsRef<str>) {
107 let payload = serde_json::json!({
108 "cmd": "toast",
109 "text": message.as_ref(),
110 });
111 let _ = write_line(&payload);
112}
113
114pub fn set_activity_badge(section: impl AsRef<str>, count: u32) {
122 let payload = serde_json::json!({
123 "cmd": "set-activity-badge",
124 "section": section.as_ref(),
125 "count": count,
126 });
127 let _ = write_line(&payload);
128}
129
130pub fn toast_info(message: impl AsRef<str>) {
134 toast_leveled(message, ToastLevel::Info);
135}
136pub fn toast_warn(message: impl AsRef<str>) {
137 toast_leveled(message, ToastLevel::Warn);
138}
139pub fn toast_error(message: impl AsRef<str>) {
140 toast_leveled(message, ToastLevel::Error);
141}
142
143fn toast_leveled(message: impl AsRef<str>, level: ToastLevel) {
144 let payload = serde_json::json!({
145 "cmd": "toast",
146 "text": message.as_ref(),
147 "level": level.as_str(),
148 });
149 let _ = write_line(&payload);
150}
151
152pub fn toast_persistent(id: impl AsRef<str>, message: impl AsRef<str>, level: ToastLevel) {
156 let payload = serde_json::json!({
157 "cmd": "toast-persistent",
158 "id": id.as_ref(),
159 "text": message.as_ref(),
160 "level": level.as_str(),
161 });
162 let _ = write_line(&payload);
163}
164
165pub fn toast_dismiss(id: impl AsRef<str>) {
168 let payload = serde_json::json!({
169 "cmd": "toast-dismiss",
170 "id": id.as_ref(),
171 });
172 let _ = write_line(&payload);
173}
174
175pub fn progress_start(id: impl AsRef<str>, label: impl AsRef<str>) {
179 let payload = serde_json::json!({
180 "cmd": "progress-start",
181 "id": id.as_ref(),
182 "text": label.as_ref(),
183 });
184 let _ = write_line(&payload);
185}
186
187pub fn progress_update(id: impl AsRef<str>, label: Option<&str>, percent: Option<u8>) {
191 let mut m = serde_json::Map::new();
192 m.insert("cmd".to_string(), serde_json::json!("progress-update"));
193 m.insert("id".to_string(), serde_json::json!(id.as_ref()));
194 if let Some(l) = label {
195 m.insert("text".to_string(), serde_json::json!(l));
196 }
197 if let Some(p) = percent {
198 m.insert("count".to_string(), serde_json::json!(p));
199 }
200 let _ = write_line(&serde_json::Value::Object(m));
201}
202
203pub fn progress_end(id: impl AsRef<str>, status: ProgressStatus) {
207 let payload = serde_json::json!({
208 "cmd": "progress-end",
209 "id": id.as_ref(),
210 "text": status.as_str(),
211 });
212 let _ = write_line(&payload);
213}
214
215#[allow(clippy::too_many_arguments)]
219pub fn statusline_set_segment(
220 id: impl AsRef<str>,
221 side: SegmentSide,
222 text: impl AsRef<str>,
223 color: Option<&str>,
224 click_command: Option<&str>,
225 priority: u8,
226 min_width: u16,
227 max_width: u16,
228) {
229 let mut m = serde_json::Map::new();
230 m.insert(
231 "cmd".to_string(),
232 serde_json::json!("statusline-set-segment"),
233 );
234 m.insert("id".to_string(), serde_json::json!(id.as_ref()));
235 m.insert("side".to_string(), serde_json::json!(side.as_str()));
236 m.insert("text".to_string(), serde_json::json!(text.as_ref()));
237 if let Some(c) = color {
238 m.insert("color".to_string(), serde_json::json!(c));
239 }
240 if let Some(c) = click_command {
241 m.insert("click_command".to_string(), serde_json::json!(c));
242 }
243 m.insert("priority".to_string(), serde_json::json!(priority));
244 m.insert("min_width".to_string(), serde_json::json!(min_width));
245 m.insert("max_width".to_string(), serde_json::json!(max_width));
246 let _ = write_line(&serde_json::Value::Object(m));
247}
248
249pub fn statusline_clear_segment(id: impl AsRef<str>) {
251 let payload = serde_json::json!({
252 "cmd": "statusline-clear-segment",
253 "id": id.as_ref(),
254 });
255 let _ = write_line(&payload);
256}
257
258pub fn notify(title: impl AsRef<str>, body: impl AsRef<str>, opts: NotifyOpts) {
267 let mut m = serde_json::Map::new();
268 m.insert("cmd".to_string(), serde_json::json!("notify"));
269 m.insert("title".to_string(), serde_json::json!(title.as_ref()));
270 m.insert("text".to_string(), serde_json::json!(body.as_ref()));
271 m.insert("level".to_string(), serde_json::json!(opts.level.as_str()));
272 if opts.sound {
273 m.insert("sound".to_string(), serde_json::json!(true));
274 }
275 if let Some(src) = &opts.source {
276 m.insert("source".to_string(), serde_json::json!(src));
277 }
278 let _ = write_line(&serde_json::Value::Object(m));
279}
280
281pub fn register_command(
291 id: impl AsRef<str>,
292 title: impl AsRef<str>,
293 group: Option<&str>,
294 keys: &[&str],
295) {
296 let payload = serde_json::json!({
297 "cmd": "register-command",
298 "id": id.as_ref(),
299 "title": title.as_ref(),
300 "group": group.unwrap_or("plugin"),
301 "keys": keys,
302 });
303 let _ = write_line(&payload);
304}
305
306fn command_file() -> Option<PathBuf> {
307 let dir = std::env::var_os("MNML_IPC_DIR")?;
308 let mut p = PathBuf::from(dir);
309 p.push("command");
310 Some(p)
311}
312
313fn write_line(value: &serde_json::Value) -> std::io::Result<()> {
314 let path = command_file().ok_or_else(|| {
315 std::io::Error::new(
316 std::io::ErrorKind::NotFound,
317 "MNML_IPC_DIR not set — not spawned by mnml",
318 )
319 })?;
320 write_line_to(&path, value)
321}
322
323#[doc(hidden)]
327pub fn write_line_to(path: &std::path::Path, value: &serde_json::Value) -> std::io::Result<()> {
328 let line = serde_json::to_string(value)?;
329 let mut f = OpenOptions::new().create(true).append(true).open(path)?;
330 f.write_all(line.as_bytes())?;
331 f.write_all(b"\n")?;
332 Ok(())
333}
334
335#[doc(hidden)]
340pub fn toast_payload(message: &str) -> serde_json::Value {
341 serde_json::json!({ "cmd": "toast", "text": message })
342}
343
344#[doc(hidden)]
345pub fn set_activity_badge_payload(section: &str, count: u32) -> serde_json::Value {
346 serde_json::json!({
347 "cmd": "set-activity-badge",
348 "section": section,
349 "count": count,
350 })
351}
352
353#[doc(hidden)]
354pub fn register_command_payload(
355 id: &str,
356 title: &str,
357 group: Option<&str>,
358 keys: &[&str],
359) -> serde_json::Value {
360 serde_json::json!({
361 "cmd": "register-command",
362 "id": id,
363 "title": title,
364 "group": group.unwrap_or("plugin"),
365 "keys": keys,
366 })
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use std::io::Read;
373
374 fn read_file(path: &std::path::Path) -> String {
375 let mut s = String::new();
376 let mut f = std::fs::File::open(path).unwrap();
377 f.read_to_string(&mut s).unwrap();
378 s
379 }
380
381 #[test]
382 fn toast_payload_shape() {
383 let v = toast_payload("hello world");
384 let s = serde_json::to_string(&v).unwrap();
385 assert!(s.contains("\"cmd\":\"toast\""));
386 assert!(s.contains("\"text\":\"hello world\""));
387 }
388
389 #[test]
390 fn set_activity_badge_payload_shape() {
391 let v = set_activity_badge_payload("agents", 5);
392 let s = serde_json::to_string(&v).unwrap();
393 assert!(s.contains("\"cmd\":\"set-activity-badge\""));
394 assert!(s.contains("\"section\":\"agents\""));
395 assert!(s.contains("\"count\":5"));
396 }
397
398 #[test]
399 fn register_command_payload_default_group() {
400 let v = register_command_payload("plug.open", "Open Plug", None, &["ctrl+shift+p"]);
401 let s = serde_json::to_string(&v).unwrap();
402 assert!(s.contains("\"cmd\":\"register-command\""));
403 assert!(s.contains("\"id\":\"plug.open\""));
404 assert!(s.contains("\"title\":\"Open Plug\""));
405 assert!(s.contains("\"group\":\"plugin\""));
406 assert!(s.contains("\"keys\":[\"ctrl+shift+p\"]"));
407 }
408
409 #[test]
410 fn register_command_payload_custom_group_and_multi_key() {
411 let v =
412 register_command_payload("plug.split", "Split", Some("view"), &["ctrl+alt+s", "F5"]);
413 let s = serde_json::to_string(&v).unwrap();
414 assert!(s.contains("\"group\":\"view\""));
415 assert!(s.contains("\"keys\":[\"ctrl+alt+s\",\"F5\"]"));
416 }
417
418 #[test]
419 fn write_line_appends_newline() {
420 let tmp = tempfile::tempdir().unwrap();
421 let p = tmp.path().join("command");
422 write_line_to(&p, &toast_payload("one")).unwrap();
423 write_line_to(&p, &toast_payload("two")).unwrap();
424 let contents = read_file(&p);
425 let lines: Vec<&str> = contents.split_terminator('\n').collect();
426 assert_eq!(lines.len(), 2);
427 assert!(lines[0].contains("\"text\":\"one\""));
428 assert!(lines[1].contains("\"text\":\"two\""));
429 }
430
431 #[test]
432 fn silent_when_env_missing() {
433 let _ = toast_payload("safe");
439 }
440}