Skip to main content

rec/export/
makefile.rs

1use std::fmt::Write;
2
3use crate::models::Session;
4
5use super::parameterize::{FormatType, Parameter, apply_parameters, render_for_format};
6use super::{escape_makefile, format_timestamp};
7
8/// Export a session as a Makefile.
9///
10/// Generates a Makefile with:
11/// - Header comments (session name, date, command count)
12/// - Make variable declarations when parameters are provided (`VAR ?= value`)
13/// - `.PHONY` targets for each step
14/// - Tab-indented recipes with `cd && command` per target
15/// - Dollar sign escaping (`$` → `$$`)
16/// - Annotations for failed commands
17///
18/// When `params` is `Some`, commands have auto-detected values replaced with
19/// `$(VAR)` references and variable declarations are added before targets.
20///
21/// Each target runs independently with its own `cd` because
22/// Makefile recipe lines each execute in a separate shell.
23///
24/// Empty sessions produce a valid Makefile with a "No commands recorded" note.
25#[must_use]
26pub fn export_makefile(session: &Session, params: Option<&[Parameter]>) -> String {
27    let mut out = String::new();
28
29    // Header
30    out.push_str("# Generated by rec\n");
31    writeln!(out, "# Session: {}", session.header.name).unwrap();
32    writeln!(
33        out,
34        "# Recorded: {}",
35        format_timestamp(session.header.started_at)
36    )
37    .unwrap();
38    writeln!(out, "# Commands: {}", session.commands.len()).unwrap();
39
40    // Parameter declarations
41    if let Some(params) = params {
42        if !params.is_empty() {
43            out.push('\n');
44            for param in params {
45                let val = param.value.as_deref().unwrap_or(&param.original);
46                writeln!(out, "{} ?= {}", param.name, val).unwrap();
47            }
48        }
49    }
50
51    if session.commands.is_empty() {
52        out.push('\n');
53        out.push_str("# No commands recorded\n");
54    } else {
55        for (i, cmd) in session.commands.iter().enumerate() {
56            let step = i + 1;
57
58            // Apply parameterization
59            let (cmd_text, cwd_str) = if let Some(p) = params {
60                let parameterized_cmd = apply_parameters(&cmd.command, p);
61                let rendered_cmd = render_for_format(&parameterized_cmd, FormatType::Make);
62                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
63                let rendered_cwd = render_for_format(&parameterized_cwd, FormatType::Make);
64                (rendered_cmd, rendered_cwd)
65            } else {
66                (cmd.command.clone(), cmd.cwd.display().to_string())
67            };
68
69            let escaped_cmd = escape_makefile(&cmd_text);
70
71            out.push('\n');
72            writeln!(out, ".PHONY: step-{step}").unwrap();
73            writeln!(out, "step-{step}:").unwrap();
74            writeln!(out, "\t# [{step}] {cmd_text} (from {cwd_str})").unwrap();
75
76            // Annotate failed commands
77            if let Some(code) = cmd.exit_code {
78                if code != 0 {
79                    writeln!(out, "\t# NOTE: exited with code {code}").unwrap();
80                }
81            }
82
83            writeln!(out, "\tcd {cwd_str} && {escaped_cmd}").unwrap();
84        }
85    }
86
87    out.push('\n');
88    out.push_str("# Generated by rec\n");
89
90    out
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
97    use std::collections::HashMap;
98    use std::path::PathBuf;
99    use uuid::Uuid;
100
101    fn make_session(commands: Vec<Command>) -> Session {
102        Session {
103            header: SessionHeader {
104                version: 2,
105                id: Uuid::new_v4(),
106                name: "test-session".to_string(),
107                shell: "bash".to_string(),
108                os: "linux".to_string(),
109                hostname: "host".to_string(),
110                env: HashMap::new(),
111                tags: vec![],
112                recovered: None,
113                started_at: 1700000000.0,
114            },
115            commands,
116            footer: Some(SessionFooter {
117                ended_at: 1700000060.0,
118                command_count: 0,
119                status: SessionStatus::Completed,
120            }),
121        }
122    }
123
124    fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
125        Command {
126            index,
127            command: command.to_string(),
128            cwd: PathBuf::from(cwd),
129            started_at: 1700000000.0 + f64::from(index),
130            ended_at: Some(1700000001.0 + f64::from(index)),
131            exit_code,
132            duration_ms: Some(1000),
133        }
134    }
135
136    #[test]
137    fn test_empty_session() {
138        let session = make_session(vec![]);
139        let result = export_makefile(&session, None);
140
141        assert!(result.contains("# Generated by rec"));
142        assert!(result.contains("# No commands recorded"));
143        assert!(result.ends_with('\n'));
144    }
145
146    #[test]
147    fn test_single_command() {
148        let session = make_session(vec![make_cmd(0, "echo hello", "/home/user", Some(0))]);
149        let result = export_makefile(&session, None);
150
151        assert!(result.contains(".PHONY: step-1"));
152        assert!(result.contains("step-1:"));
153        assert!(result.contains("\t# [1] echo hello (from /home/user)"));
154        assert!(result.contains("\tcd /home/user && echo hello"));
155        assert!(result.ends_with('\n'));
156    }
157
158    #[test]
159    fn test_dollar_escaping() {
160        let session = make_session(vec![make_cmd(0, "echo $HOME", "/home", Some(0))]);
161        let result = export_makefile(&session, None);
162
163        // Command in recipe should have $$ but comment should have original
164        assert!(result.contains("\tcd /home && echo $$HOME"));
165        // Comment shows original command
166        assert!(result.contains("# [1] echo $HOME"));
167    }
168
169    #[test]
170    fn test_each_target_has_cd() {
171        let session = make_session(vec![
172            make_cmd(0, "echo a", "/home", Some(0)),
173            make_cmd(1, "echo b", "/home", Some(0)),
174        ]);
175        let result = export_makefile(&session, None);
176
177        // Both targets should have cd (unlike bash which deduplicates)
178        let cd_count = result.matches("\tcd /home && ").count();
179        assert_eq!(cd_count, 2, "Each Makefile target needs its own cd");
180    }
181
182    #[test]
183    fn test_tab_indentation() {
184        let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
185        let result = export_makefile(&session, None);
186
187        // Verify tab characters are used (not spaces)
188        for line in result.lines() {
189            if line.starts_with('\t') {
190                // This is a recipe line - good, it has a tab
191                assert!(
192                    line.starts_with('\t'),
193                    "Recipe line should start with tab: {line}"
194                );
195            }
196        }
197        // At least one line should be tab-indented
198        assert!(result.lines().any(|l| l.starts_with('\t')));
199    }
200
201    #[test]
202    fn test_failed_command_annotation() {
203        let session = make_session(vec![make_cmd(0, "false", "/home", Some(1))]);
204        let result = export_makefile(&session, None);
205
206        assert!(result.contains("\t# NOTE: exited with code 1"));
207    }
208
209    #[test]
210    fn test_output_ends_with_newline() {
211        let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))]);
212        let result = export_makefile(&session, None);
213        assert!(result.ends_with('\n'));
214    }
215
216    #[test]
217    fn test_phony_per_target() {
218        let session = make_session(vec![
219            make_cmd(0, "echo a", "/home", Some(0)),
220            make_cmd(1, "echo b", "/tmp", Some(0)),
221            make_cmd(2, "echo c", "/var", Some(0)),
222        ]);
223        let result = export_makefile(&session, None);
224
225        assert!(result.contains(".PHONY: step-1"));
226        assert!(result.contains(".PHONY: step-2"));
227        assert!(result.contains(".PHONY: step-3"));
228    }
229}