Skip to main content

atomcode_core/
session.rs

1//! Session management for persistent conversation contexts.
2//!
3//! Each session represents an independent conversation with its own message history,
4//! associated with a specific working directory.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11use crate::conversation::message::{Message, Role};
12
13/// Unique identifier for a session.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct SessionId(String);
16
17impl SessionId {
18    pub fn new() -> Self {
19        Self(Uuid::new_v4().to_string())
20    }
21
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25
26    /// Create from an existing string (for loading sessions).
27    pub fn from_string(s: String) -> Self {
28        Self(s)
29    }
30}
31
32impl Default for SessionId {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl std::fmt::Display for SessionId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.0)
41    }
42}
43
44/// A session represents an independent conversation context.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Session {
47    /// Unique identifier.
48    pub id: SessionId,
49    /// Display name (AI-generated or user-specified).
50    pub name: String,
51    /// Working directory this session is associated with.
52    pub working_dir: PathBuf,
53    /// Creation timestamp (seconds since UNIX epoch).
54    pub created_at: u64,
55    /// Last update timestamp.
56    pub updated_at: u64,
57    /// Conversation messages.
58    pub messages: Vec<Message>,
59    /// True once the user has explicitly run `/rename`. Drives the
60    /// session-name badge above the input box: auto-named sessions
61    /// (default / session-* / first-message-derived) stay badge-less
62    /// so the chrome doesn't get noisy on every fresh conversation —
63    /// the badge is reserved for names the user deliberately chose.
64    /// `#[serde(default)]` so sessions saved before this field exists
65    /// load as `false` (i.e., behave like auto-named).
66    #[serde(default)]
67    pub user_renamed: bool,
68}
69
70impl Session {
71    /// Create a new session for the given working directory.
72    pub fn new(working_dir: PathBuf) -> Self {
73        let now = current_timestamp();
74        Self {
75            id: SessionId::new(),
76            name: format!("session-{}", format_timestamp(now)),
77            working_dir,
78            created_at: now,
79            updated_at: now,
80            messages: Vec::new(),
81            user_renamed: false,
82        }
83    }
84
85    /// Create a default session (used on first launch).
86    pub fn default_session(working_dir: PathBuf) -> Self {
87        Self {
88            id: SessionId::new(),
89            name: "default".to_string(),
90            working_dir,
91            created_at: current_timestamp(),
92            updated_at: current_timestamp(),
93            messages: Vec::new(),
94            user_renamed: false,
95        }
96    }
97
98    /// Update the session's name in response to an explicit user
99    /// `/rename`. Also flips `user_renamed` so the session-name badge
100    /// becomes visible — auto_name_from_messages must NOT call this.
101    pub fn rename(&mut self, name: String) {
102        self.name = name;
103        self.user_renamed = true;
104        self.touch();
105    }
106
107    /// Auto-name an untouched session from the first real user message.
108    ///
109    /// This mirrors the TUI persistence behavior: default names are replaced
110    /// by the first non-synthetic user turn, while user-renamed sessions are
111    /// left alone.
112    pub fn auto_name_from_messages(&mut self) {
113        if !should_auto_name_session(&self.name) {
114            return;
115        }
116
117        let first_real_user = self
118            .messages
119            .iter()
120            .filter(|m| matches!(m.role, Role::User))
121            .find_map(|m| m.text().filter(|t| !is_synthetic_user_text(t)));
122
123        if let Some(text) = first_real_user {
124            let name: String = text.lines().next().unwrap_or("").chars().take(40).collect();
125            if !name.is_empty() {
126                self.name = name;
127            }
128        }
129    }
130
131    /// Update the last modified timestamp.
132    pub fn touch(&mut self) {
133        self.updated_at = current_timestamp();
134    }
135
136    /// Get a short display ID (first 8 chars of UUID).
137    pub fn short_id(&self) -> &str {
138        &self.id.0[..8]
139    }
140}
141
142fn should_auto_name_session(name: &str) -> bool {
143    name == "default" || name.starts_with("session-") || name.trim_start().starts_with('[')
144}
145
146fn is_synthetic_user_text(text: &str) -> bool {
147    text.trim_start().starts_with('[')
148}
149
150/// Metadata for a session (without full message history).
151/// Used for listing sessions efficiently.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SessionMeta {
154    pub id: SessionId,
155    pub name: String,
156    pub working_dir: PathBuf,
157    pub created_at: u64,
158    pub updated_at: u64,
159    pub message_count: usize,
160    /// Session file size in bytes
161    #[serde(default)]
162    pub file_size: u64,
163}
164
165impl From<&Session> for SessionMeta {
166    fn from(session: &Session) -> Self {
167        Self {
168            id: session.id.clone(),
169            name: session.name.clone(),
170            working_dir: session.working_dir.clone(),
171            created_at: session.created_at,
172            updated_at: session.updated_at,
173            message_count: session.messages.len(),
174            file_size: 0, // Will be populated by list()
175        }
176    }
177}
178
179/// Session manager handles persistence and lifecycle.
180pub struct SessionManager {
181    /// Root directory for session storage (~/.atomcode/sessions/).
182    sessions_dir: PathBuf,
183    /// Hash of the current project's working directory.
184    project_hash: String,
185}
186
187impl SessionManager {
188    /// Get the root directory for all sessions ($ATOMCODE_HOME/sessions/).
189    pub fn sessions_root_dir() -> PathBuf {
190        crate::config::Config::config_dir().join("sessions")
191    }
192
193    /// Get the legacy sessions directory (used on macOS before v4.16).
194    /// Returns None on non-macOS platforms.
195    fn legacy_sessions_dir() -> Option<PathBuf> {
196        if cfg!(target_os = "macos") {
197            dirs::data_local_dir().map(|p| p.join("atomcode").join("sessions"))
198        } else {
199            None
200        }
201    }
202
203    /// Migrate sessions from legacy location to new location.
204    /// This is a no-op if:
205    /// - Not on macOS
206    /// - Legacy directory doesn't exist
207    /// - New directory already has sessions
208    pub fn migrate_from_legacy() {
209        let Some(legacy_dir) = Self::legacy_sessions_dir() else {
210            return; // Not macOS, no migration needed
211        };
212
213        if !legacy_dir.exists() {
214            return; // No legacy data
215        }
216
217        let new_dir = Self::sessions_root_dir();
218        if new_dir.exists() && std::fs::read_dir(&new_dir).map_or(false, |mut d| d.next().is_some())
219        {
220            return; // New location already has data, skip migration
221        }
222
223        // Perform migration
224        if let Err(e) = std::fs::create_dir_all(&new_dir) {
225            eprintln!("[session] Failed to create sessions dir: {}", e);
226            return;
227        }
228
229        match std::fs::read_dir(&legacy_dir) {
230            Ok(entries) => {
231                let mut migrated = 0;
232                for entry in entries.flatten() {
233                    let src = entry.path();
234                    let dst = new_dir.join(entry.file_name());
235                    if src.is_dir() {
236                        if let Err(e) = std::fs::create_dir_all(&dst) {
237                            eprintln!("[session] Failed to create {:?}: {}", dst, e);
238                            continue;
239                        }
240                        if let Ok(files) = std::fs::read_dir(&src) {
241                            for file in files.flatten() {
242                                let src_file = file.path();
243                                let dst_file = dst.join(file.file_name());
244                                if let Err(e) = std::fs::copy(&src_file, &dst_file) {
245                                    eprintln!("[session] Failed to copy {:?}: {}", src_file, e);
246                                } else {
247                                    migrated += 1;
248                                }
249                            }
250                        }
251                    }
252                }
253                if migrated > 0 {
254                    eprintln!(
255                        "[session] Migrated {} session(s) from legacy location",
256                        migrated
257                    );
258                }
259            }
260            Err(e) => {
261                eprintln!("[session] Failed to read legacy sessions dir: {}", e);
262            }
263        }
264    }
265
266    /// Create a new session manager for the given working directory.
267    pub fn new(working_dir: &Path) -> Self {
268        // Auto-migrate from legacy location on first use
269        Self::migrate_from_legacy();
270
271        let sessions_dir = Self::sessions_root_dir();
272        let project_hash = hash_path(working_dir);
273
274        Self {
275            sessions_dir,
276            project_hash,
277        }
278    }
279
280    /// Get the directory for this project's sessions.
281    fn project_dir(&self) -> PathBuf {
282        self.sessions_dir.join(&self.project_hash)
283    }
284
285    /// Ensure the project session directory exists.
286    fn ensure_dir(&self) -> std::io::Result<()> {
287        std::fs::create_dir_all(self.project_dir())
288    }
289
290    /// Save a session to disk.
291    pub fn save(&self, session: &Session) -> std::io::Result<()> {
292        self.ensure_dir()?;
293        let path = self.project_dir().join(format!("{}.json", session.id));
294        let json = serde_json::to_string_pretty(session)
295            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
296        std::fs::write(path, json)
297    }
298
299    /// Load a session by ID.
300    pub fn load(&self, id: &SessionId) -> std::io::Result<Session> {
301        let path = self.project_dir().join(format!("{}.json", id));
302        let json = std::fs::read_to_string(path)?;
303        serde_json::from_str(&json).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
304    }
305
306    /// List all sessions for this project (metadata only).
307    pub fn list(&self) -> std::io::Result<Vec<SessionMeta>> {
308        let project_dir = self.project_dir();
309        if !project_dir.exists() {
310            return Ok(Vec::new());
311        }
312
313        let mut sessions = Vec::new();
314        for entry in std::fs::read_dir(project_dir)? {
315            let entry = entry?;
316            let path = entry.path();
317            if path.extension().map_or(false, |ext| ext == "json") {
318                let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
319                if let Ok(json) = std::fs::read_to_string(&path) {
320                    if let Ok(session) = serde_json::from_str::<Session>(&json) {
321                        let mut meta = SessionMeta::from(&session);
322                        meta.file_size = file_size;
323                        sessions.push(meta);
324                    }
325                }
326            }
327        }
328
329        // Sort by updated_at descending (most recent first)
330        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
331        Ok(sessions)
332    }
333
334    /// Delete a session by ID.
335    pub fn delete(&self, id: &SessionId) -> std::io::Result<()> {
336        let path = self.project_dir().join(format!("{}.json", id));
337        std::fs::remove_file(path)
338    }
339
340    /// Check if any sessions exist for this project.
341    pub fn has_sessions(&self) -> bool {
342        let project_dir = self.project_dir();
343        project_dir.exists()
344            && std::fs::read_dir(project_dir).map_or(false, |mut d| d.next().is_some())
345    }
346
347    /// Get the most recently updated session.
348    pub fn latest(&self) -> std::io::Result<Option<Session>> {
349        let metas = self.list()?;
350        if let Some(latest) = metas.first() {
351            return self.load(&latest.id).map(Some);
352        }
353        Ok(None)
354    }
355}
356
357/// Generate a hash for a path (used as directory name).
358///
359/// Normalizes the path before hashing to ensure consistent results across:
360/// - Different path separators (Windows: `\` vs `/`)
361/// - Case sensitivity (Windows paths are case-insensitive)
362/// - Trailing slashes
363fn hash_path(path: &Path) -> String {
364    use std::collections::hash_map::DefaultHasher;
365    use std::hash::{Hash, Hasher};
366
367    // Normalize the path:
368    // 1. Convert to string representation
369    // 2. Replace backslashes with forward slashes (Windows)
370    // 3. Remove trailing slash (but keep root "/" or "C:/")
371    // 4. Lowercase on Windows (case-insensitive filesystem)
372    let normalized = path.to_string_lossy();
373    let mut normalized = normalized.replace('\\', "/");
374
375    if normalized.len() > 1 && normalized.ends_with('/') {
376        normalized.pop();
377    }
378
379    #[cfg(windows)]
380    let normalized = normalized.to_lowercase();
381
382    // IMPORTANT: hash through `Path::hash`, not `str::hash`. `Path`
383    // hashes its components with length prefixes, which is NOT the
384    // same as hashing the whole string. All sessions saved before
385    // the normalization pass was added went into buckets keyed by
386    // `Path::hash`; feeding the normalized string back through a
387    // `PathBuf` keeps us on that same bucket so /resume still finds
388    // legacy sessions. Hashing the raw `&str` here would silently
389    // orphan every pre-normalization session — see the "where did
390    // my /resume history go?" regression.
391    let mut hasher = DefaultHasher::new();
392    let p: PathBuf = PathBuf::from(normalized);
393    p.hash(&mut hasher);
394    format!("{:016x}", hasher.finish())
395}
396
397/// Get current timestamp in seconds.
398fn current_timestamp() -> u64 {
399    SystemTime::now()
400        .duration_since(UNIX_EPOCH)
401        .unwrap_or_default()
402        .as_secs()
403}
404
405/// Format timestamp as YYYYMMDD-HHMMSS.
406fn format_timestamp(ts: u64) -> String {
407    use chrono::{TimeZone, Utc};
408    let dt = Utc
409        .timestamp_opt(ts as i64, 0)
410        .single()
411        .unwrap_or_else(|| Utc::now());
412    dt.format("%Y%m%d-%H%M%S").to_string()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_session_id_is_unique() {
421        let id1 = SessionId::new();
422        let id2 = SessionId::new();
423        assert_ne!(id1, id2);
424    }
425
426    #[test]
427    fn test_session_new() {
428        let session = Session::new(PathBuf::from("/tmp/test"));
429        assert!(!session.id.0.is_empty());
430        assert!(session.name.starts_with("session-"));
431    }
432
433    #[test]
434    fn auto_name_uses_first_real_user_message() {
435        let mut session = Session::new(PathBuf::from("/tmp/test"));
436        session
437            .messages
438            .push(Message::new(Role::User, "[System meta · not a user message]\nignored"));
439        session
440            .messages
441            .push(Message::new(Role::User, "帮我修复 VS Code 会话标题自动命名的问题\n更多内容"));
442
443        session.auto_name_from_messages();
444
445        assert_eq!(session.name, "帮我修复 VS Code 会话标题自动命名的问题");
446    }
447
448    #[test]
449    fn auto_name_preserves_user_renamed_session() {
450        let mut session = Session::new(PathBuf::from("/tmp/test"));
451        session.rename("手动命名".to_string());
452        session.messages.push(Message::new(Role::User, "新的用户消息"));
453
454        session.auto_name_from_messages();
455
456        assert_eq!(session.name, "手动命名");
457    }
458
459    #[test]
460    fn rename_sets_user_renamed_flag() {
461        let mut session = Session::new(PathBuf::from("/tmp/test"));
462        assert!(!session.user_renamed, "fresh session must not be flagged as user-renamed");
463        session.rename("我的会话".to_string());
464        assert!(session.user_renamed, "rename() must mark the session as user-renamed");
465    }
466
467    #[test]
468    fn auto_name_does_not_set_user_renamed_flag() {
469        let mut session = Session::new(PathBuf::from("/tmp/test"));
470        session.messages.push(Message::new(Role::User, "first message body"));
471        session.auto_name_from_messages();
472        assert_eq!(session.name, "first message body");
473        assert!(
474            !session.user_renamed,
475            "auto_name_from_messages must NOT flag the session as user-renamed; only /rename should"
476        );
477    }
478
479    #[test]
480    fn test_hash_path_consistent() {
481        let path = Path::new("/Users/test/project");
482        let hash1 = hash_path(path);
483        let hash2 = hash_path(path);
484        assert_eq!(hash1, hash2);
485        assert_eq!(hash1.len(), 16);
486    }
487
488    #[test]
489    fn test_hash_path_normalized() {
490        // Same path with different representations should produce the same hash
491        // Note: on non-Windows, case sensitivity is preserved
492
493        // Test trailing slash normalization
494        let path1 = Path::new("/Users/test/project");
495        let path2 = Path::new("/Users/test/project/");
496        assert_eq!(
497            hash_path(path1),
498            hash_path(path2),
499            "Trailing slash should not affect hash"
500        );
501
502        // Test backslash normalization (Windows-style paths)
503        let path3 = Path::new("C:\\Users\\test\\project");
504        let path4 = Path::new("C:/Users/test/project");
505        assert_eq!(
506            hash_path(path3),
507            hash_path(path4),
508            "Backslashes should be normalized to forward slashes"
509        );
510
511        // Test combined: backslash + trailing slash
512        let path5 = Path::new("C:\\Users\\test\\project\\");
513        assert_eq!(
514            hash_path(path4),
515            hash_path(path5),
516            "Backslashes and trailing slash should both be normalized"
517        );
518    }
519
520    #[test]
521    fn hash_path_matches_legacy_path_hash_on_unix() {
522        // Regression guard: the pre-normalization implementation just did
523        // `path.hash(&mut hasher)`. Every session saved before the
524        // normalization pass lives in a bucket keyed by that hash. If
525        // `hash_path` stops matching `Path::hash` for a plain-ASCII Unix
526        // path with no trailing slash / backslashes, every legacy
527        // `/resume` session becomes invisible. See the "where did my
528        // /resume history go?" regression.
529        use std::collections::hash_map::DefaultHasher;
530        use std::hash::{Hash, Hasher};
531
532        let p = Path::new("/Users/theo/Documents/workspace/atomcode");
533        let mut expected = DefaultHasher::new();
534        p.hash(&mut expected);
535        let legacy = format!("{:016x}", expected.finish());
536        assert_eq!(hash_path(p), legacy);
537    }
538}