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#[must_use]
26pub fn export_makefile(session: &Session, params: Option<&[Parameter]>) -> String {
27 let mut out = String::new();
28
29 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 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(¶m.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 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(¶meterized_cmd, FormatType::Make);
62 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
63 let rendered_cwd = render_for_format(¶meterized_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 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 assert!(result.contains("\tcd /home && echo $$HOME"));
165 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 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 for line in result.lines() {
189 if line.starts_with('\t') {
190 assert!(
192 line.starts_with('\t'),
193 "Recipe line should start with tab: {line}"
194 );
195 }
196 }
197 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}