1use regex::Regex;
2
3use crate::models::Session;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct Parameter {
8 pub name: String,
10 pub original: String,
12 pub value: Option<String>,
14 pub auto_detected: bool,
16}
17
18#[must_use]
24pub fn detect_parameters(session: &Session) -> Vec<Parameter> {
25 let mut params = Vec::new();
26
27 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 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 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum FormatType {
122 Shell,
124 Make,
126 Markdown,
128}
129
130#[must_use]
136pub fn render_for_format(s: &str, format: FormatType) -> String {
137 match format {
138 FormatType::Shell => {
139 let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").expect("valid regex");
141 re.replace_all(s, |caps: ®ex::Captures| format!("${}", &caps[1]))
142 .into_owned()
143 }
144 FormatType::Make => {
145 let re = regex::Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}").expect("valid regex");
147 re.replace_all(s, |caps: ®ex::Captures| format!("$({})", &caps[1]))
148 .into_owned()
149 }
150 FormatType::Markdown => {
151 s.to_string()
153 }
154 }
155}
156
157#[must_use]
163pub fn apply_parameters(command: &str, params: &[Parameter]) -> String {
164 let mut auto_params: Vec<&Parameter> = params.iter().filter(|p| p.auto_detected).collect();
166
167 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(¶m.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 fn make_session(
189 env: HashMap<String, String>,
190 hostname: &str,
191 commands: &[(&str, &str)], ) -> 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 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", ¶ms);
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 let result = apply_parameters("ls /home/alice && whoami | grep alice", ¶ms);
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 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 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 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 let result = apply_parameters("ls /home/alice && curl {{DB_HOST}}", ¶ms);
423 assert_eq!(result, "ls {{HOME_DIR}} && curl {{DB_HOST}}");
424 }
425}