1use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use serde::{Deserialize, Serialize};
36
37use super::wire::ChatMessage;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SessionRecord {
45 pub id: String,
46 #[serde(default)]
48 pub title: Option<String>,
49 #[serde(default)]
51 pub model: Option<String>,
52 #[serde(default)]
54 pub cwd: Option<String>,
55 #[serde(default)]
58 pub parent_id: Option<String>,
59 pub created_at: u64,
61 pub updated_at: u64,
62}
63
64pub(crate) fn now_millis() -> u64 {
67 SystemTime::now()
68 .duration_since(UNIX_EPOCH)
69 .map(|d| d.as_millis() as u64)
70 .unwrap_or(0)
71}
72
73pub(crate) fn new_session_id() -> String {
77 static COUNTER: AtomicU64 = AtomicU64::new(0);
78 let nanos = SystemTime::now()
79 .duration_since(UNIX_EPOCH)
80 .map(|d| d.as_nanos())
81 .unwrap_or(0);
82 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
83 format!("ses_{nanos:x}_{n:x}")
84}
85
86pub(crate) fn title_from_prompt(prompt: &str) -> String {
89 let first = prompt.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
90 let title: String = first.chars().take(60).collect();
91 if title.is_empty() {
92 "New session".to_owned()
93 } else {
94 title
95 }
96}
97
98#[derive(Debug, Clone)]
101pub(crate) struct FileStore {
102 root: PathBuf,
103}
104
105impl FileStore {
106 pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
107 Self { root: root.into() }
108 }
109
110 fn log_path(&self, id: &str) -> PathBuf {
112 self.root.join("sessions").join(format!("{id}.jsonl"))
113 }
114
115 fn legacy_record_path(&self, id: &str) -> PathBuf {
118 self.root.join("sessions").join(format!("{id}.json"))
119 }
120 fn legacy_messages_paths(&self, id: &str) -> [PathBuf; 2] {
121 let dir = self.root.join("messages");
122 [dir.join(format!("{id}.jsonl")), dir.join(format!("{id}.json"))]
123 }
124
125 pub(crate) fn put_record(&self, record: &SessionRecord) -> Result<(), String> {
129 let messages = self.load_messages(&record.id)?;
130 self.write_log(&record.id, record, &messages)
131 }
132
133 pub(crate) fn get_record(&self, id: &str) -> Result<Option<SessionRecord>, String> {
135 match read_header(&self.log_path(id)) {
136 Some(record) => Ok(Some(record)),
137 None => read_json_opt(&self.legacy_record_path(id)),
138 }
139 }
140
141 pub(crate) fn list_records(&self) -> Result<Vec<SessionRecord>, String> {
145 let dir = self.root.join("sessions");
146 let entries = match std::fs::read_dir(&dir) {
147 Ok(e) => e,
148 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
149 Err(e) => return Err(format!("listing sessions in {}: {e}", dir.display())),
150 };
151 let mut out: Vec<SessionRecord> = entries
152 .flatten()
153 .filter_map(|entry| {
154 let path = entry.path();
155 match path.extension().and_then(|x| x.to_str()) {
156 Some("jsonl") => read_header(&path),
159 Some("json") => read_json_opt::<SessionRecord>(&path).ok().flatten(),
160 _ => None,
161 }
162 })
163 .collect();
164 out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
165 Ok(out)
166 }
167
168 pub(crate) fn touch(&self, id: &str, updated_at: u64) -> Result<(), String> {
170 if let Some(mut record) = self.get_record(id)? {
171 record.updated_at = updated_at;
172 self.put_record(&record)?;
173 }
174 Ok(())
175 }
176
177 pub(crate) fn load_messages(&self, id: &str) -> Result<Vec<ChatMessage>, String> {
183 if let Some(text) = read_to_string_opt(&self.log_path(id))? {
184 return Ok(text.lines().filter_map(|line| parse_line(line).message()).collect());
185 }
186 let [jsonl, json] = self.legacy_messages_paths(id);
187 if let Some(text) = read_to_string_opt(&jsonl)? {
188 return Ok(text.lines().filter_map(|line| serde_json::from_str(line).ok()).collect());
189 }
190 Ok(read_json_opt(&json)?.unwrap_or_default())
191 }
192
193 pub(crate) fn append_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
198 if messages.is_empty() {
199 return Ok(());
200 }
201 let path = self.log_path(id);
202 ensure_parent(&path)?;
203 let mut file = std::fs::OpenOptions::new()
204 .create(true)
205 .append(true)
206 .open(&path)
207 .map_err(|e| format!("opening {}: {e}", path.display()))?;
208 std::io::Write::write_all(&mut file, encode_messages(messages)?.as_bytes())
209 .map_err(|e| format!("appending to {}: {e}", path.display()))
210 }
211
212 pub(crate) fn replace_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
216 let header = self.get_record(id)?;
217 match header {
218 Some(record) => self.write_log(id, &record, messages),
219 None => {
222 let path = self.log_path(id);
223 ensure_parent(&path)?;
224 write_atomic(&path, &encode_messages(messages)?)
225 }
226 }
227 }
228
229 fn write_log(&self, id: &str, record: &SessionRecord, messages: &[ChatMessage]) -> Result<(), String> {
231 let path = self.log_path(id);
232 ensure_parent(&path)?;
233 let header = serde_json::to_string(&LogLine::Session(record.clone()))
234 .map_err(|e| format!("serializing the header for {}: {e}", path.display()))?;
235 write_atomic(&path, &format!("{header}\n{}", encode_messages(messages)?))
236 }
237}
238
239#[derive(Serialize, Deserialize)]
244#[serde(tag = "type", rename_all = "snake_case")]
245enum LogLine {
246 Session(SessionRecord),
247 Message(ChatMessage),
248}
249
250impl LogLine {
251 fn message(self) -> Option<ChatMessage> {
252 match self {
253 Self::Message(message) => Some(message),
254 Self::Session(_) => None,
255 }
256 }
257}
258
259fn parse_line(line: &str) -> LogLine {
260 serde_json::from_str(line).unwrap_or(LogLine::Session(SessionRecord {
261 id: String::new(),
262 title: None,
263 model: None,
264 cwd: None,
265 parent_id: None,
266 created_at: 0,
267 updated_at: 0,
268 }))
269}
270
271fn read_header(path: &Path) -> Option<SessionRecord> {
273 let file = std::fs::File::open(path).ok()?;
274 let mut first = String::new();
275 std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first).ok()?;
276 match serde_json::from_str(&first).ok()? {
277 LogLine::Session(record) if !record.id.is_empty() => Some(record),
278 _ => None,
279 }
280}
281
282fn encode_messages(messages: &[ChatMessage]) -> Result<String, String> {
283 let mut out = String::new();
284 for message in messages {
285 let line = serde_json::to_string(&LogLine::Message(message.clone()))
286 .map_err(|e| format!("serializing a message: {e}"))?;
287 out.push_str(&line);
288 out.push('\n');
289 }
290 Ok(out)
291}
292
293fn ensure_parent(path: &Path) -> Result<(), String> {
294 if let Some(parent) = path.parent() {
295 std::fs::create_dir_all(parent).map_err(|e| format!("creating {}: {e}", parent.display()))?;
296 }
297 Ok(())
298}
299
300fn read_to_string_opt(path: &Path) -> Result<Option<String>, String> {
301 match std::fs::read_to_string(path) {
302 Ok(text) => Ok(Some(text)),
303 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
304 Err(e) => Err(format!("reading {}: {e}", path.display())),
305 }
306}
307
308fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
310 let temp = path.with_extension(format!("{}.tmp", std::process::id()));
311 std::fs::write(&temp, contents).map_err(|e| format!("writing {}: {e}", temp.display()))?;
312 std::fs::rename(&temp, path).map_err(|e| {
313 let _ = std::fs::remove_file(&temp);
314 format!("replacing {}: {e}", path.display())
315 })
316}
317
318fn read_json_opt<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, String> {
319 match std::fs::read_to_string(path) {
320 Ok(s) => serde_json::from_str(&s).map(Some).map_err(|e| format!("parsing {}: {e}", path.display())),
321 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
322 Err(e) => Err(format!("reading {}: {e}", path.display())),
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn scratch(tag: &str) -> PathBuf {
331 let dir = std::env::temp_dir().join(format!("hl-session-{tag}-{}", std::process::id()));
332 let _ = std::fs::remove_dir_all(&dir);
333 dir
334 }
335
336 #[test]
337 fn record_roundtrip_and_list_is_newest_first() {
338 let dir = scratch("rec");
339 let store = FileStore::new(&dir);
340 assert!(store.get_record("nope").unwrap().is_none());
341 assert!(store.list_records().unwrap().is_empty());
342
343 let a = SessionRecord { id: "a".into(), title: Some("first".into()), model: Some("m".into()), cwd: None, parent_id: None, created_at: 100, updated_at: 100 };
344 let b = SessionRecord { id: "b".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 200, updated_at: 200 };
345 store.put_record(&a).unwrap();
346 store.put_record(&b).unwrap();
347 assert_eq!(store.get_record("a").unwrap().unwrap().title.as_deref(), Some("first"));
348 let ids: Vec<String> = store.list_records().unwrap().into_iter().map(|r| r.id).collect();
349 assert_eq!(ids, ["b", "a"], "newest updated_at first");
350 let _ = std::fs::remove_dir_all(&dir);
351 }
352
353 #[test]
354 fn the_header_and_the_transcript_live_in_one_file() {
355 let dir = scratch("onefile");
359 let store = FileStore::new(&dir);
360 let record = SessionRecord {
361 id: "s1".to_owned(),
362 title: Some("a chat".to_owned()),
363 model: Some("m".to_owned()),
364 cwd: None,
365 parent_id: None,
366 created_at: 1,
367 updated_at: 2,
368 };
369 store.put_record(&record).unwrap();
370 store.append_messages("s1", &[ChatMessage::user("hello")]).unwrap();
371
372 assert!(dir.join("sessions").join("s1.jsonl").is_file());
373 assert!(!dir.join("messages").exists(), "no second file to fall out of step");
374
375 let listed = store.list_records().unwrap();
376 assert_eq!(listed.len(), 1);
377 assert_eq!(listed[0].title.as_deref(), Some("a chat"));
378 assert_eq!(store.load_messages("s1").unwrap().len(), 1, "the header is not a message");
379 let _ = std::fs::remove_dir_all(&dir);
380 }
381
382 #[test]
383 fn a_truncated_last_line_costs_only_that_turn() {
384 let dir = scratch("torn");
388 let store = FileStore::new(&dir);
389 store
390 .append_messages("s1", &[ChatMessage::user("first"), ChatMessage::user("second")])
391 .unwrap();
392
393 let path = dir.join("sessions").join("s1.jsonl");
394 let mut raw = std::fs::read_to_string(&path).unwrap();
395 raw.push_str("{\"role\":\"user\",\"cont"); std::fs::write(&path, raw).unwrap();
397
398 let loaded = store.load_messages("s1").unwrap();
399 assert_eq!(loaded.len(), 2, "the intact turns survive a torn tail");
400 assert_eq!(loaded[0].content.as_deref(), Some("first"));
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 #[test]
405 fn a_session_written_by_an_older_build_still_loads() {
406 let dir = scratch("legacy");
409 let store = FileStore::new(&dir);
410 let legacy = dir.join("messages").join("s1.json");
411 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
412 std::fs::write(&legacy, serde_json::to_string(&vec![ChatMessage::user("from before")]).unwrap())
413 .unwrap();
414
415 let loaded = store.load_messages("s1").unwrap();
416 assert_eq!(loaded.len(), 1);
417 assert_eq!(loaded[0].content.as_deref(), Some("from before"));
418 let _ = std::fs::remove_dir_all(&dir);
419 }
420
421 #[test]
422 fn appending_extends_rather_than_replacing() {
423 let dir = scratch("append");
424 let store = FileStore::new(&dir);
425 store.append_messages("s1", &[ChatMessage::user("one")]).unwrap();
426 store.append_messages("s1", &[ChatMessage::user("two")]).unwrap();
427 assert_eq!(store.load_messages("s1").unwrap().len(), 2);
428
429 store.replace_messages("s1", &[ChatMessage::user("summary")]).unwrap();
431 let loaded = store.load_messages("s1").unwrap();
432 assert_eq!(loaded.len(), 1, "replace truncates, it does not extend");
433 assert_eq!(loaded[0].content.as_deref(), Some("summary"));
434 let _ = std::fs::remove_dir_all(&dir);
435 }
436
437 #[test]
438 fn messages_roundtrip_and_touch() {
439 let dir = scratch("msg");
440 let store = FileStore::new(&dir);
441 assert!(store.load_messages("s1").unwrap().is_empty());
442
443 let msgs = vec![ChatMessage::user("hi"), ChatMessage::tool_result("c1", "done")];
444 store.append_messages("s1", &msgs).unwrap();
445 let loaded = store.load_messages("s1").unwrap();
446 assert_eq!(loaded.len(), 2);
447 assert_eq!(loaded[0].content.as_deref(), Some("hi"));
448
449 store.touch("s1", 999).unwrap();
451 assert!(store.get_record("s1").unwrap().is_none());
452 store.put_record(&SessionRecord { id: "s1".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 1, updated_at: 1 }).unwrap();
453 store.touch("s1", 999).unwrap();
454 assert_eq!(store.get_record("s1").unwrap().unwrap().updated_at, 999);
455 let _ = std::fs::remove_dir_all(&dir);
456 }
457
458 #[test]
459 fn an_unreadable_session_reads_as_an_error_and_not_as_an_absent_one() {
460 let dir = scratch("unreadable");
464 let store = FileStore::new(&dir);
465 std::fs::create_dir_all(&dir).unwrap();
466
467 std::fs::write(dir.join("sessions"), "not a directory").unwrap();
469 assert!(store.list_records().is_err(), "an unlistable directory is not an empty one");
470 std::fs::remove_file(dir.join("sessions")).unwrap();
471
472 std::fs::create_dir_all(dir.join("sessions").join("s1.jsonl")).unwrap();
474 assert!(store.load_messages("s1").is_err(), "an unreadable transcript is not an empty one");
475
476 std::fs::create_dir_all(dir.join("sessions").join("s2.json")).unwrap();
478 assert!(store.get_record("s2").is_err(), "an unreadable record is not an absent one");
479 let _ = std::fs::remove_dir_all(&dir);
480 }
481
482 #[test]
483 fn a_record_written_by_an_older_build_still_loads_and_lists() {
484 let dir = scratch("legacyrec");
488 let store = FileStore::new(&dir);
489 let path = dir.join("sessions").join("s1.json");
490 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
491 let record = SessionRecord {
492 id: "s1".to_owned(),
493 title: Some("an older chat".to_owned()),
494 model: Some("m".to_owned()),
495 cwd: None,
496 parent_id: None,
497 created_at: 5,
498 updated_at: 5,
499 };
500 std::fs::write(&path, serde_json::to_string(&record).unwrap()).unwrap();
501
502 assert_eq!(store.get_record("s1").unwrap().unwrap().title.as_deref(), Some("an older chat"));
503 let listed = store.list_records().unwrap();
504 assert_eq!(listed.len(), 1, "a legacy record lists alongside current ones");
505 assert_eq!(listed[0].id, "s1");
506 let _ = std::fs::remove_dir_all(&dir);
507 }
508
509 #[test]
510 fn a_header_without_an_id_is_not_a_session() {
511 let dir = scratch("noid");
515 let store = FileStore::new(&dir);
516 let path = dir.join("sessions").join("s1.jsonl");
517 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
518 std::fs::write(&path, "{\"type\":\"session\",\"id\":\"\",\"created_at\":0,\"updated_at\":0}\n").unwrap();
519
520 assert!(store.get_record("s1").unwrap().is_none());
521 assert!(store.list_records().unwrap().is_empty(), "an id-less header is skipped, not listed");
522 let _ = std::fs::remove_dir_all(&dir);
523 }
524
525 #[test]
526 fn session_timestamps_come_from_a_real_clock() {
527 let now = now_millis();
531 assert!(now > 1_700_000_000_000, "epoch milliseconds, not seconds and not a constant: {now}");
532 }
533
534 #[test]
535 fn new_session_id_is_unique_and_prefixed() {
536 let a = new_session_id();
537 let b = new_session_id();
538 assert_ne!(a, b);
539 assert!(a.starts_with("ses_"));
540 }
541
542 #[test]
543 fn title_is_first_line_capped() {
544 assert_eq!(title_from_prompt("Fix the parser\nand tests"), "Fix the parser");
545 assert_eq!(title_from_prompt(" \n hello "), "hello");
546 assert_eq!(title_from_prompt(""), "New session");
547 assert_eq!(title_from_prompt(&"x".repeat(100)).chars().count(), 60);
548 }
549}