1pub mod errors {
2 use crate::common::utils::now_ms;
3 use serde::{Deserialize, Serialize};
4 #[derive(Debug, Clone, Serialize, Deserialize)]
5 #[serde(rename_all = "camelCase")]
6 pub enum Severity {
7 Common,
8 Suspicious,
9 Fault,
10 }
11 #[derive(Debug, Serialize)]
12 #[serde(rename_all = "camelCase")]
13 pub struct LavendeError {
14 pub timestamp: u64,
15 pub status: u16,
16 pub error: String,
17 pub message: String,
18 pub path: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub trace: Option<String>,
21 }
22 impl LavendeError {
23 pub fn bad_request(message: impl Into<String>, path: impl Into<String>) -> Self {
24 Self::new(400, "Bad Request", message, path)
25 }
26 pub fn not_found(message: impl Into<String>, path: impl Into<String>) -> Self {
27 Self::new(404, "Not Found", message, path)
28 }
29 pub fn new(
30 status: u16,
31 error: impl Into<String>,
32 message: impl Into<String>,
33 path: impl Into<String>,
34 ) -> Self {
35 Self {
36 timestamp: now_ms(),
37 status,
38 error: error.into(),
39 message: message.into(),
40 path: path.into(),
41 trace: None,
42 }
43 }
44 }
45}
46pub mod types {
47 use rand::{Rng, distributions::Alphanumeric};
48 use std::{ops::Deref, sync::Arc};
49 use tokio::sync::{Mutex, RwLock};
50 pub type Shared<T> = Arc<Mutex<T>>;
51 pub type SharedRw<T> = Arc<RwLock<T>>;
52 pub type AnyError = Box<dyn std::error::Error + Send + Sync>;
53 pub type AnyResult<T> = std::result::Result<T, AnyError>;
54 macro_rules! define_id {
55 ($name:ident, $type:ty) => {
56 #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
57 #[serde(transparent)]
58 pub struct $name(pub $type);
59 impl From<$type> for $name {
60 fn from(val: $type) -> Self {
61 Self(val)
62 }
63 }
64 impl std::fmt::Display for $name {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}", self.0)
67 }
68 }
69 };
70 ($name:ident, $type:ty, copy) => {
71 #[derive(
72 Debug,
73 Clone,
74 Copy,
75 PartialEq,
76 Eq,
77 PartialOrd,
78 Ord,
79 Hash,
80 serde::Serialize,
81 serde::Deserialize,
82 )]
83 #[serde(transparent)]
84 pub struct $name(pub $type);
85 impl From<$type> for $name {
86 fn from(val: $type) -> Self {
87 Self(val)
88 }
89 }
90 impl std::fmt::Display for $name {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(f, "{}", self.0)
93 }
94 }
95 };
96 }
97 define_id!(GuildId, String);
98 define_id!(SessionId, String);
99 define_id!(UserId, u64, copy);
100 define_id!(ChannelId, u64, copy);
101 impl Deref for GuildId {
102 type Target = str;
103 fn deref(&self) -> &Self::Target {
104 &self.0
105 }
106 }
107 impl Deref for SessionId {
108 type Target = str;
109 fn deref(&self) -> &Self::Target {
110 &self.0
111 }
112 }
113 impl SessionId {
114 pub fn generate() -> Self {
115 let rng = rand::thread_rng();
116 let s: String = rng
117 .sample_iter(&Alphanumeric)
118 .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
119 .take(16)
120 .map(char::from)
121 .collect();
122 Self(s)
123 }
124 }
125 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126 pub enum AudioFormat {
127 Aac,
128 Opus,
129 Webm,
130 Mp4,
131 Mp3,
132 Ogg,
133 Flac,
134 Wav,
135 Unknown,
136 }
137 impl AudioFormat {
138 pub fn as_ext(&self) -> &'static str {
139 match self {
140 Self::Aac => "aac",
141 Self::Opus => "opus",
142 Self::Webm => "webm",
143 Self::Mp4 => "mp4",
144 Self::Mp3 => "mp3",
145 Self::Ogg => "ogg",
146 Self::Flac => "flac",
147 Self::Wav => "wav",
148 Self::Unknown => "",
149 }
150 }
151 pub fn from_ext(ext: &str) -> Self {
152 match ext.to_lowercase().as_str() {
153 "aac" => Self::Aac,
154 "opus" => Self::Opus,
155 "webm" => Self::Webm,
156 "mp4" | "m4a" => Self::Mp4,
157 "mp3" => Self::Mp3,
158 "ogg" => Self::Ogg,
159 "flac" => Self::Flac,
160 "wav" => Self::Wav,
161 _ => Self::Unknown,
162 }
163 }
164 pub fn from_url(url: &str) -> Self {
165 if url.contains(".m3u8") || url.contains("/playlist") {
166 return Self::Aac;
167 }
168 if let Some(itag) = extract_youtube_itag(url) {
169 match itag {
170 249..=251 => return Self::Webm,
171 139..=141 => return Self::Mp4,
172 _ => {}
173 }
174 }
175 if url.contains("mime=audio%2Fwebm") || url.contains("mime=audio/webm") {
176 return Self::Webm;
177 }
178 if url.contains("mime=audio%2Fmp4") || url.contains("mime=audio/mp4") {
179 return Self::Mp4;
180 }
181 let from_path = url
182 .split('?')
183 .next()
184 .and_then(|path| std::path::Path::new(path).extension())
185 .and_then(|ext| ext.to_str())
186 .map(Self::from_ext)
187 .unwrap_or(Self::Unknown);
188 if from_path != Self::Unknown {
189 return from_path;
190 }
191 if url.contains(".mp4") || url.contains(".m4a") {
192 return Self::Mp4;
193 }
194 if url.contains(".flac") {
195 return Self::Flac;
196 }
197 if url.contains(".mp3") {
198 return Self::Mp3;
199 }
200 if url.contains(".ogg") {
201 return Self::Ogg;
202 }
203 if url.contains(".webm") {
204 return Self::Webm;
205 }
206 Self::Unknown
207 }
208 pub fn is_opus_passthrough(&self) -> bool {
209 matches!(self, Self::Webm | Self::Ogg | Self::Opus)
210 }
211 }
212 fn extract_youtube_itag(url: &str) -> Option<u32> {
213 url.split('?').nth(1)?.split('&').find_map(|pair| {
214 let (k, v) = pair.split_once('=')?;
215 if k == "itag" { v.parse().ok() } else { None }
216 })
217 }
218}
219
220pub mod http {
221 use crate::{common::utils::default_user_agent, config::HttpProxyConfig};
222 use dashmap::DashMap;
223 use reqwest::{Client, Proxy};
224 use std::{sync::Arc, time::Duration};
225 use tracing::warn;
226 pub struct HttpClientPool {
227 clients: DashMap<Option<HttpProxyConfig>, Arc<Client>>,
228 }
229 impl HttpClientPool {
230 pub fn new() -> Self {
231 Self {
232 clients: DashMap::new(),
233 }
234 }
235 pub fn get(&self, proxy: Option<HttpProxyConfig>) -> Arc<Client> {
236 self.clients
237 .entry(proxy.clone())
238 .or_insert_with(|| Arc::new(self.create_client(proxy)))
239 .clone()
240 }
241 fn create_client(&self, proxy: Option<HttpProxyConfig>) -> Client {
242 let mut builder = Client::builder()
243 .user_agent(default_user_agent())
244 .gzip(true)
245 .deflate(true)
246 .timeout(Duration::from_secs(15))
247 .connect_timeout(Duration::from_secs(5))
248 .tcp_nodelay(true)
249 .pool_max_idle_per_host(10)
250 .pool_idle_timeout(Duration::from_secs(70));
251 if let Some(url) = proxy.as_ref().and_then(|config| config.url.as_ref()) {
252 match Proxy::all(url) {
253 Ok(mut proxy_obj) => {
254 if let Some((u, p)) = proxy
255 .as_ref()
256 .and_then(|c| c.username.as_ref().zip(c.password.as_ref()))
257 {
258 proxy_obj = proxy_obj.basic_auth(u, p);
259 }
260 builder = builder.proxy(proxy_obj);
261 }
262 Err(e) => {
263 warn!(
264 "HttpClientPool: failed to parse proxy URL '{}': {} — proxy will be ignored",
265 url, e
266 );
267 }
268 }
269 }
270 match builder.build() {
271 Ok(client) => client,
272 Err(e) => {
273 warn!(
274 "HttpClientPool: failed to build client ({}), falling back to default",
275 e
276 );
277 Client::new()
278 }
279 }
280 }
281 }
282 impl Default for HttpClientPool {
283 fn default() -> Self {
284 Self::new()
285 }
286 }
287}
288pub mod utils {
289 use std::time::{SystemTime, UNIX_EPOCH};
290 pub const RESET: &str = "\x1b[0m";
291 pub const BOLD: &str = "\x1b[1m";
292 pub const DIM: &str = "\x1b[2m";
293 pub const ORANGE: &str = "\x1b[38;5;208m";
294 pub const GREEN: &str = "\x1b[32m";
295 pub const CYAN: &str = "\x1b[36m";
296 pub const YELLOW: &str = "\x1b[33m";
297 pub const BLUE: &str = "\x1b[34m";
298 pub const MAGENTA: &str = "\x1b[35m";
299 pub const RED: &str = "\x1b[31m";
300 pub const COLOR_ERROR: &str = RED;
301 pub const COLOR_WARN: &str = YELLOW;
302 pub const COLOR_INFO: &str = GREEN;
303 pub const COLOR_DEBUG: &str = BLUE;
304 pub const COLOR_TRACE: &str = MAGENTA;
305 pub fn now_ms() -> u64 {
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .unwrap_or_default()
309 .as_millis() as u64
310 }
311 pub fn now_nanos() -> u64 {
312 SystemTime::now()
313 .duration_since(UNIX_EPOCH)
314 .unwrap_or_default()
315 .as_nanos() as u64
316 }
317 pub fn strip_ansi_escapes(s: &str) -> String {
318 let mut result = String::with_capacity(s.len());
319 let mut chars = s.chars().peekable();
320 while let Some(c) = chars.next() {
321 if c == '\x1b' {
322 if let Some('[') = chars.peek() {
323 chars.next();
324 while let Some(&nc) = chars.peek() {
325 chars.next();
326 if nc.is_ascii_alphabetic() {
327 break;
328 }
329 }
330 }
331 } else {
332 result.push(c);
333 }
334 }
335 result
336 }
337 pub fn memory_usage_report() -> String {
338 "0 B".to_string()
339 }
340 pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
341 pub fn default_user_agent() -> String {
342 DEFAULT_USER_AGENT.to_owned()
343 }
344 pub fn shorten_error_cause(err: &str) -> String {
345 let mut scrubbed = err;
346 if let Some(idx) = scrubbed.find(" for https://") {
347 scrubbed = &scrubbed[..idx];
348 } else if let Some(idx) = scrubbed.find(" for http://") {
349 scrubbed = &scrubbed[..idx];
350 } else if let Some(idx) = scrubbed.find(" (https://") {
351 scrubbed = &scrubbed[..idx];
352 } else if let Some(idx) = scrubbed.find(" (http://") {
353 scrubbed = &scrubbed[..idx];
354 } else if scrubbed.contains("error sending request for url") {
355 return "error sending request for url".to_string();
356 }
357 if let Some(line) = scrubbed.lines().next() {
358 if line.len() > 100 {
359 return format!("{}...", &line[..97]);
360 }
361 return line.to_string();
362 }
363 scrubbed.to_string()
364 }
365}
366pub mod logger {
367 pub mod formatter {
368 use crate::common::utils::{
369 BOLD, COLOR_DEBUG, COLOR_ERROR, COLOR_INFO, COLOR_TRACE, COLOR_WARN, DIM, RESET,
370 memory_usage_report,
371 };
372 use core::fmt::{self as core_fmt};
373 use tracing::{Event, Level, Subscriber};
374 use tracing_subscriber::{
375 fmt::{
376 self, FmtContext,
377 format::{FormatEvent, FormatFields},
378 },
379 registry::LookupSpan,
380 };
381 pub struct CustomFormatter {
382 use_ansi: bool,
383 }
384 impl CustomFormatter {
385 pub fn new(use_ansi: bool) -> Self {
386 Self { use_ansi }
387 }
388 fn write_timestamp(&self, writer: &mut fmt::format::Writer<'_>) -> core_fmt::Result {
389 let format = time::macros::format_description!(
390 "[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:3]"
391 );
392 let now = time::OffsetDateTime::now_local()
393 .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
394 let timestamp = now
395 .format(&format)
396 .unwrap_or_else(|_| "Unknown Time".to_string());
397 if self.use_ansi {
398 write!(writer, "{DIM}[{timestamp}]{RESET} ")
399 } else {
400 write!(writer, "[{timestamp}] ")
401 }
402 }
403 fn write_level(
404 &self,
405 writer: &mut fmt::format::Writer<'_>,
406 level: &Level,
407 ) -> core_fmt::Result {
408 let level_str = format!("{: <5}", level);
409 if self.use_ansi {
410 let color = match *level {
411 Level::ERROR => COLOR_ERROR,
412 Level::WARN => COLOR_WARN,
413 Level::INFO => COLOR_INFO,
414 Level::DEBUG => COLOR_DEBUG,
415 Level::TRACE => COLOR_TRACE,
416 };
417 write!(writer, "{color}{BOLD}{level_str}{RESET} ")
418 } else {
419 write!(writer, "{level_str} ")
420 }
421 }
422 }
423 impl<S, N> FormatEvent<S, N> for CustomFormatter
424 where
425 S: Subscriber + for<'a> LookupSpan<'a>,
426 N: for<'a> FormatFields<'a> + 'static,
427 {
428 fn format_event(
429 &self,
430 ctx: &FmtContext<'_, S, N>,
431 mut writer: fmt::format::Writer<'_>,
432 event: &Event<'_>,
433 ) -> core_fmt::Result {
434 let (reset, dim) = if self.use_ansi {
435 (RESET, DIM)
436 } else {
437 ("", "")
438 };
439 write!(writer, "{dim}[{}]{reset} ", memory_usage_report())?;
440 self.write_timestamp(&mut writer)?;
441 let metadata = event.metadata();
442 self.write_level(&mut writer, metadata.level())?;
443 let target = metadata.target();
444 let line = metadata
445 .line()
446 .map(|l| l.to_string())
447 .unwrap_or_else(|| "??".to_string());
448 write!(writer, "{dim}{target}: {line}{reset} > ")?;
449 ctx.format_fields(writer.by_ref(), event)?;
450 write!(writer, "{reset}")?;
451 writeln!(writer)
452 }
453 }
454 }
455 pub mod writer {
456 use parking_lot::Mutex;
457 use std::{
458 fs::{File, OpenOptions},
459 io::{self, BufRead, BufReader, Write},
460 path::{Path, PathBuf},
461 sync::Arc,
462 };
463 fn today_date() -> String {
464 let secs = std::time::SystemTime::now()
465 .duration_since(std::time::UNIX_EPOCH)
466 .unwrap_or_default()
467 .as_secs();
468 let days = (secs / 86400) as u32;
469 let (y, m, d) = days_to_ymd(days);
470 format!("{y:04}-{m:02}-{d:02}")
471 }
472 fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
473 days += 719468;
474 let era = days / 146097;
475 let doe = days - era * 146097;
476 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
477 let y = yoe + era * 400;
478 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
479 let mp = (5 * doy + 2) / 153;
480 let d = doy - (153 * mp + 2) / 5 + 1;
481 let m = if mp < 10 { mp + 3 } else { mp - 9 };
482 let y = if m <= 2 { y + 1 } else { y };
483 (y, m, d)
484 }
485 fn resolve_path(base_path: &str, rotate_daily: bool, date: &str) -> String {
486 if !rotate_daily {
487 return base_path.to_string();
488 }
489 let base = Path::new(base_path);
490 let stem = base
491 .file_stem()
492 .and_then(|s| s.to_str())
493 .unwrap_or("lavende");
494 let dir: PathBuf = base.parent().unwrap_or(Path::new(".")).into();
495 dir.join(format!("{stem}-{date}.log"))
496 .to_string_lossy()
497 .into_owned()
498 }
499 #[derive(Clone)]
500 pub struct CircularFileWriter {
501 base_path: String,
502 max_lines: u32,
503 max_files: u32,
504 rotate_daily: bool,
505 state: Arc<Mutex<WriterState>>,
506 }
507 struct WriterState {
508 file: Option<File>,
509 current_date: Option<String>,
510 lines_since_prune: u32,
511 is_pruning: bool,
512 }
513 impl CircularFileWriter {
514 pub fn new(path: String, max_lines: u32, max_files: u32, rotate_daily: bool) -> Self {
515 Self {
516 base_path: path,
517 max_lines,
518 max_files,
519 rotate_daily,
520 state: Arc::new(Mutex::new(WriterState {
521 file: None,
522 current_date: None,
523 lines_since_prune: 0,
524 is_pruning: false,
525 })),
526 }
527 }
528 fn current_path(&self) -> String {
529 if self.rotate_daily {
530 resolve_path(&self.base_path, true, &today_date())
531 } else {
532 self.base_path.clone()
533 }
534 }
535 fn ensure_file_open<'a>(&self, state: &'a mut WriterState) -> io::Result<&'a mut File> {
536 let today = if self.rotate_daily {
537 Some(today_date())
538 } else {
539 None
540 };
541 let need_rotate = state.file.is_none()
542 || match (&state.current_date, &today) {
543 (Some(curr), Some(new)) => curr != new,
544 _ => false,
545 };
546 if need_rotate {
547 state.file = None;
548 let path = if self.rotate_daily {
549 let d = today.as_deref().unwrap_or("");
550 resolve_path(&self.base_path, true, d)
551 } else {
552 self.base_path.clone()
553 };
554 if let Some(parent) = Path::new(&path).parent() {
555 let _ = std::fs::create_dir_all(parent);
556 }
557 state.file = Some(OpenOptions::new().create(true).append(true).open(&path)?);
558 state.current_date = today;
559 if self.rotate_daily && self.max_files > 0 {
560 self.cleanup_old_files();
561 }
562 }
563 Ok(state.file.as_mut().expect("file was just opened"))
564 }
565 fn spawn_prune(&self) {
566 let path = self.current_path();
567 let max_lines = self.max_lines;
568 let state_arc = self.state.clone();
569 std::thread::spawn(move || {
570 if let Err(e) = Self::do_prune(&path, max_lines) {
571 eprintln!("Failed to prune log file '{}': {}", path, e);
572 }
573 let mut state = state_arc.lock();
574 state.is_pruning = false;
575 });
576 }
577 fn cleanup_old_files(&self) {
578 let base = Path::new(&self.base_path);
579 let dir = base.parent().unwrap_or(Path::new("."));
580 let stem = base
581 .file_stem()
582 .and_then(|s| s.to_str())
583 .unwrap_or("lavende");
584 let max_files = self.max_files as usize;
585 let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
586 Ok(entries) => entries
587 .filter_map(|e| e.ok())
588 .map(|e| e.path())
589 .filter(|p| {
590 p.extension().and_then(|e| e.to_str()) == Some("log")
591 && p.file_stem()
592 .and_then(|s| s.to_str())
593 .map(|s| s.starts_with(stem) && s != stem)
594 .unwrap_or(false)
595 })
596 .collect(),
597 Err(_) => return,
598 };
599 if log_files.len() <= max_files {
600 return;
601 }
602 log_files.sort();
603 let to_delete = log_files.len() - max_files;
604 for path in log_files.iter().take(to_delete) {
605 if let Err(e) = std::fs::remove_file(path) {
606 eprintln!("Failed to delete old log file '{}': {}", path.display(), e);
607 }
608 }
609 }
610 fn do_prune(path: &str, max_lines: u32) -> io::Result<()> {
611 if !Path::new(path).exists() {
612 return Ok(());
613 }
614 let lines: Vec<String> = {
615 let file = File::open(path)?;
616 let reader = BufReader::new(file);
617 reader.lines().collect::<Result<_, _>>()?
618 };
619 if lines.len() > max_lines as usize {
620 let start = lines.len() - max_lines as usize;
621 let tmp_path = format!("{}.tmp", path);
622 {
623 let mut file = File::create(&tmp_path)?;
624 for line in &lines[start..] {
625 writeln!(file, "{}", line)?;
626 }
627 }
628 std::fs::rename(tmp_path, path)?;
629 }
630 Ok(())
631 }
632 }
633 impl io::Write for CircularFileWriter {
634 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
635 let mut state = self.state.lock();
636 let file = self.ensure_file_open(&mut state)?;
637 file.write_all(buf)?;
638 let new_lines = buf.iter().filter(|&&b| b == b'\n').count() as u32;
639 state.lines_since_prune += new_lines;
640 let prune_threshold = (self.max_lines / 10).max(50);
641 if state.lines_since_prune >= prune_threshold && !state.is_pruning {
642 state.is_pruning = true;
643 state.lines_since_prune = 0;
644 state.file = None;
645 self.spawn_prune();
646 }
647 Ok(buf.len())
648 }
649 fn flush(&mut self) -> io::Result<()> {
650 let mut state = self.state.lock();
651 if let Some(file) = &mut state.file {
652 file.flush()?;
653 }
654 Ok(())
655 }
656 }
657 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CircularFileWriter {
658 type Writer = Self;
659 fn make_writer(&'a self) -> Self::Writer {
660 self.clone()
661 }
662 }
663 #[cfg(test)]
664 mod tests {
665 use super::*;
666 use std::fs;
667 use tracing_subscriber::fmt::MakeWriter;
668 fn cleanup_test_file(path: &str) {
669 let _ = fs::remove_file(path);
670 let _ = fs::remove_file(format!("{}.tmp", path));
671 }
672 #[test]
673 fn test_circular_file_writer_new() {
674 let writer = CircularFileWriter::new("test_new.log".to_string(), 100, 0, false);
675 let state = writer.state.lock();
676 assert_eq!(state.lines_since_prune, 0);
677 assert!(!state.is_pruning);
678 assert!(state.file.is_none());
679 cleanup_test_file("test_new.log");
680 }
681 #[test]
682 fn test_write_creates_file() {
683 let path = "test_create.log";
684 cleanup_test_file(path);
685 let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
686 let data = b"test line\n";
687 let result = writer.write(data);
688 assert!(result.is_ok());
689 assert_eq!(result.unwrap(), data.len());
690 assert!(Path::new(path).exists());
691 cleanup_test_file(path);
692 }
693 #[test]
694 fn test_write_counts_newlines() {
695 let path = "test_newlines.log";
696 cleanup_test_file(path);
697 let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
698 writer.write(b"line1\nline2\nline3\n").unwrap();
699 let state = writer.state.lock();
700 assert_eq!(state.lines_since_prune, 3);
701 cleanup_test_file(path);
702 }
703 #[test]
704 fn test_write_no_newlines() {
705 let path = "test_no_newlines.log";
706 cleanup_test_file(path);
707 let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
708 writer.write(b"no newline here").unwrap();
709 let state = writer.state.lock();
710 assert_eq!(state.lines_since_prune, 0);
711 cleanup_test_file(path);
712 }
713 #[test]
714 fn test_flush() {
715 let path = "test_flush.log";
716 cleanup_test_file(path);
717 let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
718 writer.write(b"test\n").unwrap();
719 let result = writer.flush();
720 assert!(result.is_ok());
721 cleanup_test_file(path);
722 }
723 #[test]
724 fn test_flush_without_file() {
725 let mut writer =
726 CircularFileWriter::new("test_flush_no_file.log".to_string(), 100, 0, false);
727 let result = writer.flush();
728 assert!(result.is_ok());
729 cleanup_test_file("test_flush_no_file.log");
730 }
731 #[test]
732 fn test_clone() {
733 let writer = CircularFileWriter::new("test_clone.log".to_string(), 100, 0, false);
734 let cloned = writer.clone();
735 assert!(Arc::ptr_eq(&writer.state, &cloned.state));
736 cleanup_test_file("test_clone.log");
737 }
738 #[test]
739 fn test_make_writer() {
740 let writer =
741 CircularFileWriter::new("test_make_writer.log".to_string(), 100, 0, false);
742 let made = writer.make_writer();
743 assert!(Arc::ptr_eq(&writer.state, &made.state));
744 cleanup_test_file("test_make_writer.log");
745 }
746 #[test]
747 fn test_do_prune_nonexistent_file() {
748 let result = CircularFileWriter::do_prune("nonexistent_prune.log", 10);
749 assert!(result.is_ok());
750 }
751 #[test]
752 fn test_do_prune_small_file() {
753 let path = "test_prune_small.log";
754 cleanup_test_file(path);
755 fs::write(path, "line1\nline2\nline3\n").unwrap();
756 let result = CircularFileWriter::do_prune(path, 10);
757 assert!(result.is_ok());
758 let content = fs::read_to_string(path).unwrap();
759 assert_eq!(content.lines().count(), 3);
760 cleanup_test_file(path);
761 }
762 #[test]
763 fn test_do_prune_large_file() {
764 let path = "test_prune_large.log";
765 cleanup_test_file(path);
766 let mut content = String::new();
767 for i in 1..=20 {
768 content.push_str(&format!("line{}\n", i));
769 }
770 fs::write(path, content).unwrap();
771 let result = CircularFileWriter::do_prune(path, 10);
772 assert!(result.is_ok());
773 let pruned = fs::read_to_string(path).unwrap();
774 let lines: Vec<&str> = pruned.lines().collect();
775 assert_eq!(lines.len(), 10);
776 assert_eq!(lines[0], "line11");
777 assert_eq!(lines[9], "line20");
778 cleanup_test_file(path);
779 }
780 #[test]
781 fn test_resolve_path_no_rotate() {
782 let p = resolve_path("./logs/lavende.log", false, "2026-03-13");
783 assert_eq!(p, "./logs/lavende.log");
784 }
785 #[test]
786 fn test_resolve_path_rotate() {
787 let p = resolve_path("./logs/lavende.log", true, "2026-03-13");
788 assert!(p.contains("2026-03-13"));
789 assert!(p.ends_with(".log"));
790 }
791 #[test]
792 fn test_today_date_format() {
793 let d = today_date();
794 let parts: Vec<&str> = d.split('-').collect();
795 assert_eq!(parts.len(), 3);
796 assert_eq!(parts[0].len(), 4);
797 assert_eq!(parts[1].len(), 2);
798 assert_eq!(parts[2].len(), 2);
799 }
800 #[test]
801 fn test_prune_threshold_calculation() {
802 let _writer = CircularFileWriter::new("test.log".to_string(), 1000, 0, false);
803 let threshold = (1000 / 10).max(50);
804 assert_eq!(threshold, 100);
805 let _writer = CircularFileWriter::new("test.log".to_string(), 100, 0, false);
806 let threshold = (100 / 10).max(50);
807 assert_eq!(threshold, 50);
808 let _writer = CircularFileWriter::new("test.log".to_string(), 10, 0, false);
809 let threshold = (10 / 10).max(50);
810 assert_eq!(threshold, 50);
811 cleanup_test_file("test.log");
812 }
813 }
814 }
815 use crate::{common::utils::strip_ansi_escapes, config::LoggingConfig};
816 pub use formatter::CustomFormatter;
817 use std::{fs, path::Path, sync::OnceLock};
818 use tracing_subscriber::{EnvFilter, fmt, prelude::*};
819 pub use writer::CircularFileWriter;
820 pub(crate) static GLOBAL_FILE_WRITER: OnceLock<CircularFileWriter> = OnceLock::new();
821 #[macro_export]
822 macro_rules! log_print {
823 ($($arg:tt)*) => {{
824 let msg = format!($($arg)*);
825 std::print!("{}", msg);
826 $crate::common::logger::append_to_file_raw(&msg);
827 }};
828}
829 #[macro_export]
830 macro_rules! log_println {
831 () => {{
832 std::println!();
833 $crate::common::logger::append_to_file_raw("\n");
834 }};
835 ($($arg:tt)*) => {{
836 let msg = format!($($arg)*);
837 std::println!("{}", msg);
838 $crate::common::logger::append_to_file_raw(&format!("{}\n", msg));
839 }};
840}
841 pub fn append_to_file_raw(msg: &str) {
842 if let Some(mut writer) = GLOBAL_FILE_WRITER.get().cloned() {
843 use std::io::Write;
844 let clean_msg = strip_ansi_escapes(msg);
845 let _ = writer.write_all(clean_msg.as_bytes());
846 }
847 }
848 pub fn init(config: &LoggingConfig) {
849 let _ = tracing_log::LogTracer::init();
850 let log_level = config.level.as_deref().unwrap_or("info");
851 let filter_str = match config.filters.as_deref() {
852 Some(f) if !f.is_empty() => format!("{log_level},{f}"),
853 _ => log_level.to_string(),
854 };
855 let env_filter =
856 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter_str));
857 let stdout_layer = fmt::layer()
858 .event_format(CustomFormatter::new(true))
859 .with_ansi(true);
860 let file_layer = config.file.as_ref().map(|file_config| {
861 if let Some(parent) = Path::new(&file_config.path).parent() {
862 let _ = fs::create_dir_all(parent);
863 }
864 let writer = CircularFileWriter::new(
865 file_config.path.clone(),
866 file_config.max_lines,
867 file_config.max_files,
868 file_config.rotate_daily,
869 );
870 let _ = GLOBAL_FILE_WRITER.set(writer.clone());
871 fmt::layer()
872 .with_writer(writer)
873 .event_format(CustomFormatter::new(false))
874 .with_ansi(false)
875 });
876 let _ = tracing_subscriber::registry()
877 .with(env_filter)
878 .with(stdout_layer)
879 .with(file_layer)
880 .try_init();
881 }
882}
883pub use errors::*;
884pub use http::*;
885pub use logger::*;
886pub use types::*;
887pub use utils::*;