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