use serde::Serialize;
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize)]
pub struct IntegrationSpec {
pub id: String,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub binary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chip: Option<ChipSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<CommandSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_menu: Vec<ContextMenuEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub menu_bar: Vec<MenuBarEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub statusline: Option<StatuslineSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub settings: Vec<SettingsPage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notifications: Option<NotificationsSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires: Option<Requires>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ChipSpec {
pub glyph: String,
pub fallback: String,
pub color: String,
pub enabled: bool,
pub in_palette_bar: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub badge_key: Option<String>,
#[serde(skip)]
#[serde(default)]
pub glyph_svg_bytes: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub glyph_codepoint: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CommandSpec {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<String>,
pub run: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContextMenuEntry {
pub target: String,
pub title: String,
pub command: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MenuBarEntry {
pub path: String,
pub command: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatuslineSpec {
pub side: String,
pub segment_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub initial_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub click_command: Option<String>,
pub priority: u8,
pub min_width: u16,
pub max_width: u16,
}
#[derive(Debug, Clone, Serialize)]
pub struct SettingsPage {
pub section: String,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OsNotifyPolicy {
#[default]
Never,
ErrorOnly,
Always,
}
#[derive(Debug, Clone, Serialize)]
pub struct NotificationsSpec {
pub os_notify_on: OsNotifyPolicy,
pub os_rate_limit_sec: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Requires {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary: Option<String>,
}
pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
validate_id(&spec.id)?;
let dir = user_integration_dir()?;
fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.toml", spec.id));
let toml = toml_serialize(spec)?;
fs::write(&path, toml)?;
if let Some(chip) = &spec.chip
&& let Some(bytes) = chip.glyph_svg_bytes.as_deref()
{
match write_pending_glyph(&spec.id, bytes) {
Ok(dest) => eprintln!(
"mnml-bridge: queued glyph → {} (mnml bakes + deletes on next startup)",
dest.display()
),
Err(e) => eprintln!(
"mnml-bridge: WARN failed to queue glyph for {}: {e}",
spec.id
),
}
}
Ok(path)
}
fn write_pending_glyph(id: &str, bytes: &[u8]) -> io::Result<PathBuf> {
validate_id(id)?;
let dir = pending_glyphs_dir()?;
fs::create_dir_all(&dir)?;
let dest = dir.join(format!("{id}.svg"));
fs::write(&dest, bytes)?;
Ok(dest)
}
pub fn pending_glyphs_dir() -> io::Result<PathBuf> {
let home = std::env::var_os("HOME")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
Ok(PathBuf::from(home)
.join(".cache")
.join("mnml")
.join("pending-glyphs"))
}
pub fn uninstall_integration(id: &str) -> io::Result<bool> {
validate_id(id)?;
let path = integration_manifest_path(id)?;
if let Ok(pending) = pending_glyphs_dir() {
let _ = fs::remove_file(pending.join(format!("{id}.svg")));
}
match fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
pub fn list_installed_integrations() -> io::Result<Vec<String>> {
let dir = user_integration_dir()?;
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out: Vec<String> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(id) = name.strip_suffix(".toml")
&& !id.is_empty()
{
out.push(id.to_string());
}
}
out.sort();
Ok(out)
}
pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
validate_id(id)?;
Ok(user_integration_dir()?.join(format!("{id}.toml")))
}
fn user_integration_dir() -> io::Result<PathBuf> {
let home = std::env::var_os("HOME")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
Ok(PathBuf::from(home)
.join(".config")
.join("mnml")
.join("integrations"))
}
fn validate_id(id: &str) -> io::Result<()> {
if id.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
}
if id.contains(['/', '\\', '\0']) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("id contains path characters: {id}"),
));
}
Ok(())
}
fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
let json = serde_json::to_value(v)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
Ok(json_to_toml(&json))
}
fn json_to_toml(v: &serde_json::Value) -> String {
let mut out = String::new();
let Some(map) = v.as_object() else {
return out;
};
for (k, val) in map {
if val.is_object() || val.is_array() {
continue;
}
push_kv(&mut out, k, val);
}
for (k, val) in map {
match val {
serde_json::Value::Object(_) => {
out.push_str(&format!("\n[{k}]\n"));
for (inner_k, inner_v) in val.as_object().unwrap() {
if inner_v.is_object() || inner_v.is_array() {
continue;
}
push_kv(&mut out, inner_k, inner_v);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
if let Some(obj) = item.as_object() {
out.push_str(&format!("\n[[{k}]]\n"));
for (inner_k, inner_v) in obj {
push_kv(&mut out, inner_k, inner_v);
}
}
}
}
_ => {}
}
}
out
}
fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
match v {
serde_json::Value::String(s) => {
out.push_str(&format!("{k} = {}\n", toml_str(s)));
}
serde_json::Value::Number(n) => {
out.push_str(&format!("{k} = {n}\n"));
}
serde_json::Value::Bool(b) => {
out.push_str(&format!("{k} = {b}\n"));
}
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr
.iter()
.filter_map(|x| x.as_str().map(toml_str))
.collect();
out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
}
_ => {}
}
}
fn toml_str(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::*;
fn home_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[test]
fn validate_id_rejects_dangerous_chars() {
assert!(validate_id("").is_err());
assert!(validate_id("../foo").is_err());
assert!(validate_id("a/b").is_err());
assert!(validate_id("a\\b").is_err());
assert!(validate_id("valid_id-123").is_ok());
}
#[test]
fn serializes_minimal_spec_to_toml() {
let spec = IntegrationSpec {
id: "slack".into(),
label: "Slack".into(),
binary: "mnml-msg-slack".into(),
..Default::default()
};
let toml = toml_serialize(&spec).unwrap();
assert!(toml.contains("id = \"slack\""));
assert!(toml.contains("label = \"Slack\""));
assert!(toml.contains("binary = \"mnml-msg-slack\""));
}
#[test]
fn serializes_full_spec_with_chip_and_commands() {
let spec = IntegrationSpec {
id: "slack".into(),
label: "Slack".into(),
binary: "mnml-msg-slack".into(),
chip: Some(ChipSpec {
glyph: "S".into(),
fallback: "Sk".into(),
color: "purple".into(),
enabled: true,
in_palette_bar: false,
badge_key: None,
glyph_svg_bytes: None,
glyph_codepoint: None,
}),
commands: vec![CommandSpec {
id: "slack.open".into(),
title: "Slack: open".into(),
group: Some("integrations".into()),
keys: vec!["<leader>iS".into()],
run: ":term mnml-msg-slack".into(),
}],
..Default::default()
};
let toml = toml_serialize(&spec).unwrap();
assert!(toml.contains("[chip]"));
assert!(toml.contains("glyph = \"S\""));
assert!(toml.contains("[[commands]]"));
assert!(toml.contains("id = \"slack.open\""));
assert!(toml.contains("keys = [\"<leader>iS\"]"));
}
#[test]
fn glyph_codepoint_serializes_when_set() {
let spec = IntegrationSpec {
id: "amplify".into(),
label: "Amplify".into(),
binary: "mnml-aws-amplify".into(),
chip: Some(ChipSpec {
glyph: "\u{F1B00}".into(),
fallback: "Am".into(),
color: "purple".into(),
enabled: true,
in_palette_bar: false,
badge_key: None,
glyph_svg_bytes: None,
glyph_codepoint: Some("F1B00".into()),
}),
..Default::default()
};
let toml = toml_serialize(&spec).unwrap();
assert!(toml.contains("glyph_codepoint = \"F1B00\""));
assert!(!toml.contains("glyph_svg_bytes"));
}
#[test]
fn install_writes_glyph_bytes_to_pending_dir() {
let _lk = home_lock().lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("HOME", tmp.path()) };
let spec = IntegrationSpec {
id: "amplify".into(),
label: "Amplify".into(),
binary: "mnml-aws-amplify".into(),
chip: Some(ChipSpec {
glyph: "A".into(),
fallback: "Am".into(),
color: "purple".into(),
enabled: true,
in_palette_bar: false,
badge_key: None,
glyph_svg_bytes: Some(b"<svg/>".to_vec()),
glyph_codepoint: Some("F1B00".into()),
}),
..Default::default()
};
install_integration(&spec).unwrap();
let dest = pending_glyphs_dir().unwrap().join("amplify.svg");
assert!(dest.exists(), "glyph SVG bytes should land at {dest:?}");
assert_eq!(fs::read(&dest).unwrap(), b"<svg/>");
uninstall_integration("amplify").unwrap();
assert!(
!dest.exists(),
"pending glyph SVG should be removed on uninstall"
);
}
#[test]
fn install_survives_missing_glyph_svg_source() {
let _lk = home_lock().lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("HOME", tmp.path()) };
let spec = IntegrationSpec {
id: "broken".into(),
label: "Broken".into(),
binary: "mnml-broken".into(),
chip: Some(ChipSpec {
glyph: "B".into(),
fallback: "Br".into(),
color: "red".into(),
enabled: true,
in_palette_bar: false,
badge_key: None,
glyph_svg_bytes: None,
glyph_codepoint: None,
}),
..Default::default()
};
install_integration(&spec).unwrap();
let manifest = integration_manifest_path("broken").unwrap();
assert!(manifest.exists());
}
#[test]
fn install_and_uninstall_round_trip() {
let _lk = home_lock().lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("HOME", tmp.path()) };
let spec = IntegrationSpec {
id: "roundtrip".into(),
label: "Round Trip".into(),
binary: "mnml-rt".into(),
..Default::default()
};
let p = install_integration(&spec).unwrap();
assert!(p.exists());
assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
let ids = list_installed_integrations().unwrap();
assert!(ids.contains(&"roundtrip".to_string()));
let removed = uninstall_integration("roundtrip").unwrap();
assert!(removed);
assert!(!p.exists());
let removed2 = uninstall_integration("roundtrip").unwrap();
assert!(!removed2);
}
}