Skip to main content

mnml_bridge/
install.rs

1//! Integration manifest install helpers — sibling-authored
2//! self-registration for the rail chip, palette commands, chord
3//! bindings, context menu additions, menu-bar entries,
4//! statusline segments, settings pages, and OS notification
5//! policy. Writes a single TOML file per integration:
6//!
7//!   `~/.config/mnml/integrations/<id>.toml`
8//!
9//! mnml picks the file up on startup + on the
10//! `integrations.refresh` palette command. Uninstall = delete
11//! the file. No IPC required — the fs is the interface.
12//!
13//! ```no_run
14//! use mnml_bridge::install::{
15//!     ChipSpec, CommandSpec, IntegrationSpec, install_integration,
16//! };
17//!
18//! install_integration(&IntegrationSpec {
19//!     id: "slack".into(),
20//!     label: "Slack".into(),
21//!     description: Some("Slack browse + post".into()),
22//!     version: Some(env!("CARGO_PKG_VERSION").into()),
23//!     binary: "mnml-msg-slack".into(),
24//!     category: Some("msg".into()),
25//!     chip: Some(ChipSpec {
26//!         glyph: "\u{F0839}".into(),
27//!         fallback: "Sk".into(),
28//!         color: "purple".into(),
29//!         enabled: true,
30//!         in_palette_bar: false,
31//!         badge_key: Some("slack".into()),
32//!         ..Default::default()
33//!     }),
34//!     commands: vec![CommandSpec {
35//!         id: "slack.open".into(),
36//!         title: "Slack: open".into(),
37//!         group: Some("integrations".into()),
38//!         keys: vec!["<leader>iS".into()],
39//!         run: ":term mnml-msg-slack".into(),
40//!     }],
41//!     ..Default::default()
42//! }).ok();
43//! ```
44
45use serde::Serialize;
46use std::fs;
47use std::io;
48use std::path::PathBuf;
49
50/// Complete integration description written to the manifest
51/// file. Only `id`, `label`, and `binary` are required —
52/// everything else defaults to sensible empty values.
53///
54/// 2026-08-01 — the identity strings live here at the top level:
55///   * `label` — short display name (chip hover, tree row, picker,
56///     detail-pane header). Required. ~20 chars max.
57///   * `description` — one-sentence longer form for the detail
58///     pane subtitle. ~80 chars.
59///
60/// The old `name` field was dead code (never rendered); dropped.
61/// The old `chip.tooltip` field is folded into top-level `label`.
62#[derive(Debug, Clone, Default, Serialize)]
63pub struct IntegrationSpec {
64    pub id: String,
65    pub label: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub version: Option<String>,
70    pub binary: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub category: Option<String>,
73
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub chip: Option<ChipSpec>,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub commands: Vec<CommandSpec>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub context_menu: Vec<ContextMenuEntry>,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub menu_bar: Vec<MenuBarEntry>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub statusline: Option<StatuslineSpec>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub settings: Vec<SettingsPage>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub notifications: Option<NotificationsSpec>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub requires: Option<Requires>,
90}
91
92/// Visual + interaction settings for the sibling's chip. Display
93/// strings (label, description) live at `IntegrationSpec` top
94/// level, not here — the chip is about rendering, not identity.
95#[derive(Debug, Clone, Default, Serialize)]
96pub struct ChipSpec {
97    pub glyph: String,
98    pub fallback: String,
99    pub color: String,
100    pub enabled: bool,
101    pub in_palette_bar: bool,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub badge_key: Option<String>,
104    /// SVG bytes for the integration's icon — typically produced
105    /// by `include_bytes!("assets/icons/<id>.svg").to_vec()` at the
106    /// integration binary's build time.
107    ///
108    /// On [`install_integration`], the bytes are written to
109    /// `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml bakes any
110    /// pending SVGs into `~/Library/Fonts/MnmlSymbols.ttf` at the
111    /// next startup and DELETES the pending file so there's no
112    /// permanent glyph state under `~/.config/mnml/`. `glyph_codepoint`
113    /// (if set) pins the codepoint the integration wants; otherwise
114    /// mnml auto-assigns from the `U+F1C00–U+F1CFF` range.
115    ///
116    /// Never serialized to the manifest TOML — bytes are consumed
117    /// at install time and discarded.
118    #[serde(skip)]
119    #[serde(default)]
120    pub glyph_svg_bytes: Option<Vec<u8>>,
121    /// Optional explicit codepoint the sibling wants (uppercase
122    /// hex, no `U+` prefix — e.g. `"F1C05"`). When set, mnml uses
123    /// this codepoint verbatim for the sibling's SVG bake instead
124    /// of auto-assigning one from the sibling PUA range
125    /// (`U+F1C00–U+F1CFF`). Useful for migration cases where a
126    /// sibling wants to keep the codepoint mnml core baked before
127    /// this SDK feature landed. Trusted — no range validation
128    /// beyond "parses as u32"; the manifest author is expected to
129    /// stay inside mnml's PUA layout documented in
130    /// `src/icon_catalog.rs`.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub glyph_codepoint: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct CommandSpec {
137    pub id: String,
138    pub title: String,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub group: Option<String>,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub keys: Vec<String>,
143    pub run: String,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct ContextMenuEntry {
148    /// `tree.file` | `tree.dir` | `tab` | `agent.row` | `pane`.
149    pub target: String,
150    pub title: String,
151    pub command: String,
152}
153
154#[derive(Debug, Clone, Serialize)]
155pub struct MenuBarEntry {
156    /// Slash-separated path like `"File > Send via Slack"`.
157    pub path: String,
158    pub command: String,
159}
160
161#[derive(Debug, Clone, Serialize)]
162pub struct StatuslineSpec {
163    /// `"left"` | `"right"`.
164    pub side: String,
165    pub segment_id: String,
166    #[serde(skip_serializing_if = "String::is_empty")]
167    pub initial_text: String,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub initial_color: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub click_command: Option<String>,
172    pub priority: u8,
173    pub min_width: u16,
174    pub max_width: u16,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct SettingsPage {
179    pub section: String,
180    pub label: String,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub help: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, Default, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub enum OsNotifyPolicy {
188    #[default]
189    Never,
190    ErrorOnly,
191    Always,
192}
193
194#[derive(Debug, Clone, Serialize)]
195pub struct NotificationsSpec {
196    pub os_notify_on: OsNotifyPolicy,
197    pub os_rate_limit_sec: u64,
198}
199
200#[derive(Debug, Clone, Serialize)]
201pub struct Requires {
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub env: Vec<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub binary: Option<String>,
206}
207
208// ── Filesystem operations ─────────────────────────────
209
210/// Serialize `spec` and write to
211/// `~/.config/mnml/integrations/<id>.toml`. Creates the parent
212/// directory if needed. Overwrites any existing file with the
213/// same id. Returns the path written.
214///
215/// If `spec.chip.glyph_svg_bytes` is set, writes the bytes to
216/// `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml bakes on next
217/// startup + deletes the pending file. Nothing persistent under
218/// `~/.config/mnml/`.
219///
220/// Fails if `spec.id` contains `/` or `\` (dir traversal
221/// protection), or if the manifest fs write itself fails.
222pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
223    validate_id(&spec.id)?;
224    let dir = user_integration_dir()?;
225    fs::create_dir_all(&dir)?;
226    let path = dir.join(format!("{}.toml", spec.id));
227    let toml = toml_serialize(spec)?;
228    fs::write(&path, toml)?;
229    if let Some(chip) = &spec.chip
230        && let Some(bytes) = chip.glyph_svg_bytes.as_deref()
231    {
232        match write_pending_glyph(&spec.id, bytes) {
233            Ok(dest) => eprintln!(
234                "mnml-bridge: queued glyph → {} (mnml bakes + deletes on next startup)",
235                dest.display()
236            ),
237            Err(e) => eprintln!(
238                "mnml-bridge: WARN failed to queue glyph for {}: {e}",
239                spec.id
240            ),
241        }
242    }
243    Ok(path)
244}
245
246/// Dump `bytes` to `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml's
247/// startup path bakes these into `MnmlSymbols.ttf`, then deletes
248/// the pending file. Nothing lands under `~/.config/mnml/glyphs/`.
249fn write_pending_glyph(id: &str, bytes: &[u8]) -> io::Result<PathBuf> {
250    validate_id(id)?;
251    let dir = pending_glyphs_dir()?;
252    fs::create_dir_all(&dir)?;
253    let dest = dir.join(format!("{id}.svg"));
254    fs::write(&dest, bytes)?;
255    Ok(dest)
256}
257
258/// `~/.cache/mnml/pending-glyphs/` — handoff location for
259/// integration-shipped SVGs. mnml bakes + deletes at startup.
260/// Nothing here is expected to persist across a launch cycle.
261pub fn pending_glyphs_dir() -> io::Result<PathBuf> {
262    let home = std::env::var_os("HOME")
263        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
264    Ok(PathBuf::from(home)
265        .join(".cache")
266        .join("mnml")
267        .join("pending-glyphs"))
268}
269
270/// Delete the manifest at `~/.config/mnml/integrations/<id>.toml`.
271/// Returns `Ok(true)` if the file was removed, `Ok(false)` if
272/// the file didn't exist (already uninstalled). Fails on other
273/// fs errors.
274pub fn uninstall_integration(id: &str) -> io::Result<bool> {
275    validate_id(id)?;
276    let path = integration_manifest_path(id)?;
277    // Drop any leftover pending-glyph SVG for this id (rare — the
278    // startup auto-purge already deletes baked ones, but a fresh
279    // install that hasn't been baked yet would still have the file).
280    // The codepoint assignment persists in
281    // `~/.config/mnml/integration-glyphs.toml` so re-installing later
282    // gets the same codepoint back.
283    if let Ok(pending) = pending_glyphs_dir() {
284        let _ = fs::remove_file(pending.join(format!("{id}.svg")));
285    }
286    match fs::remove_file(&path) {
287        Ok(()) => Ok(true),
288        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
289        Err(e) => Err(e),
290    }
291}
292
293/// List installed integrations by id — reads the manifest
294/// directory + strips the `.toml` suffix. Returns an empty vec
295/// if the dir doesn't exist.
296pub fn list_installed_integrations() -> io::Result<Vec<String>> {
297    let dir = user_integration_dir()?;
298    let entries = match fs::read_dir(&dir) {
299        Ok(e) => e,
300        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
301        Err(e) => return Err(e),
302    };
303    let mut out: Vec<String> = Vec::new();
304    for entry in entries.flatten() {
305        let name = entry.file_name();
306        let Some(name) = name.to_str() else { continue };
307        if let Some(id) = name.strip_suffix(".toml")
308            && !id.is_empty()
309        {
310            out.push(id.to_string());
311        }
312    }
313    out.sort();
314    Ok(out)
315}
316
317/// Path to a specific integration's manifest file. Doesn't check
318/// whether the file exists.
319pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
320    validate_id(id)?;
321    Ok(user_integration_dir()?.join(format!("{id}.toml")))
322}
323
324fn user_integration_dir() -> io::Result<PathBuf> {
325    let home = std::env::var_os("HOME")
326        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
327    Ok(PathBuf::from(home)
328        .join(".config")
329        .join("mnml")
330        .join("integrations"))
331}
332
333fn validate_id(id: &str) -> io::Result<()> {
334    if id.is_empty() {
335        return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
336    }
337    if id.contains(['/', '\\', '\0']) {
338        return Err(io::Error::new(
339            io::ErrorKind::InvalidInput,
340            format!("id contains path characters: {id}"),
341        ));
342    }
343    Ok(())
344}
345
346fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
347    // Use serde_json → toml conversion since we don't ship the
348    // toml crate as a dep (keeps mnml-bridge's dep tree tight).
349    // Instead: format the manifest by hand for the common shape.
350    // For fidelity, we use serde_json and let the reader (mnml)
351    // parse the TOML directly. But since we're WRITING TOML, we
352    // need actual TOML serialization.
353    //
354    // The simplest path: use serde_json to reflect the struct,
355    // then hand-convert to TOML. Given the flat + list shape of
356    // IntegrationSpec, this is straightforward.
357    let json = serde_json::to_value(v)
358        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
359    Ok(json_to_toml(&json))
360}
361
362/// Best-effort JSON → TOML for the IntegrationSpec shape.
363/// Handles top-level scalar fields + nested tables +
364/// arrays-of-tables. Not a general JSON→TOML converter — but
365/// sufficient for the shapes this SDK emits.
366fn json_to_toml(v: &serde_json::Value) -> String {
367    let mut out = String::new();
368    let Some(map) = v.as_object() else {
369        return out;
370    };
371    // Emit top-level scalars first.
372    for (k, val) in map {
373        if val.is_object() || val.is_array() {
374            continue;
375        }
376        push_kv(&mut out, k, val);
377    }
378    // Then arrays-of-tables and tables.
379    for (k, val) in map {
380        match val {
381            serde_json::Value::Object(_) => {
382                out.push_str(&format!("\n[{k}]\n"));
383                for (inner_k, inner_v) in val.as_object().unwrap() {
384                    if inner_v.is_object() || inner_v.is_array() {
385                        continue;
386                    }
387                    push_kv(&mut out, inner_k, inner_v);
388                }
389            }
390            serde_json::Value::Array(arr) => {
391                for item in arr {
392                    if let Some(obj) = item.as_object() {
393                        out.push_str(&format!("\n[[{k}]]\n"));
394                        for (inner_k, inner_v) in obj {
395                            push_kv(&mut out, inner_k, inner_v);
396                        }
397                    }
398                }
399            }
400            _ => {}
401        }
402    }
403    out
404}
405
406fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
407    match v {
408        serde_json::Value::String(s) => {
409            out.push_str(&format!("{k} = {}\n", toml_str(s)));
410        }
411        serde_json::Value::Number(n) => {
412            out.push_str(&format!("{k} = {n}\n"));
413        }
414        serde_json::Value::Bool(b) => {
415            out.push_str(&format!("{k} = {b}\n"));
416        }
417        serde_json::Value::Array(arr) => {
418            let items: Vec<String> = arr
419                .iter()
420                .filter_map(|x| x.as_str().map(toml_str))
421                .collect();
422            out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
423        }
424        _ => {}
425    }
426}
427
428fn toml_str(s: &str) -> String {
429    // Basic TOML string escape — quote + escape backslash + quote.
430    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
431    format!("\"{escaped}\"")
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    // HOME-mutating tests share a single tempdir path. Rust runs
439    // tests in the same process on multiple threads by default, and
440    // set_var("HOME", …) leaks across threads — without a mutex,
441    // one test's tempdir can shadow another mid-run. Serialize
442    // every HOME-touching test through this lock.
443    fn home_lock() -> &'static std::sync::Mutex<()> {
444        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
445        LOCK.get_or_init(|| std::sync::Mutex::new(()))
446    }
447
448    #[test]
449    fn validate_id_rejects_dangerous_chars() {
450        assert!(validate_id("").is_err());
451        assert!(validate_id("../foo").is_err());
452        assert!(validate_id("a/b").is_err());
453        assert!(validate_id("a\\b").is_err());
454        assert!(validate_id("valid_id-123").is_ok());
455    }
456
457    #[test]
458    fn serializes_minimal_spec_to_toml() {
459        let spec = IntegrationSpec {
460            id: "slack".into(),
461            label: "Slack".into(),
462            binary: "mnml-msg-slack".into(),
463            ..Default::default()
464        };
465        let toml = toml_serialize(&spec).unwrap();
466        assert!(toml.contains("id = \"slack\""));
467        assert!(toml.contains("label = \"Slack\""));
468        assert!(toml.contains("binary = \"mnml-msg-slack\""));
469    }
470
471    #[test]
472    fn serializes_full_spec_with_chip_and_commands() {
473        let spec = IntegrationSpec {
474            id: "slack".into(),
475            label: "Slack".into(),
476            binary: "mnml-msg-slack".into(),
477            chip: Some(ChipSpec {
478                glyph: "S".into(),
479                fallback: "Sk".into(),
480                color: "purple".into(),
481                enabled: true,
482                in_palette_bar: false,
483                badge_key: None,
484                glyph_svg_bytes: None,
485                glyph_codepoint: None,
486            }),
487            commands: vec![CommandSpec {
488                id: "slack.open".into(),
489                title: "Slack: open".into(),
490                group: Some("integrations".into()),
491                keys: vec!["<leader>iS".into()],
492                run: ":term mnml-msg-slack".into(),
493            }],
494            ..Default::default()
495        };
496        let toml = toml_serialize(&spec).unwrap();
497        assert!(toml.contains("[chip]"));
498        assert!(toml.contains("glyph = \"S\""));
499        assert!(toml.contains("[[commands]]"));
500        assert!(toml.contains("id = \"slack.open\""));
501        assert!(toml.contains("keys = [\"<leader>iS\"]"));
502    }
503
504    #[test]
505    fn glyph_codepoint_serializes_when_set() {
506        let spec = IntegrationSpec {
507            id: "amplify".into(),
508            label: "Amplify".into(),
509            binary: "mnml-aws-amplify".into(),
510            chip: Some(ChipSpec {
511                glyph: "\u{F1B00}".into(),
512                fallback: "Am".into(),
513                color: "purple".into(),
514                enabled: true,
515                in_palette_bar: false,
516                badge_key: None,
517                glyph_svg_bytes: None,
518                glyph_codepoint: Some("F1B00".into()),
519            }),
520            ..Default::default()
521        };
522        let toml = toml_serialize(&spec).unwrap();
523        assert!(toml.contains("glyph_codepoint = \"F1B00\""));
524        // glyph_svg_bytes is #[serde(skip)] — must not appear in TOML.
525        assert!(!toml.contains("glyph_svg_bytes"));
526    }
527
528    #[test]
529    fn install_writes_glyph_bytes_to_pending_dir() {
530        let _lk = home_lock().lock().unwrap();
531        let tmp = tempfile::tempdir().unwrap();
532        unsafe { std::env::set_var("HOME", tmp.path()) };
533
534        let spec = IntegrationSpec {
535            id: "amplify".into(),
536            label: "Amplify".into(),
537            binary: "mnml-aws-amplify".into(),
538            chip: Some(ChipSpec {
539                glyph: "A".into(),
540                fallback: "Am".into(),
541                color: "purple".into(),
542                enabled: true,
543                in_palette_bar: false,
544                badge_key: None,
545                glyph_svg_bytes: Some(b"<svg/>".to_vec()),
546                glyph_codepoint: Some("F1B00".into()),
547            }),
548            ..Default::default()
549        };
550        install_integration(&spec).unwrap();
551
552        let dest = pending_glyphs_dir().unwrap().join("amplify.svg");
553        assert!(dest.exists(), "glyph SVG bytes should land at {dest:?}");
554        assert_eq!(fs::read(&dest).unwrap(), b"<svg/>");
555        // Uninstall removes the pending SVG alongside the manifest.
556        uninstall_integration("amplify").unwrap();
557        assert!(
558            !dest.exists(),
559            "pending glyph SVG should be removed on uninstall"
560        );
561    }
562
563    #[test]
564    fn install_survives_missing_glyph_svg_source() {
565        let _lk = home_lock().lock().unwrap();
566        let tmp = tempfile::tempdir().unwrap();
567        unsafe { std::env::set_var("HOME", tmp.path()) };
568
569        let spec = IntegrationSpec {
570            id: "broken".into(),
571            label: "Broken".into(),
572            binary: "mnml-broken".into(),
573            chip: Some(ChipSpec {
574                glyph: "B".into(),
575                fallback: "Br".into(),
576                color: "red".into(),
577                enabled: true,
578                in_palette_bar: false,
579                badge_key: None,
580                glyph_svg_bytes: None,
581                glyph_codepoint: None,
582            }),
583            ..Default::default()
584        };
585        // A chip with no glyph_svg_bytes is fine — the manifest
586        // still gets written so `--install` succeeds even when the
587        // sibling packager forgot to bundle the SVG.
588        install_integration(&spec).unwrap();
589        let manifest = integration_manifest_path("broken").unwrap();
590        assert!(manifest.exists());
591    }
592
593    #[test]
594    fn install_and_uninstall_round_trip() {
595        // Redirect HOME to a tempdir so we don't scribble in the
596        // real user config.
597        let _lk = home_lock().lock().unwrap();
598        let tmp = tempfile::tempdir().unwrap();
599        unsafe { std::env::set_var("HOME", tmp.path()) };
600
601        let spec = IntegrationSpec {
602            id: "roundtrip".into(),
603            label: "Round Trip".into(),
604            binary: "mnml-rt".into(),
605            ..Default::default()
606        };
607        let p = install_integration(&spec).unwrap();
608        assert!(p.exists());
609        assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
610
611        let ids = list_installed_integrations().unwrap();
612        assert!(ids.contains(&"roundtrip".to_string()));
613
614        let removed = uninstall_integration("roundtrip").unwrap();
615        assert!(removed);
616        assert!(!p.exists());
617
618        // Second uninstall is a no-op (already gone).
619        let removed2 = uninstall_integration("roundtrip").unwrap();
620        assert!(!removed2);
621    }
622}