use serde::Serialize;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ToastLevel {
#[default]
Info,
Warn,
Error,
}
impl ToastLevel {
fn as_str(self) -> &'static str {
match self {
ToastLevel::Info => "info",
ToastLevel::Warn => "warn",
ToastLevel::Error => "error",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgressStatus {
Success,
Failed,
Cancelled,
}
impl ProgressStatus {
fn as_str(self) -> &'static str {
match self {
ProgressStatus::Success => "success",
ProgressStatus::Failed => "failed",
ProgressStatus::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SegmentSide {
Left,
#[default]
Right,
}
impl SegmentSide {
fn as_str(self) -> &'static str {
match self {
SegmentSide::Left => "left",
SegmentSide::Right => "right",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NotifyOpts {
pub level: ToastLevel,
pub sound: bool,
pub source: Option<String>,
}
pub fn toast(message: impl AsRef<str>) {
let payload = serde_json::json!({
"cmd": "toast",
"text": message.as_ref(),
});
let _ = write_line(&payload);
}
pub fn set_activity_badge(section: impl AsRef<str>, count: u32) {
let payload = serde_json::json!({
"cmd": "set-activity-badge",
"section": section.as_ref(),
"count": count,
});
let _ = write_line(&payload);
}
pub fn toast_info(message: impl AsRef<str>) {
toast_leveled(message, ToastLevel::Info);
}
pub fn toast_warn(message: impl AsRef<str>) {
toast_leveled(message, ToastLevel::Warn);
}
pub fn toast_error(message: impl AsRef<str>) {
toast_leveled(message, ToastLevel::Error);
}
fn toast_leveled(message: impl AsRef<str>, level: ToastLevel) {
let payload = serde_json::json!({
"cmd": "toast",
"text": message.as_ref(),
"level": level.as_str(),
});
let _ = write_line(&payload);
}
pub fn toast_persistent(id: impl AsRef<str>, message: impl AsRef<str>, level: ToastLevel) {
let payload = serde_json::json!({
"cmd": "toast-persistent",
"id": id.as_ref(),
"text": message.as_ref(),
"level": level.as_str(),
});
let _ = write_line(&payload);
}
pub fn toast_dismiss(id: impl AsRef<str>) {
let payload = serde_json::json!({
"cmd": "toast-dismiss",
"id": id.as_ref(),
});
let _ = write_line(&payload);
}
pub fn progress_start(id: impl AsRef<str>, label: impl AsRef<str>) {
let payload = serde_json::json!({
"cmd": "progress-start",
"id": id.as_ref(),
"text": label.as_ref(),
});
let _ = write_line(&payload);
}
pub fn progress_update(id: impl AsRef<str>, label: Option<&str>, percent: Option<u8>) {
let mut m = serde_json::Map::new();
m.insert("cmd".to_string(), serde_json::json!("progress-update"));
m.insert("id".to_string(), serde_json::json!(id.as_ref()));
if let Some(l) = label {
m.insert("text".to_string(), serde_json::json!(l));
}
if let Some(p) = percent {
m.insert("count".to_string(), serde_json::json!(p));
}
let _ = write_line(&serde_json::Value::Object(m));
}
pub fn progress_end(id: impl AsRef<str>, status: ProgressStatus) {
let payload = serde_json::json!({
"cmd": "progress-end",
"id": id.as_ref(),
"text": status.as_str(),
});
let _ = write_line(&payload);
}
#[allow(clippy::too_many_arguments)]
pub fn statusline_set_segment(
id: impl AsRef<str>,
side: SegmentSide,
text: impl AsRef<str>,
color: Option<&str>,
click_command: Option<&str>,
priority: u8,
min_width: u16,
max_width: u16,
) {
let mut m = serde_json::Map::new();
m.insert(
"cmd".to_string(),
serde_json::json!("statusline-set-segment"),
);
m.insert("id".to_string(), serde_json::json!(id.as_ref()));
m.insert("side".to_string(), serde_json::json!(side.as_str()));
m.insert("text".to_string(), serde_json::json!(text.as_ref()));
if let Some(c) = color {
m.insert("color".to_string(), serde_json::json!(c));
}
if let Some(c) = click_command {
m.insert("click_command".to_string(), serde_json::json!(c));
}
m.insert("priority".to_string(), serde_json::json!(priority));
m.insert("min_width".to_string(), serde_json::json!(min_width));
m.insert("max_width".to_string(), serde_json::json!(max_width));
let _ = write_line(&serde_json::Value::Object(m));
}
pub fn statusline_clear_segment(id: impl AsRef<str>) {
let payload = serde_json::json!({
"cmd": "statusline-clear-segment",
"id": id.as_ref(),
});
let _ = write_line(&payload);
}
pub fn notify(title: impl AsRef<str>, body: impl AsRef<str>, opts: NotifyOpts) {
let mut m = serde_json::Map::new();
m.insert("cmd".to_string(), serde_json::json!("notify"));
m.insert("title".to_string(), serde_json::json!(title.as_ref()));
m.insert("text".to_string(), serde_json::json!(body.as_ref()));
m.insert("level".to_string(), serde_json::json!(opts.level.as_str()));
if opts.sound {
m.insert("sound".to_string(), serde_json::json!(true));
}
if let Some(src) = &opts.source {
m.insert("source".to_string(), serde_json::json!(src));
}
let _ = write_line(&serde_json::Value::Object(m));
}
pub fn register_command(
id: impl AsRef<str>,
title: impl AsRef<str>,
group: Option<&str>,
keys: &[&str],
) {
let payload = serde_json::json!({
"cmd": "register-command",
"id": id.as_ref(),
"title": title.as_ref(),
"group": group.unwrap_or("plugin"),
"keys": keys,
});
let _ = write_line(&payload);
}
fn command_file() -> Option<PathBuf> {
let dir = std::env::var_os("MNML_IPC_DIR")?;
let mut p = PathBuf::from(dir);
p.push("command");
Some(p)
}
fn write_line(value: &serde_json::Value) -> std::io::Result<()> {
let path = command_file().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"MNML_IPC_DIR not set — not spawned by mnml",
)
})?;
write_line_to(&path, value)
}
#[doc(hidden)]
pub fn write_line_to(path: &std::path::Path, value: &serde_json::Value) -> std::io::Result<()> {
let line = serde_json::to_string(value)?;
let mut f = OpenOptions::new().create(true).append(true).open(path)?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
Ok(())
}
#[doc(hidden)]
pub fn toast_payload(message: &str) -> serde_json::Value {
serde_json::json!({ "cmd": "toast", "text": message })
}
#[doc(hidden)]
pub fn set_activity_badge_payload(section: &str, count: u32) -> serde_json::Value {
serde_json::json!({
"cmd": "set-activity-badge",
"section": section,
"count": count,
})
}
#[doc(hidden)]
pub fn register_command_payload(
id: &str,
title: &str,
group: Option<&str>,
keys: &[&str],
) -> serde_json::Value {
serde_json::json!({
"cmd": "register-command",
"id": id,
"title": title,
"group": group.unwrap_or("plugin"),
"keys": keys,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn read_file(path: &std::path::Path) -> String {
let mut s = String::new();
let mut f = std::fs::File::open(path).unwrap();
f.read_to_string(&mut s).unwrap();
s
}
#[test]
fn toast_payload_shape() {
let v = toast_payload("hello world");
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"cmd\":\"toast\""));
assert!(s.contains("\"text\":\"hello world\""));
}
#[test]
fn set_activity_badge_payload_shape() {
let v = set_activity_badge_payload("agents", 5);
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"cmd\":\"set-activity-badge\""));
assert!(s.contains("\"section\":\"agents\""));
assert!(s.contains("\"count\":5"));
}
#[test]
fn register_command_payload_default_group() {
let v = register_command_payload("plug.open", "Open Plug", None, &["ctrl+shift+p"]);
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"cmd\":\"register-command\""));
assert!(s.contains("\"id\":\"plug.open\""));
assert!(s.contains("\"title\":\"Open Plug\""));
assert!(s.contains("\"group\":\"plugin\""));
assert!(s.contains("\"keys\":[\"ctrl+shift+p\"]"));
}
#[test]
fn register_command_payload_custom_group_and_multi_key() {
let v =
register_command_payload("plug.split", "Split", Some("view"), &["ctrl+alt+s", "F5"]);
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"group\":\"view\""));
assert!(s.contains("\"keys\":[\"ctrl+alt+s\",\"F5\"]"));
}
#[test]
fn write_line_appends_newline() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("command");
write_line_to(&p, &toast_payload("one")).unwrap();
write_line_to(&p, &toast_payload("two")).unwrap();
let contents = read_file(&p);
let lines: Vec<&str> = contents.split_terminator('\n').collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"text\":\"one\""));
assert!(lines[1].contains("\"text\":\"two\""));
}
#[test]
fn silent_when_env_missing() {
let _ = toast_payload("safe");
}
}