use std::path::{Path, PathBuf};
use misanthropic::Prompt;
use sha2::Digest;
#[derive(Debug, thiserror::Error)]
pub enum PromptLogError {
#[error("serializing prompt: {0}")]
Serialize(#[from] serde_json::Error),
#[error("writing {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl PromptLogError {
fn io(path: impl Into<PathBuf>) -> impl FnOnce(std::io::Error) -> Self {
let path = path.into();
move |source| Self::Io { path, source }
}
}
pub async fn save(
prompt: &Prompt,
dir: impl AsRef<Path>,
) -> Result<(PathBuf, String), PromptLogError> {
let json = serde_json::to_vec_pretty(prompt)?;
let hash = hex::encode(sha2::Sha256::digest(&json));
let dir = dir.as_ref().join(&hash[..2]);
tokio::fs::create_dir_all(&dir)
.await
.map_err(PromptLogError::io(&dir))?;
let path = dir.join(format!("{hash}.json"));
if let Ok(true) = tokio::fs::try_exists(&path).await {
return Ok((path, hash));
}
tokio::fs::write(&path, &json)
.await
.map_err(PromptLogError::io(&path))?;
Ok((path, hash))
}
#[cfg(test)]
mod tests {
use super::*;
use misanthropic::prompt::Message;
use misanthropic::prompt::message::{Content, Role};
fn prompt(text: &str) -> Prompt {
Prompt {
messages: vec![Message {
role: Role::User,
content: Content::text(text.to_owned()),
}],
..Default::default()
}
}
#[tokio::test]
async fn writes_sharded_by_hash_prefix() {
let dir = tempfile::tempdir().unwrap();
let (path, hash) = save(&prompt("hello"), dir.path()).await.unwrap();
assert!(path.exists());
assert_eq!(path.file_name().unwrap(), format!("{hash}.json").as_str());
assert_eq!(path.parent().unwrap().file_name().unwrap(), &hash[..2]);
}
#[tokio::test]
async fn filename_is_the_digest_of_the_file() {
let dir = tempfile::tempdir().unwrap();
let (path, hash) = save(&prompt("hello"), dir.path()).await.unwrap();
let written = tokio::fs::read(&path).await.unwrap();
assert_eq!(hex::encode(sha2::Sha256::digest(&written)), hash);
}
#[tokio::test]
async fn identical_prompts_collapse_to_one_file() {
let dir = tempfile::tempdir().unwrap();
let (first, _) = save(&prompt("hello"), dir.path()).await.unwrap();
let (second, _) = save(&prompt("hello"), dir.path()).await.unwrap();
assert_eq!(first, second);
let shard = std::fs::read_dir(first.parent().unwrap()).unwrap();
assert_eq!(shard.count(), 1);
}
#[tokio::test]
async fn differing_prompts_get_distinct_files() {
let dir = tempfile::tempdir().unwrap();
let (first, _) = save(&prompt("hello"), dir.path()).await.unwrap();
let (second, _) = save(&prompt("goodbye"), dir.path()).await.unwrap();
assert_ne!(first, second);
assert!(first.exists() && second.exists());
}
#[tokio::test]
async fn creates_missing_intermediate_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("logs").join("prompts");
let (path, _) = save(&prompt("hello"), &nested).await.unwrap();
assert!(path.starts_with(&nested));
assert!(path.exists());
}
}