Skip to main content

rec/models/
session.rs

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/// Status of a recording session.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SessionStatus {
16    /// Session is currently being recorded
17    #[default]
18    Recording,
19    /// Session completed normally
20    Completed,
21    /// Session was interrupted (e.g., SIGINT, crash)
22    Interrupted,
23}
24
25/// Header information for a recording session.
26///
27/// Contains metadata about the session that is written at the start
28/// of recording. Matches the NDJSON schema from CONTEXT.md.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SessionHeader {
31    /// Schema version (always 2)
32    pub version: u8,
33
34    /// Unique session identifier
35    pub id: Uuid,
36
37    /// Human-readable session name
38    pub name: String,
39
40    /// Shell type and version (e.g., "bash 5.1.16")
41    pub shell: String,
42
43    /// Operating system info (e.g., "Linux 6.14.0")
44    pub os: String,
45
46    /// Machine hostname
47    pub hostname: String,
48
49    /// Selected environment variables (PATH, SHELL, HOME, USER, PWD)
50    pub env: HashMap<String, String>,
51
52    /// User-defined tags for organization
53    #[serde(default)]
54    pub tags: Vec<String>,
55
56    /// Whether this session was recovered from a stale recording lock.
57    /// Only present on sessions recovered via stale lock cleanup.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub recovered: Option<bool>,
60
61    /// Unix timestamp with milliseconds when session started
62    pub started_at: f64,
63}
64
65/// Footer information for a recording session.
66///
67/// Contains summary information written when the session ends.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SessionFooter {
70    /// Unix timestamp with milliseconds when session ended
71    pub ended_at: f64,
72
73    /// Total number of commands recorded
74    pub command_count: u32,
75
76    /// Final status of the session
77    pub status: SessionStatus,
78}
79
80/// A complete recording session.
81///
82/// Contains the header (metadata), list of commands, and optional footer
83/// (None while still recording).
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Session {
86    /// Session metadata
87    pub header: SessionHeader,
88
89    /// Commands recorded in this session
90    pub commands: Vec<Command>,
91
92    /// Footer information (None while recording)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub footer: Option<SessionFooter>,
95}
96
97/// Generate a timestamp-based session name.
98///
99/// Format: session-YYYY-MM-DD-HHMMSS
100/// Example: session-2026-01-26-143052
101#[must_use]
102pub fn generate_session_name() -> String {
103    Local::now().format("session-%Y-%m-%d-%H%M%S").to_string()
104}
105
106/// Validate a session name.
107///
108/// Valid names contain only alphanumeric characters, dashes, and underscores.
109/// No spaces or special characters allowed (filesystem-safe).
110///
111/// # Errors
112///
113/// Returns `RecError::InvalidSessionName` if the name is empty or contains
114/// characters other than alphanumeric, dash, or underscore.
115pub 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    /// Create a new recording session with auto-detected metadata.
134    ///
135    /// Automatically detects:
136    /// - Shell type and version from SHELL env var
137    /// - OS from `/etc/os-release` or uname
138    /// - Hostname from `/etc/hostname` or environment
139    /// - Environment variables: PATH, SHELL, HOME, USER, PWD
140    ///
141    /// # Panics
142    ///
143    /// Panics if the system clock is before the Unix epoch.
144    #[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    /// Add a command to the session.
175    pub fn add_command(&mut self, cmd: Command) {
176        self.commands.push(cmd);
177    }
178
179    /// Mark the session as complete with the given status.
180    ///
181    /// Creates the footer with the current timestamp and command count.
182    ///
183    /// # Panics
184    ///
185    /// Panics if the system clock is before the Unix epoch.
186    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    /// Get the session ID.
200    #[must_use]
201    pub fn id(&self) -> Uuid {
202        self.header.id
203    }
204
205    /// Get the session name.
206    #[must_use]
207    pub fn name(&self) -> &str {
208        &self.header.name
209    }
210
211    /// Check if the session is still recording.
212    #[must_use]
213    pub fn is_recording(&self) -> bool {
214        self.footer.is_none()
215    }
216}
217
218/// Detect shell type and version from environment.
219fn detect_shell() -> String {
220    let shell_path = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
221
222    // Try to get version by running shell --version
223    // For simplicity, just return shell name without version for now
224    shell_path.rsplit('/').next().unwrap_or("sh").to_string()
225}
226
227/// Detect operating system information.
228fn detect_os() -> String {
229    // Try to read from /etc/os-release first (Linux)
230    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    // Fallback to basic info from uname
240    let os_type = env::consts::OS;
241    let arch = env::consts::ARCH;
242    format!("{os_type} {arch}")
243}
244
245/// Detect machine hostname.
246fn detect_hostname() -> String {
247    // Try /etc/hostname first
248    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    // Try HOSTNAME env var
256    if let Ok(hostname) = env::var("HOSTNAME") {
257        return hostname;
258    }
259
260    // Final fallback
261    "unknown".to_string()
262}
263
264/// Capture selected environment variables.
265fn 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        // Format: session-YYYY-MM-DD-HHMMSS (25 chars)
356        assert_eq!(name.len(), 25);
357        // Should be a valid session name
358        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()); // space
373        assert!(validate_session_name("test@123").is_err()); // special char
374        assert!(validate_session_name("").is_err()); // empty
375        assert!(validate_session_name("hello/world").is_err()); // slash
376        assert!(validate_session_name("name.ext").is_err()); // dot
377    }
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        // JSON without 'recovered' field — simulates old session files
406        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}