///|
/// The rule of a file stem that an input breaks.
pub enum StemRule {
/// The text is empty.
Empty
/// The text has more than 64 characters.
TooLong
/// The first character is not an ASCII letter.
NotLetterStart
/// A character is not an ASCII letter, an ASCII digit, `_` or `-`.
InvalidCharacter(Char)
/// The text ends with `_` or `-` and one or more digits, for example
/// `acme_001` or `acme-20261027`. A file name of a shorter stem has that
/// form, so two file sets could own the same file.
NumericSuffix
/// The text is a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to
/// `COM9` or `LPT0` to `LPT9`) in any letter case.
ReservedDeviceName
} derive(Eq, Debug)
///|
pub extend StemRule with Eq::{equal, not_equal}
///|
pub extend StemRule with @debug.Debug::{to_repr}
///|
/// A count of bytes: a file size or the encoded size of a record. It is zero or
/// more.
pub struct ByteCount {
priv bytes : Int64
} derive(Eq, Compare, Hash, Debug)
///|
pub extend ByteCount with Eq::{equal, not_equal}
///|
pub extend ByteCount with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend ByteCount with Hash::{hash, hash_combine}
///|
pub extend ByteCount with @debug.Debug::{to_repr}
///|
/// Make a byte count, for example a file size from the file system. It raises
/// `NegativeByteCount` when `bytes` is less than zero.
pub fn ByteCount::of(bytes : Int64) -> ByteCount raise RollingError {
guard bytes >= 0L else { raise NegativeByteCount(bytes~) }
{ bytes, }
}
///|
/// The count of UTF-8 bytes of `text`, for example a rendered record with its
/// LF.
pub fn ByteCount::of_text(text : String) -> ByteCount {
{ bytes: @formatting.utf8_length(text) }
}
///|
/// No bytes.
pub fn ByteCount::zero() -> ByteCount {
{ bytes: 0L }
}
///|
/// The count as a number.
pub fn ByteCount::to_int64(self : ByteCount) -> Int64 {
self.bytes
}
///|
/// The count as text, for example `2048`.
pub impl Show for ByteCount with output(self, logger) {
logger.write_string(self.bytes.to_string())
}
///|
pub extend ByteCount with Show::{output, to_string}
///|
/// A rolling input that breaks a rule, or a file set that has no next file
/// name. Callers can match the cases, but only this package makes them.
pub suberror RollingError {
/// The stem `input` breaks the rule `rule`.
InvalidStem(input~ : String, rule~ : StemRule)
/// The file byte limit `bytes` is zero or less.
NotPositiveByteLimit(bytes~ : Int64)
/// The retained file count `count` is zero or less.
NotPositiveFileCount(count~ : Int)
/// The byte count `bytes` is less than zero.
NegativeByteCount(bytes~ : Int64)
/// The file `last` has the largest sequence number, so the period has no
/// next file name. The file set does not use a name again.
SequenceExhausted(last~ : String)
} derive(Debug)
///|
pub extend RollingError with @debug.Debug::{to_repr}
///|
/// Text for people that describes the fault.
pub fn RollingError::message(self : RollingError) -> String {
match self {
InvalidStem(input~, rule~) => {
let reason = match rule {
Empty => "the text is empty"
TooLong => "the text must have at most \{MAX_STEM_LENGTH} characters"
NotLetterStart => "the first character must be an ASCII letter"
InvalidCharacter(char) =>
"the character \{char.to_string().escape()} is not an ASCII letter, an ASCII digit, _ or -"
NumericSuffix =>
"the text must not end with _ or - and digits, because the file names of another stem have that form"
ReservedDeviceName => "the text is a reserved Windows device name"
}
"Invalid log file stem \{input.escape()}: \{reason}"
}
NotPositiveByteLimit(bytes~) =>
"Invalid file byte limit \{bytes}: the value must be greater than zero"
NotPositiveFileCount(count~) =>
"Invalid retained file count \{count}: the value must be greater than zero"
NegativeByteCount(bytes~) =>
"Invalid byte count \{bytes}: the value must not be less than zero"
SequenceExhausted(last~) =>
"No file name after \{last.escape()}: the sequence number is at its largest value"
}
}
///|
/// The largest number of characters in a stem.
const MAX_STEM_LENGTH : Int = 64
///|
/// The first part of each file name of a file set, for example `acme` in
/// `acme-20261027.log`. It has 1 to 64 characters: an ASCII letter, then ASCII
/// letters, ASCII digits, `_` and `-`. It does not end with `_` or `-` and
/// digits, and it is not a Windows device name. So the file names are valid on
/// each OS, and two stems never make the same file name.
pub struct LogFileStem {
priv text : String
} derive(Eq, Compare, Hash, Debug)
///|
pub extend LogFileStem with Eq::{equal, not_equal}
///|
pub extend LogFileStem with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend LogFileStem with Hash::{hash, hash_combine}
///|
pub extend LogFileStem with @debug.Debug::{to_repr}
///|
fn is_ascii_letter(char : Char) -> Bool {
(char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
///|
fn is_ascii_digit(char : Char) -> Bool {
char >= '0' && char <= '9'
}
///|
/// Parse a file stem. The checks are in the order of the cases of `StemRule`.
pub fn LogFileStem::parse(text : String) -> LogFileStem raise RollingError {
fn invalid(rule : StemRule) -> RollingError {
InvalidStem(input=text, rule~)
}
let chars = text.to_array()
guard chars.length() > 0 else { raise invalid(Empty) }
guard chars.length() <= MAX_STEM_LENGTH else { raise invalid(TooLong) }
guard is_ascii_letter(chars[0]) else { raise invalid(NotLetterStart) }
for char in chars {
guard is_ascii_letter(char) ||
is_ascii_digit(char) ||
char == '_' ||
char == '-' else {
raise invalid(InvalidCharacter(char))
}
}
let mut digits_start = chars.length()
while digits_start > 0 && is_ascii_digit(chars[digits_start - 1]) {
digits_start -= 1
}
guard digits_start == chars.length() ||
!(chars[digits_start - 1] == '_' || chars[digits_start - 1] == '-') else {
raise invalid(NumericSuffix)
}
guard !is_device_name(text.to_upper()) else {
raise invalid(ReservedDeviceName)
}
{ text, }
}
///|
/// True when `upper`, in upper case, is a Windows device name.
fn is_device_name(upper : String) -> Bool {
match upper {
"CON" | "PRN" | "AUX" | "NUL" => true
_ => {
let chars = upper.to_array()
chars.length() == 4 &&
is_ascii_digit(chars[3]) &&
(String::from_array(chars[0:3]) is ("COM" | "LPT"))
}
}
}
///|
/// The stem text.
pub impl Show for LogFileStem with output(self, logger) {
logger.write_string(self.text)
}
///|
pub extend LogFileStem with Show::{output, to_string}
///|
/// The size at which a file rolls: a positive number of bytes.
pub struct FileByteLimit {
priv bytes : Int64
} derive(Eq, Compare, Hash, Debug)
///|
pub extend FileByteLimit with Eq::{equal, not_equal}
///|
pub extend FileByteLimit with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend FileByteLimit with Hash::{hash, hash_combine}
///|
pub extend FileByteLimit with @debug.Debug::{to_repr}
///|
/// Make a limit of `bytes` bytes. The value must be greater than zero.
pub fn FileByteLimit::bytes(bytes : Int64) -> FileByteLimit raise RollingError {
guard bytes > 0L else { raise NotPositiveByteLimit(bytes~) }
{ bytes, }
}
///|
/// The default limit: 10 MiB (10 485 760 bytes).
pub fn FileByteLimit::default() -> FileByteLimit {
{ bytes: 10L * 1024L * 1024L }
}
///|
/// The limit in bytes, greater than zero.
pub fn FileByteLimit::to_int64(self : FileByteLimit) -> Int64 {
self.bytes
}
///|
/// The number of newest files that retention keeps, including the active file:
/// a positive count.
pub struct RetainedFileCount {
priv count : Int
} derive(Eq, Compare, Hash, Debug)
///|
pub extend RetainedFileCount with Eq::{equal, not_equal}
///|
pub extend RetainedFileCount with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend RetainedFileCount with Hash::{hash, hash_combine}
///|
pub extend RetainedFileCount with @debug.Debug::{to_repr}
///|
/// Make a count of `count` files. The value must be greater than zero.
pub fn RetainedFileCount::count(count : Int) -> RetainedFileCount raise RollingError {
guard count > 0 else { raise NotPositiveFileCount(count~) }
{ count, }
}
///|
/// The default count: 31 files.
pub fn RetainedFileCount::default() -> RetainedFileCount {
{ count: 31 }
}
///|
/// The count, greater than zero.
pub fn RetainedFileCount::to_int(self : RetainedFileCount) -> Int {
self.count
}
///|
/// When a file set starts a new file. Periods are UTC days.
pub(all) enum RollingPolicy {
/// A new file for each UTC day, of any size.
Daily
/// A new file when the next record would make the file larger than the
/// limit. The UTC day has no effect.
Size(FileByteLimit)
/// A new file for each UTC day, and in a day also at the size limit.
DailyAndSize(FileByteLimit)
} derive(Eq, Debug)
///|
pub extend RollingPolicy with Eq::{equal, not_equal}
///|
pub extend RollingPolicy with @debug.Debug::{to_repr}
///|
/// The default policy: a new file for each UTC day and at 10 MiB.
pub fn RollingPolicy::default() -> RollingPolicy {
DailyAndSize(FileByteLimit::default())
}
///|
/// Which old files of a file set stay after a roll.
pub(all) enum Retention {
/// Keep this number of the newest files, including the active file.
KeepNewest(RetainedFileCount)
/// Keep all files.
KeepAll
} derive(Eq, Debug)
///|
pub extend Retention with Eq::{equal, not_equal}
///|
pub extend Retention with @debug.Debug::{to_repr}
///|
/// The default retention: the newest 31 files, including the active file.
pub fn Retention::default() -> Retention {
KeepNewest(RetainedFileCount::default())
}
///|
/// The format of the records in the files of a file set.
pub(all) enum FileFormat {
/// Readable text, from `@formatting.format_text`.
Text
/// JSON Lines, from `@formatting.format_json_line`.
JsonLines
} derive(Eq, Debug)
///|
pub extend FileFormat with Eq::{equal, not_equal}
///|
pub extend FileFormat with @debug.Debug::{to_repr}
///|
/// The default format: readable text.
pub fn FileFormat::default() -> FileFormat {
Text
}
///|
/// The file name extension of the format, without the dot: `log` or `jsonl`.
pub fn FileFormat::extension(self : FileFormat) -> String {
match self {
Text => "log"
JsonLines => "jsonl"
}
}