1use std::fmt::Write;
2
3use crate::models::Session;
4
5use super::parameterize::{FormatType, Parameter, apply_parameters, render_for_format};
6use super::{format_duration, format_timestamp};
7
8#[must_use]
22pub fn export_markdown(session: &Session, params: Option<&[Parameter]>) -> String {
23 let mut out = String::new();
24
25 writeln!(out, "# Session: {}", session.header.name).unwrap();
27 out.push('\n');
28
29 let duration_str = match &session.footer {
31 Some(f) => format_duration(f.ended_at - session.header.started_at),
32 None => "N/A".to_string(),
33 };
34
35 let tags_str = if session.header.tags.is_empty() {
36 "none".to_string()
37 } else {
38 session.header.tags.join(", ")
39 };
40
41 out.push_str("| Property | Value |\n");
42 out.push_str("|----------|-------|\n");
43 writeln!(
44 out,
45 "| Date | {} |",
46 format_timestamp(session.header.started_at)
47 )
48 .unwrap();
49 writeln!(out, "| Duration | {duration_str} |").unwrap();
50 writeln!(out, "| Commands | {} |", session.commands.len()).unwrap();
51 writeln!(out, "| Tags | {tags_str} |").unwrap();
52
53 if let Some(params) = params {
55 if !params.is_empty() {
56 out.push('\n');
57 out.push_str("## Parameters\n");
58 out.push('\n');
59 out.push_str("| Parameter | Value |\n");
60 out.push_str("|-----------|-------|\n");
61 for param in params {
62 let val = param.value.as_deref().unwrap_or(¶m.original);
63 writeln!(out, "| {} | {} |", param.name, val).unwrap();
64 }
65 }
66 }
67
68 if session.commands.is_empty() {
69 out.push('\n');
70 out.push_str("*No commands recorded.*\n");
71 } else {
72 for (i, cmd) in session.commands.iter().enumerate() {
73 let step = i + 1;
74
75 let (cmd_text, cwd_str) = if let Some(p) = params {
77 let parameterized_cmd = apply_parameters(&cmd.command, p);
78 let rendered_cmd = render_for_format(¶meterized_cmd, FormatType::Markdown);
79 let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
80 let rendered_cwd = render_for_format(¶meterized_cwd, FormatType::Markdown);
81 (rendered_cmd, rendered_cwd)
82 } else {
83 (cmd.command.clone(), cmd.cwd.display().to_string())
84 };
85
86 let heading_text = truncate_command(&cmd_text, 60);
88
89 out.push('\n');
90 out.push_str("---\n");
91 out.push('\n');
92 writeln!(out, "### Step {step}: {heading_text}").unwrap();
93 out.push('\n');
94 out.push_str("```bash\n");
95 writeln!(out, "{cmd_text}").unwrap();
96 out.push_str("```\n");
97 out.push('\n');
98
99 let exit_str = match cmd.exit_code {
100 Some(code) => code.to_string(),
101 None => "unknown".to_string(),
102 };
103
104 writeln!(out, "> **Directory:** `{cwd_str}`").unwrap();
105 writeln!(out, "> **Exit code:** {exit_str}").unwrap();
106 }
107 }
108
109 out.push('\n');
110 out.push_str("---\n");
111 out.push('\n');
112 out.push_str("*Generated by rec*\n");
113
114 out
115}
116
117fn truncate_command(command: &str, max_len: usize) -> String {
121 if command.len() <= max_len {
122 command.to_string()
123 } else {
124 format!("{}...", &command[..max_len])
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::models::{Command, Session, SessionFooter, SessionHeader, SessionStatus};
132 use std::collections::HashMap;
133 use std::path::PathBuf;
134 use uuid::Uuid;
135
136 fn make_session(commands: Vec<Command>, tags: Vec<String>) -> Session {
137 Session {
138 header: SessionHeader {
139 version: 2,
140 id: Uuid::new_v4(),
141 name: "test-session".to_string(),
142 shell: "bash".to_string(),
143 os: "linux".to_string(),
144 hostname: "host".to_string(),
145 env: HashMap::new(),
146 tags,
147 recovered: None,
148 started_at: 1700000000.0,
149 },
150 commands,
151 footer: Some(SessionFooter {
152 ended_at: 1700000060.0,
153 command_count: 0,
154 status: SessionStatus::Completed,
155 }),
156 }
157 }
158
159 fn make_cmd(index: u32, command: &str, cwd: &str, exit_code: Option<i32>) -> Command {
160 Command {
161 index,
162 command: command.to_string(),
163 cwd: PathBuf::from(cwd),
164 started_at: 1700000000.0 + f64::from(index),
165 ended_at: Some(1700000001.0 + f64::from(index)),
166 exit_code,
167 duration_ms: Some(1000),
168 }
169 }
170
171 #[test]
172 fn test_empty_session() {
173 let session = make_session(vec![], vec![]);
174 let result = export_markdown(&session, None);
175
176 assert!(result.contains("# Session: test-session"));
177 assert!(result.contains("*No commands recorded.*"));
178 assert!(result.contains("*Generated by rec*"));
179 assert!(result.ends_with('\n'));
180 }
181
182 #[test]
183 fn test_single_command() {
184 let session = make_session(
185 vec![make_cmd(0, "echo hello", "/home/user", Some(0))],
186 vec![],
187 );
188 let result = export_markdown(&session, None);
189
190 assert!(result.contains("# Session: test-session"));
191 assert!(result.contains("### Step 1: echo hello"));
192 assert!(result.contains("```bash\necho hello\n```"));
193 assert!(result.contains("> **Directory:** `/home/user`"));
194 assert!(result.contains("> **Exit code:** 0"));
195 assert!(result.ends_with('\n'));
196 }
197
198 #[test]
199 fn test_tags_display() {
200 let session = make_session(vec![], vec!["deploy".to_string(), "prod".to_string()]);
201 let result = export_markdown(&session, None);
202
203 assert!(result.contains("| Tags | deploy, prod |"));
204 }
205
206 #[test]
207 fn test_no_tags() {
208 let session = make_session(vec![], vec![]);
209 let result = export_markdown(&session, None);
210
211 assert!(result.contains("| Tags | none |"));
212 }
213
214 #[test]
215 fn test_duration_with_footer() {
216 let session = make_session(vec![], vec![]);
217 let result = export_markdown(&session, None);
218
219 assert!(result.contains("| Duration | 1m 0s |"));
221 }
222
223 #[test]
224 fn test_duration_no_footer() {
225 let mut session = make_session(vec![], vec![]);
226 session.footer = None;
227 let result = export_markdown(&session, None);
228
229 assert!(result.contains("| Duration | N/A |"));
230 }
231
232 #[test]
233 fn test_unknown_exit_code() {
234 let session = make_session(vec![make_cmd(0, "running-cmd", "/home", None)], vec![]);
235 let result = export_markdown(&session, None);
236
237 assert!(result.contains("> **Exit code:** unknown"));
238 }
239
240 #[test]
241 fn test_truncate_long_command() {
242 let long_cmd = "a".repeat(100);
243 let session = make_session(vec![make_cmd(0, &long_cmd, "/home", Some(0))], vec![]);
244 let result = export_markdown(&session, None);
245
246 let expected_heading = format!("### Step 1: {}...", "a".repeat(60));
248 assert!(result.contains(&expected_heading));
249
250 assert!(result.contains(&format!("```bash\n{long_cmd}\n```")));
252 }
253
254 #[test]
255 fn test_separators_between_steps() {
256 let session = make_session(
257 vec![
258 make_cmd(0, "echo a", "/home", Some(0)),
259 make_cmd(1, "echo b", "/home", Some(0)),
260 ],
261 vec![],
262 );
263 let result = export_markdown(&session, None);
264
265 assert!(result.contains("---\n\n### Step 1:"));
267 assert!(result.contains("---\n\n### Step 2:"));
268 }
269
270 #[test]
271 fn test_output_ends_with_newline() {
272 let session = make_session(vec![make_cmd(0, "ls", "/home", Some(0))], vec![]);
273 let result = export_markdown(&session, None);
274 assert!(result.ends_with('\n'));
275 }
276
277 #[test]
278 fn test_fenced_code_blocks() {
279 let session = make_session(vec![make_cmd(0, "echo hello", "/home", Some(0))], vec![]);
280 let result = export_markdown(&session, None);
281
282 assert!(result.contains("```bash"));
283 assert!(result.contains("```\n"));
284 }
285
286 #[test]
287 fn test_summary_table() {
288 let session = make_session(vec![], vec![]);
289 let result = export_markdown(&session, None);
290
291 assert!(result.contains("| Property | Value |"));
292 assert!(result.contains("|----------|-------|"));
293 assert!(result.contains("| Date |"));
294 assert!(result.contains("| Duration |"));
295 assert!(result.contains("| Commands | 0 |"));
296 assert!(result.contains("| Tags |"));
297 }
298}