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 | crate::event::Event::DeferredFormApplied { .. } => {
208 stats.user_message_count = stats.user_message_count.saturating_add(1);
209 stats.message_count = stats.message_count.saturating_add(1);
210 }
211 crate::event::Event::AssistantMsg { .. }
212 | crate::event::Event::ToolResultMsg { .. } => {
213 stats.message_count = stats.message_count.saturating_add(1);
214 }
215 _ => {}
216 }
217 stats.save_unlocked(session_dir)
218 }
219
220 fn load_unchecked(session_dir: &Path) -> Option<Self> {
221 let bytes = std::fs::read(session_dir.join(STATS_FILENAME)).ok()?;
222 let stats: Self = serde_json::from_slice(&bytes).ok()?;
223 (stats.schema_version == STATS_SCHEMA_VERSION).then_some(stats)
224 }
225
226 fn scan(events_path: &Path) -> std::io::Result<Self> {
227 #[cfg(test)]
228 STATS_SCANS.with(|count| count.set(count.get().saturating_add(1)));
229
230 let file = match std::fs::File::open(events_path) {
231 Ok(file) => file,
232 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
233 return Ok(Self::default());
234 }
235 Err(error) => return Err(error),
236 };
237 let mut reader = std::io::BufReader::new(file);
238 let mut stats = Self::default();
239 let mut line = String::new();
240 loop {
241 line.clear();
242 let bytes = reader.read_line(&mut line)?;
243 if bytes == 0 {
244 break;
245 }
246 stats.event_bytes = stats.event_bytes.saturating_add(bytes as u64);
247 let text = line.trim();
248 if text.is_empty() {
249 continue;
250 }
251 stats.event_count = stats.event_count.saturating_add(1);
252 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
253 continue;
254 };
255 if stats.first_ts.is_none() {
256 stats.first_ts = value
257 .get("ts")
258 .and_then(serde_json::Value::as_str)
259 .and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
260 .map(|ts| ts.with_timezone(&Utc));
261 }
262 match value.get("type").and_then(serde_json::Value::as_str) {
263 Some("user_msg") => {
264 stats.user_message_count = stats.user_message_count.saturating_add(1);
265 stats.message_count = stats.message_count.saturating_add(1);
266 }
267 Some("assistant_msg" | "tool_result_msg") => {
268 stats.message_count = stats.message_count.saturating_add(1);
269 }
270 _ => {}
271 }
272 }
273 Ok(stats)
274 }
275
276 fn save_unlocked(&self, session_dir: &Path) -> std::io::Result<()> {
277 let bytes = serde_json::to_vec(self)
278 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
279 let temp = session_dir.join(".stats.json.tmp");
280 std::fs::write(&temp, bytes)?;
281 if let Err(error) = std::fs::rename(&temp, session_dir.join(STATS_FILENAME)) {
282 let _ = std::fs::remove_file(temp);
283 return Err(error);
284 }
285 Ok(())
286 }
287}
288
289fn lock_stats(session_dir: &Path) -> std::io::Result<std::fs::File> {
290 let lock = std::fs::OpenOptions::new()
291 .create(true)
292 .truncate(false)
293 .read(true)
294 .write(true)
295 .open(session_dir.join(STATS_LOCK_FILENAME))?;
296 lock.lock_exclusive()?;
297 Ok(lock)
298}
299
300#[cfg(test)]
301thread_local! {
302 static STATS_SCANS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
303}
304
305impl SessionMeta {
306 pub fn load(session_dir: &Path) -> Option<Self> {
307 let path = session_dir.join(META_FILENAME);
308 let bytes = std::fs::read(&path).ok()?;
309 serde_json::from_slice(&bytes).ok()
310 }
311
312 pub fn save(&self, session_dir: &Path) -> std::io::Result<()> {
313 let path = session_dir.join(META_FILENAME);
314 let bytes = serde_json::to_vec_pretty(self)
315 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
316 std::fs::write(&path, bytes)
317 }
318
319 pub fn set_auto_title_if_unclaimed(
320 session_dir: &Path,
321 title: impl Into<String>,
322 ) -> std::io::Result<Option<Self>> {
323 let title = title.into().trim().to_owned();
324 if title.is_empty() {
325 return Err(std::io::Error::new(
326 std::io::ErrorKind::InvalidInput,
327 "title cannot be empty",
328 ));
329 }
330 let mut meta = Self::load(session_dir).ok_or_else(|| {
331 std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
332 })?;
333 if matches!(meta.name_source, NameSource::User) {
334 return Ok(None);
335 }
336 meta.title = Some(title);
337 meta.name_source = NameSource::Auto;
338 meta.save(session_dir)?;
339 Ok(Some(meta))
340 }
341
342 pub fn rename(session_dir: &Path, title: impl Into<String>) -> std::io::Result<Self> {
343 let title = title.into().trim().to_owned();
344 if title.is_empty() {
345 return Err(std::io::Error::new(
346 std::io::ErrorKind::InvalidInput,
347 "title cannot be empty",
348 ));
349 }
350 let mut meta = Self::load(session_dir).ok_or_else(|| {
351 std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
352 })?;
353 meta.title = Some(title);
354 meta.name_source = NameSource::User;
355 meta.save(session_dir)?;
356 Ok(meta)
357 }
358
359 pub fn discover(root: &Path, scope: SessionScope<'_>) -> std::io::Result<Vec<SessionSummary>> {
360 let sessions = root.join("sessions");
361 let mut summaries = Vec::new();
362 if !sessions.exists() {
363 return Ok(summaries);
364 }
365 for entry in std::fs::read_dir(sessions)? {
366 let entry = entry?;
367 let path = entry.path();
368 if !path.is_dir() {
369 continue;
370 }
371 let Some(meta) = Self::load(&path) else {
372 continue;
373 };
374 if let SessionScope::CurrentProject(project) = scope
375 && meta.project_root.as_deref() != Some(project)
376 {
377 continue;
378 }
379 let event_count = SessionStats::load_or_rebuild(&path)
380 .map(|stats| stats.event_count as usize)
381 .unwrap_or(0);
382 summaries.push(SessionSummary {
383 id: entry.file_name().to_string_lossy().into_owned(),
384 title: meta.title.unwrap_or_else(|| "Untitled session".into()),
385 name_source: meta.name_source,
386 project_root: meta.project_root,
387 created_at: meta.created_at,
388 event_count,
389 });
390 }
391 summaries.sort_by(|a, b| {
392 b.created_at
393 .cmp(&a.created_at)
394 .then_with(|| a.id.cmp(&b.id))
395 });
396 Ok(summaries)
397 }
398
399 pub fn from_cwd() -> Self {
400 let cwd = std::env::current_dir().ok();
401 Self::from_start_path(cwd.as_deref())
402 }
403
404 pub fn from_start_path(start: Option<&Path>) -> Self {
405 let project_root = start.map(canonical_root);
406 let project_fingerprint = project_root.as_deref().map(fingerprint_from_root);
407 Self {
408 start_path: project_root.clone(),
409 project_root,
410 project_fingerprint,
411 created_at: Some(Utc::now()),
412 title: None,
413 name_source: NameSource::Auto,
414 tags: Vec::new(),
415 }
416 }
417
418 pub fn rebase(&mut self, new_cwd: &Path) {
419 let project_root = canonical_root(new_cwd);
420 self.start_path = Some(project_root.clone());
421 self.project_fingerprint = Some(fingerprint_from_root(&project_root));
422 self.project_root = Some(project_root);
423 }
424
425 pub fn set_title(session_dir: &Path, title: Option<String>) -> std::io::Result<()> {
426 let mut meta = Self::load(session_dir).unwrap_or_default();
427 meta.title = title;
428 meta.name_source = NameSource::User;
429 meta.save(session_dir)
430 }
431
432 pub fn set_auto_title(session_dir: &Path, title: impl Into<String>) -> std::io::Result<bool> {
433 Self::set_auto_title_with_force(session_dir, title, false)
434 }
435
436 pub fn set_auto_title_with_force(
437 session_dir: &Path,
438 title: impl Into<String>,
439 force: bool,
440 ) -> std::io::Result<bool> {
441 let mut meta = Self::load(session_dir).unwrap_or_default();
442 if !force && meta.name_source == NameSource::User {
443 return Ok(false);
444 }
445 let title = title.into().trim().replace(['\n', '\r'], " ");
446 let title: String = title.chars().take(60).collect();
447 if title.is_empty() {
448 return Ok(false);
449 }
450 meta.title = Some(title);
451 meta.name_source = NameSource::Auto;
452 meta.save(session_dir)?;
453 Ok(true)
454 }
455}
456
457pub fn fingerprint_from_root(root: &Path) -> String {
458 let stable = root
459 .canonicalize()
460 .or_else(|_| {
461 if root.is_absolute() {
462 Ok(root.to_path_buf())
463 } else {
464 std::env::current_dir().map(|cwd| cwd.join(root))
465 }
466 })
467 .unwrap_or_else(|_| root.to_path_buf());
468 let digest = blake3::hash(stable.to_string_lossy().as_bytes());
469 hex_prefix(digest.as_bytes(), 16)
470}
471
472pub fn canonical_root(root: &Path) -> PathBuf {
474 root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
475}
476
477fn hex_prefix(bytes: &[u8], hex_chars: usize) -> String {
478 let mut out = String::with_capacity(hex_chars);
479 for byte in bytes {
480 if out.len() >= hex_chars {
481 break;
482 }
483 out.push_str(&format!("{byte:02x}"));
484 }
485 out.truncate(hex_chars);
486 out
487}
488
489pub fn find_project_root(start: &Path) -> Option<PathBuf> {
490 let mut cursor: Option<&Path> = Some(start);
491 while let Some(dir) = cursor {
492 if dir.join(".atman").is_dir() || dir.join(".git").exists() {
493 return Some(dir.to_path_buf());
494 }
495 cursor = dir.parent();
496 }
497 None
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use tempfile::TempDir;
504
505 #[test]
506 fn discovery_query_matches_project_identity_and_legacy_policy() {
507 let root = PathBuf::from("/tmp/project");
508 let query = SessionDiscoveryQuery::current_project(&root).with_legacy(false);
509 let matching = SessionMeta {
510 project_root: Some(root.clone()),
511 project_fingerprint: Some(fingerprint_from_root(&root)),
512 ..SessionMeta::default()
513 };
514 let other = SessionMeta {
515 project_root: Some(PathBuf::from("/tmp/other")),
516 project_fingerprint: Some(fingerprint_from_root(Path::new("/tmp/other"))),
517 ..SessionMeta::default()
518 };
519 assert!(query.matches_meta(Some(&matching)));
520 assert!(!query.matches_meta(Some(&other)));
521 assert!(!query.matches_meta(Some(&SessionMeta::default())));
522 assert!(!SessionDiscoveryQuery::all_projects().matches_meta(None));
523 assert!(
524 SessionDiscoveryQuery::all_projects()
525 .with_legacy(true)
526 .matches_meta(None)
527 );
528 }
529
530 #[test]
531 fn fingerprint_is_stable_16_hex_chars() {
532 let tmp = TempDir::new().unwrap();
533 let fp = fingerprint_from_root(tmp.path());
534 assert_eq!(fp.len(), 16);
535 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
536 assert_eq!(fp, fingerprint_from_root(tmp.path()));
537 }
538
539 #[test]
540 fn find_project_root_locates_git_ancestor() {
541 let tmp = TempDir::new().unwrap();
542 std::fs::create_dir(tmp.path().join(".git")).unwrap();
543 let sub = tmp.path().join("nested/deep");
544 std::fs::create_dir_all(&sub).unwrap();
545 assert_eq!(
546 find_project_root(&sub).unwrap().canonicalize().unwrap(),
547 tmp.path().canonicalize().unwrap()
548 );
549 }
550
551 #[test]
552 fn find_project_root_prefers_atman_dir() {
553 let tmp = TempDir::new().unwrap();
554 std::fs::create_dir(tmp.path().join(".atman")).unwrap();
555 let root = find_project_root(tmp.path()).unwrap();
556 assert_eq!(
557 root.canonicalize().unwrap(),
558 tmp.path().canonicalize().unwrap()
559 );
560 }
561
562 #[test]
563 fn find_project_root_returns_none_when_nothing_matches() {
564 let tmp = TempDir::new().unwrap();
565 assert!(find_project_root(tmp.path()).is_none());
566 }
567
568 #[test]
569 fn start_path_is_the_project_without_repository_markers() {
570 let tmp = TempDir::new().unwrap();
571 let nested = tmp.path().join("plain").join("nested");
572 std::fs::create_dir_all(&nested).unwrap();
573 let meta = SessionMeta::from_start_path(Some(&nested));
574 let canonical = nested.canonicalize().unwrap();
575 assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
576 assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
577 let fingerprint = fingerprint_from_root(&canonical);
578 assert_eq!(
579 meta.project_fingerprint.as_deref(),
580 Some(fingerprint.as_str())
581 );
582 }
583
584 #[test]
585 fn auto_title_does_not_overwrite_user_title() {
586 let tmp = TempDir::new().unwrap();
587 SessionMeta::from_start_path(Some(tmp.path()))
588 .save(tmp.path())
589 .unwrap();
590 assert!(SessionMeta::set_auto_title(tmp.path(), "Generated name").unwrap());
591 SessionMeta::set_title(tmp.path(), Some("User name".into())).unwrap();
592 assert!(!SessionMeta::set_auto_title(tmp.path(), "Replacement").unwrap());
593 let meta = SessionMeta::load(tmp.path()).unwrap();
594 assert_eq!(meta.title.as_deref(), Some("User name"));
595 assert_eq!(meta.name_source, NameSource::User);
596 }
597
598 #[test]
599 fn built_in_session_name_flow_parses() {
600 let parsed = atman_dsl::parse::parse_file(crate::templates::SESSION_NAME_AT).unwrap();
601 assert_eq!(parsed.flows[0].name.name, "session_name");
602 }
603
604 #[test]
605 fn save_then_load_round_trips() {
606 let tmp = TempDir::new().unwrap();
607 let meta = SessionMeta {
608 project_root: Some(PathBuf::from("/tmp/foo")),
609 start_path: Some(PathBuf::from("/tmp/foo/sub")),
610 project_fingerprint: Some("deadbeef".repeat(2)),
611 created_at: Some(Utc::now()),
612 title: Some("nice title".into()),
613 name_source: NameSource::User,
614 tags: vec!["x".into()],
615 };
616 meta.save(tmp.path()).unwrap();
617 let back = SessionMeta::load(tmp.path()).unwrap();
618 assert_eq!(back.project_root, meta.project_root);
619 assert_eq!(back.start_path, meta.start_path);
620 assert_eq!(back.project_fingerprint, meta.project_fingerprint);
621 }
622
623 #[test]
624 fn auto_title_does_not_overwrite_manual_rename() {
625 let tmp = TempDir::new().unwrap();
626 SessionMeta::default().save(tmp.path()).unwrap();
627 let auto = SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Generated title")
628 .unwrap()
629 .unwrap();
630 assert_eq!(auto.name_source, NameSource::Auto);
631 let manual = SessionMeta::rename(tmp.path(), "Manual title").unwrap();
632 assert_eq!(manual.name_source, NameSource::User);
633 assert!(
634 SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Late generated title")
635 .unwrap()
636 .is_none()
637 );
638 let loaded = SessionMeta::load(tmp.path()).unwrap();
639 assert_eq!(loaded.title.as_deref(), Some("Manual title"));
640 assert_eq!(loaded.name_source, NameSource::User);
641 }
642
643 #[test]
644 fn discovery_filters_and_renames_sessions() {
645 let tmp = TempDir::new().unwrap();
646 let project = tmp.path().join("project");
647 let first = tmp.path().join("sessions/first");
648 let second = tmp.path().join("sessions/second");
649 std::fs::create_dir_all(&first).unwrap();
650 std::fs::create_dir_all(&second).unwrap();
651 SessionMeta {
652 project_root: Some(project.clone()),
653 created_at: Some(Utc::now()),
654 ..SessionMeta::default()
655 }
656 .save(&first)
657 .unwrap();
658 SessionMeta {
659 project_root: Some(tmp.path().join("other")),
660 created_at: Some(Utc::now()),
661 ..SessionMeta::default()
662 }
663 .save(&second)
664 .unwrap();
665 std::fs::write(first.join("events.jsonl"), "{}\n{}\n").unwrap();
666 assert_eq!(
667 SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project))
668 .unwrap()
669 .len(),
670 1
671 );
672 assert_eq!(
673 SessionMeta::discover(tmp.path(), SessionScope::AllProjects)
674 .unwrap()
675 .len(),
676 2
677 );
678 assert_eq!(
679 SessionMeta::rename(&first, " Login fix ")
680 .unwrap()
681 .title
682 .as_deref(),
683 Some("Login fix")
684 );
685 assert!(SessionMeta::rename(&first, " ").is_err());
686 assert_eq!(
687 SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project)).unwrap()[0]
688 .event_count,
689 2
690 );
691 }
692
693 #[test]
694 fn rebase_updates_project_root_and_fingerprint() {
695 let tmp = TempDir::new().unwrap();
696 std::fs::create_dir(tmp.path().join(".git")).unwrap();
697 let sub = tmp.path().join("sub");
698 std::fs::create_dir_all(&sub).unwrap();
699
700 let mut meta = SessionMeta {
701 project_root: Some(PathBuf::from("/old")),
702 start_path: Some(PathBuf::from("/old")),
703 project_fingerprint: Some("0000000000000000".into()),
704 created_at: None,
705 title: None,
706 name_source: NameSource::Auto,
707 tags: vec![],
708 };
709 meta.rebase(&sub);
710 let canonical = sub.canonicalize().unwrap();
711 assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
712 assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
713 let expected_fp = fingerprint_from_root(&canonical);
714 assert_eq!(meta.project_fingerprint, Some(expected_fp));
715 }
716
717 #[test]
718 fn session_meta_serde_backward_compat_no_start_path() {
719 let json = r#"{"project_root":"/tmp/foo","project_fingerprint":"deadbeefdeadbeef","created_at":"2025-01-01T00:00:00Z"}"#;
721 let meta: SessionMeta = serde_json::from_str(json).unwrap();
722 assert_eq!(meta.project_root, Some(PathBuf::from("/tmp/foo")));
723 assert_eq!(meta.start_path, None);
724 }
725
726 #[test]
727 fn load_returns_none_when_file_missing() {
728 let tmp = TempDir::new().unwrap();
729 assert!(SessionMeta::load(tmp.path()).is_none());
730 }
731
732 #[test]
733 fn session_stats_backfill_is_reused_until_the_log_changes() {
734 let tmp = TempDir::new().unwrap();
735 let events = tmp.path().join("events.jsonl");
736 let first = concat!(
737 "{\"type\":\"user_msg\",\"ts\":\"2026-09-01T00:00:00Z\"}\n",
738 "not-json\n",
739 "{\"type\":\"assistant_msg\",\"ts\":\"2026-09-01T00:00:01Z\"}\n"
740 );
741 std::fs::write(&events, first).unwrap();
742 STATS_SCANS.with(|count| count.set(0));
743
744 let stats = SessionStats::load_or_rebuild(tmp.path()).unwrap();
745 assert_eq!(stats.event_bytes, first.len() as u64);
746 assert_eq!(stats.event_count, 3);
747 assert_eq!(stats.user_message_count, 1);
748 assert_eq!(stats.message_count, 2);
749 assert_eq!(
750 stats.first_ts,
751 Some("2026-09-01T00:00:00Z".parse().unwrap())
752 );
753 assert!(tmp.path().join(STATS_FILENAME).exists());
754
755 assert_eq!(SessionStats::load_or_rebuild(tmp.path()).unwrap(), stats);
756 assert_eq!(STATS_SCANS.with(std::cell::Cell::get), 1);
757
758 use std::io::Write;
759 let mut file = std::fs::OpenOptions::new()
760 .append(true)
761 .open(&events)
762 .unwrap();
763 writeln!(file, "{{\"type\":\"tool_result_msg\"}}").unwrap();
764 let updated = SessionStats::load_or_rebuild(tmp.path()).unwrap();
765 assert_eq!(updated.event_count, 4);
766 assert_eq!(updated.message_count, 3);
767 assert_eq!(STATS_SCANS.with(std::cell::Cell::get), 2);
768 }
769}