1use serde::{Deserialize, Serialize};
40use std::fs;
41use std::io::{self, Write};
42use std::path::{Path, PathBuf};
43use std::time::{SystemTime, UNIX_EPOCH};
44
45use crate::canonical;
46
47pub type IntentId = String;
52
53pub type SessionId = String;
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ModelDescriptor {
62 pub provider: String,
64 pub name: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub version: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Intent {
80 pub intent_id: IntentId,
81 pub prompt: String,
82 pub session_id: SessionId,
83 pub model: ModelDescriptor,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub parent_intent: Option<IntentId>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub issue_id: Option<crate::issue::IssueId>,
94 pub created_at: u64,
98}
99
100impl Intent {
101 pub fn new(
106 prompt: impl Into<String>,
107 session_id: impl Into<SessionId>,
108 model: ModelDescriptor,
109 parent_intent: Option<IntentId>,
110 ) -> Self {
111 let now = SystemTime::now()
112 .duration_since(UNIX_EPOCH)
113 .map(|d| d.as_secs())
114 .unwrap_or(0);
115 Self::with_timestamp(prompt, session_id, model, parent_intent, now)
116 }
117
118 pub fn with_timestamp(
122 prompt: impl Into<String>,
123 session_id: impl Into<SessionId>,
124 model: ModelDescriptor,
125 parent_intent: Option<IntentId>,
126 created_at: u64,
127 ) -> Self {
128 let prompt = prompt.into();
129 let session_id = session_id.into();
130 let intent_id =
131 compute_intent_id(&prompt, &session_id, &model, parent_intent.as_deref(), None);
132 Self {
133 intent_id,
134 prompt,
135 session_id,
136 model,
137 parent_intent,
138 issue_id: None,
139 created_at,
140 }
141 }
142
143 pub fn with_issue(mut self, issue_id: crate::issue::IssueId) -> Self {
148 self.intent_id = compute_intent_id(
149 &self.prompt,
150 &self.session_id,
151 &self.model,
152 self.parent_intent.as_deref(),
153 Some(issue_id.as_str()),
154 );
155 self.issue_id = Some(issue_id);
156 self
157 }
158}
159
160fn compute_intent_id(
161 prompt: &str,
162 session_id: &str,
163 model: &ModelDescriptor,
164 parent_intent: Option<&str>,
165 issue_id: Option<&str>,
166) -> IntentId {
167 let view = CanonicalIntentView {
168 prompt,
169 session_id,
170 model,
171 parent_intent,
172 issue_id,
173 };
174 canonical::hash(&view)
175}
176
177#[derive(Serialize)]
181struct CanonicalIntentView<'a> {
182 prompt: &'a str,
183 session_id: &'a str,
184 model: &'a ModelDescriptor,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 parent_intent: Option<&'a str>,
187 #[serde(skip_serializing_if = "Option::is_none")]
190 issue_id: Option<&'a str>,
191}
192
193pub struct IntentLog {
199 dir: PathBuf,
200}
201
202impl IntentLog {
203 pub fn open(root: &Path) -> io::Result<Self> {
204 let dir = root.join("intents");
205 fs::create_dir_all(&dir)?;
206 Ok(Self { dir })
207 }
208
209 fn path(&self, id: &IntentId) -> PathBuf {
210 self.dir.join(format!("{id}.json"))
211 }
212
213 pub fn put(&self, intent: &Intent) -> io::Result<()> {
217 let path = self.path(&intent.intent_id);
218 if path.exists() {
219 return Ok(());
220 }
221 let bytes = serde_json::to_vec(intent)
222 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
223 let tmp = path.with_extension("json.tmp");
224 let mut f = fs::File::create(&tmp)?;
225 f.write_all(&bytes)?;
226 f.sync_all()?;
227 fs::rename(&tmp, &path)?;
228 Ok(())
229 }
230
231 pub fn get(&self, id: &IntentId) -> io::Result<Option<Intent>> {
232 let path = self.path(id);
233 if !path.exists() {
234 return Ok(None);
235 }
236 let bytes = fs::read(&path)?;
237 let intent: Intent = serde_json::from_slice(&bytes)
238 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
239 Ok(Some(intent))
240 }
241}
242
243#[cfg(test)]
246mod tests {
247 use super::*;
248
249 fn anthropic() -> ModelDescriptor {
250 ModelDescriptor {
251 provider: "anthropic".into(),
252 name: "claude-opus-4-7".into(),
253 version: None,
254 }
255 }
256
257 #[test]
258 fn same_prompt_session_model_hashes_equal() {
259 let a = Intent::with_timestamp(
265 "fix the auth bug", "ses_abc", anthropic(), None, 1000,
266 );
267 let b = Intent::with_timestamp(
268 "fix the auth bug", "ses_abc", anthropic(), None, 99999,
269 );
270 assert_eq!(a.intent_id, b.intent_id);
271 assert_ne!(a.created_at, b.created_at);
272 }
273
274 #[test]
275 fn different_prompts_hash_differently() {
276 let a = Intent::with_timestamp(
277 "fix the auth bug", "ses_abc", anthropic(), None, 0,
278 );
279 let b = Intent::with_timestamp(
280 "fix the cache bug", "ses_abc", anthropic(), None, 0,
281 );
282 assert_ne!(a.intent_id, b.intent_id);
283 }
284
285 #[test]
286 fn different_sessions_hash_differently() {
287 let a = Intent::with_timestamp(
288 "fix the auth bug", "ses_abc", anthropic(), None, 0,
289 );
290 let b = Intent::with_timestamp(
291 "fix the auth bug", "ses_xyz", anthropic(), None, 0,
292 );
293 assert_ne!(a.intent_id, b.intent_id);
294 }
295
296 #[test]
297 fn different_models_hash_differently() {
298 let a = Intent::with_timestamp(
299 "fix the auth bug", "ses_abc", anthropic(), None, 0,
300 );
301 let mut model = anthropic();
302 model.name = "claude-sonnet-4-6".into();
303 let b = Intent::with_timestamp(
304 "fix the auth bug", "ses_abc", model, None, 0,
305 );
306 assert_ne!(a.intent_id, b.intent_id);
307 }
308
309 #[test]
310 fn refinement_chain_distinguishes_parent_intent() {
311 let a = Intent::with_timestamp(
312 "now also handle Y", "ses_abc", anthropic(), None, 0,
313 );
314 let b = Intent::with_timestamp(
315 "now also handle Y", "ses_abc", anthropic(),
316 Some("parent-intent-id".into()), 0,
317 );
318 assert_ne!(
319 a.intent_id, b.intent_id,
320 "an intent with a parent is causally distinct from one without",
321 );
322 }
323
324 #[test]
325 fn intent_id_is_64_char_lowercase_hex() {
326 let i = Intent::with_timestamp(
327 "test", "ses_abc", anthropic(), None, 0,
328 );
329 assert_eq!(i.intent_id.len(), 64);
330 assert!(i.intent_id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
331 }
332
333 #[test]
334 fn round_trip_through_serde_json() {
335 let i = Intent::with_timestamp(
336 "fix the auth bug", "ses_abc", anthropic(),
337 Some("parent".into()), 12345,
338 );
339 let json = serde_json::to_string(&i).unwrap();
340 let back: Intent = serde_json::from_str(&json).unwrap();
341 assert_eq!(i, back);
342 }
343
344 #[test]
350 fn canonical_form_is_stable_for_a_known_input() {
351 let i = Intent::with_timestamp(
352 "fix the auth bug",
353 "ses_abc",
354 ModelDescriptor {
355 provider: "anthropic".into(),
356 name: "claude-opus-4-7".into(),
357 version: None,
358 },
359 None,
360 0,
361 );
362 assert_eq!(
363 i.intent_id,
364 "5ede62683a249cd00afff49fdf56e8f659fe878a668c8b61e36f5fbc1de7c734",
365 );
366 }
367
368 #[test]
371 fn intent_log_round_trips_through_disk() {
372 let tmp = tempfile::tempdir().unwrap();
373 let log = IntentLog::open(tmp.path()).unwrap();
374 let i = Intent::with_timestamp(
375 "fix the auth bug", "ses_abc", anthropic(), None, 100,
376 );
377 log.put(&i).unwrap();
378 let read_back = log.get(&i.intent_id).unwrap().unwrap();
379 assert_eq!(i, read_back);
380 }
381
382 #[test]
383 fn intent_log_get_unknown_returns_none() {
384 let tmp = tempfile::tempdir().unwrap();
385 let log = IntentLog::open(tmp.path()).unwrap();
386 assert!(log.get(&"nonexistent".to_string()).unwrap().is_none());
387 }
388
389 #[test]
390 fn intent_log_put_is_idempotent() {
391 let tmp = tempfile::tempdir().unwrap();
392 let log = IntentLog::open(tmp.path()).unwrap();
393 let i = Intent::with_timestamp(
394 "fix the auth bug", "ses_abc", anthropic(), None, 100,
395 );
396 log.put(&i).unwrap();
397 log.put(&i).unwrap();
401 let read_back = log.get(&i.intent_id).unwrap().unwrap();
402 assert_eq!(i, read_back);
403 }
404}