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    /// Auth fields the integration needs configured before its
91    /// commands can talk to their backend — tokens, base URLs, an
92    /// email, etc. mnml's per-integration Settings pane renders one
93    /// form control per entry (secrets are masked as `•••`) and
94    /// writes the user's answers back to the manifest TOML under
95    /// `[auth_values]`.
96    ///
97    /// When a command from this integration fires without a required
98    /// field set (and its `env_fallback` env var also unset), mnml
99    /// intercepts the dispatch and opens the Settings pane instead
100    /// of silently failing. Added in 0.7.0 (2026-08-11).
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub auth: Vec<AuthField>,
103}
104
105/// One user-configurable field the integration needs before it can
106/// operate. Declared in `IntegrationSpec::auth`; rendered by mnml's
107/// per-integration Settings pane; persisted to `[auth_values]` in
108/// the same manifest TOML. Added in 0.7.0 (2026-08-11).
109///
110/// Example:
111///
112/// ```
113/// use mnml_bridge::AuthField;
114/// let f = AuthField {
115///     key: "bot_token".into(),
116///     label: "Slack bot token".into(),
117///     kind: "secret".into(),
118///     env_fallback: Some("SLACK_BOT_TOKEN".into()),
119///     help_url: Some("https://api.slack.com/apps".into()),
120///     help: Some("Create a Slack app + install to workspace + copy the token.".into()),
121///     required: true,
122/// };
123/// # let _ = f;
124/// ```
125#[derive(Debug, Clone, Serialize)]
126pub struct AuthField {
127    /// Key the user's answer is written under in `[auth_values]`.
128    /// e.g. `"bot_token"`.
129    pub key: String,
130    /// Human label rendered next to the input.
131    pub label: String,
132    /// One of `"secret"` (masked in UI, keychain-backed in a future
133    /// mnml phase), `"text"`, `"number"`, `"url"`, `"email"`.
134    ///
135    /// `Default::default()` returns `"text"` to match mnml core's
136    /// `default_kind()` deserialize fallback — an unset kind on both
137    /// sides means "plain text field".
138    pub kind: String,
139    /// Env-var name to fall back to when `[auth_values]` doesn't
140    /// have a stored value. Backward-compatibility hatch for
141    /// integrations whose users have already set `$SLACK_BOT_TOKEN`
142    /// etc. in their shell profile.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub env_fallback: Option<String>,
145    /// One-line link rendered as "Get one: <url>" under the input.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub help_url: Option<String>,
148    /// Short inline help sentence under the label.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub help: Option<String>,
151    /// When true, an integration action fired without a value here
152    /// AND no env_fallback env var set triggers mnml's first-hit
153    /// auth prompt: the Settings pane opens instead of the action.
154    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155    pub required: bool,
156}
157
158impl Default for AuthField {
159    fn default() -> Self {
160        Self {
161            key: String::new(),
162            label: String::new(),
163            // Match mnml core's `default_kind()` — an unset kind
164            // means "plain text field", not the empty string. Without
165            // this, `..Default::default()` in a sibling would write
166            // `kind = ""` to the TOML, bypassing core's fallback and
167            // rendering as an empty-string kind in the Settings pane.
168            // Caught by code-reviewer 2026-08-11 pre-publish.
169            kind: "text".to_string(),
170            env_fallback: None,
171            help_url: None,
172            help: None,
173            required: false,
174        }
175    }
176}
177
178/// Visual + interaction settings for the sibling's chip. Display
179/// strings (label, description) live at `IntegrationSpec` top
180/// level, not here — the chip is about rendering, not identity.
181#[derive(Debug, Clone, Default, Serialize)]
182pub struct ChipSpec {
183    pub glyph: String,
184    pub fallback: String,
185    pub color: String,
186    pub enabled: bool,
187    pub in_palette_bar: bool,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub badge_key: Option<String>,
190    /// SVG bytes for the integration's icon — typically produced
191    /// by `include_bytes!("assets/icons/<id>.svg").to_vec()` at the
192    /// integration binary's build time.
193    ///
194    /// On [`install_integration`], the bytes are written to
195    /// `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml bakes any
196    /// pending SVGs into `~/Library/Fonts/MnmlSymbols.ttf` at the
197    /// next startup and DELETES the pending file so there's no
198    /// permanent glyph state under `~/.config/mnml/`. `glyph_codepoint`
199    /// (if set) pins the codepoint the integration wants; otherwise
200    /// mnml auto-assigns from the `U+F1C00–U+F1CFF` range.
201    ///
202    /// Never serialized to the manifest TOML — bytes are consumed
203    /// at install time and discarded.
204    #[serde(skip)]
205    #[serde(default)]
206    pub glyph_svg_bytes: Option<Vec<u8>>,
207    /// Optional explicit codepoint the sibling wants (uppercase
208    /// hex, no `U+` prefix — e.g. `"F1C05"`). When set, mnml uses
209    /// this codepoint verbatim for the sibling's SVG bake instead
210    /// of auto-assigning one from the sibling PUA range
211    /// (`U+F1C00–U+F1CFF`). Useful for migration cases where a
212    /// sibling wants to keep the codepoint mnml core baked before
213    /// this SDK feature landed. Trusted — no range validation
214    /// beyond "parses as u32"; the manifest author is expected to
215    /// stay inside mnml's PUA layout documented in
216    /// `src/icon_catalog.rs`.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub glyph_codepoint: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize)]
222pub struct CommandSpec {
223    pub id: String,
224    pub title: String,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub group: Option<String>,
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub keys: Vec<String>,
229    pub run: String,
230}
231
232#[derive(Debug, Clone, Serialize)]
233pub struct ContextMenuEntry {
234    /// `tree.file` | `tree.dir` | `tab` | `agent.row` | `pane`.
235    pub target: String,
236    pub title: String,
237    pub command: String,
238}
239
240#[derive(Debug, Clone, Serialize)]
241pub struct MenuBarEntry {
242    /// Slash-separated path like `"File > Send via Slack"`.
243    pub path: String,
244    pub command: String,
245}
246
247#[derive(Debug, Clone, Serialize)]
248pub struct StatuslineSpec {
249    /// `"left"` | `"right"`.
250    pub side: String,
251    pub segment_id: String,
252    #[serde(skip_serializing_if = "String::is_empty")]
253    pub initial_text: String,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub initial_color: Option<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub click_command: Option<String>,
258    pub priority: u8,
259    pub min_width: u16,
260    pub max_width: u16,
261}
262
263#[derive(Debug, Clone, Serialize)]
264pub struct SettingsPage {
265    pub section: String,
266    pub label: String,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub help: Option<String>,
269}
270
271#[derive(Debug, Clone, Copy, Default, Serialize)]
272#[serde(rename_all = "snake_case")]
273pub enum OsNotifyPolicy {
274    #[default]
275    Never,
276    ErrorOnly,
277    Always,
278}
279
280#[derive(Debug, Clone, Serialize)]
281pub struct NotificationsSpec {
282    pub os_notify_on: OsNotifyPolicy,
283    pub os_rate_limit_sec: u64,
284}
285
286#[derive(Debug, Clone, Serialize)]
287pub struct Requires {
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub env: Vec<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub binary: Option<String>,
292}
293
294// ── Filesystem operations ─────────────────────────────
295
296/// Serialize `spec` and write to
297/// `~/.config/mnml/integrations/<id>.toml`. Creates the parent
298/// directory if needed. Overwrites any existing file with the
299/// same id. Returns the path written.
300///
301/// If `spec.chip.glyph_svg_bytes` is set, writes the bytes to
302/// `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml bakes on next
303/// startup + deletes the pending file. Nothing persistent under
304/// `~/.config/mnml/`.
305///
306/// Fails if `spec.id` contains `/` or `\` (dir traversal
307/// protection), or if the manifest fs write itself fails.
308pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
309    validate_id(&spec.id)?;
310    let dir = user_integration_dir()?;
311    fs::create_dir_all(&dir)?;
312    let path = dir.join(format!("{}.toml", spec.id));
313    let toml = toml_serialize(spec)?;
314    fs::write(&path, toml)?;
315    if let Some(chip) = &spec.chip
316        && let Some(bytes) = chip.glyph_svg_bytes.as_deref()
317    {
318        match write_pending_glyph(&spec.id, bytes) {
319            Ok(dest) => eprintln!(
320                "mnml-bridge: queued glyph → {} (mnml bakes + deletes on next startup)",
321                dest.display()
322            ),
323            Err(e) => eprintln!(
324                "mnml-bridge: WARN failed to queue glyph for {}: {e}",
325                spec.id
326            ),
327        }
328    }
329    Ok(path)
330}
331
332/// Dump `bytes` to `~/.cache/mnml/pending-glyphs/<id>.svg`. mnml's
333/// startup path bakes these into `MnmlSymbols.ttf`, then deletes
334/// the pending file. Nothing lands under `~/.config/mnml/glyphs/`.
335fn write_pending_glyph(id: &str, bytes: &[u8]) -> io::Result<PathBuf> {
336    validate_id(id)?;
337    let dir = pending_glyphs_dir()?;
338    fs::create_dir_all(&dir)?;
339    let dest = dir.join(format!("{id}.svg"));
340    fs::write(&dest, bytes)?;
341    Ok(dest)
342}
343
344/// `~/.cache/mnml/pending-glyphs/` — handoff location for
345/// integration-shipped SVGs. mnml bakes + deletes at startup.
346/// Nothing here is expected to persist across a launch cycle.
347pub fn pending_glyphs_dir() -> io::Result<PathBuf> {
348    let home = std::env::var_os("HOME")
349        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
350    Ok(PathBuf::from(home)
351        .join(".cache")
352        .join("mnml")
353        .join("pending-glyphs"))
354}
355
356/// Delete the manifest at `~/.config/mnml/integrations/<id>.toml`.
357/// Returns `Ok(true)` if the file was removed, `Ok(false)` if
358/// the file didn't exist (already uninstalled). Fails on other
359/// fs errors.
360pub fn uninstall_integration(id: &str) -> io::Result<bool> {
361    validate_id(id)?;
362    let path = integration_manifest_path(id)?;
363    // Drop any leftover pending-glyph SVG for this id (rare — the
364    // startup auto-purge already deletes baked ones, but a fresh
365    // install that hasn't been baked yet would still have the file).
366    // The codepoint assignment persists in
367    // `~/.config/mnml/integration-glyphs.toml` so re-installing later
368    // gets the same codepoint back.
369    if let Ok(pending) = pending_glyphs_dir() {
370        let _ = fs::remove_file(pending.join(format!("{id}.svg")));
371    }
372    match fs::remove_file(&path) {
373        Ok(()) => Ok(true),
374        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
375        Err(e) => Err(e),
376    }
377}
378
379/// List installed integrations by id — reads the manifest
380/// directory + strips the `.toml` suffix. Returns an empty vec
381/// if the dir doesn't exist.
382pub fn list_installed_integrations() -> io::Result<Vec<String>> {
383    let dir = user_integration_dir()?;
384    let entries = match fs::read_dir(&dir) {
385        Ok(e) => e,
386        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
387        Err(e) => return Err(e),
388    };
389    let mut out: Vec<String> = Vec::new();
390    for entry in entries.flatten() {
391        let name = entry.file_name();
392        let Some(name) = name.to_str() else { continue };
393        if let Some(id) = name.strip_suffix(".toml")
394            && !id.is_empty()
395        {
396            out.push(id.to_string());
397        }
398    }
399    out.sort();
400    Ok(out)
401}
402
403/// Path to a specific integration's manifest file. Doesn't check
404/// whether the file exists.
405pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
406    validate_id(id)?;
407    Ok(user_integration_dir()?.join(format!("{id}.toml")))
408}
409
410fn user_integration_dir() -> io::Result<PathBuf> {
411    let home = std::env::var_os("HOME")
412        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
413    Ok(PathBuf::from(home)
414        .join(".config")
415        .join("mnml")
416        .join("integrations"))
417}
418
419fn validate_id(id: &str) -> io::Result<()> {
420    if id.is_empty() {
421        return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
422    }
423    if id.contains(['/', '\\', '\0']) {
424        return Err(io::Error::new(
425            io::ErrorKind::InvalidInput,
426            format!("id contains path characters: {id}"),
427        ));
428    }
429    Ok(())
430}
431
432fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
433    // Use serde_json → toml conversion since we don't ship the
434    // toml crate as a dep (keeps mnml-bridge's dep tree tight).
435    // Instead: format the manifest by hand for the common shape.
436    // For fidelity, we use serde_json and let the reader (mnml)
437    // parse the TOML directly. But since we're WRITING TOML, we
438    // need actual TOML serialization.
439    //
440    // The simplest path: use serde_json to reflect the struct,
441    // then hand-convert to TOML. Given the flat + list shape of
442    // IntegrationSpec, this is straightforward.
443    let json = serde_json::to_value(v)
444        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
445    Ok(json_to_toml(&json))
446}
447
448/// Best-effort JSON → TOML for the IntegrationSpec shape.
449/// Handles top-level scalar fields + nested tables +
450/// arrays-of-tables. Not a general JSON→TOML converter — but
451/// sufficient for the shapes this SDK emits.
452fn json_to_toml(v: &serde_json::Value) -> String {
453    let mut out = String::new();
454    let Some(map) = v.as_object() else {
455        return out;
456    };
457    // Emit top-level scalars first.
458    for (k, val) in map {
459        if val.is_object() || val.is_array() {
460            continue;
461        }
462        push_kv(&mut out, k, val);
463    }
464    // Then arrays-of-tables and tables.
465    for (k, val) in map {
466        match val {
467            serde_json::Value::Object(_) => {
468                out.push_str(&format!("\n[{k}]\n"));
469                for (inner_k, inner_v) in val.as_object().unwrap() {
470                    if inner_v.is_object() || inner_v.is_array() {
471                        continue;
472                    }
473                    push_kv(&mut out, inner_k, inner_v);
474                }
475            }
476            serde_json::Value::Array(arr) => {
477                for item in arr {
478                    if let Some(obj) = item.as_object() {
479                        out.push_str(&format!("\n[[{k}]]\n"));
480                        for (inner_k, inner_v) in obj {
481                            push_kv(&mut out, inner_k, inner_v);
482                        }
483                    }
484                }
485            }
486            _ => {}
487        }
488    }
489    out
490}
491
492fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
493    match v {
494        serde_json::Value::String(s) => {
495            out.push_str(&format!("{k} = {}\n", toml_str(s)));
496        }
497        serde_json::Value::Number(n) => {
498            out.push_str(&format!("{k} = {n}\n"));
499        }
500        serde_json::Value::Bool(b) => {
501            out.push_str(&format!("{k} = {b}\n"));
502        }
503        serde_json::Value::Array(arr) => {
504            let items: Vec<String> = arr
505                .iter()
506                .filter_map(|x| x.as_str().map(toml_str))
507                .collect();
508            out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
509        }
510        _ => {}
511    }
512}
513
514fn toml_str(s: &str) -> String {
515    // Basic TOML string escape — quote + escape backslash + quote.
516    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
517    format!("\"{escaped}\"")
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    // HOME-mutating tests share a single tempdir path. Rust runs
525    // tests in the same process on multiple threads by default, and
526    // set_var("HOME", …) leaks across threads — without a mutex,
527    // one test's tempdir can shadow another mid-run. Serialize
528    // every HOME-touching test through this lock.
529    fn home_lock() -> &'static std::sync::Mutex<()> {
530        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
531        LOCK.get_or_init(|| std::sync::Mutex::new(()))
532    }
533
534    #[test]
535    fn validate_id_rejects_dangerous_chars() {
536        assert!(validate_id("").is_err());
537        assert!(validate_id("../foo").is_err());
538        assert!(validate_id("a/b").is_err());
539        assert!(validate_id("a\\b").is_err());
540        assert!(validate_id("valid_id-123").is_ok());
541    }
542
543    #[test]
544    fn serializes_auth_fields_as_array_of_tables() {
545        let spec = IntegrationSpec {
546            id: "slack".into(),
547            label: "Slack".into(),
548            binary: "mnml-msg-slack".into(),
549            auth: vec![
550                AuthField {
551                    key: "bot_token".into(),
552                    label: "Slack bot token".into(),
553                    kind: "secret".into(),
554                    env_fallback: Some("SLACK_BOT_TOKEN".into()),
555                    help_url: Some("https://api.slack.com/apps".into()),
556                    required: true,
557                    ..Default::default()
558                },
559                AuthField {
560                    key: "team_id".into(),
561                    label: "Team ID".into(),
562                    // Default kind = "text" per AuthField's Default impl.
563                    ..Default::default()
564                },
565            ],
566            ..Default::default()
567        };
568        let toml = toml_serialize(&spec).unwrap();
569        // Both fields serialize as [[auth]] tables with all populated
570        // scalars intact.
571        assert!(toml.contains("[[auth]]"));
572        assert!(toml.contains("key = \"bot_token\""));
573        assert!(toml.contains("kind = \"secret\""));
574        assert!(toml.contains("env_fallback = \"SLACK_BOT_TOKEN\""));
575        assert!(toml.contains("required = true"));
576        // Second field: default kind is "text", NOT the empty string.
577        assert!(toml.contains("key = \"team_id\""));
578        assert!(toml.contains("kind = \"text\""));
579        // `required = false` is skipped via skip_serializing_if.
580        assert!(!toml.contains("required = false"));
581    }
582
583    #[test]
584    fn serializes_minimal_spec_to_toml() {
585        let spec = IntegrationSpec {
586            id: "slack".into(),
587            label: "Slack".into(),
588            binary: "mnml-msg-slack".into(),
589            ..Default::default()
590        };
591        let toml = toml_serialize(&spec).unwrap();
592        assert!(toml.contains("id = \"slack\""));
593        assert!(toml.contains("label = \"Slack\""));
594        assert!(toml.contains("binary = \"mnml-msg-slack\""));
595    }
596
597    #[test]
598    fn serializes_full_spec_with_chip_and_commands() {
599        let spec = IntegrationSpec {
600            id: "slack".into(),
601            label: "Slack".into(),
602            binary: "mnml-msg-slack".into(),
603            chip: Some(ChipSpec {
604                glyph: "S".into(),
605                fallback: "Sk".into(),
606                color: "purple".into(),
607                enabled: true,
608                in_palette_bar: false,
609                badge_key: None,
610                glyph_svg_bytes: None,
611                glyph_codepoint: None,
612            }),
613            commands: vec![CommandSpec {
614                id: "slack.open".into(),
615                title: "Slack: open".into(),
616                group: Some("integrations".into()),
617                keys: vec!["<leader>iS".into()],
618                run: ":term mnml-msg-slack".into(),
619            }],
620            ..Default::default()
621        };
622        let toml = toml_serialize(&spec).unwrap();
623        assert!(toml.contains("[chip]"));
624        assert!(toml.contains("glyph = \"S\""));
625        assert!(toml.contains("[[commands]]"));
626        assert!(toml.contains("id = \"slack.open\""));
627        assert!(toml.contains("keys = [\"<leader>iS\"]"));
628    }
629
630    #[test]
631    fn glyph_codepoint_serializes_when_set() {
632        let spec = IntegrationSpec {
633            id: "amplify".into(),
634            label: "Amplify".into(),
635            binary: "mnml-aws-amplify".into(),
636            chip: Some(ChipSpec {
637                glyph: "\u{F1B00}".into(),
638                fallback: "Am".into(),
639                color: "purple".into(),
640                enabled: true,
641                in_palette_bar: false,
642                badge_key: None,
643                glyph_svg_bytes: None,
644                glyph_codepoint: Some("F1B00".into()),
645            }),
646            ..Default::default()
647        };
648        let toml = toml_serialize(&spec).unwrap();
649        assert!(toml.contains("glyph_codepoint = \"F1B00\""));
650        // glyph_svg_bytes is #[serde(skip)] — must not appear in TOML.
651        assert!(!toml.contains("glyph_svg_bytes"));
652    }
653
654    #[test]
655    fn install_writes_glyph_bytes_to_pending_dir() {
656        let _lk = home_lock().lock().unwrap();
657        let tmp = tempfile::tempdir().unwrap();
658        unsafe { std::env::set_var("HOME", tmp.path()) };
659
660        let spec = IntegrationSpec {
661            id: "amplify".into(),
662            label: "Amplify".into(),
663            binary: "mnml-aws-amplify".into(),
664            chip: Some(ChipSpec {
665                glyph: "A".into(),
666                fallback: "Am".into(),
667                color: "purple".into(),
668                enabled: true,
669                in_palette_bar: false,
670                badge_key: None,
671                glyph_svg_bytes: Some(b"<svg/>".to_vec()),
672                glyph_codepoint: Some("F1B00".into()),
673            }),
674            ..Default::default()
675        };
676        install_integration(&spec).unwrap();
677
678        let dest = pending_glyphs_dir().unwrap().join("amplify.svg");
679        assert!(dest.exists(), "glyph SVG bytes should land at {dest:?}");
680        assert_eq!(fs::read(&dest).unwrap(), b"<svg/>");
681        // Uninstall removes the pending SVG alongside the manifest.
682        uninstall_integration("amplify").unwrap();
683        assert!(
684            !dest.exists(),
685            "pending glyph SVG should be removed on uninstall"
686        );
687    }
688
689    #[test]
690    fn install_survives_missing_glyph_svg_source() {
691        let _lk = home_lock().lock().unwrap();
692        let tmp = tempfile::tempdir().unwrap();
693        unsafe { std::env::set_var("HOME", tmp.path()) };
694
695        let spec = IntegrationSpec {
696            id: "broken".into(),
697            label: "Broken".into(),
698            binary: "mnml-broken".into(),
699            chip: Some(ChipSpec {
700                glyph: "B".into(),
701                fallback: "Br".into(),
702                color: "red".into(),
703                enabled: true,
704                in_palette_bar: false,
705                badge_key: None,
706                glyph_svg_bytes: None,
707                glyph_codepoint: None,
708            }),
709            ..Default::default()
710        };
711        // A chip with no glyph_svg_bytes is fine — the manifest
712        // still gets written so `--install` succeeds even when the
713        // sibling packager forgot to bundle the SVG.
714        install_integration(&spec).unwrap();
715        let manifest = integration_manifest_path("broken").unwrap();
716        assert!(manifest.exists());
717    }
718
719    #[test]
720    fn install_and_uninstall_round_trip() {
721        // Redirect HOME to a tempdir so we don't scribble in the
722        // real user config.
723        let _lk = home_lock().lock().unwrap();
724        let tmp = tempfile::tempdir().unwrap();
725        unsafe { std::env::set_var("HOME", tmp.path()) };
726
727        let spec = IntegrationSpec {
728            id: "roundtrip".into(),
729            label: "Round Trip".into(),
730            binary: "mnml-rt".into(),
731            ..Default::default()
732        };
733        let p = install_integration(&spec).unwrap();
734        assert!(p.exists());
735        assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
736
737        let ids = list_installed_integrations().unwrap();
738        assert!(ids.contains(&"roundtrip".to_string()));
739
740        let removed = uninstall_integration("roundtrip").unwrap();
741        assert!(removed);
742        assert!(!p.exists());
743
744        // Second uninstall is a no-op (already gone).
745        let removed2 = uninstall_integration("roundtrip").unwrap();
746        assert!(!removed2);
747    }
748}