1use chrono::Local;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::env;
5use std::fs;
6use std::time::{SystemTime, UNIX_EPOCH};
7use uuid::Uuid;
8
9use super::Command;
10use crate::error::RecError;
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SessionStatus {
16 #[default]
18 Recording,
19 Completed,
21 Interrupted,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SessionHeader {
31 pub version: u8,
33
34 pub id: Uuid,
36
37 pub name: String,
39
40 pub shell: String,
42
43 pub os: String,
45
46 pub hostname: String,
48
49 pub env: HashMap<String, String>,
51
52 #[serde(default)]
54 pub tags: Vec<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub recovered: Option<bool>,
60
61 pub started_at: f64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SessionFooter {
70 pub ended_at: f64,
72
73 pub command_count: u32,
75
76 pub status: SessionStatus,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Session {
86 pub header: SessionHeader,
88
89 pub commands: Vec<Command>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub footer: Option<SessionFooter>,
95}
96
97#[must_use]
102pub fn generate_session_name() -> String {
103 Local::now().format("session-%Y-%m-%d-%H%M%S").to_string()
104}
105
106pub fn validate_session_name(name: &str) -> crate::error::Result<()> {
116 if name.is_empty() {
117 return Err(RecError::InvalidSessionName(
118 "name cannot be empty".to_string(),
119 ));
120 }
121
122 if name
123 .chars()
124 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
125 {
126 Ok(())
127 } else {
128 Err(RecError::InvalidSessionName(name.to_string()))
129 }
130}
131
132impl Session {
133 #[must_use]
145 pub fn new(name: &str) -> Self {
146 let started_at = SystemTime::now()
147 .duration_since(UNIX_EPOCH)
148 .expect("Time went backwards")
149 .as_secs_f64();
150
151 let shell = detect_shell();
152 let os = detect_os();
153 let hostname = detect_hostname();
154 let env_vars = capture_env_vars();
155
156 Self {
157 header: SessionHeader {
158 version: 2,
159 id: Uuid::new_v4(),
160 name: name.to_string(),
161 shell,
162 os,
163 hostname,
164 env: env_vars,
165 tags: Vec::new(),
166 recovered: None,
167 started_at,
168 },
169 commands: Vec::new(),
170 footer: None,
171 }
172 }
173
174 pub fn add_command(&mut self, cmd: Command) {
176 self.commands.push(cmd);
177 }
178
179 pub fn complete(&mut self, status: SessionStatus) {
187 let ended_at = SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .expect("Time went backwards")
190 .as_secs_f64();
191
192 self.footer = Some(SessionFooter {
193 ended_at,
194 command_count: self.commands.len() as u32,
195 status,
196 });
197 }
198
199 #[must_use]
201 pub fn id(&self) -> Uuid {
202 self.header.id
203 }
204
205 #[must_use]
207 pub fn name(&self) -> &str {
208 &self.header.name
209 }
210
211 #[must_use]
213 pub fn is_recording(&self) -> bool {
214 self.footer.is_none()
215 }
216}
217
218fn detect_shell() -> String {
220 let shell_path = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
221
222 shell_path.rsplit('/').next().unwrap_or("sh").to_string()
225}
226
227fn detect_os() -> String {
229 if let Ok(content) = fs::read_to_string("/etc/os-release") {
231 for line in content.lines() {
232 if line.starts_with("PRETTY_NAME=") {
233 let value = line.trim_start_matches("PRETTY_NAME=").trim_matches('"');
234 return value.to_string();
235 }
236 }
237 }
238
239 let os_type = env::consts::OS;
241 let arch = env::consts::ARCH;
242 format!("{os_type} {arch}")
243}
244
245fn detect_hostname() -> String {
247 if let Ok(hostname) = fs::read_to_string("/etc/hostname") {
249 let hostname = hostname.trim();
250 if !hostname.is_empty() {
251 return hostname.to_string();
252 }
253 }
254
255 if let Ok(hostname) = env::var("HOSTNAME") {
257 return hostname;
258 }
259
260 "unknown".to_string()
262}
263
264fn capture_env_vars() -> HashMap<String, String> {
266 let mut env_vars = HashMap::new();
267 let keys = ["PATH", "SHELL", "HOME", "USER", "PWD"];
268
269 for key in keys {
270 if let Ok(value) = env::var(key) {
271 env_vars.insert(key.to_string(), value);
272 }
273 }
274
275 env_vars
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_session_new() {
284 let session = Session::new("test-session");
285
286 assert_eq!(session.header.version, 2);
287 assert_eq!(session.header.name, "test-session");
288 assert!(session.header.started_at > 0.0);
289 assert!(session.commands.is_empty());
290 assert!(session.footer.is_none());
291 assert!(session.is_recording());
292 }
293
294 #[test]
295 fn test_session_add_command() {
296 let mut session = Session::new("test-session");
297 let cmd = Command::new(
298 0,
299 "echo hello".to_string(),
300 std::path::PathBuf::from("/home"),
301 );
302
303 session.add_command(cmd);
304
305 assert_eq!(session.commands.len(), 1);
306 assert_eq!(session.commands[0].command, "echo hello");
307 }
308
309 #[test]
310 fn test_session_complete() {
311 let mut session = Session::new("test-session");
312 session.add_command(Command::new(
313 0,
314 "echo hello".to_string(),
315 std::path::PathBuf::from("/home"),
316 ));
317
318 session.complete(SessionStatus::Completed);
319
320 assert!(session.footer.is_some());
321 let footer = session.footer.as_ref().unwrap();
322 assert_eq!(footer.command_count, 1);
323 assert_eq!(footer.status, SessionStatus::Completed);
324 assert!(!session.is_recording());
325 }
326
327 #[test]
328 fn test_session_serialization() {
329 let mut session = Session::new("test-session");
330 session.complete(SessionStatus::Completed);
331
332 let json = serde_json::to_string(&session).expect("Failed to serialize");
333 assert!(json.contains("\"name\":\"test-session\""));
334 assert!(json.contains("\"version\":2"));
335
336 let deserialized: Session = serde_json::from_str(&json).expect("Failed to deserialize");
337 assert_eq!(deserialized.header.name, session.header.name);
338 }
339
340 #[test]
341 fn test_session_status_serialization() {
342 let status = SessionStatus::Completed;
343 let json = serde_json::to_string(&status).expect("Failed to serialize");
344 assert_eq!(json, "\"completed\"");
345
346 let deserialized: SessionStatus =
347 serde_json::from_str(&json).expect("Failed to deserialize");
348 assert_eq!(deserialized, SessionStatus::Completed);
349 }
350
351 #[test]
352 fn test_generate_session_name() {
353 let name = generate_session_name();
354 assert!(name.starts_with("session-"));
355 assert_eq!(name.len(), 25);
357 assert!(validate_session_name(&name).is_ok());
359 }
360
361 #[test]
362 fn test_validate_session_name_valid() {
363 assert!(validate_session_name("my-session").is_ok());
364 assert!(validate_session_name("test_123").is_ok());
365 assert!(validate_session_name("MySession").is_ok());
366 assert!(validate_session_name("session-2026-01-26-143052").is_ok());
367 assert!(validate_session_name("a").is_ok());
368 }
369
370 #[test]
371 fn test_validate_session_name_invalid() {
372 assert!(validate_session_name("my session").is_err()); assert!(validate_session_name("test@123").is_err()); assert!(validate_session_name("").is_err()); assert!(validate_session_name("hello/world").is_err()); assert!(validate_session_name("name.ext").is_err()); }
378
379 #[test]
380 fn test_recovered_none_omitted_from_json() {
381 let session = Session::new("test-serde-skip");
382 assert!(session.header.recovered.is_none());
383
384 let json = serde_json::to_string(&session.header).expect("Failed to serialize");
385 assert!(
386 !json.contains("\"recovered\""),
387 "JSON should not contain '\"recovered\"' key when None: {json}"
388 );
389 }
390
391 #[test]
392 fn test_recovered_some_true_serializes() {
393 let mut session = Session::new("test-recovered-true");
394 session.header.recovered = Some(true);
395
396 let json = serde_json::to_string(&session.header).expect("Failed to serialize");
397 assert!(
398 json.contains("\"recovered\":true"),
399 "JSON should contain 'recovered':true: {json}"
400 );
401 }
402
403 #[test]
404 fn test_recovered_backward_compat_deserialization() {
405 let json = r#"{
407 "version": 2,
408 "id": "00000000-0000-0000-0000-000000000001",
409 "name": "old-session",
410 "shell": "bash",
411 "os": "linux",
412 "hostname": "test",
413 "env": {},
414 "tags": [],
415 "started_at": 1700000000.0
416 }"#;
417
418 let header: SessionHeader =
419 serde_json::from_str(json).expect("Should deserialize without recovered field");
420 assert_eq!(header.recovered, None);
421 assert_eq!(header.name, "old-session");
422 }
423}