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 pub init: bool,
97}
98
99pub fn parse_isolation_metadata(extra_lines: &[&str]) -> IsolationMetadata {
101 let mut metadata = IsolationMetadata::default();
102
103 let env_mode_re = Regex::new(r"\[Isolation\] Environment: (\w+), Mode: (\w+)").unwrap();
104 let session_re = Regex::new(r"\[Isolation\] Session: (.+)").unwrap();
105 let image_re = Regex::new(r"\[Isolation\] Image: (.+)").unwrap();
106 let endpoint_re = Regex::new(r"\[Isolation\] Endpoint: (.+)").unwrap();
107 let user_re = Regex::new(r"\[Isolation\] User: (\w+)").unwrap();
108
109 for line in extra_lines {
110 if let Some(caps) = env_mode_re.captures(line) {
111 metadata.isolation = Some(caps[1].to_string());
112 metadata.mode = Some(caps[2].to_string());
113 continue;
114 }
115
116 if let Some(caps) = session_re.captures(line) {
117 metadata.session = Some(caps[1].to_string());
118 continue;
119 }
120
121 if let Some(caps) = image_re.captures(line) {
122 metadata.image = Some(caps[1].to_string());
123 continue;
124 }
125
126 if *line == "[Isolation] Init: true" {
128 metadata.init = true;
129 continue;
130 }
131
132 if let Some(caps) = endpoint_re.captures(line) {
133 metadata.endpoint = Some(caps[1].to_string());
134 continue;
135 }
136
137 if let Some(caps) = user_re.captures(line) {
138 metadata.user = Some(caps[1].to_string());
139 }
140 }
141
142 metadata
143}
144
145pub fn generate_isolation_lines(
147 metadata: &IsolationMetadata,
148 container_or_screen_name: Option<&str>,
149) -> Vec<String> {
150 let mut lines = Vec::new();
151
152 if let Some(ref isolation) = metadata.isolation {
153 lines.push(create_timeline_line("isolation", isolation));
154 }
155
156 if let Some(ref mode) = metadata.mode {
157 lines.push(create_timeline_line("mode", mode));
158 }
159
160 if let Some(ref image) = metadata.image {
161 lines.push(create_timeline_line("image", image));
162 }
163
164 if let Some(ref isolation) = metadata.isolation {
166 let name = container_or_screen_name
167 .map(String::from)
168 .or_else(|| metadata.session.clone());
169
170 if let Some(name) = name {
171 match isolation.as_str() {
172 "docker" => lines.push(create_timeline_line("container", &name)),
173 "screen" => lines.push(create_timeline_line("screen", &name)),
174 "tmux" => lines.push(create_timeline_line("tmux", &name)),
175 "ssh" => {
176 if let Some(ref endpoint) = metadata.endpoint {
177 lines.push(create_timeline_line("endpoint", endpoint));
178 }
179 }
180 _ => {}
181 }
182 }
183 }
184
185 if metadata.init {
186 lines.push(create_timeline_line("init", "true"));
187 }
188
189 if let Some(ref user) = metadata.user {
190 lines.push(create_timeline_line("user", user));
191 }
192
193 lines
194}
195
196pub struct StartBlockOptions<'a> {
198 pub session_id: &'a str,
199 pub timestamp: &'a str,
200 pub command: &'a str,
201 pub extra_lines: Option<Vec<&'a str>>,
202 pub style: Option<&'a str>,
203 pub width: Option<usize>,
204 pub defer_command: bool,
207}
208
209pub fn create_start_block(options: &StartBlockOptions) -> String {
211 let mut lines = Vec::new();
212
213 lines.push(create_timeline_line("session", options.session_id));
215 lines.push(create_timeline_line("start", options.timestamp));
216
217 if let Some(ref extra) = options.extra_lines {
219 let metadata = parse_isolation_metadata(extra);
220
221 if metadata.isolation.is_some() {
222 lines.push(create_empty_timeline_line());
223 lines.extend(generate_isolation_lines(&metadata, None));
224 }
225 }
226
227 lines.push(create_empty_timeline_line());
229
230 if !options.defer_command {
232 lines.push(create_command_line(options.command));
233 }
234
235 lines.join("\n")
236}
237
238pub fn format_duration(duration_ms: f64) -> String {
240 let seconds = duration_ms / 1000.0;
241 if seconds < 0.001 {
242 "0.001s".to_string()
243 } else if seconds < 10.0 {
244 format!("{:.3}s", seconds)
246 } else if seconds < 100.0 {
247 format!("{:.2}s", seconds)
248 } else {
249 format!("{:.1}s", seconds)
250 }
251}
252
253pub struct FinishBlockOptions<'a> {
255 pub session_id: &'a str,
256 pub timestamp: &'a str,
257 pub exit_code: i32,
258 pub log_path: &'a str,
259 pub duration_ms: Option<f64>,
260 pub result_message: Option<&'a str>,
261 pub extra_lines: Option<Vec<&'a str>>,
262 pub style: Option<&'a str>,
263 pub width: Option<usize>,
264}
265
266pub fn create_finish_block(options: &FinishBlockOptions) -> String {
278 let mut lines = Vec::new();
279
280 lines.push(get_result_marker(options.exit_code).to_string());
282
283 lines.push(create_timeline_line("finish", options.timestamp));
285
286 if let Some(duration_ms) = options.duration_ms {
287 lines.push(create_timeline_line(
288 "duration",
289 &format_duration(duration_ms),
290 ));
291 }
292
293 lines.push(create_timeline_line("exit", &options.exit_code.to_string()));
294
295 if let Some(ref extra) = options.extra_lines {
297 let metadata = parse_isolation_metadata(extra);
298 if metadata.isolation.is_some() {
299 lines.push(create_empty_timeline_line());
300 lines.extend(generate_isolation_lines(&metadata, None));
301 }
302 }
303
304 lines.push(create_empty_timeline_line());
306
307 lines.push(create_timeline_line("log", options.log_path));
309 lines.push(create_timeline_line("session", options.session_id));
310
311 lines.join("\n")
312}
313
314pub fn escape_for_links_notation(value: &str) -> String {
317 let has_colon = value.contains(':');
318 let has_double_quotes = value.contains('"');
319 let has_single_quotes = value.contains('\'');
320 let has_parens = value.contains('(') || value.contains(')');
321 let has_newline = value.contains('\n');
322 let has_space = value.contains(' ');
323
324 let needs_quoting = has_colon
325 || has_double_quotes
326 || has_single_quotes
327 || has_parens
328 || has_newline
329 || has_space;
330
331 if !needs_quoting {
332 return value.to_string();
333 }
334
335 if has_double_quotes && !has_single_quotes {
336 format!("'{}'", value)
338 } else if has_single_quotes && !has_double_quotes {
339 format!("\"{}\"", value)
341 } else if has_double_quotes && has_single_quotes {
342 let double_quote_count = value.matches('"').count();
344 let single_quote_count = value.matches('\'').count();
345
346 if single_quote_count <= double_quote_count {
347 let escaped = value.replace('\'', "''");
349 format!("'{}'", escaped)
350 } else {
351 let escaped = value.replace('"', "\"\"");
353 format!("\"{}\"", escaped)
354 }
355 } else {
356 format!("\"{}\"", value)
358 }
359}
360
361pub fn format_value_for_links_notation(value: &serde_json::Value) -> String {
363 match value {
364 serde_json::Value::Null => "null".to_string(),
365 serde_json::Value::Bool(b) => b.to_string(),
366 serde_json::Value::Number(n) => n.to_string(),
367 serde_json::Value::String(s) => escape_for_links_notation(s),
368 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
369 let s = serde_json::to_string(value).unwrap_or_default();
371 escape_for_links_notation(&s)
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_create_timeline_line() {
382 let line = create_timeline_line("session", "abc123");
383 assert!(line.starts_with("│"));
384 assert!(line.contains("session"));
385 assert!(line.contains("abc123"));
386 }
387
388 #[test]
389 fn test_create_command_line() {
390 let line = create_command_line("echo hello");
391 assert_eq!(line, "$ echo hello");
392 }
393
394 #[test]
395 fn test_parse_isolation_metadata_screen() {
396 let extra_lines = vec![
397 "[Isolation] Environment: screen, Mode: attached",
398 "[Isolation] Session: screen-1234567890-abc123",
399 ];
400 let metadata = parse_isolation_metadata(&extra_lines);
401 assert_eq!(metadata.isolation, Some("screen".to_string()));
402 assert_eq!(metadata.mode, Some("attached".to_string()));
403 assert_eq!(
404 metadata.session,
405 Some("screen-1234567890-abc123".to_string())
406 );
407 }
408
409 #[test]
410 fn test_parse_isolation_metadata_tmux() {
411 let extra_lines = vec![
412 "[Isolation] Environment: tmux, Mode: detached",
413 "[Isolation] Session: tmux-1234567890-xyz789",
414 ];
415 let metadata = parse_isolation_metadata(&extra_lines);
416 assert_eq!(metadata.isolation, Some("tmux".to_string()));
417 assert_eq!(metadata.mode, Some("detached".to_string()));
418 assert_eq!(metadata.session, Some("tmux-1234567890-xyz789".to_string()));
419 }
420
421 #[test]
422 fn test_parse_isolation_metadata_docker() {
423 let extra_lines = vec![
424 "[Isolation] Environment: docker, Mode: attached",
425 "[Isolation] Session: docker-1234567890-def456",
426 "[Isolation] Image: alpine:latest",
427 ];
428 let metadata = parse_isolation_metadata(&extra_lines);
429 assert_eq!(metadata.isolation, Some("docker".to_string()));
430 assert_eq!(metadata.mode, Some("attached".to_string()));
431 assert_eq!(
432 metadata.session,
433 Some("docker-1234567890-def456".to_string())
434 );
435 assert_eq!(metadata.image, Some("alpine:latest".to_string()));
436 }
437
438 #[test]
439 fn test_generate_isolation_lines_screen() {
440 let metadata = IsolationMetadata {
441 isolation: Some("screen".to_string()),
442 mode: Some("attached".to_string()),
443 session: Some("screen-1234567890-abc123".to_string()),
444 ..Default::default()
445 };
446 let lines = generate_isolation_lines(&metadata, None);
447 assert!(lines
448 .iter()
449 .any(|l| l.contains("isolation") && l.contains("screen")));
450 assert!(lines
451 .iter()
452 .any(|l| l.contains("mode") && l.contains("attached")));
453 assert!(
455 lines
456 .iter()
457 .any(|l| l.contains("screen") && l.contains("screen-1234567890-abc123")),
458 "Should display screen session name for reconnection (issue #67)"
459 );
460 }
461
462 #[test]
463 fn test_generate_isolation_lines_tmux() {
464 let metadata = IsolationMetadata {
465 isolation: Some("tmux".to_string()),
466 mode: Some("detached".to_string()),
467 session: Some("tmux-1234567890-xyz789".to_string()),
468 ..Default::default()
469 };
470 let lines = generate_isolation_lines(&metadata, None);
471 assert!(lines
472 .iter()
473 .any(|l| l.contains("isolation") && l.contains("tmux")));
474 assert!(lines
475 .iter()
476 .any(|l| l.contains("mode") && l.contains("detached")));
477 assert!(
479 lines
480 .iter()
481 .any(|l| l.contains("tmux") && l.contains("tmux-1234567890-xyz789")),
482 "Should display tmux session name for reconnection (issue #67)"
483 );
484 }
485
486 #[test]
487 fn test_generate_isolation_lines_docker() {
488 let metadata = IsolationMetadata {
489 isolation: Some("docker".to_string()),
490 mode: Some("attached".to_string()),
491 session: Some("docker-1234567890-def456".to_string()),
492 image: Some("alpine:latest".to_string()),
493 ..Default::default()
494 };
495 let lines = generate_isolation_lines(&metadata, None);
496 assert!(lines
497 .iter()
498 .any(|l| l.contains("isolation") && l.contains("docker")));
499 assert!(lines
500 .iter()
501 .any(|l| l.contains("mode") && l.contains("attached")));
502 assert!(lines
503 .iter()
504 .any(|l| l.contains("image") && l.contains("alpine:latest")));
505 assert!(
507 lines
508 .iter()
509 .any(|l| l.contains("container") && l.contains("docker-1234567890-def456")),
510 "Should display docker container name for reconnection (issue #67)"
511 );
512 }
513
514 #[test]
515 fn test_create_start_block_with_isolation() {
516 let extra_lines: Vec<&str> = vec![
517 "[Isolation] Environment: screen, Mode: attached",
518 "[Isolation] Session: screen-1234567890-test",
519 ];
520 let block = create_start_block(&StartBlockOptions {
521 session_id: "uuid-123",
522 timestamp: "2026-01-08 12:00:00",
523 command: "echo hello",
524 extra_lines: Some(extra_lines),
525 style: None,
526 width: None,
527 defer_command: false,
528 });
529 assert!(block.contains("│ session uuid-123"));
531 assert!(block.contains("│ isolation screen"));
532 assert!(
533 block.contains("│ screen screen-1234567890-test"),
534 "Start block should display screen session name for reconnection (issue #67)"
535 );
536 }
537
538 #[test]
539 fn test_create_finish_block_with_isolation() {
540 let extra_lines: Vec<&str> = vec![
541 "[Isolation] Environment: docker, Mode: attached",
542 "[Isolation] Session: docker-1234567890-test",
543 "[Isolation] Image: alpine:latest",
544 ];
545 let block = create_finish_block(&FinishBlockOptions {
546 session_id: "uuid-456",
547 timestamp: "2026-01-08 12:00:01",
548 exit_code: 0,
549 log_path: "/tmp/test.log",
550 duration_ms: Some(100.0),
551 result_message: None,
552 extra_lines: Some(extra_lines),
553 style: None,
554 width: None,
555 });
556 assert!(block.contains("✓"));
558 assert!(block.contains("│ session uuid-456"));
559 assert!(
560 block.contains("│ container docker-1234567890-test"),
561 "Finish block should display docker container name for reconnection (issue #67)"
562 );
563 }
564
565 #[test]
566 fn test_format_duration() {
567 assert_eq!(format_duration(500.0), "0.500s");
568 assert_eq!(format_duration(1500.0), "1.500s");
569 assert_eq!(format_duration(15000.0), "15.00s");
570 assert_eq!(format_duration(150000.0), "150.0s");
571 }
572
573 #[test]
574 fn test_escape_for_links_notation() {
575 assert_eq!(escape_for_links_notation("simple"), "simple");
577 assert_eq!(escape_for_links_notation("hello world"), "\"hello world\"");
579 assert_eq!(escape_for_links_notation("key:value"), "\"key:value\"");
581 }
582}