Skip to main content

rec/models/
command.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5/// A single command captured during a recording session.
6///
7/// Each command records the command text, working directory, timing information,
8/// and exit code. This matches the NDJSON schema from CONTEXT.md.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Command {
11    /// Command sequence number within the session (0-indexed)
12    pub index: u32,
13
14    /// The actual command text that was executed
15    pub command: String,
16
17    /// Working directory when the command was executed
18    pub cwd: PathBuf,
19
20    /// Unix timestamp with milliseconds when command started
21    pub started_at: f64,
22
23    /// Unix timestamp with milliseconds when command ended (None if still running)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub ended_at: Option<f64>,
26
27    /// Exit code of the command (None if still running)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub exit_code: Option<i32>,
30
31    /// Duration in milliseconds (calculated from timestamps)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub duration_ms: Option<u64>,
34}
35
36impl Command {
37    /// Create a new command with the current timestamp.
38    ///
39    /// The command starts in a "running" state with `ended_at`, `exit_code`,
40    /// and `duration_ms` all set to `None`.
41    ///
42    /// # Panics
43    ///
44    /// Panics if the system clock is before the Unix epoch.
45    #[must_use]
46    pub fn new(index: u32, command: String, cwd: PathBuf) -> Self {
47        let started_at = SystemTime::now()
48            .duration_since(UNIX_EPOCH)
49            .expect("Time went backwards")
50            .as_secs_f64();
51
52        Self {
53            index,
54            command,
55            cwd,
56            started_at,
57            ended_at: None,
58            exit_code: None,
59            duration_ms: None,
60        }
61    }
62
63    /// Mark the command as complete with the given exit code.
64    ///
65    /// Sets `ended_at` to the current timestamp, records the `exit_code`,
66    /// and calculates `duration_ms`.
67    ///
68    /// # Panics
69    ///
70    /// Panics if the system clock is before the Unix epoch.
71    pub fn complete(&mut self, exit_code: i32) {
72        let ended_at = SystemTime::now()
73            .duration_since(UNIX_EPOCH)
74            .expect("Time went backwards")
75            .as_secs_f64();
76
77        self.ended_at = Some(ended_at);
78        self.exit_code = Some(exit_code);
79        self.duration_ms = Some(((ended_at - self.started_at) * 1000.0) as u64);
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::thread::sleep;
87    use std::time::Duration;
88
89    #[test]
90    fn test_command_new() {
91        let cmd = Command::new(0, "echo hello".to_string(), PathBuf::from("/home/user"));
92
93        assert_eq!(cmd.index, 0);
94        assert_eq!(cmd.command, "echo hello");
95        assert_eq!(cmd.cwd, PathBuf::from("/home/user"));
96        assert!(cmd.started_at > 0.0);
97        assert!(cmd.ended_at.is_none());
98        assert!(cmd.exit_code.is_none());
99        assert!(cmd.duration_ms.is_none());
100    }
101
102    #[test]
103    fn test_command_complete() {
104        let mut cmd = Command::new(0, "echo hello".to_string(), PathBuf::from("/home/user"));
105
106        // Small delay to ensure measurable duration
107        sleep(Duration::from_millis(10));
108
109        cmd.complete(0);
110
111        assert!(cmd.ended_at.is_some());
112        assert_eq!(cmd.exit_code, Some(0));
113        assert!(cmd.duration_ms.is_some());
114        assert!(cmd.duration_ms.unwrap() >= 10);
115    }
116
117    #[test]
118    fn test_command_serialization() {
119        let mut cmd = Command::new(0, "echo hello".to_string(), PathBuf::from("/home/user"));
120        cmd.complete(0);
121
122        let json = serde_json::to_string(&cmd).expect("Failed to serialize");
123        assert!(json.contains("\"command\":\"echo hello\""));
124        assert!(json.contains("\"exit_code\":0"));
125
126        let deserialized: Command = serde_json::from_str(&json).expect("Failed to deserialize");
127        assert_eq!(deserialized.command, cmd.command);
128        assert_eq!(deserialized.exit_code, cmd.exit_code);
129    }
130}