atman_runtime/
session_meta.rs1use 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 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
86pub 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 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}