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