Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-log

Structured event logging for Pudu: message templates, enrichment, filtering, formatting, and sinks

0.1.0Apache-2.01

InstallClose

Rolling.pudu

Pudu167 lines7.0 KB

GitHub ↗
1/** @Log.Domain.Rolling.Module — rolling file names, periods, and retention */2module PuduLangLog.Domain.Rolling34import Std.Char as Char5import Std.List as List6import Std.Math as Math7import Std.Option as Option8import Std.Path as Path9import Std.Text as Text10import Std.Time.Format as Calendar11import PuduLangLog.Domain.Dates as Dates12import PuduLangLog as Log1314/** @Log.Domain.Rolling.Rolled — a file name read back into its period */15export type Rolled = { name: Str, period: Option[Int], sequence: Option[Int] }1617/// Milliseconds in a minute.18const MINUTE: Int = 600001920/// The date pattern a file name carries for an interval.21export fn periodFormat(interval: Log.RollingInterval) -> Str {22  match interval {23    case Log.Infinite => ""24    case Log.Year => "yyyy"25    case Log.Month => "yyyyMM"26    case Log.Day => "yyyyMMdd"27    case Log.Hour => "yyyyMMddHH"28    case Log.Minute => "yyyyMMddHHmm"29  }30}3132/// The start of the period holding the moment, as the local clock's milliseconds; none for33/// `Infinite`.34export fn checkpoint(interval: Log.RollingInterval, moment: &Log.Timestamp) -> Option[Int] {35  let parts = Calendar.partsOf(moment.millis + moment.offset * MINUTE)36  let start = match interval {37    case Log.Infinite => { return None }38    case Log.Year => Calendar.Parts{..parts, month: 1, day: 1, hour: 0, minute: 0, second: 0, milli: 0}39    case Log.Month => Calendar.Parts{..parts, day: 1, hour: 0, minute: 0, second: 0, milli: 0}40    case Log.Day => Calendar.Parts{..parts, hour: 0, minute: 0, second: 0, milli: 0}41    case Log.Hour => Calendar.Parts{..parts, minute: 0, second: 0, milli: 0}42    case Log.Minute => Calendar.Parts{..parts, second: 0, milli: 0}43  }44  Some(Calendar.millisOf(&start))45}4647/// The start of the period after the one holding the moment, as the local clock's milliseconds.48export fn next(interval: Log.RollingInterval, moment: &Log.Timestamp) -> Option[Int] {49  let start = Calendar.partsOf(checkpoint(interval, moment) ?)50  let following = match interval {51    case Log.Year => Calendar.Parts{..start, year: start.year + 1}52    case Log.Month => Calendar.Parts{..start, month: start.month + 1}53    case Log.Day => Calendar.Parts{..start, day: start.day + 1}54    case Log.Hour => Calendar.Parts{..start, hour: start.hour + 1}55    case _ => Calendar.Parts{..start, minute: start.minute + 1}56  }57  Some(Calendar.millisOf(&following))58}5960/// The file for the period holding the moment: the period's date and an optional `_NNN` sequence61/// inserted before the path's extension.62export fn fileName(path: Str, interval: Log.RollingInterval, moment: &Log.Timestamp, sequence: Option[Int]) -> Str {63  let period = match checkpoint(interval, moment) {64    case Some(start) => Dates.formatMoment(&Log.Timestamp{millis: start, offset: 0}, &Some(periodFormat(interval)))65    case None => ""66  }67  let suffix = match sequence {68    case Some(number) => "_" + Text.padLeft(show(number), 3, "0")69    case None => ""70  }71  let (stem, extension) = partsOf(path)72  stem + period + suffix + extension73}7475/// The path without its extension, and the extension with its dot.76fn partsOf(path: Str) -> (Str, Str) {77  let (stem, extension) = Path.splitExtension(path)78  match extension {79    case Some(given) => (stem, "." + given)80    case None => (stem, "")81  }82}8384/// A file name in the path's directory read back into its period and sequence, when it is one of85/// the path's rolled files.86export fn matches(path: Str, interval: Log.RollingInterval, name: Str) -> Option[Rolled] {87  let (stem, extension) = partsOf(Path.nameOf(path))88  if !name.startsWith(stem) || !name.endsWith(extension) || name.length() < stem.length() + extension.length() { return None }89  let middle = name.slice(stem.length(), name.length() - extension.length())90  let width = periodFormat(interval).length()91  let digits = middle.take(width)92  if digits.length() != width || (width > 0 && !allDigits(digits)) { return None }93  let rest = middle.drop(width)94  let sequence = if rest.isEmpty() {95    None96  } else {97    let number = rest.drop(1)98    if !rest.startsWith("_") || number.length() < 3 || !allDigits(number) { return None }99    Text.countOf(number)100  }101  Some(Rolled{name: name, period: periodOf(interval, digits), sequence: sequence})102}103104/// Whether text is one or more digits.105fn allDigits(text: Str) -> Bool { !text.isEmpty() && Text.allChars(text, |character: Char| Char.isDigit(character)) }106107/// The local milliseconds a period's digits name, when they form a date. A month outside 1 to 12108/// has no days, so its first day is already out of range.109fn periodOf(interval: Log.RollingInterval, digits: Str) -> Option[Int] {110  if interval == Log.Infinite { return None }111  let year = Text.countOf(digits.take(4)) ?112  let month = if digits.length() >= 6 { Text.countOf(digits.slice(4, 6)) ? } else { 1 }113  let day = if digits.length() >= 8 { Text.countOf(digits.slice(6, 8)) ? } else { 1 }114  let hour = if digits.length() >= 10 { Text.countOf(digits.slice(8, 10)) ? } else { 0 }115  let minute = if digits.length() >= 12 { Text.countOf(digits.slice(10, 12)) ? } else { 0 }116  if day < 1 || day > Calendar.daysInMonth(year, month) || hour > 23 || minute > 59 { return None }117  Some(Calendar.millisOf(&Calendar.Parts{year: year, month: month, day: day, hour: hour, minute: minute, second: 0, milli: 0}))118}119120/// The highest sequence among the files of one period, when any carries one.121export fn latestSequence(files: &Array[Rolled], period: Option[Int]) -> Option[Int] {122  var latest: Option[Int] = None123  for file in files {124    if file.period == period {125      if let Some(number) = file.sequence {126        latest = Some(Math.max(number, Option.unwrapOr(latest, 0)))127      }128    }129  }130  latest131}132133/// A text key ordering files by period, sequence, and name, a missing number counting as the134/// oldest: each number is a presence mark and twenty zero-padded digits, so the text sorts as the135/// numbers do.136fn orderOf(file: &Rolled) -> Str { numberKey(&file.period) + numberKey(&file.sequence) + file.name }137138/// An optional non-negative number as sortable text.139fn numberKey(number: &Option[Int]) -> Str {140  match number {141    case Some(value) => "1" + Text.padLeft(show(value), 20, "0")142    case None => "0"143  }144}145146/// The names of files to delete: every file but the current one, newest first (by period, then147/// sequence, then name, a missing value counting as the oldest), after the first `count - 1` of148/// them, and any whose period started before `oldest`.149export fn retired(files: &Array[Rolled], current: Str, count: Option[Int], oldest: Option[Int]) -> Array[Str] {150  let others = List.sortOn(&files.filter(|file: Rolled| file.name.toLower() != current.toLower()), |file: Rolled| orderOf(&file)).reverse()151  var removed: Array[Str] = []152  var index = 0153  for file in others {154    let tooMany = match count {155      case Some(limit) => index >= limit - 1156      case None => false157    }158    let tooOld = match (oldest, file.period) {159      case (Some(limit), Some(started)) => started < limit160      case _ => false161    }162    if tooMany || tooOld || !removed.isEmpty() { removed = removed.push(file.name) }163    index = index + 1164  }165  removed166}167