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#[must_use]
23pub fn export_bash(session: &Session, params: Option<&[Parameter]>) -> String {
24 let mut out = String::new();
25
26 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 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 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(¶m.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 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(¶meterized_cmd, FormatType::Shell);
71 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
72 let rendered_cwd = render_for_format(¶meterized_cwd, FormatType::Shell);
73 (rendered_cmd, rendered_cwd)
74 } else {
75 (cmd.command.clone(), cmd.cwd.display().to_string())
76 };
77
78 writeln!(out, "# [{}] {} (from {})", i + 1, cmd_text, cwd_str).unwrap();
80
81 let need_cd = match &prev_cwd {
83 Some(prev) => prev != &cwd_str,
84 None => true, };
86
87 if need_cd {
88 writeln!(out, "cd {cwd_str}").unwrap();
89 }
90
91 writeln!(out, "{cmd_text}").unwrap();
93
94 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 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(¶ms));
224
225 assert!(result.contains("# Parameters\n"));
227 assert!(result.contains("HOME_DIR=\"/opt/app\""));
228 assert!(result.contains("$HOME_DIR/docs"));
230 assert!(result.contains("cd $HOME_DIR"));
231 assert!(!result.contains("cd /home/alice"));
233 }
234}