1use std::{
2 env, fmt,
3 io::{self, BufWriter, Write},
4 panic::{self, PanicHookInfo},
5 process,
6 sync::{
7 Arc, Once, OnceLock,
8 atomic::{AtomicBool, AtomicU64, Ordering},
9 },
10 thread,
11 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13
14use crossbeam_channel::{Receiver, Sender, TrySendError};
15use tracing::{
16 Event, Level, Metadata, Subscriber,
17 field::{Field, Visit},
18};
19use tracing_subscriber::{
20 fmt::{FmtContext, FormatEvent, FormatFields, MakeWriter, format::Writer},
21 registry::LookupSpan,
22};
23
24use crate::{Controller, HttpException, Module, RouteDef};
25
26const RESET: &str = "\x1b[0m";
27const BRAND: &str = "\x1b[38;5;45m";
28const PID: &str = "\x1b[38;5;141m";
29const TIME: &str = "\x1b[38;5;245m";
30const CONTEXT: &str = "\x1b[38;5;111m";
31const INFO: &str = "\x1b[38;5;39m";
32const WARN: &str = "\x1b[38;5;214m";
33const ERROR: &str = "\x1b[38;5;203m";
34const DEBUG: &str = "\x1b[38;5;177m";
35const OK_STATUS: &str = "\x1b[38;5;82m";
36const ACCESS_LOG_QUEUE_CAPACITY: usize = 65_536;
37const ACCESS_LOG_BATCH_SIZE: usize = 1_024;
38const ACCESS_LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(100);
39const ACCESS_LOG_DROP_REPORT_INTERVAL: Duration = Duration::from_secs(1);
40
41static TRACING_INIT: Once = Once::new();
42static PANIC_HOOK_INIT: Once = Once::new();
43static LOG_PRIORITY: OnceLock<u8> = OnceLock::new();
44static HTTP_REQUEST_LOGGING: OnceLock<bool> = OnceLock::new();
45static CAELIX_TRACING_SUBSCRIBER: AtomicBool = AtomicBool::new(false);
46static ACCESS_LOG_WRITER: OnceLock<AccessLogWriter> = OnceLock::new();
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum LogLevel {
51 Info,
53 Log,
55 Warn,
57 Error,
59 Debug,
61}
62
63impl LogLevel {
64 fn label(self) -> &'static str {
65 match self {
66 Self::Info | Self::Log => "INFO",
67 Self::Warn => "WARN",
68 Self::Error => "ERROR",
69 Self::Debug => "DEBUG",
70 }
71 }
72
73 fn color(self) -> &'static str {
74 match self {
75 Self::Info | Self::Log => INFO,
76 Self::Warn => WARN,
77 Self::Error => ERROR,
78 Self::Debug => DEBUG,
79 }
80 }
81
82 fn priority(self) -> u8 {
83 match self {
84 Self::Error => 1,
85 Self::Warn => 2,
86 Self::Info | Self::Log => 3,
87 Self::Debug => 4,
88 }
89 }
90}
91
92#[derive(Clone, Debug)]
93pub struct Logger {
95 context: String,
96}
97
98impl Logger {
99 pub fn new(context: impl Into<String>) -> Self {
101 Self {
102 context: short_type_name(&context.into()).to_string(),
103 }
104 }
105
106 pub fn for_type<T: ?Sized>() -> Self {
108 Self::new(std::any::type_name::<T>())
109 }
110
111 pub fn context(&self) -> &str {
113 &self.context
114 }
115
116 pub fn info(&self, message: impl AsRef<str>) {
118 self.write(LogLevel::Info, message, None);
119 }
120
121 pub fn warn(&self, message: impl AsRef<str>) {
123 self.write(LogLevel::Warn, message, None);
124 }
125
126 pub fn error(&self, message: impl AsRef<str>) {
128 self.write(LogLevel::Error, message, None);
129 }
130
131 pub fn debug(&self, message: impl AsRef<str>) {
133 self.write(LogLevel::Debug, message, None);
134 }
135
136 pub(crate) fn write(
137 &self,
138 level: LogLevel,
139 message: impl AsRef<str>,
140 elapsed: Option<Duration>,
141 ) {
142 self.write_inner(level, message, elapsed, false);
143 }
144
145 fn write_forced(&self, level: LogLevel, message: impl AsRef<str>) {
146 self.write_inner(level, message, None, true);
147 }
148
149 fn write_inner(
150 &self,
151 level: LogLevel,
152 message: impl AsRef<str>,
153 elapsed: Option<Duration>,
154 force: bool,
155 ) {
156 if !force && !log_level_enabled(level) {
157 return;
158 }
159
160 init_logging();
161
162 let line = format_log_line(&self.context, level, message.as_ref(), elapsed);
163
164 match level {
165 LogLevel::Info | LogLevel::Log => {
166 tracing::info!(target: "caelix", message = line.as_str())
167 }
168 LogLevel::Warn => tracing::warn!(target: "caelix", message = line.as_str()),
169 LogLevel::Error => tracing::error!(target: "caelix", message = line.as_str()),
170 LogLevel::Debug => tracing::debug!(target: "caelix", message = line.as_str()),
171 }
172 }
173}
174
175fn format_log_line(
176 context: &str,
177 level: LogLevel,
178 message: &str,
179 elapsed: Option<Duration>,
180) -> String {
181 let pid = process::id();
182 let timestamp = current_timestamp();
183 let level_label = format!("{:>5}", level.label());
184 let elapsed = elapsed
185 .map(|duration| format!(" {}", format_elapsed(duration)))
186 .unwrap_or_default();
187 let message = match level {
188 LogLevel::Error => format!("{}{}{}", ERROR, message, RESET),
189 _ => message.to_string(),
190 };
191
192 format!(
193 "{}[Caelix]{} {}{}{} - {}{}{} {}{}{} {}[{}]{} {}{}",
194 BRAND,
195 RESET,
196 PID,
197 pid,
198 RESET,
199 TIME,
200 timestamp,
201 RESET,
202 level.color(),
203 level_label,
204 RESET,
205 CONTEXT,
206 context,
207 RESET,
208 message,
209 elapsed
210 )
211}
212
213fn format_elapsed(duration: Duration) -> String {
214 let milliseconds = duration.as_millis();
215
216 if milliseconds > 0 {
217 format!("+{milliseconds}ms")
218 } else {
219 format!("+{}µs", duration.as_micros())
220 }
221}
222
223fn current_timestamp() -> String {
224 let duration = SystemTime::now()
225 .duration_since(UNIX_EPOCH)
226 .unwrap_or(Duration::ZERO);
227 let total_seconds = duration.as_secs();
228 let days = (total_seconds / 86_400) as i64;
229 let seconds_of_day = total_seconds % 86_400;
230 let (year, month, day) = civil_from_days(days);
231 let hour_24 = seconds_of_day / 3_600;
232 let minute = (seconds_of_day % 3_600) / 60;
233 let second = seconds_of_day % 60;
234 let period = if hour_24 < 12 { "AM" } else { "PM" };
235 let hour_12 = match hour_24 % 12 {
236 0 => 12,
237 hour => hour,
238 };
239
240 format!("{month:02}/{day:02}/{year:04}, {hour_12:02}:{minute:02}:{second:02} {period}")
241}
242
243fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
244 let z = days_since_epoch + 719_468;
245 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
246 let doe = z - era * 146_097;
247 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
248 let year = yoe + era * 400;
249 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
250 let mp = (5 * doy + 2) / 153;
251 let day = doy - (153 * mp + 2) / 5 + 1;
252 let month = mp + if mp < 10 { 3 } else { -9 };
253 let year = year + if month <= 2 { 1 } else { 0 };
254
255 (year, month as u32, day as u32)
256}
257
258impl crate::Injectable for Logger {
259 fn dependencies() -> Vec<crate::ProviderDependency> {
260 crate::provider_dependencies![]
261 }
262
263 fn create(_container: &crate::Container) -> crate::BoxFuture<'_, crate::Result<Self>> {
264 Box::pin(async move { Ok(Self::new("Application")) })
265 }
266}
267
268pub fn log(context: &str, level: LogLevel, message: impl AsRef<str>, elapsed: Option<Duration>) {
270 Logger::new(context).write(level, message, elapsed);
271}
272
273pub fn log_http_exception(exception: &HttpException) {
275 if !exception.status.is_server_error() {
276 return;
277 }
278
279 let message = match &exception.source {
280 Some(source) => format!(
281 "{} {}: {} | source: {source:#}",
282 exception.status.as_u16(),
283 exception.error,
284 exception.message
285 ),
286 None => format!(
287 "{} {}: {}",
288 exception.status.as_u16(),
289 exception.error,
290 exception.message
291 ),
292 };
293
294 Logger::new("ExceptionHandler").error(message);
295}
296
297pub(crate) fn init_logging() {
298 install_panic_hook();
299 init_tracing();
300}
301
302fn init_tracing() {
303 TRACING_INIT.call_once(|| {
304 let subscriber = tracing_subscriber::fmt()
305 .without_time()
306 .with_level(false)
307 .with_target(false)
308 .with_ansi(false)
309 .with_writer(CaelixMakeWriter)
310 .event_format(CaelixFormatter)
311 .finish();
312
313 if tracing::subscriber::set_global_default(subscriber).is_ok() {
314 CAELIX_TRACING_SUBSCRIBER.store(true, Ordering::Release);
315 }
316 });
317}
318
319fn install_panic_hook() {
320 PANIC_HOOK_INIT.call_once(|| {
321 panic::set_hook(Box::new(|info| {
322 Logger::new("ExceptionHandler").write_forced(LogLevel::Error, panic_message(info));
323 }));
324 });
325}
326
327fn panic_message(info: &PanicHookInfo<'_>) -> String {
328 let payload = if let Some(message) = info.payload().downcast_ref::<&str>() {
329 *message
330 } else if let Some(message) = info.payload().downcast_ref::<String>() {
331 message.as_str()
332 } else {
333 "panic occurred"
334 };
335
336 if let Some(location) = info.location() {
337 format!(
338 "panic: {} (at {}:{}:{})",
339 payload,
340 location.file(),
341 location.line(),
342 location.column()
343 )
344 } else {
345 format!("panic: {}", payload)
346 }
347}
348
349struct CaelixFormatter;
350
351impl<S, N> FormatEvent<S, N> for CaelixFormatter
352where
353 S: Subscriber + for<'span> LookupSpan<'span>,
354 N: for<'writer> FormatFields<'writer> + 'static,
355{
356 fn format_event(
357 &self,
358 _ctx: &FmtContext<'_, S, N>,
359 mut writer: Writer<'_>,
360 event: &Event<'_>,
361 ) -> fmt::Result {
362 if event.metadata().target() != "caelix" {
363 return Ok(());
364 }
365
366 let mut visitor = MessageVisitor::default();
367 event.record(&mut visitor);
368
369 writeln!(writer, "{}", visitor.message)
370 }
371}
372
373struct CaelixMakeWriter;
374
375impl<'a> MakeWriter<'a> for CaelixMakeWriter {
376 type Writer = CaelixStreamWriter;
377
378 fn make_writer(&'a self) -> Self::Writer {
379 CaelixStreamWriter::Stdout(io::stdout())
380 }
381
382 fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Self::Writer {
383 if *meta.level() == Level::ERROR {
384 CaelixStreamWriter::Stderr(io::stderr())
385 } else {
386 CaelixStreamWriter::Stdout(io::stdout())
387 }
388 }
389}
390
391enum CaelixStreamWriter {
392 Stdout(io::Stdout),
393 Stderr(io::Stderr),
394}
395
396impl Write for CaelixStreamWriter {
397 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
398 match self {
399 Self::Stdout(writer) => writer.write(buf),
400 Self::Stderr(writer) => writer.write(buf),
401 }
402 }
403
404 fn flush(&mut self) -> io::Result<()> {
405 match self {
406 Self::Stdout(writer) => writer.flush(),
407 Self::Stderr(writer) => writer.flush(),
408 }
409 }
410}
411
412#[derive(Default)]
413struct MessageVisitor {
414 message: String,
415}
416
417impl Visit for MessageVisitor {
418 fn record_str(&mut self, field: &Field, value: &str) {
419 if field.name() == "message" {
420 self.message = value.to_string();
421 }
422 }
423
424 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
425 if field.name() == "message" && self.message.is_empty() {
426 self.message = format!("{value:?}");
427 }
428 }
429}
430
431pub fn log_application_starting() {
433 log(
434 "Application",
435 LogLevel::Info,
436 "Starting Caelix application...",
437 None,
438 );
439}
440
441pub fn log_application_started(elapsed: Duration) {
443 log(
444 "Application",
445 LogLevel::Info,
446 "Caelix application successfully started",
447 Some(elapsed),
448 );
449}
450
451pub fn log_listening(addr: &str) {
453 log(
454 "Application",
455 LogLevel::Info,
456 format!("Caelix application listening on {}", addr),
457 None,
458 );
459}
460
461pub fn log_module_initialized(module: &str, elapsed: Duration) {
463 log(
464 "InstanceLoader",
465 LogLevel::Info,
466 format!("{} dependencies initialized", short_type_name(module)),
467 Some(elapsed),
468 );
469}
470
471pub fn log_provider_initialized(provider: &str, elapsed: Duration) {
473 log(
474 "ProviderLoader",
475 LogLevel::Info,
476 format!("{} initialized", short_type_name(provider)),
477 Some(elapsed),
478 );
479}
480
481pub fn log_controller_routes<C: Controller>() {
483 let routes = C::routes();
484
485 if routes.is_empty() {
486 return;
487 }
488
489 log(
490 "RoutesResolver",
491 LogLevel::Info,
492 format!(
493 "{} {{{}}}:",
494 short_type_name(std::any::type_name::<C>()),
495 C::base_path()
496 ),
497 Some(Duration::ZERO),
498 );
499
500 for route in routes {
501 log_route_mapped(route);
502 }
503}
504
505pub fn log_route_mapped(route: &RouteDef) {
507 log(
508 "RouterExplorer",
509 LogLevel::Info,
510 format!(
511 "Mapped {{{}, {}}} route",
512 route.path,
513 route.method.to_uppercase()
514 ),
515 Some(Duration::ZERO),
516 );
517}
518
519pub fn log_http_request(method: &str, path: &str, status: u16, elapsed: Duration) {
521 let level = match status {
522 500..=599 => LogLevel::Error,
523 _ => LogLevel::Info,
524 };
525 if !log_level_enabled(level) {
526 return;
527 }
528
529 init_logging();
530
531 if CAELIX_TRACING_SUBSCRIBER.load(Ordering::Acquire) {
532 access_log_writer().write(method, path, status, elapsed);
533 } else {
534 let status = color_status(status);
535 log(
536 "HTTP",
537 level,
538 format!("{} {} {}", method, path, status),
539 Some(elapsed),
540 );
541 }
542}
543
544#[doc(hidden)]
548#[allow(clippy::too_many_arguments)]
549pub fn log_http_request_info(
551 client_address: &str,
552 method: &str,
553 path_and_query: &str,
554 protocol: &str,
555 status: u16,
556 response_size: Option<u64>,
557 referrer: &str,
558 user_agent: &str,
559 elapsed: Duration,
560) {
561 let level = match status {
562 500..=599 => LogLevel::Error,
563 _ => LogLevel::Info,
564 };
565 if !log_level_enabled(level) {
566 return;
567 }
568
569 init_logging();
570
571 if CAELIX_TRACING_SUBSCRIBER.load(Ordering::Acquire) {
572 access_log_writer().write_info(HttpAccessLogInfo::new(
573 client_address,
574 method,
575 path_and_query,
576 protocol,
577 status,
578 response_size,
579 referrer,
580 user_agent,
581 elapsed,
582 ));
583 } else {
584 log(
585 "HTTP",
586 level,
587 format_http_request_info(
588 client_address,
589 method,
590 path_and_query,
591 protocol,
592 status,
593 response_size,
594 referrer,
595 user_agent,
596 elapsed,
597 ),
598 None,
599 );
600 }
601}
602
603pub fn dropped_http_request_logs() -> u64 {
606 ACCESS_LOG_WRITER
607 .get()
608 .map(|writer| writer.dropped.load(Ordering::Relaxed))
609 .unwrap_or(0)
610}
611
612struct AccessLogWriter {
613 sender: Sender<AccessLogEvent>,
614 dropped: Arc<AtomicU64>,
615 pending_drop_report: Arc<AtomicU64>,
616}
617
618enum AccessLogEvent {
619 Compact {
620 request: String,
621 status: u16,
622 elapsed: Duration,
623 },
624 Info(HttpAccessLogInfo),
625}
626
627struct HttpAccessLogInfo {
628 client_address: String,
629 method: String,
630 path_and_query: String,
631 protocol: String,
632 status: u16,
633 response_size: Option<u64>,
634 referrer: String,
635 user_agent: String,
636 elapsed: Duration,
637}
638
639impl HttpAccessLogInfo {
640 #[allow(clippy::too_many_arguments)]
641 fn new(
642 client_address: &str,
643 method: &str,
644 path_and_query: &str,
645 protocol: &str,
646 status: u16,
647 response_size: Option<u64>,
648 referrer: &str,
649 user_agent: &str,
650 elapsed: Duration,
651 ) -> Self {
652 Self {
653 client_address: client_address.to_string(),
654 method: method.to_string(),
655 path_and_query: path_and_query.to_string(),
656 protocol: protocol.to_string(),
657 status,
658 response_size,
659 referrer: referrer.to_string(),
660 user_agent: user_agent.to_string(),
661 elapsed,
662 }
663 }
664}
665
666impl AccessLogWriter {
667 fn new() -> Self {
668 let (sender, receiver) = crossbeam_channel::bounded(ACCESS_LOG_QUEUE_CAPACITY);
669 let dropped = Arc::new(AtomicU64::new(0));
670 let pending_drop_report = Arc::new(AtomicU64::new(0));
671 let worker_drop_report = Arc::clone(&pending_drop_report);
672
673 thread::Builder::new()
674 .name("caelix-access-log".to_string())
675 .spawn(move || write_access_logs(receiver, worker_drop_report))
676 .expect("failed to start the Caelix access-log writer");
677
678 Self {
679 sender,
680 dropped,
681 pending_drop_report,
682 }
683 }
684
685 fn write(&self, method: &str, path: &str, status: u16, elapsed: Duration) {
686 if self.sender.is_full() {
687 self.record_drop();
688 return;
689 }
690
691 let mut request = String::with_capacity(method.len() + path.len() + 1);
692 request.push_str(method);
693 request.push(' ');
694 request.push_str(path);
695
696 let event = AccessLogEvent::Compact {
697 request,
698 status,
699 elapsed,
700 };
701
702 if matches!(self.sender.try_send(event), Err(TrySendError::Full(_))) {
703 self.record_drop();
704 }
705 }
706
707 fn write_info(&self, event: HttpAccessLogInfo) {
708 if self.sender.is_full() {
709 self.record_drop();
710 return;
711 }
712
713 if matches!(
714 self.sender.try_send(AccessLogEvent::Info(event)),
715 Err(TrySendError::Full(_))
716 ) {
717 self.record_drop();
718 }
719 }
720
721 fn record_drop(&self) {
722 self.dropped.fetch_add(1, Ordering::Relaxed);
723 self.pending_drop_report.fetch_add(1, Ordering::Relaxed);
724 }
725}
726
727fn access_log_writer() -> &'static AccessLogWriter {
728 ACCESS_LOG_WRITER.get_or_init(AccessLogWriter::new)
729}
730
731fn write_access_logs(receiver: Receiver<AccessLogEvent>, dropped: Arc<AtomicU64>) {
732 let stdout = io::stdout();
733 let stderr = io::stderr();
734 let mut stdout = BufWriter::with_capacity(256 * 1024, stdout.lock());
735 let mut stderr = BufWriter::with_capacity(16 * 1024, stderr.lock());
736 let mut last_drop_report = Instant::now();
737
738 loop {
739 match receiver.recv_timeout(ACCESS_LOG_FLUSH_INTERVAL) {
740 Ok(event) => {
741 write_access_log(&mut stdout, &mut stderr, event);
742
743 for event in receiver.try_iter().take(ACCESS_LOG_BATCH_SIZE - 1) {
744 write_access_log(&mut stdout, &mut stderr, event);
745 }
746 }
747 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
748 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
749 }
750
751 if last_drop_report.elapsed() >= ACCESS_LOG_DROP_REPORT_INTERVAL {
752 report_dropped_access_logs(&mut stderr, &dropped);
753 last_drop_report = Instant::now();
754 }
755
756 let _ = stdout.flush();
757 let _ = stderr.flush();
758 }
759
760 report_dropped_access_logs(&mut stderr, &dropped);
761 let _ = stdout.flush();
762 let _ = stderr.flush();
763}
764
765fn write_access_log(
766 stdout: &mut BufWriter<io::StdoutLock<'_>>,
767 stderr: &mut BufWriter<io::StderrLock<'_>>,
768 event: AccessLogEvent,
769) {
770 let (level, message, elapsed) = match event {
771 AccessLogEvent::Compact {
772 request,
773 status,
774 elapsed,
775 } => {
776 let level = log_level_for_status(status);
777 (
778 level,
779 format!("{} {}", request, color_status(status)),
780 Some(elapsed),
781 )
782 }
783 AccessLogEvent::Info(info) => {
784 let level = log_level_for_status(info.status);
785 (
786 level,
787 format_http_request_info(
788 &info.client_address,
789 &info.method,
790 &info.path_and_query,
791 &info.protocol,
792 info.status,
793 info.response_size,
794 &info.referrer,
795 &info.user_agent,
796 info.elapsed,
797 ),
798 None,
799 )
800 }
801 };
802 let line = format_log_line("HTTP", level, &message, elapsed);
803
804 if level == LogLevel::Error {
805 let _ = writeln!(stderr, "{line}");
806 } else {
807 let _ = writeln!(stdout, "{line}");
808 }
809}
810
811fn log_level_for_status(status: u16) -> LogLevel {
812 match status {
813 500..=599 => LogLevel::Error,
814 _ => LogLevel::Info,
815 }
816}
817
818#[allow(clippy::too_many_arguments)]
819fn format_http_request_info(
820 client_address: &str,
821 method: &str,
822 path_and_query: &str,
823 protocol: &str,
824 status: u16,
825 response_size: Option<u64>,
826 referrer: &str,
827 user_agent: &str,
828 elapsed: Duration,
829) -> String {
830 let request = if path_and_query.is_empty() {
831 method.to_string()
832 } else {
833 format!("{method} {path_and_query}")
834 };
835 let response_size = response_size
836 .map(|size| size.to_string())
837 .unwrap_or_else(|| "-".to_string());
838
839 format!(
840 "{client_address} \"{request} {protocol}\" {} {response_size} \"{referrer}\" \"{user_agent}\" {:.6}",
841 color_status(status),
842 elapsed.as_secs_f64(),
843 )
844}
845
846fn report_dropped_access_logs(stderr: &mut BufWriter<io::StderrLock<'_>>, dropped: &AtomicU64) {
847 let dropped = dropped.swap(0, Ordering::Relaxed);
848
849 if dropped > 0 {
850 let _ = writeln!(
851 stderr,
852 "[Caelix] access-log writer queue was full; dropped {dropped} entries"
853 );
854 }
855}
856
857pub fn http_request_logging_enabled() -> bool {
859 *HTTP_REQUEST_LOGGING.get_or_init(|| {
860 env::var("CAELIX_HTTP_LOG")
861 .or_else(|_| env::var("CAELIX_ACCESS_LOG"))
862 .ok()
863 .and_then(|value| parse_bool(&value))
864 .unwrap_or(false)
865 })
866}
867
868pub fn log_module_routes<M: Module>() {
870 let metadata = M::register();
871
872 for import in &metadata.imports {
873 (import.route_log_fn)();
874 }
875
876 for controller in &metadata.controllers {
877 (controller.route_log_fn)();
878 }
879}
880
881fn short_type_name(name: &str) -> &str {
882 name.rsplit("::").next().unwrap_or(name)
883}
884
885fn color_status(status: u16) -> String {
886 let color = match status {
887 500..=599 => ERROR,
888 400..=499 => WARN,
889 _ => OK_STATUS,
890 };
891
892 format!("{}{}{}", color, status, RESET)
893}
894
895fn log_level_enabled(level: LogLevel) -> bool {
896 level.priority() <= configured_log_priority()
897}
898
899fn configured_log_priority() -> u8 {
900 *LOG_PRIORITY.get_or_init(|| {
901 env::var("CAELIX_LOG")
902 .ok()
903 .and_then(|value| parse_log_level(&value))
904 .or_else(|| {
905 env::var("RUST_LOG")
906 .ok()
907 .and_then(|value| parse_rust_log_level(&value))
908 })
909 .unwrap_or(LogLevel::Info.priority())
910 })
911}
912
913fn parse_rust_log_level(value: &str) -> Option<u8> {
914 value.split(',').find_map(|directive| {
915 let directive = directive.trim();
916
917 if let Some((target, level)) = directive.split_once('=') {
918 let target = target.trim();
919 if matches!(target, "caelix" | "caelix_core" | "caelix-core") {
920 return parse_log_level(level);
921 }
922
923 return None;
924 }
925
926 parse_log_level(directive)
927 })
928}
929
930fn parse_log_level(value: &str) -> Option<u8> {
931 match value.trim().to_ascii_lowercase().as_str() {
932 "debug" | "trace" => Some(LogLevel::Debug.priority()),
933 "info" | "log" => Some(LogLevel::Info.priority()),
934 "warn" | "warning" => Some(LogLevel::Warn.priority()),
935 "error" => Some(LogLevel::Error.priority()),
936 "off" => Some(0),
937 _ => None,
938 }
939}
940
941fn parse_bool(value: &str) -> Option<bool> {
942 match value.trim().to_ascii_lowercase().as_str() {
943 "1" | "true" | "yes" | "y" | "on" => Some(true),
944 "0" | "false" | "no" | "n" | "off" => Some(false),
945 _ => None,
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn elapsed_format_preserves_sub_millisecond_detail() {
955 assert_eq!(format_elapsed(Duration::ZERO), "+0µs");
956 assert_eq!(format_elapsed(Duration::from_micros(742)), "+742µs");
957 assert_eq!(format_elapsed(Duration::from_micros(1_200)), "+1ms");
958 assert_eq!(format_elapsed(Duration::from_millis(42)), "+42ms");
959 }
960
961 #[test]
962 fn parse_bool_accepts_common_env_values() {
963 assert_eq!(parse_bool("1"), Some(true));
964 assert_eq!(parse_bool("on"), Some(true));
965 assert_eq!(parse_bool("false"), Some(false));
966 assert_eq!(parse_bool("off"), Some(false));
967 assert_eq!(parse_bool("sometimes"), None);
968 }
969
970 #[test]
971 fn detailed_http_log_includes_actix_default_fields() {
972 let line = format_http_request_info(
973 "127.0.0.1:43120",
974 "GET",
975 "/hello?name=Caelix",
976 "HTTP/1.1",
977 200,
978 Some(42),
979 "https://example.com",
980 "CaelixTest/1.0",
981 Duration::from_micros(1_250),
982 );
983
984 assert!(line.starts_with("127.0.0.1:43120 \"GET /hello?name=Caelix HTTP/1.1\""));
985 assert!(line.contains(" 42 \"https://example.com\" \"CaelixTest/1.0\" 0.001250"));
986 }
987}