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};
7
8#[must_use]
25pub fn export_gitlab_ci(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("stages:\n");
41 out.push_str(" - build\n");
42 out.push_str("build:\n");
43 out.push_str(" stage: build\n");
44 out.push_str(" image: ubuntu:22.04\n");
45
46 if let Some(params) = params {
48 if !params.is_empty() {
49 out.push_str(" variables:\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(" script:\n");
58
59 if session.commands.is_empty() {
60 out.push_str(" - echo 'No commands recorded'\n");
61 } else {
62 let mut prev_cwd: Option<String> = None;
63
64 for cmd in &session.commands {
65 let (cmd_text, cwd_str) = if let Some(p) = params {
67 let parameterized_cmd = apply_parameters(&cmd.command, p);
68 let rendered_cmd = render_for_format(¶meterized_cmd, FormatType::Shell);
69 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
70 let rendered_cwd = render_for_format(¶meterized_cwd, FormatType::Shell);
71 (rendered_cmd, rendered_cwd)
72 } else {
73 (cmd.command.clone(), cmd.cwd.display().to_string())
74 };
75
76 let need_cd = match &prev_cwd {
78 Some(prev) => prev != &cwd_str,
79 None => true,
80 };
81
82 if need_cd {
83 writeln!(out, " - cd {}", escape_yaml(&cwd_str)).unwrap();
84 }
85
86 if let Some(code) = cmd.exit_code {
88 if code != 0 {
89 writeln!(out, " # NOTE: exited with code {code}").unwrap();
90 }
91 }
92
93 writeln!(out, " - {}", escape_yaml(&cmd_text)).unwrap();
94
95 prev_cwd = Some(cwd_str);
96 }
97 }
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_gitlab_ci(&session, None);
152
153 assert!(result.contains("echo 'No commands recorded'"));
154 assert!(result.contains("stages:"));
155 assert!(result.contains(" - build"));
156 assert!(result.contains("build:"));
157 assert!(result.contains(" stage: build"));
158 assert!(result.contains(" image: ubuntu:22.04"));
159 assert!(result.contains(" script:"));
160 assert!(result.ends_with('\n'));
161 }
162
163 #[test]
164 fn test_single_command() {
165 let session = make_session(vec![make_cmd(0, "npm install", "/home/user", Some(0))]);
166 let result = export_gitlab_ci(&session, None);
167
168 assert!(result.contains(" - cd /home/user"));
169 assert!(result.contains(" - npm install"));
170 }
171
172 #[test]
173 fn test_cd_only_on_change() {
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 make_cmd(2, "echo c", "/tmp", Some(0)),
178 ]);
179 let result = export_gitlab_ci(&session, None);
180
181 let cd_count = result.matches(" - cd ").count();
183 assert_eq!(cd_count, 2, "Expected 2 cd commands, got {cd_count}");
184 }
185
186 #[test]
187 fn test_failed_command_annotation() {
188 let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
189 let result = export_gitlab_ci(&session, None);
190
191 assert!(result.contains(" # NOTE: exited with code 1"));
192 }
193
194 #[test]
195 fn test_yaml_escaping() {
196 let session = make_session(vec![make_cmd(0, "echo hello: world", "/home", Some(0))]);
197 let result = export_gitlab_ci(&session, None);
198
199 assert!(result.contains(" - \"echo hello: world\""));
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_gitlab_ci(&session, None);
206 assert!(result.ends_with('\n'));
207 }
208}