Skip to main content

lepiter_core/
plugin.rs

1//! plugin sdk for external snippet renderers.
2
3use std::io::{self, BufRead, Write};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// ipc request sent to an external snippet plugin.
9#[derive(Debug, Deserialize, Serialize)]
10pub struct PluginRequest {
11    /// snippet type (e.g. `wardleyMap`).
12    #[serde(rename = "type")]
13    pub typ: String,
14    /// raw snippet json.
15    pub snippet: Value,
16}
17
18/// ipc response returned by an external snippet plugin.
19#[derive(Debug, Serialize, Deserialize)]
20pub struct PluginResponse {
21    /// whether the render succeeded.
22    pub ok: bool,
23    /// rendered text lines.
24    pub lines: Vec<String>,
25    /// optional error message.
26    pub error: Option<String>,
27}
28
29impl PluginResponse {
30    /// successful response with rendered lines.
31    pub fn ok(lines: Vec<String>) -> Self {
32        Self {
33            ok: true,
34            lines,
35            error: None,
36        }
37    }
38
39    /// error response with message.
40    pub fn error(message: impl Into<String>) -> Self {
41        Self {
42            ok: false,
43            lines: Vec::new(),
44            error: Some(message.into()),
45        }
46    }
47}
48
49/// runs a newline-delimited json plugin loop over the given reader and writer.
50///
51/// the handler is invoked once per request. responses are serialized back to the writer.
52/// use [`plugin_loop`] for the common case of stdin/stdout.
53pub fn plugin_loop_io<R, W, F>(reader: R, mut writer: W, mut handler: F) -> io::Result<()>
54where
55    R: BufRead,
56    W: Write,
57    F: FnMut(PluginRequest) -> PluginResponse,
58{
59    for line in reader.lines() {
60        let line = match line {
61            Ok(line) => line,
62            Err(_) => break,
63        };
64        if line.trim().is_empty() {
65            continue;
66        }
67        let req: Result<PluginRequest, _> = serde_json::from_str(&line);
68        let resp = match req {
69            Ok(req) => handler(req),
70            Err(err) => PluginResponse::error(format!("invalid request: {err}")),
71        };
72        let json = serde_json::to_string(&resp).unwrap_or_else(|_| {
73            "{\"ok\":false,\"lines\":[],\"error\":\"encode failed\"}".to_string()
74        });
75        writeln!(writer, "{json}")?;
76        writer.flush()?;
77    }
78    Ok(())
79}
80
81/// runs a newline-delimited json plugin loop on stdin/stdout.
82///
83/// convenience wrapper around [`plugin_loop_io`].
84pub fn plugin_loop<F>(handler: F) -> io::Result<()>
85where
86    F: FnMut(PluginRequest) -> PluginResponse,
87{
88    let stdin = io::stdin();
89    let reader = stdin.lock();
90    let writer = io::BufWriter::new(io::stdout());
91    plugin_loop_io(reader, writer, handler)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::io::Cursor;
98
99    fn run_loop(input: &str, handler: impl FnMut(PluginRequest) -> PluginResponse) -> String {
100        let reader = Cursor::new(input.as_bytes().to_vec());
101        let mut output = Vec::new();
102        plugin_loop_io(reader, &mut output, handler).unwrap();
103        String::from_utf8(output).unwrap()
104    }
105
106    fn echo_handler(req: PluginRequest) -> PluginResponse {
107        PluginResponse::ok(vec![format!("echo: {}", req.typ)])
108    }
109
110    #[test]
111    fn valid_request() {
112        let input = "{\"type\":\"wardleyMap\",\"snippet\":{}}\n";
113        let output = run_loop(input, echo_handler);
114        let resp: PluginResponse = serde_json::from_str(output.trim()).unwrap();
115        assert!(resp.ok);
116        assert_eq!(resp.lines, vec!["echo: wardleyMap"]);
117        assert!(resp.error.is_none());
118    }
119
120    #[test]
121    fn malformed_json() {
122        let input = "not valid json\n";
123        let output = run_loop(input, echo_handler);
124        let resp: PluginResponse = serde_json::from_str(output.trim()).unwrap();
125        assert!(!resp.ok);
126        assert!(resp.error.as_ref().unwrap().contains("invalid request"));
127    }
128
129    #[test]
130    fn empty_lines_skipped() {
131        let input = "\n   \n{\"type\":\"a\",\"snippet\":null}\n\n";
132        let output = run_loop(input, echo_handler);
133        let lines: Vec<&str> = output.trim().lines().collect();
134        assert_eq!(lines.len(), 1);
135        let resp: PluginResponse = serde_json::from_str(lines[0]).unwrap();
136        assert!(resp.ok);
137        assert_eq!(resp.lines, vec!["echo: a"]);
138    }
139
140    #[test]
141    fn handler_error_propagated() {
142        let input = "{\"type\":\"x\",\"snippet\":{}}\n";
143        let output = run_loop(input, |_| PluginResponse::error("boom"));
144        let resp: PluginResponse = serde_json::from_str(output.trim()).unwrap();
145        assert!(!resp.ok);
146        assert_eq!(resp.error.as_deref(), Some("boom"));
147        assert!(resp.lines.is_empty());
148    }
149
150    #[test]
151    fn serialization_roundtrip() {
152        let req = PluginRequest {
153            typ: "code".to_string(),
154            snippet: serde_json::json!({"lang": "rust"}),
155        };
156        let json = serde_json::to_string(&req).unwrap();
157        let decoded: PluginRequest = serde_json::from_str(&json).unwrap();
158        assert_eq!(decoded.typ, "code");
159        assert_eq!(decoded.snippet["lang"], "rust");
160
161        let resp = PluginResponse::ok(vec!["line1".into(), "line2".into()]);
162        let json = serde_json::to_string(&resp).unwrap();
163        let decoded: PluginResponse = serde_json::from_str(&json).unwrap();
164        assert!(decoded.ok);
165        assert_eq!(decoded.lines, vec!["line1", "line2"]);
166        assert!(decoded.error.is_none());
167    }
168
169    #[test]
170    fn multiple_requests() {
171        let input = "{\"type\":\"a\",\"snippet\":{}}\n{\"type\":\"b\",\"snippet\":{}}\n";
172        let mut calls = 0usize;
173        let output = run_loop(input, |req| {
174            calls += 1;
175            PluginResponse::ok(vec![req.typ])
176        });
177        let lines: Vec<PluginResponse> = output
178            .trim()
179            .lines()
180            .map(|l| serde_json::from_str(l).unwrap())
181            .collect();
182        assert_eq!(lines.len(), 2);
183        assert_eq!(lines[0].lines, vec!["a"]);
184        assert_eq!(lines[1].lines, vec!["b"]);
185    }
186}