1use serde::Serialize;
46use std::fs;
47use std::io;
48use std::path::PathBuf;
49
50#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub auth: Vec<AuthField>,
103}
104
105#[derive(Debug, Clone, Serialize)]
126pub struct AuthField {
127 pub key: String,
130 pub label: String,
132 pub kind: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
144 pub env_fallback: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub help_url: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub help: Option<String>,
151 #[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 kind: "text".to_string(),
170 env_fallback: None,
171 help_url: None,
172 help: None,
173 required: false,
174 }
175 }
176}
177
178#[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 #[serde(skip)]
205 #[serde(default)]
206 pub glyph_svg_bytes: Option<Vec<u8>>,
207 #[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 pub target: String,
236 pub title: String,
237 pub command: String,
238}
239
240#[derive(Debug, Clone, Serialize)]
241pub struct MenuBarEntry {
242 pub path: String,
244 pub command: String,
245}
246
247#[derive(Debug, Clone, Serialize)]
248pub struct StatuslineSpec {
249 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
294pub 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
332fn 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
344pub 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
356pub fn uninstall_integration(id: &str) -> io::Result<bool> {
361 validate_id(id)?;
362 let path = integration_manifest_path(id)?;
363 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
379pub 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
403pub 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 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
448fn 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 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 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 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
517 format!("\"{escaped}\"")
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 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::default()
564 },
565 ],
566 ..Default::default()
567 };
568 let toml = toml_serialize(&spec).unwrap();
569 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 assert!(toml.contains("key = \"team_id\""));
578 assert!(toml.contains("kind = \"text\""));
579 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 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_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 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 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 let removed2 = uninstall_integration("roundtrip").unwrap();
746 assert!(!removed2);
747 }
748}