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
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
9pub struct SessionMeta {
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub project_root: Option<PathBuf>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub start_path: Option<PathBuf>,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub project_fingerprint: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub created_at: Option<DateTime<Utc>>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub title: Option<String>,
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub tags: Vec<String>,
22}
23
24impl SessionMeta {
25    pub fn load(session_dir: &Path) -> Option<Self> {
26        let path = session_dir.join(META_FILENAME);
27        let bytes = std::fs::read(&path).ok()?;
28        serde_json::from_slice(&bytes).ok()
29    }
30
31    pub fn save(&self, session_dir: &Path) -> std::io::Result<()> {
32        let path = session_dir.join(META_FILENAME);
33        let bytes = serde_json::to_vec_pretty(self)
34            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
35        std::fs::write(&path, bytes)
36    }
37
38    pub fn from_cwd() -> Self {
39        let cwd = std::env::current_dir().ok();
40        Self::from_start_path(cwd.as_deref())
41    }
42
43    pub fn from_start_path(start: Option<&Path>) -> Self {
44        let project_root = start.and_then(find_project_root);
45        let project_fingerprint = project_root.as_deref().map(fingerprint_from_root);
46        Self {
47            project_root,
48            start_path: start.map(|p| p.to_path_buf()),
49            project_fingerprint,
50            created_at: Some(Utc::now()),
51            title: None,
52            tags: Vec::new(),
53        }
54    }
55
56    /// Recompute `project_root`, `project_fingerprint`, and `start_path`
57    /// from a new working directory.
58    pub fn rebase(&mut self, new_cwd: &Path) {
59        self.start_path = Some(new_cwd.to_path_buf());
60        self.project_root = find_project_root(new_cwd);
61        self.project_fingerprint = self.project_root.as_deref().map(fingerprint_from_root);
62    }
63
64    pub fn set_title(session_dir: &Path, title: Option<String>) -> std::io::Result<()> {
65        let mut meta = Self::load(session_dir).unwrap_or_default();
66        meta.title = title;
67        meta.save(session_dir)
68    }
69}
70
71pub fn fingerprint_from_root(root: &Path) -> String {
72    let stable = root
73        .canonicalize()
74        .or_else(|_| {
75            if root.is_absolute() {
76                Ok(root.to_path_buf())
77            } else {
78                std::env::current_dir().map(|cwd| cwd.join(root))
79            }
80        })
81        .unwrap_or_else(|_| root.to_path_buf());
82    let digest = blake3::hash(stable.to_string_lossy().as_bytes());
83    hex_prefix(digest.as_bytes(), 16)
84}
85
86/// Return the canonical form of the project root, falling back to the raw path.
87pub fn canonical_root(root: &Path) -> PathBuf {
88    root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
89}
90
91fn hex_prefix(bytes: &[u8], hex_chars: usize) -> String {
92    let mut out = String::with_capacity(hex_chars);
93    for byte in bytes {
94        if out.len() >= hex_chars {
95            break;
96        }
97        out.push_str(&format!("{byte:02x}"));
98    }
99    out.truncate(hex_chars);
100    out
101}
102
103pub fn find_project_root(start: &Path) -> Option<PathBuf> {
104    let mut cursor: Option<&Path> = Some(start);
105    while let Some(dir) = cursor {
106        if dir.join(".atman").is_dir() || dir.join(".git").exists() {
107            return Some(dir.to_path_buf());
108        }
109        cursor = dir.parent();
110    }
111    None
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use tempfile::TempDir;
118
119    #[test]
120    fn fingerprint_is_stable_16_hex_chars() {
121        let tmp = TempDir::new().unwrap();
122        let fp = fingerprint_from_root(tmp.path());
123        assert_eq!(fp.len(), 16);
124        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
125        assert_eq!(fp, fingerprint_from_root(tmp.path()));
126    }
127
128    #[test]
129    fn find_project_root_locates_git_ancestor() {
130        let tmp = TempDir::new().unwrap();
131        std::fs::create_dir(tmp.path().join(".git")).unwrap();
132        let sub = tmp.path().join("nested/deep");
133        std::fs::create_dir_all(&sub).unwrap();
134        assert_eq!(
135            find_project_root(&sub).unwrap().canonicalize().unwrap(),
136            tmp.path().canonicalize().unwrap()
137        );
138    }
139
140    #[test]
141    fn find_project_root_prefers_atman_dir() {
142        let tmp = TempDir::new().unwrap();
143        std::fs::create_dir(tmp.path().join(".atman")).unwrap();
144        let root = find_project_root(tmp.path()).unwrap();
145        assert_eq!(
146            root.canonicalize().unwrap(),
147            tmp.path().canonicalize().unwrap()
148        );
149    }
150
151    #[test]
152    fn find_project_root_returns_none_when_nothing_matches() {
153        let tmp = TempDir::new().unwrap();
154        assert!(find_project_root(tmp.path()).is_none());
155    }
156
157    #[test]
158    fn save_then_load_round_trips() {
159        let tmp = TempDir::new().unwrap();
160        let meta = SessionMeta {
161            project_root: Some(PathBuf::from("/tmp/foo")),
162            start_path: Some(PathBuf::from("/tmp/foo/sub")),
163            project_fingerprint: Some("deadbeef".repeat(2)),
164            created_at: Some(Utc::now()),
165            title: Some("nice title".into()),
166            tags: vec!["x".into()],
167        };
168        meta.save(tmp.path()).unwrap();
169        let back = SessionMeta::load(tmp.path()).unwrap();
170        assert_eq!(back.project_root, meta.project_root);
171        assert_eq!(back.start_path, meta.start_path);
172        assert_eq!(back.project_fingerprint, meta.project_fingerprint);
173    }
174
175    #[test]
176    fn rebase_updates_project_root_and_fingerprint() {
177        let tmp = TempDir::new().unwrap();
178        std::fs::create_dir(tmp.path().join(".git")).unwrap();
179        let sub = tmp.path().join("sub");
180        std::fs::create_dir_all(&sub).unwrap();
181
182        let mut meta = SessionMeta {
183            project_root: Some(PathBuf::from("/old")),
184            start_path: Some(PathBuf::from("/old")),
185            project_fingerprint: Some("0000000000000000".into()),
186            created_at: None,
187            title: None,
188            tags: vec![],
189        };
190        meta.rebase(&sub);
191        assert_eq!(meta.start_path, Some(sub.clone()));
192        assert_eq!(
193            meta.project_root.unwrap().canonicalize().unwrap(),
194            tmp.path().canonicalize().unwrap()
195        );
196        let expected_fp = fingerprint_from_root(tmp.path());
197        assert_eq!(meta.project_fingerprint, Some(expected_fp));
198    }
199
200    #[test]
201    fn session_meta_serde_backward_compat_no_start_path() {
202        // Old meta.json without start_path should deserialize with start_path = None.
203        let json = r#"{"project_root":"/tmp/foo","project_fingerprint":"deadbeefdeadbeef","created_at":"2025-01-01T00:00:00Z"}"#;
204        let meta: SessionMeta = serde_json::from_str(json).unwrap();
205        assert_eq!(meta.project_root, Some(PathBuf::from("/tmp/foo")));
206        assert_eq!(meta.start_path, None);
207    }
208
209    #[test]
210    fn load_returns_none_when_file_missing() {
211        let tmp = TempDir::new().unwrap();
212        assert!(SessionMeta::load(tmp.path()).is_none());
213    }
214}