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