Skip to main content

pitchfork_cli/
logger.rs

1use crate::Result;
2use std::fs::{File, OpenOptions};
3use std::io::Write;
4use std::path::Path;
5use std::sync::Mutex;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::thread;
8
9use crate::{env, ui};
10use log::{Level, LevelFilter, Metadata, Record};
11use miette::IntoDiagnostic;
12use once_cell::sync::Lazy;
13
14/// Atomic level storage so settings can update log levels after init.
15static TERM_LEVEL: AtomicUsize = AtomicUsize::new(LevelFilter::Info as usize);
16static FILE_LEVEL: AtomicUsize = AtomicUsize::new(LevelFilter::Info as usize);
17
18fn usize_to_level_filter(n: usize) -> LevelFilter {
19    match n {
20        0 => LevelFilter::Off,
21        1 => LevelFilter::Error,
22        2 => LevelFilter::Warn,
23        3 => LevelFilter::Info,
24        4 => LevelFilter::Debug,
25        5 => LevelFilter::Trace,
26        _ => LevelFilter::Info, // unreachable in practice
27    }
28}
29
30fn load_term_level() -> LevelFilter {
31    usize_to_level_filter(TERM_LEVEL.load(Ordering::Relaxed))
32}
33
34fn load_file_level() -> LevelFilter {
35    usize_to_level_filter(FILE_LEVEL.load(Ordering::Relaxed))
36}
37
38#[derive(Debug)]
39struct Logger {
40    log_file: Option<Mutex<File>>,
41}
42
43impl log::Log for Logger {
44    fn enabled(&self, metadata: &Metadata) -> bool {
45        let max_level = std::cmp::max(load_term_level(), load_file_level());
46        metadata.level() <= max_level
47    }
48
49    fn log(&self, record: &Record) {
50        let file_level = load_file_level();
51        let term_level = load_term_level();
52        if record.level() <= file_level
53            && let Some(log_file) = &self.log_file
54        {
55            let mut log_file = match log_file.lock() {
56                Ok(guard) => guard,
57                Err(poisoned) => poisoned.into_inner(),
58            };
59            let out = format!(
60                "{now} {level} {args}",
61                now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
62                level = self.styled_level(record.level()),
63                args = record.args()
64            );
65            let _ = writeln!(log_file, "{}", console::strip_ansi_codes(&out));
66        }
67        if record.level() <= term_level {
68            let out = self.render(record, term_level);
69            if !out.is_empty() {
70                eprintln!("{out}");
71            }
72        }
73    }
74
75    fn flush(&self) {}
76}
77
78static LOGGER: Lazy<Logger> = Lazy::new(Logger::init);
79
80impl Logger {
81    fn init() -> Self {
82        let term_level = *env::PITCHFORK_LOG;
83        let file_level = *env::PITCHFORK_LOG_FILE_LEVEL;
84
85        // Store initial levels (from env vars) into atomics.
86        TERM_LEVEL.store(term_level as usize, Ordering::Relaxed);
87        FILE_LEVEL.store(file_level as usize, Ordering::Relaxed);
88
89        let mut logger = Logger { log_file: None };
90
91        let log_file = &*env::PITCHFORK_LOG_FILE;
92        if let Ok(log_file) = init_log_file(log_file) {
93            logger.log_file = Some(Mutex::new(log_file));
94        } else {
95            warn!("could not open log file: {log_file:?}");
96        }
97
98        logger
99    }
100
101    /// Re-apply log levels from the settings system.
102    ///
103    /// The logger is initialised very early (before config files are parsed),
104    /// so it can only see environment variables at that point. After
105    /// `Settings::load()` has merged env + config-file values, call this
106    /// function to pick up any `log_level` / `log_file_level` that was set
107    /// in a pitchfork.toml `[settings]` section.
108    fn apply_settings_levels(&self) {
109        use std::sync::atomic::Ordering;
110        let s = crate::settings::settings();
111
112        let term_level: LevelFilter = s.general.log_level.parse().unwrap_or(LevelFilter::Info);
113        let file_level: LevelFilter = s.general.log_file_level.parse().unwrap_or(term_level);
114        let max_level = std::cmp::max(term_level, file_level);
115
116        // Update the cached levels inside LOGGER.
117        // Safety: these are only read by the `log` trait methods which
118        // tolerate momentary inconsistency (worst case: one extra or
119        // one missing log line during the switch).
120        //
121        // We use AtomicUsize fields so we can update them after init.
122        TERM_LEVEL.store(term_level as usize, Ordering::Relaxed);
123        FILE_LEVEL.store(file_level as usize, Ordering::Relaxed);
124
125        // Also update the global max level so the `log` crate's
126        // fast-path filter reflects the new configuration.
127        log::set_max_level(max_level);
128    }
129
130    fn render(&self, record: &Record, level: LevelFilter) -> String {
131        match level {
132            LevelFilter::Off => "".to_string(),
133            LevelFilter::Trace => {
134                let file = record.file().unwrap_or("<unknown>");
135                let ignore_crates = ["/notify-debouncer-full-", "/notify-"];
136                if record.level() == Level::Trace && ignore_crates.iter().any(|c| file.contains(c))
137                {
138                    return "".to_string();
139                }
140                let meta = ui::style::edim(format!(
141                    "{thread_id:>2} [{file}:{line}]",
142                    thread_id = thread_id(),
143                    line = record.line().unwrap_or(0),
144                ));
145                format!(
146                    "{level} {meta} {args}",
147                    level = self.styled_level(record.level()),
148                    args = record.args()
149                )
150            }
151            LevelFilter::Debug => format!(
152                "{level} {args}",
153                level = self.styled_level(record.level()),
154                args = record.args()
155            ),
156            _ => {
157                let pitchfork = match record.level() {
158                    Level::Error => ui::style::ered("pitchfork"),
159                    Level::Warn => ui::style::eyellow("pitchfork"),
160                    _ => ui::style::edim("pitchfork"),
161                };
162                match record.level() {
163                    Level::Info => format!("{pitchfork} {args}", args = record.args()),
164                    _ => format!(
165                        "{pitchfork} {level} {args}",
166                        level = self.styled_level(record.level()),
167                        args = record.args()
168                    ),
169                }
170            }
171        }
172    }
173
174    fn styled_level(&self, level: Level) -> String {
175        let level = match level {
176            Level::Error => ui::style::ered("ERROR").to_string(),
177            Level::Warn => ui::style::eyellow("WARN").to_string(),
178            Level::Info => ui::style::ecyan("INFO").to_string(),
179            Level::Debug => ui::style::emagenta("DEBUG").to_string(),
180            Level::Trace => ui::style::edim("TRACE").to_string(),
181        };
182        console::pad_str(&level, 5, console::Alignment::Left, None).to_string()
183    }
184}
185
186pub fn thread_id() -> String {
187    let id = format!("{:?}", thread::current().id());
188    let id = id.replace("ThreadId(", "");
189    id.replace(")", "")
190}
191
192pub fn init() {
193    static INIT: std::sync::Once = std::sync::Once::new();
194    INIT.call_once(|| {
195        let max_level = std::cmp::max(load_term_level(), load_file_level());
196        if let Err(err) = log::set_logger(&*LOGGER).map(|()| log::set_max_level(max_level)) {
197            eprintln!("pitchfork: could not initialize logger: {err}");
198        }
199    });
200}
201
202/// Re-apply log levels from the loaded settings.
203///
204/// Call this once after `Settings::load()` has run so that log levels
205/// configured in pitchfork.toml `[settings.general]` take effect.
206pub fn apply_settings() {
207    LOGGER.apply_settings_levels();
208}
209
210fn init_log_file(log_file: &Path) -> Result<File> {
211    if let Some(log_dir) = log_file.parent() {
212        xx::file::mkdirp(log_dir)?;
213    }
214    OpenOptions::new()
215        .create(true)
216        .append(true)
217        .open(log_file)
218        .into_diagnostic()
219}