Skip to main content

rec/export/
parameterize.rs

1use regex::Regex;
2
3use crate::models::Session;
4
5/// A detected or manual parameter with its name, original value, and replacement value.
6#[derive(Debug, Clone, PartialEq)]
7pub struct Parameter {
8    /// Parameter name, e.g. "`HOME_DIR`", "USERNAME", "HOSTNAME", or user-defined
9    pub name: String,
10    /// Original value, e.g. "/home/alice", "alice", "workstation"
11    pub original: String,
12    /// Replacement value (None = use original)
13    pub value: Option<String>,
14    /// true if auto-detected, false if manual {{VAR}}
15    pub auto_detected: bool,
16}
17
18/// Auto-detect environment-specific parameters from a session.
19///
20/// Extracts home directory, username, and hostname from session metadata,
21/// but only includes them if the value actually appears in at least one
22/// command string or cwd path.
23#[must_use]
24pub fn detect_parameters(session: &Session) -> Vec<Parameter> {
25    let mut params = Vec::new();
26
27    // Collect candidate values from session metadata
28    let mut candidates: Vec<(&str, String)> = Vec::new();
29
30    if let Some(home) = session.header.env.get("HOME") {
31        if !home.is_empty() {
32            candidates.push(("HOME_DIR", home.clone()));
33        }
34    }
35
36    if let Some(user) = session.header.env.get("USER") {
37        if !user.is_empty() {
38            candidates.push(("USERNAME", user.clone()));
39        }
40    }
41
42    if session.header.hostname != "unknown" && !session.header.hostname.is_empty() {
43        candidates.push(("HOSTNAME", session.header.hostname.clone()));
44    }
45
46    // Only include candidates whose original value appears in at least one
47    // command string or cwd path
48    for (name, original) in candidates {
49        let found = session.commands.iter().any(|cmd| {
50            cmd.command.contains(&original) || cmd.cwd.to_string_lossy().contains(&original)
51        });
52
53        if found {
54            // Deduplicate: skip if we already have this exact original value
55            if !params.iter().any(|p: &Parameter| p.original == original) {
56                params.push(Parameter {
57                    name: name.to_string(),
58                    original,
59                    value: None,
60                    auto_detected: true,
61                });
62            }
63        }
64    }
65
66    params
67}
68
69/// Parse manual {{`VAR_NAME`}} placeholders from command strings.
70///
71/// Scans all commands for `{{VAR_NAME}}` patterns. Returns a Parameter
72/// for each unique placeholder name found, skipping names that match
73/// auto-detected parameter names (`HOME_DIR`, USERNAME, HOSTNAME).
74///
75/// # Panics
76///
77/// Panics if the internal regex pattern is invalid (should never happen).
78#[must_use]
79pub fn parse_manual_placeholders(session: &Session) -> Vec<Parameter> {
80    let re = Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").expect("valid regex");
81
82    let auto_names: &[&str] = &["HOME_DIR", "USERNAME", "HOSTNAME"];
83    let mut seen: Vec<String> = Vec::new();
84    let mut params = Vec::new();
85
86    for cmd in &session.commands {
87        for cap in re.captures_iter(&cmd.command) {
88            let var_name = cap[1].to_string();
89            if auto_names.contains(&var_name.as_str()) {
90                continue;
91            }
92            if seen.contains(&var_name) {
93                continue;
94            }
95            seen.push(var_name.clone());
96            params.push(Parameter {
97                name: var_name.clone(),
98                original: format!("{{{{{var_name}}}}}"),
99                value: None,
100                auto_detected: false,
101            });
102        }
103    }
104
105    params
106}
107
108/// Detect all parameters: auto-detected environment values and manual placeholders.
109///
110/// Auto-detected parameters appear first, followed by manual ones.
111#[must_use]
112pub fn detect_all_parameters(session: &Session) -> Vec<Parameter> {
113    let mut params = detect_parameters(session);
114    let manual = parse_manual_placeholders(session);
115    params.extend(manual);
116    params
117}
118
119/// Format type for rendering variables in format-native syntax.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum FormatType {
122    /// Bash/YAML/Dockerfile: {{NAME}} → $NAME
123    Shell,
124    /// Makefile: {{NAME}} → $(NAME)
125    Make,
126    /// Markdown: {{NAME}} → {{NAME}} (passthrough)
127    Markdown,
128}
129
130/// Replace `{{VAR}}` placeholders with format-native variable references.
131///
132/// # Panics
133///
134/// Panics if the internal regex pattern is invalid (should never happen).
135#[must_use]
136pub fn render_for_format(s: &str, format: FormatType) -> String {
137    match format {
138        FormatType::Shell => {
139            // Replace {{NAME}} with $NAME
140            let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").expect("valid regex");
141            re.replace_all(s, |caps: &regex::Captures| format!("${}", &caps[1]))
142                .into_owned()
143        }
144        FormatType::Make => {
145            // Replace {{NAME}} with $(NAME)
146            let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").expect("valid regex");
147            re.replace_all(s, |caps: &regex::Captures| format!("$({})", &caps[1]))
148                .into_owned()
149        }
150        FormatType::Markdown => {
151            // Passthrough
152            s.to_string()
153        }
154    }
155}
156
157/// Replace auto-detected parameter values in a command string with {{PLACEHOLDER}} syntax.
158///
159/// Applies replacements longest-first to avoid partial matches
160/// (e.g., "/home/alice" is replaced before "alice").
161/// Manual placeholders (already {{NAME}} in the string) are left as-is.
162#[must_use]
163pub fn apply_parameters(command: &str, params: &[Parameter]) -> String {
164    // Only replace auto-detected parameters
165    let mut auto_params: Vec<&Parameter> = params.iter().filter(|p| p.auto_detected).collect();
166
167    // Sort by original length descending (longest first) to prevent partial matches
168    auto_params.sort_by(|a, b| b.original.len().cmp(&a.original.len()));
169
170    let mut result = command.to_string();
171    for param in auto_params {
172        let placeholder = format!("{{{{{}}}}}", param.name);
173        result = result.replace(&param.original, &placeholder);
174    }
175
176    result
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::models::{Command, Session, SessionHeader, SessionStatus};
183    use std::collections::HashMap;
184    use std::path::PathBuf;
185    use uuid::Uuid;
186
187    /// Helper to create a test session with given env vars, hostname, and commands.
188    fn make_session(
189        env: HashMap<String, String>,
190        hostname: &str,
191        commands: &[(&str, &str)], // (command_text, cwd)
192    ) -> Session {
193        let header = SessionHeader {
194            version: 2,
195            id: Uuid::new_v4(),
196            name: "test-session".to_string(),
197            shell: "bash".to_string(),
198            os: "linux".to_string(),
199            hostname: hostname.to_string(),
200            env,
201            tags: Vec::new(),
202            recovered: None,
203            started_at: 1700000000.0,
204        };
205
206        let cmds: Vec<Command> = commands
207            .iter()
208            .enumerate()
209            .map(|(i, (cmd, cwd))| Command {
210                index: i as u32,
211                command: cmd.to_string(),
212                cwd: PathBuf::from(cwd),
213                started_at: 1700000000.0 + i as f64,
214                ended_at: Some(1700000001.0 + i as f64),
215                exit_code: Some(0),
216                duration_ms: Some(1000),
217            })
218            .collect();
219
220        let cmd_count = cmds.len() as u32;
221        Session {
222            header,
223            commands: cmds,
224            footer: Some(crate::models::SessionFooter {
225                ended_at: 1700000010.0,
226                command_count: cmd_count,
227                status: SessionStatus::Completed,
228            }),
229        }
230    }
231
232    fn env_with(pairs: &[(&str, &str)]) -> HashMap<String, String> {
233        pairs
234            .iter()
235            .map(|(k, v)| (k.to_string(), v.to_string()))
236            .collect()
237    }
238
239    #[test]
240    fn test_detect_home_dir() {
241        let session = make_session(
242            env_with(&[("HOME", "/home/alice")]),
243            "unknown",
244            &[("ls /home/alice/docs", "/home/alice")],
245        );
246
247        let params = detect_parameters(&session);
248        assert_eq!(params.len(), 1);
249        assert_eq!(params[0].name, "HOME_DIR");
250        assert_eq!(params[0].original, "/home/alice");
251        assert!(params[0].auto_detected);
252    }
253
254    #[test]
255    fn test_detect_username() {
256        let session = make_session(
257            env_with(&[("USER", "alice")]),
258            "unknown",
259            &[("echo alice", "/tmp")],
260        );
261
262        let params = detect_parameters(&session);
263        assert_eq!(params.len(), 1);
264        assert_eq!(params[0].name, "USERNAME");
265        assert_eq!(params[0].original, "alice");
266    }
267
268    #[test]
269    fn test_detect_hostname() {
270        let session = make_session(env_with(&[]), "myhost", &[("ssh myhost", "/tmp")]);
271
272        let params = detect_parameters(&session);
273        assert_eq!(params.len(), 1);
274        assert_eq!(params[0].name, "HOSTNAME");
275        assert_eq!(params[0].original, "myhost");
276    }
277
278    #[test]
279    fn test_no_detection_when_absent() {
280        // HOME is set but never appears in any command or cwd
281        let session = make_session(
282            env_with(&[("HOME", "/home/alice")]),
283            "unknown",
284            &[("echo hello", "/tmp")],
285        );
286
287        let params = detect_parameters(&session);
288        assert!(params.is_empty());
289    }
290
291    #[test]
292    fn test_manual_placeholder() {
293        let session = make_session(
294            env_with(&[]),
295            "unknown",
296            &[("curl {{DB_HOST}}:5432", "/tmp")],
297        );
298
299        let params = parse_manual_placeholders(&session);
300        assert_eq!(params.len(), 1);
301        assert_eq!(params[0].name, "DB_HOST");
302        assert_eq!(params[0].original, "{{DB_HOST}}");
303        assert!(!params[0].auto_detected);
304    }
305
306    #[test]
307    fn test_apply_parameters() {
308        let params = vec![Parameter {
309            name: "HOME_DIR".to_string(),
310            original: "/home/alice".to_string(),
311            value: None,
312            auto_detected: true,
313        }];
314
315        let result = apply_parameters("ls /home/alice/docs", &params);
316        assert_eq!(result, "ls {{HOME_DIR}}/docs");
317    }
318
319    #[test]
320    fn test_longest_first_replacement() {
321        let params = vec![
322            Parameter {
323                name: "HOME_DIR".to_string(),
324                original: "/home/alice".to_string(),
325                value: None,
326                auto_detected: true,
327            },
328            Parameter {
329                name: "USERNAME".to_string(),
330                original: "alice".to_string(),
331                value: None,
332                auto_detected: true,
333            },
334        ];
335
336        // "/home/alice" (longer) should be replaced first, so "alice" is not
337        // partially matched inside the path
338        let result = apply_parameters("ls /home/alice && whoami | grep alice", &params);
339        assert_eq!(result, "ls {{HOME_DIR}} && whoami | grep {{USERNAME}}");
340    }
341
342    #[test]
343    fn test_detect_all_combines() {
344        let session = make_session(
345            env_with(&[("HOME", "/home/alice")]),
346            "unknown",
347            &[("ls /home/alice && curl {{DB_HOST}}", "/home/alice")],
348        );
349
350        let params = detect_all_parameters(&session);
351        // Should have HOME_DIR (auto) and DB_HOST (manual)
352        assert_eq!(params.len(), 2);
353        assert_eq!(params[0].name, "HOME_DIR");
354        assert!(params[0].auto_detected);
355        assert_eq!(params[1].name, "DB_HOST");
356        assert!(!params[1].auto_detected);
357    }
358
359    #[test]
360    fn test_cwd_scanning() {
361        // HOME appears only in cwd, not in command string
362        let session = make_session(
363            env_with(&[("HOME", "/home/alice")]),
364            "unknown",
365            &[("echo hello", "/home/alice/projects")],
366        );
367
368        let params = detect_parameters(&session);
369        assert_eq!(params.len(), 1);
370        assert_eq!(params[0].name, "HOME_DIR");
371    }
372
373    #[test]
374    fn test_manual_placeholder_skips_auto_names() {
375        // If someone writes {{HOME_DIR}} in a command, it should be skipped
376        // by manual detection since it matches an auto-detected name
377        let session = make_session(
378            env_with(&[]),
379            "unknown",
380            &[("echo {{HOME_DIR}} {{CUSTOM_VAR}}", "/tmp")],
381        );
382
383        let params = parse_manual_placeholders(&session);
384        assert_eq!(params.len(), 1);
385        assert_eq!(params[0].name, "CUSTOM_VAR");
386    }
387
388    #[test]
389    fn test_manual_placeholder_deduplication() {
390        let session = make_session(
391            env_with(&[]),
392            "unknown",
393            &[
394                ("curl {{DB_HOST}}:5432", "/tmp"),
395                ("ping {{DB_HOST}}", "/tmp"),
396            ],
397        );
398
399        let params = parse_manual_placeholders(&session);
400        assert_eq!(params.len(), 1);
401        assert_eq!(params[0].name, "DB_HOST");
402    }
403
404    #[test]
405    fn test_apply_parameters_ignores_manual() {
406        let params = vec![
407            Parameter {
408                name: "HOME_DIR".to_string(),
409                original: "/home/alice".to_string(),
410                value: None,
411                auto_detected: true,
412            },
413            Parameter {
414                name: "DB_HOST".to_string(),
415                original: "{{DB_HOST}}".to_string(),
416                value: None,
417                auto_detected: false,
418            },
419        ];
420
421        // Manual placeholder should stay as-is
422        let result = apply_parameters("ls /home/alice && curl {{DB_HOST}}", &params);
423        assert_eq!(result, "ls {{HOME_DIR}} && curl {{DB_HOST}}");
424    }
425}