1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use anyhow::{bail, Result};
6use fs2::FileExt;
7use regex::Regex;
8
9static SESSION_ID_RE: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
11
12pub struct SessionContext {
14 pub session_id: String,
15 pub session_dir: PathBuf,
16 pub is_new_session: bool,
17 _lock: Option<File>,
18}
19
20impl SessionContext {
21 #[allow(dead_code)]
22 pub fn session_id_path(&self) -> PathBuf {
23 self.session_dir.join("session_id")
24 }
25
26 #[allow(dead_code)]
27 pub fn metadata_path(&self) -> PathBuf {
28 self.session_dir.join("session_meta.json")
29 }
30
31 pub fn log_path(&self) -> PathBuf {
32 self.session_dir.join("session.log")
33 }
34
35 pub fn turn_path(&self, turn: usize) -> PathBuf {
36 self.session_dir.join(format!("turn_{:03}.jsonl", turn))
37 }
38}
39
40pub fn validate_session_id(session_id: &str) -> Result<()> {
42 if session_id.is_empty() || session_id.len() > 128 {
43 bail!("Session ID must be 1-128 characters, got {}", session_id.len());
44 }
45
46 if !SESSION_ID_RE.is_match(session_id) {
47 bail!(
48 "Invalid session_id format '{}'. Only alphanumeric, hyphens, and underscores allowed.",
49 session_id
50 );
51 }
52
53 Ok(())
54}
55
56pub fn resolve_session_id(cli_session_id: Option<&str>) -> Result<String> {
58 if let Some(id) = cli_session_id {
59 validate_session_id(id)?;
60 return Ok(id.to_string());
61 }
62
63 if let Ok(id) = std::env::var("PHI_SESSION_ID") {
64 if !id.is_empty() {
65 validate_session_id(&id)?;
66 return Ok(id);
67 }
68 }
69
70 Ok(generate_session_id())
71}
72
73pub fn generate_session_id() -> String {
75 let now = chrono::Local::now();
76 let uuid = uuid::Uuid::new_v4().to_string();
77 let uuid_short = &uuid[..8.min(uuid.len())];
78 format!("{}_{}", now.format("%Y%m%d"), uuid_short)
79}
80
81pub fn get_or_create_session_dir(session_id: &str, base_dir: &Path) -> Result<(PathBuf, bool)> {
83 let session_dir = base_dir.join("sessions").join(session_id);
84 let is_new = !session_dir.exists();
85
86 if is_new {
87 std::fs::create_dir_all(&session_dir)?;
88 tracing::info!(session_id = %session_id, path = %session_dir.display(), "created new session directory");
89 } else {
90 tracing::info!(session_id = %session_id, path = %session_dir.display(), "reusing existing session directory");
91 }
92
93 std::fs::write(session_dir.join("session_id"), session_id)?;
95
96 update_session_meta(&session_dir, session_id)?;
98
99 Ok((session_dir, is_new))
100}
101
102pub fn acquire_session_lock(session_dir: &Path) -> Result<File> {
104 let lock_path = session_dir.join("session.lock");
105 let file = File::create(&lock_path)?;
106
107 file.try_lock_exclusive().map_err(|_| {
108 anyhow::anyhow!(
109 "Session '{}' is currently in use by another process",
110 session_dir
111 .file_name()
112 .map(|n| n.to_string_lossy().to_string())
113 .unwrap_or_default()
114 )
115 })?;
116
117 Ok(file)
118}
119
120fn update_session_meta(session_dir: &Path, session_id: &str) -> Result<()> {
122 let meta_path = session_dir.join("session_meta.json");
123
124 let mut meta = if meta_path.exists() {
125 let content = std::fs::read_to_string(&meta_path)?;
126 serde_json::from_str::<serde_json::Value>(&content)?
127 } else {
128 serde_json::json!({
129 "session_id": session_id,
130 "created_at": chrono::Utc::now().to_rfc3339(),
131 })
132 };
133
134 meta["last_active_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
135
136 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
137 Ok(())
138}
139
140pub fn cleanup_expired_sessions(base_dir: &Path, max_age_days: i64) -> Result<u32> {
142 let sessions_dir = base_dir.join("sessions");
143 if !sessions_dir.exists() {
144 return Ok(0);
145 }
146
147 let now = chrono::Utc::now();
148 let mut cleaned = 0;
149
150 for entry in std::fs::read_dir(&sessions_dir)? {
151 let entry = entry?;
152 let path = entry.path();
153
154 if !path.is_dir() {
155 continue;
156 }
157
158 let lock_path = path.join("session.lock");
159 if lock_path.exists() {
160 if let Ok(file) = File::open(&lock_path) {
161 if file.try_lock_shared().is_err() {
162 continue; }
164 }
165 }
166
167 let meta_path = path.join("session_meta.json");
168 if !meta_path.exists() {
169 std::fs::remove_dir_all(&path)?;
170 cleaned += 1;
171 continue;
172 }
173
174 let content = std::fs::read_to_string(&meta_path)?;
175 let meta: serde_json::Value = serde_json::from_str(&content)?;
176
177 if let Some(last_active) = meta["last_active_at"].as_str() {
178 if let Ok(last_active) = chrono::DateTime::parse_from_rfc3339(last_active) {
179 let age = now - last_active.with_timezone(&chrono::Utc);
180 if age.num_days() > max_age_days {
181 tracing::info!(path = %path.display(), age_days = age.num_days(), "removing expired session");
182 std::fs::remove_dir_all(&path)?;
183 cleaned += 1;
184 }
185 }
186 }
187 }
188
189 if cleaned > 0 {
190 tracing::info!(count = cleaned, "cleaned up expired sessions");
191 }
192
193 Ok(cleaned)
194}
195
196pub fn resolve_session(cli_session_id: Option<&str>, base_dir: &Path) -> Result<SessionContext> {
198 let session_id = resolve_session_id(cli_session_id)?;
199 let (session_dir, is_new) = get_or_create_session_dir(&session_id, base_dir)?;
200 let lock = acquire_session_lock(&session_dir)?;
201
202 Ok(SessionContext {
203 session_id,
204 session_dir,
205 is_new_session: is_new,
206 _lock: Some(lock),
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::TempDir;
214
215 #[test]
216 fn test_validate_session_id_valid() {
217 assert!(validate_session_id("my-session-123").is_ok());
218 assert!(validate_session_id("test_456").is_ok());
219 assert!(validate_session_id("a").is_ok());
220 }
221
222 #[test]
223 fn test_validate_session_id_invalid() {
224 assert!(validate_session_id("").is_err());
225 assert!(validate_session_id("my session").is_err());
226 assert!(validate_session_id("../etc").is_err());
227 assert!(validate_session_id("path/traversal").is_err());
228 }
229
230 #[test]
231 fn test_generate_session_id() {
232 let id = generate_session_id();
233 assert!(id.contains('_'));
234 let parts: Vec<&str> = id.split('_').collect();
235 assert_eq!(parts.len(), 2);
236 assert_eq!(parts[0].len(), 8);
237 assert_eq!(parts[1].len(), 8);
238 }
239
240 #[test]
241 fn test_session_context_methods() {
242 let tmp = TempDir::new().unwrap();
243 let ctx = resolve_session(Some("test-ctx"), tmp.path()).unwrap();
244
245 assert_eq!(ctx.session_id, "test-ctx");
246 assert!(ctx.session_id_path().exists());
247 assert!(ctx.metadata_path().exists());
248 assert_eq!(ctx.log_path(), ctx.session_dir.join("session.log"));
249 assert_eq!(ctx.turn_path(1), ctx.session_dir.join("turn_001.jsonl"));
250 }
251
252 #[test]
253 fn test_cleanup_expired_sessions() {
254 let tmp = TempDir::new().unwrap();
255 let (dir, _) = get_or_create_session_dir("old-session", tmp.path()).unwrap();
256
257 let meta_path = dir.join("session_meta.json");
258 let mut meta: serde_json::Value =
259 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
260 let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
261 meta["last_active_at"] = serde_json::json!(old);
262 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
263
264 get_or_create_session_dir("new-session", tmp.path()).unwrap();
265 let cleaned = cleanup_expired_sessions(tmp.path(), 7).unwrap();
266 assert_eq!(cleaned, 1);
267 assert!(!dir.exists());
268 }
269}