1use crate::cli::Output;
8use crate::error::{RecError, Result};
9use crate::models::Session;
10use crate::storage::{SessionStore, set_restrictive_permissions};
11use serde::{Deserialize, Serialize};
12use std::io::Write;
13use std::path::PathBuf;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct EditableSession {
22 pub name: String,
24 pub tags: Vec<String>,
26 pub shell: String,
28 pub os: String,
30 pub hostname: String,
32 pub commands: Vec<EditableCommand>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct EditableCommand {
41 pub command: String,
43 pub cwd: String,
45 pub exit_code: Option<i32>,
47 pub duration_ms: Option<u64>,
49}
50
51pub fn session_to_toml(session: &Session) -> Result<String> {
59 let editable = EditableSession {
60 name: session.header.name.clone(),
61 tags: session.header.tags.clone(),
62 shell: session.header.shell.clone(),
63 os: session.header.os.clone(),
64 hostname: session.header.hostname.clone(),
65 commands: session
66 .commands
67 .iter()
68 .map(|cmd| EditableCommand {
69 command: cmd.command.clone(),
70 cwd: cmd.cwd.to_string_lossy().to_string(),
71 exit_code: cmd.exit_code,
72 duration_ms: cmd.duration_ms,
73 })
74 .collect(),
75 };
76
77 let toml_str =
78 toml::to_string_pretty(&editable).map_err(|e| RecError::Config(e.to_string()))?;
79
80 Ok(format!(
81 "# Edit this file to modify the session.\n\
82 # Fields: name, tags, shell, os, hostname, and commands are editable.\n\
83 # Session ID, timestamps, and version are preserved automatically.\n\
84 # Save and close to apply changes. Quit without saving to cancel.\n\
85 \n\
86 {toml_str}"
87 ))
88}
89
90pub fn toml_to_session(toml_str: &str, original: &Session) -> Result<Session> {
105 let editable: EditableSession =
106 toml::from_str(toml_str).map_err(|e| RecError::Config(e.to_string()))?;
107
108 let mut merged = original.clone();
109 merged.header.name = editable.name;
110 merged.header.tags = editable.tags;
111 merged.header.shell = editable.shell;
112 merged.header.os = editable.os;
113 merged.header.hostname = editable.hostname;
114
115 merged.commands = editable
117 .commands
118 .iter()
119 .enumerate()
120 .map(|(i, ec)| {
121 let orig = original.commands.get(i);
122 crate::models::Command {
123 index: i as u32,
124 command: ec.command.clone(),
125 cwd: PathBuf::from(&ec.cwd),
126 started_at: orig.map_or(0.0, |o| o.started_at),
127 ended_at: orig.and_then(|o| o.ended_at),
128 exit_code: ec.exit_code,
129 duration_ms: ec.duration_ms,
130 }
131 })
132 .collect();
133
134 if let Some(ref mut footer) = merged.footer {
136 footer.command_count = merged.commands.len() as u32;
137 }
138
139 Ok(merged)
140}
141
142pub fn launch_editor(content: &str) -> Result<Option<String>> {
153 let temp_dir = std::env::temp_dir();
155 let temp_path = temp_dir.join(format!("rec-edit-{}.toml", std::process::id()));
156
157 {
159 let mut file = std::fs::File::create(&temp_path)?;
160 file.write_all(content.as_bytes())?;
161 file.flush()?;
162 }
163
164 set_restrictive_permissions(&temp_path)?;
166
167 let editor = std::env::var("VISUAL")
169 .or_else(|_| std::env::var("EDITOR"))
170 .unwrap_or_else(|_| "vi".to_string());
171
172 let status = std::process::Command::new(&editor)
174 .arg(&temp_path)
175 .status()
176 .map_err(|e| {
177 let _ = std::fs::remove_file(&temp_path);
179 RecError::Io(e)
180 })?;
181
182 if !status.success() {
183 let _ = std::fs::remove_file(&temp_path);
184 return Ok(None);
185 }
186
187 let new_content = std::fs::read_to_string(&temp_path)?;
189
190 let _ = std::fs::remove_file(&temp_path);
192
193 if new_content.trim().is_empty() || new_content == content {
195 return Ok(None);
196 }
197
198 Ok(Some(new_content))
199}
200
201pub fn edit_session(store: &SessionStore, session: &Session, output: &Output) -> Result<()> {
214 let session_id = session.id().to_string();
216 let source = store.session_file_path(&session_id);
217 let backup = source.with_extension("ndjson.bak");
218 std::fs::copy(&source, &backup)?;
219
220 let mut toml_content = session_to_toml(session)?;
222
223 loop {
225 let edited = launch_editor(&toml_content)?;
226
227 match edited {
228 None => {
229 let _ = std::fs::remove_file(&backup);
231 output.info("Edit cancelled");
232 return Ok(());
233 }
234 Some(new_content) => {
235 match toml_to_session(&new_content, session) {
237 Ok(updated) => {
238 store.save(&updated)?;
240 let _ = std::fs::remove_file(&backup);
242 output.success(&format!("Session '{}' updated", updated.name()));
243 return Ok(());
244 }
245 Err(e) => {
246 output.error(
247 "TOML parse error",
248 &e.to_string(),
249 None,
250 Some("Fix the syntax error and save again"),
251 );
252
253 let re_edit = dialoguer::Confirm::new()
255 .with_prompt("Re-edit?")
256 .default(true)
257 .interact()
258 .unwrap_or(false);
259
260 if !re_edit {
261 let _ = std::fs::remove_file(&backup);
263 output.info("Edit cancelled, original preserved");
264 return Ok(());
265 }
266 toml_content = new_content;
268 }
269 }
270 }
271 }
272 }
273}
274
275#[cfg(test)]
276#[allow(clippy::float_cmp)]
277mod tests {
278 use super::*;
279 use crate::models::{Command, Session, SessionStatus};
280 use std::path::PathBuf;
281
282 fn create_test_session() -> Session {
283 let mut session = Session::new("test-edit");
284 session.header.shell = "bash".to_string();
285 session.header.os = "linux".to_string();
286 session.header.hostname = "myhost".to_string();
287 session.header.tags = vec!["setup".to_string(), "docker".to_string()];
288
289 let mut cmd0 = Command::new(0, "echo hello".to_string(), PathBuf::from("/home/user"));
290 cmd0.exit_code = Some(0);
291 cmd0.duration_ms = Some(50);
292 cmd0.ended_at = Some(cmd0.started_at + 0.05);
293 session.commands.push(cmd0);
294
295 let mut cmd1 = Command::new(1, "ls -la".to_string(), PathBuf::from("/tmp"));
296 cmd1.exit_code = Some(0);
297 cmd1.duration_ms = Some(120);
298 cmd1.ended_at = Some(cmd1.started_at + 0.12);
299 session.commands.push(cmd1);
300
301 session.complete(SessionStatus::Completed);
302 session
303 }
304
305 #[test]
306 fn test_session_to_toml_produces_valid_toml() {
307 let session = create_test_session();
308 let toml_str = session_to_toml(&session).unwrap();
309
310 assert!(toml_str.contains("# Edit this file"));
312
313 let parsed: EditableSession = toml::from_str(
315 toml_str
316 .lines()
317 .filter(|l| !l.starts_with('#'))
318 .collect::<Vec<_>>()
319 .join("\n")
320 .as_str(),
321 )
322 .expect("TOML should be valid");
323
324 assert_eq!(parsed.name, "test-edit");
325 assert_eq!(parsed.tags, vec!["setup", "docker"]);
326 assert_eq!(parsed.commands.len(), 2);
327 assert_eq!(parsed.commands[0].command, "echo hello");
328 }
329
330 #[test]
331 fn test_toml_to_session_round_trip() {
332 let session = create_test_session();
333 let original_id = session.id();
334 let original_version = session.header.version;
335 let original_started_at = session.header.started_at;
336
337 let toml_str = session_to_toml(&session).unwrap();
338 let restored = toml_to_session(&toml_str, &session).unwrap();
339
340 assert_eq!(restored.id(), original_id);
342 assert_eq!(restored.header.version, original_version);
343 assert_eq!(restored.header.started_at, original_started_at);
344
345 assert_eq!(restored.name(), "test-edit");
347 assert_eq!(restored.header.tags, vec!["setup", "docker"]);
348 assert_eq!(restored.commands.len(), 2);
349 assert_eq!(restored.commands[0].command, "echo hello");
350 assert_eq!(restored.commands[0].cwd, PathBuf::from("/home/user"));
351 }
352
353 #[test]
354 fn test_toml_to_session_with_modified_name() {
355 let session = create_test_session();
356 let original_id = session.id();
357
358 let toml_str = session_to_toml(&session).unwrap();
359 let modified = toml_str.replace("test-edit", "new-name");
360 let restored = toml_to_session(&modified, &session).unwrap();
361
362 assert_eq!(restored.name(), "new-name");
363 assert_eq!(restored.id(), original_id); }
365
366 #[test]
367 fn test_toml_to_session_with_added_command() {
368 let session = create_test_session();
369 let mut toml_str = session_to_toml(&session).unwrap();
370
371 toml_str.push_str(
373 "\n\n[[commands]]\ncommand = \"pwd\"\ncwd = \"/var\"\nexit_code = 0\nduration_ms = 10\n",
374 );
375
376 let restored = toml_to_session(&toml_str, &session).unwrap();
377 assert_eq!(restored.commands.len(), 3);
378 assert_eq!(restored.commands[2].command, "pwd");
379 assert_eq!(restored.commands[2].cwd, PathBuf::from("/var"));
380
381 assert_eq!(restored.footer.as_ref().unwrap().command_count, 3);
383 }
384
385 #[test]
386 fn test_toml_to_session_with_invalid_toml() {
387 let session = create_test_session();
388 let result = toml_to_session("this is not valid toml {{{{", &session);
389 assert!(result.is_err());
390 }
391
392 #[test]
393 fn test_toml_to_session_preserves_timestamps() {
394 let session = create_test_session();
395 let cmd0_started = session.commands[0].started_at;
396 let cmd0_ended = session.commands[0].ended_at;
397
398 let toml_str = session_to_toml(&session).unwrap();
399 let restored = toml_to_session(&toml_str, &session).unwrap();
400
401 assert_eq!(restored.commands[0].started_at, cmd0_started);
403 assert_eq!(restored.commands[0].ended_at, cmd0_ended);
404 }
405
406 #[test]
407 fn test_toml_to_session_preserves_env() {
408 let session = create_test_session();
409 let original_env = session.header.env.clone();
410
411 let toml_str = session_to_toml(&session).unwrap();
412 let restored = toml_to_session(&toml_str, &session).unwrap();
413
414 assert_eq!(restored.header.env, original_env);
415 }
416
417 #[test]
418 fn test_toml_to_session_preserves_footer() {
419 let session = create_test_session();
420 let original_ended_at = session.footer.as_ref().unwrap().ended_at;
421 let original_status = session.footer.as_ref().unwrap().status;
422
423 let toml_str = session_to_toml(&session).unwrap();
424 let restored = toml_to_session(&toml_str, &session).unwrap();
425
426 assert_eq!(
427 restored.footer.as_ref().unwrap().ended_at,
428 original_ended_at
429 );
430 assert_eq!(restored.footer.as_ref().unwrap().status, original_status);
431 }
432}