use crate::resp::{Command, RespResponse, RespView};
use std::{
collections::BTreeSet,
io::Write,
sync::{LazyLock, Mutex},
};
type Observation = (String, String, String, &'static str);
static OBSERVATIONS: LazyLock<Mutex<BTreeSet<Observation>>> =
LazyLock::new(|| Mutex::new(BTreeSet::new()));
const CONTAINER_COMMANDS: &[&[u8]] = &[
b"ACL",
b"CLIENT",
b"CLUSTER",
b"COMMAND",
b"CONFIG",
b"DEBUG",
b"FUNCTION",
b"LATENCY",
b"MEMORY",
b"MODULE",
b"OBJECT",
b"PUBSUB",
b"SCRIPT",
b"SENTINEL",
b"SLOWLOG",
b"XGROUP",
b"XINFO",
];
pub(crate) fn label(command: &Command) -> String {
let name = String::from_utf8_lossy(command.name()).to_uppercase();
if CONTAINER_COMMANDS.contains(&name.as_bytes())
&& let Some(arg) = command.get_arg(0)
{
return format!("{name} {}", String::from_utf8_lossy(&arg).to_uppercase());
}
name
}
fn kind(response: &RespResponse) -> String {
let Ok(view) = response.view() else {
return "Unreadable".to_owned();
};
match view {
RespView::SimpleString(ss) if ss == b"OK" => "SimpleString(OK)".to_owned(),
RespView::SimpleString(_) => "SimpleString".to_owned(),
RespView::Integer(i, _) if i == 0 || i == 1 => format!("Integer({i})"),
RespView::Integer(..) => "Integer".to_owned(),
RespView::Double(..) => "Double".to_owned(),
RespView::BulkString(_) => "BulkString".to_owned(),
RespView::Boolean(_) => "Boolean".to_owned(),
RespView::IntegerArray(a) => empty_or("IntegerArray", a.is_empty()),
RespView::OwnedArray(a) => empty_or("Array", a.is_empty()),
RespView::Array(c) => empty_or("Array", c.len() == 0),
RespView::Map(c) => empty_or("Map", c.len() == 0),
RespView::Set(c) => empty_or("Set", c.len() == 0),
RespView::Push(_) => "Push".to_owned(),
RespView::Error(_) => "Error".to_owned(),
RespView::Null => "Null".to_owned(),
}
}
fn empty_or(name: &str, is_empty: bool) -> String {
if is_empty {
format!("Empty{name}")
} else {
name.to_owned()
}
}
pub(crate) fn record(
label: String,
declared: &'static str,
response: &RespResponse,
decoded: bool,
) {
let observation = (
label,
normalize_declared(declared),
kind(response),
if decoded { "decoded" } else { "refused" },
);
let Ok(mut observations) = OBSERVATIONS.lock() else {
return;
};
if observations.insert(observation) {
flush(&observations);
}
}
fn normalize_declared(declared: &str) -> String {
let mut out = String::with_capacity(declared.len());
let mut segment_start = 0;
for (i, c) in declared.char_indices() {
if !is_path_char(c) {
out.push_str(last_segment(&declared[segment_start..i]));
out.push(c);
segment_start = i + c.len_utf8();
}
}
out.push_str(last_segment(&declared[segment_start..]));
out
}
fn is_path_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == ':'
}
fn last_segment(path: &str) -> &str {
match path.rsplit_once("::") {
Some((_, tail)) => tail,
None => path,
}
}
fn flush(observations: &BTreeSet<Observation>) {
let mut content = String::new();
for (label, declared, kind, outcome) in observations {
content.push_str(label);
content.push('\t');
content.push_str(declared);
content.push('\t');
content.push_str(kind);
content.push('\t');
content.push_str(outcome);
content.push('\n');
}
let path = dump_path();
if let Some(parent) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = std::fs::File::create(&path) {
let _ = file.write_all(content.as_bytes());
}
}
pub(crate) fn dump_path() -> String {
std::env::var("RUSTIS_RESPONSE_PROBE")
.unwrap_or_else(|_| "target/response_shape.tsv".to_owned())
}