1use std::fs::{File, OpenOptions};
2use std::io::{BufWriter, Write};
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::models::command::Command;
9use crate::models::session::{SessionFooter, SessionHeader};
10use crate::storage::set_restrictive_permissions;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "lowercase")]
20pub enum NdjsonLine {
21 Header(SessionHeader),
23 Command(Command),
25 Footer(SessionFooter),
27}
28
29pub struct CommandCapture;
44
45impl CommandCapture {
46 pub fn write_header(path: &Path, header: &SessionHeader) -> Result<()> {
56 let line = NdjsonLine::Header(header.clone());
57 let file = File::create(path)?;
58 let mut writer = BufWriter::new(&file);
59 serde_json::to_writer(&mut writer, &line)?;
60 writeln!(writer)?;
61 writer.flush()?;
62 file.sync_all()?;
63
64 set_restrictive_permissions(path)?;
66
67 Ok(())
68 }
69
70 pub fn append_command(path: &Path, command: &Command) -> Result<()> {
79 let line = NdjsonLine::Command(command.clone());
80 let file = OpenOptions::new().create(true).append(true).open(path)?;
81 let mut writer = BufWriter::new(&file);
82 serde_json::to_writer(&mut writer, &line)?;
83 writeln!(writer)?;
84 writer.flush()?;
85 file.sync_all()?;
86 Ok(())
87 }
88
89 pub fn write_footer(path: &Path, footer: &SessionFooter) -> Result<()> {
98 let line = NdjsonLine::Footer(footer.clone());
99 let file = OpenOptions::new().create(true).append(true).open(path)?;
100 let mut writer = BufWriter::new(&file);
101 serde_json::to_writer(&mut writer, &line)?;
102 writeln!(writer)?;
103 writer.flush()?;
104 file.sync_all()?;
105 Ok(())
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::models::session::SessionStatus;
113 use std::collections::HashMap;
114 use std::path::PathBuf;
115 use tempfile::TempDir;
116 use uuid::Uuid;
117
118 fn sample_header() -> SessionHeader {
119 SessionHeader {
120 version: 2,
121 id: Uuid::new_v4(),
122 name: "test-session".to_string(),
123 shell: "bash".to_string(),
124 os: "linux".to_string(),
125 hostname: "testhost".to_string(),
126 env: HashMap::new(),
127 tags: vec!["test".to_string()],
128 recovered: None,
129 started_at: 1700000000.123,
130 }
131 }
132
133 fn sample_command(index: u32) -> Command {
134 Command {
135 index,
136 command: format!("echo command-{index}"),
137 cwd: PathBuf::from("/home/user"),
138 started_at: 1700000000.123 + (f64::from(index) * 1.0),
139 ended_at: Some(1700000000.223 + (f64::from(index) * 1.0)),
140 exit_code: Some(0),
141 duration_ms: Some(100),
142 }
143 }
144
145 fn sample_footer(count: u32) -> SessionFooter {
146 SessionFooter {
147 ended_at: 1700000010.456,
148 command_count: count,
149 status: SessionStatus::Completed,
150 }
151 }
152
153 #[test]
154 fn test_write_header() {
155 let tmp = TempDir::new().unwrap();
156 let path = tmp.path().join("session.ndjson");
157 let header = sample_header();
158
159 CommandCapture::write_header(&path, &header).unwrap();
160
161 let content = std::fs::read_to_string(&path).unwrap();
162 let lines: Vec<&str> = content.lines().collect();
163 assert_eq!(lines.len(), 1);
164
165 let parsed: NdjsonLine = serde_json::from_str(lines[0]).unwrap();
166 match parsed {
167 NdjsonLine::Header(h) => {
168 assert_eq!(h.name, "test-session");
169 assert_eq!(h.version, 2);
170 assert_eq!(h.shell, "bash");
171 }
172 _ => panic!("Expected Header line"),
173 }
174 }
175
176 #[test]
177 fn test_append_command() {
178 let tmp = TempDir::new().unwrap();
179 let path = tmp.path().join("session.ndjson");
180 let header = sample_header();
181
182 CommandCapture::write_header(&path, &header).unwrap();
183 CommandCapture::append_command(&path, &sample_command(0)).unwrap();
184
185 let content = std::fs::read_to_string(&path).unwrap();
186 let lines: Vec<&str> = content.lines().collect();
187 assert_eq!(lines.len(), 2);
188
189 let parsed: NdjsonLine = serde_json::from_str(lines[1]).unwrap();
190 match parsed {
191 NdjsonLine::Command(c) => {
192 assert_eq!(c.index, 0);
193 assert_eq!(c.command, "echo command-0");
194 assert_eq!(c.exit_code, Some(0));
195 }
196 _ => panic!("Expected Command line"),
197 }
198 }
199
200 #[test]
201 fn test_write_footer() {
202 let tmp = TempDir::new().unwrap();
203 let path = tmp.path().join("session.ndjson");
204 let header = sample_header();
205
206 CommandCapture::write_header(&path, &header).unwrap();
207 CommandCapture::append_command(&path, &sample_command(0)).unwrap();
208 CommandCapture::write_footer(&path, &sample_footer(1)).unwrap();
209
210 let content = std::fs::read_to_string(&path).unwrap();
211 let lines: Vec<&str> = content.lines().collect();
212 assert_eq!(lines.len(), 3);
213
214 let parsed: NdjsonLine = serde_json::from_str(lines[2]).unwrap();
215 match parsed {
216 NdjsonLine::Footer(f) => {
217 assert_eq!(f.command_count, 1);
218 assert_eq!(f.status, SessionStatus::Completed);
219 }
220 _ => panic!("Expected Footer line"),
221 }
222 }
223
224 #[test]
225 fn test_multiple_commands_preserve_order() {
226 let tmp = TempDir::new().unwrap();
227 let path = tmp.path().join("session.ndjson");
228 let header = sample_header();
229
230 CommandCapture::write_header(&path, &header).unwrap();
231 for i in 0..5 {
232 CommandCapture::append_command(&path, &sample_command(i)).unwrap();
233 }
234 CommandCapture::write_footer(&path, &sample_footer(5)).unwrap();
235
236 let content = std::fs::read_to_string(&path).unwrap();
237 let lines: Vec<&str> = content.lines().collect();
238 assert_eq!(lines.len(), 7); let first: NdjsonLine = serde_json::from_str(lines[0]).unwrap();
242 assert!(matches!(first, NdjsonLine::Header(_)));
243
244 for i in 0..5 {
245 let line: NdjsonLine = serde_json::from_str(lines[i + 1]).unwrap();
246 match line {
247 NdjsonLine::Command(c) => assert_eq!(c.index, i as u32),
248 _ => panic!("Expected Command at line {}", i + 1),
249 }
250 }
251
252 let last: NdjsonLine = serde_json::from_str(lines[6]).unwrap();
253 assert!(matches!(last, NdjsonLine::Footer(_)));
254 }
255
256 #[test]
257 fn test_ndjson_line_serialization_roundtrip() {
258 let header_line = NdjsonLine::Header(sample_header());
259 let json = serde_json::to_string(&header_line).unwrap();
260 assert!(json.contains("\"type\":\"header\""));
261
262 let parsed: NdjsonLine = serde_json::from_str(&json).unwrap();
263 assert!(matches!(parsed, NdjsonLine::Header(_)));
264
265 let cmd_line = NdjsonLine::Command(sample_command(0));
266 let json = serde_json::to_string(&cmd_line).unwrap();
267 assert!(json.contains("\"type\":\"command\""));
268
269 let footer_line = NdjsonLine::Footer(sample_footer(1));
270 let json = serde_json::to_string(&footer_line).unwrap();
271 assert!(json.contains("\"type\":\"footer\""));
272 }
273
274 #[test]
275 fn test_write_header_creates_file() {
276 let tmp = TempDir::new().unwrap();
277 let path = tmp.path().join("new-session.ndjson");
278
279 assert!(!path.exists());
280 CommandCapture::write_header(&path, &sample_header()).unwrap();
281 assert!(path.exists());
282 }
283
284 #[test]
285 fn test_each_line_is_valid_json() {
286 let tmp = TempDir::new().unwrap();
287 let path = tmp.path().join("session.ndjson");
288
289 CommandCapture::write_header(&path, &sample_header()).unwrap();
290 CommandCapture::append_command(&path, &sample_command(0)).unwrap();
291 CommandCapture::write_footer(&path, &sample_footer(1)).unwrap();
292
293 let content = std::fs::read_to_string(&path).unwrap();
294 for (i, line) in content.lines().enumerate() {
295 let parsed: serde_json::Value = serde_json::from_str(line)
296 .unwrap_or_else(|e| panic!("Line {i} is not valid JSON: {e}"));
297 assert!(
298 parsed.get("type").is_some(),
299 "Line {i} missing 'type' field"
300 );
301 }
302 }
303
304 #[test]
305 #[cfg(unix)]
306 fn test_write_header_sets_restrictive_permissions() {
307 use std::os::unix::fs::PermissionsExt;
308
309 let tmp = TempDir::new().unwrap();
310 let path = tmp.path().join("session.ndjson");
311
312 CommandCapture::write_header(&path, &sample_header()).unwrap();
313
314 let metadata = std::fs::metadata(&path).unwrap();
316 let mode = metadata.permissions().mode();
317
318 let permission_bits = mode & 0o777;
320 assert_eq!(
321 permission_bits, 0o600,
322 "Session file should have 0o600 permissions, got 0o{permission_bits:o}"
323 );
324 }
325}