Skip to main content

atman_runtime/
session_meta.rs

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
471/// Return the canonical form of the project root, falling back to the raw path.
472pub 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        // Old meta.json without start_path should deserialize with start_path = None.
719        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}