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