1use std::collections::{HashMap, VecDeque};
34use std::fmt::Write as _;
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::Arc;
37
38use parking_lot::RwLock;
39
40use crate::context::Context;
41use crate::effect::Disposable;
42use crate::service::Service;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum LogKind {
52 Error,
53 Warn,
54 Info,
55 Debug,
56}
57
58impl LogKind {
59 pub fn severity(self) -> u8 {
61 match self {
62 LogKind::Error => 0,
63 LogKind::Warn => 1,
64 LogKind::Info => 2,
65 LogKind::Debug => 3,
66 }
67 }
68
69 pub fn as_str(self) -> &'static str {
71 match self {
72 LogKind::Error => "error",
73 LogKind::Warn => "warn",
74 LogKind::Info => "info",
75 LogKind::Debug => "debug",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub struct LogLevel(u8);
86
87impl LogLevel {
88 pub const ERROR: LogLevel = LogLevel(0);
89 pub const WARN: LogLevel = LogLevel(1);
90 pub const INFO: LogLevel = LogLevel(2);
91 pub const DEBUG: LogLevel = LogLevel(3);
92
93 pub fn allows(self, kind: LogKind) -> bool {
95 kind.severity() <= self.0
96 }
97}
98
99impl From<LogKind> for LogLevel {
100 fn from(kind: LogKind) -> Self {
101 LogLevel(kind.severity())
102 }
103}
104
105#[derive(Debug, Clone, PartialEq)]
112pub enum LogArg {
113 String(String),
114 Integer(i64),
115 Unsigned(u64),
116 Float(f64),
117 Bool(bool),
118 Object(serde_json::Value),
120}
121
122impl From<&str> for LogArg {
123 fn from(v: &str) -> Self {
124 LogArg::String(v.to_string())
125 }
126}
127
128impl From<String> for LogArg {
129 fn from(v: String) -> Self {
130 LogArg::String(v)
131 }
132}
133
134impl From<i64> for LogArg {
135 fn from(v: i64) -> Self {
136 LogArg::Integer(v)
137 }
138}
139
140impl From<i32> for LogArg {
141 fn from(v: i32) -> Self {
142 LogArg::Integer(v as i64)
143 }
144}
145
146impl From<u64> for LogArg {
147 fn from(v: u64) -> Self {
148 LogArg::Unsigned(v)
149 }
150}
151
152impl From<usize> for LogArg {
153 fn from(v: usize) -> Self {
154 LogArg::Unsigned(v as u64)
155 }
156}
157
158impl From<f64> for LogArg {
159 fn from(v: f64) -> Self {
160 LogArg::Float(v)
161 }
162}
163
164impl From<bool> for LogArg {
165 fn from(v: bool) -> Self {
166 LogArg::Bool(v)
167 }
168}
169
170impl From<serde_json::Value> for LogArg {
171 fn from(v: serde_json::Value) -> Self {
172 LogArg::Object(v)
173 }
174}
175
176const ANSI16: [u8; 16] = [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97];
178
179fn name_color_code(name: &str) -> u8 {
182 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
183 for byte in name.bytes() {
184 hash ^= byte as u64;
185 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
186 }
187 ANSI16[(hash % ANSI16.len() as u64) as usize]
188}
189
190fn render_object(value: &serde_json::Value, pretty: bool) -> String {
191 if !pretty {
192 return value.to_string();
193 }
194 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
195}
196
197fn arg_text(arg: &LogArg, pretty_objects: bool) -> String {
200 match arg {
201 LogArg::String(s) => s.clone(),
202 LogArg::Integer(v) => v.to_string(),
203 LogArg::Unsigned(v) => v.to_string(),
204 LogArg::Float(v) => v.to_string(),
205 LogArg::Bool(v) => v.to_string(),
206 LogArg::Object(v) => render_object(v, pretty_objects),
207 }
208}
209
210#[derive(Debug, Clone, PartialEq)]
212pub struct Message {
213 pub sequence: u64,
215 pub timestamp_ms: u64,
217 pub name: String,
219 pub kind: LogKind,
220 pub level: LogLevel,
222 pub args: Vec<LogArg>,
223 pub fiber_name: String,
227}
228
229impl Message {
230 pub fn render(&self) -> String {
240 let fmt_head = match self.args.first() {
241 Some(LogArg::String(s)) if s.contains('%') => Some(s.as_str()),
242 _ => None,
243 };
244
245 let mut out = String::new();
246 let Some(fmt) = fmt_head else {
247 for (i, arg) in self.args.iter().enumerate() {
248 if i > 0 {
249 out.push(' ');
250 }
251 out.push_str(&arg_text(arg, false));
252 }
253 return out;
254 };
255
256 let mut next = 1usize;
257 let chars: Vec<char> = fmt.chars().collect();
258 let mut i = 0usize;
259 while i < chars.len() {
260 let ch = chars[i];
261 if ch != '%' {
262 out.push(ch);
263 i += 1;
264 continue;
265 }
266 i += 1;
267 let Some(&spec) = chars.get(i) else {
268 out.push('%');
269 break;
270 };
271 i += 1;
272 match spec {
273 '%' => out.push('%'),
274 's' | 'd' | 'i' | 'f' | 'o' | 'O' => match self.args.get(next) {
275 Some(arg) => {
276 out.push_str(&arg_text(arg, spec == 'O'));
277 next += 1;
278 }
279 None => {
280 out.push('%');
281 out.push(spec);
282 }
283 },
284 'c' | 'C' => match self.args.get(next) {
285 Some(arg) => {
286 let text = arg_text(arg, false);
287 let code = name_color_code(&self.name);
288 let _ = write!(out, "\x1b[{code}m{}\x1b[0m", text);
289 if spec == 'C' {
290 let body = &out[out.len() - text.len() - 5..out.len()];
292 let _ = body;
293 let colored =
294 format!("\x1b[{code};1m{text}\x1b[0m");
295 out.truncate(out.len() - text.len() - 5 - ("\x1b[0m".len()));
296 let _ = colored;
297 let _ = write!(out, "\x1b[{code};1m{text}\x1b[0m");
298 }
299 next += 1;
300 }
301 None => {
302 out.push('%');
303 out.push(spec);
304 }
305 },
306 other => {
307 out.push('%');
308 out.push(other);
309 }
310 }
311 }
312
313 for arg in self.args.iter().skip(next) {
315 out.push(' ');
316 out.push_str(&arg_text(arg, false));
317 }
318 out
319 }
320}
321
322#[derive(Debug, Clone)]
329pub struct ExporterConfig {
330 pub levels: HashMap<String, LogLevel>,
332 pub max_length: usize,
334}
335
336impl Default for ExporterConfig {
337 fn default() -> Self {
338 Self {
339 levels: HashMap::new(),
340 max_length: 4096,
341 }
342 }
343}
344
345impl ExporterConfig {
346 pub fn new(levels: HashMap<String, LogLevel>, max_length: usize) -> Self {
347 Self { levels, max_length }
348 }
349
350 pub fn threshold(&self, name: &str) -> LogLevel {
352 self.levels
353 .get(name)
354 .copied()
355 .unwrap_or(LogLevel::DEBUG)
356 }
357
358 pub fn truncate(&self, text: String) -> String {
360 if self.max_length == 0 {
361 return String::new();
362 }
363 if text.chars().count() <= self.max_length {
364 return text;
365 }
366 text.chars().take(self.max_length).collect()
367 }
368}
369
370pub trait Exporter: Send + Sync + 'static {
374 fn export(&self, message: &Message, text: &str);
375}
376
377struct ExportSlot {
378 exporter: Arc<dyn Exporter>,
379 config: ExporterConfig,
380 key: usize,
382}
383
384#[derive(Debug, Clone, Default, PartialEq, Eq)]
396pub struct LoggerIntercept {
397 pub name: Option<String>,
398 pub level: Option<LogLevel>,
399}
400
401impl Service for LoggerIntercept {
402 fn name(&self) -> &'static str {
403 "logger_intercept"
404 }
405}
406
407impl LoggerIntercept {
408 pub fn matches(&self, logger_name: &str) -> bool {
409 match &self.name {
410 Some(n) => n == logger_name,
411 None => true,
412 }
413 }
414}
415
416pub struct LoggerService {
430 buffer: RwLock<VecDeque<Arc<Message>>>,
431 capacity: usize,
432 seq: AtomicU64,
433 levels: RwLock<HashMap<String, LogLevel>>,
434 default_level: RwLock<LogLevel>,
435 exporters: RwLock<Vec<ExportSlot>>,
436}
437
438impl Service for LoggerService {
439 fn name(&self) -> &'static str {
440 "logger_service"
441 }
442}
443
444fn unix_now_ms() -> u64 {
445 std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .map(|d| d.as_millis() as u64)
448 .unwrap_or(0)
449}
450
451fn fiber_label(ctx: &Context) -> String {
455 fiber_label_from_fiber(&ctx.fiber())
456}
457
458fn fiber_label_from_fiber(fiber: &Arc<crate::fiber::Fiber>) -> String {
459 let ptr = Arc::as_ptr(fiber) as *const () as usize;
460 format!("fiber-{ptr:x}")
461}
462
463impl Default for LoggerService {
464 fn default() -> Self {
465 Self::with_capacity(1000)
466 }
467}
468
469impl LoggerService {
470 pub fn new() -> Self {
471 Self::default()
472 }
473
474 pub fn with_capacity(capacity: usize) -> Self {
475 Self {
476 buffer: RwLock::new(VecDeque::with_capacity(capacity.min(1024))),
477 capacity: capacity.max(1),
478 seq: AtomicU64::new(0),
479 levels: RwLock::new(HashMap::new()),
480 default_level: RwLock::new(LogLevel::DEBUG),
481 exporters: RwLock::new(Vec::new()),
482 }
483 }
484
485 pub fn set_level(&self, name: impl Into<String>, level: LogLevel) {
487 self.levels.write().insert(name.into(), level);
488 }
489
490 pub fn clear_level(&self, name: &str) {
492 self.levels.write().remove(name);
493 }
494
495 pub fn set_default_level(&self, level: LogLevel) {
497 *self.default_level.write() = level;
498 }
499
500 fn effective_threshold(&self, ctx: &Context, name: &str) -> LogLevel {
503 let mut level = *self.default_level.read();
504 if let Some(pinned) = self.levels.read().get(name) {
505 level = *pinned;
506 }
507 if let Some(intercept) = ctx.get_relaxed::<LoggerIntercept>() {
508 if intercept.matches(name) {
509 if let Some(forced) = intercept.level {
510 level = forced;
511 }
512 }
513 }
514 level
515 }
516
517 pub fn enabled(&self, ctx: &Context, name: &str, kind: LogKind) -> bool {
521 self.effective_threshold(ctx, name).allows(kind)
522 }
523
524 pub fn log_with<F>(&self, ctx: &Arc<Context>, name: &str, kind: LogKind, assemble: F)
527 where
528 F: FnOnce() -> Vec<LogArg>,
529 {
530 if !self.enabled(ctx, name, kind) {
531 return;
532 }
533 self.emit(fiber_label(ctx), name, kind, assemble());
534 }
535
536 pub fn log(&self, ctx: &Arc<Context>, name: &str, kind: LogKind, args: Vec<LogArg>) {
538 self.log_with(ctx, name, kind, || args)
539 }
540
541 pub fn log_ref<F>(
545 &self,
546 fiber: &Arc<crate::fiber::Fiber>,
547 scope: &Context,
548 name: &str,
549 kind: LogKind,
550 assemble: F,
551 ) where
552 F: FnOnce() -> Vec<LogArg>,
553 {
554 if !self.effective_threshold(scope, name).allows(kind) {
555 return;
556 }
557 self.emit(fiber_label_from_fiber(fiber), name, kind, assemble());
558 }
559
560 pub fn error(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
561 self.log(ctx, name, LogKind::Error, args)
562 }
563
564 pub fn warn(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
565 self.log(ctx, name, LogKind::Warn, args)
566 }
567
568 pub fn info(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
569 self.log(ctx, name, LogKind::Info, args)
570 }
571
572 pub fn debug(&self, ctx: &Arc<Context>, name: &str, args: Vec<LogArg>) {
573 self.log(ctx, name, LogKind::Debug, args)
574 }
575
576 fn emit(&self, fiber_name: String, name: &str, kind: LogKind, args: Vec<LogArg>) {
577 let sequence = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
578 let message = Arc::new(Message {
579 sequence,
580 timestamp_ms: unix_now_ms(),
581 name: name.to_string(),
582 kind,
583 level: LogLevel::from(kind),
584 args,
585 fiber_name,
586 });
587
588 {
589 let mut buffer = self.buffer.write();
590 buffer.push_back(message.clone());
591 while buffer.len() > self.capacity {
592 buffer.pop_front();
593 }
594 }
595
596 let slots: Vec<(Arc<dyn Exporter>, ExporterConfig)> = self
599 .exporters
600 .read()
601 .iter()
602 .filter(|slot| slot.config.threshold(name).allows(kind))
603 .map(|slot| (slot.exporter.clone(), slot.config.clone()))
604 .collect();
605 if slots.is_empty() {
606 return;
607 }
608 let rendered = message.render();
609 for (exporter, config) in slots {
610 let text = config.truncate(rendered.clone());
611 exporter.export(&message, &text);
612 }
613 }
614
615 pub fn register(
617 self: &Arc<Self>,
618 exporter: Arc<dyn Exporter>,
619 config: ExporterConfig,
620 ) -> Box<dyn Disposable> {
621 let key = Arc::as_ptr(&exporter) as *const () as usize;
622 self.exporters.write().push(ExportSlot {
623 exporter,
624 config,
625 key,
626 });
627 let weak = Arc::downgrade(self);
628 Box::new(move || {
629 if let Some(service) = weak.upgrade() {
630 service.exporters.write().retain(|slot| slot.key != key);
631 }
632 })
633 }
634
635 pub fn snapshot(&self) -> Vec<Arc<Message>> {
637 self.buffer.read().iter().cloned().collect()
638 }
639
640 pub fn buffered_len(&self) -> usize {
641 self.buffer.read().len()
642 }
643}
644
645pub fn hyphenate(input: &str) -> String {
652 let chars: Vec<char> = input.chars().collect();
653 let mut out = String::with_capacity(input.len() + 4);
654 for (i, &ch) in chars.iter().enumerate() {
655 let prev = if i > 0 { chars[i - 1] } else { '\0' };
656 let next = chars.get(i + 1).copied();
657 let boundary = ch.is_uppercase()
658 && i > 0
659 && prev != '-'
660 && (!prev.is_uppercase() || matches!(next, Some(n) if n.is_ascii_lowercase()));
661 if boundary {
662 out.push('-');
663 }
664 out.push(ch.to_ascii_lowercase());
665 }
666 out
667}
668
669pub fn derived_name<T: ?Sized>() -> String {
673 let full = std::any::type_name::<T>();
674 let short = full.rsplit("::").next().unwrap_or(full);
675 hyphenate(short)
676}
677
678impl Context {
683 pub fn log_with<F>(&self, name: &str, kind: LogKind, assemble: F)
687 where
688 F: FnOnce() -> Vec<LogArg>,
689 {
690 if let Some(logger) = self.get::<LoggerService>() {
691 logger.log_ref(&self.fiber(), self, name, kind, assemble);
692 }
693 }
694
695 pub fn log(&self, name: &str, kind: LogKind, args: Vec<LogArg>) {
697 self.log_with(name, kind, || args)
698 }
699
700 pub fn info(&self, name: &str, args: Vec<LogArg>) {
701 self.log(name, LogKind::Info, args)
702 }
703
704 pub fn warn(&self, name: &str, args: Vec<LogArg>) {
705 self.log(name, LogKind::Warn, args)
706 }
707
708 pub fn debug(&self, name: &str, args: Vec<LogArg>) {
709 self.log(name, LogKind::Debug, args)
710 }
711
712 pub fn error(&self, name: &str, args: Vec<LogArg>) {
713 self.log(name, LogKind::Error, args)
714 }
715}
716
717#[cfg(test)]
722mod tests {
723 use super::*;
724 use std::sync::atomic::AtomicBool;
725 use std::sync::Mutex;
726
727 struct CountingExporter {
728 seen: Mutex<Vec<String>>,
729 }
730
731 impl CountingExporter {
732 fn new() -> Arc<Self> {
733 Arc::new(Self {
734 seen: Mutex::new(Vec::new()),
735 })
736 }
737
738 fn texts(&self) -> Vec<String> {
739 self.seen.lock().unwrap().clone()
740 }
741 }
742
743 impl Exporter for CountingExporter {
744 fn export(&self, _message: &Message, text: &str) {
745 self.seen.lock().unwrap().push(text.to_string());
746 }
747 }
748
749 fn service() -> Arc<LoggerService> {
750 Arc::new(LoggerService::new())
751 }
752
753 #[test]
754 fn buffer_bounded_at_capacity_snapshot_reads() {
755 let logger = LoggerService::with_capacity(4);
756 let ctx = Context::new_root();
757 for i in 0..10u64 {
758 logger.log(&ctx, "ring", LogKind::Info, vec![i.into()]);
759 }
760
761 assert_eq!(logger.buffered_len(), 4);
762 let snap = logger.snapshot();
763 assert_eq!(snap.len(), 4);
764 let seqs: Vec<u64> = snap.iter().map(|m| m.sequence).collect();
766 assert_eq!(seqs, vec![7, 8, 9, 10]);
767 logger.log(&ctx, "ring", LogKind::Info, vec![99u64.into()]);
769 assert_eq!(snap.len(), 4);
770 assert_eq!(logger.buffered_len(), 4);
771 assert_eq!(logger.snapshot()[3].args[0], LogArg::Unsigned(99));
772 }
773
774 #[tokio::test]
775 async fn exporter_disposal_removes_sink() {
776 let logger = service();
777 let ctx = Context::new_root();
778 let sink = CountingExporter::new();
779 let handle = logger.register(
780 sink.clone(),
781 ExporterConfig {
782 max_length: 64,
783 ..ExporterConfig::default()
784 },
785 );
786
787 logger.info(&ctx, "sink-test", vec!["one".into()]);
788 assert_eq!(sink.texts(), vec!["one".to_string()]);
789
790 handle.dispose();
791 logger.info(&ctx, "sink-test", vec!["two".into()]);
792 assert_eq!(sink.texts(), vec!["one".to_string()]);
794 assert_eq!(logger.buffered_len(), 2);
795 }
796
797 #[test]
798 fn level_routing_per_name_with_default_fallback() {
799 let logger = service();
800 let ctx = Context::new_root();
801
802 logger.set_default_level(LogLevel::WARN);
804 assert!(logger.enabled(&ctx, "quiet", LogKind::Warn));
805 assert!(!logger.enabled(&ctx, "quiet", LogKind::Info));
806
807 logger.set_level("loud", LogLevel::DEBUG);
809 assert!(logger.enabled(&ctx, "loud", LogKind::Debug));
810 assert!(!logger.enabled(&ctx, "still-quiet", LogKind::Debug));
811
812 logger.debug(&ctx, "loud", vec!["kept".into()]);
814 logger.debug(&ctx, "still-quiet", vec!["dropped".into()]);
815 let names: Vec<String> = logger
816 .snapshot()
817 .iter()
818 .map(|m| m.name.clone())
819 .collect();
820 assert_eq!(names, vec!["loud".to_string()]);
821
822 logger.set_default_level(LogLevel::DEBUG);
824 logger.debug(&ctx, "still-quiet", vec!["kept-now".into()]);
825 assert_eq!(logger.snapshot().len(), 2);
826
827 logger.clear_level("loud");
829 assert_eq!(
830 logger.effective_threshold(&ctx, "loud"),
831 LogLevel::DEBUG
832 );
833 }
834
835 #[test]
836 fn printf_placeholders_format_correctly() {
837 let logger = service();
838 let ctx = Context::new_root();
839
840 logger.info(
841 &ctx,
842 "fmt",
843 vec![
844 "%s=%d %i %f %o %% %q trailing".into(),
845 "user".into(),
846 42i64.into(),
847 (-7i64).into(),
848 2.5f64.into(),
849 serde_json::json!({"a": 1}).into(),
850 "extra".into(),
851 ],
852 );
853 let text = logger.snapshot()[0].render();
854 let pretty_b = serde_json::to_string_pretty(&serde_json::json!({"b": [1]})).unwrap();
855 assert_eq!(
856 text,
857 format!(
858 "user=42 -7 2.5 {{\"a\":1}} % %q trailing extra"
859 )
860 );
861
862 logger.debug(
865 &ctx,
866 "fmt",
867 vec![
868 "%O|%c|done".into(),
869 serde_json::json!({"b": [1]}).into(),
870 "tag".into(),
871 ],
872 );
873 let colored = logger.snapshot()[1].render();
874 assert!(colored.starts_with(&pretty_b[..pretty_b.len() - 1]));
875 assert!(colored.contains("\x1b["));
876 assert!(colored.ends_with("|done"));
877
878 let bare = Message {
880 sequence: 0,
881 timestamp_ms: 0,
882 name: "fmt".into(),
883 kind: LogKind::Info,
884 level: LogLevel::INFO,
885 args: vec!["%d items".into()],
886 fiber_name: String::new(),
887 };
888 assert_eq!(bare.render(), "%d items");
889
890 let plain = Message {
892 sequence: 0,
893 timestamp_ms: 0,
894 name: "fmt".into(),
895 kind: LogKind::Info,
896 level: LogLevel::INFO,
897 args: vec!["a".into(), 1i64.into(), true.into()],
898 fiber_name: String::new(),
899 };
900 assert_eq!(plain.render(), "a 1 true");
901 }
902
903 #[test]
904 fn enabled_gate_skips_arg_assembly() {
905 let logger = service();
906 let ctx = Context::new_root();
907 logger.set_default_level(LogLevel::WARN);
908
909 let assembled = AtomicBool::new(false);
910 let flip = |flag: &AtomicBool| flag.store(true, Ordering::SeqCst);
911
912 logger.log_with(&ctx, "gate", LogKind::Debug, || {
914 flip(&assembled);
915 vec!["expensive".into()]
916 });
917 assert!(!assembled.load(Ordering::SeqCst));
918 assert!(logger.snapshot().is_empty());
919
920 logger.warn(&ctx, "gate", vec![]);
922 logger.log_with(&ctx, "gate", LogKind::Warn, || {
923 flip(&assembled);
924 vec!["cheap-enough".into()]
925 });
926 assert!(assembled.load(Ordering::SeqCst));
927 assert_eq!(logger.buffered_len(), 2);
928
929 assert!(logger.enabled(&ctx, "gate", LogKind::Warn));
931 assert!(!logger.enabled(&ctx, "gate", LogKind::Info));
932 }
933
934 #[tokio::test]
935 async fn logger_intercept_overrides_level() {
936 let root = Context::new_root();
937 root.provide(LoggerService::new());
938
939 let logger = root.get::<LoggerService>().unwrap();
941 root.debug("svc", vec!["root-passes".into()]);
942 assert_eq!(logger.buffered_len(), 1);
943
944 let child = root.intercept(LoggerIntercept {
946 name: Some("svc".into()),
947 level: Some(LogLevel::ERROR),
948 });
949 child.debug("svc", vec!["suppressed".into()]);
950 child.error("svc", vec!["survives".into()]);
951 child.debug("other", vec!["other-passes".into()]);
953
954 let msgs = logger.snapshot();
955 let texts: Vec<&str> = msgs
956 .iter()
957 .map(|m| arg_text(&m.args[0], false).leak() as &str)
958 .collect();
959 assert_eq!(texts, ["root-passes", "survives", "other-passes"]);
960
961 let wild = root.intercept(LoggerIntercept {
963 name: None,
964 level: Some(LogLevel::ERROR),
965 });
966 wild.info("anything", vec!["blocked".into()]);
967 assert_eq!(logger.buffered_len(), 3);
968 }
969
970 #[test]
971 fn hyphenate_and_derived_names() {
972 assert_eq!(hyphenate("ToolsService"), "tools-service");
973 assert_eq!(hyphenate("HTTPServer"), "http-server");
974 assert_eq!(hyphenate("already-kebab"), "already-kebab");
975 assert_eq!(hyphenate("V2Plan"), "v2-plan");
976 assert_eq!(derived_name::<LoggerService>(), "logger-service");
977 }
978
979 #[test]
980 fn exporter_config_truncates_and_filters() {
981 let mut levels = HashMap::new();
982 levels.insert("db".to_string(), LogLevel::WARN);
983 let config = ExporterConfig::new(levels, 5);
984 assert_eq!(config.threshold("db"), LogLevel::WARN);
985 assert_eq!(config.threshold("other"), LogLevel::DEBUG);
986 assert_eq!(config.truncate("abcdefg".into()), "abcde");
987 assert_eq!(config.truncate("ok".into()), "ok");
988 assert_eq!(ExporterConfig::default().truncate("héllo".into()), "héllo");
989
990 let wide = ExporterConfig {
991 max_length: 0,
992 ..ExporterConfig::default()
993 };
994 assert_eq!(wide.truncate("anything".into()), "");
995 }
996
997 #[test]
998 fn colorization_is_stable_per_name() {
999 let mk = |name: &str| {
1000 Message {
1001 sequence: 0,
1002 timestamp_ms: 0,
1003 name: name.into(),
1004 kind: LogKind::Info,
1005 level: LogLevel::INFO,
1006 args: vec!["%c".into(), "payload".into()],
1007 fiber_name: String::new(),
1008 }
1009 .render()
1010 };
1011 let a1 = mk("alpha");
1012 let a2 = mk("alpha");
1013 assert_eq!(a1, a2);
1014 assert!(a1.contains("\x1b["));
1015 assert!(a1.contains("payload"));
1016 let bold = Message {
1018 sequence: 0,
1019 timestamp_ms: 0,
1020 name: "alpha".into(),
1021 kind: LogKind::Info,
1022 level: LogLevel::INFO,
1023 args: vec!["%C".into(), "payload".into()],
1024 fiber_name: String::new(),
1025 }
1026 .render();
1027 assert_ne!(bold, a1);
1028 }
1029}