Skip to main content

rec/export/
gitlab_ci.rs

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/// Export a session as a GitLab CI configuration YAML.
9///
10/// Generates a `.gitlab-ci.yml` with:
11/// - Metadata header comments (session name, date, command count)
12/// - Single `build` stage and job
13/// - `ubuntu:22.04` Docker image
14/// - `variables:` block when parameters are provided
15/// - `cd` commands in the script array when cwd changes
16/// - Commands passed through `escape_yaml()` for safe YAML output
17/// - Annotations for failed commands (non-zero exit codes)
18///
19/// When `params` is `Some`, a `variables:` block is added at job level
20/// and commands use `$VAR` references.
21///
22/// Empty sessions produce a valid config with a placeholder script entry
23/// (GitLab CI requires at least one script entry).
24#[must_use]
25pub fn export_gitlab_ci(session: &Session, params: Option<&[Parameter]>) -> String {
26    let mut out = String::new();
27
28    // Header comments
29    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    // Job structure
40    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    // Variables for parameters
47    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(&param.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            // Apply parameterization
66            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(&parameterized_cmd, FormatType::Shell);
69                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
70                let rendered_cwd = render_for_format(&parameterized_cwd, FormatType::Shell);
71                (rendered_cmd, rendered_cwd)
72            } else {
73                (cmd.command.clone(), cmd.cwd.display().to_string())
74            };
75
76            // cd when cwd changes
77            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            // Failed command annotation
87            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        // Should have 2 cd entries: first command + change to /tmp
182        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}