1use std::io::BufRead;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8const META_FILENAME: &str = "meta.json";
9const STATS_FILENAME: &str = "stats.json";
10const STATS_LOCK_FILENAME: &str = ".stats.lock";
11const STATS_SCHEMA_VERSION: u8 = 1;
12
13fn is_auto_name(source: &NameSource) -> bool {
14 matches!(source, NameSource::Auto)
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SessionScope<'a> {
19 CurrentProject(&'a Path),
20 AllProjects,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum DiscoveryScope {
25 CurrentProject {
26 project_root: PathBuf,
27 project_fingerprint: String,
28 },
29 AllProjects,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SessionDiscoveryQuery {
34 pub scope: DiscoveryScope,
35 pub include_legacy: bool,
36 pub text: Option<String>,
37 pub limit: Option<usize>,
38}
39
40impl SessionDiscoveryQuery {
41 pub fn all_projects() -> Self {
42 Self {
43 scope: DiscoveryScope::AllProjects,
44 include_legacy: false,
45 text: None,
46 limit: None,
47 }
48 }
49
50 pub fn current_project(project_root: &Path) -> Self {
51 Self {
52 scope: DiscoveryScope::CurrentProject {
53 project_root: canonical_root(project_root),
54 project_fingerprint: fingerprint_from_root(project_root),
55 },
56 include_legacy: false,
57 text: None,
58 limit: None,
59 }
60 }
61
62 pub fn with_legacy(mut self, include_legacy: bool) -> Self {
63 self.include_legacy = include_legacy;
64 self
65 }
66
67 pub fn matches_meta(&self, meta: Option<&SessionMeta>) -> bool {
68 let Some(meta) = meta else {
69 return self.include_legacy;
70 };
71 match &self.scope {
72 DiscoveryScope::AllProjects => {
73 self.include_legacy || meta.project_fingerprint.is_some()
74 }
75 DiscoveryScope::CurrentProject {
76 project_root,
77 project_fingerprint,
78 } => {
79 if meta.project_fingerprint.is_none() {
80 return self.include_legacy;
81 }
82 meta.project_fingerprint.as_deref() == Some(project_fingerprint)
83 || meta.project_root.as_deref().map(canonical_root).as_ref()
84 == Some(project_root)
85 }
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum NameSource {
93 #[default]
94 Auto,
95 User,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct SessionSummary {
100 pub id: String,
101 pub title: String,
102 pub name_source: NameSource,
103 pub project_root: Option<PathBuf>,
104 pub created_at: Option<DateTime<Utc>>,
105 pub event_count: usize,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
109pub struct SessionMeta {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub project_root: Option<PathBuf>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub start_path: Option<PathBuf>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub project_fingerprint: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub created_at: Option<DateTime<Utc>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub title: Option<String>,
120 #[serde(default, skip_serializing_if = "is_auto_name")]
121 pub name_source: NameSource,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub tags: Vec<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct SessionStats {
128 #[serde(default)]
129 schema_version: u8,
130 #[serde(default)]
131 pub event_bytes: u64,
132 #[serde(default)]
133 pub event_count: u64,
134 #[serde(default)]
135 pub message_count: u64,
136 #[serde(default)]
137 pub user_message_count: u64,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub first_ts: Option<DateTime<Utc>>,
140}
141
142impl Default for SessionStats {
143 fn default() -> Self {
144 Self {
145 schema_version: STATS_SCHEMA_VERSION,
146 event_bytes: 0,
147 event_count: 0,
148 message_count: 0,
149 user_message_count: 0,
150 first_ts: None,
151 }
152 }
153}
154
155impl SessionStats {
156 pub fn load_or_rebuild(session_dir: &Path) -> std::io::Result<Self> {
157 let events_path = session_dir.join("events.jsonl");
158 let mut event_bytes = std::fs::metadata(&events_path)
159 .map(|metadata| metadata.len())
160 .unwrap_or(0);
161 if let Some(stats) = Self::load_unchecked(session_dir)
162 && stats.event_bytes == event_bytes
163 {
164 return Ok(stats);
165 }
166 let _lock = match lock_stats(session_dir) {
167 Ok(lock) => lock,
168 Err(_) => return Self::scan(&events_path),
169 };
170 event_bytes = std::fs::metadata(&events_path)
171 .map(|metadata| metadata.len())
172 .unwrap_or(0);
173 if let Some(stats) = Self::load_unchecked(session_dir)
174 && stats.event_bytes == event_bytes
175 {
176 return Ok(stats);
177 }
178 let stats = Self::scan(&events_path)?;
179 let _ = stats.save_unlocked(session_dir);
180 Ok(stats)
181 }
182
183 pub(crate) fn record_persisted_event(
184 session_dir: &Path,
185 start: u64,
186 end: u64,
187 envelope: &crate::event::EventEnvelope,
188 ) -> std::io::Result<()> {
189 let _lock = lock_stats(session_dir)?;
190 let mut stats = match Self::load_unchecked(session_dir) {
191 Some(stats) if stats.event_bytes == end => return Ok(()),
192 Some(stats) if stats.event_bytes == start => stats,
193 None if start == 0 => Self::default(),
194 _ => {
195 let stats = Self::scan(&session_dir.join("events.jsonl"))?;
196 stats.save_unlocked(session_dir)?;
197 return Ok(());
198 }
199 };
200 stats.event_bytes = end;
201 stats.event_count = stats.event_count.saturating_add(1);
202 if stats.first_ts.is_none() {
203 stats.first_ts = Some(envelope.ts);
204 }
205 match &envelope.event {
206 crate::event::Event::UserMsg { .. } => {
207 stats.user_message_count = stats.user_message_count.saturating_add(1);
208 stats.message_count = stats.message_count.saturating_add(1);
209 }
210 crate::event::Event::AssistantMsg { .. }
211 | crate::event::Event::ToolResultMsg { .. } => {
212 stats.message_count = stats.message_count.saturating_add(1);
213 }
214 _ => {}
215 }
216 stats.save_unlocked(session_dir)
217 }
218
219 fn load_unchecked(session_dir: &Path) -> Option<Self> {
220 let bytes = std::fs::read(session_dir.join(STATS_FILENAME)).ok()?;
221 let stats: Self = serde_json::from_slice(&bytes).ok()?;
222 (stats.schema_version == STATS_SCHEMA_VERSION).then_some(stats)
223 }
224
225 fn scan(events_path: &Path) -> std::io::Result<Self> {
226 #[cfg(test)]
227 STATS_SCANS.with(|count| count.set(count.get().saturating_add(1)));
228
229 let file = match std::fs::File::open(events_path) {
230 Ok(file) => file,
231 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
232 return Ok(Self::default());
233 }
234 Err(error) => return Err(error),
235 };
236 let mut reader = std::io::BufReader::new(file);
237 let mut stats = Self::default();
238 let mut line = String::new();
239 loop {
240 line.clear();
241 let bytes = reader.read_line(&mut line)?;
242 if bytes == 0 {
243 break;
244 }
245 stats.event_bytes = stats.event_bytes.saturating_add(bytes as u64);
246 let text = line.trim();
247 if text.is_empty() {
248 continue;
249 }
250 stats.event_count = stats.event_count.saturating_add(1);
251 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
252 continue;
253 };
254 if stats.first_ts.is_none() {
255 stats.first_ts = value
256 .get("ts")
257 .and_then(serde_json::Value::as_str)
258 .and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
259 .map(|ts| ts.with_timezone(&Utc));
260 }
261 match value.get("type").and_then(serde_json::Value::as_str) {
262 Some("user_msg") => {
263 stats.user_message_count = stats.user_message_count.saturating_add(1);
264 stats.message_count = stats.message_count.saturating_add(1);
265 }
266 Some("assistant_msg" | "tool_result_msg") => {
267 stats.message_count = stats.message_count.saturating_add(1);
268 }
269 _ => {}
270 }
271 }
272 Ok(stats)
273 }
274
275 fn save_unlocked(&self, session_dir: &Path) -> std::io::Result<()> {
276 let bytes = serde_json::to_vec(self)
277 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
278 let temp = session_dir.join(".stats.json.tmp");
279 std::fs::write(&temp, bytes)?;
280 if let Err(error) = std::fs::rename(&temp, session_dir.join(STATS_FILENAME)) {
281 let _ = std::fs::remove_file(temp);
282 return Err(error);
283 }
284 Ok(())
285 }
286}
287
288fn lock_stats(session_dir: &Path) -> std::io::Result<std::fs::File> {
289 let lock = std::fs::OpenOptions::new()
290 .create(true)
291 .truncate(false)
292 .read(true)
293 .write(true)
294 .open(session_dir.join(STATS_LOCK_FILENAME))?;
295 lock.lock_exclusive()?;
296 Ok(lock)
297}
298
299#[cfg(test)]
300thread_local! {
301 static STATS_SCANS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
302}
303
304impl SessionMeta {
305 pub fn load(session_dir: &Path) -> Option<Self> {
306 let path = session_dir.join(META_FILENAME);
307 let bytes = std::fs::read(&path).ok()?;
308 serde_json::from_slice(&bytes).ok()
309 }
310
311 pub fn save(&self, session_dir: &Path) -> std::io::Result<()> {
312 let path = session_dir.join(META_FILENAME);
313 let bytes = serde_json::to_vec_pretty(self)
314 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
315 std::fs::write(&path, bytes)
316 }
317
318 pub fn set_auto_title_if_unclaimed(
319 session_dir: &Path,
320 title: impl Into<String>,
321 ) -> std::io::Result<Option<Self>> {
322 let title = title.into().trim().to_owned();
323 if title.is_empty() {
324 return Err(std::io::Error::new(
325 std::io::ErrorKind::InvalidInput,
326 "title cannot be empty",
327 ));
328 }
329 let mut meta = Self::load(session_dir).ok_or_else(|| {
330 std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
331 })?;
332 if matches!(meta.name_source, NameSource::User) {
333 return Ok(None);
334 }
335 meta.title = Some(title);
336 meta.name_source = NameSource::Auto;
337 meta.save(session_dir)?;
338 Ok(Some(meta))
339 }
340
341 pub fn rename(session_dir: &Path, title: impl Into<String>) -> std::io::Result<Self> {
342 let title = title.into().trim().to_owned();
343 if title.is_empty() {
344 return Err(std::io::Error::new(
345 std::io::ErrorKind::InvalidInput,
346 "title cannot be empty",
347 ));
348 }
349 let mut meta = Self::load(session_dir).ok_or_else(|| {
350 std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
351 })?;
352 meta.title = Some(title);
353 meta.name_source = NameSource::User;
354 meta.save(session_dir)?;
355 Ok(meta)
356 }
357
358 pub fn discover(root: &Path, scope: SessionScope<'_>) -> std::io::Result<Vec<SessionSummary>> {
359 let sessions = root.join("sessions");
360 let mut summaries = Vec::new();
361 if !sessions.exists() {
362 return Ok(summaries);
363 }
364 for entry in std::fs::read_dir(sessions)? {
365 let entry = entry?;
366 let path = entry.path();
367 if !path.is_dir() {
368 continue;
369 }
370 let Some(meta) = Self::load(&path) else {
371 continue;
372 };
373 if let SessionScope::CurrentProject(project) = scope
374 && meta.project_root.as_deref() != Some(project)
375 {
376 continue;
377 }
378 let event_count = SessionStats::load_or_rebuild(&path)
379 .map(|stats| stats.event_count as usize)
380 .unwrap_or(0);
381 summaries.push(SessionSummary {
382 id: entry.file_name().to_string_lossy().into_owned(),
383 title: meta.title.unwrap_or_else(|| "Untitled session".into()),
384 name_source: meta.name_source,
385 project_root: meta.project_root,
386 created_at: meta.created_at,
387 event_count,
388 });
389 }
390 summaries.sort_by(|a, b| {
391 b.created_at
392 .cmp(&a.created_at)
393 .then_with(|| a.id.cmp(&b.id))
394 });
395 Ok(summaries)
396 }
397
398 pub fn from_cwd() -> Self {
399 let cwd = std::env::current_dir().ok();
400 Self::from_start_path(cwd.as_deref())
401 }
402
403 pub fn from_start_path(start: Option<&Path>) -> Self {
404 let project_root = start.map(canonical_root);
405 let project_fingerprint = project_root.as_deref().map(fingerprint_from_root);
406 Self {
407 start_path: project_root.clone(),
408 project_root,
409 project_fingerprint,
410 created_at: Some(Utc::now()),
411 title: None,
412 name_source: NameSource::Auto,
413 tags: Vec::new(),
414 }
415 }
416
417 pub fn rebase(&mut self, new_cwd: &Path) {
418 let project_root = canonical_root(new_cwd);
419 self.start_path = Some(project_root.clone());
420 self.project_fingerprint = Some(fingerprint_from_root(&project_root));
421 self.project_root = Some(project_root);
422 }
423
424 pub fn set_title(session_dir: &Path, title: Option<String>) -> std::io::Result<()> {
425 let mut meta = Self::load(session_dir).unwrap_or_default();
426 meta.title = title;
427 meta.name_source = NameSource::User;
428 meta.save(session_dir)
429 }
430
431 pub fn set_auto_title(session_dir: &Path, title: impl Into<String>) -> std::io::Result<bool> {
432 Self::set_auto_title_with_force(session_dir, title, false)
433 }
434
435 pub fn set_auto_title_with_force(
436 session_dir: &Path,
437 title: impl Into<String>,
438 force: bool,
439 ) -> std::io::Result<bool> {
440 let mut meta = Self::load(session_dir).unwrap_or_default();
441 if !force && meta.name_source == NameSource::User {
442 return Ok(false);
443 }
444 let title = title.into().trim().replace(['\n', '\r'], " ");
445 let title: String = title.chars().take(60).collect();
446 if title.is_empty() {
447 return Ok(false);
448 }
449 meta.title = Some(title);
450 meta.name_source = NameSource::Auto;
451 meta.save(session_dir)?;
452 Ok(true)
453 }
454}
455
456pub fn fingerprint_from_root(root: &Path) -> String {
457 let stable = root
458 .canonicalize()
459 .or_else(|_| {
460 if root.is_absolute() {
461 Ok(root.to_path_buf())
462 } else {
463 std::env::current_dir().map(|cwd| cwd.join(root))
464 }
465 })
466 .unwrap_or_else(|_| root.to_path_buf());
467 let digest = blake3::hash(stable.to_string_lossy().as_bytes());
468 hex_prefix(digest.as_bytes(), 16)
469}
470
471pub fn canonical_root(root: &Path) -> PathBuf {
473 root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
474}
475
476fn hex_prefix(bytes: &[u8], hex_chars: usize) -> String {
477 let mut out = String::with_capacity(hex_chars);
478 for byte in bytes {
479 if out.len() >= hex_chars {
480 break;
481 }
482 out.push_str(&format!("{byte:02x}"));
483 }
484 out.truncate(hex_chars);
485 out
486}
487
488pub fn find_project_root(start: &Path) -> Option<PathBuf> {
489 let mut cursor: Option<&Path> = Some(start);
490 while let Some(dir) = cursor {
491 if dir.join(".atman").is_dir() || dir.join(".git").exists() {
492 return Some(dir.to_path_buf());
493 }
494 cursor = dir.parent();
495 }
496 None
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use tempfile::TempDir;
503
504 #[test]
505 fn discovery_query_matches_project_identity_and_legacy_policy() {
506 let root = PathBuf::from("/tmp/project");
507 let query = SessionDiscoveryQuery::current_project(&root).with_legacy(false);
508 let matching = SessionMeta {
509 project_root: Some(root.clone()),
510 project_fingerprint: Some(fingerprint_from_root(&root)),
511 ..SessionMeta::default()
512 };
513 let other = SessionMeta {
514 project_root: Some(PathBuf::from("/tmp/other")),
515 project_fingerprint: Some(fingerprint_from_root(Path::new("/tmp/other"))),
516 ..SessionMeta::default()
517 };
518 assert!(query.matches_meta(Some(&matching)));
519 assert!(!query.matches_meta(Some(&other)));
520 assert!(!query.matches_meta(Some(&SessionMeta::default())));
521 assert!(!SessionDiscoveryQuery::all_projects().matches_meta(None));
522 assert!(
523 SessionDiscoveryQuery::all_projects()
524 .with_legacy(true)
525 .matches_meta(None)
526 );
527 }
528
529 #[test]
530 fn fingerprint_is_stable_16_hex_chars() {
531 let tmp = TempDir::new().unwrap();
532 let fp = fingerprint_from_root(tmp.path());
533 assert_eq!(fp.len(), 16);
534 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
535 assert_eq!(fp, fingerprint_from_root(tmp.path()));
536 }
537
538 #[test]
539 fn find_project_root_locates_git_ancestor() {
540 let tmp = TempDir::new().unwrap();
541 std::fs::create_dir(tmp.path().join(".git")).unwrap();
542 let sub = tmp.path().join("nested/deep");
543 std::fs::create_dir_all(&sub).unwrap();
544 assert_eq!(
545 find_project_root(&sub).unwrap().canonicalize().unwrap(),
546 tmp.path().canonicalize().unwrap()
547 );
548 }
549
550 #[test]
551 fn find_project_root_prefers_atman_dir() {
552 let tmp = TempDir::new().unwrap();
553 std::fs::create_dir(tmp.path().join(".atman")).unwrap();
554 let root = find_project_root(tmp.path()).unwrap();
555 assert_eq!(
556 root.canonicalize().unwrap(),
557 tmp.path().canonicalize().unwrap()
558 );
559 }
560
561 #[test]
562 fn find_project_root_returns_none_when_nothing_matches() {
563 let tmp = TempDir::new().unwrap();
564 assert!(find_project_root(tmp.path()).is_none());
565 }
566
567 #[test]
568 fn start_path_is_the_project_without_repository_markers() {
569 let tmp = TempDir::new().unwrap();
570 let nested = tmp.path().join("plain").join("nested");
571 std::fs::create_dir_all(&nested).unwrap();
572 let meta = SessionMeta::from_start_path(Some(&nested));
573 let canonical = nested.canonicalize().unwrap();
574 assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
575 assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
576 let fingerprint = fingerprint_from_root(&canonical);
577 assert_eq!(
578 meta.project_fingerprint.as_deref(),
579 Some(fingerprint.as_str())
580 );
581 }
582
583 #[test]
584 fn auto_title_does_not_overwrite_user_title() {
585 let tmp = TempDir::new().unwrap();
586 SessionMeta::from_start_path(Some(tmp.path()))
587 .save(tmp.path())
588 .unwrap();
589 assert!(SessionMeta::set_auto_title(tmp.path(), "Generated name").unwrap());
590 SessionMeta::set_title(tmp.path(), Some("User name".into())).unwrap();
591 assert!(!SessionMeta::set_auto_title(tmp.path(), "Replacement").unwrap());
592 let meta = SessionMeta::load(tmp.path()).unwrap();
593 assert_eq!(meta.title.as_deref(), Some("User name"));
594 assert_eq!(meta.name_source, NameSource::User);
595 }
596
597 #[test]
598 fn built_in_session_name_flow_parses() {
599 let parsed = atman_dsl::parse::parse_file(crate::templates::SESSION_NAME_AT).unwrap();
600 assert_eq!(parsed.flows[0].name.name, "session_name");
601 }
602
603 #[test]
604 fn save_then_load_round_trips() {
605 let tmp = TempDir::new().unwrap();
606 let meta = SessionMeta {
607 project_root: Some(PathBuf::from("/tmp/foo")),
608 start_path: Some(PathBuf::from("/tmp/foo/sub")),
609 project_fingerprint: Some("deadbeef".repeat(2)),
610 created_at: Some(Utc::now()),
611 title: Some("nice title".into()),
612 name_source: NameSource::User,
613 tags: vec!["x".into()],
614 };
615 meta.save(tmp.path()).unwrap();
616 let back = SessionMeta::load(tmp.path()).unwrap();
617 assert_eq!(back.project_root, meta.project_root);
618 assert_eq!(back.start_path, meta.start_path);
619 assert_eq!(back.project_fingerprint, meta.project_fingerprint);
620 }
621
622 #[test]
623 fn auto_title_does_not_overwrite_manual_rename() {
624 let tmp = TempDir::new().unwrap();
625 SessionMeta::default().save(tmp.path()).unwrap();
626 let auto = SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Generated title")
627 .unwrap()
628 .unwrap();
629 assert_eq!(auto.name_source, NameSource::Auto);
630 let manual = SessionMeta::rename(tmp.path(), "Manual title").unwrap();
631 assert_eq!(manual.name_source, NameSource::User);
632 assert!(
633 SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Late generated title")
634 .unwrap()
635 .is_none()
636 );
637 let loaded = SessionMeta::load(tmp.path()).unwrap();
638 assert_eq!(loaded.title.as_deref(), Some("Manual title"));
639 assert_eq!(loaded.name_source, NameSource::User);
640 }
641
642 #[test]
643 fn discovery_filters_and_renames_sessions() {
644 let tmp = TempDir::new().unwrap();
645 let project = tmp.path().join("project");
646 let first = tmp.path().join("sessions/first");
647 let second = tmp.path().join("sessions/second");
648 std::fs::create_dir_all(&first).unwrap();
649 std::fs::create_dir_all(&second).unwrap();
650 SessionMeta {
651 project_root: Some(project.clone()),
652 created_at: Some(Utc::now()),
653 ..SessionMeta::default()
654 }
655 .save(&first)
656 .unwrap();
657 SessionMeta {
658 project_root: Some(tmp.path().join("other")),
659 created_at: Some(Utc::now()),
660 ..SessionMeta::default()
661 }
662 .save(&second)
663 .unwrap();
664 std::fs::write(first.join("events.jsonl"), "{}\n{}\n").unwrap();
665 assert_eq!(
666 SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project))
667 .unwrap()
668 .len(),
669 1
670 );
671 assert_eq!(
672 SessionMeta::discover(tmp.path(), SessionScope::AllProjects)
673 .unwrap()
674 .len(),
675 2
676 );
677 assert_eq!(
678 SessionMeta::rename(&first, " Login fix ")
679 .unwrap()
680 .title
681 .as_deref(),
682 Some("Login fix")
683 );
684 assert!(SessionMeta::rename(&first, " ").is_err());
685 assert_eq!(
686 SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project)).unwrap()[0]
687 .event_count,
688 2
689 );
690 }
691
692 #[test]
693 fn rebase_updates_project_root_and_fingerprint() {
694 let tmp = TempDir::new().unwrap();
695 std::fs::create_dir(tmp.path().join(".git")).unwrap();
696 let sub = tmp.path().join("sub");
697 std::fs::create_dir_all(&sub).unwrap();
698
699 let mut meta = SessionMeta {
700 project_root: Some(PathBuf::from("/old")),
701 start_path: Some(PathBuf::from("/old")),
702 project_fingerprint: Some("0000000000000000".into()),
703 created_at: None,
704 title: None,
705 name_source: NameSource::Auto,
706 tags: vec![],
707 };
708 meta.rebase(&sub);
709 let canonical = sub.canonicalize().unwrap();
710 assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
711 assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
712 let expected_fp = fingerprint_from_root(&canonical);
713 assert_eq!(meta.project_fingerprint, Some(expected_fp));
714 }
715
716 #[test]
717 fn session_meta_serde_backward_compat_no_start_path() {
718 let json = r#"{"project_root":"/tmp/foo","project_fingerprint":"deadbeefdeadbeef","created_at":"2025-01-01T00:00:00Z"}"#;
720 let meta: SessionMeta = serde_json::from_str(json).unwrap();
721 assert_eq!(meta.project_root, Some(PathBuf::from("/tmp/foo")));
722 assert_eq!(meta.start_path, None);
723 }
724
725 #[test]
726 fn load_returns_none_when_file_missing() {
727 let tmp = TempDir::new().unwrap();
728 assert!(SessionMeta::load(tmp.path()).is_none());
729 }
730
731 #[test]
732 fn session_stats_backfill_is_reused_until_the_log_changes() {
733 let tmp = TempDir::new().unwrap();
734 let events = tmp.path().join("events.jsonl");
735 let first = concat!(
736 "{\"type\":\"user_msg\",\"ts\":\"2026-09-01T00:00:00Z\"}\n",
737 "not-json\n",
738 "{\"type\":\"assistant_msg\",\"ts\":\"2026-09-01T00:00:01Z\"}\n"
739 );
740 std::fs::write(&events, first).unwrap();
741 STATS_SCANS.with(|count| count.set(0));
742
743 let stats = SessionStats::load_or_rebuild(tmp.path()).unwrap();
744 assert_eq!(stats.event_bytes, first.len() as u64);
745 assert_eq!(stats.event_count, 3);
746 assert_eq!(stats.user_message_count, 1);
747 assert_eq!(stats.message_count, 2);
748 assert_eq!(
749 stats.first_ts,
750 Some("2026-09-01T00:00:00Z".parse().unwrap())
751 );
752 assert!(tmp.path().join(STATS_FILENAME).exists());
753
754 assert_eq!(SessionStats::load_or_rebuild(tmp.path()).unwrap(), stats);
755 assert_eq!(STATS_SCANS.with(std::cell::Cell::get), 1);
756
757 use std::io::Write;
758 let mut file = std::fs::OpenOptions::new()
759 .append(true)
760 .open(&events)
761 .unwrap();
762 writeln!(file, "{{\"type\":\"tool_result_msg\"}}").unwrap();
763 let updated = SessionStats::load_or_rebuild(tmp.path()).unwrap();
764 assert_eq!(updated.event_count, 4);
765 assert_eq!(updated.message_count, 3);
766 assert_eq!(STATS_SCANS.with(std::cell::Cell::get), 2);
767 }
768}