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]
25pub fn export_circleci(session: &Session, params: Option<&[Parameter]>) -> String {
26 let mut out = String::new();
27
28 out.push_str("# Generated by rec\n");
30 writeln!(out, "# Session: {}", session.header.name).unwrap();
31 writeln!(
32 out,
33 "# Recorded: {}",
34 format_timestamp(session.header.started_at)
35 )
36 .unwrap();
37 writeln!(out, "# Commands: {}", session.commands.len()).unwrap();
38
39 out.push_str("version: 2.1\n");
41 out.push_str("jobs:\n");
42 out.push_str(" build:\n");
43 out.push_str(" docker:\n");
44 out.push_str(" - image: cimg/base:current\n");
45
46 if let Some(params) = params {
48 if !params.is_empty() {
49 out.push_str(" environment:\n");
50 for param in params {
51 let val = param.value.as_deref().unwrap_or(¶m.original);
52 writeln!(out, " {}: {}", param.name, escape_yaml(val)).unwrap();
53 }
54 }
55 }
56
57 out.push_str(" steps:\n");
58 out.push_str(" - checkout\n");
59
60 if session.commands.is_empty() {
61 out.push_str(" # No commands recorded\n");
62 } else {
63 for (i, cmd) in session.commands.iter().enumerate() {
64 let step = i + 1;
65
66 let (cmd_text, cwd_str) = if let Some(p) = params {
68 let parameterized_cmd = apply_parameters(&cmd.command, p);
69 let rendered_cmd = render_for_format(¶meterized_cmd, FormatType::Shell);
70 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
71 let rendered_cwd = render_for_format(¶meterized_cwd, FormatType::Shell);
72 (rendered_cmd, rendered_cwd)
73 } else {
74 (cmd.command.clone(), cmd.cwd.display().to_string())
75 };
76
77 let step_name = truncate_step_name(&cmd_text, 60);
78 out.push_str(" - run:\n");
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, " command: {}", escape_yaml(&cmd_text)).unwrap();
89 writeln!(out, " working_directory: {cwd_str}").unwrap();
90 }
91 }
92
93 out.push_str("workflows:\n");
95 out.push_str(" main:\n");
96 out.push_str(" jobs:\n");
97 out.push_str(" - build\n");
98
99 out.push('\n');
100 out.push_str("# Generated by rec\n");
101
102 out
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
109 use std::collections::HashMap;
110 use std::path::PathBuf;
111 use uuid::Uuid;
112
113 fn make_session(commands: Vec<Command>) -> Session {
114 Session {
115 header: SessionHeader {
116 version: 2,
117 id: Uuid::new_v4(),
118 name: "test-session".to_string(),
119 shell: "bash".to_string(),
120 os: "linux".to_string(),
121 hostname: "host".to_string(),
122 env: HashMap::new(),
123 tags: vec![],
124 recovered: None,
125 started_at: 1700000000.0,
126 },
127 commands,
128 footer: Some(SessionFooter {
129 ended_at: 1700000060.0,
130 command_count: 0,
131 status: SessionStatus::Completed,
132 }),
133 }
134 }
135
136 fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
137 Command {
138 index,
139 command: command.to_string(),
140 cwd: PathBuf::from(cwd),
141 started_at: 1700000000.0 + f64::from(index),
142 ended_at: Some(1700000001.0 + f64::from(index)),
143 exit_code,
144 duration_ms: Some(1000),
145 }
146 }
147
148 #[test]
149 fn test_empty_session() {
150 let session = make_session(vec![]);
151 let result = export_circleci(&session, None);
152
153 assert!(result.contains("- checkout"));
154 assert!(result.contains("# No commands recorded"));
155 assert!(result.contains("workflows:"));
156 assert!(result.contains("version: 2.1"));
157 assert!(result.contains("cimg/base:current"));
158 assert!(result.ends_with('\n'));
159 }
160
161 #[test]
162 fn test_single_command() {
163 let session = make_session(vec![make_cmd(0, "npm install", "/home/user", Some(0))]);
164 let result = export_circleci(&session, None);
165
166 assert!(result.contains(" - run:"));
167 assert!(result.contains(" name: \"Step 1: npm install\""));
168 assert!(result.contains(" command: npm install"));
169 assert!(result.contains(" working_directory: /home/user"));
170 }
171
172 #[test]
173 fn test_working_directory_every_step() {
174 let session = make_session(vec![
175 make_cmd(0, "echo a", "/home/user", Some(0)),
176 make_cmd(1, "echo b", "/home/user", Some(0)),
177 ]);
178 let result = export_circleci(&session, None);
179
180 let wd_count = result
182 .matches(" working_directory: /home/user")
183 .count();
184 assert_eq!(
185 wd_count, 2,
186 "Expected 2 working_directory entries, got {wd_count}"
187 );
188 }
189
190 #[test]
191 fn test_failed_command_annotation() {
192 let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
193 let result = export_circleci(&session, None);
194
195 assert!(result.contains(" # NOTE: exited with code 1"));
196 }
197
198 #[test]
199 fn test_step_name_truncation() {
200 let long_cmd = "a".repeat(100);
201 let session = make_session(vec![make_cmd(0, &long_cmd, "/home", Some(0))]);
202 let result = export_circleci(&session, None);
203
204 let expected_name = format!("\"Step 1: {}...\"", "a".repeat(60));
206 assert!(result.contains(&expected_name));
207
208 assert!(result.contains(&format!("command: {long_cmd}")));
210 }
211
212 #[test]
213 fn test_yaml_escaping_in_command() {
214 let session = make_session(vec![make_cmd(0, "echo hello: world", "/home", Some(0))]);
215 let result = export_circleci(&session, None);
216
217 assert!(result.contains("command: \"echo hello: world\""));
218 }
219
220 #[test]
221 fn test_workflows_section_present() {
222 let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
223 let result = export_circleci(&session, None);
224
225 assert!(result.contains("workflows:"));
226 assert!(result.contains(" - build"));
227 }
228
229 #[test]
230 fn test_output_ends_with_newline() {
231 let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
232 let result = export_circleci(&session, None);
233 assert!(result.ends_with('\n'));
234 }
235}