Skip to main content

rec/export/
github_action.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 GitHub Actions workflow YAML.
9///
10/// Generates a workflow with:
11/// - Metadata header comments (session name, date, command count)
12/// - Trigger on push to main
13/// - Single build job on ubuntu-latest
14/// - `env:` block when parameters are provided
15/// - Checkout step followed by one step per command
16/// - `working-directory` on every step
17/// - Annotations for failed commands (non-zero exit codes)
18///
19/// When `params` is `Some`, an `env:` block is added at job level and
20/// commands use `$VAR` references.
21///
22/// Empty sessions produce a valid workflow with a "No commands recorded" comment.
23#[must_use]
24pub fn export_github_action(session: &Session, params: Option<&[Parameter]>) -> String {
25    let mut out = String::new();
26
27    // Header comments
28    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    // Workflow structure
39    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    // Environment variables for parameters
48    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(&param.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            // Apply parameterization
68            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(&parameterized_cmd, FormatType::Shell);
71                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
72                let rendered_cwd = render_for_format(&parameterized_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            // 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, "        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        // Both steps should have working-directory
173        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        // Step name should be truncated
195        let expected_name = format!("\"Step 1: {}...\"", "a".repeat(60));
196        assert!(result.contains(&expected_name));
197
198        // But run value should have full command
199        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}