Skip to main content

start_command/
output_blocks.rs

1//! Output formatting utilities for nicely rendered command blocks
2//!
3//! Provides "timeline" format: a width-independent, lossless output format
4//! that works in TTY, tmux, SSH, CI, and logs.
5//!
6//! Core concepts:
7//! - `│` prefix → tool metadata (timeline marker)
8//! - `$` → executed command (virtual or user command)
9//! - No prefix → program output (stdout/stderr)
10//! - Result marker (`✓` / `✗`) appears after output
11
12use regex::Regex;
13
14/// Timeline marker character (formerly called "spine")
15/// Used to prefix metadata lines in the timeline format
16pub const TIMELINE_MARKER: &str = "│";
17
18/// Alias for backward compatibility
19#[deprecated(since = "0.20.0", note = "Use TIMELINE_MARKER instead")]
20pub const SPINE: &str = "│";
21
22/// Success result marker
23pub const SUCCESS_MARKER: &str = "✓";
24
25/// Failure result marker
26pub const FAILURE_MARKER: &str = "✗";
27
28/// Create a metadata line with timeline marker prefix
29pub fn create_timeline_line(label: &str, value: &str) -> String {
30    // Pad label to 10 characters for alignment
31    format!("{} {:10}{}", TIMELINE_MARKER, label, value)
32}
33
34/// Alias for backward compatibility
35#[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
40/// Create an empty timeline line (just the timeline marker character)
41pub fn create_empty_timeline_line() -> String {
42    TIMELINE_MARKER.to_string()
43}
44
45/// Alias for backward compatibility
46#[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
51/// Create a command line with $ prefix
52pub fn create_command_line(command: &str) -> String {
53    format!("$ {}", command)
54}
55
56/// Create a virtual command block for setup steps (like docker pull)
57/// Virtual commands are displayed separately in the timeline to show
58/// intermediate steps that the tool performs automatically
59pub fn create_virtual_command_block(command: &str) -> String {
60    create_command_line(command)
61}
62
63/// Create a result marker line for a virtual command
64pub 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
72/// Create a separator line between virtual commands and user commands
73pub fn create_timeline_separator() -> String {
74    create_empty_timeline_line()
75}
76
77/// Get the result marker based on exit code
78pub 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/// Parsed isolation metadata
87#[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
97/// Parse isolation metadata from extra lines
98pub 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
137/// Generate isolation metadata lines for timeline format
138pub 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    // Use provided container/screen name or fall back to metadata.session
157    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
184/// Options for creating a start block
185pub 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    /// If true, the command line is omitted from the start block
193    /// (useful when virtual commands will be shown before the actual command)
194    pub defer_command: bool,
195}
196
197/// Create a start block for command execution using timeline format
198pub fn create_start_block(options: &StartBlockOptions) -> String {
199    let mut lines = Vec::new();
200
201    // Header: session and start time
202    lines.push(create_timeline_line("session", options.session_id));
203    lines.push(create_timeline_line("start", options.timestamp));
204
205    // Parse and add isolation metadata if present
206    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    // Empty timeline line before command (always needed for separation)
216    lines.push(create_empty_timeline_line());
217
218    // Command line (unless deferred for virtual command handling)
219    if !options.defer_command {
220        lines.push(create_command_line(options.command));
221    }
222
223    lines.join("\n")
224}
225
226/// Format duration in seconds with appropriate precision
227pub 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        // For durations under 10 seconds, show 3 decimal places
233        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
241/// Options for creating a finish block
242pub 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
254/// Create a finish block for command execution using timeline format
255///
256/// Bottom block ordering rules:
257/// 1. Result marker (✓ or ✗)
258/// 2. finish timestamp
259/// 3. duration
260/// 4. exit code
261/// 5. (repeated isolation metadata, if any)
262/// 6. empty timeline line
263/// 7. log path (always second-to-last)
264/// 8. session ID (always last)
265pub fn create_finish_block(options: &FinishBlockOptions) -> String {
266    let mut lines = Vec::new();
267
268    // Result marker appears first in footer (after program output)
269    lines.push(get_result_marker(options.exit_code).to_string());
270
271    // Finish metadata
272    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    // Repeat isolation metadata if present
284    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    // Empty timeline line before final two entries
293    lines.push(create_empty_timeline_line());
294
295    // Log and session are ALWAYS last (in that order)
296    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
302/// Escape a value for Links notation
303/// Smart quoting: uses single or double quotes based on content
304pub 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        // Has " but not ' → use single quotes
325        format!("'{}'", value)
326    } else if has_single_quotes && !has_double_quotes {
327        // Has ' but not " → use double quotes
328        format!("\"{}\"", value)
329    } else if has_double_quotes && has_single_quotes {
330        // Has both " and ' → choose wrapper with fewer escapes
331        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            // Escape single quotes by doubling them
336            let escaped = value.replace('\'', "''");
337            format!("'{}'", escaped)
338        } else {
339            // Escape double quotes by doubling them
340            let escaped = value.replace('"', "\"\"");
341            format!("\"{}\"", escaped)
342        }
343    } else {
344        // Has colon, parentheses, newlines, or spaces but no quotes
345        format!("\"{}\"", value)
346    }
347}
348
349/// Format a serde_json::Value for Links notation
350pub 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            // For complex types, serialize and quote
358            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        // Issue #67: Session name should be displayed for screen
442        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        // Issue #67: Session name should be displayed for tmux
466        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        // Issue #67: Container name should be displayed for docker
494        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        // Issue #67: The start block should include the session name for reconnection
518        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        // Issue #67: The finish block should include the container name for reconnection
545        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        // Simple value - no quoting needed
564        assert_eq!(escape_for_links_notation("simple"), "simple");
565        // Value with space - needs quoting
566        assert_eq!(escape_for_links_notation("hello world"), "\"hello world\"");
567        // Value with colon - needs quoting
568        assert_eq!(escape_for_links_notation("key:value"), "\"key:value\"");
569    }
570}