1use serde::Serialize;
46use std::fs;
47use std::io;
48use std::path::PathBuf;
49
50#[derive(Debug, Clone, Default, Serialize)]
54pub struct IntegrationSpec {
55 pub id: String,
56 pub name: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub description: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub version: Option<String>,
61 pub binary: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub category: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub chip: Option<ChipSpec>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub commands: Vec<CommandSpec>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub context_menu: Vec<ContextMenuEntry>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub menu_bar: Vec<MenuBarEntry>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub statusline: Option<StatuslineSpec>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub settings: Vec<SettingsPage>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub notifications: Option<NotificationsSpec>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub requires: Option<Requires>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct ChipSpec {
85 pub glyph: String,
86 pub fallback: String,
87 pub color: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub tooltip: Option<String>,
90 pub enabled: bool,
91 pub in_palette_bar: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub badge_key: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct CommandSpec {
98 pub id: String,
99 pub title: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub group: Option<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub keys: Vec<String>,
104 pub run: String,
105}
106
107#[derive(Debug, Clone, Serialize)]
108pub struct ContextMenuEntry {
109 pub target: String,
111 pub title: String,
112 pub command: String,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct MenuBarEntry {
117 pub path: String,
119 pub command: String,
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct StatuslineSpec {
124 pub side: String,
126 pub segment_id: String,
127 #[serde(skip_serializing_if = "String::is_empty")]
128 pub initial_text: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub initial_color: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub click_command: Option<String>,
133 pub priority: u8,
134 pub min_width: u16,
135 pub max_width: u16,
136}
137
138#[derive(Debug, Clone, Serialize)]
139pub struct SettingsPage {
140 pub section: String,
141 pub label: String,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub help: Option<String>,
144}
145
146#[derive(Debug, Clone, Copy, Default, Serialize)]
147#[serde(rename_all = "snake_case")]
148pub enum OsNotifyPolicy {
149 #[default]
150 Never,
151 ErrorOnly,
152 Always,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct NotificationsSpec {
157 pub os_notify_on: OsNotifyPolicy,
158 pub os_rate_limit_sec: u64,
159}
160
161#[derive(Debug, Clone, Serialize)]
162pub struct Requires {
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub env: Vec<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub binary: Option<String>,
167}
168
169pub fn install_integration(spec: &IntegrationSpec) -> io::Result<PathBuf> {
179 validate_id(&spec.id)?;
180 let dir = user_integration_dir()?;
181 fs::create_dir_all(&dir)?;
182 let path = dir.join(format!("{}.toml", spec.id));
183 let toml = toml_serialize(spec)?;
184 fs::write(&path, toml)?;
185 Ok(path)
186}
187
188pub fn uninstall_integration(id: &str) -> io::Result<bool> {
193 validate_id(id)?;
194 let path = integration_manifest_path(id)?;
195 match fs::remove_file(&path) {
196 Ok(()) => Ok(true),
197 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
198 Err(e) => Err(e),
199 }
200}
201
202pub fn list_installed_integrations() -> io::Result<Vec<String>> {
206 let dir = user_integration_dir()?;
207 let entries = match fs::read_dir(&dir) {
208 Ok(e) => e,
209 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
210 Err(e) => return Err(e),
211 };
212 let mut out: Vec<String> = Vec::new();
213 for entry in entries.flatten() {
214 let name = entry.file_name();
215 let Some(name) = name.to_str() else { continue };
216 if let Some(id) = name.strip_suffix(".toml")
217 && !id.is_empty()
218 {
219 out.push(id.to_string());
220 }
221 }
222 out.sort();
223 Ok(out)
224}
225
226pub fn integration_manifest_path(id: &str) -> io::Result<PathBuf> {
229 validate_id(id)?;
230 Ok(user_integration_dir()?.join(format!("{id}.toml")))
231}
232
233fn user_integration_dir() -> io::Result<PathBuf> {
234 let home = std::env::var_os("HOME")
235 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME is not set"))?;
236 Ok(PathBuf::from(home)
237 .join(".config")
238 .join("mnml")
239 .join("integrations"))
240}
241
242fn validate_id(id: &str) -> io::Result<()> {
243 if id.is_empty() {
244 return Err(io::Error::new(io::ErrorKind::InvalidInput, "id is empty"));
245 }
246 if id.contains(['/', '\\', '\0']) {
247 return Err(io::Error::new(
248 io::ErrorKind::InvalidInput,
249 format!("id contains path characters: {id}"),
250 ));
251 }
252 Ok(())
253}
254
255fn toml_serialize<T: Serialize>(v: &T) -> io::Result<String> {
256 let json = serde_json::to_value(v)
267 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}")))?;
268 Ok(json_to_toml(&json))
269}
270
271fn json_to_toml(v: &serde_json::Value) -> String {
276 let mut out = String::new();
277 let Some(map) = v.as_object() else {
278 return out;
279 };
280 for (k, val) in map {
282 if val.is_object() || val.is_array() {
283 continue;
284 }
285 push_kv(&mut out, k, val);
286 }
287 for (k, val) in map {
289 match val {
290 serde_json::Value::Object(_) => {
291 out.push_str(&format!("\n[{k}]\n"));
292 for (inner_k, inner_v) in val.as_object().unwrap() {
293 if inner_v.is_object() || inner_v.is_array() {
294 continue;
295 }
296 push_kv(&mut out, inner_k, inner_v);
297 }
298 }
299 serde_json::Value::Array(arr) => {
300 for item in arr {
301 if let Some(obj) = item.as_object() {
302 out.push_str(&format!("\n[[{k}]]\n"));
303 for (inner_k, inner_v) in obj {
304 push_kv(&mut out, inner_k, inner_v);
305 }
306 }
307 }
308 }
309 _ => {}
310 }
311 }
312 out
313}
314
315fn push_kv(out: &mut String, k: &str, v: &serde_json::Value) {
316 match v {
317 serde_json::Value::String(s) => {
318 out.push_str(&format!("{k} = {}\n", toml_str(s)));
319 }
320 serde_json::Value::Number(n) => {
321 out.push_str(&format!("{k} = {n}\n"));
322 }
323 serde_json::Value::Bool(b) => {
324 out.push_str(&format!("{k} = {b}\n"));
325 }
326 serde_json::Value::Array(arr) => {
327 let items: Vec<String> = arr
328 .iter()
329 .filter_map(|x| x.as_str().map(toml_str))
330 .collect();
331 out.push_str(&format!("{k} = [{}]\n", items.join(", ")));
332 }
333 _ => {}
334 }
335}
336
337fn toml_str(s: &str) -> String {
338 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
340 format!("\"{escaped}\"")
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn validate_id_rejects_dangerous_chars() {
349 assert!(validate_id("").is_err());
350 assert!(validate_id("../foo").is_err());
351 assert!(validate_id("a/b").is_err());
352 assert!(validate_id("a\\b").is_err());
353 assert!(validate_id("valid_id-123").is_ok());
354 }
355
356 #[test]
357 fn serializes_minimal_spec_to_toml() {
358 let spec = IntegrationSpec {
359 id: "slack".into(),
360 name: "Slack".into(),
361 binary: "mnml-msg-slack".into(),
362 ..Default::default()
363 };
364 let toml = toml_serialize(&spec).unwrap();
365 assert!(toml.contains("id = \"slack\""));
366 assert!(toml.contains("name = \"Slack\""));
367 assert!(toml.contains("binary = \"mnml-msg-slack\""));
368 }
369
370 #[test]
371 fn serializes_full_spec_with_chip_and_commands() {
372 let spec = IntegrationSpec {
373 id: "slack".into(),
374 name: "Slack".into(),
375 binary: "mnml-msg-slack".into(),
376 chip: Some(ChipSpec {
377 glyph: "S".into(),
378 fallback: "Sk".into(),
379 color: "purple".into(),
380 tooltip: None,
381 enabled: true,
382 in_palette_bar: false,
383 badge_key: None,
384 }),
385 commands: vec![CommandSpec {
386 id: "slack.open".into(),
387 title: "Slack: open".into(),
388 group: Some("integrations".into()),
389 keys: vec!["<leader>iS".into()],
390 run: ":term mnml-msg-slack".into(),
391 }],
392 ..Default::default()
393 };
394 let toml = toml_serialize(&spec).unwrap();
395 assert!(toml.contains("[chip]"));
396 assert!(toml.contains("glyph = \"S\""));
397 assert!(toml.contains("[[commands]]"));
398 assert!(toml.contains("id = \"slack.open\""));
399 assert!(toml.contains("keys = [\"<leader>iS\"]"));
400 }
401
402 #[test]
403 fn install_and_uninstall_round_trip() {
404 let tmp = tempfile::tempdir().unwrap();
407 unsafe { std::env::set_var("HOME", tmp.path()) };
408
409 let spec = IntegrationSpec {
410 id: "roundtrip".into(),
411 name: "Round Trip".into(),
412 binary: "mnml-rt".into(),
413 ..Default::default()
414 };
415 let p = install_integration(&spec).unwrap();
416 assert!(p.exists());
417 assert_eq!(p.file_name().unwrap(), "roundtrip.toml");
418
419 let ids = list_installed_integrations().unwrap();
420 assert!(ids.contains(&"roundtrip".to_string()));
421
422 let removed = uninstall_integration("roundtrip").unwrap();
423 assert!(removed);
424 assert!(!p.exists());
425
426 let removed2 = uninstall_integration("roundtrip").unwrap();
428 assert!(!removed2);
429 }
430}