Skip to main content

xchecker_utils/
logging.rs

1//! Logging and observability infrastructure for xchecker
2//!
3//! This module provides structured logging capabilities with timing,
4//! resource usage tracking, and verbose output support.
5//!
6//! Implements FR-OBS-001: Structured logging with tracing support
7
8use chrono::{DateTime, Utc};
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11use sysinfo::{Pid, System};
12use tracing::{Level, debug, error, info, span, warn};
13use tracing_subscriber::{
14    EnvFilter,
15    fmt::{self, format::FmtSpan},
16    layer::SubscriberExt,
17    util::SubscriberInitExt,
18};
19use xchecker_redaction::SecretRedactor;
20
21/// Initialize tracing subscriber for structured logging (FR-OBS-001)
22///
23/// Sets up tracing with either compact (default) or verbose format.
24/// Verbose format includes `spec_id`, phase, `duration_ms`, and `runner_mode` fields.
25///
26/// # Arguments
27/// * `verbose` - If true, use verbose format with structured fields
28///
29/// # Returns
30/// Result indicating success or failure of initialization
31#[allow(dead_code)] // Future-facing: used when CLI adds --verbose flag
32pub fn init_tracing(verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
33    let env_filter = EnvFilter::try_from_default_env()
34        .or_else(|_| {
35            if verbose {
36                EnvFilter::try_new("xchecker=debug,info")
37            } else {
38                EnvFilter::try_new("xchecker=info,warn")
39            }
40        })
41        .unwrap_or_else(|_| EnvFilter::new("info"));
42
43    if verbose {
44        // Verbose format: structured with all fields
45        tracing_subscriber::registry()
46            .with(env_filter)
47            .with(
48                fmt::layer()
49                    .with_target(true)
50                    .with_thread_ids(false)
51                    .with_thread_names(false)
52                    .with_line_number(false)
53                    .with_file(false)
54                    .with_span_events(FmtSpan::CLOSE)
55                    .compact(),
56            )
57            .try_init()?;
58    } else {
59        // Compact format: human-readable, minimal
60        tracing_subscriber::registry()
61            .with(env_filter)
62            .with(
63                fmt::layer()
64                    .with_target(false)
65                    .with_thread_ids(false)
66                    .with_thread_names(false)
67                    .with_line_number(false)
68                    .with_file(false)
69                    .compact(),
70            )
71            .try_init()?;
72    }
73
74    Ok(())
75}
76
77/// Create a span for phase execution with structured fields (FR-OBS-001)
78///
79/// # Arguments
80/// * `spec_id` - The spec identifier
81/// * `phase` - The phase name
82/// * `runner_mode` - The runner mode (native, wsl, auto)
83///
84/// # Returns
85/// A tracing span with the specified fields
86#[allow(dead_code)] // Future-facing: used for structured logging in orchestrator
87pub fn phase_span(spec_id: &str, phase: &str, runner_mode: &str) -> tracing::Span {
88    span!(
89        Level::INFO,
90        "phase_execution",
91        spec_id = %spec_id,
92        phase = %phase,
93        runner_mode = %runner_mode,
94    )
95}
96
97/// Log phase start with structured fields (FR-OBS-001)
98#[allow(dead_code)] // Future-facing: used for structured logging in orchestrator
99pub fn log_phase_start(spec_id: &str, phase: &str, runner_mode: &str) {
100    info!(
101        spec_id = %spec_id,
102        phase = %phase,
103        runner_mode = %runner_mode,
104        "Starting phase execution"
105    );
106}
107
108/// Log phase completion with duration (FR-OBS-001)
109#[allow(dead_code)] // Future-facing: used for structured logging in orchestrator
110pub fn log_phase_complete(spec_id: &str, phase: &str, duration_ms: u128) {
111    info!(
112        spec_id = %spec_id,
113        phase = %phase,
114        duration_ms = %duration_ms,
115        "Phase execution completed"
116    );
117}
118
119/// Log phase error with context (FR-OBS-001, FR-OBS-002, FR-OBS-003)
120///
121/// Error messages are redacted to prevent secrets from appearing in logs.
122#[allow(dead_code)] // Future-facing: used for structured logging in orchestrator
123pub fn log_phase_error(spec_id: &str, phase: &str, error: &str, duration_ms: u128) {
124    // Create a temporary redactor to sanitize the error message
125    let redactor = SecretRedactor::new().expect("Failed to create SecretRedactor");
126    let sanitized_error = match redactor.redact_content(error, "<log>") {
127        Ok(result) => result.content,
128        Err(_) => "[REDACTION_ERROR]".to_string(),
129    };
130
131    error!(
132        spec_id = %spec_id,
133        phase = %phase,
134        duration_ms = %duration_ms,
135        error = %sanitized_error,
136        "Phase execution failed"
137    );
138}
139
140/// Logger for verbose output and observability (R7.5, NFR5)
141pub struct Logger {
142    verbose: bool,
143    start_time: Instant,
144    operation_timings: HashMap<String, Duration>,
145    file_operations: Vec<FileOperation>,
146    /// Multiple timings for the same operation (for percentile calculation)
147    operation_samples: HashMap<String, Vec<Duration>>,
148    /// System info for memory monitoring
149    system: System,
150    /// Initial memory usage
151    initial_memory: u64,
152    /// Peak memory usage observed
153    peak_memory: u64,
154    /// Performance targets for validation (NFR1)
155    performance_targets: PerformanceTargets,
156    /// Current `spec_id` for structured logging
157    spec_id: Option<String>,
158    /// Current phase for structured logging
159    phase: Option<String>,
160    /// Current runner mode for structured logging
161    runner_mode: Option<String>,
162    /// Secret redactor for sanitizing log output (FR-OBS-002, FR-OBS-003)
163    redactor: SecretRedactor,
164}
165
166/// Represents a file operation for logging
167#[allow(dead_code)] // Future-facing: used for detailed operation logging
168#[derive(Debug, Clone)]
169pub struct FileOperation {
170    pub path: String,
171    pub operation: String,
172    pub size_bytes: Option<usize>,
173    pub hash: Option<String>,
174    pub timestamp: DateTime<Utc>,
175}
176
177/// Timing information for operations
178#[allow(dead_code)] // Future-facing: used for performance tracking
179#[derive(Debug, Clone)]
180pub struct TimingInfo {
181    pub operation: String,
182    pub duration: Duration,
183    pub timestamp: DateTime<Utc>,
184}
185
186/// Performance metrics for benchmarking (NFR1)
187#[allow(dead_code)] // Future-facing: used for performance reporting
188#[derive(Debug, Clone)]
189pub struct PerformanceMetrics {
190    /// Total execution time
191    pub total_duration: Duration,
192    /// Empty run time (no Claude calls)
193    pub empty_run_duration: Option<Duration>,
194    /// Packetization time
195    pub packetization_duration: Option<Duration>,
196    /// Number of files processed during packetization
197    pub files_processed: usize,
198    /// Memory usage statistics
199    pub memory_stats: MemoryStats,
200    /// Timing percentiles for key operations
201    pub timing_percentiles: HashMap<String, TimingPercentiles>,
202}
203
204/// Memory usage statistics
205#[allow(dead_code)] // Future-facing: used for memory profiling
206#[derive(Debug, Clone, Default)]
207pub struct MemoryStats {
208    /// Peak memory usage in bytes
209    pub peak_memory_bytes: u64,
210    /// Initial memory usage in bytes
211    pub initial_memory_bytes: u64,
212    /// Final memory usage in bytes
213    pub final_memory_bytes: u64,
214}
215
216/// Timing percentiles for performance analysis
217#[allow(dead_code)] // Future-facing: used for performance analysis
218#[derive(Debug, Clone)]
219pub struct TimingPercentiles {
220    pub p50: Duration,
221    pub p95: Duration,
222    pub min: Duration,
223    pub max: Duration,
224    pub count: usize,
225}
226
227/// Performance targets for validation (NFR1)
228#[derive(Debug, Clone)]
229pub struct PerformanceTargets {
230    /// Target for empty run (≤ 5s)
231    pub empty_run_target: Duration,
232    /// Target for packetization (≤ 200ms for 100 files)
233    pub packetization_target_per_100_files: Duration,
234}
235
236impl Logger {
237    /// Create a new logger instance
238    #[must_use]
239    pub fn new(verbose: bool) -> Self {
240        let mut system = System::new_all();
241        system.refresh_all();
242
243        let initial_memory = Self::get_current_memory_usage(&system);
244
245        Self {
246            verbose,
247            start_time: Instant::now(),
248            operation_timings: HashMap::new(),
249            file_operations: Vec::new(),
250            operation_samples: HashMap::new(),
251            system,
252            initial_memory,
253            peak_memory: initial_memory,
254            performance_targets: PerformanceTargets {
255                empty_run_target: Duration::from_secs(5), // NFR1: ≤ 5s
256                packetization_target_per_100_files: Duration::from_millis(200), // NFR1: ≤ 200ms for 100 files
257            },
258            spec_id: None,
259            phase: None,
260            runner_mode: None,
261            redactor: SecretRedactor::new().expect("Failed to create SecretRedactor"),
262        }
263    }
264
265    /// Redact secrets from a string before logging (FR-OBS-002, FR-OBS-003)
266    ///
267    /// This ensures no secrets are ever written to logs, even in error messages,
268    /// context strings, or other user-facing output.
269    fn redact(&self, content: &str) -> String {
270        match self.redactor.redact_content(content, "<log>") {
271            Ok(result) => result.content,
272            Err(_) => {
273                // If redaction fails, return a safe placeholder
274                "[REDACTION_ERROR]".to_string()
275            }
276        }
277    }
278
279    /// Sanitize a string by removing environment variables and redacting secrets (FR-OBS-002, FR-OBS-003)
280    ///
281    /// Environment variables are never logged to prevent leaking sensitive configuration.
282    /// Secrets are redacted using the `SecretRedactor`.
283    fn sanitize(&self, content: &str) -> String {
284        // First, check if this looks like it might contain environment variables
285        // We don't want to log anything that looks like KEY=VALUE patterns
286        let sanitized = if content.contains('=')
287            && (content.contains("KEY")
288                || content.contains("TOKEN")
289                || content.contains("SECRET")
290                || content.contains("PASSWORD"))
291        {
292            "[ENV_VAR_REDACTED]".to_string()
293        } else {
294            content.to_string()
295        };
296
297        // Then apply secret redaction
298        self.redact(&sanitized)
299    }
300
301    /// Set the `spec_id` for structured logging (FR-OBS-001)
302    #[allow(dead_code)] // Future-facing: used for structured logging context
303    pub fn set_spec_id(&mut self, spec_id: String) {
304        self.spec_id = Some(spec_id);
305    }
306
307    /// Set the phase for structured logging (FR-OBS-001)
308    #[allow(dead_code)] // Future-facing: used for structured logging context
309    pub fn set_phase(&mut self, phase: String) {
310        self.phase = Some(phase);
311    }
312
313    /// Set the runner mode for structured logging (FR-OBS-001)
314    #[allow(dead_code)] // Future-facing: used for structured logging context
315    pub fn set_runner_mode(&mut self, runner_mode: String) {
316        self.runner_mode = Some(runner_mode);
317    }
318
319    /// Get the current `spec_id`
320    #[must_use]
321    #[allow(dead_code)] // Future-facing: used for structured logging context
322    pub fn spec_id(&self) -> Option<&str> {
323        self.spec_id.as_deref()
324    }
325
326    /// Get the current phase
327    #[must_use]
328    #[allow(dead_code)] // Future-facing: used for structured logging context
329    pub fn phase(&self) -> Option<&str> {
330        self.phase.as_deref()
331    }
332
333    /// Get the current runner mode
334    #[must_use]
335    #[allow(dead_code)] // Future-facing: used for structured logging context
336    pub fn runner_mode(&self) -> Option<&str> {
337        self.runner_mode.as_deref()
338    }
339
340    /// Check if verbose mode is enabled
341    #[must_use]
342    #[allow(dead_code)] // Future-facing: used for verbose logging control
343    pub const fn is_verbose(&self) -> bool {
344        self.verbose
345    }
346
347    /// Log a message if verbose mode is enabled (FR-OBS-002, FR-OBS-003)
348    ///
349    /// All messages are sanitized to remove environment variables and redact secrets.
350    pub fn verbose(&self, message: &str) {
351        if self.verbose {
352            let elapsed = self.start_time.elapsed();
353            let sanitized_message = self.sanitize(message);
354
355            // Use structured logging if context is available
356            if let (Some(spec_id), Some(phase), Some(runner_mode)) = (
357                self.spec_id.as_ref(),
358                self.phase.as_ref(),
359                self.runner_mode.as_ref(),
360            ) {
361                debug!(
362                    spec_id = %spec_id,
363                    phase = %phase,
364                    runner_mode = %runner_mode,
365                    duration_ms = %elapsed.as_millis(),
366                    message = %sanitized_message,
367                    "Verbose log"
368                );
369            } else {
370                debug!(
371                    duration_ms = %elapsed.as_millis(),
372                    message = %sanitized_message,
373                    "Verbose log"
374                );
375            }
376
377            // Also print to stdout for backward compatibility
378            println!("[{:>8.3}s] {}", elapsed.as_secs_f64(), sanitized_message);
379        }
380    }
381
382    /// Log a message with formatting if verbose mode is enabled (FR-OBS-002, FR-OBS-003)
383    ///
384    /// All messages are sanitized to remove environment variables and redact secrets.
385    #[allow(dead_code)] // Future-facing: used for formatted verbose logging
386    pub fn verbose_fmt(&self, args: std::fmt::Arguments) {
387        if self.verbose {
388            let elapsed = self.start_time.elapsed();
389            let message = format!("{args}");
390            let sanitized_message = self.sanitize(&message);
391
392            // Use structured logging if context is available
393            if let (Some(spec_id), Some(phase), Some(runner_mode)) = (
394                self.spec_id.as_ref(),
395                self.phase.as_ref(),
396                self.runner_mode.as_ref(),
397            ) {
398                debug!(
399                    spec_id = %spec_id,
400                    phase = %phase,
401                    runner_mode = %runner_mode,
402                    duration_ms = %elapsed.as_millis(),
403                    message = %sanitized_message,
404                    "Verbose log"
405                );
406            } else {
407                debug!(
408                    duration_ms = %elapsed.as_millis(),
409                    message = %sanitized_message,
410                    "Verbose log"
411                );
412            }
413
414            // Also print to stdout for backward compatibility
415            println!("[{:>8.3}s] {}", elapsed.as_secs_f64(), sanitized_message);
416        }
417    }
418
419    /// Log an info message with structured fields (FR-OBS-001, FR-OBS-002, FR-OBS-003)
420    ///
421    /// All messages are sanitized to remove environment variables and redact secrets.
422    #[allow(dead_code)] // Future-facing: used for structured info logging
423    pub fn info(&self, message: &str) {
424        let sanitized_message = self.sanitize(message);
425        if let (Some(spec_id), Some(phase), Some(runner_mode)) = (
426            self.spec_id.as_ref(),
427            self.phase.as_ref(),
428            self.runner_mode.as_ref(),
429        ) {
430            info!(
431                spec_id = %spec_id,
432                phase = %phase,
433                runner_mode = %runner_mode,
434                duration_ms = %self.start_time.elapsed().as_millis(),
435                message = %sanitized_message,
436            );
437        } else {
438            info!(message = %sanitized_message);
439        }
440    }
441
442    /// Log a warning message with structured fields (FR-OBS-001, FR-OBS-002, FR-OBS-003)
443    ///
444    /// All messages are sanitized to remove environment variables and redact secrets.
445    #[allow(dead_code)] // Future-facing: used for structured warning logging
446    pub fn warn(&self, message: &str) {
447        let sanitized_message = self.sanitize(message);
448        if let (Some(spec_id), Some(phase), Some(runner_mode)) = (
449            self.spec_id.as_ref(),
450            self.phase.as_ref(),
451            self.runner_mode.as_ref(),
452        ) {
453            warn!(
454                spec_id = %spec_id,
455                phase = %phase,
456                runner_mode = %runner_mode,
457                duration_ms = %self.start_time.elapsed().as_millis(),
458                message = %sanitized_message,
459            );
460        } else {
461            warn!(message = %sanitized_message);
462        }
463    }
464
465    /// Log an error message with structured fields (FR-OBS-001, FR-OBS-002, FR-OBS-003)
466    ///
467    /// All messages are sanitized to remove environment variables and redact secrets.
468    /// This is critical for error messages which may contain sensitive context.
469    #[allow(dead_code)] // Future-facing: used for structured error logging
470    pub fn error(&self, message: &str) {
471        let sanitized_message = self.sanitize(message);
472        if let (Some(spec_id), Some(phase), Some(runner_mode)) = (
473            self.spec_id.as_ref(),
474            self.phase.as_ref(),
475            self.runner_mode.as_ref(),
476        ) {
477            error!(
478                spec_id = %spec_id,
479                phase = %phase,
480                runner_mode = %runner_mode,
481                duration_ms = %self.start_time.elapsed().as_millis(),
482                message = %sanitized_message,
483            );
484        } else {
485            error!(message = %sanitized_message);
486        }
487    }
488
489    /// Start timing an operation
490    pub fn start_timing(&mut self, operation: &str) {
491        if self.verbose {
492            self.verbose(&format!("Starting: {operation}"));
493        }
494    }
495
496    /// End timing an operation and record the duration
497    pub fn end_timing(&mut self, operation: &str) -> Duration {
498        let duration = self.start_time.elapsed();
499        self.operation_timings
500            .insert(operation.to_string(), duration);
501
502        // Also record for percentile calculation
503        self.operation_samples
504            .entry(operation.to_string())
505            .or_default()
506            .push(duration);
507
508        // Update memory usage
509        self.update_memory_usage();
510
511        if self.verbose {
512            self.verbose(&format!(
513                "Completed: {} ({:.3}s)",
514                operation,
515                duration.as_secs_f64()
516            ));
517        }
518
519        duration
520    }
521
522    /// Log a file operation (R7.5, NFR5)
523    #[allow(dead_code)] // Future-facing: used for file operation logging
524    pub fn log_file_operation(
525        &mut self,
526        path: &str,
527        operation: &str,
528        size_bytes: Option<usize>,
529        hash: Option<String>,
530    ) {
531        let file_op = FileOperation {
532            path: path.to_string(),
533            operation: operation.to_string(),
534            size_bytes,
535            hash: hash.clone(),
536            timestamp: Utc::now(),
537        };
538
539        self.file_operations.push(file_op);
540
541        if self.verbose {
542            let size_info = size_bytes
543                .map(|s| format!(" ({s} bytes)"))
544                .unwrap_or_default();
545
546            let hash_info = hash
547                .map(|h| format!(" [{}]", &h[..8.min(h.len())]))
548                .unwrap_or_default();
549
550            self.verbose(&format!("File {operation}: {path}{size_info}{hash_info}"));
551        }
552    }
553
554    /// Log selected files for packet construction (R7.5, NFR5)
555    #[allow(dead_code)] // Future-facing: used for verbose packet logging
556    pub fn log_selected_files(&self, file_count: usize, total_bytes: usize) {
557        if !self.verbose {
558            return;
559        }
560
561        self.verbose(&format!("Selected {file_count} files for packet"));
562        self.verbose(&format!("Total packet size: {total_bytes} bytes"));
563    }
564
565    /// Log individual file selection (R7.5, NFR5)
566    #[allow(dead_code)] // Future-facing: used for verbose packet logging
567    pub fn log_file_selected(&self, path: &str, bytes: usize, priority: &str) {
568        if self.verbose {
569            self.verbose(&format!("  - {path} ({bytes} bytes) [{priority}]"));
570        }
571    }
572
573    /// Log packet construction details (R7.5, NFR5)
574    #[allow(dead_code)] // Future-facing: used for verbose packet logging
575    pub fn log_packet_construction(
576        &self,
577        used_bytes: usize,
578        used_lines: usize,
579        max_bytes: usize,
580        max_lines: usize,
581    ) {
582        if self.verbose {
583            let bytes_pct = (used_bytes as f64 / max_bytes as f64) * 100.0;
584            let lines_pct = (used_lines as f64 / max_lines as f64) * 100.0;
585
586            self.verbose(&format!(
587                "Packet budget: {used_bytes} / {max_bytes} bytes ({bytes_pct:.1}%), {used_lines} / {max_lines} lines ({lines_pct:.1}%)"
588            ));
589        }
590    }
591
592    /// Log Claude CLI execution details (R7.5)
593    #[allow(dead_code)] // Future-facing: used for verbose execution logging
594    pub fn log_claude_execution(&self, model: &str, runner: &str, args: &[String]) {
595        if self.verbose {
596            self.verbose("Executing Claude CLI:");
597            self.verbose(&format!("  Model: {model}"));
598            self.verbose(&format!("  Runner: {runner}"));
599            self.verbose(&format!("  Args: {}", args.join(" ")));
600        }
601    }
602
603    /// Log canonicalization details (R7.5)
604    #[allow(dead_code)] // Future-facing: used for verbose canonicalization logging
605    pub fn log_canonicalization(
606        &self,
607        file_type: &str,
608        original_size: usize,
609        canonical_size: usize,
610        hash: &str,
611    ) {
612        if self.verbose {
613            let short_hash = &hash[..8.min(hash.len())];
614            self.verbose(&format!(
615                "Canonicalized {file_type}: {original_size} → {canonical_size} bytes [{short_hash}]"
616            ));
617        }
618    }
619
620    /// Log secret redaction results (R7.5, NFR5 - no secrets logged)
621    #[allow(dead_code)] // Future-facing: used for verbose redaction logging
622    pub fn log_redaction_results(
623        &self,
624        files_scanned: usize,
625        patterns_matched: usize,
626        files_with_secrets: usize,
627    ) {
628        if self.verbose {
629            self.verbose(&format!(
630                "Secret redaction: {files_scanned} files scanned, {patterns_matched} patterns matched in {files_with_secrets} files"
631            ));
632
633            if patterns_matched > 0 {
634                self.verbose("  ⚠ Secrets detected - run aborted for security");
635            }
636        }
637    }
638
639    /// Get timing summary for all operations
640    #[must_use]
641    #[allow(dead_code)] // Future-facing: used for performance reporting
642    pub fn get_timing_summary(&self) -> Vec<TimingInfo> {
643        self.operation_timings
644            .iter()
645            .map(|(op, duration)| TimingInfo {
646                operation: op.clone(),
647                duration: *duration,
648                timestamp: Utc::now(), // Approximation
649            })
650            .collect()
651    }
652
653    /// Get file operations summary
654    #[must_use]
655    #[allow(dead_code)] // Future-facing: used for file operation reporting
656    pub fn get_file_operations(&self) -> &[FileOperation] {
657        &self.file_operations
658    }
659
660    /// Get total elapsed time since logger creation
661    #[must_use]
662    pub fn total_elapsed(&self) -> Duration {
663        self.start_time.elapsed()
664    }
665
666    /// Update memory usage tracking
667    fn update_memory_usage(&mut self) {
668        self.system.refresh_memory();
669        let current_memory = Self::get_current_memory_usage(&self.system);
670        if current_memory > self.peak_memory {
671            self.peak_memory = current_memory;
672        }
673    }
674
675    /// Get current memory usage for this process
676    fn get_current_memory_usage(system: &System) -> u64 {
677        let pid = Pid::from(std::process::id() as usize);
678        system.process(pid).map_or(0, sysinfo::Process::memory) * 1024 // Convert from KB to bytes
679    }
680
681    /// Record packetization timing with file count (NFR1)
682    pub fn record_packetization_timing(&mut self, duration: Duration, file_count: usize) {
683        self.operation_samples
684            .entry("packetization".to_string())
685            .or_default()
686            .push(duration);
687
688        if self.verbose {
689            let per_file_ms = if file_count > 0 {
690                duration.as_millis() as f64 / file_count as f64
691            } else {
692                0.0
693            };
694
695            self.verbose(&format!(
696                "Packetization: {:.1}ms for {} files ({:.2}ms/file)",
697                duration.as_millis(),
698                file_count,
699                per_file_ms
700            ));
701
702            // Check against performance target (NFR1)
703            let target_for_files = if file_count > 0 {
704                let target_millis = (self
705                    .performance_targets
706                    .packetization_target_per_100_files
707                    .as_millis() as u64
708                    * file_count as u64)
709                    / 100;
710                Duration::from_millis(target_millis)
711            } else {
712                Duration::from_millis(0)
713            };
714
715            if duration > target_for_files {
716                self.verbose(&format!(
717                    "  ⚠ Packetization slower than target ({:.1}ms vs {:.1}ms)",
718                    duration.as_millis(),
719                    target_for_files.as_millis()
720                ));
721            } else {
722                self.verbose("  ✓ Packetization within target");
723            }
724        }
725    }
726
727    /// Record empty run timing (NFR1)
728    pub fn record_empty_run_timing(&mut self, duration: Duration) {
729        self.operation_samples
730            .entry("empty_run".to_string())
731            .or_default()
732            .push(duration);
733
734        if self.verbose {
735            self.verbose(&format!("Empty run: {:.3}s", duration.as_secs_f64()));
736
737            // Check against performance target (NFR1)
738            if duration > self.performance_targets.empty_run_target {
739                self.verbose(&format!(
740                    "  ⚠ Empty run slower than target ({:.1}s vs {:.1}s)",
741                    duration.as_secs_f64(),
742                    self.performance_targets.empty_run_target.as_secs_f64()
743                ));
744            } else {
745                self.verbose("  ✓ Empty run within target");
746            }
747        }
748    }
749
750    /// Calculate timing percentiles for an operation
751    #[must_use]
752    pub fn calculate_percentiles(&self, operation: &str) -> Option<TimingPercentiles> {
753        let samples = self.operation_samples.get(operation)?;
754        if samples.is_empty() {
755            return None;
756        }
757
758        let mut sorted_samples = samples.clone();
759        sorted_samples.sort();
760
761        let count = sorted_samples.len();
762        let p50_idx = count / 2;
763        let p95_idx = (count * 95) / 100;
764
765        Some(TimingPercentiles {
766            p50: sorted_samples[p50_idx],
767            p95: sorted_samples[p95_idx.min(count - 1)],
768            min: sorted_samples[0],
769            max: sorted_samples[count - 1],
770            count,
771        })
772    }
773
774    /// Generate comprehensive performance metrics (NFR1)
775    pub fn generate_performance_metrics(&mut self) -> PerformanceMetrics {
776        self.update_memory_usage();
777
778        let total_duration = self.total_elapsed();
779        let empty_run_duration = self
780            .operation_samples
781            .get("empty_run")
782            .and_then(|samples| samples.last())
783            .copied();
784        let packetization_duration = self
785            .operation_samples
786            .get("packetization")
787            .and_then(|samples| samples.last())
788            .copied();
789
790        let files_processed = self.file_operations.len();
791
792        let memory_stats = MemoryStats {
793            peak_memory_bytes: self.peak_memory,
794            initial_memory_bytes: self.initial_memory,
795            final_memory_bytes: Self::get_current_memory_usage(&self.system),
796        };
797
798        let mut timing_percentiles = HashMap::new();
799        for operation in self.operation_samples.keys() {
800            if let Some(percentiles) = self.calculate_percentiles(operation) {
801                timing_percentiles.insert(operation.clone(), percentiles);
802            }
803        }
804
805        PerformanceMetrics {
806            total_duration,
807            empty_run_duration,
808            packetization_duration,
809            files_processed,
810            memory_stats,
811            timing_percentiles,
812        }
813    }
814
815    /// Validate performance against targets (NFR1)
816    #[must_use]
817    pub fn validate_performance_targets(&self) -> Vec<String> {
818        let mut violations = Vec::new();
819
820        // Check empty run target
821        if let Some(samples) = self.operation_samples.get("empty_run")
822            && let Some(&last_empty_run) = samples.last()
823            && last_empty_run > self.performance_targets.empty_run_target
824        {
825            violations.push(format!(
826                "Empty run exceeded target: {:.3}s > {:.3}s",
827                last_empty_run.as_secs_f64(),
828                self.performance_targets.empty_run_target.as_secs_f64()
829            ));
830        }
831
832        // Check packetization target (scaled by file count)
833        if let Some(samples) = self.operation_samples.get("packetization")
834            && let Some(&last_packetization) = samples.last()
835        {
836            let file_count = self.file_operations.len();
837            if file_count > 0 {
838                let target_millis = (self
839                    .performance_targets
840                    .packetization_target_per_100_files
841                    .as_millis() as u64
842                    * file_count as u64)
843                    / 100;
844                let target_for_files = Duration::from_millis(target_millis);
845
846                if last_packetization > target_for_files {
847                    violations.push(format!(
848                        "Packetization exceeded target: {:.1}ms > {:.1}ms for {} files",
849                        last_packetization.as_millis(),
850                        target_for_files.as_millis(),
851                        file_count
852                    ));
853                }
854            }
855        }
856
857        violations
858    }
859
860    /// Log cache statistics (wires cache stats into logging)
861    #[allow(dead_code)] // Diagnostic logging utility
862    pub fn log_cache_stats(&self, stats: &crate::cache::CacheStats) {
863        if !self.verbose {
864            return;
865        }
866
867        self.verbose(&format!(
868            "Insight cache: {} hits, {} misses, {} entries, hit_ratio={:.2}",
869            stats.hits,
870            stats.misses,
871            stats.hits + stats.misses,
872            stats.hit_ratio()
873        ));
874    }
875
876    /// Print performance summary if verbose
877    pub fn print_performance_summary(&self) {
878        if !self.verbose {
879            return;
880        }
881
882        let total_time = self.total_elapsed();
883        self.verbose("=== Performance Summary ===");
884        self.verbose(&format!(
885            "Total execution time: {:.3}s",
886            total_time.as_secs_f64()
887        ));
888
889        // Memory usage summary
890        let memory_mb = self.peak_memory as f64 / (1024.0 * 1024.0);
891        let initial_mb = self.initial_memory as f64 / (1024.0 * 1024.0);
892        self.verbose(&format!(
893            "Memory usage: {memory_mb:.1}MB peak ({initial_mb:.1}MB initial)"
894        ));
895
896        // Performance target validation
897        let violations = self.validate_performance_targets();
898        if violations.is_empty() {
899            self.verbose("✓ All performance targets met");
900        } else {
901            self.verbose("⚠ Performance target violations:");
902            for violation in violations {
903                self.verbose(&format!("  - {violation}"));
904            }
905        }
906
907        if !self.operation_timings.is_empty() {
908            self.verbose("Operation timings:");
909            let mut timings: Vec<_> = self.operation_timings.iter().collect();
910            timings.sort_by(|a, b| b.1.cmp(a.1)); // Sort by duration, descending
911
912            for (operation, duration) in timings {
913                let percentage = (duration.as_secs_f64() / total_time.as_secs_f64()) * 100.0;
914
915                // Show percentiles if available
916                if let Some(percentiles) = self.calculate_percentiles(operation) {
917                    self.verbose(&format!(
918                        "  {}: {:.3}s ({:.1}%) [P50: {:.3}s, P95: {:.3}s, n={}]",
919                        operation,
920                        duration.as_secs_f64(),
921                        percentage,
922                        percentiles.p50.as_secs_f64(),
923                        percentiles.p95.as_secs_f64(),
924                        percentiles.count
925                    ));
926                } else {
927                    self.verbose(&format!(
928                        "  {}: {:.3}s ({:.1}%)",
929                        operation,
930                        duration.as_secs_f64(),
931                        percentage
932                    ));
933                }
934            }
935        }
936
937        if !self.file_operations.is_empty() {
938            let total_files = self.file_operations.len();
939            let total_bytes: usize = self
940                .file_operations
941                .iter()
942                .filter_map(|op| op.size_bytes)
943                .sum();
944
945            self.verbose(&format!(
946                "File operations: {total_files} total, {total_bytes} bytes processed"
947            ));
948        }
949    }
950}
951
952/// Log doctor report to console (wires Doctor into logging)
953pub fn log_doctor_report(report: &crate::types::DoctorOutput) {
954    use crate::types::CheckStatus;
955    use crossterm::style::{Attribute, Color, Stylize};
956
957    println!(
958        "{}",
959        "🩺 xchecker Environment Health Check"
960            .with(Color::Cyan)
961            .attribute(Attribute::Bold)
962    );
963    println!(
964        "{}",
965        "─────────────────────────────────────"
966            .with(Color::Cyan)
967            .attribute(Attribute::Bold)
968    );
969    println!();
970
971    for check in &report.checks {
972        let (status_symbol, color) = match check.status {
973            CheckStatus::Pass => ("✓", Color::Green),
974            CheckStatus::Warn => ("⚠", Color::Yellow),
975            CheckStatus::Fail => ("✗", Color::Red),
976        };
977
978        // Format name as Title Case for better readability (e.g., claude_path -> Claude Path)
979        let formatted_name = to_title_case(&check.name);
980
981        // Build the status line
982        // Pass: "✓ Claude Path"
983        // Warn: "⚠ Claude Path [WARN]"
984        // Fail: "✗ Claude Path [FAIL]"
985        match check.status {
986            CheckStatus::Pass => {
987                println!(
988                    "{} {}",
989                    status_symbol.with(color).attribute(Attribute::Bold),
990                    formatted_name.attribute(Attribute::Bold)
991                );
992            }
993            CheckStatus::Warn => {
994                println!(
995                    "{} {} {}",
996                    status_symbol.with(color).attribute(Attribute::Bold),
997                    formatted_name.attribute(Attribute::Bold),
998                    "[WARN]".with(color).attribute(Attribute::Bold)
999                );
1000            }
1001            CheckStatus::Fail => {
1002                println!(
1003                    "{} {} {}",
1004                    status_symbol.with(color).attribute(Attribute::Bold),
1005                    formatted_name.attribute(Attribute::Bold),
1006                    "[FAIL]".with(color).attribute(Attribute::Bold)
1007                );
1008            }
1009        }
1010
1011        println!("  {}", check.details);
1012        println!();
1013    }
1014
1015    // Add separator
1016    println!(
1017        "{}",
1018        "─────────────────────────────────────".with(Color::DarkGrey)
1019    );
1020
1021    let (overall_text, overall_color) = if report.ok {
1022        ("✓ HEALTHY: All systems operational", Color::Green)
1023    } else {
1024        ("✗ ISSUES DETECTED", Color::Red)
1025    };
1026
1027    println!(
1028        "{}",
1029        overall_text.with(overall_color).attribute(Attribute::Bold)
1030    );
1031
1032    if !report.ok {
1033        println!();
1034        println!(
1035            "{}",
1036            "Tip: Run 'xchecker doctor --verbose' for detailed diagnostics.".with(Color::Yellow)
1037        );
1038        println!(
1039            "{}",
1040            "     See docs/DOCTOR.md for troubleshooting steps.".with(Color::Yellow)
1041        );
1042    }
1043}
1044
1045// Helper to convert snake_case to Title Case with acronym support
1046fn to_title_case(s: &str) -> String {
1047    // List of known acronyms that should be fully uppercase
1048    const ACRONYMS: &[&str] = &[
1049        "CLI", "LLM", "WSL", "API", "URL", "JSON", "YAML", "GH", "FS", "HTTP",
1050    ];
1051
1052    s.split('_')
1053        .map(|word| {
1054            // Check if word (case-insensitive) matches any known acronym
1055            let upper_word = word.to_uppercase();
1056            if ACRONYMS.contains(&upper_word.as_str()) {
1057                return upper_word;
1058            }
1059
1060            // Otherwise standard title case
1061            let mut c = word.chars();
1062            match c.next() {
1063                None => String::new(),
1064                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1065            }
1066        })
1067        .collect::<Vec<String>>()
1068        .join(" ")
1069}
1070
1071/// Log cache statistics (standalone function for use outside Logger)
1072pub fn log_cache_stats(stats: &crate::cache::CacheStats) {
1073    tracing::info!(
1074        target: "xchecker::cache",
1075        hits = stats.hits,
1076        misses = stats.misses,
1077        entries = stats.hits + stats.misses,
1078        hit_ratio = stats.hit_ratio(),
1079        "Insight cache stats: hits={}, misses={}, entries={}, hit_ratio={:.2}",
1080        stats.hits,
1081        stats.misses,
1082        stats.hits + stats.misses,
1083        stats.hit_ratio()
1084    );
1085}
1086
1087/// Macro for verbose logging with formatting
1088#[macro_export]
1089macro_rules! verbose {
1090    ($logger:expr, $($arg:tt)*) => {
1091        $logger.verbose_fmt(format_args!($($arg)*))
1092    };
1093}
1094
1095/// Macro for timing operations
1096#[macro_export]
1097macro_rules! time_operation {
1098    ($logger:expr, $operation:expr, $block:block) => {{
1099        $logger.start_timing($operation);
1100        let result = $block;
1101        $logger.end_timing($operation);
1102        result
1103    }};
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109    use crate::test_support;
1110    use std::thread;
1111
1112    #[test]
1113    fn test_logger_creation() {
1114        let logger = Logger::new(true);
1115        assert!(logger.is_verbose());
1116        assert!(logger.operation_timings.is_empty());
1117        assert!(logger.file_operations.is_empty());
1118        assert!(logger.spec_id.is_none());
1119        assert!(logger.phase.is_none());
1120        assert!(logger.runner_mode.is_none());
1121    }
1122
1123    #[test]
1124    fn test_logger_context_setters() {
1125        let mut logger = Logger::new(false);
1126
1127        logger.set_spec_id("test-spec".to_string());
1128        logger.set_phase("requirements".to_string());
1129        logger.set_runner_mode("native".to_string());
1130
1131        assert_eq!(logger.spec_id(), Some("test-spec"));
1132        assert_eq!(logger.phase(), Some("requirements"));
1133        assert_eq!(logger.runner_mode(), Some("native"));
1134    }
1135
1136    #[test]
1137    fn test_timing_operations() {
1138        let mut logger = Logger::new(false); // Non-verbose for test
1139
1140        logger.start_timing("test_operation");
1141        thread::sleep(Duration::from_millis(10));
1142        let duration = logger.end_timing("test_operation");
1143
1144        assert!(duration >= Duration::from_millis(10));
1145        assert!(logger.operation_timings.contains_key("test_operation"));
1146    }
1147
1148    #[test]
1149    fn test_file_operation_logging() {
1150        let mut logger = Logger::new(false);
1151
1152        logger.log_file_operation(
1153            "test.txt",
1154            "read",
1155            Some(1024),
1156            Some("abc123def456".to_string()),
1157        );
1158
1159        assert_eq!(logger.file_operations.len(), 1);
1160        let op = &logger.file_operations[0];
1161        assert_eq!(op.path, "test.txt");
1162        assert_eq!(op.operation, "read");
1163        assert_eq!(op.size_bytes, Some(1024));
1164        assert_eq!(op.hash, Some("abc123def456".to_string()));
1165    }
1166
1167    #[test]
1168    fn test_timing_summary() {
1169        let mut logger = Logger::new(false);
1170
1171        logger.start_timing("op1");
1172        thread::sleep(Duration::from_millis(5));
1173        logger.end_timing("op1");
1174
1175        logger.start_timing("op2");
1176        thread::sleep(Duration::from_millis(10));
1177        logger.end_timing("op2");
1178
1179        let summary = logger.get_timing_summary();
1180        assert_eq!(summary.len(), 2);
1181
1182        // Find op2 which should be longer
1183        let op2_timing = summary.iter().find(|t| t.operation == "op2").unwrap();
1184        assert!(op2_timing.duration >= Duration::from_millis(10));
1185    }
1186
1187    #[test]
1188    fn test_verbose_mode_disabled() {
1189        let logger = Logger::new(false);
1190
1191        // These should not panic or produce output when verbose is false
1192        logger.verbose("test message");
1193        logger.verbose_fmt(format_args!("formatted {}", "message"));
1194    }
1195
1196    #[test]
1197    fn test_performance_summary() {
1198        let mut logger = Logger::new(false);
1199
1200        logger.log_file_operation("file1.txt", "read", Some(100), None);
1201        logger.log_file_operation("file2.txt", "write", Some(200), Some("hash123".to_string()));
1202
1203        logger.start_timing("test_op");
1204        thread::sleep(Duration::from_millis(5));
1205        logger.end_timing("test_op");
1206
1207        // Should not panic
1208        logger.print_performance_summary();
1209
1210        assert_eq!(logger.get_file_operations().len(), 2);
1211        assert!(logger.total_elapsed() >= Duration::from_millis(5));
1212    }
1213
1214    #[test]
1215    fn test_tracing_initialization_compact() {
1216        // Test compact format initialization
1217        // Note: This will fail if tracing is already initialized in the test process
1218        // In real usage, init_tracing is called once at program start
1219        let result = init_tracing(false);
1220        // May fail if already initialized, which is okay in tests
1221        assert!(result.is_ok() || result.is_err());
1222    }
1223
1224    #[test]
1225    fn test_tracing_initialization_verbose() {
1226        // Test verbose format initialization
1227        // Note: This will fail if tracing is already initialized in the test process
1228        let result = init_tracing(true);
1229        // May fail if already initialized, which is okay in tests
1230        assert!(result.is_ok() || result.is_err());
1231    }
1232
1233    #[test]
1234    fn test_phase_span_creation() {
1235        // Test that phase span can be created without panic
1236        let span = phase_span("test-spec", "requirements", "native");
1237        // Verify the span was created (metadata may be None if tracing not initialized)
1238        if let Some(metadata) = span.metadata() {
1239            assert_eq!(metadata.name(), "phase_execution");
1240        }
1241        // The important thing is that creating the span doesn't panic
1242    }
1243
1244    #[test]
1245    fn test_structured_logging_functions() {
1246        // Test that structured logging functions don't panic
1247        log_phase_start("test-spec", "requirements", "native");
1248        log_phase_complete("test-spec", "requirements", 1000);
1249        log_phase_error("test-spec", "requirements", "test error", 1000);
1250    }
1251
1252    #[test]
1253    fn test_logger_structured_methods() {
1254        let mut logger = Logger::new(false);
1255
1256        // Set context
1257        logger.set_spec_id("test-spec".to_string());
1258        logger.set_phase("requirements".to_string());
1259        logger.set_runner_mode("native".to_string());
1260
1261        // Test structured logging methods don't panic
1262        logger.info("test info message");
1263        logger.warn("test warning message");
1264        logger.error("test error message");
1265    }
1266
1267    #[test]
1268    fn test_logger_structured_methods_without_context() {
1269        let logger = Logger::new(false);
1270
1271        // Test structured logging methods work without context
1272        logger.info("test info message");
1273        logger.warn("test warning message");
1274        logger.error("test error message");
1275    }
1276
1277    #[test]
1278    fn test_verbose_format_includes_required_fields() {
1279        // Test that verbose logging includes spec_id, phase, duration_ms, runner_mode
1280        let mut logger = Logger::new(true);
1281
1282        // Set all required context fields (FR-OBS-001)
1283        logger.set_spec_id("test-spec-123".to_string());
1284        logger.set_phase("design".to_string());
1285        logger.set_runner_mode("wsl".to_string());
1286
1287        // Verify context is set
1288        assert_eq!(logger.spec_id(), Some("test-spec-123"));
1289        assert_eq!(logger.phase(), Some("design"));
1290        assert_eq!(logger.runner_mode(), Some("wsl"));
1291
1292        // Log messages with structured fields
1293        logger.info("test message with all fields");
1294        logger.verbose("verbose message with all fields");
1295
1296        // The actual field output is tested by the tracing subscriber
1297        // which we can see in the test output above
1298    }
1299
1300    #[test]
1301    fn test_default_format_is_compact() {
1302        // Test that default (non-verbose) format is compact
1303        let logger = Logger::new(false);
1304        assert!(!logger.is_verbose());
1305
1306        // Compact format should work without context
1307        logger.info("compact message");
1308    }
1309
1310    #[test]
1311    fn test_phase_logging_functions() {
1312        // Test the standalone phase logging functions (FR-OBS-001)
1313        log_phase_start("spec-1", "requirements", "native");
1314        log_phase_complete("spec-1", "requirements", 5000);
1315        log_phase_error("spec-1", "requirements", "timeout occurred", 10000);
1316
1317        // These should not panic and should emit structured logs
1318    }
1319
1320    // Tests for redaction in logging (FR-OBS-002, FR-OBS-003)
1321
1322    #[test]
1323    fn test_redact_github_token_in_log() {
1324        let logger = Logger::new(false);
1325        let token = test_support::github_pat();
1326        let message = format!("Using token {} for auth", token);
1327        let redacted = logger.redact(&message);
1328
1329        // Should not contain the actual token
1330        assert!(!redacted.contains(&token));
1331        // Should contain redaction marker
1332        assert!(redacted.contains("[REDACTED:github_pat]"));
1333    }
1334
1335    #[test]
1336    fn test_redact_aws_key_in_log() {
1337        let logger = Logger::new(false);
1338        let aws_key = test_support::aws_access_key_id();
1339        let message = format!("AWS key: {}", aws_key);
1340        let redacted = logger.redact(&message);
1341
1342        assert!(!redacted.contains(&aws_key));
1343        assert!(redacted.contains("[REDACTED:aws_access_key]"));
1344    }
1345
1346    #[test]
1347    fn test_redact_bearer_token_in_log() {
1348        let logger = Logger::new(false);
1349        let token = test_support::bearer_token();
1350        let message = format!("Authorization: {}", token);
1351        let redacted = logger.redact(&message);
1352
1353        assert!(!redacted.contains(&token));
1354        assert!(redacted.contains("[REDACTED:bearer_token]"));
1355    }
1356
1357    #[test]
1358    fn test_sanitize_environment_variables() {
1359        let logger = Logger::new(false);
1360        let aws_secret = test_support::aws_secret_access_key();
1361
1362        // Test various environment variable patterns
1363        let test_cases = vec![
1364            "API_KEY=secret123".to_string(),
1365            "SECRET_TOKEN=abc".to_string(),
1366            "PASSWORD=mypass".to_string(),
1367            aws_secret,
1368        ];
1369
1370        for test_case in test_cases {
1371            let sanitized = logger.sanitize(&test_case);
1372            // Should be redacted as environment variable
1373            assert_eq!(sanitized, "[ENV_VAR_REDACTED]");
1374        }
1375    }
1376
1377    #[test]
1378    fn test_sanitize_normal_content() {
1379        let logger = Logger::new(false);
1380        let message = "Processing file test.txt with 1024 bytes";
1381        let sanitized = logger.sanitize(message);
1382
1383        // Normal content should pass through unchanged
1384        assert_eq!(sanitized, message);
1385    }
1386
1387    #[test]
1388    fn test_verbose_logging_redacts_secrets() {
1389        let logger = Logger::new(true);
1390        let token = test_support::github_pat();
1391
1392        // This should not panic and should redact the secret
1393        // We can't easily test the output, but we can verify it doesn't crash
1394        logger.verbose(&format!("Token: {}", token));
1395    }
1396
1397    #[test]
1398    fn test_info_logging_redacts_secrets() {
1399        let logger = Logger::new(false);
1400        let aws_key = test_support::aws_access_key_id();
1401
1402        // Should redact secrets in info logs
1403        logger.info(&format!("Using AWS key {}", aws_key));
1404    }
1405
1406    #[test]
1407    fn test_warn_logging_redacts_secrets() {
1408        let logger = Logger::new(false);
1409        let token = test_support::github_pat();
1410
1411        // Should redact secrets in warning logs
1412        logger.warn(&format!("Warning: exposed token {}", token));
1413    }
1414
1415    #[test]
1416    fn test_error_logging_redacts_secrets() {
1417        let logger = Logger::new(false);
1418        let token = test_support::bearer_token();
1419
1420        // Should redact secrets in error logs
1421        logger.error(&format!("Error: failed with {}", token));
1422    }
1423
1424    #[test]
1425    fn test_multiple_secrets_in_message() {
1426        let logger = Logger::new(false);
1427        let github_token = test_support::github_pat();
1428        let aws_key = test_support::aws_access_key_id();
1429        // Test with secrets on separate lines to avoid the multi-secret-per-line bug in redaction.rs
1430        let message = format!("GitHub token {}\nAWS key {}", github_token, aws_key);
1431        let redacted = logger.redact(&message);
1432
1433        // Both secrets should be redacted when on separate lines
1434        assert!(!redacted.contains(&github_token));
1435        assert!(!redacted.contains(&aws_key));
1436        assert!(redacted.contains("[REDACTED:github_pat]"));
1437        assert!(redacted.contains("[REDACTED:aws_access_key]"));
1438    }
1439
1440    #[test]
1441    fn test_log_phase_error_redacts_secrets() {
1442        let token = test_support::github_pat();
1443        // Test the standalone function
1444        log_phase_error(
1445            "test-spec",
1446            "requirements",
1447            &format!("Failed with token {}", token),
1448            1000,
1449        );
1450
1451        // If this doesn't panic, the redaction is working
1452        // We can't easily capture the log output in tests, but we verify it doesn't crash
1453    }
1454
1455    #[test]
1456    fn test_redaction_with_context() {
1457        let mut logger = Logger::new(false);
1458        logger.set_spec_id("test-spec".to_string());
1459        logger.set_phase("requirements".to_string());
1460        logger.set_runner_mode("native".to_string());
1461        let token = test_support::github_pat();
1462
1463        // Should redact even with context set
1464        logger.error(&format!("Error with secret: {}", token));
1465    }
1466
1467    #[test]
1468    fn test_no_environment_variables_in_logs() {
1469        let logger = Logger::new(false);
1470
1471        // Simulate logging something that looks like env vars
1472        let message = "Config: DATABASE_PASSWORD=secret123";
1473        let sanitized = logger.sanitize(message);
1474
1475        // Should not contain the actual password
1476        assert!(!sanitized.contains("secret123"));
1477        assert_eq!(sanitized, "[ENV_VAR_REDACTED]");
1478    }
1479
1480    #[test]
1481    fn test_error_context_without_sensitive_data() {
1482        let logger = Logger::new(false);
1483
1484        // Test that error context is properly sanitized
1485        let error_msg = "Authentication failed with API_KEY=secret123";
1486        let sanitized = logger.sanitize(error_msg);
1487
1488        // Should not contain sensitive data
1489        assert!(!sanitized.contains("secret123"));
1490        assert_eq!(sanitized, "[ENV_VAR_REDACTED]");
1491    }
1492
1493    #[test]
1494    fn test_redaction_preserves_safe_content() {
1495        let logger = Logger::new(false);
1496        let message = "Processing completed successfully in 1.5 seconds";
1497        let redacted = logger.redact(message);
1498
1499        // Safe content should be preserved
1500        assert_eq!(redacted, message);
1501    }
1502
1503    #[test]
1504    fn test_sanitize_slack_token() {
1505        let logger = Logger::new(false);
1506        let token = test_support::slack_bot_token();
1507        let message = format!("Slack webhook: {}", token);
1508        let sanitized = logger.sanitize(&message);
1509
1510        // Should redact Slack token
1511        assert!(!sanitized.contains(&token));
1512        assert!(sanitized.contains("[REDACTED:slack_token]"));
1513    }
1514
1515    #[test]
1516    fn test_verbose_fmt_redacts_secrets() {
1517        let logger = Logger::new(true);
1518        let token = test_support::github_pat();
1519
1520        // Test formatted logging with secrets
1521        logger.verbose_fmt(format_args!("Token: {}", token));
1522
1523        // Should not panic and should redact
1524    }
1525
1526    #[test]
1527    fn test_to_title_case() {
1528        assert_eq!(to_title_case("claude_path"), "Claude Path");
1529        assert_eq!(to_title_case("claude_cli_version"), "Claude CLI Version");
1530        assert_eq!(to_title_case("wsl_availability"), "WSL Availability");
1531        assert_eq!(to_title_case("llm_provider"), "LLM Provider");
1532        assert_eq!(
1533            to_title_case("http_provider_config"),
1534            "HTTP Provider Config"
1535        );
1536        assert_eq!(to_title_case("json_output"), "JSON Output");
1537        assert_eq!(to_title_case("simple_test"), "Simple Test");
1538        assert_eq!(to_title_case("api_key"), "API Key");
1539        assert_eq!(to_title_case("gh_repo"), "GH Repo");
1540        assert_eq!(to_title_case("fs_path"), "FS Path");
1541    }
1542}