use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const DEFAULT_DUMP_DIR: &str = "llm_request_dumps";
static DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
pub(crate) fn dump_dir_from_env(value: Option<&str>) -> Option<PathBuf> {
let value = value?.trim();
match value.to_ascii_lowercase().as_str() {
"" | "0" | "false" => None,
"1" | "true" => Some(PathBuf::from(DEFAULT_DUMP_DIR)),
_ => Some(PathBuf::from(value)),
}
}
pub(crate) fn write_request_dump(
dir: &Path,
session_id: &str,
iteration: usize,
model: &str,
messages: &[Value],
tools: &[Value],
force_text: bool,
) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let seq = DUMP_SEQ.fetch_add(1, Ordering::Relaxed);
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let safe_session: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = dir.join(format!(
"{timestamp}_{seq:06}_{safe_session}_iter{iteration:03}.json"
));
let dump = json!({
"timestamp": timestamp.to_string(),
"session_id": session_id,
"iteration": iteration,
"model": model,
"force_text": force_text,
"message_count": messages.len(),
"messages": messages,
"tools": tools,
});
std::fs::write(&path, serde_json::to_string_pretty(&dump)?)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn env_unset_or_disabled_returns_none() {
assert_eq!(dump_dir_from_env(None), None);
assert_eq!(dump_dir_from_env(Some("")), None);
assert_eq!(dump_dir_from_env(Some("0")), None);
assert_eq!(dump_dir_from_env(Some("false")), None);
assert_eq!(dump_dir_from_env(Some("FALSE")), None);
}
#[test]
fn env_truthy_uses_default_dir() {
assert_eq!(
dump_dir_from_env(Some("1")),
Some(PathBuf::from("llm_request_dumps"))
);
assert_eq!(
dump_dir_from_env(Some("true")),
Some(PathBuf::from("llm_request_dumps"))
);
assert_eq!(
dump_dir_from_env(Some("TRUE")),
Some(PathBuf::from("llm_request_dumps"))
);
}
#[test]
fn env_path_value_used_as_dir() {
assert_eq!(
dump_dir_from_env(Some("/tmp/my_dumps")),
Some(PathBuf::from("/tmp/my_dumps"))
);
}
#[test]
fn write_dump_round_trips_payload() {
let dir = tempfile::tempdir().unwrap();
let messages = vec![
json!({"role": "system", "content": "sys prompt"}),
json!({"role": "user", "content": "hi"}),
];
let tools = vec![json!({"name": "terminal", "description": "run commands"})];
let path = write_request_dump(
dir.path(),
"telegram:123",
2,
"gpt-test",
&messages,
&tools,
false,
)
.unwrap();
let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["session_id"], "telegram:123");
assert_eq!(parsed["iteration"], 2);
assert_eq!(parsed["model"], "gpt-test");
assert_eq!(parsed["force_text"], false);
assert_eq!(parsed["message_count"], 2);
assert_eq!(parsed["messages"], Value::Array(messages));
assert_eq!(parsed["tools"], Value::Array(tools));
}
#[test]
fn filename_sanitizes_session_id_and_is_unique() {
let dir = tempfile::tempdir().unwrap();
let messages = vec![json!({"role": "user", "content": "x"})];
let p1 =
write_request_dump(dir.path(), "slack:C1/D2", 1, "m", &messages, &[], false).unwrap();
let p2 =
write_request_dump(dir.path(), "slack:C1/D2", 1, "m", &messages, &[], false).unwrap();
assert_ne!(p1, p2, "consecutive dumps must not collide");
let fname = p1.file_name().unwrap().to_str().unwrap();
assert!(
!fname.contains(':') && !fname.contains('/'),
"session separators must be sanitized out of the file name: {fname}"
);
assert!(fname.contains("slack_C1_D2"), "file name: {fname}");
}
#[test]
fn write_dump_creates_missing_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b");
let path = write_request_dump(&nested, "s", 1, "m", &[], &[], true).unwrap();
assert!(path.exists());
let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["force_text"], true);
}
}