1use std::fmt::Write;
2
3use crate::models::Session;
4
5use super::parameterize::{FormatType, Parameter, apply_parameters, render_for_format};
6use super::{escape_yaml, format_timestamp, truncate_step_name};
7
8#[must_use]
24pub fn export_github_action(session: &Session, params: Option<&[Parameter]>) -> String {
25 let mut out = String::new();
26
27 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 writeln!(out, "name: {}", session.header.name).unwrap();
40 out.push_str("on:\n");
41 out.push_str(" push:\n");
42 out.push_str(" branches: [main]\n");
43 out.push_str("jobs:\n");
44 out.push_str(" build:\n");
45 out.push_str(" runs-on: ubuntu-latest\n");
46
47 if let Some(params) = params {
49 if !params.is_empty() {
50 out.push_str(" env:\n");
51 for param in params {
52 let val = param.value.as_deref().unwrap_or(¶m.original);
53 writeln!(out, " {}: {}", param.name, escape_yaml(val)).unwrap();
54 }
55 }
56 }
57
58 out.push_str(" steps:\n");
59 out.push_str(" - uses: actions/checkout@v4\n");
60
61 if session.commands.is_empty() {
62 out.push_str(" # No commands recorded\n");
63 } else {
64 for (i, cmd) in session.commands.iter().enumerate() {
65 let step = i + 1;
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 let step_name = truncate_step_name(&cmd_text, 60);
79 writeln!(out, " - name: \"Step {step}: {step_name}\"").unwrap();
80
81 if let Some(code) = cmd.exit_code {
83 if code != 0 {
84 writeln!(out, " # NOTE: exited with code {code}").unwrap();
85 }
86 }
87
88 writeln!(out, " run: {}", escape_yaml(&cmd_text)).unwrap();
89 writeln!(out, " working-directory: {cwd_str}").unwrap();
90 }
91 }
92
93 out.push('\n');
94 out.push_str("# Generated by rec\n");
95
96 out
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
103 use std::collections::HashMap;
104 use std::path::PathBuf;
105 use uuid::Uuid;
106
107 fn make_session(commands: Vec<Command>) -> Session {
108 Session {
109 header: SessionHeader {
110 version: 2,
111 id: Uuid::new_v4(),
112 name: "test-session".to_string(),
113 shell: "bash".to_string(),
114 os: "linux".to_string(),
115 hostname: "host".to_string(),
116 env: HashMap::new(),
117 tags: vec![],
118 recovered: None,
119 started_at: 1700000000.0,
120 },
121 commands,
122 footer: Some(SessionFooter {
123 ended_at: 1700000060.0,
124 command_count: 0,
125 status: SessionStatus::Completed,
126 }),
127 }
128 }
129
130 fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
131 Command {
132 index,
133 command: command.to_string(),
134 cwd: PathBuf::from(cwd),
135 started_at: 1700000000.0 + f64::from(index),
136 ended_at: Some(1700000001.0 + f64::from(index)),
137 exit_code,
138 duration_ms: Some(1000),
139 }
140 }
141
142 #[test]
143 fn test_empty_session() {
144 let session = make_session(vec![]);
145 let result = export_github_action(&session, None);
146
147 assert!(result.contains("- uses: actions/checkout@v4"));
148 assert!(result.contains("# No commands recorded"));
149 assert!(result.contains("# Generated by rec"));
150 assert!(result.contains("runs-on: ubuntu-latest"));
151 assert!(result.ends_with('\n'));
152 }
153
154 #[test]
155 fn test_single_command() {
156 let session = make_session(vec![make_cmd(0, "npm install", "/home/user", Some(0))]);
157 let result = export_github_action(&session, None);
158
159 assert!(result.contains("- name: \"Step 1: npm install\""));
160 assert!(result.contains("run: npm install"));
161 assert!(result.contains("working-directory: /home/user"));
162 }
163
164 #[test]
165 fn test_working_directory_every_step() {
166 let session = make_session(vec![
167 make_cmd(0, "echo a", "/home/user", Some(0)),
168 make_cmd(1, "echo b", "/home/user", Some(0)),
169 ]);
170 let result = export_github_action(&session, None);
171
172 let wd_count = result.matches("working-directory: /home/user").count();
174 assert_eq!(
175 wd_count, 2,
176 "Expected 2 working-directory entries, got {wd_count}"
177 );
178 }
179
180 #[test]
181 fn test_failed_command_annotation() {
182 let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
183 let result = export_github_action(&session, None);
184
185 assert!(result.contains("# NOTE: exited with code 1"));
186 }
187
188 #[test]
189 fn test_step_name_truncation() {
190 let long_cmd = "a".repeat(100);
191 let session = make_session(vec![make_cmd(0, &long_cmd, "/home", Some(0))]);
192 let result = export_github_action(&session, None);
193
194 let expected_name = format!("\"Step 1: {}...\"", "a".repeat(60));
196 assert!(result.contains(&expected_name));
197
198 assert!(result.contains(&format!("run: {long_cmd}")));
200 }
201
202 #[test]
203 fn test_yaml_escaping_in_run() {
204 let session = make_session(vec![make_cmd(0, "echo hello: world", "/home", Some(0))]);
205 let result = export_github_action(&session, None);
206
207 assert!(result.contains("run: \"echo hello: world\""));
208 }
209
210 #[test]
211 fn test_output_ends_with_newline() {
212 let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
213 let result = export_github_action(&session, None);
214 assert!(result.ends_with('\n'));
215 }
216}