Skip to main content

atman_runtime/
session_meta.rs

1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6const META_FILENAME: &str = "meta.json";
7
8fn is_auto_name(source: &NameSource) -> bool {
9    matches!(source, NameSource::Auto)
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SessionScope<'a> {
14    CurrentProject(&'a Path),
15    AllProjects,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum DiscoveryScope {
20    CurrentProject {
21        project_root: PathBuf,
22        project_fingerprint: String,
23    },
24    AllProjects,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SessionDiscoveryQuery {
29    pub scope: DiscoveryScope,
30    pub include_legacy: bool,
31    pub text: Option<String>,
32    pub limit: Option<usize>,
33}
34
35impl SessionDiscoveryQuery {
36    pub fn all_projects() -> Self {
37        Self {
38            scope: DiscoveryScope::AllProjects,
39            include_legacy: false,
40            text: None,
41            limit: None,
42        }
43    }
44
45    pub fn current_project(project_root: &Path) -> Self {
46        Self {
47            scope: DiscoveryScope::CurrentProject {
48                project_root: canonical_root(project_root),
49                project_fingerprint: fingerprint_from_root(project_root),
50            },
51            include_legacy: false,
52            text: None,
53            limit: None,
54        }
55    }
56
57    pub fn with_legacy(mut self, include_legacy: bool) -> Self {
58        self.include_legacy = include_legacy;
59        self
60    }
61
62    pub fn matches_meta(&self, meta: Option<&SessionMeta>) -> bool {
63        let Some(meta) = meta else {
64            return self.include_legacy;
65        };
66        match &self.scope {
67            DiscoveryScope::AllProjects => {
68                self.include_legacy || meta.project_fingerprint.is_some()
69            }
70            DiscoveryScope::CurrentProject {
71                project_root,
72                project_fingerprint,
73            } => {
74                if meta.project_fingerprint.is_none() {
75                    return self.include_legacy;
76                }
77                meta.project_fingerprint.as_deref() == Some(project_fingerprint)
78                    || meta.project_root.as_deref().map(canonical_root).as_ref()
79                        == Some(project_root)
80            }
81        }
82    }
83}
84
85#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
86#[serde(rename_all = "snake_case")]
87pub enum NameSource {
88    #[default]
89    Auto,
90    User,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct SessionSummary {
95    pub id: String,
96    pub title: String,
97    pub name_source: NameSource,
98    pub project_root: Option<PathBuf>,
99    pub created_at: Option<DateTime<Utc>>,
100    pub event_count: usize,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct SessionMeta {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub project_root: Option<PathBuf>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub start_path: Option<PathBuf>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub project_fingerprint: Option<String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub created_at: Option<DateTime<Utc>>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub title: Option<String>,
115    #[serde(default, skip_serializing_if = "is_auto_name")]
116    pub name_source: NameSource,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub tags: Vec<String>,
119}
120
121impl SessionMeta {
122    pub fn load(session_dir: &Path) -> Option<Self> {
123        let path = session_dir.join(META_FILENAME);
124        let bytes = std::fs::read(&path).ok()?;
125        serde_json::from_slice(&bytes).ok()
126    }
127
128    pub fn save(&self, session_dir: &Path) -> std::io::Result<()> {
129        let path = session_dir.join(META_FILENAME);
130        let bytes = serde_json::to_vec_pretty(self)
131            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
132        std::fs::write(&path, bytes)
133    }
134
135    pub fn set_auto_title_if_unclaimed(
136        session_dir: &Path,
137        title: impl Into<String>,
138    ) -> std::io::Result<Option<Self>> {
139        let title = title.into().trim().to_owned();
140        if title.is_empty() {
141            return Err(std::io::Error::new(
142                std::io::ErrorKind::InvalidInput,
143                "title cannot be empty",
144            ));
145        }
146        let mut meta = Self::load(session_dir).ok_or_else(|| {
147            std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
148        })?;
149        if matches!(meta.name_source, NameSource::User) {
150            return Ok(None);
151        }
152        meta.title = Some(title);
153        meta.name_source = NameSource::Auto;
154        meta.save(session_dir)?;
155        Ok(Some(meta))
156    }
157
158    pub fn rename(session_dir: &Path, title: impl Into<String>) -> std::io::Result<Self> {
159        let title = title.into().trim().to_owned();
160        if title.is_empty() {
161            return Err(std::io::Error::new(
162                std::io::ErrorKind::InvalidInput,
163                "title cannot be empty",
164            ));
165        }
166        let mut meta = Self::load(session_dir).ok_or_else(|| {
167            std::io::Error::new(std::io::ErrorKind::NotFound, "session metadata not found")
168        })?;
169        meta.title = Some(title);
170        meta.name_source = NameSource::User;
171        meta.save(session_dir)?;
172        Ok(meta)
173    }
174
175    pub fn discover(root: &Path, scope: SessionScope<'_>) -> std::io::Result<Vec<SessionSummary>> {
176        let sessions = root.join("sessions");
177        let mut summaries = Vec::new();
178        if !sessions.exists() {
179            return Ok(summaries);
180        }
181        for entry in std::fs::read_dir(sessions)? {
182            let entry = entry?;
183            let path = entry.path();
184            if !path.is_dir() {
185                continue;
186            }
187            let Some(meta) = Self::load(&path) else {
188                continue;
189            };
190            if let SessionScope::CurrentProject(project) = scope
191                && meta.project_root.as_deref() != Some(project)
192            {
193                continue;
194            }
195            let event_count = std::fs::read_to_string(path.join("events.jsonl"))
196                .map(|s| s.lines().filter(|line| !line.trim().is_empty()).count())
197                .unwrap_or(0);
198            summaries.push(SessionSummary {
199                id: entry.file_name().to_string_lossy().into_owned(),
200                title: meta.title.unwrap_or_else(|| "Untitled session".into()),
201                name_source: meta.name_source,
202                project_root: meta.project_root,
203                created_at: meta.created_at,
204                event_count,
205            });
206        }
207        summaries.sort_by(|a, b| {
208            b.created_at
209                .cmp(&a.created_at)
210                .then_with(|| a.id.cmp(&b.id))
211        });
212        Ok(summaries)
213    }
214
215    pub fn from_cwd() -> Self {
216        let cwd = std::env::current_dir().ok();
217        Self::from_start_path(cwd.as_deref())
218    }
219
220    pub fn from_start_path(start: Option<&Path>) -> Self {
221        let project_root = start.map(canonical_root);
222        let project_fingerprint = project_root.as_deref().map(fingerprint_from_root);
223        Self {
224            start_path: project_root.clone(),
225            project_root,
226            project_fingerprint,
227            created_at: Some(Utc::now()),
228            title: None,
229            name_source: NameSource::Auto,
230            tags: Vec::new(),
231        }
232    }
233
234    pub fn rebase(&mut self, new_cwd: &Path) {
235        let project_root = canonical_root(new_cwd);
236        self.start_path = Some(project_root.clone());
237        self.project_fingerprint = Some(fingerprint_from_root(&project_root));
238        self.project_root = Some(project_root);
239    }
240
241    pub fn set_title(session_dir: &Path, title: Option<String>) -> std::io::Result<()> {
242        let mut meta = Self::load(session_dir).unwrap_or_default();
243        meta.title = title;
244        meta.name_source = NameSource::User;
245        meta.save(session_dir)
246    }
247
248    pub fn set_auto_title(session_dir: &Path, title: impl Into<String>) -> std::io::Result<bool> {
249        Self::set_auto_title_with_force(session_dir, title, false)
250    }
251
252    pub fn set_auto_title_with_force(
253        session_dir: &Path,
254        title: impl Into<String>,
255        force: bool,
256    ) -> std::io::Result<bool> {
257        let mut meta = Self::load(session_dir).unwrap_or_default();
258        if !force && meta.name_source == NameSource::User {
259            return Ok(false);
260        }
261        let title = title.into().trim().replace(['\n', '\r'], " ");
262        let title: String = title.chars().take(60).collect();
263        if title.is_empty() {
264            return Ok(false);
265        }
266        meta.title = Some(title);
267        meta.name_source = NameSource::Auto;
268        meta.save(session_dir)?;
269        Ok(true)
270    }
271}
272
273pub fn fingerprint_from_root(root: &Path) -> String {
274    let stable = root
275        .canonicalize()
276        .or_else(|_| {
277            if root.is_absolute() {
278                Ok(root.to_path_buf())
279            } else {
280                std::env::current_dir().map(|cwd| cwd.join(root))
281            }
282        })
283        .unwrap_or_else(|_| root.to_path_buf());
284    let digest = blake3::hash(stable.to_string_lossy().as_bytes());
285    hex_prefix(digest.as_bytes(), 16)
286}
287
288/// Return the canonical form of the project root, falling back to the raw path.
289pub fn canonical_root(root: &Path) -> PathBuf {
290    root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
291}
292
293fn hex_prefix(bytes: &[u8], hex_chars: usize) -> String {
294    let mut out = String::with_capacity(hex_chars);
295    for byte in bytes {
296        if out.len() >= hex_chars {
297            break;
298        }
299        out.push_str(&format!("{byte:02x}"));
300    }
301    out.truncate(hex_chars);
302    out
303}
304
305pub fn find_project_root(start: &Path) -> Option<PathBuf> {
306    let mut cursor: Option<&Path> = Some(start);
307    while let Some(dir) = cursor {
308        if dir.join(".atman").is_dir() || dir.join(".git").exists() {
309            return Some(dir.to_path_buf());
310        }
311        cursor = dir.parent();
312    }
313    None
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use tempfile::TempDir;
320
321    #[test]
322    fn discovery_query_matches_project_identity_and_legacy_policy() {
323        let root = PathBuf::from("/tmp/project");
324        let query = SessionDiscoveryQuery::current_project(&root).with_legacy(false);
325        let matching = SessionMeta {
326            project_root: Some(root.clone()),
327            project_fingerprint: Some(fingerprint_from_root(&root)),
328            ..SessionMeta::default()
329        };
330        let other = SessionMeta {
331            project_root: Some(PathBuf::from("/tmp/other")),
332            project_fingerprint: Some(fingerprint_from_root(Path::new("/tmp/other"))),
333            ..SessionMeta::default()
334        };
335        assert!(query.matches_meta(Some(&matching)));
336        assert!(!query.matches_meta(Some(&other)));
337        assert!(!query.matches_meta(Some(&SessionMeta::default())));
338        assert!(!SessionDiscoveryQuery::all_projects().matches_meta(None));
339        assert!(
340            SessionDiscoveryQuery::all_projects()
341                .with_legacy(true)
342                .matches_meta(None)
343        );
344    }
345
346    #[test]
347    fn fingerprint_is_stable_16_hex_chars() {
348        let tmp = TempDir::new().unwrap();
349        let fp = fingerprint_from_root(tmp.path());
350        assert_eq!(fp.len(), 16);
351        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
352        assert_eq!(fp, fingerprint_from_root(tmp.path()));
353    }
354
355    #[test]
356    fn find_project_root_locates_git_ancestor() {
357        let tmp = TempDir::new().unwrap();
358        std::fs::create_dir(tmp.path().join(".git")).unwrap();
359        let sub = tmp.path().join("nested/deep");
360        std::fs::create_dir_all(&sub).unwrap();
361        assert_eq!(
362            find_project_root(&sub).unwrap().canonicalize().unwrap(),
363            tmp.path().canonicalize().unwrap()
364        );
365    }
366
367    #[test]
368    fn find_project_root_prefers_atman_dir() {
369        let tmp = TempDir::new().unwrap();
370        std::fs::create_dir(tmp.path().join(".atman")).unwrap();
371        let root = find_project_root(tmp.path()).unwrap();
372        assert_eq!(
373            root.canonicalize().unwrap(),
374            tmp.path().canonicalize().unwrap()
375        );
376    }
377
378    #[test]
379    fn find_project_root_returns_none_when_nothing_matches() {
380        let tmp = TempDir::new().unwrap();
381        assert!(find_project_root(tmp.path()).is_none());
382    }
383
384    #[test]
385    fn start_path_is_the_project_without_repository_markers() {
386        let tmp = TempDir::new().unwrap();
387        let nested = tmp.path().join("plain").join("nested");
388        std::fs::create_dir_all(&nested).unwrap();
389        let meta = SessionMeta::from_start_path(Some(&nested));
390        let canonical = nested.canonicalize().unwrap();
391        assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
392        assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
393        let fingerprint = fingerprint_from_root(&canonical);
394        assert_eq!(
395            meta.project_fingerprint.as_deref(),
396            Some(fingerprint.as_str())
397        );
398    }
399
400    #[test]
401    fn auto_title_does_not_overwrite_user_title() {
402        let tmp = TempDir::new().unwrap();
403        SessionMeta::from_start_path(Some(tmp.path()))
404            .save(tmp.path())
405            .unwrap();
406        assert!(SessionMeta::set_auto_title(tmp.path(), "Generated name").unwrap());
407        SessionMeta::set_title(tmp.path(), Some("User name".into())).unwrap();
408        assert!(!SessionMeta::set_auto_title(tmp.path(), "Replacement").unwrap());
409        let meta = SessionMeta::load(tmp.path()).unwrap();
410        assert_eq!(meta.title.as_deref(), Some("User name"));
411        assert_eq!(meta.name_source, NameSource::User);
412    }
413
414    #[test]
415    fn built_in_session_name_flow_parses() {
416        let parsed = atman_dsl::parse::parse_file(crate::templates::SESSION_NAME_AT).unwrap();
417        assert_eq!(parsed.flows[0].name.name, "session_name");
418    }
419
420    #[test]
421    fn save_then_load_round_trips() {
422        let tmp = TempDir::new().unwrap();
423        let meta = SessionMeta {
424            project_root: Some(PathBuf::from("/tmp/foo")),
425            start_path: Some(PathBuf::from("/tmp/foo/sub")),
426            project_fingerprint: Some("deadbeef".repeat(2)),
427            created_at: Some(Utc::now()),
428            title: Some("nice title".into()),
429            name_source: NameSource::User,
430            tags: vec!["x".into()],
431        };
432        meta.save(tmp.path()).unwrap();
433        let back = SessionMeta::load(tmp.path()).unwrap();
434        assert_eq!(back.project_root, meta.project_root);
435        assert_eq!(back.start_path, meta.start_path);
436        assert_eq!(back.project_fingerprint, meta.project_fingerprint);
437    }
438
439    #[test]
440    fn auto_title_does_not_overwrite_manual_rename() {
441        let tmp = TempDir::new().unwrap();
442        SessionMeta::default().save(tmp.path()).unwrap();
443        let auto = SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Generated title")
444            .unwrap()
445            .unwrap();
446        assert_eq!(auto.name_source, NameSource::Auto);
447        let manual = SessionMeta::rename(tmp.path(), "Manual title").unwrap();
448        assert_eq!(manual.name_source, NameSource::User);
449        assert!(
450            SessionMeta::set_auto_title_if_unclaimed(tmp.path(), "Late generated title")
451                .unwrap()
452                .is_none()
453        );
454        let loaded = SessionMeta::load(tmp.path()).unwrap();
455        assert_eq!(loaded.title.as_deref(), Some("Manual title"));
456        assert_eq!(loaded.name_source, NameSource::User);
457    }
458
459    #[test]
460    fn discovery_filters_and_renames_sessions() {
461        let tmp = TempDir::new().unwrap();
462        let project = tmp.path().join("project");
463        let first = tmp.path().join("sessions/first");
464        let second = tmp.path().join("sessions/second");
465        std::fs::create_dir_all(&first).unwrap();
466        std::fs::create_dir_all(&second).unwrap();
467        SessionMeta {
468            project_root: Some(project.clone()),
469            created_at: Some(Utc::now()),
470            ..SessionMeta::default()
471        }
472        .save(&first)
473        .unwrap();
474        SessionMeta {
475            project_root: Some(tmp.path().join("other")),
476            created_at: Some(Utc::now()),
477            ..SessionMeta::default()
478        }
479        .save(&second)
480        .unwrap();
481        std::fs::write(first.join("events.jsonl"), "{}\n{}\n").unwrap();
482        assert_eq!(
483            SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project))
484                .unwrap()
485                .len(),
486            1
487        );
488        assert_eq!(
489            SessionMeta::discover(tmp.path(), SessionScope::AllProjects)
490                .unwrap()
491                .len(),
492            2
493        );
494        assert_eq!(
495            SessionMeta::rename(&first, "  Login fix  ")
496                .unwrap()
497                .title
498                .as_deref(),
499            Some("Login fix")
500        );
501        assert!(SessionMeta::rename(&first, " ").is_err());
502        assert_eq!(
503            SessionMeta::discover(tmp.path(), SessionScope::CurrentProject(&project)).unwrap()[0]
504                .event_count,
505            2
506        );
507    }
508
509    #[test]
510    fn rebase_updates_project_root_and_fingerprint() {
511        let tmp = TempDir::new().unwrap();
512        std::fs::create_dir(tmp.path().join(".git")).unwrap();
513        let sub = tmp.path().join("sub");
514        std::fs::create_dir_all(&sub).unwrap();
515
516        let mut meta = SessionMeta {
517            project_root: Some(PathBuf::from("/old")),
518            start_path: Some(PathBuf::from("/old")),
519            project_fingerprint: Some("0000000000000000".into()),
520            created_at: None,
521            title: None,
522            name_source: NameSource::Auto,
523            tags: vec![],
524        };
525        meta.rebase(&sub);
526        let canonical = sub.canonicalize().unwrap();
527        assert_eq!(meta.start_path.as_deref(), Some(canonical.as_path()));
528        assert_eq!(meta.project_root.as_deref(), Some(canonical.as_path()));
529        let expected_fp = fingerprint_from_root(&canonical);
530        assert_eq!(meta.project_fingerprint, Some(expected_fp));
531    }
532
533    #[test]
534    fn session_meta_serde_backward_compat_no_start_path() {
535        // Old meta.json without start_path should deserialize with start_path = None.
536        let json = r#"{"project_root":"/tmp/foo","project_fingerprint":"deadbeefdeadbeef","created_at":"2025-01-01T00:00:00Z"}"#;
537        let meta: SessionMeta = serde_json::from_str(json).unwrap();
538        assert_eq!(meta.project_root, Some(PathBuf::from("/tmp/foo")));
539        assert_eq!(meta.start_path, None);
540    }
541
542    #[test]
543    fn load_returns_none_when_file_missing() {
544        let tmp = TempDir::new().unwrap();
545        assert!(SessionMeta::load(tmp.path()).is_none());
546    }
547}