///|
/// The largest sequence number of a file in one period.
const MAX_SEQUENCE : Int = 2147483647
///|
/// The files of one stem and one format in a log directory, with one rolling
/// policy. A file set owns only the names of its complete naming scheme. For the
/// stem `acme`:
///
/// | Policy | First file | Next files |
/// | --- | --- | --- |
/// | `Daily` or `DailyAndSize` | `acme-20261027.log` | `acme-20261027_001.log`, `acme-20261027_002.log` |
/// | `Size` | `acme.log` | `acme_001.log`, `acme_002.log` |
///
/// `JsonLines` uses `.jsonl` in place of `.log`. A sequence number has at least
/// three digits, and has more digits after 999 (`_1000`).
pub struct FileSet {
priv stem : LogFileStem
priv format : FileFormat
priv policy : RollingPolicy
} derive(Eq, Debug)
///|
pub extend FileSet with Eq::{equal, not_equal}
///|
pub extend FileSet with @debug.Debug::{to_repr}
///|
/// Make the file set of `stem` and `format` that rolls by `policy`.
pub fn FileSet::new(
stem : LogFileStem,
format : FileFormat,
policy : RollingPolicy,
) -> FileSet {
{ stem, format, policy }
}
///|
/// The stem of the file names.
pub fn FileSet::stem(self : FileSet) -> LogFileStem {
self.stem
}
///|
/// The format of the records.
pub fn FileSet::format(self : FileSet) -> FileFormat {
self.format
}
///|
/// The rolling policy.
pub fn FileSet::policy(self : FileSet) -> RollingPolicy {
self.policy
}
///|
/// The name of the ownership marker of the file set, for example
/// `acme.log.lock`. It does not have the form of an owned file name, so
/// retention never deletes it. The same stem and format with a different policy
/// has the same marker, so one directory cannot mix policies for one file set.
pub fn FileSet::marker_name(self : FileSet) -> String {
"\{self.stem}.\{self.format.extension()}.lock"
}
///|
/// True when the file names have a UTC date.
fn FileSet::is_daily(self : FileSet) -> Bool {
self.policy is (Daily | DailyAndSize(_))
}
///|
/// The size limit of the policy, or `None` for `Daily`.
fn FileSet::limit(self : FileSet) -> FileByteLimit? {
match self.policy {
Daily => None
Size(limit) | DailyAndSize(limit) => Some(limit)
}
}
///|
/// One UTC day of a dated file name: the days after 1970-01-01 and the date as
/// eight digits.
priv struct Period {
day : Int64
date : String
} derive(Eq, Compare, Hash, Debug)
///|
/// The name of one file that a file set owns: an optional UTC day and a sequence
/// number, where 0 is the name with no number. Names of one file set sort by day
/// and then by sequence number.
pub struct LogFileName {
priv period : Period?
priv sequence : Int
priv text : String
} derive(Eq, Compare, Hash, Debug)
///|
pub extend LogFileName with Eq::{equal, not_equal}
///|
pub extend LogFileName with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend LogFileName with Hash::{hash, hash_combine}
///|
pub extend LogFileName with @debug.Debug::{to_repr}
///|
/// The UTC day of the name as days after 1970-01-01, as
/// `LogTimestamp::epoch_day` gives it, or `None` for a name with no date.
pub fn LogFileName::epoch_day(self : LogFileName) -> Int64? {
self.period.map(period => period.day)
}
///|
/// The sequence number in the period: 0 for the name with no number, then 1 for
/// `_001` and so on.
pub fn LogFileName::sequence(self : LogFileName) -> Int {
self.sequence
}
///|
/// The file name, for example `acme-20261027_001.log`.
pub impl Show for LogFileName with output(self, logger) {
logger.write_string(self.text)
}
///|
pub extend LogFileName with Show::{output, to_string}
///|
/// The text of a sequence number in a name: nothing for 0, else `_` and at
/// least three digits.
fn sequence_text(sequence : Int) -> String {
if sequence == 0 {
""
} else if sequence < 10 {
"_00\{sequence}"
} else if sequence < 100 {
"_0\{sequence}"
} else {
"_\{sequence}"
}
}
///|
/// The name of the file set in `period` with `sequence`.
fn FileSet::make_name(
self : FileSet,
period : Period?,
sequence : Int,
) -> LogFileName {
let date = match period {
Some(period) => "-" + period.date
None => ""
}
let text = "\{self.stem}\{date}\{sequence_text(sequence)}.\{self.format.extension()}"
{ period, sequence, text }
}
///|
/// The first name of the period of `now`: with its UTC date for a daily policy.
fn FileSet::first_name(self : FileSet, now : @formatting.LogTimestamp) -> LogFileName {
let period = if self.is_daily() {
Some({ day: now.epoch_day(), date: now.compact_date() })
} else {
None
}
self.make_name(period, 0)
}
///|
/// The name after `name` in its period.
fn FileSet::next_name(
self : FileSet,
name : LogFileName,
) -> LogFileName raise RollingError {
guard name.sequence < MAX_SEQUENCE else {
raise SequenceExhausted(last=name.text)
}
self.make_name(name.period, name.sequence + 1)
}
///|
/// The number of days in `month` of `year`.
fn days_in_month(year : Int64, month : Int64) -> Int64 {
match month {
2L =>
if (year % 4L == 0L && year % 100L != 0L) || year % 400L == 0L {
29L
} else {
28L
}
4L | 6L | 9L | 11L => 30L
_ => 31L
}
}
///|
/// The days after 1970-01-01 of a valid date from the year 1970. This is the
/// days-from-civil algorithm of Howard Hinnant.
fn days_from_civil(year : Int64, month : Int64, day : Int64) -> Int64 {
let shifted_year = if month <= 2L { year - 1L } else { year }
let era = shifted_year / 400L
let year_of_era = shifted_year - era * 400L
let shifted_month = if month > 2L { month - 3L } else { month + 9L }
let day_of_year = (153L * shifted_month + 2L) / 5L + day - 1L
let day_of_era = year_of_era * 365L +
year_of_era / 4L -
year_of_era / 100L +
day_of_year
era * 146097L + day_of_era - 719468L
}
///|
/// The value of the ASCII digits `chars`.
fn digits_value(chars : ArrayView[Char]) -> Int64 {
let mut value = 0L
for char in chars {
value = value * 10L + (char.to_int() - '0'.to_int()).to_int64()
}
value
}
///|
/// The period of eight date digits `YYYYMMDD`, or `None` when they are not a
/// date from 1970-01-01 to 9999-12-31.
fn period_of(digits : ArrayView[Char]) -> Period? {
let year = digits_value(digits[0:4])
let month = digits_value(digits[4:6])
let day = digits_value(digits[6:8])
guard year >= 1970L &&
month >= 1L &&
month <= 12L &&
day >= 1L &&
day <= days_in_month(year, month) else {
return None
}
Some({
day: days_from_civil(year, month, day),
date: String::from_array(digits),
})
}
///|
/// The owned name `text`, or `None` when `text` does not have the complete form
/// of a name of this file set. The check is exact: the stem with the same
/// letter case, a real date for a daily policy, a sequence number in its one
/// text form (`_001`, not `_01`, `_0001` or `_000`) and the extension of the
/// format. So `acme-old.log`, `acme-20261027.log.bak`, `acmex.log`, the names of
/// another stem or format and the marker are not owned.
pub fn FileSet::parse_name(self : FileSet, text : String) -> LogFileName? {
let chars = text.to_array()
let stem = self.stem.text.to_array()
guard chars.length() > stem.length() &&
chars[0:stem.length()] == stem[:] else {
return None
}
let mut index = stem.length()
let period = if self.is_daily() {
guard index + 9 <= chars.length() &&
chars[index] == '-' &&
chars[index + 1:index + 9].iter().all(is_ascii_digit) else {
return None
}
guard period_of(chars[index + 1:index + 9]) is Some(period) else {
return None
}
index += 9
Some(period)
} else {
None
}
let mut sequence = 0
if index < chars.length() && chars[index] == '_' {
let start = index + 1
let mut end = start
while end < chars.length() && is_ascii_digit(chars[end]) {
end += 1
}
guard end - start >= 3 && end - start <= 10 else { return None }
let value = digits_value(chars[start:end])
guard value >= 1L && value <= MAX_SEQUENCE.to_int64() else { return None }
sequence = value.to_int()
guard sequence_text(sequence) == "_" + String::from_array(chars[start:end]) else {
return None
}
index = end
}
guard String::from_array(chars[index:]) == "." + self.format.extension() else {
return None
}
Some({ period, sequence, text })
}