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//!     name: "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//!         tooltip: Some("Slack".into()),
30//!         enabled: true,
31//!         in_palette_bar: false,
32//!         badge_key: Some("slack".into()),
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`, `name`, and `binary` are required — everything
52/// else defaults to sensible empty values.
53#[derive(Debug, Clone, Default, Serialize)]
54pub struct IntegrationSpec {
55    pub id: String,
56    pub name: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub description: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub version: Option<String>,
61    pub binary: String,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub category: Option<String>,
64
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub chip: Option<ChipSpec>,
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub commands: Vec<CommandSpec>,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub context_menu: Vec<ContextMenuEntry>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub menu_bar: Vec<MenuBarEntry>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub statusline: Option<StatuslineSpec>,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub settings: Vec<SettingsPage>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub notifications: Option<NotificationsSpec>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub requires: Option<Requires>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct ChipSpec {
85    pub glyph: String,
86    pub fallback: String,
87    pub color: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub tooltip: Option<String>,
90    pub enabled: bool,
91    pub in_palette_bar: bool,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub badge_key: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct CommandSpec {
98    pub id: String,
99    pub title: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub group: Option<String>,
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub keys: Vec<String>,
104    pub run: String,
105}
106
107#[derive(Debug, Clone, Serialize)]
108pub struct ContextMenuEntry {
109    /// `tree.file` | `tree.dir` | `tab` | `agent.row` | `pane`.
110    pub target: String,
111    pub title: String,
112    pub command: String,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct MenuBarEntry {
117    /// Slash-separated path like `"File > Send via Slack"`.
118    pub path: String,
119    pub command: String,
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct StatuslineSpec {
124    /// `"left"` | `"right"`.
125    pub side: String,
126    pub segment_id: String,
127    #[serde(skip_serializing_if = "String::is_empty")]
128    pub initial_text: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub initial_color: Option<String>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub click_command: Option<String>,
133    pub priority: u8,
134    pub min_width: u16,
135    pub max_width: u16,
136}
137
138#[derive(Debug, Clone, Serialize)]
139pub struct SettingsPage {
140    pub section: String,
141    pub label: String,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub help: Option<String>,
144}
145
146#[derive(Debug, Clone, Copy, Default, Serialize)]
147#[serde(rename_all = "snake_case")]
148pub enum OsNotifyPolicy {
149    #[default]
150    Never,
151    ErrorOnly,
152    Always,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct NotificationsSpec {
157    pub os_notify_on: OsNotifyPolicy,
158    pub os_rate_limit_sec: u64,
159}
160
161#[derive(Debug, Clone, Serialize)]
162pub struct Requires {
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub env: Vec<String>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub binary: Option<String>,
167}
168
169// ── Filesystem operations ─────────────────────────────
170
171/// Serialize `spec` and write to
172/// `~/.config/mnml/integrations/<id>.toml`. Creates the parent
173/// directory if needed. Overwrites any existing file with the
174/// same id. Returns the path written.
175///
176/// Fails if `spec.id` contains `/` or `\` (dir traversal
177/// protection), or if the fs operation itself fails.
178pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
179    validate_id(&spec.id)?;
180    let dir = user_integration_dir()?;
181    fs::create_dir_all(&dir)?;
182    let path = dir.join(format!("{}.toml", spec.id));
183    let toml = toml_serialize(spec)?;
184    fs::write(&path, toml)?;
185    Ok(path)
186}
187
188/// Delete the manifest at `~/.config/mnml/integrations/<id>.toml`.
189/// Returns `Ok(true)` if the file was removed, `Ok(false)` if
190/// the file didn't exist (already uninstalled). Fails on other
191/// fs errors.
192pub fn uninstall_integration(id: &str) -> io::Result<bool> {
193    validate_id(id)?;
194    let path = integration_manifest_path(id)?;
195    match fs::remove_file(&path) {
196        Ok(()) => Ok(true),
197        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
198        Err(e) => Err(e),
199    }
200}
201
202/// List installed integrations by id — reads the manifest
203/// directory + strips the `.toml` suffix. Returns an empty vec
204/// if the dir doesn't exist.
205pub fn list_installed_integrations() -> io::Result<Vec<String>> {
206    let dir = user_integration_dir()?;
207    let entries = match fs::read_dir(&dir) {
208        Ok(e) => e,
209        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
210        Err(e) => return Err(e),
211    };
212    let mut out: Vec<String> = Vec::new();
213    for entry in entries.flatten() {
214        let name = entry.file_name();
215        let Some(name) = name.to_str() else { continue };
216        if let Some(id) = name.strip_suffix(".toml")
217            && !id.is_empty()
218        {
219            out.push(id.to_string());
220        }
221    }
222    out.sort();
223    Ok(out)
224}
225
226/// Path to a specific integration's manifest file. Doesn't check
227/// whether the file exists.
228pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
229    validate_id(id)?;
230    Ok(user_integration_dir()?.join(format!("{id}.toml")))
231}
232
233fn user_integration_dir() -> io::Result<PathBuf> {
234    let home = std::env::var_os("HOME")
235        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
236    Ok(PathBuf::from(home)
237        .join(".config")
238        .join("mnml")
239        .join("integrations"))
240}
241
242fn validate_id(id: &str) -> io::Result<()> {
243    if id.is_empty() {
244        return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
245    }
246    if id.contains(['/', '\\', '\0']) {
247        return Err(io::Error::new(
248            io::ErrorKind::InvalidInput,
249            format!("id contains path characters: {id}"),
250        ));
251    }
252    Ok(())
253}
254
255fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
256    // Use serde_json → toml conversion since we don't ship the
257    // toml crate as a dep (keeps mnml-bridge's dep tree tight).
258    // Instead: format the manifest by hand for the common shape.
259    // For fidelity, we use serde_json and let the reader (mnml)
260    // parse the TOML directly. But since we're WRITING TOML, we
261    // need actual TOML serialization.
262    //
263    // The simplest path: use serde_json to reflect the struct,
264    // then hand-convert to TOML. Given the flat + list shape of
265    // IntegrationSpec, this is straightforward.
266    let json = serde_json::to_value(v)
267        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
268    Ok(json_to_toml(&json))
269}
270
271/// Best-effort JSON → TOML for the IntegrationSpec shape.
272/// Handles top-level scalar fields + nested tables +
273/// arrays-of-tables. Not a general JSON→TOML converter — but
274/// sufficient for the shapes this SDK emits.
275fn json_to_toml(v: &serde_json::Value) -> String {
276    let mut out = String::new();
277    let Some(map) = v.as_object() else {
278        return out;
279    };
280    // Emit top-level scalars first.
281    for (k, val) in map {
282        if val.is_object() || val.is_array() {
283            continue;
284        }
285        push_kv(&mut out, k, val);
286    }
287    // Then arrays-of-tables and tables.
288    for (k, val) in map {
289        match val {
290            serde_json::Value::Object(_) => {
291                out.push_str(&format!("\n[{k}]\n"));
292                for (inner_k, inner_v) in val.as_object().unwrap() {
293                    if inner_v.is_object() || inner_v.is_array() {
294                        continue;
295                    }
296                    push_kv(&mut out, inner_k, inner_v);
297                }
298            }
299            serde_json::Value::Array(arr) => {
300                for item in arr {
301                    if let Some(obj) = item.as_object() {
302                        out.push_str(&format!("\n[[{k}]]\n"));
303                        for (inner_k, inner_v) in obj {
304                            push_kv(&mut out, inner_k, inner_v);
305                        }
306                    }
307                }
308            }
309            _ => {}
310        }
311    }
312    out
313}
314
315fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
316    match v {
317        serde_json::Value::String(s) => {
318            out.push_str(&format!("{k} = {}\n", toml_str(s)));
319        }
320        serde_json::Value::Number(n) => {
321            out.push_str(&format!("{k} = {n}\n"));
322        }
323        serde_json::Value::Bool(b) => {
324            out.push_str(&format!("{k} = {b}\n"));
325        }
326        serde_json::Value::Array(arr) => {
327            let items: Vec<String> = arr
328                .iter()
329                .filter_map(|x| x.as_str().map(toml_str))
330                .collect();
331            out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
332        }
333        _ => {}
334    }
335}
336
337fn toml_str(s: &str) -> String {
338    // Basic TOML string escape — quote + escape backslash + quote.
339    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
340    format!("\"{escaped}\"")
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn validate_id_rejects_dangerous_chars() {
349        assert!(validate_id("").is_err());
350        assert!(validate_id("../foo").is_err());
351        assert!(validate_id("a/b").is_err());
352        assert!(validate_id("a\\b").is_err());
353        assert!(validate_id("valid_id-123").is_ok());
354    }
355
356    #[test]
357    fn serializes_minimal_spec_to_toml() {
358        let spec = IntegrationSpec {
359            id: "slack".into(),
360            name: "Slack".into(),
361            binary: "mnml-msg-slack".into(),
362            ..Default::default()
363        };
364        let toml = toml_serialize(&spec).unwrap();
365        assert!(toml.contains("id = \"slack\""));
366        assert!(toml.contains("name = \"Slack\""));
367        assert!(toml.contains("binary = \"mnml-msg-slack\""));
368    }
369
370    #[test]
371    fn serializes_full_spec_with_chip_and_commands() {
372        let spec = IntegrationSpec {
373            id: "slack".into(),
374            name: "Slack".into(),
375            binary: "mnml-msg-slack".into(),
376            chip: Some(ChipSpec {
377                glyph: "S".into(),
378                fallback: "Sk".into(),
379                color: "purple".into(),
380                tooltip: None,
381                enabled: true,
382                in_palette_bar: false,
383                badge_key: None,
384            }),
385            commands: vec![CommandSpec {
386                id: "slack.open".into(),
387                title: "Slack: open".into(),
388                group: Some("integrations".into()),
389                keys: vec!["<leader>iS".into()],
390                run: ":term mnml-msg-slack".into(),
391            }],
392            ..Default::default()
393        };
394        let toml = toml_serialize(&spec).unwrap();
395        assert!(toml.contains("[chip]"));
396        assert!(toml.contains("glyph = \"S\""));
397        assert!(toml.contains("[[commands]]"));
398        assert!(toml.contains("id = \"slack.open\""));
399        assert!(toml.contains("keys = [\"<leader>iS\"]"));
400    }
401
402    #[test]
403    fn install_and_uninstall_round_trip() {
404        // Redirect HOME to a tempdir so we don't scribble in the
405        // real user config.
406        let tmp = tempfile::tempdir().unwrap();
407        unsafe { std::env::set_var("HOME", tmp.path()) };
408
409        let spec = IntegrationSpec {
410            id: "roundtrip".into(),
411            name: "Round Trip".into(),
412            binary: "mnml-rt".into(),
413            ..Default::default()
414        };
415        let p = install_integration(&spec).unwrap();
416        assert!(p.exists());
417        assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
418
419        let ids = list_installed_integrations().unwrap();
420        assert!(ids.contains(&"roundtrip".to_string()));
421
422        let removed = uninstall_integration("roundtrip").unwrap();
423        assert!(removed);
424        assert!(!p.exists());
425
426        // Second uninstall is a no-op (already gone).
427        let removed2 = uninstall_integration("roundtrip").unwrap();
428        assert!(!removed2);
429    }
430}