//! Log lines, each with a time and a level, written to standard error or
//! standard output.
//!
//! `Log::trace`, `Log::debug`, `Log::info`, `Log::warn` and `Log::error` each
//! write one line made of the time in UTC, the level and the message, such as
//! `2026-08-19T02:40:13.123Z INFO server started`. The message is written as
//! given, so a newline in it splits it over lines. A call below the
//! configured `LogLevel` writes nothing. `Log::init_with` sets that level and
//! where lines go; until it is called, the level is `Info` and lines go to
//! standard error. Every module sees these names without an import, and a
//! module's own definition of one of them shadows the one here.
//!
//! Logging is not `async`: each call writes its line before it returns, so
//! any function can log. The configuration holds for the whole program, and
//! the last call to `Log::init` or `Log::init_with` sets it.
//!
//! Logging at the default level, then raising the level so `info` lines are
//! dropped:
//!
//! ```
//! // Writes a line such as `2026-08-19T02:40:13.123Z INFO cache warmed` to
//! // standard error.
//! Log::info("cache warmed")
//!
//! // From here on, only `Warn` and `Error` are written.
//! Log::init_with(LogConfig { level: Warn, format: Human, output: Stderr })
//! Log::info("dropped: below the configured level")
//! Log::warn("disk almost full")
//!
//! // Levels compare by rank.
//! assert(Warn.to_int() > Info.to_int(), "Warn is more severe than Info")
//! assert_eq(Warn.label(), "WARN")
//! ```
// Levels, ordered by `to_int` below. The order is load-bearing: filtering
// compares those integers, because Nova has no `==` on sum types
// (`E0013`), so `to_int` is the only mechanism available rather than a
// convenience.
/// How severe a log message is, from least to most: `Trace`, `Debug`,
/// `Info`, `Warn`, `Error`.
///
/// The configured level lets through the messages at that level and above,
/// and drops the rest.
pub type LogLevel =
/// Fine-grained detail; the least severe level.
| Trace
/// Detail for debugging.
| Debug
/// A normal event; the default level.
| Info
/// Something unexpected that the program can carry on from.
| Warn
/// A failure; the most severe level.
| Error
// One variant today. `Json` arrives with the increment that brings string
// escaping; declaring the field now means adding that variant later breaks
// only exhaustive matches, where adding a *field* to `LogConfig` would
// break every construction site.
/// How a log line is laid out. `Human` is the only layout.
pub type LogFormat =
/// The time, the level's label and the message, separated by single
/// spaces, such as `2026-08-19T02:40:13.123Z WARN disk almost full`.
| Human
// `File(String)` arrives with the increment that adds a synchronous
// file-write path; every `std/fs` write is `async fn` today.
/// Where log lines are written.
pub type LogOutput =
/// Standard error; the default.
| Stderr
/// Standard output.
| Stdout
/// A logger configuration, for `Log::init_with`.
pub record LogConfig {
/// The least severe level written; messages below it are dropped.
pub level: LogLevel,
/// How each line is laid out.
pub format: LogFormat,
/// Where the lines are written.
pub output: LogOutput,
}
// The logger's namespace. An empty record because Nova has no free-standing
// namespaces, and associated functions rather than top-level ones because a
// top-level `pub fn` is glob-imported into every module: `error`, `info` and
// `debug` at top level would take those names from all user code, and
// `import_std_module` resolves such a collision silently in the *user's*
// favour — making this module's `error` unreachable with no diagnostic.
// `std/strings` refused the same trade for `join`.
/// The logger, whose functions are called on the type itself, as in
/// `Log::warn("disk almost full")`.
///
/// It holds nothing. Its functions write log lines and set the logger's
/// configuration.
pub record Log {}
impl LogLevel {
/// Returns the level's rank, from 0 for `Trace` to 4 for `Error`: a higher
/// rank is more severe, so two levels compare by their ranks.
pub fn to_int(self) -> Int {
match self {
Trace => 0
Debug => 1
Info => 2
Warn => 3
Error => 4
}
}
/// Returns the level's name as log lines show it: `TRACE`, `DEBUG`,
/// `INFO`, `WARN` or `ERROR`.
pub fn label(self) -> String {
match self {
Trace => "TRACE"
Debug => "DEBUG"
Info => "INFO"
Warn => "WARN"
Error => "ERROR"
}
}
}
// `match` rather than `out == Stderr`: there is no equality on sum types.
fn to_stderr_flag(out: LogOutput) -> Int {
match out {
Stderr => 1
Stdout => 0
}
}
// Emit one line if `level` clears the configured threshold.
//
// Private, so the glob import never binds it and it takes no name from user
// code. The threshold is checked *before* the clock is read and the line is
// built, so a filtered-out call costs one intrinsic and no allocation.
//
// The comparison is `<`, which makes the threshold inclusive: at threshold
// `Info`, an `Info` message is emitted.
fn emit(level: LogLevel, msg: String) {
if level.to_int() < log_config_level() { return }
let line = "${SystemTime::now().to_iso8601()} ${level.label()} ${msg}"
if log_config_to_stderr() == 1 { eprintln(line) } else { println(line) }
}
impl Log {
// Install the default: `Info`, `Human`, `Stderr`. Not a prerequisite —
// the runtime's getter installs the same default on first read, so a
// program that never calls this still logs. This is the override spelled
// explicitly.
//
// This literal duplicates the runtime's own default (`DEFAULT` in
// `crates/nova-runtime/src/log.rs`) rather than reading it back — there
// is no intrinsic that returns "the default", only `log_set_config`,
// which takes one explicit configuration to install. The duplication is
// pinned equal by `log_init_resets_to_default_run`
// (`crates/nova-cli/tests/run_tests.rs`), which overwrites a
// configuration wrong in both fields with a call to `init()` and checks
// that the runtime's actual default comes back, not the wrong one.
/// Restores the default configuration: level `Info`, the `Human` layout,
/// and standard error.
///
/// Calling it is optional, since a program that never configures the
/// logger has this configuration already. It replaces any configuration
/// set before.
pub fn init() { Log::init_with(LogConfig { level: Info, format: Human, output: Stderr }) }
// `config.format` is deliberately unread: `LogFormat` has one variant,
// so there is nothing to branch on yet.
/// Sets the logger's configuration, replacing any set before.
///
/// From then on, messages below `config.level` are dropped, and lines go
/// to `config.output`. `config.format` has one choice, `Human`.
pub fn init_with(config: LogConfig) {
log_set_config(config.level.to_int(), to_stderr_flag(config.output))
}
/// Writes `msg` as a log line at level `Trace`, the least severe, if the
/// configured level is `Trace`.
pub fn trace(msg: String) { emit(Trace, msg) }
/// Writes `msg` as a log line at level `Debug`, if the configured level is
/// `Debug` or `Trace`.
pub fn debug(msg: String) { emit(Debug, msg) }
/// Writes `msg` as a log line at level `Info`, if the configured level is
/// `Info`, as it is by default, or less severe.
pub fn info(msg: String) { emit(Info, msg) }
/// Writes `msg` as a log line at level `Warn`, unless the configured level
/// is `Error`.
pub fn warn(msg: String) { emit(Warn, msg) }
/// Writes `msg` as a log line at level `Error`, the most severe, whatever
/// the configured level.
pub fn error(msg: String) { emit(Error, msg) }
}