1use serde::Serialize;
47use std::fs;
48use std::io;
49use std::path::{Path, PathBuf};
50
51#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
119 pub glyph_svg: Option<PathBuf>,
120 #[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 pub target: String,
149 pub title: String,
150 pub command: String,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct MenuBarEntry {
155 pub path: String,
157 pub command: String,
158}
159
160#[derive(Debug, Clone, Serialize)]
161pub struct StatuslineSpec {
162 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
207pub 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 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
249fn 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
267pub 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
284pub fn uninstall_integration(id: &str) -> io::Result<bool> {
289 validate_id(id)?;
290 let path = integration_manifest_path(id)?;
291 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
309pub 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
333pub 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 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
378fn 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 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 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 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
447 format!("\"{escaped}\"")
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 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 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 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_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 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 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 let removed2 = uninstall_integration("roundtrip").unwrap();
643 assert!(!removed2);
644 }
645}