use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const PANEL_PROTO_VERSION: u32 = 2;
pub const INPUT_DEBOUNCE_MS: u64 = 200;
pub const MIN_QUERY_CHARS: usize = 2;
pub const INPUT_SNAPSHOT_MAX_CHARS: usize = 2000;
pub fn murmur_run_dir(mur_home: &Path) -> PathBuf {
mur_home.join("runtime").join("murmur")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelSession {
pub pid: u32,
pub agent: String,
pub cwd: String,
pub sock: String,
#[serde(default)]
pub terminal_program: Option<String>,
pub proto_version: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanelTab {
Information,
Activities,
Preview,
Notifications,
Schedule,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PreviewKind {
File,
Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PanelFrame {
Hello {
session: PanelSession,
},
State {
cwd: String,
agent: String,
},
Panel {
focus: PanelTab,
},
Preview {
kind: PreviewKind,
target: String,
},
Stream {
delta: String,
},
InputChanged {
text: String,
},
Bye,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HubFrame {
Insert { text: String },
}
pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Option<T> {
serde_json::from_str(line).ok()
}
pub fn input_snapshot(text: &str) -> String {
let redacted: Vec<String> = text
.split_whitespace()
.map(|tok| {
if looks_secret(tok) {
"[redacted]".to_string()
} else {
tok.to_string()
}
})
.collect();
let joined = redacted.join(" ");
let n = joined.chars().count();
if n <= INPUT_SNAPSHOT_MAX_CHARS {
joined
} else {
joined.chars().skip(n - INPUT_SNAPSHOT_MAX_CHARS).collect()
}
}
fn looks_secret(tok: &str) -> bool {
let is_b64ish = |s: &str| {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| c == '+'
|| c == '/'
|| c == '='
|| c == '_'
|| c == '-'
})
};
if let Some(rest) = tok.strip_prefix("sk-")
&& rest.len() >= 16
&& is_b64ish(rest)
{
return true;
}
if let Some(rest) = tok.strip_prefix("AKIA")
&& rest.len() == 16
&& rest
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return true;
}
tok.len() >= 32 && is_b64ish(tok) && tok.chars().any(|c| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn run_dir_under_runtime() {
assert_eq!(
murmur_run_dir(Path::new("/h")),
Path::new("/h/runtime/murmur")
);
}
#[test]
fn frames_round_trip() {
let s = PanelSession {
pid: 42,
agent: "mur".into(),
cwd: "/tmp".into(),
sock: "/h/runtime/murmur/42.sock".into(),
terminal_program: Some("iTerm.app".into()),
proto_version: PANEL_PROTO_VERSION,
};
let line = serde_json::to_string(&PanelFrame::Hello { session: s }).unwrap();
assert!(line.contains("\"type\":\"hello\""));
assert!(matches!(
decode_line::<PanelFrame>(&line),
Some(PanelFrame::Hello { .. })
));
let line = serde_json::to_string(&PanelFrame::Panel {
focus: PanelTab::Preview,
})
.unwrap();
assert!(line.contains("\"focus\":\"preview\""));
let line = serde_json::to_string(&PanelFrame::Panel {
focus: PanelTab::Schedule,
})
.unwrap();
assert!(line.contains("\"focus\":\"schedule\""));
let line = serde_json::to_string(&PanelFrame::Stream {
delta: "tok".into(),
})
.unwrap();
assert!(line.contains("\"type\":\"stream\""));
assert!(matches!(
decode_line::<PanelFrame>(&line),
Some(PanelFrame::Stream { delta }) if delta == "tok"
));
let line = serde_json::to_string(&HubFrame::Insert {
text: "/help".into(),
})
.unwrap();
assert!(matches!(
decode_line::<HubFrame>(&line),
Some(HubFrame::Insert { text }) if text == "/help"
));
}
#[test]
fn unknown_frames_are_none() {
assert!(decode_line::<PanelFrame>(r#"{"type":"from_the_future"}"#).is_none());
assert!(decode_line::<HubFrame>(r#"{"type":"execute","cmd":"rm"}"#).is_none());
assert!(decode_line::<HubFrame>("not json").is_none());
}
#[test]
fn input_changed_round_trips() {
let line = serde_json::to_string(&PanelFrame::InputChanged {
text: "run book".into(),
})
.unwrap();
assert!(line.contains("\"type\":\"input_changed\""));
assert!(matches!(
decode_line::<PanelFrame>(&line),
Some(PanelFrame::InputChanged { text }) if text == "run book"
));
}
#[test]
fn snapshot_redacts_secrets() {
let s = input_snapshot("use sk-abcdefghijklmnop1234 please");
assert_eq!(s, "use [redacted] please");
let s = input_snapshot("key AKIAIOSFODNN7EXAMPLE here");
assert_eq!(s, "key [redacted] here");
let s = input_snapshot("token 0123456789abcdef0123456789abcdef end");
assert_eq!(s, "token [redacted] end");
assert_eq!(input_snapshot("fix the panel"), "fix the panel");
let hex31 = "0123456789abcdef0123456789abcde";
assert_eq!(input_snapshot(hex31), hex31);
}
#[test]
fn snapshot_redacts_across_newlines() {
let s = input_snapshot("line1\nsk-abcdefghijklmnop1234\tend");
assert_eq!(s, "line1 [redacted] end");
}
#[test]
fn snapshot_truncates_to_tail() {
let long = "a".repeat(INPUT_SNAPSHOT_MAX_CHARS + 100) + " tail";
let s = input_snapshot(&long);
assert!(s.chars().count() <= INPUT_SNAPSHOT_MAX_CHARS);
assert!(s.ends_with(" tail")); }
}