use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::conversation::message::{Message, Role};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(String);
impl SessionId {
pub fn new() -> Self {
Self(Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_string(s: String) -> Self {
Self(s)
}
}
impl Default for SessionId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnStat {
pub after_message: usize,
pub turn_count: usize,
pub tool_call_count: usize,
pub duration_ms: u64,
pub total_tokens: usize,
#[serde(default)]
pub errored: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub name: String,
pub working_dir: PathBuf,
pub created_at: u64,
pub updated_at: u64,
pub messages: Vec<Message>,
#[serde(default)]
pub user_renamed: bool,
#[serde(default)]
pub turn_stats: Vec<TurnStat>,
}
impl Session {
pub fn new(working_dir: PathBuf) -> Self {
let now = current_timestamp();
Self {
id: SessionId::new(),
name: format!("session-{}", format_timestamp(now)),
working_dir,
created_at: now,
updated_at: now,
messages: Vec::new(),
user_renamed: false,
turn_stats: Vec::new(),
}
}
pub fn default_session(working_dir: PathBuf) -> Self {
Self {
id: SessionId::new(),
name: "default".to_string(),
working_dir,
created_at: current_timestamp(),
updated_at: current_timestamp(),
messages: Vec::new(),
user_renamed: false,
turn_stats: Vec::new(),
}
}
pub fn rename(&mut self, name: String) {
self.name = name;
self.user_renamed = true;
self.touch();
}
pub fn auto_name_from_messages(&mut self) {
if !should_auto_name_session(&self.name) {
return;
}
let first_real_user = self
.messages
.iter()
.filter(|m| matches!(m.role, Role::User) && !m.synthetic)
.find_map(|m| m.text().filter(|t| !is_synthetic_user_text_legacy(t)));
if let Some(text) = first_real_user {
let name: String = text.lines().next().unwrap_or("").chars().take(40).collect();
if !name.is_empty() {
self.name = name;
}
}
}
pub fn touch(&mut self) {
self.updated_at = current_timestamp();
}
pub fn short_id(&self) -> &str {
&self.id.0[..8]
}
}
fn should_auto_name_session(name: &str) -> bool {
name == "default" || name.starts_with("session-") || name.trim_start().starts_with('[')
}
fn is_synthetic_user_text_legacy(text: &str) -> bool {
text.trim_start().starts_with('[')
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub id: SessionId,
pub name: String,
pub working_dir: PathBuf,
pub created_at: u64,
pub updated_at: u64,
pub message_count: usize,
#[serde(default)]
pub file_size: u64,
}
impl From<&Session> for SessionMeta {
fn from(session: &Session) -> Self {
Self {
id: session.id.clone(),
name: session.name.clone(),
working_dir: session.working_dir.clone(),
created_at: session.created_at,
updated_at: session.updated_at,
message_count: session.messages.len(),
file_size: 0,
}
}
}
pub struct SessionManager {
sessions_dir: PathBuf,
project_hash: String,
}
impl SessionManager {
pub fn sessions_root_dir() -> PathBuf {
crate::config::Config::config_dir().join("sessions")
}
fn legacy_sessions_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
dirs::data_local_dir().map(|p| p.join("atomcode").join("sessions"))
} else {
None
}
}
pub fn migrate_from_legacy() {
let Some(legacy_dir) = Self::legacy_sessions_dir() else {
return;
};
if !legacy_dir.exists() {
return;
}
let new_dir = Self::sessions_root_dir();
if new_dir.exists() && std::fs::read_dir(&new_dir).map_or(false, |mut d| d.next().is_some())
{
return;
}
if let Err(e) = std::fs::create_dir_all(&new_dir) {
tracing::warn!("[session] Failed to create sessions dir: {}", e);
return;
}
match std::fs::read_dir(&legacy_dir) {
Ok(entries) => {
let mut migrated = 0;
for entry in entries.flatten() {
let src = entry.path();
let dst = new_dir.join(entry.file_name());
if src.is_dir() {
if let Err(e) = std::fs::create_dir_all(&dst) {
tracing::warn!("[session] Failed to create {:?}: {}", dst, e);
continue;
}
if let Ok(files) = std::fs::read_dir(&src) {
for file in files.flatten() {
let src_file = file.path();
let dst_file = dst.join(file.file_name());
if let Err(e) = std::fs::copy(&src_file, &dst_file) {
tracing::warn!("[session] Failed to copy {:?}: {}", src_file, e);
} else {
migrated += 1;
}
}
}
}
}
if migrated > 0 {
tracing::info!(
"[session] Migrated {} session(s) from legacy location",
migrated
);
}
}
Err(e) => {
tracing::warn!("[session] Failed to read legacy sessions dir: {}", e);
}
}
}
pub fn new(working_dir: &Path) -> Self {
Self::migrate_from_legacy();
let sessions_dir = Self::sessions_root_dir();
let project_hash = hash_path(working_dir);
Self {
sessions_dir,
project_hash,
}
}
fn project_dir(&self) -> PathBuf {
self.sessions_dir.join(&self.project_hash)
}
fn ensure_dir(&self) -> std::io::Result<()> {
std::fs::create_dir_all(self.project_dir())
}
pub fn save(&self, session: &Session) -> std::io::Result<()> {
self.ensure_dir()?;
let path = self.project_dir().join(format!("{}.json", session.id));
let json = serde_json::to_string_pretty(session)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(path, json)
}
pub fn load(&self, id: &SessionId) -> std::io::Result<Session> {
let path = self.project_dir().join(format!("{}.json", id));
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}
pub fn list(&self) -> std::io::Result<Vec<SessionMeta>> {
let project_dir = self.project_dir();
if !project_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(project_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") {
let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
if let Ok(json) = std::fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<Session>(&json) {
let mut meta = SessionMeta::from(&session);
meta.file_size = file_size;
sessions.push(meta);
}
}
}
}
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(sessions)
}
pub fn delete(&self, id: &SessionId) -> std::io::Result<()> {
let path = self.project_dir().join(format!("{}.json", id));
std::fs::remove_file(path)
}
pub fn has_sessions(&self) -> bool {
let project_dir = self.project_dir();
project_dir.exists()
&& std::fs::read_dir(project_dir).map_or(false, |mut d| d.next().is_some())
}
pub fn latest(&self) -> std::io::Result<Option<Session>> {
let metas = self.list()?;
if let Some(latest) = metas.first() {
return self.load(&latest.id).map(Some);
}
Ok(None)
}
}
fn hash_path(path: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let normalized = path.to_string_lossy();
let mut normalized = normalized.replace('\\', "/");
if normalized.len() > 1 && normalized.ends_with('/') {
normalized.pop();
}
#[cfg(windows)]
let normalized = normalized.to_lowercase();
// IMPORTANT: hash through `Path::hash`, not `str::hash`. `Path`
// hashes its components with length prefixes, which is NOT the
// same as hashing the whole string. All sessions saved before
// the normalization pass was added went into buckets keyed by
// `Path::hash`; feeding the normalized string back through a
// `PathBuf` keeps us on that same bucket so /resume still finds
// legacy sessions. Hashing the raw `&str` here would silently
// orphan every pre-normalization session — see the "where did
// my /resume history go?" regression.
let mut hasher = DefaultHasher::new();
let p: PathBuf = PathBuf::from(normalized);
p.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Get current timestamp in seconds.
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Format timestamp as YYYYMMDD-HHMMSS.
fn format_timestamp(ts: u64) -> String {
use chrono::{TimeZone, Utc};
let dt = Utc
.timestamp_opt(ts as i64, 0)
.single()
.unwrap_or_else(|| Utc::now());
dt.format("%Y%m%d-%H%M%S").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_id_is_unique() {
let id1 = SessionId::new();
let id2 = SessionId::new();
assert_ne!(id1, id2);
}
#[test]
fn turn_stats_round_trip_and_default_empty_for_old_sessions() {
let mut s = Session::new(PathBuf::from("/tmp/x"));
s.turn_stats.push(TurnStat {
after_message: 4,
turn_count: 3,
tool_call_count: 5,
duration_ms: 6800,
total_tokens: 1651,
errored: false,
});
let json = serde_json::to_string(&s).unwrap();
let back: Session = serde_json::from_str(&json).unwrap();
assert_eq!(back.turn_stats.len(), 1);
assert_eq!(back.turn_stats[0].after_message, 4);
assert_eq!(back.turn_stats[0].total_tokens, 1651);
// A session saved before this field existed (no `turn_stats` key) must
// still load — `#[serde(default)]` → empty vec, replay falls back to
// plain dividers.
let old = r#"{"id":"abc","name":"x","working_dir":"/tmp/x","created_at":0,"updated_at":0,"messages":[]}"#;
let loaded: Session = serde_json::from_str(old).unwrap();
assert!(loaded.turn_stats.is_empty());
}
#[test]
fn test_session_new() {
let session = Session::new(PathBuf::from("/tmp/test"));
assert!(!session.id.0.is_empty());
assert!(session.name.starts_with("session-"));
}
#[test]
fn auto_name_uses_first_real_user_message() {
let mut session = Session::new(PathBuf::from("/tmp/test"));
session
.messages
.push(Message::new(Role::User, "[System meta · not a user message]\nignored"));
session
.messages
.push(Message::new(Role::User, "帮我修复 VS Code 会话标题自动命名的问题\n更多内容"));
session.auto_name_from_messages();
assert_eq!(session.name, "帮我修复 VS Code 会话标题自动命名的问题");
}
#[test]
fn auto_name_preserves_user_renamed_session() {
let mut session = Session::new(PathBuf::from("/tmp/test"));
session.rename("手动命名".to_string());
session.messages.push(Message::new(Role::User, "新的用户消息"));
session.auto_name_from_messages();
assert_eq!(session.name, "手动命名");
}
#[test]
fn rename_sets_user_renamed_flag() {
let mut session = Session::new(PathBuf::from("/tmp/test"));
assert!(!session.user_renamed, "fresh session must not be flagged as user-renamed");
session.rename("我的会话".to_string());
assert!(session.user_renamed, "rename() must mark the session as user-renamed");
}
#[test]
fn auto_name_does_not_set_user_renamed_flag() {
let mut session = Session::new(PathBuf::from("/tmp/test"));
session.messages.push(Message::new(Role::User, "first message body"));
session.auto_name_from_messages();
assert_eq!(session.name, "first message body");
assert!(
!session.user_renamed,
"auto_name_from_messages must NOT flag the session as user-renamed; only /rename should"
);
}
#[test]
fn test_hash_path_consistent() {
let path = Path::new("/Users/test/project");
let hash1 = hash_path(path);
let hash2 = hash_path(path);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 16);
}
#[test]
fn test_hash_path_normalized() {
// Same path with different representations should produce the same hash
// Note: on non-Windows, case sensitivity is preserved
// Test trailing slash normalization
let path1 = Path::new("/Users/test/project");
let path2 = Path::new("/Users/test/project/");
assert_eq!(
hash_path(path1),
hash_path(path2),
"Trailing slash should not affect hash"
);
// Test backslash normalization (Windows-style paths)
let path3 = Path::new("C:\\Users\\test\\project");
let path4 = Path::new("C:/Users/test/project");
assert_eq!(
hash_path(path3),
hash_path(path4),
"Backslashes should be normalized to forward slashes"
);
// Test combined: backslash + trailing slash
let path5 = Path::new("C:\\Users\\test\\project\\");
assert_eq!(
hash_path(path4),
hash_path(path5),
"Backslashes and trailing slash should both be normalized"
);
}
#[test]
fn hash_path_matches_legacy_path_hash_on_unix() {
// Regression guard: the pre-normalization implementation just did
// `path.hash(&mut hasher)`. Every session saved before the
// normalization pass lives in a bucket keyed by that hash. If
// `hash_path` stops matching `Path::hash` for a plain-ASCII Unix
// path with no trailing slash / backslashes, every legacy
// `/resume` session becomes invisible. See the "where did my
// /resume history go?" regression.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let p = Path::new("/Users/theo/Documents/workspace/atomcode");
let mut expected = DefaultHasher::new();
p.hash(&mut expected);
let legacy = format!("{:016x}", expected.finish());
assert_eq!(hash_path(p), legacy);
}
}