Skip to main content

rec/export/
mod.rs

1//! Multi-format session export.
2//!
3//! Converts recorded sessions to reusable formats: Bash scripts, Makefiles,
4//! Markdown docs, GitHub Actions, GitLab CI, Dockerfiles, and `CircleCI` configs.
5
6pub mod bash;
7pub mod circleci;
8pub mod dockerfile;
9pub mod github_action;
10pub mod gitlab_ci;
11pub mod makefile;
12pub mod markdown;
13pub mod parameterize;
14
15pub use bash::export_bash;
16pub use circleci::export_circleci;
17pub use dockerfile::export_dockerfile;
18pub use github_action::export_github_action;
19pub use gitlab_ci::export_gitlab_ci;
20pub use makefile::export_makefile;
21pub use markdown::export_markdown;
22pub use parameterize::{Parameter, apply_parameters, detect_all_parameters};
23
24/// Escape a string for safe use as a YAML scalar value.
25///
26/// Wraps the string in double quotes if it contains any characters
27/// that could be misinterpreted by a YAML parser. Returns the
28/// original string if no escaping is needed.
29#[must_use]
30pub fn escape_yaml(s: &str) -> String {
31    let needs_quoting = s.is_empty()
32        || s.starts_with(' ')
33        || s.starts_with('#')
34        || s.starts_with('{')
35        || s.starts_with('[')
36        || s.starts_with('*')
37        || s.starts_with('&')
38        || s.starts_with('!')
39        || s.starts_with('|')
40        || s.starts_with('>')
41        || s.starts_with('%')
42        || s.starts_with('@')
43        || s.starts_with('`')
44        || s.starts_with('?')
45        || s.starts_with('-')
46        || s.starts_with(',')
47        || s.starts_with('\'')
48        || s.starts_with('"')
49        || s.contains(": ")
50        || s.contains(" #")
51        || s.contains('{')
52        || s.contains('}')
53        || s.contains('[')
54        || s.contains(']');
55
56    if needs_quoting {
57        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
58        format!("\"{escaped}\"")
59    } else {
60        s.to_string()
61    }
62}
63
64/// Truncate a command string for use as a CI step name.
65///
66/// If the string is longer than `max_len`, truncates and appends "...".
67#[must_use]
68pub fn truncate_step_name(command: &str, max_len: usize) -> String {
69    if command.len() <= max_len {
70        command.to_string()
71    } else {
72        format!("{}...", &command[..max_len])
73    }
74}
75
76/// Escape dollar signs for Makefile compatibility.
77///
78/// In Makefiles, `$` is used for variable expansion, so literal `$` in
79/// shell commands must be escaped as `$$`.
80#[must_use]
81pub fn escape_makefile(s: &str) -> String {
82    s.replace('$', "$$")
83}
84
85/// Format a Unix timestamp (f64) as "YYYY-MM-DD HH:MM".
86///
87/// Returns "unknown" if the timestamp cannot be converted.
88#[must_use]
89pub fn format_timestamp(ts: f64) -> String {
90    use chrono::{DateTime, Local};
91
92    let secs = ts as i64;
93    let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
94
95    match DateTime::from_timestamp(secs, nanos) {
96        Some(utc) => {
97            let local = utc.with_timezone(&Local);
98            local.format("%Y-%m-%d %H:%M").to_string()
99        }
100        None => "unknown".to_string(),
101    }
102}
103
104/// Format a duration in seconds as a human-readable string.
105///
106/// Examples: "45s", "2m 15s", "1h 3m 0s"
107#[must_use]
108pub fn format_duration(seconds: f64) -> String {
109    let total_secs = seconds as u64;
110    let hours = total_secs / 3600;
111    let minutes = (total_secs % 3600) / 60;
112    let secs = total_secs % 60;
113
114    if hours > 0 {
115        format!("{hours}h {minutes}m {secs}s")
116    } else if minutes > 0 {
117        format!("{minutes}m {secs}s")
118    } else {
119        format!("{secs}s")
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_escape_makefile() {
129        assert_eq!(escape_makefile("echo $HOME"), "echo $$HOME");
130        assert_eq!(escape_makefile("no dollars"), "no dollars");
131        assert_eq!(escape_makefile("$A $B"), "$$A $$B");
132        assert_eq!(escape_makefile(""), "");
133    }
134
135    #[test]
136    fn test_format_timestamp() {
137        // Known timestamp: 2026-01-01 00:00:00 UTC = 1767225600
138        let result = format_timestamp(1767225600.0);
139        // Should produce a valid date string (exact value depends on local timezone)
140        assert!(result.contains("2026") || result.contains("2025")); // near year boundary
141        assert_ne!(result, "unknown");
142    }
143
144    #[test]
145    fn test_format_timestamp_unknown() {
146        // Negative timestamps that chrono can't handle
147        let result = format_timestamp(-1e18);
148        assert_eq!(result, "unknown");
149    }
150
151    #[test]
152    fn test_escape_yaml_plain_string() {
153        assert_eq!(escape_yaml("echo hello"), "echo hello");
154        assert_eq!(escape_yaml("npm install"), "npm install");
155    }
156
157    #[test]
158    fn test_escape_yaml_colon_space() {
159        assert_eq!(escape_yaml("echo: world"), "\"echo: world\"");
160    }
161
162    #[test]
163    fn test_escape_yaml_hash_start() {
164        assert_eq!(escape_yaml("#comment"), "\"#comment\"");
165    }
166
167    #[test]
168    fn test_escape_yaml_brace() {
169        assert_eq!(escape_yaml("echo {foo}"), "\"echo {foo}\"");
170    }
171
172    #[test]
173    fn test_escape_yaml_empty_string() {
174        assert_eq!(escape_yaml(""), "\"\"");
175    }
176
177    #[test]
178    fn test_escape_yaml_double_quote_inside() {
179        // String starting with " triggers quoting, internal " gets escaped
180        assert_eq!(escape_yaml("\"hello\""), "\"\\\"hello\\\"\"");
181    }
182
183    #[test]
184    fn test_escape_yaml_backslash_inside() {
185        // Backslash in a string that needs quoting (contains `: `)
186        assert_eq!(escape_yaml("key: val\\ue"), "\"key: val\\\\ue\"");
187    }
188
189    #[test]
190    fn test_escape_yaml_no_quoting_needed() {
191        // Simple strings with quotes/backslashes mid-string don't need quoting
192        assert_eq!(escape_yaml("echo hello"), "echo hello");
193    }
194
195    #[test]
196    fn test_truncate_step_name_short() {
197        assert_eq!(truncate_step_name("echo hello", 60), "echo hello");
198    }
199
200    #[test]
201    fn test_truncate_step_name_long() {
202        let long = "a".repeat(61);
203        let result = truncate_step_name(&long, 60);
204        assert_eq!(result.len(), 63); // 60 + "..."
205        assert!(result.ends_with("..."));
206        assert!(result.starts_with(&"a".repeat(60)));
207    }
208
209    #[test]
210    fn test_format_duration() {
211        assert_eq!(format_duration(5.0), "5s");
212        assert_eq!(format_duration(65.0), "1m 5s");
213        assert_eq!(format_duration(3665.0), "1h 1m 5s");
214        assert_eq!(format_duration(0.0), "0s");
215        assert_eq!(format_duration(3600.0), "1h 0m 0s");
216    }
217}