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
288pub 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 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}