1use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10pub const PANEL_PROTO_VERSION: u32 = 2;
11
12pub const INPUT_DEBOUNCE_MS: u64 = 200;
15pub const MIN_QUERY_CHARS: usize = 2;
17pub const INPUT_SNAPSHOT_MAX_CHARS: usize = 2000;
19
20pub fn murmur_run_dir(mur_home: &Path) -> PathBuf {
23 mur_home.join("runtime").join("murmur")
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PanelSession {
29 pub pid: u32,
30 pub agent: String,
31 pub cwd: String,
32 pub sock: String,
33 #[serde(default)]
34 pub terminal_program: Option<String>,
35 pub proto_version: u32,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PanelTab {
41 Information,
42 Activities,
43 Preview,
44 Notifications,
45 Schedule,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum PreviewKind {
51 File,
52 Url,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum PanelFrame {
59 Hello {
61 session: PanelSession,
62 },
63 State {
65 cwd: String,
66 agent: String,
67 },
68 Panel {
70 focus: PanelTab,
71 },
72 Preview {
74 kind: PreviewKind,
75 target: String,
76 },
77 Stream {
79 delta: String,
80 },
81 InputChanged {
85 text: String,
86 },
87 Bye,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum HubFrame {
96 Insert { text: String },
97}
98
99pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Option<T> {
102 serde_json::from_str(line).ok()
103}
104
105pub fn input_snapshot(text: &str) -> String {
109 let redacted: Vec<String> = text
110 .split_whitespace()
111 .map(|tok| {
112 if looks_secret(tok) {
113 "[redacted]".to_string()
114 } else {
115 tok.to_string()
116 }
117 })
118 .collect();
119 let joined = redacted.join(" ");
120 let n = joined.chars().count();
121 if n <= INPUT_SNAPSHOT_MAX_CHARS {
122 joined
123 } else {
124 joined.chars().skip(n - INPUT_SNAPSHOT_MAX_CHARS).collect()
125 }
126}
127
128fn looks_secret(tok: &str) -> bool {
131 let is_b64ish = |s: &str| {
132 !s.is_empty()
133 && s.chars().all(|c| {
134 c.is_ascii_alphanumeric()
135 || c == '+'
136 || c == '/'
137 || c == '='
138 || c == '_'
139 || c == '-'
140 })
141 };
142 if let Some(rest) = tok.strip_prefix("sk-")
143 && rest.len() >= 16
144 && is_b64ish(rest)
145 {
146 return true;
147 }
148 if let Some(rest) = tok.strip_prefix("AKIA")
149 && rest.len() == 16
150 && rest
151 .chars()
152 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
153 {
154 return true;
155 }
156 tok.len() >= 32 && is_b64ish(tok) && tok.chars().any(|c| c.is_ascii_digit())
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::path::Path;
163
164 #[test]
165 fn run_dir_under_runtime() {
166 assert_eq!(
167 murmur_run_dir(Path::new("/h")),
168 Path::new("/h/runtime/murmur")
169 );
170 }
171
172 #[test]
173 fn frames_round_trip() {
174 let s = PanelSession {
175 pid: 42,
176 agent: "mur".into(),
177 cwd: "/tmp".into(),
178 sock: "/h/runtime/murmur/42.sock".into(),
179 terminal_program: Some("iTerm.app".into()),
180 proto_version: PANEL_PROTO_VERSION,
181 };
182 let line = serde_json::to_string(&PanelFrame::Hello { session: s }).unwrap();
183 assert!(line.contains("\"type\":\"hello\""));
184 assert!(matches!(
185 decode_line::<PanelFrame>(&line),
186 Some(PanelFrame::Hello { .. })
187 ));
188
189 let line = serde_json::to_string(&PanelFrame::Panel {
190 focus: PanelTab::Preview,
191 })
192 .unwrap();
193 assert!(line.contains("\"focus\":\"preview\""));
194
195 let line = serde_json::to_string(&PanelFrame::Panel {
196 focus: PanelTab::Schedule,
197 })
198 .unwrap();
199 assert!(line.contains("\"focus\":\"schedule\""));
200
201 let line = serde_json::to_string(&PanelFrame::Stream {
202 delta: "tok".into(),
203 })
204 .unwrap();
205 assert!(line.contains("\"type\":\"stream\""));
206 assert!(matches!(
207 decode_line::<PanelFrame>(&line),
208 Some(PanelFrame::Stream { delta }) if delta == "tok"
209 ));
210
211 let line = serde_json::to_string(&HubFrame::Insert {
212 text: "/help".into(),
213 })
214 .unwrap();
215 assert!(matches!(
216 decode_line::<HubFrame>(&line),
217 Some(HubFrame::Insert { text }) if text == "/help"
218 ));
219 }
220
221 #[test]
222 fn unknown_frames_are_none() {
223 assert!(decode_line::<PanelFrame>(r#"{"type":"from_the_future"}"#).is_none());
224 assert!(decode_line::<HubFrame>(r#"{"type":"execute","cmd":"rm"}"#).is_none());
225 assert!(decode_line::<HubFrame>("not json").is_none());
226 }
227
228 #[test]
229 fn input_changed_round_trips() {
230 let line = serde_json::to_string(&PanelFrame::InputChanged {
231 text: "run book".into(),
232 })
233 .unwrap();
234 assert!(line.contains("\"type\":\"input_changed\""));
235 assert!(matches!(
236 decode_line::<PanelFrame>(&line),
237 Some(PanelFrame::InputChanged { text }) if text == "run book"
238 ));
239 }
240
241 #[test]
242 fn snapshot_redacts_secrets() {
243 let s = input_snapshot("use sk-abcdefghijklmnop1234 please");
245 assert_eq!(s, "use [redacted] please");
246 let s = input_snapshot("key AKIAIOSFODNN7EXAMPLE here");
248 assert_eq!(s, "key [redacted] here");
249 let s = input_snapshot("token 0123456789abcdef0123456789abcdef end");
251 assert_eq!(s, "token [redacted] end");
252 assert_eq!(input_snapshot("fix the panel"), "fix the panel");
254 let hex31 = "0123456789abcdef0123456789abcde";
255 assert_eq!(input_snapshot(hex31), hex31);
256 }
257
258 #[test]
259 fn snapshot_redacts_across_newlines() {
260 let s = input_snapshot("line1\nsk-abcdefghijklmnop1234\tend");
261 assert_eq!(s, "line1 [redacted] end");
262 }
263
264 #[test]
265 fn snapshot_truncates_to_tail() {
266 let long = "a".repeat(INPUT_SNAPSHOT_MAX_CHARS + 100) + " tail";
267 let s = input_snapshot(&long);
268 assert!(s.chars().count() <= INPUT_SNAPSHOT_MAX_CHARS);
269 assert!(s.ends_with(" tail")); }
271}