use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::canonical;
pub type IntentId = String;
pub type SessionId = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelDescriptor {
pub provider: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Intent {
pub intent_id: IntentId,
pub prompt: String,
pub session_id: SessionId,
pub model: ModelDescriptor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_intent: Option<IntentId>,
pub created_at: u64,
}
impl Intent {
pub fn new(
prompt: impl Into<String>,
session_id: impl Into<SessionId>,
model: ModelDescriptor,
parent_intent: Option<IntentId>,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self::with_timestamp(prompt, session_id, model, parent_intent, now)
}
pub fn with_timestamp(
prompt: impl Into<String>,
session_id: impl Into<SessionId>,
model: ModelDescriptor,
parent_intent: Option<IntentId>,
created_at: u64,
) -> Self {
let prompt = prompt.into();
let session_id = session_id.into();
let intent_id = compute_intent_id(&prompt, &session_id, &model, parent_intent.as_deref());
Self {
intent_id,
prompt,
session_id,
model,
parent_intent,
created_at,
}
}
}
fn compute_intent_id(
prompt: &str,
session_id: &str,
model: &ModelDescriptor,
parent_intent: Option<&str>,
) -> IntentId {
let view = CanonicalIntentView {
prompt,
session_id,
model,
parent_intent,
};
canonical::hash(&view)
}
#[derive(Serialize)]
struct CanonicalIntentView<'a> {
prompt: &'a str,
session_id: &'a str,
model: &'a ModelDescriptor,
#[serde(skip_serializing_if = "Option::is_none")]
parent_intent: Option<&'a str>,
}
pub struct IntentLog {
dir: PathBuf,
}
impl IntentLog {
pub fn open(root: &Path) -> io::Result<Self> {
let dir = root.join("intents");
fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
fn path(&self, id: &IntentId) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
pub fn put(&self, intent: &Intent) -> io::Result<()> {
let path = self.path(&intent.intent_id);
if path.exists() {
return Ok(());
}
let bytes = serde_json::to_vec(intent)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
let mut f = fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
fs::rename(&tmp, &path)?;
Ok(())
}
pub fn get(&self, id: &IntentId) -> io::Result<Option<Intent>> {
let path = self.path(id);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let intent: Intent = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(intent))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn anthropic() -> ModelDescriptor {
ModelDescriptor {
provider: "anthropic".into(),
name: "claude-opus-4-7".into(),
version: None,
}
}
#[test]
fn same_prompt_session_model_hashes_equal() {
let a = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 1000,
);
let b = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 99999,
);
assert_eq!(a.intent_id, b.intent_id);
assert_ne!(a.created_at, b.created_at);
}
#[test]
fn different_prompts_hash_differently() {
let a = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 0,
);
let b = Intent::with_timestamp(
"fix the cache bug", "ses_abc", anthropic(), None, 0,
);
assert_ne!(a.intent_id, b.intent_id);
}
#[test]
fn different_sessions_hash_differently() {
let a = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 0,
);
let b = Intent::with_timestamp(
"fix the auth bug", "ses_xyz", anthropic(), None, 0,
);
assert_ne!(a.intent_id, b.intent_id);
}
#[test]
fn different_models_hash_differently() {
let a = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 0,
);
let mut model = anthropic();
model.name = "claude-sonnet-4-6".into();
let b = Intent::with_timestamp(
"fix the auth bug", "ses_abc", model, None, 0,
);
assert_ne!(a.intent_id, b.intent_id);
}
#[test]
fn refinement_chain_distinguishes_parent_intent() {
let a = Intent::with_timestamp(
"now also handle Y", "ses_abc", anthropic(), None, 0,
);
let b = Intent::with_timestamp(
"now also handle Y", "ses_abc", anthropic(),
Some("parent-intent-id".into()), 0,
);
assert_ne!(
a.intent_id, b.intent_id,
"an intent with a parent is causally distinct from one without",
);
}
#[test]
fn intent_id_is_64_char_lowercase_hex() {
let i = Intent::with_timestamp(
"test", "ses_abc", anthropic(), None, 0,
);
assert_eq!(i.intent_id.len(), 64);
assert!(i.intent_id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
}
#[test]
fn round_trip_through_serde_json() {
let i = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(),
Some("parent".into()), 12345,
);
let json = serde_json::to_string(&i).unwrap();
let back: Intent = serde_json::from_str(&json).unwrap();
assert_eq!(i, back);
}
#[test]
fn canonical_form_is_stable_for_a_known_input() {
let i = Intent::with_timestamp(
"fix the auth bug",
"ses_abc",
ModelDescriptor {
provider: "anthropic".into(),
name: "claude-opus-4-7".into(),
version: None,
},
None,
0,
);
assert_eq!(
i.intent_id,
"5ede62683a249cd00afff49fdf56e8f659fe878a668c8b61e36f5fbc1de7c734",
);
}
#[test]
fn intent_log_round_trips_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let log = IntentLog::open(tmp.path()).unwrap();
let i = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 100,
);
log.put(&i).unwrap();
let read_back = log.get(&i.intent_id).unwrap().unwrap();
assert_eq!(i, read_back);
}
#[test]
fn intent_log_get_unknown_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let log = IntentLog::open(tmp.path()).unwrap();
assert!(log.get(&"nonexistent".to_string()).unwrap().is_none());
}
#[test]
fn intent_log_put_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let log = IntentLog::open(tmp.path()).unwrap();
let i = Intent::with_timestamp(
"fix the auth bug", "ses_abc", anthropic(), None, 100,
);
log.put(&i).unwrap();
log.put(&i).unwrap();
let read_back = log.get(&i.intent_id).unwrap().unwrap();
assert_eq!(i, read_back);
}
}