taskers_runtime/
signals.rs1use taskers_domain::{SignalEvent, SignalKind};
2
3const OSC_PREFIX: &str = "\u{1b}]777;taskers;";
4const BEL: char = '\u{7}';
5const ST: &str = "\u{1b}\\";
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ParsedSignal {
9 pub kind: SignalKind,
10 pub message: Option<String>,
11 pub title: Option<String>,
12}
13
14#[derive(Debug, Default, Clone)]
15pub struct SignalStreamParser {
16 pending: String,
17}
18
19impl ParsedSignal {
20 pub fn into_event(self, source: impl Into<String>) -> SignalEvent {
21 SignalEvent::new(source, self.kind, self.message)
22 }
23}
24
25pub fn parse_signal_frames(buffer: &str) -> Vec<ParsedSignal> {
26 let mut parser = SignalStreamParser::default();
27 parser.push(buffer)
28}
29
30impl SignalStreamParser {
31 pub fn push(&mut self, chunk: &str) -> Vec<ParsedSignal> {
32 self.pending.push_str(chunk);
33
34 let mut frames = Vec::new();
35 let mut cursor = 0usize;
36 let mut keep_from = self.pending.len().saturating_sub(OSC_PREFIX.len());
37
38 while let Some(found) = self.pending[cursor..].find(OSC_PREFIX) {
39 let frame_start = cursor + found;
40 let content_start = frame_start + OSC_PREFIX.len();
41 let remainder = &self.pending[content_start..];
42
43 let Some((raw_frame, consumed)) = frame_slice(remainder) else {
44 keep_from = frame_start;
45 break;
46 };
47
48 if let Some(parsed) = parse_frame(raw_frame) {
49 frames.push(parsed);
50 }
51
52 cursor = content_start + consumed;
53 keep_from = cursor;
54 }
55
56 self.pending = self.pending[keep_from..].to_string();
57 frames
58 }
59}
60
61fn parse_frame(frame: &str) -> Option<ParsedSignal> {
62 let mut kind = None;
63 let mut message = None;
64 let mut title = None;
65
66 for part in frame.split(';') {
67 let (key, value) = part.split_once('=')?;
68 match key {
69 "kind" => {
70 kind = Some(match value {
71 "started" => SignalKind::Started,
72 "progress" => SignalKind::Progress,
73 "completed" => SignalKind::Completed,
74 "waiting_input" => SignalKind::WaitingInput,
75 "error" => SignalKind::Error,
76 "notification" => SignalKind::Notification,
77 _ => return None,
78 });
79 }
80 "message" => message = Some(value.replace("%20", " ")),
81 "title" => title = Some(value.replace("%20", " ")),
82 _ => {}
83 }
84 }
85
86 Some(ParsedSignal {
87 kind: kind?,
88 message,
89 title,
90 })
91}
92
93fn frame_slice(remainder: &str) -> Option<(&str, usize)> {
94 if let Some(end) = remainder.find(BEL) {
95 return Some((&remainder[..end], end + BEL.len_utf8()));
96 }
97 if let Some(end) = remainder.find(ST) {
98 return Some((&remainder[..end], end + ST.len()));
99 }
100 None
101}
102
103#[cfg(test)]
104mod tests {
105 use taskers_domain::SignalKind;
106
107 use super::{SignalStreamParser, parse_signal_frames};
108
109 #[test]
110 fn parses_multiple_frames_with_different_terminators() {
111 let output = concat!(
112 "hello",
113 "\u{1b}]777;taskers;kind=waiting_input;message=Need%20approval\u{7}",
114 "world",
115 "\u{1b}]777;taskers;kind=completed;message=Done\u{1b}\\",
116 );
117
118 let frames = parse_signal_frames(output);
119
120 assert_eq!(frames.len(), 2);
121 assert_eq!(frames[0].kind, SignalKind::WaitingInput);
122 assert_eq!(frames[0].message.as_deref(), Some("Need approval"));
123 assert_eq!(frames[1].kind, SignalKind::Completed);
124 }
125
126 #[test]
127 fn ignores_unknown_frames() {
128 let output = "\u{1b}]777;taskers;kind=unknown;message=Bad\u{7}";
129 assert!(parse_signal_frames(output).is_empty());
130 }
131
132 #[test]
133 fn stream_parser_handles_split_frames() {
134 let mut parser = SignalStreamParser::default();
135
136 assert!(
137 parser
138 .push("\u{1b}]777;taskers;kind=waiting_input;message=Need")
139 .is_empty()
140 );
141
142 let frames = parser.push("%20approval\u{7}");
143 assert_eq!(frames.len(), 1);
144 assert_eq!(frames[0].kind, SignalKind::WaitingInput);
145 assert_eq!(frames[0].message.as_deref(), Some("Need approval"));
146 }
147}