use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
pub fn dir() -> Option<PathBuf> {
match std::env::var("NSED_DUMP_PROMPTS_DIR") {
Ok(d) if !d.trim().is_empty() => Some(PathBuf::from(d)),
_ => None,
}
}
pub struct Meta<'a> {
pub agent: &'a str,
pub round: Option<u32>,
pub phase: Option<&'a str>,
}
pub fn dump(meta: &Meta, provider: &str, ext: &str, body: &str) {
let Some(dir) = dir() else {
return;
};
if let Err(e) = dump_to(&dir, meta, provider, ext, body) {
tracing::warn!(error = %e, dir = %dir.display(), "dump_prompts: write failed");
}
}
fn dump_to(
dir: &Path,
meta: &Meta,
provider: &str,
ext: &str,
body: &str,
) -> std::io::Result<PathBuf> {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let mut name = format!("{seq:06}-{}", sanitize(meta.agent));
if let Some(r) = meta.round {
name.push_str(&format!("-r{r}"));
}
if let Some(p) = meta.phase {
name.push_str(&format!("-{}", sanitize(p)));
}
name.push_str(&format!("-{}.{}", sanitize(provider), sanitize(ext)));
std::fs::create_dir_all(dir)?;
let path = dir.join(name);
std::fs::write(&path, body)?;
Ok(path)
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub fn render_claude(command: &[String], prompt: &str) -> String {
let mut out = String::new();
for (i, a) in command.iter().enumerate() {
out.push_str(&format!("[{i}] {a}\n"));
}
out.push_str("\n----- stdin prompt -----\n");
out.push_str(prompt);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dump_to_writes_labeled_file_with_body() {
let dir = std::env::temp_dir().join(format!("nsed-dump-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let meta = Meta {
agent: "PropductBot Fast",
round: Some(3),
phase: Some("propose"),
};
let path = dump_to(&dir, &meta, "claude", "txt", "hello body").unwrap();
let fname = path.file_name().unwrap().to_string_lossy();
assert!(
fname.ends_with("-claude.txt"),
"provider+ext in name: {fname}"
);
assert!(
fname.contains("-r3-propose-"),
"round+phase in name: {fname}"
);
assert!(
fname.contains("PropductBot_Fast"),
"sanitized agent: {fname}"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello body");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dump_to_omits_absent_round_and_phase() {
let dir = std::env::temp_dir().join(format!("nsed-dump-min-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let meta = Meta {
agent: "agentA",
round: None,
phase: None,
};
let path = dump_to(&dir, &meta, "openai", "json", "{}").unwrap();
let fname = path.file_name().unwrap().to_string_lossy();
assert!(
fname.contains("-agentA-openai.json"),
"minimal name: {fname}"
);
assert!(!fname.contains("-r"), "no round segment: {fname}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dir_none_when_env_unset_and_sanitize_maps_unsafe_chars() {
assert!(dir().is_none(), "unset env → feature off");
assert_eq!(sanitize("a/b c:d"), "a_b_c_d");
}
#[test]
fn render_claude_lists_argv_then_prompt() {
let cmd = vec![
"claude".to_string(),
"--model".to_string(),
"opus".to_string(),
];
let out = render_claude(&cmd, "the task");
assert!(out.contains("[0] claude"));
assert!(out.contains("[1] --model"));
assert!(out.contains("----- stdin prompt -----\nthe task"));
}
}