1use regex::Regex;
13
14pub const TIMELINE_MARKER: &str = "│";
17
18#[deprecated(since = "0.20.0", note = "Use TIMELINE_MARKER instead")]
20pub const SPINE: &str = "│";
21
22pub const SUCCESS_MARKER: &str = "✓";
24
25pub const FAILURE_MARKER: &str = "✗";
27
28pub fn create_timeline_line(label: &str, value: &str) -> String {
30 format!("{} {:10}{}", TIMELINE_MARKER, label, value)
32}
33
34#[deprecated(since = "0.20.0", note = "Use create_timeline_line instead")]
36pub fn create_spine_line(label: &str, value: &str) -> String {
37 create_timeline_line(label, value)
38}
39
40pub fn create_empty_timeline_line() -> String {
42 TIMELINE_MARKER.to_string()
43}
44
45#[deprecated(since = "0.20.0", note = "Use create_empty_timeline_line instead")]
47pub fn create_empty_spine_line() -> String {
48 create_empty_timeline_line()
49}
50
51pub fn create_command_line(command: &str) -> String {
53 format!("$ {}", command)
54}
55
56pub fn create_virtual_command_block(command: &str) -> String {
60 create_command_line(command)
61}
62
63pub fn create_virtual_command_result(success: bool) -> String {
65 if success {
66 SUCCESS_MARKER.to_string()
67 } else {
68 FAILURE_MARKER.to_string()
69 }
70}
71
72pub fn create_timeline_separator() -> String {
74 create_empty_timeline_line()
75}
76
77pub fn get_result_marker(exit_code: i32) -> &'static str {
79 if exit_code == 0 {
80 SUCCESS_MARKER
81 } else {
82 FAILURE_MARKER
83 }
84}
85
86#[derive(Default)]
88pub struct IsolationMetadata {
89 pub isolation: Option<String>,
90 pub mode: Option<String>,
91 pub image: Option<String>,
92 pub session: Option<String>,
93 pub endpoint: Option<String>,
94 pub user: Option<String>,
95}
96
97pub fn parse_isolation_metadata(extra_lines: &[&str]) -> IsolationMetadata {
99 let mut metadata = IsolationMetadata::default();
100
101 let env_mode_re = Regex::new(r"\[Isolation\] Environment: (\w+), Mode: (\w+)").unwrap();
102 let session_re = Regex::new(r"\[Isolation\] Session: (.+)").unwrap();
103 let image_re = Regex::new(r"\[Isolation\] Image: (.+)").unwrap();
104 let endpoint_re = Regex::new(r"\[Isolation\] Endpoint: (.+)").unwrap();
105 let user_re = Regex::new(r"\[Isolation\] User: (\w+)").unwrap();
106
107 for line in extra_lines {
108 if let Some(caps) = env_mode_re.captures(line) {
109 metadata.isolation = Some(caps[1].to_string());
110 metadata.mode = Some(caps[2].to_string());
111 continue;
112 }
113
114 if let Some(caps) = session_re.captures(line) {
115 metadata.session = Some(caps[1].to_string());
116 continue;
117 }
118
119 if let Some(caps) = image_re.captures(line) {
120 metadata.image = Some(caps[1].to_string());
121 continue;
122 }
123
124 if let Some(caps) = endpoint_re.captures(line) {
125 metadata.endpoint = Some(caps[1].to_string());
126 continue;
127 }
128
129 if let Some(caps) = user_re.captures(line) {
130 metadata.user = Some(caps[1].to_string());
131 }
132 }
133
134 metadata
135}
136
137pub fn generate_isolation_lines(
139 metadata: &IsolationMetadata,
140 container_or_screen_name: Option<&str>,
141) -> Vec<String> {
142 let mut lines = Vec::new();
143
144 if let Some(ref isolation) = metadata.isolation {
145 lines.push(create_timeline_line("isolation", isolation));
146 }
147
148 if let Some(ref mode) = metadata.mode {
149 lines.push(create_timeline_line("mode", mode));
150 }
151
152 if let Some(ref image) = metadata.image {
153 lines.push(create_timeline_line("image", image));
154 }
155
156 if let Some(ref isolation) = metadata.isolation {
158 let name = container_or_screen_name
159 .map(String::from)
160 .or_else(|| metadata.session.clone());
161
162 if let Some(name) = name {
163 match isolation.as_str() {
164 "docker" => lines.push(create_timeline_line("container", &name)),
165 "screen" => lines.push(create_timeline_line("screen", &name)),
166 "tmux" => lines.push(create_timeline_line("tmux", &name)),
167 "ssh" => {
168 if let Some(ref endpoint) = metadata.endpoint {
169 lines.push(create_timeline_line("endpoint", endpoint));
170 }
171 }
172 _ => {}
173 }
174 }
175 }
176
177 if let Some(ref user) = metadata.user {
178 lines.push(create_timeline_line("user", user));
179 }
180
181 lines
182}
183
184pub struct StartBlockOptions<'a> {
186 pub session_id: &'a str,
187 pub timestamp: &'a str,
188 pub command: &'a str,
189 pub extra_lines: Option<Vec<&'a str>>,
190 pub style: Option<&'a str>,
191 pub width: Option<usize>,
192 pub defer_command: bool,
195}
196
197pub fn create_start_block(options: &StartBlockOptions) -> String {
199 let mut lines = Vec::new();
200
201 lines.push(create_timeline_line("session", options.session_id));
203 lines.push(create_timeline_line("start", options.timestamp));
204
205 if let Some(ref extra) = options.extra_lines {
207 let metadata = parse_isolation_metadata(extra);
208
209 if metadata.isolation.is_some() {
210 lines.push(create_empty_timeline_line());
211 lines.extend(generate_isolation_lines(&metadata, None));
212 }
213 }
214
215 lines.push(create_empty_timeline_line());
217
218 if !options.defer_command {
220 lines.push(create_command_line(options.command));
221 }
222
223 lines.join("\n")
224}
225
226pub fn format_duration(duration_ms: f64) -> String {
228 let seconds = duration_ms / 1000.0;
229 if seconds < 0.001 {
230 "0.001s".to_string()
231 } else if seconds < 10.0 {
232 format!("{:.3}s", seconds)
234 } else if seconds < 100.0 {
235 format!("{:.2}s", seconds)
236 } else {
237 format!("{:.1}s", seconds)
238 }
239}
240
241pub struct FinishBlockOptions<'a> {
243 pub session_id: &'a str,
244 pub timestamp: &'a str,
245 pub exit_code: i32,
246 pub log_path: &'a str,
247 pub duration_ms: Option<f64>,
248 pub result_message: Option<&'a str>,
249 pub extra_lines: Option<Vec<&'a str>>,
250 pub style: Option<&'a str>,
251 pub width: Option<usize>,
252}
253
254pub fn create_finish_block(options: &FinishBlockOptions) -> String {
266 let mut lines = Vec::new();
267
268 lines.push(get_result_marker(options.exit_code).to_string());
270
271 lines.push(create_timeline_line("finish", options.timestamp));
273
274 if let Some(duration_ms) = options.duration_ms {
275 lines.push(create_timeline_line(
276 "duration",
277 &format_duration(duration_ms),
278 ));
279 }
280
281 lines.push(create_timeline_line("exit", &options.exit_code.to_string()));
282
283 if let Some(ref extra) = options.extra_lines {
285 let metadata = parse_isolation_metadata(extra);
286 if metadata.isolation.is_some() {
287 lines.push(create_empty_timeline_line());
288 lines.extend(generate_isolation_lines(&metadata, None));
289 }
290 }
291
292 lines.push(create_empty_timeline_line());
294
295 lines.push(create_timeline_line("log", options.log_path));
297 lines.push(create_timeline_line("session", options.session_id));
298
299 lines.join("\n")
300}
301
302pub fn escape_for_links_notation(value: &str) -> String {
305 let has_colon = value.contains(':');
306 let has_double_quotes = value.contains('"');
307 let has_single_quotes = value.contains('\'');
308 let has_parens = value.contains('(') || value.contains(')');
309 let has_newline = value.contains('\n');
310 let has_space = value.contains(' ');
311
312 let needs_quoting = has_colon
313 || has_double_quotes
314 || has_single_quotes
315 || has_parens
316 || has_newline
317 || has_space;
318
319 if !needs_quoting {
320 return value.to_string();
321 }
322
323 if has_double_quotes && !has_single_quotes {
324 format!("'{}'", value)
326 } else if has_single_quotes && !has_double_quotes {
327 format!("\"{}\"", value)
329 } else if has_double_quotes && has_single_quotes {
330 let double_quote_count = value.matches('"').count();
332 let single_quote_count = value.matches('\'').count();
333
334 if single_quote_count <= double_quote_count {
335 let escaped = value.replace('\'', "''");
337 format!("'{}'", escaped)
338 } else {
339 let escaped = value.replace('"', "\"\"");
341 format!("\"{}\"", escaped)
342 }
343 } else {
344 format!("\"{}\"", value)
346 }
347}
348
349pub fn format_value_for_links_notation(value: &serde_json::Value) -> String {
351 match value {
352 serde_json::Value::Null => "null".to_string(),
353 serde_json::Value::Bool(b) => b.to_string(),
354 serde_json::Value::Number(n) => n.to_string(),
355 serde_json::Value::String(s) => escape_for_links_notation(s),
356 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
357 let s = serde_json::to_string(value).unwrap_or_default();
359 escape_for_links_notation(&s)
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_create_timeline_line() {
370 let line = create_timeline_line("session", "abc123");
371 assert!(line.starts_with("│"));
372 assert!(line.contains("session"));
373 assert!(line.contains("abc123"));
374 }
375
376 #[test]
377 fn test_create_command_line() {
378 let line = create_command_line("echo hello");
379 assert_eq!(line, "$ echo hello");
380 }
381
382 #[test]
383 fn test_parse_isolation_metadata_screen() {
384 let extra_lines = vec![
385 "[Isolation] Environment: screen, Mode: attached",
386 "[Isolation] Session: screen-1234567890-abc123",
387 ];
388 let metadata = parse_isolation_metadata(&extra_lines);
389 assert_eq!(metadata.isolation, Some("screen".to_string()));
390 assert_eq!(metadata.mode, Some("attached".to_string()));
391 assert_eq!(
392 metadata.session,
393 Some("screen-1234567890-abc123".to_string())
394 );
395 }
396
397 #[test]
398 fn test_parse_isolation_metadata_tmux() {
399 let extra_lines = vec![
400 "[Isolation] Environment: tmux, Mode: detached",
401 "[Isolation] Session: tmux-1234567890-xyz789",
402 ];
403 let metadata = parse_isolation_metadata(&extra_lines);
404 assert_eq!(metadata.isolation, Some("tmux".to_string()));
405 assert_eq!(metadata.mode, Some("detached".to_string()));
406 assert_eq!(metadata.session, Some("tmux-1234567890-xyz789".to_string()));
407 }
408
409 #[test]
410 fn test_parse_isolation_metadata_docker() {
411 let extra_lines = vec![
412 "[Isolation] Environment: docker, Mode: attached",
413 "[Isolation] Session: docker-1234567890-def456",
414 "[Isolation] Image: alpine:latest",
415 ];
416 let metadata = parse_isolation_metadata(&extra_lines);
417 assert_eq!(metadata.isolation, Some("docker".to_string()));
418 assert_eq!(metadata.mode, Some("attached".to_string()));
419 assert_eq!(
420 metadata.session,
421 Some("docker-1234567890-def456".to_string())
422 );
423 assert_eq!(metadata.image, Some("alpine:latest".to_string()));
424 }
425
426 #[test]
427 fn test_generate_isolation_lines_screen() {
428 let metadata = IsolationMetadata {
429 isolation: Some("screen".to_string()),
430 mode: Some("attached".to_string()),
431 session: Some("screen-1234567890-abc123".to_string()),
432 ..Default::default()
433 };
434 let lines = generate_isolation_lines(&metadata, None);
435 assert!(lines
436 .iter()
437 .any(|l| l.contains("isolation") && l.contains("screen")));
438 assert!(lines
439 .iter()
440 .any(|l| l.contains("mode") && l.contains("attached")));
441 assert!(
443 lines
444 .iter()
445 .any(|l| l.contains("screen") && l.contains("screen-1234567890-abc123")),
446 "Should display screen session name for reconnection (issue #67)"
447 );
448 }
449
450 #[test]
451 fn test_generate_isolation_lines_tmux() {
452 let metadata = IsolationMetadata {
453 isolation: Some("tmux".to_string()),
454 mode: Some("detached".to_string()),
455 session: Some("tmux-1234567890-xyz789".to_string()),
456 ..Default::default()
457 };
458 let lines = generate_isolation_lines(&metadata, None);
459 assert!(lines
460 .iter()
461 .any(|l| l.contains("isolation") && l.contains("tmux")));
462 assert!(lines
463 .iter()
464 .any(|l| l.contains("mode") && l.contains("detached")));
465 assert!(
467 lines
468 .iter()
469 .any(|l| l.contains("tmux") && l.contains("tmux-1234567890-xyz789")),
470 "Should display tmux session name for reconnection (issue #67)"
471 );
472 }
473
474 #[test]
475 fn test_generate_isolation_lines_docker() {
476 let metadata = IsolationMetadata {
477 isolation: Some("docker".to_string()),
478 mode: Some("attached".to_string()),
479 session: Some("docker-1234567890-def456".to_string()),
480 image: Some("alpine:latest".to_string()),
481 ..Default::default()
482 };
483 let lines = generate_isolation_lines(&metadata, None);
484 assert!(lines
485 .iter()
486 .any(|l| l.contains("isolation") && l.contains("docker")));
487 assert!(lines
488 .iter()
489 .any(|l| l.contains("mode") && l.contains("attached")));
490 assert!(lines
491 .iter()
492 .any(|l| l.contains("image") && l.contains("alpine:latest")));
493 assert!(
495 lines
496 .iter()
497 .any(|l| l.contains("container") && l.contains("docker-1234567890-def456")),
498 "Should display docker container name for reconnection (issue #67)"
499 );
500 }
501
502 #[test]
503 fn test_create_start_block_with_isolation() {
504 let extra_lines: Vec<&str> = vec![
505 "[Isolation] Environment: screen, Mode: attached",
506 "[Isolation] Session: screen-1234567890-test",
507 ];
508 let block = create_start_block(&StartBlockOptions {
509 session_id: "uuid-123",
510 timestamp: "2026-01-08 12:00:00",
511 command: "echo hello",
512 extra_lines: Some(extra_lines),
513 style: None,
514 width: None,
515 defer_command: false,
516 });
517 assert!(block.contains("│ session uuid-123"));
519 assert!(block.contains("│ isolation screen"));
520 assert!(
521 block.contains("│ screen screen-1234567890-test"),
522 "Start block should display screen session name for reconnection (issue #67)"
523 );
524 }
525
526 #[test]
527 fn test_create_finish_block_with_isolation() {
528 let extra_lines: Vec<&str> = vec![
529 "[Isolation] Environment: docker, Mode: attached",
530 "[Isolation] Session: docker-1234567890-test",
531 "[Isolation] Image: alpine:latest",
532 ];
533 let block = create_finish_block(&FinishBlockOptions {
534 session_id: "uuid-456",
535 timestamp: "2026-01-08 12:00:01",
536 exit_code: 0,
537 log_path: "/tmp/test.log",
538 duration_ms: Some(100.0),
539 result_message: None,
540 extra_lines: Some(extra_lines),
541 style: None,
542 width: None,
543 });
544 assert!(block.contains("✓"));
546 assert!(block.contains("│ session uuid-456"));
547 assert!(
548 block.contains("│ container docker-1234567890-test"),
549 "Finish block should display docker container name for reconnection (issue #67)"
550 );
551 }
552
553 #[test]
554 fn test_format_duration() {
555 assert_eq!(format_duration(500.0), "0.500s");
556 assert_eq!(format_duration(1500.0), "1.500s");
557 assert_eq!(format_duration(15000.0), "15.00s");
558 assert_eq!(format_duration(150000.0), "150.0s");
559 }
560
561 #[test]
562 fn test_escape_for_links_notation() {
563 assert_eq!(escape_for_links_notation("simple"), "simple");
565 assert_eq!(escape_for_links_notation("hello world"), "\"hello world\"");
567 assert_eq!(escape_for_links_notation("key:value"), "\"key:value\"");
569 }
570}