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}
91
92#[derive(Debug, Clone, Default, Serialize)]
96pub struct ChipSpec {
97 pub glyph: String,
98 pub fallback: String,
99 pub color: String,
100 pub enabled: bool,
101 pub in_palette_bar: bool,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub badge_key: Option<String>,
104 #[serde(skip)]
119 #[serde(default)]
120 pub glyph_svg_bytes: Option<Vec<u8>>,
121 #[serde(skip_serializing_if = "Option::is_none")]
132 pub glyph_codepoint: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct CommandSpec {
137 pub id: String,
138 pub title: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub group: Option<String>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub keys: Vec<String>,
143 pub run: String,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct ContextMenuEntry {
148 pub target: String,
150 pub title: String,
151 pub command: String,
152}
153
154#[derive(Debug, Clone, Serialize)]
155pub struct MenuBarEntry {
156 pub path: String,
158 pub command: String,
159}
160
161#[derive(Debug, Clone, Serialize)]
162pub struct StatuslineSpec {
163 pub side: String,
165 pub segment_id: String,
166 #[serde(skip_serializing_if = "String::is_empty")]
167 pub initial_text: String,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub initial_color: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub click_command: Option<String>,
172 pub priority: u8,
173 pub min_width: u16,
174 pub max_width: u16,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct SettingsPage {
179 pub section: String,
180 pub label: String,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub help: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, Default, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub enum OsNotifyPolicy {
188 #[default]
189 Never,
190 ErrorOnly,
191 Always,
192}
193
194#[derive(Debug, Clone, Serialize)]
195pub struct NotificationsSpec {
196 pub os_notify_on: OsNotifyPolicy,
197 pub os_rate_limit_sec: u64,
198}
199
200#[derive(Debug, Clone, Serialize)]
201pub struct Requires {
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub env: Vec<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub binary: Option<String>,
206}
207
208pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
223 validate_id(&spec.id)?;
224 let dir = user_integration_dir()?;
225 fs::create_dir_all(&dir)?;
226 let path = dir.join(format!("{}.toml", spec.id));
227 let toml = toml_serialize(spec)?;
228 fs::write(&path, toml)?;
229 if let Some(chip) = &spec.chip
230 && let Some(bytes) = chip.glyph_svg_bytes.as_deref()
231 {
232 match write_pending_glyph(&spec.id, bytes) {
233 Ok(dest) => eprintln!(
234 "mnml-bridge: queued glyph → {} (mnml bakes + deletes on next startup)",
235 dest.display()
236 ),
237 Err(e) => eprintln!(
238 "mnml-bridge: WARN failed to queue glyph for {}: {e}",
239 spec.id
240 ),
241 }
242 }
243 Ok(path)
244}
245
246fn write_pending_glyph(id: &str, bytes: &[u8]) -> io::Result<PathBuf> {
250 validate_id(id)?;
251 let dir = pending_glyphs_dir()?;
252 fs::create_dir_all(&dir)?;
253 let dest = dir.join(format!("{id}.svg"));
254 fs::write(&dest, bytes)?;
255 Ok(dest)
256}
257
258pub fn pending_glyphs_dir() -> io::Result<PathBuf> {
262 let home = std::env::var_os("HOME")
263 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
264 Ok(PathBuf::from(home)
265 .join(".cache")
266 .join("mnml")
267 .join("pending-glyphs"))
268}
269
270pub fn uninstall_integration(id: &str) -> io::Result<bool> {
275 validate_id(id)?;
276 let path = integration_manifest_path(id)?;
277 if let Ok(pending) = pending_glyphs_dir() {
284 let _ = fs::remove_file(pending.join(format!("{id}.svg")));
285 }
286 match fs::remove_file(&path) {
287 Ok(()) => Ok(true),
288 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
289 Err(e) => Err(e),
290 }
291}
292
293pub fn list_installed_integrations() -> io::Result<Vec<String>> {
297 let dir = user_integration_dir()?;
298 let entries = match fs::read_dir(&dir) {
299 Ok(e) => e,
300 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
301 Err(e) => return Err(e),
302 };
303 let mut out: Vec<String> = Vec::new();
304 for entry in entries.flatten() {
305 let name = entry.file_name();
306 let Some(name) = name.to_str() else { continue };
307 if let Some(id) = name.strip_suffix(".toml")
308 && !id.is_empty()
309 {
310 out.push(id.to_string());
311 }
312 }
313 out.sort();
314 Ok(out)
315}
316
317pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
320 validate_id(id)?;
321 Ok(user_integration_dir()?.join(format!("{id}.toml")))
322}
323
324fn user_integration_dir() -> io::Result<PathBuf> {
325 let home = std::env::var_os("HOME")
326 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
327 Ok(PathBuf::from(home)
328 .join(".config")
329 .join("mnml")
330 .join("integrations"))
331}
332
333fn validate_id(id: &str) -> io::Result<()> {
334 if id.is_empty() {
335 return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
336 }
337 if id.contains(['/', '\\', '\0']) {
338 return Err(io::Error::new(
339 io::ErrorKind::InvalidInput,
340 format!("id contains path characters: {id}"),
341 ));
342 }
343 Ok(())
344}
345
346fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
347 let json = serde_json::to_value(v)
358 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
359 Ok(json_to_toml(&json))
360}
361
362fn json_to_toml(v: &serde_json::Value) -> String {
367 let mut out = String::new();
368 let Some(map) = v.as_object() else {
369 return out;
370 };
371 for (k, val) in map {
373 if val.is_object() || val.is_array() {
374 continue;
375 }
376 push_kv(&mut out, k, val);
377 }
378 for (k, val) in map {
380 match val {
381 serde_json::Value::Object(_) => {
382 out.push_str(&format!("\n[{k}]\n"));
383 for (inner_k, inner_v) in val.as_object().unwrap() {
384 if inner_v.is_object() || inner_v.is_array() {
385 continue;
386 }
387 push_kv(&mut out, inner_k, inner_v);
388 }
389 }
390 serde_json::Value::Array(arr) => {
391 for item in arr {
392 if let Some(obj) = item.as_object() {
393 out.push_str(&format!("\n[[{k}]]\n"));
394 for (inner_k, inner_v) in obj {
395 push_kv(&mut out, inner_k, inner_v);
396 }
397 }
398 }
399 }
400 _ => {}
401 }
402 }
403 out
404}
405
406fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
407 match v {
408 serde_json::Value::String(s) => {
409 out.push_str(&format!("{k} = {}\n", toml_str(s)));
410 }
411 serde_json::Value::Number(n) => {
412 out.push_str(&format!("{k} = {n}\n"));
413 }
414 serde_json::Value::Bool(b) => {
415 out.push_str(&format!("{k} = {b}\n"));
416 }
417 serde_json::Value::Array(arr) => {
418 let items: Vec<String> = arr
419 .iter()
420 .filter_map(|x| x.as_str().map(toml_str))
421 .collect();
422 out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
423 }
424 _ => {}
425 }
426}
427
428fn toml_str(s: &str) -> String {
429 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
431 format!("\"{escaped}\"")
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn home_lock() -> &'static std::sync::Mutex<()> {
444 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
445 LOCK.get_or_init(|| std::sync::Mutex::new(()))
446 }
447
448 #[test]
449 fn validate_id_rejects_dangerous_chars() {
450 assert!(validate_id("").is_err());
451 assert!(validate_id("../foo").is_err());
452 assert!(validate_id("a/b").is_err());
453 assert!(validate_id("a\\b").is_err());
454 assert!(validate_id("valid_id-123").is_ok());
455 }
456
457 #[test]
458 fn serializes_minimal_spec_to_toml() {
459 let spec = IntegrationSpec {
460 id: "slack".into(),
461 label: "Slack".into(),
462 binary: "mnml-msg-slack".into(),
463 ..Default::default()
464 };
465 let toml = toml_serialize(&spec).unwrap();
466 assert!(toml.contains("id = \"slack\""));
467 assert!(toml.contains("label = \"Slack\""));
468 assert!(toml.contains("binary = \"mnml-msg-slack\""));
469 }
470
471 #[test]
472 fn serializes_full_spec_with_chip_and_commands() {
473 let spec = IntegrationSpec {
474 id: "slack".into(),
475 label: "Slack".into(),
476 binary: "mnml-msg-slack".into(),
477 chip: Some(ChipSpec {
478 glyph: "S".into(),
479 fallback: "Sk".into(),
480 color: "purple".into(),
481 enabled: true,
482 in_palette_bar: false,
483 badge_key: None,
484 glyph_svg_bytes: None,
485 glyph_codepoint: None,
486 }),
487 commands: vec![CommandSpec {
488 id: "slack.open".into(),
489 title: "Slack: open".into(),
490 group: Some("integrations".into()),
491 keys: vec!["<leader>iS".into()],
492 run: ":term mnml-msg-slack".into(),
493 }],
494 ..Default::default()
495 };
496 let toml = toml_serialize(&spec).unwrap();
497 assert!(toml.contains("[chip]"));
498 assert!(toml.contains("glyph = \"S\""));
499 assert!(toml.contains("[[commands]]"));
500 assert!(toml.contains("id = \"slack.open\""));
501 assert!(toml.contains("keys = [\"<leader>iS\"]"));
502 }
503
504 #[test]
505 fn glyph_codepoint_serializes_when_set() {
506 let spec = IntegrationSpec {
507 id: "amplify".into(),
508 label: "Amplify".into(),
509 binary: "mnml-aws-amplify".into(),
510 chip: Some(ChipSpec {
511 glyph: "\u{F1B00}".into(),
512 fallback: "Am".into(),
513 color: "purple".into(),
514 enabled: true,
515 in_palette_bar: false,
516 badge_key: None,
517 glyph_svg_bytes: None,
518 glyph_codepoint: Some("F1B00".into()),
519 }),
520 ..Default::default()
521 };
522 let toml = toml_serialize(&spec).unwrap();
523 assert!(toml.contains("glyph_codepoint = \"F1B00\""));
524 assert!(!toml.contains("glyph_svg_bytes"));
526 }
527
528 #[test]
529 fn install_writes_glyph_bytes_to_pending_dir() {
530 let _lk = home_lock().lock().unwrap();
531 let tmp = tempfile::tempdir().unwrap();
532 unsafe { std::env::set_var("HOME", tmp.path()) };
533
534 let spec = IntegrationSpec {
535 id: "amplify".into(),
536 label: "Amplify".into(),
537 binary: "mnml-aws-amplify".into(),
538 chip: Some(ChipSpec {
539 glyph: "A".into(),
540 fallback: "Am".into(),
541 color: "purple".into(),
542 enabled: true,
543 in_palette_bar: false,
544 badge_key: None,
545 glyph_svg_bytes: Some(b"<svg/>".to_vec()),
546 glyph_codepoint: Some("F1B00".into()),
547 }),
548 ..Default::default()
549 };
550 install_integration(&spec).unwrap();
551
552 let dest = pending_glyphs_dir().unwrap().join("amplify.svg");
553 assert!(dest.exists(), "glyph SVG bytes should land at {dest:?}");
554 assert_eq!(fs::read(&dest).unwrap(), b"<svg/>");
555 uninstall_integration("amplify").unwrap();
557 assert!(
558 !dest.exists(),
559 "pending glyph SVG should be removed on uninstall"
560 );
561 }
562
563 #[test]
564 fn install_survives_missing_glyph_svg_source() {
565 let _lk = home_lock().lock().unwrap();
566 let tmp = tempfile::tempdir().unwrap();
567 unsafe { std::env::set_var("HOME", tmp.path()) };
568
569 let spec = IntegrationSpec {
570 id: "broken".into(),
571 label: "Broken".into(),
572 binary: "mnml-broken".into(),
573 chip: Some(ChipSpec {
574 glyph: "B".into(),
575 fallback: "Br".into(),
576 color: "red".into(),
577 enabled: true,
578 in_palette_bar: false,
579 badge_key: None,
580 glyph_svg_bytes: None,
581 glyph_codepoint: None,
582 }),
583 ..Default::default()
584 };
585 install_integration(&spec).unwrap();
589 let manifest = integration_manifest_path("broken").unwrap();
590 assert!(manifest.exists());
591 }
592
593 #[test]
594 fn install_and_uninstall_round_trip() {
595 let _lk = home_lock().lock().unwrap();
598 let tmp = tempfile::tempdir().unwrap();
599 unsafe { std::env::set_var("HOME", tmp.path()) };
600
601 let spec = IntegrationSpec {
602 id: "roundtrip".into(),
603 label: "Round Trip".into(),
604 binary: "mnml-rt".into(),
605 ..Default::default()
606 };
607 let p = install_integration(&spec).unwrap();
608 assert!(p.exists());
609 assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
610
611 let ids = list_installed_integrations().unwrap();
612 assert!(ids.contains(&"roundtrip".to_string()));
613
614 let removed = uninstall_integration("roundtrip").unwrap();
615 assert!(removed);
616 assert!(!p.exists());
617
618 let removed2 = uninstall_integration("roundtrip").unwrap();
620 assert!(!removed2);
621 }
622}