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                clx::progress::pause();
71                eprintln!("{out}");
72                clx::progress::resume();
73            }
74        }
75    }
76
77    fn flush(&self) {}
78}
79
80static LOGGER: Lazy<Logger> = Lazy::new(Logger::init);
81
82impl Logger {
83    fn init() -> Self {
84        let term_level = *env::PITCHFORK_LOG;
85        let file_level = *env::PITCHFORK_LOG_FILE_LEVEL;
86
87        // Store initial levels (from env vars) into atomics.
88        TERM_LEVEL.store(term_level as usize, Ordering::Relaxed);
89        FILE_LEVEL.store(file_level as usize, Ordering::Relaxed);
90
91        let mut logger = Logger { log_file: None };
92
93        let log_file = &*env::PITCHFORK_LOG_FILE;
94        if let Ok(log_file) = init_log_file(log_file) {
95            logger.log_file = Some(Mutex::new(log_file));
96        } else {
97            warn!("could not open log file: {log_file:?}");
98        }
99
100        logger
101    }
102
103    /// Re-apply log levels from the settings system.
104    ///
105    /// The logger is initialised very early (before config files are parsed),
106    /// so it can only see environment variables at that point. After
107    /// `Settings::load()` has merged env + config-file values, call this
108    /// function to pick up any `log_level` / `log_file_level` that was set
109    /// in a pitchfork.toml `[settings]` section.
110    fn apply_settings_levels(&self) {
111        use std::sync::atomic::Ordering;
112        let s = crate::settings::settings();
113
114        let term_level: LevelFilter = s.general.log_level.parse().unwrap_or(LevelFilter::Info);
115        let file_level: LevelFilter = s.general.log_file_level.parse().unwrap_or(term_level);
116        let max_level = std::cmp::max(term_level, file_level);
117
118        // Update the cached levels inside LOGGER.
119        // Safety: these are only read by the `log` trait methods which
120        // tolerate momentary inconsistency (worst case: one extra or
121        // one missing log line during the switch).
122        //
123        // We use AtomicUsize fields so we can update them after init.
124        TERM_LEVEL.store(term_level as usize, Ordering::Relaxed);
125        FILE_LEVEL.store(file_level as usize, Ordering::Relaxed);
126
127        // Also update the global max level so the `log` crate's
128        // fast-path filter reflects the new configuration.
129        log::set_max_level(max_level);
130    }
131
132    fn render(&self, record: &Record, level: LevelFilter) -> String {
133        match level {
134            LevelFilter::Off => "".to_string(),
135            LevelFilter::Trace => {
136                let file = record.file().unwrap_or("<unknown>");
137                let ignore_crates = ["/notify-debouncer-full-", "/notify-"];
138                if record.level() == Level::Trace && ignore_crates.iter().any(|c| file.contains(c))
139                {
140                    return "".to_string();
141                }
142                let meta = ui::style::edim(format!(
143                    "{thread_id:>2} [{file}:{line}]",
144                    thread_id = thread_id(),
145                    line = record.line().unwrap_or(0),
146                ));
147                format!(
148                    "{level} {meta} {args}",
149                    level = self.styled_level(record.level()),
150                    args = record.args()
151                )
152            }
153            LevelFilter::Debug => format!(
154                "{level} {args}",
155                level = self.styled_level(record.level()),
156                args = record.args()
157            ),
158            _ => {
159                let pitchfork = match record.level() {
160                    Level::Error => ui::style::ered("pitchfork"),
161                    Level::Warn => ui::style::eyellow("pitchfork"),
162                    _ => ui::style::edim("pitchfork"),
163                };
164                match record.level() {
165                    Level::Info => format!("{pitchfork} {args}", args = record.args()),
166                    _ => format!(
167                        "{pitchfork} {level} {args}",
168                        level = self.styled_level(record.level()),
169                        args = record.args()
170                    ),
171                }
172            }
173        }
174    }
175
176    fn styled_level(&self, level: Level) -> String {
177        let level = match level {
178            Level::Error => ui::style::ered("ERROR").to_string(),
179            Level::Warn => ui::style::eyellow("WARN").to_string(),
180            Level::Info => ui::style::ecyan("INFO").to_string(),
181            Level::Debug => ui::style::emagenta("DEBUG").to_string(),
182            Level::Trace => ui::style::edim("TRACE").to_string(),
183        };
184        console::pad_str(&level, 5, console::Alignment::Left, None).to_string()
185    }
186}
187
188pub fn thread_id() -> String {
189    let id = format!("{:?}", thread::current().id());
190    let id = id.replace("ThreadId(", "");
191    id.replace(")", "")
192}
193
194pub fn init() {
195    static INIT: std::sync::Once = std::sync::Once::new();
196    INIT.call_once(|| {
197        let max_level = std::cmp::max(load_term_level(), load_file_level());
198        if let Err(err) = log::set_logger(&*LOGGER).map(|()| log::set_max_level(max_level)) {
199            eprintln!("pitchfork: could not initialize logger: {err}");
200        }
201    });
202}
203
204/// Re-apply log levels from the loaded settings.
205///
206/// Call this once after `Settings::load()` has run so that log levels
207/// configured in pitchfork.toml `[settings.general]` take effect.
208pub fn apply_settings() {
209    LOGGER.apply_settings_levels();
210}
211
212fn init_log_file(log_file: &Path) -> Result<File> {
213    if let Some(log_dir) = log_file.parent() {
214        xx::file::mkdirp(log_dir)?;
215    }
216    OpenOptions::new()
217        .create(true)
218        .append(true)
219        .open(log_file)
220        .into_diagnostic()
221}