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

FileTest.pudu

Pudu123 lines6.3 KB

GitHub ↗
1/** @Test.Sinks.File.Suite — appending, rolling, limits, retention, and buffering */2module PuduLangLog.Sinks.FileTest34import Std.Concurrent as Concurrent5import Std.Env as Env6import Std.Fs as Fs7import Std.Io as Io8import Std.List as List9import Std.Result as Result10import Std.Sync as Sync11import Std.Test as Test12import Std.Time as Time13import PuduLangLog.Event as Event14import PuduLangLog as Log15import PuduLangLog.Sinks.File as File1617/// Milliseconds in a day.18const DAY: Int = 864000001920/// 2026-09-28 10:00 UTC.21const MORNING: Int = 17905896000002223/// An event on a day after the first, carrying a message.24fn event(day: Int, message: Str) -> Log.Event {25  Event.create(Log.Timestamp{millis: MORNING + day * DAY, offset: 0}, Log.Information, message, [])26}2728/// Options writing each message on its own line.29fn lines(path: Str) -> File.Options { File.Options{..File.defaults(path), formatter: fn(written: &Log.Event) -> Str { Event.message(written) + "\n" }} }3031/// Emits events through a sink built from options, then closes it.32fn run(options: File.Options, events: Array[Log.Event]) -> Array[Result[(), Str]] {33  let sink = File.sink(options)34  let results = events.map(|written: Log.Event| (sink.emit)(&written))35  (sink.close)()36  results37}3839/// The sorted names of a directory's files.40fn namesIn(directory: Str) -> Array[Str] {41  List.sortBy(&Result.unwrapOr(Io.list(directory), []), fn(left: Str, right: Str) -> Bool { left < right })42}4344/// A file's text, or empty text when it cannot be read.45fn read(path: Str) -> Str { Result.unwrapOr(Io.read(path), "") }4647/// Runs the suite.48fn main() -> Int {49  let root = Io.join(Env.temporaryDirectory(), "pudu-lang-log-file-" + show(Time.toMillis(&Time.currentInstant())))50  let _cleared = Fs.removeTree(root)5152  let plain = Io.join(root, "plain/nested/app.log")53  let plainResults = run(lines(plain), [event(0, "one"), event(0, "two")])5455  let daily = Io.join(root, "daily")56  let _daily = run(File.Options{..lines(Io.join(daily, "app-.log")), rollingInterval: Log.Day}, [event(0, "first"), event(1, "second"), event(1, "third")])5758  let limited = Io.join(root, "limited/app.log")59  let _limited = run(File.Options{..lines(limited), fileSizeLimitBytes: Some(10)}, [event(0, "aaaaaaa"), event(0, "bbbbbbb"), event(0, "ccccccc")])6061  let sized = Io.join(root, "sized")62  let _sized = run(File.Options{..lines(Io.join(sized, "app.log")), fileSizeLimitBytes: Some(10), rollOnFileSizeLimit: true}, [event(0, "aaaaaaaaaa"), event(0, "bbbbbbbbbb"), event(0, "cccccccccc")])6364  let kept = Io.join(root, "kept")65  let deleted = Sync.cell(0)66  let _kept = run(File.Options{..lines(Io.join(kept, "app-.log")), rollingInterval: Log.Day, retainedFileCountLimit: Some(2), onDeleting: Some(fn(_path: Str) -> () {67          if let Ok(count) = Sync.get(&deleted) { let _stored = Sync.set(&deleted, count + 1) }68        }) }, [event(0, "a"), event(1, "b"), event(2, "c"), event(3, "d")])6970  let aged = Io.join(root, "aged")71  let _aged = run(File.Options{..lines(Io.join(aged, "app-.log")), rollingInterval: Log.Day, retainedFileCountLimit: None, retainedFileTimeLimit: Some(2 * DAY)}, [event(0, "a"), event(1, "b"), event(2, "c"), event(3, "d")])7273  let headed = Io.join(root, "headed/app-.log")74  let _headed = run(File.Options{..lines(headed), rollingInterval: Log.Day, onOpened: Some(fn(_path: Str) -> Option[Str] { Some("# header\n") })}, [event(0, "a"), event(0, "b")])75  let _reopened = run(File.Options{..lines(headed), rollingInterval: Log.Day, onOpened: Some(fn(_path: Str) -> Option[Str] { Some("# header\n") })}, [event(0, "c")])7677  let resumed = Io.join(root, "resumed")78  let _made = Io.makeDirectory(resumed)79  let _seeded = Io.write(Io.join(resumed, "app-20260928_003.log"), "old\n")80  let _resumed = run(File.Options{..lines(Io.join(resumed, "app-.log")), rollingInterval: Log.Day}, [event(0, "new")])8182  let buffered = Io.join(root, "buffered/app.log")83  let bufferedSink = File.sink(File.Options{..lines(buffered), buffered: true})84  let _first = (bufferedSink.emit)(&event(0, "held"))85  let beforeFlush = Io.exists(buffered)86  (bufferedSink.flush)()87  let afterFlush = read(buffered)88  (bufferedSink.close)()89  let afterClose = (bufferedSink.emit)(&event(0, "late"))9091  let timed = Io.join(root, "timed/app.log")92  let timedSink = File.sink(File.Options{..lines(timed), buffered: true, flushInterval: Some(100)})93  let _timed = (timedSink.emit)(&event(0, "soon"))94  let _waited = Concurrent.sleep(400)95  let timedText = read(timed)96  (timedSink.close)()9798  let checks = Test.suite("Sinks.File", &[99      Test.equals("events are appended in order", &read(plain), &"one\ntwo\n"),100      Test.equals("every write succeeds", &plainResults, &[Ok(()), Ok(())]),101      Test.equals("a file per day", &namesIn(daily), &["app-20260928.log", "app-20260929.log"]),102      Test.equals("each day holds its events", &(read(Io.join(daily, "app-20260928.log")), read(Io.join(daily, "app-20260929.log"))), &("first\n", "second\nthird\n")),103      Test.equals("a full file takes no more events", &read(limited), &"aaaaaaa\nbbbbbbb\n"),104      Test.equals("a full file gives way to the next sequence", &namesIn(sized), &["app.log", "app_001.log", "app_002.log"]),105      Test.equals("each sequence holds one event", &read(Io.join(sized, "app_002.log")), &"cccccccccc\n"),106      Test.equals("retention keeps the count", &namesIn(kept), &["app-20260930.log", "app-20261001.log"]),107      Test.equals("the deleting hook sees each removal", &Sync.get(&deleted), &Ok(2)),108      Test.equals("retention by age removes periods that started before the limit", &namesIn(aged), &["app-20260930.log", "app-20261001.log"]),109      Test.equals("a header starts only a new file", &read(Io.join(root, "headed/app-20260928.log")), &"# header\na\nb\nc\n"),110      Test.equals("the latest sequence on disk is resumed", &(namesIn(resumed), read(Io.join(resumed, "app-20260928_003.log"))), &(["app-20260928_003.log"], "old\nnew\n")),111      Test.that("buffered text waits for a flush", !beforeFlush),112      Test.equals("a flush writes buffered text", &afterFlush, &"held\n"),113      Test.equals("a closed sink refuses events", &afterClose, &Err("the file sink is closed")),114      Test.equals("a flushing thread writes on its own", &timedText, &"soon\n")115    ])116  let _removed = Fs.removeTree(root)117  let ran = Test.run(&checks)118  for failure in Test.failuresOf(&ran) {119    let _reported = Io.writeErrorLine(failure)120  }121  Test.report(&ran)122}123