Skip to main content

rec/export/
bash.rs

1use std::fmt::Write;
2
3use crate::models::Session;
4
5use super::parameterize::{FormatType, Parameter, apply_parameters, render_for_format};
6use super::{format_duration, format_timestamp};
7
8/// Export a session as a bash script.
9///
10/// Generates a self-contained bash script with:
11/// - Shebang and strict mode (`set -euo pipefail`)
12/// - Metadata header (session name, date, command count)
13/// - Variable declarations when parameters are provided
14/// - Commands with `cd` when working directory changes
15/// - Annotations for failed commands (non-zero exit codes)
16/// - Footer comment
17///
18/// When `params` is `Some`, commands have auto-detected values replaced with
19/// `$VAR` references and variable declarations are added after `set -euo pipefail`.
20///
21/// Empty sessions produce a valid script with a "No commands recorded" note.
22#[must_use]
23pub fn export_bash(session: &Session, params: Option<&[Parameter]>) -> String {
24    let mut out = String::new();
25
26    // Header
27    out.push_str("#!/bin/bash\n");
28    out.push_str("# Generated by rec\n");
29    writeln!(out, "# Session: {}", session.header.name).unwrap();
30    writeln!(
31        out,
32        "# Recorded: {}",
33        format_timestamp(session.header.started_at)
34    )
35    .unwrap();
36    writeln!(out, "# Commands: {}", session.commands.len()).unwrap();
37
38    // Duration if available
39    if let Some(ref footer) = session.footer {
40        let duration = footer.ended_at - session.header.started_at;
41        writeln!(out, "# Duration: {}", format_duration(duration)).unwrap();
42    }
43
44    out.push_str("set -euo pipefail\n");
45
46    // Parameter declarations
47    if let Some(params) = params {
48        if !params.is_empty() {
49            out.push('\n');
50            out.push_str("# Parameters\n");
51            for param in params {
52                let val = param.value.as_deref().unwrap_or(&param.original);
53                writeln!(out, "{}=\"{}\"", param.name, val).unwrap();
54            }
55        }
56    }
57
58    if session.commands.is_empty() {
59        out.push('\n');
60        out.push_str("# No commands recorded\n");
61    } else {
62        let mut prev_cwd: Option<String> = None;
63
64        for (i, cmd) in session.commands.iter().enumerate() {
65            out.push('\n');
66
67            // Apply parameterization to command and cwd
68            let (cmd_text, cwd_str) = if let Some(p) = params {
69                let parameterized_cmd = apply_parameters(&cmd.command, p);
70                let rendered_cmd = render_for_format(&parameterized_cmd, FormatType::Shell);
71                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
72                let rendered_cwd = render_for_format(&parameterized_cwd, FormatType::Shell);
73                (rendered_cmd, rendered_cwd)
74            } else {
75                (cmd.command.clone(), cmd.cwd.display().to_string())
76            };
77
78            // Step comment with index and command summary
79            writeln!(out, "# [{}] {} (from {})", i + 1, cmd_text, cwd_str).unwrap();
80
81            // Emit cd if cwd changed from previous command
82            let need_cd = match &prev_cwd {
83                Some(prev) => prev != &cwd_str,
84                None => true, // First command always gets cd
85            };
86
87            if need_cd {
88                writeln!(out, "cd {cwd_str}").unwrap();
89            }
90
91            // The command itself
92            writeln!(out, "{cmd_text}").unwrap();
93
94            // Annotate failed commands
95            if let Some(code) = cmd.exit_code {
96                if code != 0 {
97                    writeln!(out, "# NOTE: exited with code {code}").unwrap();
98                }
99            }
100
101            prev_cwd = Some(cwd_str);
102        }
103    }
104
105    out.push('\n');
106    out.push_str("# Generated by rec\n");
107
108    out
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
115    use std::collections::HashMap;
116    use std::path::PathBuf;
117    use uuid::Uuid;
118
119    fn make_session(commands: Vec<Command>) -> Session {
120        Session {
121            header: SessionHeader {
122                version: 2,
123                id: Uuid::new_v4(),
124                name: "test-session".to_string(),
125                shell: "bash".to_string(),
126                os: "linux".to_string(),
127                hostname: "host".to_string(),
128                env: HashMap::new(),
129                tags: vec![],
130                recovered: None,
131                started_at: 1700000000.0,
132            },
133            commands,
134            footer: Some(SessionFooter {
135                ended_at: 1700000060.0,
136                command_count: 0,
137                status: SessionStatus::Completed,
138            }),
139        }
140    }
141
142    fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
143        Command {
144            index,
145            command: command.to_string(),
146            cwd: PathBuf::from(cwd),
147            started_at: 1700000000.0 + f64::from(index),
148            ended_at: Some(1700000001.0 + f64::from(index)),
149            exit_code,
150            duration_ms: Some(1000),
151        }
152    }
153
154    #[test]
155    fn test_empty_session() {
156        let session = make_session(vec![]);
157        let result = export_bash(&session, None);
158
159        assert!(result.starts_with("#!/bin/bash\n"));
160        assert!(result.contains("set -euo pipefail"));
161        assert!(result.contains("# No commands recorded"));
162        assert!(result.contains("# Generated by rec"));
163        assert!(result.ends_with('\n'));
164    }
165
166    #[test]
167    fn test_single_command() {
168        let session = make_session(vec![make_cmd(0, "echo hello", "/home/user", Some(0))]);
169        let result = export_bash(&session, None);
170
171        assert!(result.contains("#!/bin/bash"));
172        assert!(result.contains("set -euo pipefail"));
173        assert!(result.contains("# [1] echo hello (from /home/user)"));
174        assert!(result.contains("cd /home/user"));
175        assert!(result.contains("echo hello"));
176        assert!(!result.contains("NOTE: exited with code"));
177        assert!(result.ends_with('\n'));
178    }
179
180    #[test]
181    fn test_cd_only_on_change() {
182        let session = make_session(vec![
183            make_cmd(0, "echo a", "/home/user", Some(0)),
184            make_cmd(1, "echo b", "/home/user", Some(0)),
185            make_cmd(2, "echo c", "/tmp", Some(0)),
186        ]);
187        let result = export_bash(&session, None);
188
189        // Count how many times "cd " appears (should be 2: initial + change)
190        let cd_count = result.matches("\ncd ").count();
191        assert_eq!(cd_count, 2, "Expected 2 cd commands, got {cd_count}");
192    }
193
194    #[test]
195    fn test_failed_command_annotation() {
196        let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
197        let result = export_bash(&session, None);
198
199        assert!(result.contains("# NOTE: exited with code 1"));
200    }
201
202    #[test]
203    fn test_output_ends_with_newline() {
204        let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
205        let result = export_bash(&session, None);
206        assert!(result.ends_with('\n'));
207    }
208
209    #[test]
210    fn test_parameterized_bash() {
211        let session = make_session(vec![make_cmd(
212            0,
213            "ls /home/alice/docs",
214            "/home/alice",
215            Some(0),
216        )]);
217        let params = vec![Parameter {
218            name: "HOME_DIR".to_string(),
219            original: "/home/alice".to_string(),
220            value: Some("/opt/app".to_string()),
221            auto_detected: true,
222        }];
223        let result = export_bash(&session, Some(&params));
224
225        // Should have variable declaration
226        assert!(result.contains("# Parameters\n"));
227        assert!(result.contains("HOME_DIR=\"/opt/app\""));
228        // Commands should use $HOME_DIR
229        assert!(result.contains("$HOME_DIR/docs"));
230        assert!(result.contains("cd $HOME_DIR"));
231        // Original value should NOT appear in commands
232        assert!(!result.contains("cd /home/alice"));
233    }
234}