Skip to main content

rec/export/
markdown.rs

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/// Export a session as Markdown documentation.
9///
10/// Generates a Markdown document with:
11/// - Session title
12/// - Summary table (date, duration, command count, tags)
13/// - Parameters table when parameters are provided
14/// - Numbered steps with fenced code blocks
15/// - Directory and exit code metadata per step
16///
17/// When `params` is `Some`, a parameters table is added after the summary
18/// and commands show `{{VAR}}` placeholders (Markdown passthrough).
19///
20/// Empty sessions produce a valid document with a "No commands recorded" note.
21#[must_use]
22pub fn export_markdown(session: &Session, params: Option<&[Parameter]>) -> String {
23    let mut out = String::new();
24
25    // Title
26    writeln!(out, "# Session: {}", session.header.name).unwrap();
27    out.push('\n');
28
29    // Summary table
30    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    // Parameters table
54    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(&param.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            // Apply parameterization (Markdown uses passthrough — keeps {{VAR}})
76            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(&parameterized_cmd, FormatType::Markdown);
79                let parameterized_cwd = apply_parameters(&cmd.cwd.display().to_string(), p);
80                let rendered_cwd = render_for_format(&parameterized_cwd, FormatType::Markdown);
81                (rendered_cmd, rendered_cwd)
82            } else {
83                (cmd.command.clone(), cmd.cwd.display().to_string())
84            };
85
86            // Truncate long command text for heading
87            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
117/// Truncate a command string for use in step headings.
118///
119/// If the command is longer than `max_len`, truncates and appends "...".
120fn 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        // Duration should be calculated from started_at to ended_at (60 seconds)
220        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        // Step heading should be truncated
247        let expected_heading = format!("### Step 1: {}...", "a".repeat(60));
248        assert!(result.contains(&expected_heading));
249
250        // But code block should have full command
251        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        // Should have --- separators
266        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}