Skip to main content

rec/export/
circleci.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, truncate_step_name};
7
8/// Export a session as a `CircleCI` configuration YAML.
9///
10/// Generates a `.circleci/config.yml` with:
11/// - Metadata header comments (session name, date, command count)
12/// - Version 2.1 config
13/// - Single `build` job using `cimg/base:current` Docker image
14/// - `environment:` block when parameters are provided
15/// - Checkout step followed by expanded `run:` steps
16/// - `working_directory` on every step
17/// - `workflows` section at the end
18/// - Annotations for failed commands (non-zero exit codes)
19///
20/// When `params` is `Some`, an `environment:` block is added at job level
21/// and commands use `$VAR` references.
22///
23/// Empty sessions produce a valid config with a "No commands recorded" comment.
24#[must_use]
25pub fn export_circleci(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("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    // Environment variables for parameters
47    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(&param.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            // Apply parameterization
67            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(&parameterized_cmd, FormatType::Shell);
70                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
71                let rendered_cwd = render_for_format(&parameterized_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            // Failed command annotation
82            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    // Workflows section
94    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        // Both steps should have working_directory
181        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        // Step name should be truncated
205        let expected_name = format!("\"Step 1: {}...\"", "a".repeat(60));
206        assert!(result.contains(&expected_name));
207
208        // But command value should have full command
209        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}