1pub mod errors {
2use serde::{Deserialize, Serialize};
3use crate::common::utils::now_ms;
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub enum Severity {
7 Common,
8 Suspicious,
9 Fault,
10}
11#[derive(Debug, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct RustalinkError {
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}
22impl RustalinkError {
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 {
47use std::{ops::Deref, sync::Arc};
48use rand::{Rng, distributions::Alphanumeric};
49use tokio::sync::{Mutex, RwLock};
50pub type Shared<T> = Arc<Mutex<T>>;
51pub type SharedRw<T> = Arc<RwLock<T>>;
52pub type AnyError = Box<dyn std::error::Error + Send + Sync>;
53pub type AnyResult<T> = std::result::Result<T, AnyError>;
54macro_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}
97define_id!(GuildId, String);
98define_id!(SessionId, String);
99define_id!(UserId, u64, copy);
100define_id!(ChannelId, u64, copy);
101impl Deref for GuildId {
102 type Target = str;
103 fn deref(&self) -> &Self::Target {
104 &self.0
105 }
106}
107impl Deref for SessionId {
108 type Target = str;
109 fn deref(&self) -> &Self::Target {
110 &self.0
111 }
112}
113impl 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)]
126pub enum AudioFormat {
127 Aac,
128 Opus,
129 Webm,
130 Mp4,
131 Mp3,
132 Ogg,
133 Flac,
134 Wav,
135 Unknown,
136}
137impl 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}
212fn 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 {
221use std::{sync::Arc, time::Duration};
222use dashmap::DashMap;
223use reqwest::{Client, Proxy};
224use tracing::warn;
225use crate::{common::utils::default_user_agent, config::HttpProxyConfig};
226pub struct HttpClientPool {
227 clients: DashMap<Option<HttpProxyConfig>, Arc<Client>>,
228}
229impl 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}
282impl Default for HttpClientPool {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287}
288pub mod utils {
289use std::time::{SystemTime, UNIX_EPOCH};
290pub const RESET: &str = "\x1b[0m";
291pub const BOLD: &str = "\x1b[1m";
292pub const DIM: &str = "\x1b[2m";
293pub const ORANGE: &str = "\x1b[38;5;208m";
294pub const GREEN: &str = "\x1b[32m";
295pub const CYAN: &str = "\x1b[36m";
296pub const YELLOW: &str = "\x1b[33m";
297pub const BLUE: &str = "\x1b[34m";
298pub const MAGENTA: &str = "\x1b[35m";
299pub const RED: &str = "\x1b[31m";
300pub const COLOR_ERROR: &str = RED;
301pub const COLOR_WARN: &str = YELLOW;
302pub const COLOR_INFO: &str = GREEN;
303pub const COLOR_DEBUG: &str = BLUE;
304pub const COLOR_TRACE: &str = MAGENTA;
305pub fn now_ms() -> u64 {
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .unwrap_or_default()
309 .as_millis() as u64
310}
311pub fn now_nanos() -> u64 {
312 SystemTime::now()
313 .duration_since(UNIX_EPOCH)
314 .unwrap_or_default()
315 .as_nanos() as u64
316}
317pub 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}
337pub fn memory_usage_report() -> String {
338 "0 B".to_string()
339}
340pub 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";
341pub fn default_user_agent() -> String {
342 DEFAULT_USER_AGENT.to_owned()
343}
344pub 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 {
367pub mod formatter {
368use core::fmt::{self as core_fmt};
369use tracing::{Event, Level, Subscriber};
370use tracing_subscriber::{
371 fmt::{
372 self, FmtContext,
373 format::{FormatEvent, FormatFields},
374 },
375 registry::LookupSpan,
376};
377use crate::common::utils::{
378 BOLD, COLOR_DEBUG, COLOR_ERROR, COLOR_INFO, COLOR_TRACE, COLOR_WARN, DIM, RESET,
379 memory_usage_report,
380};
381pub struct CustomFormatter {
382 use_ansi: bool,
383}
384impl 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 =
393 time::OffsetDateTime::now_local().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(&self, writer: &mut fmt::format::Writer<'_>, level: &Level) -> core_fmt::Result {
404 let level_str = format!("{: <5}", level);
405 if self.use_ansi {
406 let color = match *level {
407 Level::ERROR => COLOR_ERROR,
408 Level::WARN => COLOR_WARN,
409 Level::INFO => COLOR_INFO,
410 Level::DEBUG => COLOR_DEBUG,
411 Level::TRACE => COLOR_TRACE,
412 };
413 write!(writer, "{color}{BOLD}{level_str}{RESET} ")
414 } else {
415 write!(writer, "{level_str} ")
416 }
417 }
418}
419impl<S, N> FormatEvent<S, N> for CustomFormatter
420where
421 S: Subscriber + for<'a> LookupSpan<'a>,
422 N: for<'a> FormatFields<'a> + 'static,
423{
424 fn format_event(
425 &self,
426 ctx: &FmtContext<'_, S, N>,
427 mut writer: fmt::format::Writer<'_>,
428 event: &Event<'_>,
429 ) -> core_fmt::Result {
430 let (reset, dim) = if self.use_ansi {
431 (RESET, DIM)
432 } else {
433 ("", "")
434 };
435 write!(writer, "{dim}[{}]{reset} ", memory_usage_report())?;
436 self.write_timestamp(&mut writer)?;
437 let metadata = event.metadata();
438 self.write_level(&mut writer, metadata.level())?;
439 let target = metadata.target();
440 let line = metadata
441 .line()
442 .map(|l| l.to_string())
443 .unwrap_or_else(|| "??".to_string());
444 write!(writer, "{dim}{target}: {line}{reset} > ")?;
445 ctx.format_fields(writer.by_ref(), event)?;
446 write!(writer, "{reset}")?;
447 writeln!(writer)
448 }
449}
450}
451pub mod writer {
452use std::{
453 fs::{File, OpenOptions},
454 io::{self, BufRead, BufReader, Write},
455 path::{Path, PathBuf},
456 sync::Arc,
457};
458use parking_lot::Mutex;
459fn today_date() -> String {
460 let secs = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .unwrap_or_default()
463 .as_secs();
464 let days = (secs / 86400) as u32;
465 let (y, m, d) = days_to_ymd(days);
466 format!("{y:04}-{m:02}-{d:02}")
467}
468fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
469 days += 719468;
470 let era = days / 146097;
471 let doe = days - era * 146097;
472 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
473 let y = yoe + era * 400;
474 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
475 let mp = (5 * doy + 2) / 153;
476 let d = doy - (153 * mp + 2) / 5 + 1;
477 let m = if mp < 10 { mp + 3 } else { mp - 9 };
478 let y = if m <= 2 { y + 1 } else { y };
479 (y, m, d)
480}
481fn resolve_path(base_path: &str, rotate_daily: bool, date: &str) -> String {
482 if !rotate_daily {
483 return base_path.to_string();
484 }
485 let base = Path::new(base_path);
486 let stem = base
487 .file_stem()
488 .and_then(|s| s.to_str())
489 .unwrap_or("rustalink");
490 let dir: PathBuf = base.parent().unwrap_or(Path::new(".")).into();
491 dir.join(format!("{stem}-{date}.log"))
492 .to_string_lossy()
493 .into_owned()
494}
495#[derive(Clone)]
496pub struct CircularFileWriter {
497 base_path: String,
498 max_lines: u32,
499 max_files: u32,
500 rotate_daily: bool,
501 state: Arc<Mutex<WriterState>>,
502}
503struct WriterState {
504 file: Option<File>,
505 current_date: Option<String>,
506 lines_since_prune: u32,
507 is_pruning: bool,
508}
509impl CircularFileWriter {
510 pub fn new(path: String, max_lines: u32, max_files: u32, rotate_daily: bool) -> Self {
511 Self {
512 base_path: path,
513 max_lines,
514 max_files,
515 rotate_daily,
516 state: Arc::new(Mutex::new(WriterState {
517 file: None,
518 current_date: None,
519 lines_since_prune: 0,
520 is_pruning: false,
521 })),
522 }
523 }
524 fn current_path(&self) -> String {
525 if self.rotate_daily {
526 resolve_path(&self.base_path, true, &today_date())
527 } else {
528 self.base_path.clone()
529 }
530 }
531 fn ensure_file_open<'a>(&self, state: &'a mut WriterState) -> io::Result<&'a mut File> {
532 let today = if self.rotate_daily {
533 Some(today_date())
534 } else {
535 None
536 };
537 let need_rotate = state.file.is_none()
538 || match (&state.current_date, &today) {
539 (Some(curr), Some(new)) => curr != new,
540 _ => false,
541 };
542 if need_rotate {
543 state.file = None;
544 let path = if self.rotate_daily {
545 let d = today.as_deref().unwrap_or("");
546 resolve_path(&self.base_path, true, d)
547 } else {
548 self.base_path.clone()
549 };
550 if let Some(parent) = Path::new(&path).parent() {
551 let _ = std::fs::create_dir_all(parent);
552 }
553 state.file = Some(OpenOptions::new().create(true).append(true).open(&path)?);
554 state.current_date = today;
555 if self.rotate_daily && self.max_files > 0 {
556 self.cleanup_old_files();
557 }
558 }
559 Ok(state.file.as_mut().expect("file was just opened"))
560 }
561 fn spawn_prune(&self) {
562 let path = self.current_path();
563 let max_lines = self.max_lines;
564 let state_arc = self.state.clone();
565 std::thread::spawn(move || {
566 if let Err(e) = Self::do_prune(&path, max_lines) {
567 eprintln!("Failed to prune log file '{}': {}", path, e);
568 }
569 let mut state = state_arc.lock();
570 state.is_pruning = false;
571 });
572 }
573 fn cleanup_old_files(&self) {
574 let base = Path::new(&self.base_path);
575 let dir = base.parent().unwrap_or(Path::new("."));
576 let stem = base
577 .file_stem()
578 .and_then(|s| s.to_str())
579 .unwrap_or("rustalink");
580 let max_files = self.max_files as usize;
581 let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
582 Ok(entries) => entries
583 .filter_map(|e| e.ok())
584 .map(|e| e.path())
585 .filter(|p| {
586 p.extension().and_then(|e| e.to_str()) == Some("log")
587 && p.file_stem()
588 .and_then(|s| s.to_str())
589 .map(|s| s.starts_with(stem) && s != stem)
590 .unwrap_or(false)
591 })
592 .collect(),
593 Err(_) => return,
594 };
595 if log_files.len() <= max_files {
596 return;
597 }
598 log_files.sort();
599 let to_delete = log_files.len() - max_files;
600 for path in log_files.iter().take(to_delete) {
601 if let Err(e) = std::fs::remove_file(path) {
602 eprintln!("Failed to delete old log file '{}': {}", path.display(), e);
603 }
604 }
605 }
606 fn do_prune(path: &str, max_lines: u32) -> io::Result<()> {
607 if !Path::new(path).exists() {
608 return Ok(());
609 }
610 let lines: Vec<String> = {
611 let file = File::open(path)?;
612 let reader = BufReader::new(file);
613 reader.lines().collect::<Result<_, _>>()?
614 };
615 if lines.len() > max_lines as usize {
616 let start = lines.len() - max_lines as usize;
617 let tmp_path = format!("{}.tmp", path);
618 {
619 let mut file = File::create(&tmp_path)?;
620 for line in &lines[start..] {
621 writeln!(file, "{}", line)?;
622 }
623 }
624 std::fs::rename(tmp_path, path)?;
625 }
626 Ok(())
627 }
628}
629impl io::Write for CircularFileWriter {
630 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
631 let mut state = self.state.lock();
632 let file = self.ensure_file_open(&mut state)?;
633 file.write_all(buf)?;
634 let new_lines = buf.iter().filter(|&&b| b == b'\n').count() as u32;
635 state.lines_since_prune += new_lines;
636 let prune_threshold = (self.max_lines / 10).max(50);
637 if state.lines_since_prune >= prune_threshold && !state.is_pruning {
638 state.is_pruning = true;
639 state.lines_since_prune = 0;
640 state.file = None;
641 self.spawn_prune();
642 }
643 Ok(buf.len())
644 }
645 fn flush(&mut self) -> io::Result<()> {
646 let mut state = self.state.lock();
647 if let Some(file) = &mut state.file {
648 file.flush()?;
649 }
650 Ok(())
651 }
652}
653impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CircularFileWriter {
654 type Writer = Self;
655 fn make_writer(&'a self) -> Self::Writer {
656 self.clone()
657 }
658}
659#[cfg(test)]
660mod tests {
661 use std::fs;
662 use tracing_subscriber::fmt::MakeWriter;
663 use super::*;
664 fn cleanup_test_file(path: &str) {
665 let _ = fs::remove_file(path);
666 let _ = fs::remove_file(format!("{}.tmp", path));
667 }
668 #[test]
669 fn test_circular_file_writer_new() {
670 let writer = CircularFileWriter::new("test_new.log".to_string(), 100, 0, false);
671 let state = writer.state.lock();
672 assert_eq!(state.lines_since_prune, 0);
673 assert!(!state.is_pruning);
674 assert!(state.file.is_none());
675 cleanup_test_file("test_new.log");
676 }
677 #[test]
678 fn test_write_creates_file() {
679 let path = "test_create.log";
680 cleanup_test_file(path);
681 let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
682 let data = b"test line\n";
683 let result = writer.write(data);
684 assert!(result.is_ok());
685 assert_eq!(result.unwrap(), data.len());
686 assert!(Path::new(path).exists());
687 cleanup_test_file(path);
688 }
689 #[test]
690 fn test_write_counts_newlines() {
691 let path = "test_newlines.log";
692 cleanup_test_file(path);
693 let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
694 writer.write(b"line1\nline2\nline3\n").unwrap();
695 let state = writer.state.lock();
696 assert_eq!(state.lines_since_prune, 3);
697 cleanup_test_file(path);
698 }
699 #[test]
700 fn test_write_no_newlines() {
701 let path = "test_no_newlines.log";
702 cleanup_test_file(path);
703 let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
704 writer.write(b"no newline here").unwrap();
705 let state = writer.state.lock();
706 assert_eq!(state.lines_since_prune, 0);
707 cleanup_test_file(path);
708 }
709 #[test]
710 fn test_flush() {
711 let path = "test_flush.log";
712 cleanup_test_file(path);
713 let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
714 writer.write(b"test\n").unwrap();
715 let result = writer.flush();
716 assert!(result.is_ok());
717 cleanup_test_file(path);
718 }
719 #[test]
720 fn test_flush_without_file() {
721 let mut writer =
722 CircularFileWriter::new("test_flush_no_file.log".to_string(), 100, 0, false);
723 let result = writer.flush();
724 assert!(result.is_ok());
725 cleanup_test_file("test_flush_no_file.log");
726 }
727 #[test]
728 fn test_clone() {
729 let writer = CircularFileWriter::new("test_clone.log".to_string(), 100, 0, false);
730 let cloned = writer.clone();
731 assert!(Arc::ptr_eq(&writer.state, &cloned.state));
732 cleanup_test_file("test_clone.log");
733 }
734 #[test]
735 fn test_make_writer() {
736 let writer = CircularFileWriter::new("test_make_writer.log".to_string(), 100, 0, false);
737 let made = writer.make_writer();
738 assert!(Arc::ptr_eq(&writer.state, &made.state));
739 cleanup_test_file("test_make_writer.log");
740 }
741 #[test]
742 fn test_do_prune_nonexistent_file() {
743 let result = CircularFileWriter::do_prune("nonexistent_prune.log", 10);
744 assert!(result.is_ok());
745 }
746 #[test]
747 fn test_do_prune_small_file() {
748 let path = "test_prune_small.log";
749 cleanup_test_file(path);
750 fs::write(path, "line1\nline2\nline3\n").unwrap();
751 let result = CircularFileWriter::do_prune(path, 10);
752 assert!(result.is_ok());
753 let content = fs::read_to_string(path).unwrap();
754 assert_eq!(content.lines().count(), 3);
755 cleanup_test_file(path);
756 }
757 #[test]
758 fn test_do_prune_large_file() {
759 let path = "test_prune_large.log";
760 cleanup_test_file(path);
761 let mut content = String::new();
762 for i in 1..=20 {
763 content.push_str(&format!("line{}\n", i));
764 }
765 fs::write(path, content).unwrap();
766 let result = CircularFileWriter::do_prune(path, 10);
767 assert!(result.is_ok());
768 let pruned = fs::read_to_string(path).unwrap();
769 let lines: Vec<&str> = pruned.lines().collect();
770 assert_eq!(lines.len(), 10);
771 assert_eq!(lines[0], "line11");
772 assert_eq!(lines[9], "line20");
773 cleanup_test_file(path);
774 }
775 #[test]
776 fn test_resolve_path_no_rotate() {
777 let p = resolve_path("./logs/rustalink.log", false, "2026-03-13");
778 assert_eq!(p, "./logs/rustalink.log");
779 }
780 #[test]
781 fn test_resolve_path_rotate() {
782 let p = resolve_path("./logs/rustalink.log", true, "2026-03-13");
783 assert!(p.contains("2026-03-13"));
784 assert!(p.ends_with(".log"));
785 }
786 #[test]
787 fn test_today_date_format() {
788 let d = today_date();
789 let parts: Vec<&str> = d.split('-').collect();
790 assert_eq!(parts.len(), 3);
791 assert_eq!(parts[0].len(), 4);
792 assert_eq!(parts[1].len(), 2);
793 assert_eq!(parts[2].len(), 2);
794 }
795 #[test]
796 fn test_prune_threshold_calculation() {
797 let _writer = CircularFileWriter::new("test.log".to_string(), 1000, 0, false);
798 let threshold = (1000 / 10).max(50);
799 assert_eq!(threshold, 100);
800 let _writer = CircularFileWriter::new("test.log".to_string(), 100, 0, false);
801 let threshold = (100 / 10).max(50);
802 assert_eq!(threshold, 50);
803 let _writer = CircularFileWriter::new("test.log".to_string(), 10, 0, false);
804 let threshold = (10 / 10).max(50);
805 assert_eq!(threshold, 50);
806 cleanup_test_file("test.log");
807 }
808}
809}
810use std::{fs, path::Path, sync::OnceLock};
811use tracing_subscriber::{EnvFilter, fmt, prelude::*};
812use crate::{common::utils::strip_ansi_escapes, config::LoggingConfig};
813pub use formatter::CustomFormatter;
814pub use writer::CircularFileWriter;
815pub(crate) static GLOBAL_FILE_WRITER: OnceLock<CircularFileWriter> = OnceLock::new();
816#[macro_export]
817macro_rules! log_print {
818 ($($arg:tt)*) => {{
819 let msg = format!($($arg)*);
820 std::print!("{}", msg);
821 $crate::common::logger::append_to_file_raw(&msg);
822 }};
823}
824#[macro_export]
825macro_rules! log_println {
826 () => {{
827 std::println!();
828 $crate::common::logger::append_to_file_raw("\n");
829 }};
830 ($($arg:tt)*) => {{
831 let msg = format!($($arg)*);
832 std::println!("{}", msg);
833 $crate::common::logger::append_to_file_raw(&format!("{}\n", msg));
834 }};
835}
836pub fn append_to_file_raw(msg: &str) {
837 if let Some(mut writer) = GLOBAL_FILE_WRITER.get().cloned() {
838 use std::io::Write;
839 let clean_msg = strip_ansi_escapes(msg);
840 let _ = writer.write_all(clean_msg.as_bytes());
841 }
842}
843pub fn init(config: &LoggingConfig) {
844 let _ = tracing_log::LogTracer::init();
845 let log_level = config.level.as_deref().unwrap_or("info");
846 let filter_str = match config.filters.as_deref() {
847 Some(f) if !f.is_empty() => format!("{log_level},{f}"),
848 _ => log_level.to_string(),
849 };
850 let env_filter =
851 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter_str));
852 let stdout_layer = fmt::layer()
853 .event_format(CustomFormatter::new(true))
854 .with_ansi(true);
855 let file_layer = config.file.as_ref().map(|file_config| {
856 if let Some(parent) = Path::new(&file_config.path).parent() {
857 let _ = fs::create_dir_all(parent);
858 }
859 let writer = CircularFileWriter::new(
860 file_config.path.clone(),
861 file_config.max_lines,
862 file_config.max_files,
863 file_config.rotate_daily,
864 );
865 let _ = GLOBAL_FILE_WRITER.set(writer.clone());
866 fmt::layer()
867 .with_writer(writer)
868 .event_format(CustomFormatter::new(false))
869 .with_ansi(false)
870 });
871 let _ = tracing_subscriber::registry()
872 .with(env_filter)
873 .with(stdout_layer)
874 .with(file_layer)
875 .try_init();
876}
877}
878pub use errors::*;
879pub use http::*;
880pub use logger::*;
881pub use types::*;
882pub use utils::*;