Skip to main content

rec/session/
edit.rs

1//! TOML round-trip editing for sessions.
2//!
3//! Converts NDJSON sessions to human-readable TOML for editing in $EDITOR,
4//! then parses changes back while preserving non-editable fields (id, version,
5//! timestamps).
6
7use 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/// Editable representation of a session for TOML serialization.
16///
17/// Contains only the fields that users should be able to modify.
18/// Non-editable fields (id, version, `started_at`, `ended_at`, env) are
19/// preserved from the original session during round-trip.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct EditableSession {
22    /// Human-readable session name
23    pub name: String,
24    /// User-defined tags
25    pub tags: Vec<String>,
26    /// Shell type
27    pub shell: String,
28    /// Operating system info
29    pub os: String,
30    /// Machine hostname
31    pub hostname: String,
32    /// Editable commands
33    pub commands: Vec<EditableCommand>,
34}
35
36/// Editable representation of a single command.
37///
38/// Uses String for cwd instead of `PathBuf` for TOML friendliness.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct EditableCommand {
41    /// The command text
42    pub command: String,
43    /// Working directory (as string for TOML)
44    pub cwd: String,
45    /// Exit code (None if command was still running)
46    pub exit_code: Option<i32>,
47    /// Duration in milliseconds
48    pub duration_ms: Option<u64>,
49}
50
51/// Convert a Session to a TOML string for editing.
52///
53/// Prepends a comment block explaining what's editable.
54///
55/// # Errors
56///
57/// Returns an error if TOML serialization fails.
58pub 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
90/// Parse a TOML string back into a Session, merging with the original.
91///
92/// Preserves non-editable fields from the original session:
93/// - header.version, header.id, `header.started_at`, header.env
94/// - footer (`ended_at`, status)
95/// - command timestamps (`started_at`, `ended_at`) for existing indices
96///
97/// Updates from the editable content:
98/// - header.name, header.tags, header.shell, header.os, header.hostname
99/// - commands (text, cwd, `exit_code`, `duration_ms`)
100///
101/// # Errors
102///
103/// Returns an error if TOML parsing fails.
104pub 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    // Rebuild commands, preserving timestamps from originals where possible
116    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    // Update footer command count if footer exists
135    if let Some(ref mut footer) = merged.footer {
136        footer.command_count = merged.commands.len() as u32;
137    }
138
139    Ok(merged)
140}
141
142/// Launch an editor with the given content and return the edited result.
143///
144/// Returns `Ok(None)` if the user quit without saving (content unchanged or empty).
145/// Returns `Ok(Some(content))` with the new content if changes were made.
146///
147/// Editor preference: $VISUAL -> $EDITOR -> vi
148///
149/// # Errors
150///
151/// Returns an error if the temp file cannot be created/read or the editor fails to launch.
152pub fn launch_editor(content: &str) -> Result<Option<String>> {
153    // Create temp file with .toml extension
154    let temp_dir = std::env::temp_dir();
155    let temp_path = temp_dir.join(format!("rec-edit-{}.toml", std::process::id()));
156
157    // Write content to temp file
158    {
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 (0o600) to prevent other users from reading
165    set_restrictive_permissions(&temp_path)?;
166
167    // Detect editor
168    let editor = std::env::var("VISUAL")
169        .or_else(|_| std::env::var("EDITOR"))
170        .unwrap_or_else(|_| "vi".to_string());
171
172    // Spawn editor
173    let status = std::process::Command::new(&editor)
174        .arg(&temp_path)
175        .status()
176        .map_err(|e| {
177            // Clean up on error
178            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    // Read result
188    let new_content = std::fs::read_to_string(&temp_path)?;
189
190    // Clean up
191    let _ = std::fs::remove_file(&temp_path);
192
193    // If content is empty or unchanged, treat as cancel
194    if new_content.trim().is_empty() || new_content == content {
195        return Ok(None);
196    }
197
198    Ok(Some(new_content))
199}
200
201/// Edit a session interactively using $EDITOR with TOML format.
202///
203/// Workflow:
204/// 1. Create backup of the session file
205/// 2. Convert session to TOML
206/// 3. Open in editor
207/// 4. Parse changes back, re-opening on syntax errors
208/// 5. Save the updated session
209///
210/// # Errors
211///
212/// Returns an error if backup creation, TOML conversion, or session save fails.
213pub fn edit_session(store: &SessionStore, session: &Session, output: &Output) -> Result<()> {
214    // Create backup
215    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    // Convert to TOML
221    let mut toml_content = session_to_toml(session)?;
222
223    // Edit loop
224    loop {
225        let edited = launch_editor(&toml_content)?;
226
227        match edited {
228            None => {
229                // No changes made — clean up backup (non-fatal if removal fails)
230                let _ = std::fs::remove_file(&backup);
231                output.info("Edit cancelled");
232                return Ok(());
233            }
234            Some(new_content) => {
235                // Try parsing
236                match toml_to_session(&new_content, session) {
237                    Ok(updated) => {
238                        // Save may fail — if so, backup is preserved via ? propagation
239                        store.save(&updated)?;
240                        // Success — clean up backup (non-fatal if removal fails)
241                        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                        // Ask to re-edit
254                        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                            // User gave up — clean up backup (non-fatal if removal fails)
262                            let _ = std::fs::remove_file(&backup);
263                            output.info("Edit cancelled, original preserved");
264                            return Ok(());
265                        }
266                        // Re-open with the broken content so user can fix
267                        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        // Should contain the comment header
311        assert!(toml_str.contains("# Edit this file"));
312
313        // Should be parseable as TOML
314        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        // Non-editable fields preserved
341        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        // Editable fields preserved
346        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); // ID preserved
364    }
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        // Add a new command
372        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        // Footer command count updated
382        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        // Command timestamps preserved from original
402        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}