Skip to main content

rec/export/
dockerfile.rs

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/// Export a session as a Dockerfile.
9///
10/// Generates a Dockerfile with:
11/// - Metadata header comments (session name, date, command count)
12/// - `FROM ubuntu:22.04` base image
13/// - `ARG` declarations when parameters are provided
14/// - `WORKDIR` instructions when the working directory changes
15/// - One `RUN` instruction per command (no escaping needed)
16/// - Annotations for failed commands (non-zero exit codes)
17///
18/// When `params` is `Some`, `ARG` declarations are added after the FROM line
19/// and commands use `$VAR` references.
20///
21/// Empty sessions produce a valid Dockerfile with a "No commands recorded" comment.
22#[must_use]
23pub fn export_dockerfile(session: &Session, params: Option<&[Parameter]>) -> String {
24    let mut out = String::new();
25
26    // Header comments
27    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    // ARG declarations for parameters
40    if let Some(params) = params {
41        if !params.is_empty() {
42            for param in params {
43                let val = param.value.as_deref().unwrap_or(&param.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            // Apply parameterization
56            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(&parameterized_cmd, FormatType::Shell);
59                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
60                let rendered_cwd = render_for_format(&parameterized_cwd, FormatType::Shell);
61                (rendered_cmd, rendered_cwd)
62            } else {
63                (cmd.command.clone(), cmd.cwd.display().to_string())
64            };
65
66            // Emit WORKDIR when cwd changes
67            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            // Failed command annotation
77            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        // Should have 2 WORKDIR: first command + change to /tmp
168        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        // Dollar signs should NOT be escaped in Dockerfile
191        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}