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    /// `[Isolation] Init: true` (docker run --init, issue #244)
96    pub init: bool,
97}
98
99/// Parse isolation metadata from extra lines
100pub 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        // docker run --init (issue #244)
127        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
145/// Generate isolation metadata lines for timeline format
146pub 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    // Use provided container/screen name or fall back to metadata.session
165    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
196/// Options for creating a start block
197pub 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    /// If true, the command line is omitted from the start block
205    /// (useful when virtual commands will be shown before the actual command)
206    pub defer_command: bool,
207}
208
209/// Create a start block for command execution using timeline format
210pub fn create_start_block(options: &StartBlockOptions) -> String {
211    let mut lines = Vec::new();
212
213    // Header: session and start time
214    lines.push(create_timeline_line("session", options.session_id));
215    lines.push(create_timeline_line("start", options.timestamp));
216
217    // Parse and add isolation metadata if present
218    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    // Empty timeline line before command (always needed for separation)
228    lines.push(create_empty_timeline_line());
229
230    // Command line (unless deferred for virtual command handling)
231    if !options.defer_command {
232        lines.push(create_command_line(options.command));
233    }
234
235    lines.join("\n")
236}
237
238/// Format duration in seconds with appropriate precision
239pub 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        // For durations under 10 seconds, show 3 decimal places
245        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
253/// Options for creating a finish block
254pub 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
266/// Create a finish block for command execution using timeline format
267///
268/// Bottom block ordering rules:
269/// 1. Result marker (✓ or ✗)
270/// 2. finish timestamp
271/// 3. duration
272/// 4. exit code
273/// 5. (repeated isolation metadata, if any)
274/// 6. empty timeline line
275/// 7. log path (always second-to-last)
276/// 8. session ID (always last)
277pub fn create_finish_block(options: &FinishBlockOptions) -> String {
278    let mut lines = Vec::new();
279
280    // Result marker appears first in footer (after program output)
281    lines.push(get_result_marker(options.exit_code).to_string());
282
283    // Finish metadata
284    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    // Repeat isolation metadata if present
296    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    // Empty timeline line before final two entries
305    lines.push(create_empty_timeline_line());
306
307    // Log and session are ALWAYS last (in that order)
308    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
314/// Escape a value for Links notation
315/// Smart quoting: uses single or double quotes based on content
316pub 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        // Has " but not ' → use single quotes
337        format!("'{}'", value)
338    } else if has_single_quotes && !has_double_quotes {
339        // Has ' but not " → use double quotes
340        format!("\"{}\"", value)
341    } else if has_double_quotes && has_single_quotes {
342        // Has both " and ' → choose wrapper with fewer escapes
343        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            // Escape single quotes by doubling them
348            let escaped = value.replace('\'', "''");
349            format!("'{}'", escaped)
350        } else {
351            // Escape double quotes by doubling them
352            let escaped = value.replace('"', "\"\"");
353            format!("\"{}\"", escaped)
354        }
355    } else {
356        // Has colon, parentheses, newlines, or spaces but no quotes
357        format!("\"{}\"", value)
358    }
359}
360
361/// Format a serde_json::Value for Links notation
362pub 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            // For complex types, serialize and quote
370            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        // Issue #67: Session name should be displayed for screen
454        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        // Issue #67: Session name should be displayed for tmux
478        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        // Issue #67: Container name should be displayed for docker
506        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        // Issue #67: The start block should include the session name for reconnection
530        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        // Issue #67: The finish block should include the container name for reconnection
557        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        // Simple value - no quoting needed
576        assert_eq!(escape_for_links_notation("simple"), "simple");
577        // Value with space - needs quoting
578        assert_eq!(escape_for_links_notation("hello world"), "\"hello world\"");
579        // Value with colon - needs quoting
580        assert_eq!(escape_for_links_notation("key:value"), "\"key:value\"");
581    }
582}