Skip to main content

snip_it/
logging.rs

1//! Logging infrastructure using the `tracing` crate.
2//!
3//! Provides structured logging with file rotation and panic handling.
4//!
5//! # Log Levels
6//!
7//! - `trace`: Very detailed diagnostics
8//! - `debug`: Debug information
9//! - `info`: General information (default)
10//! - `warn`: Warning messages
11//! - `error`: Error messages
12//!
13//! # Log Locations
14//!
15//! - All platforms: `~/.config/snp/logs/`
16//!   (or `$XDG_CONFIG_HOME/snp/logs/` if set)
17
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::LazyLock;
21use std::sync::mpsc;
22use std::time::{SystemTime, UNIX_EPOCH};
23use tracing::Level;
24use tracing_appender::non_blocking::WorkerGuard;
25use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
26
27const AUDIT_LOG_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024; // 10 MB — rotate audit log when exceeded
28const AUDIT_LOG_RETENTION_DAYS: u64 = 30; // Keep 30 days of rotated audit logs
29const AUDIT_LOG_CHANNEL_SIZE: usize = 1024; // Bounded channel for async audit writes
30const SECS_PER_DAY: u64 = 86400;
31
32struct AuditLogEntry {
33    timestamp: u64,
34    action: String,
35    snippet_id: String,
36    description: String,
37    library_id: String,
38    device_id: String,
39}
40
41static AUDIT_TX: LazyLock<std::sync::Mutex<Option<mpsc::SyncSender<AuditLogEntry>>>> =
42    LazyLock::new(|| std::sync::Mutex::new(None));
43
44/// Holds the non-blocking log writer guard alive for the process lifetime.
45/// Dropping this would stop log file writes.
46static LOG_GUARD: LazyLock<std::sync::Mutex<Option<WorkerGuard>>> =
47    LazyLock::new(|| std::sync::Mutex::new(None));
48
49/// Configuration for logging behavior.
50#[allow(dead_code)]
51pub struct LogConfig {
52    pub log_dir: PathBuf,
53    pub file_name: String,
54    pub level: Level,
55    pub include_target: bool,
56    pub audit_max_size_bytes: u64,
57    pub audit_retention_days: u64,
58}
59
60impl Default for LogConfig {
61    fn default() -> Self {
62        LogConfig {
63            log_dir: get_default_log_dir(),
64            file_name: "snp.log".to_string(),
65            level: Level::INFO,
66            include_target: true,
67            audit_max_size_bytes: AUDIT_LOG_MAX_SIZE_BYTES,
68            audit_retention_days: AUDIT_LOG_RETENTION_DAYS,
69        }
70    }
71}
72
73pub fn get_default_log_dir() -> PathBuf {
74    crate::utils::config::get_config_dir().join("logs")
75}
76
77fn level_str(level: Level) -> &'static str {
78    match level {
79        Level::ERROR => "error",
80        Level::WARN => "warn",
81        Level::INFO => "info",
82        Level::DEBUG => "debug",
83        Level::TRACE => "trace",
84    }
85}
86
87pub fn init_logging(config: &LogConfig) -> Result<(), Box<dyn std::error::Error>> {
88    let log_dir = &config.log_dir;
89
90    fs::create_dir_all(log_dir)?;
91
92    #[cfg(unix)]
93    {
94        use std::os::unix::fs::PermissionsExt;
95        let perms = fs::Permissions::from_mode(0o700);
96        fs::set_permissions(log_dir, perms)?;
97    }
98
99    let file_appender = tracing_appender::rolling::daily(log_dir, &config.file_name);
100
101    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
102
103    let env_filter = if let Ok(filter_str) = std::env::var("SNP_LOG") {
104        EnvFilter::try_new(&filter_str).unwrap_or_else(|e| {
105            eprintln!("Warning: Invalid SNP_LOG filter '{filter_str}': {e}");
106            EnvFilter::new(format!("snp={}", level_str(config.level)))
107        })
108    } else {
109        EnvFilter::try_from_default_env()
110            .unwrap_or_else(|_| EnvFilter::new(format!("snp={}", level_str(config.level))))
111    };
112
113    let file_layer = fmt::layer()
114        .with_writer(non_blocking)
115        .with_ansi(false)
116        .with_target(config.include_target)
117        .with_thread_ids(true)
118        .with_file(true)
119        .with_line_number(true);
120
121    let subscriber = tracing_subscriber::registry()
122        .with(env_filter)
123        .with(file_layer);
124
125    let _ = subscriber.try_init();
126
127    *LOG_GUARD.lock().unwrap_or_else(|e| e.into_inner()) = Some(guard);
128
129    init_async_audit_log();
130
131    tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
132    tracing::info!("Log level: {:?}", config.level);
133
134    Ok(())
135}
136
137pub fn init_default_logging() {
138    // Ensure the config directory exists (and is permission-tightened on
139    // Unix) before we try to create the logs subdirectory or the audit
140    // log. `ensure_config_dir` is idempotent and cheap on subsequent
141    // calls.
142    if let Err(e) = crate::utils::config::ensure_config_dir() {
143        eprintln!("Warning: Failed to ensure config directory: {e}");
144    }
145    let config = LogConfig::default();
146    if let Err(e) = init_logging(&config) {
147        eprintln!("Warning: Failed to initialize logging: {e}");
148    }
149    self_check();
150}
151
152fn self_check() {
153    let log_dir = get_default_log_dir();
154    if !log_dir.exists()
155        && let Err(e) = fs::create_dir_all(&log_dir)
156    {
157        eprintln!(
158            "Warning: Failed to create log directory {}: {}",
159            log_dir.display(),
160            e
161        );
162        return;
163    }
164
165    match fs::OpenOptions::new()
166        .create(true)
167        .append(true)
168        .open(log_dir.join(".self_check"))
169    {
170        Ok(_) => {
171            let _ = fs::remove_file(log_dir.join(".self_check"));
172        }
173        Err(e) => {
174            eprintln!(
175                "Warning: Log directory {} is not writable: {}",
176                log_dir.display(),
177                e
178            );
179        }
180    }
181
182    let config_dir = crate::utils::config::get_config_dir();
183    if !config_dir.exists()
184        && let Err(e) = fs::create_dir_all(&config_dir)
185    {
186        eprintln!(
187            "Warning: Failed to create config directory {}: {}",
188            config_dir.display(),
189            e
190        );
191    }
192}
193
194fn init_async_audit_log() {
195    let (tx, rx) = mpsc::sync_channel(AUDIT_LOG_CHANNEL_SIZE);
196
197    std::thread::spawn(move || {
198        let mut writer = AuditLogWriter { rx };
199        writer.run();
200    });
201
202    *AUDIT_TX.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
203}
204
205struct AuditLogWriter {
206    rx: mpsc::Receiver<AuditLogEntry>,
207}
208
209impl AuditLogWriter {
210    fn run(&mut self) {
211        while let Ok(entry) = self.rx.recv() {
212            if let Err(e) = self.write_entry(&entry) {
213                tracing::error!(error = %e, "Failed to write audit log entry");
214            }
215        }
216    }
217
218    fn write_entry(&self, entry: &AuditLogEntry) -> std::io::Result<()> {
219        let log_path = match get_audit_log_path() {
220            Ok(p) => p,
221            Err(e) => {
222                tracing::warn!(error = %e, "Failed to get audit log path");
223                return Err(e);
224            }
225        };
226
227        let _ = rotate_audit_log_if_needed(
228            &log_path,
229            AUDIT_LOG_MAX_SIZE_BYTES,
230            AUDIT_LOG_RETENTION_DAYS,
231        );
232
233        let log_entry = format!(
234            "{}|{}|{}|{}|{}|{}\n",
235            entry.timestamp,
236            entry.action,
237            escape_pipe(&entry.snippet_id),
238            escape_pipe(&entry.description),
239            entry.library_id,
240            escape_pipe(&entry.device_id),
241        );
242
243        use std::io::Write;
244        let mut file = fs::OpenOptions::new()
245            .create(true)
246            .append(true)
247            .open(&log_path)?;
248
249        #[cfg(unix)]
250        {
251            use std::os::unix::fs::PermissionsExt;
252            let perms = std::fs::Permissions::from_mode(0o600);
253            if let Err(e) = fs::set_permissions(&log_path, perms) {
254                tracing::warn!(
255                    path = %log_path.display(),
256                    error = %e,
257                    "Failed to set restrictive permissions on log file"
258                );
259            }
260        }
261
262        file.write_all(log_entry.as_bytes())
263    }
264}
265
266pub fn shutdown_logging() {
267    if let Some(tx) = AUDIT_TX.lock().unwrap_or_else(|e| e.into_inner()).take() {
268        drop(tx);
269    }
270    if let Some(guard) = LOG_GUARD.lock().unwrap_or_else(|e| e.into_inner()).take() {
271        tracing::info!("Logging shutdown complete");
272        std::thread::sleep(std::time::Duration::from_millis(100));
273        drop(guard);
274    }
275}
276
277fn extract_panic_info(panic_info: &std::panic::PanicHookInfo) -> (String, String) {
278    let location = panic_info
279        .location()
280        .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
281        .unwrap_or_else(|| "unknown".to_string());
282
283    let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
284        s.to_string()
285    } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
286        s.clone()
287    } else {
288        "Unknown panic".to_string()
289    };
290
291    (location, message)
292}
293
294pub fn log_panic_info(panic_info: &std::panic::PanicHookInfo) {
295    let (location, message) = extract_panic_info(panic_info);
296
297    tracing::error!(
298        target: "panic",
299        location = %location,
300        message = %message,
301        "Application panicked"
302    );
303}
304
305pub fn setup_panic_handler() {
306    let previous_hook = std::panic::take_hook();
307    std::panic::set_hook(Box::new(move |panic_info| {
308        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
309            // Best-effort: explicitly disable mouse capture before restoring
310            // the terminal, since some terminals leave the mouse in a weird
311            // state if `ratatui::restore()` runs alone.
312            let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
313            ratatui::restore();
314        }));
315
316        log_panic_info(panic_info);
317
318        // Flush the audit log before the process exits. The 100ms sleep in
319        // shutdown_logging gives the writer thread time to process the panic event.
320        shutdown_logging();
321
322        let (location, message) = extract_panic_info(panic_info);
323
324        eprintln!("PANIC at {location}: {message}");
325
326        previous_hook(panic_info);
327    }));
328}
329
330#[tracing::instrument(level = "info", skip(result), fields(command = %redact_command(command)))]
331pub fn log_command_execution(
332    command: &str,
333    args: &[String],
334    result: &std::result::Result<(), String>,
335    working_dir: Option<&std::path::Path>,
336) {
337    match result {
338        Ok(()) => {
339            tracing::info!(
340                args_count = args.len(),
341                working_dir = ?working_dir,
342                "Command executed successfully"
343            );
344        }
345        Err(e) => {
346            tracing::error!(
347                args_count = args.len(),
348                error = %e,
349                working_dir = ?working_dir,
350                "Command execution failed"
351            );
352        }
353    }
354}
355
356fn redact_command(command: &str) -> String {
357    if command.chars().count() > 80 {
358        let truncated: String = command.chars().take(77).collect();
359        format!("{truncated}...")
360    } else {
361        command.to_string()
362    }
363}
364
365pub fn log_config_operation(operation: &str, path: &Path, result: &Result<(), &str>) {
366    match result {
367        Ok(()) => {
368            tracing::debug!(
369                operation = %operation,
370                path = %path.display(),
371                "Config operation completed"
372            );
373        }
374        Err(e) => {
375            tracing::warn!(
376                operation = %operation,
377                path = %path.display(),
378                error = %e,
379                "Config operation failed"
380            );
381        }
382    }
383}
384
385pub fn log_clipboard_operation(operation: &str, success: bool) {
386    if success {
387        tracing::debug!(operation = %operation, "Clipboard operation successful");
388    } else {
389        tracing::warn!(operation = %operation, "Clipboard operation failed");
390    }
391}
392
393pub fn log_startup_info() {
394    tracing::debug!("=== SNP Application Starting ===");
395    tracing::debug!("Version: {}", env!("CARGO_PKG_VERSION"));
396    tracing::debug!("Platform: {}", std::env::consts::OS);
397    tracing::debug!("Architecture: {}", std::env::consts::ARCH);
398    tracing::debug!(
399        "Config directory: {}",
400        crate::utils::config::get_config_dir().display()
401    );
402    tracing::debug!("Log directory: {}", get_default_log_dir().display());
403}
404
405pub fn log_shutdown_info() {
406    tracing::info!("=== SNP Application Shutting Down ===");
407    shutdown_logging();
408}
409
410pub fn get_audit_log_path() -> std::io::Result<std::path::PathBuf> {
411    let cfg_dir = crate::utils::config::get_config_dir();
412    std::fs::create_dir_all(&cfg_dir)?;
413    Ok(cfg_dir.join("audit.log"))
414}
415
416#[tracing::instrument(level = "info", skip(snippet), fields(action = %action, snippet_id = %snippet.id))]
417pub fn audit_log(
418    action: &str,
419    snippet: &crate::library::Snippet,
420    library_id: Option<&str>,
421) -> std::io::Result<()> {
422    let timestamp = SystemTime::now()
423        .duration_since(UNIX_EPOCH)
424        .unwrap_or_default()
425        .as_secs();
426
427    let entry = AuditLogEntry {
428        timestamp,
429        action: action.to_string(),
430        snippet_id: snippet.id.clone(),
431        description: snippet.description.clone(),
432        library_id: library_id.unwrap_or("").to_string(),
433        device_id: snippet.device_id.clone(),
434    };
435
436    let tx = AUDIT_TX
437        .lock()
438        .unwrap_or_else(|e| e.into_inner())
439        .as_ref()
440        .cloned();
441
442    match tx {
443        Some(tx) => tx.try_send(entry).map_err(|e| {
444            tracing::warn!("Audit log channel full, dropping entry: {}", e);
445            std::io::Error::new(std::io::ErrorKind::WouldBlock, e.to_string())
446        }),
447        None => {
448            tracing::warn!("Audit log channel not initialized, writing synchronously");
449            write_audit_log_entry_sync(&entry)
450        }
451    }
452}
453
454fn write_audit_log_entry_sync(entry: &AuditLogEntry) -> std::io::Result<()> {
455    let log_path = match get_audit_log_path() {
456        Ok(p) => p,
457        Err(e) => {
458            tracing::warn!(error = %e, "Failed to get audit log path");
459            return Err(e);
460        }
461    };
462
463    let _ = rotate_audit_log_if_needed(
464        &log_path,
465        AUDIT_LOG_MAX_SIZE_BYTES,
466        AUDIT_LOG_RETENTION_DAYS,
467    );
468
469    let log_entry = format!(
470        "{}|{}|{}|{}|{}|{}\n",
471        entry.timestamp,
472        entry.action,
473        escape_pipe(&entry.snippet_id),
474        escape_pipe(&entry.description),
475        entry.library_id,
476        escape_pipe(&entry.device_id),
477    );
478
479    use std::io::Write;
480    let mut file = match fs::OpenOptions::new()
481        .create(true)
482        .append(true)
483        .open(&log_path)
484    {
485        Ok(f) => f,
486        Err(e) => {
487            tracing::error!(error = %e, path = %log_path.display(), "Failed to open audit log for writing");
488            return Err(e);
489        }
490    };
491
492    if let Err(e) = file.write_all(log_entry.as_bytes()) {
493        tracing::error!(error = %e, path = %log_path.display(), "Failed to write to audit log");
494        return Err(e);
495    }
496    Ok(())
497}
498
499fn escape_pipe(s: &str) -> String {
500    let mut result = String::with_capacity(s.len());
501    for c in s.chars() {
502        match c {
503            '\\' => result.push_str("\\\\"),
504            '|' => result.push_str("\\|"),
505            '\n' => result.push_str("\\n"),
506            '\r' => result.push_str("\\r"),
507            c if c.is_control() => {
508                result.push_str(&format!("\\x{:02x}", c as u32));
509            }
510            _ => result.push(c),
511        }
512    }
513    result
514}
515
516fn rotate_audit_log_if_needed(
517    log_path: &Path,
518    max_size_bytes: u64,
519    retention_days: u64,
520) -> std::io::Result<()> {
521    let metadata = fs::symlink_metadata(log_path)?;
522    let size = metadata.len();
523
524    if size > max_size_bytes {
525        let timestamp = SystemTime::now()
526            .duration_since(UNIX_EPOCH)
527            .unwrap_or_default()
528            .as_secs();
529        let rotated_path = log_path.with_extension(format!("{timestamp}.rotated"));
530        fs::rename(log_path, rotated_path)?;
531    }
532
533    let log_dir = log_path.parent().unwrap_or(log_path);
534    if let Ok(entries) = fs::read_dir(log_dir) {
535        let retention_secs = retention_days * SECS_PER_DAY;
536
537        for entry in entries.flatten() {
538            let path = entry.path();
539            if path.extension().and_then(|s| s.to_str()) == Some("rotated")
540                && let Ok(metadata) = entry.metadata()
541                && let Ok(modified) = metadata.modified()
542            {
543                let age = SystemTime::now()
544                    .duration_since(modified)
545                    .unwrap_or_default()
546                    .as_secs();
547                if age > retention_secs {
548                    let _ = fs::remove_file(path);
549                }
550            }
551        }
552    }
553
554    Ok(())
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_escape_pipe_backslash() {
563        assert_eq!(escape_pipe(r"foo\bar"), r"foo\\bar");
564    }
565
566    #[test]
567    fn test_escape_pipe_pipe() {
568        assert_eq!(escape_pipe("foo|bar"), r"foo\|bar");
569    }
570
571    #[test]
572    fn test_escape_pipe_newline() {
573        assert_eq!(escape_pipe("foo\nbar"), r"foo\nbar");
574    }
575
576    #[test]
577    fn test_escape_pipe_carriage_return() {
578        assert_eq!(escape_pipe("foo\rbar"), r"foo\rbar");
579    }
580
581    #[test]
582    fn test_escape_pipe_combined() {
583        assert_eq!(escape_pipe("a\\b|c\nd"), r"a\\b\|c\nd");
584    }
585
586    #[test]
587    fn test_escape_pipe_empty() {
588        assert_eq!(escape_pipe(""), "");
589    }
590
591    #[test]
592    fn test_rotate_audit_log_creates_rotated_file() {
593        let dir = tempfile::TempDir::new().unwrap();
594        let log_path = dir.path().join("audit.log");
595        fs::write(&log_path, "x".repeat(100)).unwrap();
596        rotate_audit_log_if_needed(&log_path, 50, 30).unwrap();
597        assert!(!log_path.exists());
598        let entries: Vec<_> = fs::read_dir(dir.path())
599            .unwrap()
600            .filter_map(|e| e.ok())
601            .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rotated"))
602            .collect();
603        assert_eq!(entries.len(), 1);
604    }
605
606    #[test]
607    fn test_rotate_audit_log_no_rotation_under_limit() {
608        let dir = tempfile::TempDir::new().unwrap();
609        let log_path = dir.path().join("audit.log");
610        fs::write(&log_path, "small").unwrap();
611        rotate_audit_log_if_needed(&log_path, 1024, 30).unwrap();
612        assert!(log_path.exists());
613    }
614}