print_break/
lib.rs

1//! # print-break
2//!
3//! A simple debugging macro that pretty-prints variables and pauses execution.
4//! No debugger needed - just prints to stderr and waits for Enter.
5//!
6//! ## Features
7//!
8//! - Pretty-prints any `Debug` type with syntax highlighting
9//! - Auto-detects and formats JSON, TOML, YAML strings with colors
10//! - Shows file:line location and elapsed time between breakpoints
11//! - Pauses execution until you press Enter
12//! - **Compiles to nothing in release builds**
13//! - **Disable at runtime with `PRINT_BREAK=0`**
14//! - **Non-TTY safe** - won't hang in CI/piped output
15//!
16//! ## Usage
17//!
18//! ```rust,no_run
19//! use print_break::print_break;
20//!
21//! let x = 42;
22//! let name = "ferris";
23//! let json = r#"{"user": "alice", "id": 123}"#;
24//!
25//! print_break!(x, name, json);
26//! ```
27//!
28//! ## Environment Variables
29//!
30//! - `PRINT_BREAK=0` - Disable all breakpoints
31//! - `PRINT_BREAK=1` - Enable breakpoints (default)
32//! - `PRINT_BREAK_DEPTH=N` - Max nesting depth before collapsing (default: 4)
33//! - `PRINT_BREAK_STYLE=X` - Border style: `rounded`, `sharp`, `double`, `ascii`
34//!
35//! ## Interactive Controls
36//!
37//! When paused at a breakpoint:
38//! - **Enter** - Continue to next breakpoint
39//! - **m** - Show full output (if truncated)
40//! - **t** - Show stack trace
41//! - **c** - Copy value to clipboard
42//! - **s** - Skip all remaining breakpoints
43//! - **q** - Quit the program immediately
44//! - **h / ?** - Show help
45
46use std::fmt::Debug;
47use std::io::IsTerminal;
48use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
49use std::sync::Mutex;
50use std::time::Instant;
51
52/// Global flag to skip all remaining breakpoints
53static SKIP_ALL: AtomicBool = AtomicBool::new(false);
54
55/// Global breakpoint counter
56static BREAK_COUNT: AtomicUsize = AtomicUsize::new(0);
57
58/// Last breakpoint timestamp for elapsed time
59static LAST_BREAK_TIME: Mutex<Option<Instant>> = Mutex::new(None);
60
61/// Border style characters
62#[derive(Clone, Copy)]
63pub struct BorderStyle {
64    pub top_left: char,
65    pub top_right: char,
66    pub bottom_left: char,
67    pub bottom_right: char,
68    pub horizontal: char,
69    pub vertical: char,
70    pub tee_right: char,
71}
72
73impl BorderStyle {
74    pub const ROUNDED: Self = Self {
75        top_left: '╭',
76        top_right: '╮',
77        bottom_left: '╰',
78        bottom_right: '╯',
79        horizontal: '─',
80        vertical: '│',
81        tee_right: '├',
82    };
83
84    pub const SHARP: Self = Self {
85        top_left: '┌',
86        top_right: '┐',
87        bottom_left: '└',
88        bottom_right: '┘',
89        horizontal: '─',
90        vertical: '│',
91        tee_right: '├',
92    };
93
94    pub const DOUBLE: Self = Self {
95        top_left: '╔',
96        top_right: '╗',
97        bottom_left: '╚',
98        bottom_right: '╝',
99        horizontal: '═',
100        vertical: '║',
101        tee_right: '╠',
102    };
103
104    pub const ASCII: Self = Self {
105        top_left: '+',
106        top_right: '+',
107        bottom_left: '+',
108        bottom_right: '+',
109        horizontal: '-',
110        vertical: '|',
111        tee_right: '+',
112    };
113}
114
115/// Get border style from environment variable
116#[doc(hidden)]
117pub fn get_border_style() -> BorderStyle {
118    match std::env::var("PRINT_BREAK_STYLE").as_deref() {
119        Ok("round") | Ok("rounded") => BorderStyle::ROUNDED,
120        Ok("sharp") => BorderStyle::SHARP,
121        Ok("double") => BorderStyle::DOUBLE,
122        Ok("ascii") => BorderStyle::ASCII,
123        _ => BorderStyle::ROUNDED,
124    }
125}
126
127// ============================================================================
128// Colors - Centralized ANSI color codes
129// ============================================================================
130
131/// ANSI color codes for terminal output
132#[derive(Clone, Copy)]
133pub struct Colors {
134    pub green: &'static str,
135    pub cyan: &'static str,
136    pub yellow: &'static str,
137    pub magenta: &'static str,
138    pub white: &'static str,
139    pub gray: &'static str,
140    pub red: &'static str,
141    pub reset: &'static str,
142}
143
144impl Colors {
145    const TTY: Self = Self {
146        green: "\x1b[1;32m",
147        cyan: "\x1b[36m",
148        yellow: "\x1b[1;33m",
149        magenta: "\x1b[35m",
150        white: "\x1b[37m",
151        gray: "\x1b[90m",
152        red: "\x1b[1;31m",
153        reset: "\x1b[0m",
154    };
155
156    const PLAIN: Self = Self {
157        green: "",
158        cyan: "",
159        yellow: "",
160        magenta: "",
161        white: "",
162        gray: "",
163        red: "",
164        reset: "",
165    };
166
167    /// Get colors based on TTY detection
168    #[inline]
169    pub fn get() -> Self {
170        if is_tty() { Self::TTY } else { Self::PLAIN }
171    }
172}
173
174/// Format elapsed duration for display
175#[doc(hidden)]
176pub fn format_elapsed(d: std::time::Duration) -> String {
177    let c = Colors::get();
178    let micros = d.as_micros();
179    if micros < 1000 {
180        format!(" {}+{}µs{}", c.gray, micros, c.reset)
181    } else if micros < 1_000_000 {
182        format!(" {}+{:.1}ms{}", c.gray, micros as f64 / 1000.0, c.reset)
183    } else {
184        format!(" {}+{:.2}s{}", c.gray, micros as f64 / 1_000_000.0, c.reset)
185    }
186}
187
188/// Get elapsed time since last breakpoint
189#[doc(hidden)]
190pub fn get_elapsed() -> Option<std::time::Duration> {
191    if let Ok(guard) = LAST_BREAK_TIME.lock() {
192        guard.map(|t| t.elapsed())
193    } else {
194        None
195    }
196}
197
198/// Update last breakpoint time
199#[doc(hidden)]
200pub fn update_break_time() {
201    if let Ok(mut guard) = LAST_BREAK_TIME.lock() {
202        *guard = Some(Instant::now());
203    }
204}
205
206/// Maximum lines to show before truncating
207const MAX_LINES: usize = 50;
208
209/// Colorize JSON output
210fn colorize_json(s: &str) -> String {
211    let c = Colors::get();
212    if c.cyan.is_empty() {
213        return s.to_string();
214    }
215
216    let (cyan, magenta, yellow, gray, reset) = (c.cyan, c.magenta, c.yellow, c.gray, c.reset);
217
218    let mut result = String::new();
219    let mut in_string = false;
220    let mut is_key = true;
221    let mut chars = s.chars().peekable();
222    // Track nesting: true = object (has keys), false = array (no keys)
223    let mut context_stack: Vec<bool> = Vec::new();
224
225    while let Some(c) = chars.next() {
226        match c {
227            '"' if !in_string => {
228                in_string = true;
229                let color = if is_key { cyan } else { magenta };
230                result.push_str(color);
231                result.push('"');
232            }
233            '"' if in_string => {
234                result.push('"');
235                result.push_str(reset);
236                in_string = false;
237            }
238            ':' if !in_string => {
239                result.push_str(gray);
240                result.push(':');
241                result.push_str(reset);
242                is_key = false;
243            }
244            ',' if !in_string => {
245                result.push_str(gray);
246                result.push(',');
247                result.push_str(reset);
248                // After comma, next string is a key only if we're in an object
249                is_key = context_stack.last().copied().unwrap_or(true);
250            }
251            '{' | '[' if !in_string => {
252                result.push_str(gray);
253                result.push(c);
254                result.push_str(reset);
255                let is_object = c == '{';
256                context_stack.push(is_object);
257                is_key = is_object;
258            }
259            '}' | ']' if !in_string => {
260                result.push_str(gray);
261                result.push(c);
262                result.push_str(reset);
263                context_stack.pop();
264            }
265            '0'..='9' | '-' | '.' if !in_string => {
266                result.push_str(yellow);
267                result.push(c);
268                // Continue collecting the number
269                while let Some(&next) = chars.peek() {
270                    if next.is_ascii_digit() || next == '.' || next == 'e' || next == 'E' || next == '+' || next == '-' {
271                        result.push(chars.next().unwrap());
272                    } else {
273                        break;
274                    }
275                }
276                result.push_str(reset);
277            }
278            't' if !in_string => {
279                // Check for "true"
280                let rest: String = chars.by_ref().take(3).collect();
281                if rest == "rue" {
282                    result.push_str(yellow);
283                    result.push_str("true");
284                    result.push_str(reset);
285                } else {
286                    result.push('t');
287                    result.push_str(&rest);
288                }
289            }
290            'f' if !in_string => {
291                // Check for "false"
292                let rest: String = chars.by_ref().take(4).collect();
293                if rest == "alse" {
294                    result.push_str(yellow);
295                    result.push_str("false");
296                    result.push_str(reset);
297                } else {
298                    result.push('f');
299                    result.push_str(&rest);
300                }
301            }
302            'n' if !in_string => {
303                // Check for "null"
304                let rest: String = chars.by_ref().take(3).collect();
305                if rest == "ull" {
306                    result.push_str(yellow);
307                    result.push_str("null");
308                    result.push_str(reset);
309                } else {
310                    result.push('n');
311                    result.push_str(&rest);
312                }
313            }
314            _ => result.push(c),
315        }
316    }
317
318    result
319}
320
321/// Colorize TOML output
322fn colorize_toml(s: &str) -> String {
323    let c = Colors::get();
324    if c.cyan.is_empty() {
325        return s.to_string();
326    }
327
328    let (green, cyan, magenta, yellow, gray, reset) =
329        (c.green, c.cyan, c.magenta, c.yellow, c.gray, c.reset);
330
331    let mut result = String::new();
332
333    for line in s.lines() {
334        let trimmed = line.trim();
335
336        if trimmed.starts_with('[') && trimmed.ends_with(']') {
337            // Section header
338            result.push_str(green);
339            result.push_str(line);
340            result.push_str(reset);
341        } else if let Some(eq_pos) = trimmed.find(" = ") {
342            // Key = value
343            let indent = &line[..line.len() - trimmed.len()];
344            let key = &trimmed[..eq_pos];
345            let value = &trimmed[eq_pos + 3..];
346
347            result.push_str(indent);
348            result.push_str(cyan);
349            result.push_str(key);
350            result.push_str(reset);
351            result.push_str(gray);
352            result.push_str(" = ");
353            result.push_str(reset);
354            result.push_str(&colorize_toml_value(value, magenta, yellow, gray, reset));
355        } else {
356            result.push_str(line);
357        }
358        result.push('\n');
359    }
360
361    result.trim_end().to_string()
362}
363
364fn colorize_toml_value(s: &str, magenta: &str, yellow: &str, gray: &str, reset: &str) -> String {
365    let trimmed = s.trim();
366
367    if trimmed.starts_with('"') && trimmed.ends_with('"') {
368        format!("{}{}{}", magenta, trimmed, reset)
369    } else if trimmed == "true" || trimmed == "false" || trimmed.parse::<f64>().is_ok() {
370        format!("{}{}{}", yellow, trimmed, reset)
371    } else if trimmed.starts_with('[') {
372        // Array - colorize elements
373        let mut result = format!("{}[{}", gray, reset);
374        let inner = &trimmed[1..trimmed.len()-1];
375        let parts: Vec<&str> = inner.split(", ").collect();
376        for (i, part) in parts.iter().enumerate() {
377            if i > 0 {
378                result.push_str(&format!("{}, {}", gray, reset));
379            }
380            result.push_str(&colorize_toml_value(part, magenta, yellow, gray, reset));
381        }
382        result.push_str(&format!("{}]{}", gray, reset));
383        result
384    } else {
385        s.to_string()
386    }
387}
388
389/// Colorize YAML output
390fn colorize_yaml(s: &str) -> String {
391    let c = Colors::get();
392    if c.cyan.is_empty() {
393        return s.to_string();
394    }
395
396    let (cyan, magenta, yellow, gray, reset) = (c.cyan, c.magenta, c.yellow, c.gray, c.reset);
397
398    let mut result = String::new();
399
400    for line in s.lines() {
401        if let Some(colon_pos) = line.find(':') {
402            let before_colon = &line[..colon_pos];
403            let after_colon = &line[colon_pos + 1..];
404
405            // Check if this is a key (not a list item continuation)
406            let trimmed_before = before_colon.trim_start_matches([' ', '-']);
407
408            if !trimmed_before.is_empty() && !trimmed_before.starts_with('#') {
409                let indent = &before_colon[..before_colon.len() - trimmed_before.len()];
410
411                // Handle "- key:" pattern
412                if indent.contains('-') {
413                    let dash_pos = indent.find('-').unwrap();
414                    result.push_str(&indent[..dash_pos]);
415                    result.push_str(gray);
416                    result.push('-');
417                    result.push_str(reset);
418                    result.push_str(&indent[dash_pos + 1..]);
419                } else {
420                    result.push_str(indent);
421                }
422
423                result.push_str(cyan);
424                result.push_str(trimmed_before);
425                result.push_str(reset);
426                result.push_str(gray);
427                result.push(':');
428                result.push_str(reset);
429
430                // Colorize value
431                let value = after_colon.trim();
432                if !value.is_empty() {
433                    result.push(' ');
434                    result.push_str(&colorize_yaml_value(value, magenta, yellow, reset));
435                }
436            } else {
437                result.push_str(line);
438            }
439        } else if line.trim().starts_with('-') {
440            // List item
441            let trimmed = line.trim();
442            let indent = &line[..line.len() - trimmed.len()];
443            result.push_str(indent);
444            result.push_str(gray);
445            result.push('-');
446            result.push_str(reset);
447            result.push_str(&colorize_yaml_value(trimmed[1..].trim(), magenta, yellow, reset));
448        } else {
449            result.push_str(line);
450        }
451        result.push('\n');
452    }
453
454    result.trim_end().to_string()
455}
456
457fn colorize_yaml_value(s: &str, magenta: &str, yellow: &str, reset: &str) -> String {
458    let trimmed = s.trim();
459
460    if trimmed.starts_with('"') || trimmed.starts_with('\'') {
461        format!("{}{}{}", magenta, trimmed, reset)
462    } else if matches!(trimmed, "true" | "false" | "null" | "~") || trimmed.parse::<f64>().is_ok() {
463        format!("{}{}{}", yellow, trimmed, reset)
464    } else if !trimmed.is_empty() && !trimmed.contains(':') {
465        // Unquoted string
466        format!("{}{}{}", magenta, trimmed, reset)
467    } else {
468        s.to_string()
469    }
470}
471
472/// Check if print-break is enabled via environment variable
473#[doc(hidden)]
474pub fn is_enabled() -> bool {
475    if SKIP_ALL.load(Ordering::Relaxed) {
476        return false;
477    }
478    match std::env::var("PRINT_BREAK") {
479        Ok(val) => !matches!(val.as_str(), "0" | "false" | "no" | "off"),
480        Err(_) => true, // Enabled by default
481    }
482}
483
484/// Check if we're running in a TTY (interactive terminal)
485#[doc(hidden)]
486pub fn is_tty() -> bool {
487    std::io::stderr().is_terminal() && std::io::stdin().is_terminal()
488}
489
490/// Get and increment breakpoint counter
491#[doc(hidden)]
492pub fn next_break_id() -> usize {
493    BREAK_COUNT.fetch_add(1, Ordering::Relaxed) + 1
494}
495
496/// Set the skip-all flag
497#[doc(hidden)]
498pub fn set_skip_all(skip: bool) {
499    SKIP_ALL.store(skip, Ordering::Relaxed);
500}
501
502/// Attempts to format a value as pretty JSON/TOML/YAML if it's a config string.
503/// Falls back to Debug formatting otherwise.
504/// Truncates output if it exceeds MAX_LINES.
505#[doc(hidden)]
506pub fn format_value<T: Debug>(value: &T) -> String {
507    let debug_str = format!("{:?}", value);
508    let raw_output;
509
510    // Check if it's a string
511    if let Some(inner) = debug_str.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
512        // Unescape the string
513        let unescaped = inner
514            .replace("\\\"", "\"")
515            .replace("\\n", "\n")
516            .replace("\\t", "\t")
517            .replace("\\\\", "\\");
518
519        let trimmed = unescaped.trim();
520
521        let c = Colors::get();
522        let (gray, reset) = (c.gray, c.reset);
523
524        // Try JSON first (most specific - must start with { or [)
525        if trimmed.starts_with('{') || trimmed.starts_with('[') {
526            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&unescaped) {
527                if let Ok(pretty) = serde_json::to_string_pretty(&json) {
528                    let colorized = colorize_json(&pretty);
529                    raw_output = format!("{}(json){}\n{}", gray, reset, colorized);
530                    return truncate_output(&raw_output);
531                }
532            }
533        }
534
535        // Try TOML (look for key = value or [section] patterns)
536        if trimmed.contains(" = ") || trimmed.contains("]\n") || trimmed.starts_with('[') {
537            if let Ok(toml_val) = toml::from_str::<toml::Value>(&unescaped) {
538                if let Ok(pretty) = toml::to_string_pretty(&toml_val) {
539                    let colorized = colorize_toml(&pretty);
540                    raw_output = format!("{}(toml){}\n{}", gray, reset, colorized);
541                    return truncate_output(&raw_output);
542                }
543            }
544        }
545
546        // Try YAML (look for key: value patterns, but not just any colon)
547        if trimmed.contains(": ") || trimmed.contains(":\n") {
548            if let Ok(yaml_val) = serde_yaml::from_str::<serde_yaml::Value>(&unescaped) {
549                // Only use YAML if it parsed into something structured (not just a string)
550                if yaml_val.is_mapping() || yaml_val.is_sequence() {
551                    if let Ok(pretty) = serde_yaml::to_string(&yaml_val) {
552                        let colorized = colorize_yaml(pretty.trim());
553                        raw_output = format!("{}(yaml){}\n{}", gray, reset, colorized);
554                        return truncate_output(&raw_output);
555                    }
556                }
557            }
558        }
559
560        // For plain text strings, show with newlines and word wrap
561        raw_output = format!("{}(string, {} chars){}\n{}", gray, unescaped.len(), reset, word_wrap(&unescaped, 80));
562        return truncate_output(&raw_output);
563    }
564
565    // Fall back to pretty Debug format with colorization
566    let debug_output = format!("{:#?}", value);
567    raw_output = colorize_debug(&debug_output);
568    truncate_output(&raw_output)
569}
570
571/// Format value without truncation (for "more" output)
572#[doc(hidden)]
573pub fn format_value_full<T: Debug>(value: &T) -> String {
574    let debug_str = format!("{:?}", value);
575
576    // Check if it's a string
577    if let Some(inner) = debug_str.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
578        // Unescape the string
579        let unescaped = inner
580            .replace("\\\"", "\"")
581            .replace("\\n", "\n")
582            .replace("\\t", "\t")
583            .replace("\\\\", "\\");
584
585        let trimmed = unescaped.trim();
586
587        // Try JSON
588        if trimmed.starts_with('{') || trimmed.starts_with('[') {
589            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&unescaped) {
590                if let Ok(pretty) = serde_json::to_string_pretty(&json) {
591                    return pretty;
592                }
593            }
594        }
595
596        // Try TOML
597        if trimmed.contains(" = ") || trimmed.contains("]\n") || trimmed.starts_with('[') {
598            if let Ok(toml_val) = toml::from_str::<toml::Value>(&unescaped) {
599                if let Ok(pretty) = toml::to_string_pretty(&toml_val) {
600                    return pretty;
601                }
602            }
603        }
604
605        // Try YAML
606        if trimmed.contains(": ") || trimmed.contains(":\n") {
607            if let Ok(yaml_val) = serde_yaml::from_str::<serde_yaml::Value>(&unescaped) {
608                if yaml_val.is_mapping() || yaml_val.is_sequence() {
609                    if let Ok(pretty) = serde_yaml::to_string(&yaml_val) {
610                        return pretty.trim().to_string();
611                    }
612                }
613            }
614        }
615
616        // Plain text with word wrap
617        return word_wrap(&unescaped, 100);
618    }
619
620    // Colorize debug output
621    colorize_debug(&format!("{:#?}", value))
622}
623
624/// Default maximum nesting depth before collapsing
625const DEFAULT_MAX_DEPTH: usize = 4;
626
627/// Get max depth from environment variable or use default
628fn max_depth() -> usize {
629    std::env::var("PRINT_BREAK_DEPTH")
630        .ok()
631        .and_then(|v| v.parse().ok())
632        .unwrap_or(DEFAULT_MAX_DEPTH)
633}
634
635/// Colorize Debug output for structs/enums
636fn colorize_debug(s: &str) -> String {
637    let c = Colors::get();
638    if c.cyan.is_empty() {
639        return s.to_string();
640    }
641
642    let (green, cyan, yellow, magenta, white, gray, reset) =
643        (c.green, c.cyan, c.yellow, c.magenta, c.white, c.gray, c.reset);
644
645    let mut result = String::new();
646    let lines: Vec<&str> = s.lines().collect();
647    let mut current_depth: usize = 0;
648    let mut skip_until_depth: Option<usize> = None;
649
650    for line in lines {
651        let trimmed = line.trim_start();
652        let indent_count = line.len() - trimmed.len();
653        let indent_level = indent_count / 4;
654
655        // Track depth changes
656        let opens = trimmed.ends_with('{') || trimmed.ends_with('[') || trimmed.ends_with('(');
657        let closes = trimmed.starts_with('}') || trimmed.starts_with(']') || trimmed.starts_with(')');
658
659        if closes {
660            current_depth = current_depth.saturating_sub(1);
661        }
662
663        // Check if we're skipping due to depth
664        if let Some(skip_depth) = skip_until_depth {
665            if current_depth < skip_depth {
666                skip_until_depth = None;
667            } else {
668                if opens {
669                    current_depth += 1;
670                }
671                continue;
672            }
673        }
674
675        // If we're at max depth and opening a new block, collapse it
676        if opens && current_depth >= max_depth() {
677            // Add indentation guides
678            for _ in 0..indent_level {
679                result.push_str(&format!("{}│{} ", gray, reset));
680            }
681
682            // Show collapsed version
683            let name = trimmed.trim_end_matches(['{', '[', '(', ' ']);
684            if !name.is_empty() {
685                result.push_str(&format!("{}{}{} {}{{ ... }}{}", green, name, reset, gray, reset));
686            } else {
687                result.push_str(&format!("{}[ ... ]{}", gray, reset));
688            }
689            result.push('\n');
690
691            skip_until_depth = Some(current_depth);
692            current_depth += 1;
693            continue;
694        }
695
696        // Add indentation guides
697        for _ in 0..indent_level {
698            result.push_str(&format!("{}│{} ", gray, reset));
699        }
700
701        // Colorize the content
702        if opens {
703            // Struct/enum name line: "User {" or "Some(" or "["
704            let name = trimmed.trim_end_matches(['{', '[', '(', ' ']);
705            let bracket = trimmed.chars().last().unwrap_or(' ');
706            if !name.is_empty() {
707                result.push_str(&format!("{}{}{} {}{}{}", green, name, reset, gray, bracket, reset));
708            } else {
709                result.push_str(&format!("{}{}{}", gray, bracket, reset));
710            }
711            current_depth += 1;
712        } else if closes || trimmed.ends_with("},") || trimmed.ends_with("],") || trimmed.ends_with("),") {
713            // Closing brace
714            result.push_str(&format!("{}{}{}", gray, trimmed, reset));
715        } else if trimmed.contains(": ") {
716            // Field: value line
717            if let Some(colon_pos) = trimmed.find(": ") {
718                let field = &trimmed[..colon_pos];
719                let value = &trimmed[colon_pos + 2..];
720                let colored_value = colorize_value(value, yellow, magenta, white, gray, reset);
721                result.push_str(&format!("{}{}{}{}: {}", cyan, field, reset, gray, colored_value));
722            } else {
723                result.push_str(trimmed);
724            }
725        } else {
726            // Array element or other
727            let colored = colorize_value(trimmed, yellow, magenta, white, gray, reset);
728            result.push_str(&colored);
729        }
730        result.push('\n');
731    }
732
733    result.trim_end().to_string()
734}
735
736/// Colorize a single value
737fn colorize_value(s: &str, yellow: &str, magenta: &str, white: &str, gray: &str, reset: &str) -> String {
738    let trimmed = s.trim_end_matches(',');
739    let has_comma = s.ends_with(',');
740    let comma = if has_comma { format!("{},{}", gray, reset) } else { String::new() };
741
742    if trimmed.starts_with('"') {
743        // String value
744        format!("{}{}{}{}", magenta, trimmed, reset, comma)
745    } else if trimmed.parse::<f64>().is_ok() || trimmed.starts_with('-') && trimmed[1..].parse::<f64>().is_ok() {
746        // Number
747        format!("{}{}{}{}", yellow, trimmed, reset, comma)
748    } else if trimmed == "true" || trimmed == "false" {
749        // Boolean
750        format!("{}{}{}{}", yellow, trimmed, reset, comma)
751    } else if trimmed == "None" || trimmed.starts_with("Some(") {
752        // Option
753        format!("{}{}{}{}", white, trimmed, reset, comma)
754    } else {
755        format!("{}{}{}{}", white, trimmed, reset, comma)
756    }
757}
758
759/// Word wrap text at specified width
760fn word_wrap(s: &str, width: usize) -> String {
761    let mut result = String::new();
762    for line in s.lines() {
763        if line.len() <= width {
764            result.push_str(line);
765            result.push('\n');
766        } else {
767            let mut current_line = String::new();
768            for word in line.split_whitespace() {
769                if current_line.is_empty() {
770                    current_line = word.to_string();
771                } else if current_line.len() + 1 + word.len() <= width {
772                    current_line.push(' ');
773                    current_line.push_str(word);
774                } else {
775                    result.push_str(&current_line);
776                    result.push('\n');
777                    current_line = word.to_string();
778                }
779            }
780            if !current_line.is_empty() {
781                result.push_str(&current_line);
782                result.push('\n');
783            }
784        }
785    }
786    result.trim_end().to_string()
787}
788
789/// Truncate output if it exceeds MAX_LINES
790fn truncate_output(s: &str) -> String {
791    let lines: Vec<&str> = s.lines().collect();
792    if lines.len() > MAX_LINES {
793        let c = Colors::get();
794        let truncated = lines[..MAX_LINES].join("\n");
795        format!("{}\n{}... ({} more lines){}", truncated, c.gray, lines.len() - MAX_LINES, c.reset)
796    } else {
797        s.to_string()
798    }
799}
800
801/// Stored full output for "show more" functionality
802static LAST_FULL_OUTPUT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
803
804/// Store full output for potential "show more"
805#[doc(hidden)]
806pub fn store_full_output(output: String) {
807    if let Ok(mut guard) = LAST_FULL_OUTPUT.lock() {
808        *guard = Some(output);
809    }
810}
811
812/// Show help menu
813fn show_help() {
814    eprintln!("\n\x1b[1;33m─── print-break Help ───\x1b[0m");
815    eprintln!("\x1b[36mEnter\x1b[0m     Continue to next breakpoint");
816    eprintln!("\x1b[36mm\x1b[0m         Show full output (if truncated)");
817    eprintln!("\x1b[36mt\x1b[0m         Show stack trace");
818    eprintln!("\x1b[36mc\x1b[0m         Copy last value to clipboard");
819    eprintln!("\x1b[36ms\x1b[0m         Skip all remaining breakpoints");
820    eprintln!("\x1b[36mq\x1b[0m         Quit the program");
821    eprintln!("\x1b[36mh / ?\x1b[0m     Show this help");
822    eprintln!();
823    eprintln!("\x1b[90mEnvironment variables:\x1b[0m");
824    eprintln!("  \x1b[36mPRINT_BREAK=0\x1b[0m          Disable all breakpoints");
825    eprintln!("  \x1b[36mPRINT_BREAK_DEPTH=N\x1b[0m    Max nesting depth (default: 4)");
826    eprintln!("  \x1b[36mPRINT_BREAK_STYLE=X\x1b[0m    Border style: rounded, sharp, double, ascii");
827    eprintln!("\x1b[1;33m─────────────────────────\x1b[0m\n");
828}
829
830/// Show stack trace
831fn show_stack_trace() {
832    eprintln!("\n\x1b[1;33m─── Stack Trace ───\x1b[0m");
833
834    let bt = backtrace::Backtrace::new();
835    let mut in_relevant = false;
836    let mut count = 0;
837
838    for frame in bt.frames() {
839        for symbol in frame.symbols() {
840            if let Some(name) = symbol.name() {
841                let name_str = name.to_string();
842
843                // Skip internal frames
844                if name_str.contains("print_break::") || name_str.contains("backtrace::") {
845                    continue;
846                }
847
848                // Start showing after we exit print_break internals
849                if !in_relevant && !name_str.contains("print_break") {
850                    in_relevant = true;
851                }
852
853                if in_relevant {
854                    let file = symbol.filename()
855                        .map(|p| p.display().to_string())
856                        .unwrap_or_default();
857                    let line = symbol.lineno().unwrap_or(0);
858
859                    // Simplify long paths
860                    let short_file = file.rsplit('/').next().unwrap_or(&file);
861
862                    if !name_str.contains("std::") && !name_str.contains("core::") && !name_str.contains("__rust") {
863                        eprintln!("\x1b[90m{:>3}.\x1b[0m \x1b[36m{}\x1b[0m", count, name_str);
864                        if !file.is_empty() && line > 0 {
865                            eprintln!("      \x1b[90mat {}:{}\x1b[0m", short_file, line);
866                        }
867                        count += 1;
868
869                        if count >= 15 {
870                            eprintln!("\x1b[90m     ... (truncated)\x1b[0m");
871                            break;
872                        }
873                    }
874                }
875            }
876        }
877        if count >= 15 {
878            break;
879        }
880    }
881
882    eprintln!("\x1b[1;33m───────────────────\x1b[0m\n");
883}
884
885/// Copy text to clipboard using system commands
886fn copy_to_clipboard(text: &str) -> bool {
887    use std::process::{Command, Stdio};
888    use std::io::Write as IoWrite;
889
890    // Try different clipboard commands based on platform
891    let commands = if cfg!(target_os = "macos") {
892        vec![("pbcopy", vec![])]
893    } else if cfg!(target_os = "windows") {
894        vec![("clip", vec![])]
895    } else {
896        // Linux - try multiple options
897        vec![
898            ("xclip", vec!["-selection", "clipboard"]),
899            ("xsel", vec!["--clipboard", "--input"]),
900            ("wl-copy", vec![]),
901        ]
902    };
903
904    for (cmd, args) in commands {
905        if let Ok(mut child) = Command::new(cmd)
906            .args(&args)
907            .stdin(Stdio::piped())
908            .stdout(Stdio::null())
909            .stderr(Stdio::null())
910            .spawn()
911        {
912            if let Some(mut stdin) = child.stdin.take() {
913                if stdin.write_all(text.as_bytes()).is_ok() {
914                    drop(stdin);
915                    if child.wait().map(|s| s.success()).unwrap_or(false) {
916                        return true;
917                    }
918                }
919            }
920        }
921    }
922    false
923}
924
925/// Handle user input at breakpoint. Returns true if should continue, false if should quit.
926#[doc(hidden)]
927pub fn handle_input() -> bool {
928    use std::io::{self, BufRead, Write};
929
930    // If not a TTY, don't pause - just continue (for CI/piped output)
931    if !is_tty() {
932        eprintln!("(non-interactive mode, continuing...)");
933        return true;
934    }
935
936    loop {
937        eprint!("\x1b[90m[Enter, m=more, t=trace, c=copy, s=skip, q=quit, h=help]\x1b[0m ");
938        io::stderr().flush().unwrap();
939
940        let stdin = io::stdin();
941        let mut line = String::new();
942        if stdin.lock().read_line(&mut line).is_ok() {
943            let input = line.trim().to_lowercase();
944            match input.as_str() {
945                "q" | "quit" => {
946                    eprintln!("\x1b[1;31mQuitting...\x1b[0m");
947                    std::process::exit(0);
948                }
949                "s" | "skip" => {
950                    eprintln!("\x1b[1;33mSkipping remaining breakpoints...\x1b[0m");
951                    set_skip_all(true);
952                    break;
953                }
954                "m" | "more" => {
955                    // Show full output
956                    if let Ok(guard) = LAST_FULL_OUTPUT.lock() {
957                        if let Some(ref full) = *guard {
958                            eprintln!("\n\x1b[1;33m─── Full Output ───\x1b[0m");
959                            for line in full.lines() {
960                                eprintln!("\x1b[37m{}\x1b[0m", line);
961                            }
962                            eprintln!("\x1b[1;33m───────────────────\x1b[0m\n");
963                        } else {
964                            eprintln!("\x1b[90m(no truncated output to show)\x1b[0m");
965                        }
966                    }
967                    continue;
968                }
969                "t" | "trace" => {
970                    show_stack_trace();
971                    continue;
972                }
973                "c" | "copy" => {
974                    if let Ok(guard) = LAST_FULL_OUTPUT.lock() {
975                        if let Some(ref full) = *guard {
976                            // Strip ANSI codes for clipboard
977                            let clean = strip_ansi_codes(full);
978                            if copy_to_clipboard(&clean) {
979                                eprintln!("\x1b[1;32mCopied to clipboard!\x1b[0m");
980                            } else {
981                                eprintln!("\x1b[1;31mFailed to copy (install xclip or xsel)\x1b[0m");
982                            }
983                        } else {
984                            eprintln!("\x1b[90m(nothing to copy)\x1b[0m");
985                        }
986                    }
987                    continue;
988                }
989                "h" | "?" | "help" => {
990                    show_help();
991                    continue;
992                }
993                _ => break // Continue
994            }
995        } else {
996            break;
997        }
998    }
999    eprintln!();
1000    true
1001}
1002
1003/// Strip ANSI escape codes from a string
1004fn strip_ansi_codes(s: &str) -> String {
1005    let mut result = String::new();
1006    let mut in_escape = false;
1007
1008    for c in s.chars() {
1009        if c == '\x1b' {
1010            in_escape = true;
1011        } else if in_escape {
1012            if c == 'm' {
1013                in_escape = false;
1014            }
1015        } else {
1016            result.push(c);
1017        }
1018    }
1019
1020    result
1021}
1022
1023/// Pretty-prints variables and pauses execution until Enter is pressed.
1024///
1025/// # Features
1026///
1027/// - Compiles to nothing in release builds
1028/// - Can be disabled with `PRINT_BREAK=0` environment variable
1029/// - Interactive: Enter=continue, q=quit, s=skip all remaining
1030///
1031/// # Examples
1032///
1033/// ```rust,no_run
1034/// use print_break::print_break;
1035///
1036/// let user_id = 123;
1037/// let items = vec!["apple", "banana"];
1038/// let json = r#"{"status": "ok"}"#;
1039///
1040/// // Print single variable
1041/// print_break!(user_id);
1042///
1043/// // Print multiple variables
1044/// print_break!(user_id, items, json);
1045///
1046/// // Print with no variables (just pause)
1047/// print_break!();
1048/// ```
1049#[macro_export]
1050#[cfg(debug_assertions)]
1051macro_rules! print_break {
1052    () => {{
1053        if $crate::is_enabled() {
1054            let break_id = $crate::next_break_id();
1055            let elapsed_str = $crate::get_elapsed().map($crate::format_elapsed).unwrap_or_default();
1056            $crate::update_break_time();
1057
1058            let location = format!("{}:{}", file!(), line!());
1059            let width = 50;
1060            let border = $crate::get_border_style();
1061            let c = $crate::Colors::get();
1062
1063            let h = border.horizontal.to_string();
1064
1065            eprintln!();
1066            eprintln!("{}{}{} BREAK #{} {}{}{}", c.yellow, border.top_left, h, break_id, elapsed_str, h.repeat(width - 14 - break_id.to_string().len() - elapsed_str.len() / 3), c.reset);
1067            eprintln!("{}{}{} {}{}{}", c.yellow, border.vertical, c.reset, c.cyan, location, c.reset);
1068            eprintln!("{}{}{}{}", c.yellow, border.bottom_left, h.repeat(width), c.reset);
1069
1070            $crate::handle_input();
1071        }
1072    }};
1073    ($($var:expr),+ $(,)?) => {{
1074        if $crate::is_enabled() {
1075            let break_id = $crate::next_break_id();
1076            let elapsed_str = $crate::get_elapsed().map($crate::format_elapsed).unwrap_or_default();
1077            $crate::update_break_time();
1078
1079            let location = format!("{}:{}", file!(), line!());
1080            let width = 50;
1081            let border = $crate::get_border_style();
1082            let c = $crate::Colors::get();
1083
1084            // Collect full output for "more" option
1085            let mut full_output = String::new();
1086
1087            let h = border.horizontal.to_string();
1088
1089            eprintln!();
1090            eprintln!("{}{}{} BREAK #{} {}{}{}", c.yellow, border.top_left, h, break_id, elapsed_str, h.repeat(width - 14 - break_id.to_string().len() - elapsed_str.len() / 3), c.reset);
1091            eprintln!("{}{}{} {}{}{}", c.yellow, border.vertical, c.reset, c.cyan, location, c.reset);
1092            eprintln!("{}{}{}{}", c.yellow, border.tee_right, h.repeat(width), c.reset);
1093
1094            $(
1095                let formatted = $crate::format_value(&$var);
1096                let name = stringify!($var);
1097
1098                // Store untruncated version
1099                full_output.push_str(&format!("{} = {}\n\n", name, $crate::format_value_full(&$var)));
1100
1101                if formatted.contains('\n') {
1102                    eprintln!("{}{}{} {}{}{}=", c.yellow, border.vertical, c.reset, c.green, name, c.reset);
1103                    for line in formatted.lines() {
1104                        eprintln!("{}{}{} {}{}{}", c.yellow, border.vertical, c.reset, c.white, line, c.reset);
1105                    }
1106                } else {
1107                    eprintln!("{}{}{} {}{}{} = {}{}{}", c.yellow, border.vertical, c.reset, c.green, name, c.reset, c.white, formatted, c.reset);
1108                }
1109            )+
1110
1111            $crate::store_full_output(full_output);
1112
1113            eprintln!("{}{}{}{}", c.yellow, border.bottom_left, h.repeat(width), c.reset);
1114            $crate::handle_input();
1115        }
1116    }};
1117}
1118
1119/// Conditional breakpoint - only triggers if condition is true.
1120///
1121/// # Examples
1122///
1123/// ```rust,no_run
1124/// use print_break::print_break_if;
1125///
1126/// for i in 0..100 {
1127///     print_break_if!(i == 50, i);  // Only breaks when i is 50
1128/// }
1129///
1130/// let x = 42;
1131/// print_break_if!(x > 10, x);  // Breaks because x > 10
1132/// ```
1133#[macro_export]
1134#[cfg(debug_assertions)]
1135macro_rules! print_break_if {
1136    ($cond:expr) => {{
1137        if $cond {
1138            $crate::print_break!();
1139        }
1140    }};
1141    ($cond:expr, $($var:expr),+ $(,)?) => {{
1142        if $cond {
1143            $crate::print_break!($($var),+);
1144        }
1145    }};
1146}
1147
1148/// In release builds, print_break_if! compiles to nothing
1149#[macro_export]
1150#[cfg(not(debug_assertions))]
1151macro_rules! print_break_if {
1152    ($cond:expr) => {{}};
1153    ($cond:expr, $($var:expr),+ $(,)?) => {{}};
1154}
1155
1156/// In release builds, print_break! compiles to nothing
1157#[macro_export]
1158#[cfg(not(debug_assertions))]
1159macro_rules! print_break {
1160    () => {{}};
1161    ($($var:expr),+ $(,)?) => {{}};
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    #[test]
1169    fn format_json_string() {
1170        let json = r#"{"name": "test", "value": 42}"#;
1171        let formatted = format_value(&json);
1172        assert!(formatted.contains("\"name\": \"test\""));
1173        assert!(formatted.contains('\n')); // Should be pretty-printed
1174    }
1175
1176    #[test]
1177    fn format_non_json() {
1178        let x = 42;
1179        let formatted = format_value(&x);
1180        assert_eq!(formatted, "42");
1181    }
1182
1183    #[test]
1184    fn format_struct() {
1185        #[derive(Debug)]
1186        struct Test { a: i32, b: String }
1187
1188        let t = Test { a: 1, b: "hello".to_string() };
1189        let formatted = format_value(&t);
1190        assert!(formatted.contains("Test"));
1191    }
1192
1193    #[test]
1194    fn truncation_works() {
1195        let long_vec: Vec<i32> = (0..1000).collect();
1196        let formatted = format_value(&long_vec);
1197        assert!(formatted.contains("more lines"));
1198    }
1199
1200    #[test]
1201    fn env_var_disable() {
1202        std::env::set_var("PRINT_BREAK", "0");
1203        assert!(!is_enabled());
1204        std::env::set_var("PRINT_BREAK", "1");
1205        assert!(is_enabled());
1206        std::env::remove_var("PRINT_BREAK");
1207    }
1208
1209    #[test]
1210    fn json_array_elements_same_color() {
1211        // Regression test: array elements should all be values (magenta), not keys (cyan)
1212        // Test colorize_json directly since format_value skips colors in non-TTY test env
1213        let json = r#"["a", "b", "c"]"#;
1214        // Parse and pretty-print first (like format_value does)
1215        let parsed: serde_json::Value = serde_json::from_str(json).unwrap();
1216        let pretty = serde_json::to_string_pretty(&parsed).unwrap();
1217        let formatted = colorize_json(&pretty);
1218
1219        // In non-TTY mode, colorize_json returns unmodified string
1220        // So we test the structure is preserved (no corruption)
1221        assert!(formatted.contains("\"a\""));
1222        assert!(formatted.contains("\"b\""));
1223        assert!(formatted.contains("\"c\""));
1224    }
1225}