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")]
116 pub glyph_svg: Option<PathBuf>,
117 #[serde(skip)]
133 #[serde(default)]
134 pub glyph_svg_bytes: Option<Vec<u8>>,
135 #[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 pub target: String,
164 pub title: String,
165 pub command: String,
166}
167
168#[derive(Debug, Clone, Serialize)]
169pub struct MenuBarEntry {
170 pub path: String,
172 pub command: String,
173}
174
175#[derive(Debug, Clone, Serialize)]
176pub struct StatuslineSpec {
177 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
222pub 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 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
276fn 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
289pub 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
302fn 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
320pub 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
337pub fn uninstall_integration(id: &str) -> io::Result<bool> {
342 validate_id(id)?;
343 let path = integration_manifest_path(id)?;
344 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
365pub 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
389pub 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 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
434fn 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 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 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 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
503 format!("\"{escaped}\"")
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 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 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 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_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 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 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 let removed2 = uninstall_integration("roundtrip").unwrap();
699 assert!(!removed2);
700 }
701}