use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use inklog::{
ConsoleSinkConfig, FileSinkConfig, InklogConfig, LogLevel, LoggerManager, RateLimitConfig,
SamplingConfig, TargetSamplingRule,
};
const DEFAULT_LEVEL: &str = "info";
const TARGET_LEVELS_ENV: &str = "CODENEXUS_LOG_TARGETS";
const SAMPLE_ENV: &str = "CODENEXUS_LOG_SAMPLE";
const RATE_LIMIT_ENV: &str = "CODENEXUS_LOG_RATE_LIMIT";
fn log_file() -> PathBuf {
PathBuf::from(crate::DEFAULT_DB_DIR).join("logs/codenexus.log")
}
const LOG_FORMAT: &str = "{timestamp} [{level}] {target} - {message}";
const CONSOLE_STDERR_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "fatal"];
const LOG_CHANNEL_CAPACITY: usize = 10_000;
const LOG_FILE_MAX_SIZE: &str = "100MB";
const LOG_FILE_KEEP_FILES: u32 = 30;
static LOGGER: OnceLock<LoggerManager> = OnceLock::new();
fn canonical_level(s: &str) -> Option<&'static str> {
match LogLevel::from_str(s)? {
LogLevel::Trace => Some("trace"),
LogLevel::Debug => Some("debug"),
LogLevel::Info => Some("info"),
LogLevel::Warn => Some("warn"),
LogLevel::Error | LogLevel::Fatal => Some("error"),
}
}
fn global_level(rust_log: &str) -> &'static str {
rust_log
.split(',')
.filter(|token| !token.contains('='))
.filter_map(|token| canonical_level(token.trim()))
.next_back()
.unwrap_or(DEFAULT_LEVEL)
}
fn target_levels(spec: &str) -> HashMap<String, String> {
let mut levels = HashMap::new();
for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
let parsed = entry
.split_once('=')
.filter(|(target, _)| !target.trim().is_empty())
.and_then(|(target, level)| {
canonical_level(level.trim()).map(|level| (target.trim(), level))
});
match parsed {
Some((target, level)) => {
levels.insert(target.to_string(), level.to_string());
}
None => {
let display: String = entry.chars().filter(|c| !c.is_control()).collect();
eprintln!(
"{}",
crate::i18n::t("logging-invalid_target_level", &[("entry", display)])
);
}
}
}
levels
}
enum SampleRule {
Level(&'static str, u64),
Prefix(String, TargetSamplingRule),
}
fn parse_sample_entry(entry: &str) -> Option<SampleRule> {
let (key, spec) = entry.split_once('=')?;
let (every_n, keep_level) = match spec.split_once('@') {
Some((n, keep)) => (n, Some(canonical_level(keep.trim())?)),
None => (spec, None),
};
let every_n: u64 = every_n.trim().parse().ok()?;
if every_n == 0 {
return None;
}
let key = key.trim();
if key.is_empty() {
return None;
}
if let Some(level) = canonical_level(key) {
return Some(SampleRule::Level(level, every_n));
}
Some(SampleRule::Prefix(
key.to_string(),
TargetSamplingRule {
keep_level: keep_level.map(str::to_string),
sample_every_n: every_n,
},
))
}
fn sample_config(spec: &str) -> SamplingConfig {
let mut config = SamplingConfig::default();
for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
match parse_sample_entry(entry) {
Some(SampleRule::Level(level, every_n)) => {
config.per_level.insert(level.to_string(), every_n);
}
Some(SampleRule::Prefix(prefix, rule)) => {
config.per_target_prefix.insert(prefix, rule);
}
None => {
let display: String = entry.chars().filter(|c| !c.is_control()).collect();
eprintln!(
"{}",
crate::i18n::t("logging-invalid_sample_entry", &[("entry", display)])
);
}
}
}
config
}
fn parse_rate_limit_entry(entry: &str) -> Option<(String, u64)> {
let (prefix, rate) = entry.split_once('=')?;
let rate: u64 = rate.trim().parse().ok()?;
(rate > 0 && !prefix.trim().is_empty()).then(|| (prefix.trim().to_string(), rate))
}
fn rate_limit_config(spec: &str) -> RateLimitConfig {
let mut config = RateLimitConfig::default();
for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
match parse_rate_limit_entry(entry) {
Some((prefix, rate)) => {
config.rules.insert(prefix, rate);
}
None => {
let display: String = entry.chars().filter(|c| !c.is_control()).collect();
eprintln!(
"{}",
crate::i18n::t("logging-invalid_rate_limit_entry", &[("entry", display)])
);
}
}
}
config
}
#[derive(Debug, Clone, Default)]
struct LogEnv {
rust_log: String,
targets: String,
sample: String,
rate_limit: String,
}
impl LogEnv {
fn from_env() -> Self {
Self {
rust_log: std::env::var("RUST_LOG").unwrap_or_default(),
targets: std::env::var(TARGET_LEVELS_ENV).unwrap_or_default(),
sample: std::env::var(SAMPLE_ENV).unwrap_or_default(),
rate_limit: std::env::var(RATE_LIMIT_ENV).unwrap_or_default(),
}
}
}
fn config(env: &LogEnv) -> InklogConfig {
let mut config = InklogConfig::default();
config.global.level = global_level(&env.rust_log).to_string();
config.global.format = LOG_FORMAT.to_string();
config.console_sink = Some(ConsoleSinkConfig {
enabled: true,
colored: true,
stderr_levels: CONSOLE_STDERR_LEVELS
.iter()
.map(|level| (*level).to_string())
.collect(),
..Default::default()
});
config.file_sink = Some(FileSinkConfig {
enabled: true,
path: log_file(),
max_size: LOG_FILE_MAX_SIZE.to_string(),
rotation_time: "daily".to_string(),
keep_files: LOG_FILE_KEEP_FILES,
compress: true,
..Default::default()
});
config.performance.channel_capacity = LOG_CHANNEL_CAPACITY;
config.target_levels = target_levels(&env.targets);
config.sampling = sample_config(&env.sample);
config.rate_limit = rate_limit_config(&env.rate_limit);
config
}
pub fn init_logging() {
if LOGGER.get().is_some() {
return;
}
let config = config(&LogEnv::from_env());
let runtime = tokio::runtime::Runtime::new().expect("create tokio runtime for inklog");
let logger = runtime
.block_on(LoggerManager::with_config(config))
.expect("init inklog");
std::mem::forget(runtime);
let _ = LOGGER.set(logger);
}
pub fn set_level(target: Option<&str>, level: &str) -> Result<(), String> {
let logger = LOGGER
.get()
.ok_or_else(|| "logging is not initialized".to_string())?;
let level = canonical_level(level).ok_or_else(|| format!("invalid log level: {level}"))?;
if let Some(target) = target {
if !valid_target(target) {
return Err(format!("invalid log target: {target}"));
}
}
logger.set_level(target, level).map_err(|e| e.to_string())
}
fn valid_target(target: &str) -> bool {
!target.is_empty()
&& target.len() <= 128
&& target
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.'))
}
pub fn flush() {
let Some(logger) = LOGGER.get() else {
return;
};
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(200);
while logger.channel_len() > 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
let _ = logger.shutdown();
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn global_level_takes_last_plain_level_only() {
assert_eq!(global_level(""), "info");
assert_eq!(global_level("debug"), "debug");
assert_eq!(global_level("trace,debug"), "debug");
assert_eq!(global_level("debug,codenexus=trace"), "debug");
assert_eq!(global_level("codenexus=debug,sqlx=warn"), "info");
assert_eq!(global_level("warning"), "warn");
assert_eq!(global_level("not-a-level"), "info");
assert_eq!(global_level(" debug "), "debug");
}
#[test]
fn target_levels_parses_pairs_and_skips_invalid_entries() {
assert_eq!(target_levels(""), HashMap::new());
assert_eq!(
target_levels("hyper=warn, codenexus::parse=debug"),
HashMap::from([
("hyper".to_string(), "warn".to_string()),
("codenexus::parse".to_string(), "debug".to_string()),
])
);
assert_eq!(
target_levels("hyper=warn,hyper=error"),
HashMap::from([("hyper".to_string(), "error".to_string())])
);
assert_eq!(
target_levels("a=warning,b=critical"),
HashMap::from([
("a".to_string(), "warn".to_string()),
("b".to_string(), "error".to_string()),
])
);
}
#[test]
fn target_levels_never_panics_on_invalid_entries() {
assert_eq!(target_levels("hyper"), HashMap::new());
assert_eq!(target_levels("hyper=verbose"), HashMap::new());
assert_eq!(target_levels("=warn"), HashMap::new());
assert_eq!(
target_levels("bad,hyper=warn"),
HashMap::from([("hyper".to_string(), "warn".to_string())])
);
}
#[test]
fn config_carries_every_previous_builder_knob() {
let config = config(&LogEnv {
rust_log: "debug".to_string(),
..Default::default()
});
assert_eq!(config.global.level, "debug");
assert_eq!(config.global.format, LOG_FORMAT);
let console = config.console_sink.as_ref().expect("console sink enabled");
assert!(console.enabled);
assert!(console.colored);
assert_eq!(
console.stderr_levels,
CONSOLE_STDERR_LEVELS
.iter()
.map(|level| level.to_string())
.collect::<Vec<_>>()
);
let file = config.file_sink.as_ref().expect("file sink enabled");
assert!(file.enabled);
assert_eq!(file.path, log_file());
assert_eq!(file.max_size, LOG_FILE_MAX_SIZE);
assert_eq!(file.rotation_time, "daily");
assert_eq!(file.keep_files, LOG_FILE_KEEP_FILES);
assert!(file.compress);
assert_eq!(config.performance.channel_capacity, LOG_CHANNEL_CAPACITY);
assert!(config.target_levels.is_empty());
assert!(config.sampling.is_empty());
assert!(config.rate_limit.is_empty());
}
#[test]
fn sample_config_splits_levels_from_target_prefixes() {
let config = sample_config("debug=10, sqlx=100@warn, warning=5");
assert_eq!(
config.per_level,
HashMap::from([
("debug".to_string(), 10),
("warn".to_string(), 5), ])
);
let rule = config
.per_target_prefix
.get("sqlx")
.expect("sqlx rule present");
assert_eq!(rule.sample_every_n, 100);
assert_eq!(rule.keep_level.as_deref(), Some("warn"));
let plain = sample_config("lbug=3");
assert!(plain.per_level.is_empty());
let rule = plain.per_target_prefix.get("lbug").expect("prefix rule");
assert_eq!(rule.sample_every_n, 3);
assert_eq!(rule.keep_level, None);
for bad in [
"debug", "=10", "debug=0", "sqlx=10@no", "sqlx=x", ] {
let config = sample_config(bad);
assert!(
config.per_level.is_empty() && config.per_target_prefix.is_empty(),
"invalid entry {bad:?} must be dropped"
);
}
}
#[test]
fn rate_limit_config_parses_prefix_quotas_and_skips_invalid() {
let config = rate_limit_config("sqlx=50, codenexus::daemon=200");
assert_eq!(
config.rules,
HashMap::from([
("sqlx".to_string(), 50),
("codenexus::daemon".to_string(), 200),
])
);
for bad in ["sqlx", "=50", "sqlx=0", "sqlx=fast"] {
assert!(
rate_limit_config(bad).rules.is_empty(),
"invalid entry {bad:?} must be dropped"
);
}
}
#[test]
fn valid_target_rejects_filter_separators() {
assert!(valid_target("codenexus::parse"));
assert!(valid_target("hyper"));
assert!(valid_target("my-crate"));
assert!(!valid_target(""));
assert!(!valid_target("a=b"));
assert!(!valid_target("a,b"));
assert!(!valid_target("a b"));
assert!(!valid_target(&"x".repeat(129)));
}
#[test]
#[serial_test::serial(logging_env)]
fn hot_set_level_normalizes_aliases_and_rejects_bad_targets() {
let _env = EnvGuard::set("RUST_LOG", "");
init_logging();
let logger = LOGGER.get().expect("init_logging must install the manager");
set_level(None, "warning").expect("alias level must be accepted");
assert_eq!(logger.current_level_filter_string(), "warn");
assert!(set_level(Some("bad target=1"), "debug").is_err());
assert!(set_level(Some("hyper"), "verbose").is_err());
assert_eq!(
logger.current_level_filter_string(),
"warn",
"rejected calls must leave the directive set unchanged"
);
set_level(None, "error").expect("restore quiet level for other tests");
}
#[test]
fn config_global_level_is_always_a_plain_level() {
for rust_log in [
"",
"debug",
"codenexus=debug,sqlx=warn",
"trace,codenexus=trace",
"not-a-level",
] {
let config = config(&LogEnv {
rust_log: rust_log.to_string(),
targets: "hyper=warn".to_string(),
..Default::default()
});
assert!(
LogLevel::is_valid_level(&config.global.level),
"global level must stay a plain level name, got {:?} for RUST_LOG={rust_log:?}",
config.global.level
);
assert_eq!(
config.target_levels.get("hyper").map(String::as_str),
Some("warn")
);
}
}
#[tokio::test]
#[serial_test::serial(logging_env)]
async fn rust_log_directives_and_presets_reach_the_env_filter() {
let _env = EnvGuard::set("RUST_LOG", "codenexus=debug,sqlx=warn");
let (manager, _subscriber, filter) = LoggerManager::build_detached(config(&LogEnv {
rust_log: std::env::var("RUST_LOG").unwrap(),
targets: "hyper=warn".to_string(),
..Default::default()
}))
.await
.expect("build_detached should succeed");
assert_eq!(
manager.current_level_filter_string(),
"info,hyper=warn,codenexus=debug,sqlx=warn"
);
let rendered = filter.to_string();
for directive in ["info", "hyper=warn", "codenexus=debug", "sqlx=warn"] {
assert!(
rendered.contains(directive),
"EnvFilter must carry {directive}, got: {rendered}"
);
}
let _ = manager.shutdown();
}
#[tokio::test]
#[serial_test::serial(logging_env)]
async fn hot_set_level_upserts_directives_and_rejects_invalid() {
let _env = EnvGuard::set("RUST_LOG", "");
let (manager, _subscriber, _filter) = LoggerManager::build_detached(config(&LogEnv {
rust_log: "info".to_string(),
targets: "hyper=warn".to_string(),
..Default::default()
}))
.await
.expect("build_detached should succeed");
assert_eq!(manager.current_level_filter_string(), "info,hyper=warn");
let set = |target: Option<&str>, level: &str| {
manager.set_level(target, level).map_err(|e| e.to_string())
};
assert!(set(Some("hyper"), "trace").is_ok());
assert_eq!(manager.current_level_filter_string(), "info,hyper=trace");
assert!(set(Some("my_crate"), "debug").is_ok());
assert_eq!(
manager.current_level_filter_string(),
"info,hyper=trace,my_crate=debug"
);
assert!(set(None, "debug").is_ok());
assert_eq!(
manager.current_level_filter_string(),
"debug,hyper=trace,my_crate=debug"
);
let err = set(Some("hyper"), "not-a-level").unwrap_err();
assert!(err.contains("Invalid log level"), "unexpected error: {err}");
assert_eq!(
manager.current_level_filter_string(),
"debug,hyper=trace,my_crate=debug",
"a rejected level must leave the directive set untouched"
);
let _ = manager.shutdown();
}
#[test]
#[serial_test::serial(logging_env)]
fn init_logging_retains_a_manager_for_hot_tuning() {
let _env = EnvGuard::set("RUST_LOG", "");
init_logging();
init_logging();
let logger = LOGGER.get().expect("init_logging must install the manager");
set_level(None, "error").expect("hot-tuning the installed logger must succeed");
assert_eq!(logger.current_level_filter_string(), "error");
set_level(None, "debug").expect("tuning the level back up must succeed");
assert_eq!(LOGGER.get().unwrap().current_level_filter_string(), "debug");
assert!(set_level(Some("codenexus"), "invalid-level").is_err());
set_level(None, "error").expect("a rejected level must not break later hot-tuning");
assert_eq!(LOGGER.get().unwrap().current_level_filter_string(), "error");
}
#[test]
fn logging_comment_names_the_backend_inklog_is_built_with() {
let main_rs = include_str!("main.rs");
let lock = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"),
)
.expect("read Cargo.lock");
let entry = lock
.find("name = \"inklog\"")
.expect("Cargo.lock must carry an inklog entry");
let block = &lock[entry..];
let block = &block[..block.find(']').expect("dependency list closes")];
let zstd_backend = block.contains("\"zstd\"");
let gzip_backend = block.contains("\"flate2\"");
assert!(
zstd_backend != gzip_backend,
"inklog must resolve to exactly one compression backend, got zstd={zstd_backend} gzip={gzip_backend}"
);
let comment = main_rs
.lines()
.skip_while(|l| !l.contains("Global inklog backend"))
.take_while(|l| !l.contains("init_logging();"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!comment.is_empty(),
"main.rs must narrate the inklog backend above init_logging()"
);
if gzip_backend {
assert!(
comment.contains("gzip"),
"inklog is built with the gzip (flate2) backend, so the comment must say so: {comment}"
);
} else {
assert!(
!comment.contains("gzip"),
"inklog is built with the zstd backend (no flate2 in its lock entry), so the comment must not promise gzip: {comment}"
);
}
if zstd_backend {
assert!(
comment.contains("zstd"),
"inklog's compression feature pulls zstd; the comment must name it: {comment}"
);
}
}
}