1use std::fmt::Write;
2
3use crate::models::Session;
4
5use super::format_timestamp;
6use super::parameterize::{FormatType, Parameter, apply_parameters, render_for_format};
7
8#[must_use]
23pub fn export_dockerfile(session: &Session, params: Option<&[Parameter]>) -> String {
24 let mut out = String::new();
25
26 out.push_str("# Generated by rec\n");
28 writeln!(out, "# Session: {}", session.header.name).unwrap();
29 writeln!(
30 out,
31 "# Recorded: {}",
32 format_timestamp(session.header.started_at)
33 )
34 .unwrap();
35 writeln!(out, "# Commands: {}", session.commands.len()).unwrap();
36
37 out.push_str("FROM ubuntu:22.04\n");
38
39 if let Some(params) = params {
41 if !params.is_empty() {
42 for param in params {
43 let val = param.value.as_deref().unwrap_or(¶m.original);
44 writeln!(out, "ARG {}={}", param.name, val).unwrap();
45 }
46 }
47 }
48
49 if session.commands.is_empty() {
50 out.push_str("# No commands recorded\n");
51 } else {
52 let mut prev_cwd: Option<String> = None;
53
54 for cmd in &session.commands {
55 let (cmd_text, cwd_str) = if let Some(p) = params {
57 let parameterized_cmd = apply_parameters(&cmd.command, p);
58 let rendered_cmd = render_for_format(¶meterized_cmd, FormatType::Shell);
59 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
60 let rendered_cwd = render_for_format(¶meterized_cwd, FormatType::Shell);
61 (rendered_cmd, rendered_cwd)
62 } else {
63 (cmd.command.clone(), cmd.cwd.display().to_string())
64 };
65
66 let need_workdir = match &prev_cwd {
68 Some(prev) => prev != &cwd_str,
69 None => true,
70 };
71
72 if need_workdir {
73 writeln!(out, "WORKDIR {cwd_str}").unwrap();
74 }
75
76 if let Some(code) = cmd.exit_code {
78 if code != 0 {
79 writeln!(out, "# NOTE: exited with code {code}").unwrap();
80 }
81 }
82
83 writeln!(out, "RUN {cmd_text}").unwrap();
84
85 prev_cwd = Some(cwd_str);
86 }
87 }
88
89 out.push('\n');
90 out.push_str("# Generated by rec\n");
91
92 out
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
99 use std::collections::HashMap;
100 use std::path::PathBuf;
101 use uuid::Uuid;
102
103 fn make_session(commands: Vec<Command>) -> Session {
104 Session {
105 header: SessionHeader {
106 version: 2,
107 id: Uuid::new_v4(),
108 name: "test-session".to_string(),
109 shell: "bash".to_string(),
110 os: "linux".to_string(),
111 hostname: "host".to_string(),
112 env: HashMap::new(),
113 tags: vec![],
114 recovered: None,
115 started_at: 1700000000.0,
116 },
117 commands,
118 footer: Some(SessionFooter {
119 ended_at: 1700000060.0,
120 command_count: 0,
121 status: SessionStatus::Completed,
122 }),
123 }
124 }
125
126 fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
127 Command {
128 index,
129 command: command.to_string(),
130 cwd: PathBuf::from(cwd),
131 started_at: 1700000000.0 + f64::from(index),
132 ended_at: Some(1700000001.0 + f64::from(index)),
133 exit_code,
134 duration_ms: Some(1000),
135 }
136 }
137
138 #[test]
139 fn test_empty_session() {
140 let session = make_session(vec![]);
141 let result = export_dockerfile(&session, None);
142
143 assert!(result.contains("FROM ubuntu:22.04"));
144 assert!(result.contains("# No commands recorded"));
145 assert!(result.contains("# Generated by rec"));
146 assert!(result.ends_with('\n'));
147 }
148
149 #[test]
150 fn test_single_command() {
151 let session = make_session(vec![make_cmd(0, "npm install", "/home/user", Some(0))]);
152 let result = export_dockerfile(&session, None);
153
154 assert!(result.contains("WORKDIR /home/user"));
155 assert!(result.contains("RUN npm install"));
156 }
157
158 #[test]
159 fn test_workdir_on_change() {
160 let session = make_session(vec![
161 make_cmd(0, "echo a", "/home/user", Some(0)),
162 make_cmd(1, "echo b", "/home/user", Some(0)),
163 make_cmd(2, "echo c", "/tmp", Some(0)),
164 ]);
165 let result = export_dockerfile(&session, None);
166
167 let workdir_count = result.matches("WORKDIR ").count();
169 assert_eq!(
170 workdir_count, 2,
171 "Expected 2 WORKDIR instructions, got {workdir_count}"
172 );
173 assert!(result.contains("WORKDIR /home/user"));
174 assert!(result.contains("WORKDIR /tmp"));
175 }
176
177 #[test]
178 fn test_failed_command_annotation() {
179 let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
180 let result = export_dockerfile(&session, None);
181
182 assert!(result.contains("# NOTE: exited with code 1"));
183 }
184
185 #[test]
186 fn test_no_escaping_dollar_signs() {
187 let session = make_session(vec![make_cmd(0, "echo $HOME", "/home", Some(0))]);
188 let result = export_dockerfile(&session, None);
189
190 assert!(result.contains("RUN echo $HOME"));
192 assert!(!result.contains("$$HOME"));
193 }
194
195 #[test]
196 fn test_output_ends_with_newline() {
197 let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
198 let result = export_dockerfile(&session, None);
199 assert!(result.ends_with('\n'));
200 }
201}