BazerUtils.jl

Assorted Julia utilities including custom logging
Log | Files | Refs | README | LICENSE

2026-03-24-logger-cleanup.md (48905B)


      1 # Logger Cleanup & Format Expansion Implementation Plan
      2 
      3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
      4 
      5 **Goal:** Rewrite CustomLogger.jl to fix robustness issues, add JSON/logfmt/log4j_standard formats, rename :log4j to :oneline, add exact-level filtering, and refactor to multiple dispatch.
      6 
      7 **Architecture:** Replace if/elseif format dispatch with Julia multiple dispatch on `LogFormat` subtypes. Each format implements `format_log(io, fmt, log_record, timestamp; kwargs...)`. FileSink gains per-stream `ReentrantLock`s with IO deduplication for single-file mode. `cascading_loglevels` kwarg controls `MinLevelLogger` vs exact `EarlyFilteredLogger`.
      8 
      9 **Tech Stack:** Julia 1.10+, LoggingExtras.jl (EarlyFilteredLogger, FormatLogger, MinLevelLogger, TeeLogger), Dates
     10 
     11 **Spec:** `docs/superpowers/specs/2026-03-24-logger-cleanup-design.md`
     12 
     13 ---
     14 
     15 ### Task 1: Format types, resolve_format, and helper functions
     16 
     17 **Files:**
     18 - Modify: `src/CustomLogger.jl` (replace lines 1-17 with new infrastructure, add helpers before format functions)
     19 - Test: `test/UnitTests/customlogger.jl`
     20 
     21 - [ ] **Step 1: Write failing tests for resolve_format and helpers**
     22 
     23 Add at the **top** of the `@testset "CustomLogger"` block in `test/UnitTests/customlogger.jl`:
     24 
     25 ```julia
     26     @testset "resolve_format" begin
     27         @test BazerUtils.resolve_format(:pretty) isa BazerUtils.PrettyFormat
     28         @test BazerUtils.resolve_format(:oneline) isa BazerUtils.OnelineFormat
     29         @test BazerUtils.resolve_format(:syslog) isa BazerUtils.SyslogFormat
     30         @test BazerUtils.resolve_format(:json) isa BazerUtils.JsonFormat
     31         @test BazerUtils.resolve_format(:logfmt) isa BazerUtils.LogfmtFormat
     32         @test BazerUtils.resolve_format(:log4j_standard) isa BazerUtils.Log4jStandardFormat
     33         @test_throws ArgumentError BazerUtils.resolve_format(:invalid_format)
     34         # :log4j is deprecated alias for :oneline
     35         @test BazerUtils.resolve_format(:log4j) isa BazerUtils.OnelineFormat
     36     end
     37 
     38     @testset "get_module_name" begin
     39         @test BazerUtils.get_module_name(nothing) == "unknown"
     40         @test BazerUtils.get_module_name(Base) == "Base"
     41         @test BazerUtils.get_module_name(Main) == "Main"
     42     end
     43 
     44     @testset "json_escape" begin
     45         @test BazerUtils.json_escape("hello") == "hello"
     46         @test BazerUtils.json_escape("line1\nline2") == "line1\\nline2"
     47         @test BazerUtils.json_escape("say \"hi\"") == "say \\\"hi\\\""
     48         @test BazerUtils.json_escape("back\\slash") == "back\\\\slash"
     49         @test BazerUtils.json_escape("tab\there") == "tab\\there"
     50     end
     51 
     52     @testset "logfmt_escape" begin
     53         @test BazerUtils.logfmt_escape("simple") == "simple"
     54         @test BazerUtils.logfmt_escape("has space") == "\"has space\""
     55         @test BazerUtils.logfmt_escape("has\"quote") == "\"has\\\"quote\""
     56         @test BazerUtils.logfmt_escape("has=equals") == "\"has=equals\""
     57     end
     58 ```
     59 
     60 - [ ] **Step 2: Run tests to verify they fail**
     61 
     62 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
     63 Expected: FAIL — `resolve_format`, `get_module_name`, `json_escape`, `logfmt_escape` not defined
     64 
     65 - [ ] **Step 3: Implement format types, resolve_format, and helpers**
     66 
     67 Replace lines 1–15 of `src/CustomLogger.jl` (everything ABOVE `abstract type LogSink end` on line 16 — keep `LogSink` and everything after it intact until Task 2) with:
     68 
     69 ```julia
     70 # ==================================================================================================
     71 # CustomLogger.jl — Custom multi-sink logger with per-level filtering and pluggable formats
     72 # ==================================================================================================
     73 
     74 
     75 # --- Format types (multiple dispatch instead of if/elseif) ---
     76 
     77 abstract type LogFormat end
     78 struct PrettyFormat <: LogFormat end
     79 struct OnelineFormat <: LogFormat end
     80 struct SyslogFormat <: LogFormat end
     81 struct JsonFormat <: LogFormat end
     82 struct LogfmtFormat <: LogFormat end
     83 struct Log4jStandardFormat <: LogFormat end
     84 
     85 const VALID_FORMATS = "Valid options: :pretty, :oneline, :syslog, :json, :logfmt, :log4j_standard"
     86 
     87 """
     88     resolve_format(s::Symbol) -> LogFormat
     89 
     90 Map a format symbol to its LogFormat type. `:log4j` is a deprecated alias for `:oneline`.
     91 """
     92 function resolve_format(s::Symbol)::LogFormat
     93     s === :pretty && return PrettyFormat()
     94     s === :oneline && return OnelineFormat()
     95     s === :log4j && (Base.depwarn(
     96         ":log4j is deprecated, use :oneline for single-line format or :log4j_standard for Apache Log4j format. :log4j will be removed in a future major version.",
     97         :log4j); return OnelineFormat())
     98     s === :syslog && return SyslogFormat()
     99     s === :json && return JsonFormat()
    100     s === :logfmt && return LogfmtFormat()
    101     s === :log4j_standard && return Log4jStandardFormat()
    102     throw(ArgumentError("Unknown log_format: :$s. $VALID_FORMATS"))
    103 end
    104 
    105 
    106 # --- Helper functions ---
    107 
    108 """
    109     get_module_name(mod) -> String
    110 
    111 Extract module name as a string, returning "unknown" for `nothing`.
    112 """
    113 get_module_name(mod::Module) = string(nameof(mod))
    114 get_module_name(::Nothing) = "unknown"
    115 
    116 """
    117     reformat_msg(log_record; displaysize=(50,100)) -> String
    118 
    119 Convert log record message to a string. Strings pass through; other types
    120 are rendered via `show` with display size limits.
    121 """
    122 function reformat_msg(log_record; displaysize::Tuple{Int,Int}=(50,100))::String
    123     msg = log_record.message
    124     msg isa AbstractString && return String(msg)
    125     buf = IOBuffer()
    126     show(IOContext(buf, :limit => true, :compact => true, :displaysize => displaysize),
    127          "text/plain", msg)
    128     return String(take!(buf))
    129 end
    130 
    131 """
    132     msg_to_singleline(message::AbstractString) -> String
    133 
    134 Collapse a multi-line message to a single line, using ` | ` as separator.
    135 """
    136 function msg_to_singleline(message::AbstractString)::String
    137     message |>
    138         str -> replace(str, r"\"\"\"[\r\n\s]*(.+?)[\r\n\s]*\"\"\""s => s"\1") |>
    139         str -> replace(str, r"\n\s*" => " | ") |>
    140         str -> replace(str, r"\|\s*\|" => "|") |>
    141         str -> replace(str, r"\s*\|\s*" => " | ") |>
    142         str -> replace(str, r"\|\s*$" => "") |>
    143         strip |> String
    144 end
    145 
    146 """
    147     json_escape(s::AbstractString) -> String
    148 
    149 Escape a string for inclusion in a JSON value (without surrounding quotes).
    150 """
    151 function json_escape(s::AbstractString)::String
    152     s = replace(s, '\\' => "\\\\")
    153     s = replace(s, '"' => "\\\"")
    154     s = replace(s, '\n' => "\\n")
    155     s = replace(s, '\r' => "\\r")
    156     s = replace(s, '\t' => "\\t")
    157     return s
    158 end
    159 
    160 """
    161     logfmt_escape(s::AbstractString) -> String
    162 
    163 Format a value for logfmt output. Quotes the value if it contains spaces, equals, or quotes.
    164 """
    165 function logfmt_escape(s::AbstractString)::String
    166     needs_quoting = contains(s, ' ') || contains(s, '"') || contains(s, '=')
    167     if needs_quoting
    168         return "\"" * replace(s, '"' => "\\\"") * "\""
    169     end
    170     return s
    171 end
    172 ```
    173 
    174 - [ ] **Step 4: Run tests to verify they pass**
    175 
    176 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    177 Expected: The new testsets pass. Existing tests may fail because old code was replaced — that's OK, we fix it in subsequent tasks.
    178 
    179 - [ ] **Step 5: Commit**
    180 
    181 ```bash
    182 git add src/CustomLogger.jl test/UnitTests/customlogger.jl
    183 git commit -m "feat: add format types, resolve_format, and helper functions"
    184 ```
    185 
    186 ---
    187 
    188 ### Task 2: Refactor FileSink (finalizer, locks, IO deduplication)
    189 
    190 **Files:**
    191 - Modify: `src/CustomLogger.jl` (the `FileSink` struct and related functions)
    192 - Test: `test/UnitTests/customlogger.jl`
    193 
    194 - [ ] **Step 1: Write failing tests for FileSink**
    195 
    196 Add after the `logfmt_escape` testset:
    197 
    198 ```julia
    199     @testset "FileSink" begin
    200         tmp = tempname()
    201         # Single file mode: deduplicates IO handles
    202         sink = BazerUtils.FileSink(tmp; create_files=false)
    203         @test length(sink.ios) == 4
    204         @test length(unique(objectid.(sink.ios))) == 1  # all same IO
    205         @test length(sink.locks) == 4
    206         @test length(unique(objectid.(sink.locks))) == 1  # all same lock
    207         @test all(io -> io !== stdout && io !== stderr, sink.ios)
    208         close(sink)
    209         rm(tmp, force=true)
    210 
    211         # Multi file mode: separate IO handles
    212         sink2 = BazerUtils.FileSink(tmp; create_files=true)
    213         @test length(sink2.ios) == 4
    214         @test length(unique(objectid.(sink2.ios))) == 4  # all different IO
    215         @test length(unique(objectid.(sink2.locks))) == 4  # all different locks
    216         close(sink2)
    217         rm.(BazerUtils.get_log_filenames(tmp; create_files=true), force=true)
    218 
    219         # close guard: closing twice doesn't error
    220         sink3 = BazerUtils.FileSink(tempname(); create_files=false)
    221         close(sink3)
    222         @test_nowarn close(sink3)  # second close is safe
    223     end
    224 ```
    225 
    226 - [ ] **Step 2: Run tests to verify they fail**
    227 
    228 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    229 Expected: FAIL — `sink.locks` doesn't exist, IO dedup not implemented
    230 
    231 - [ ] **Step 3: Implement refactored FileSink**
    232 
    233 Replace the `get_log_filenames` functions and `FileSink` struct (from after the helpers to the `Base.close` function) with:
    234 
    235 ```julia
    236 # --- LogSink infrastructure ---
    237 
    238 abstract type LogSink end
    239 
    240 """
    241     get_log_filenames(filename; file_loggers, create_files) -> Vector{String}
    242 
    243 Generate log file paths. When `create_files=true`, creates `filename_level.log` per level.
    244 When `false`, repeats `filename` for all levels.
    245 """
    246 function get_log_filenames(filename::AbstractString;
    247         file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug],
    248         create_files::Bool=false)
    249     if create_files
    250         return [string(filename, "_", string(f), ".log") for f in file_loggers]
    251     else
    252         return repeat([filename], length(file_loggers))
    253     end
    254 end
    255 
    256 function get_log_filenames(files::Vector{<:AbstractString};
    257         file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug])
    258     n = length(file_loggers)
    259     length(files) != n && throw(ArgumentError(
    260         "Expected exactly $n file paths (one per logger: $(join(file_loggers, ", "))), got $(length(files))"))
    261     return files
    262 end
    263 
    264 """
    265     FileSink <: LogSink
    266 
    267 File-based log sink with per-stream locking for thread safety.
    268 
    269 When all files point to the same path (single-file mode), IO handles and locks are
    270 deduplicated — one IO and one lock shared across all slots.
    271 """
    272 struct FileSink <: LogSink
    273     files::Vector{String}
    274     ios::Vector{IO}
    275     locks::Vector{ReentrantLock}
    276 
    277     function FileSink(filename::AbstractString;
    278             file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug],
    279             create_files::Bool=false)
    280         files = get_log_filenames(filename; file_loggers=file_loggers, create_files=create_files)
    281         if create_files
    282             @info "Creating $(length(files)) log files:\n$(join(string.(" \u2B91 ", files), "\n"))"
    283         else
    284             @info "Single log sink: all levels writing to $filename"
    285         end
    286         # Deduplicate: open each unique path once, share IO + lock
    287         unique_paths = unique(files)
    288         path_to_io = Dict(p => open(p, "a") for p in unique_paths)
    289         path_to_lock = Dict(p => ReentrantLock() for p in unique_paths)
    290         ios = [path_to_io[f] for f in files]
    291         locks = [path_to_lock[f] for f in files]
    292         obj = new(files, ios, locks)
    293         finalizer(close, obj)
    294         return obj
    295     end
    296 
    297     function FileSink(files::Vector{<:AbstractString};
    298             file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug])
    299         actual_files = get_log_filenames(files; file_loggers=file_loggers)
    300         unique_paths = unique(actual_files)
    301         path_to_io = Dict(p => open(p, "a") for p in unique_paths)
    302         path_to_lock = Dict(p => ReentrantLock() for p in unique_paths)
    303         ios = [path_to_io[f] for f in actual_files]
    304         locks = [path_to_lock[f] for f in actual_files]
    305         obj = new(actual_files, ios, locks)
    306         finalizer(close, obj)
    307         return obj
    308     end
    309 end
    310 
    311 function Base.close(sink::FileSink)
    312     for io in unique(sink.ios)
    313         io !== stdout && io !== stderr && isopen(io) && close(io)
    314     end
    315 end
    316 ```
    317 
    318 - [ ] **Step 4: Run tests to verify they pass**
    319 
    320 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    321 Expected: FileSink tests pass.
    322 
    323 - [ ] **Step 5: Commit**
    324 
    325 ```bash
    326 git add src/CustomLogger.jl test/UnitTests/customlogger.jl
    327 git commit -m "feat: refactor FileSink with locks, IO dedup, finalizer, close guard"
    328 ```
    329 
    330 ---
    331 
    332 ### Task 3: Implement format_log methods for all 6 formats
    333 
    334 **Files:**
    335 - Modify: `src/CustomLogger.jl` (ADD new `format_log` methods AFTER the old format functions — keep old functions in place until Task 7 cleanup)
    336 - Modify: `src/BazerUtils.jl` (add `Logging.Error` import needed for `get_color`)
    337 - Test: `test/UnitTests/customlogger.jl`
    338 
    339 - [ ] **Step 1: Write failing tests for format_log**
    340 
    341 Add after the `FileSink` testset:
    342 
    343 ```julia
    344     @testset "format_log methods" begin
    345         T = Dates.DateTime(2024, 1, 15, 14, 30, 0)
    346         log_record = (level=Base.CoreLogging.Info, message="test message",
    347             _module=BazerUtils, file="/src/app.jl", line=42, group=:test, id=:test)
    348         nothing_record = (level=Base.CoreLogging.Info, message="nothing mod",
    349             _module=nothing, file="test.jl", line=1, group=:test, id=:test)
    350 
    351         @testset "PrettyFormat" begin
    352             buf = IOBuffer()
    353             BazerUtils.format_log(buf, BazerUtils.PrettyFormat(), log_record, T;
    354                 displaysize=(50,100))
    355             output = String(take!(buf))
    356             @test contains(output, "test message")
    357             @test contains(output, "14:30:00")
    358             @test contains(output, "BazerUtils")
    359             @test contains(output, "┌")
    360             @test contains(output, "└")
    361         end
    362 
    363         @testset "PrettyFormat _module=nothing" begin
    364             buf = IOBuffer()
    365             BazerUtils.format_log(buf, BazerUtils.PrettyFormat(), nothing_record, T;
    366                 displaysize=(50,100))
    367             output = String(take!(buf))
    368             @test contains(output, "unknown")
    369         end
    370 
    371         @testset "OnelineFormat" begin
    372             buf = IOBuffer()
    373             BazerUtils.format_log(buf, BazerUtils.OnelineFormat(), log_record, T;
    374                 displaysize=(50,100), shorten_path=:no)
    375             output = String(take!(buf))
    376             @test contains(output, "INFO")
    377             @test contains(output, "2024-01-15 14:30:00")
    378             @test contains(output, "BazerUtils")
    379             @test contains(output, "test message")
    380         end
    381 
    382         @testset "OnelineFormat _module=nothing" begin
    383             buf = IOBuffer()
    384             BazerUtils.format_log(buf, BazerUtils.OnelineFormat(), nothing_record, T;
    385                 displaysize=(50,100), shorten_path=:no)
    386             output = String(take!(buf))
    387             @test contains(output, "unknown")
    388         end
    389 
    390         @testset "SyslogFormat" begin
    391             buf = IOBuffer()
    392             BazerUtils.format_log(buf, BazerUtils.SyslogFormat(), log_record, T;
    393                 displaysize=(50,100))
    394             output = String(take!(buf))
    395             @test contains(output, "<14>")  # facility=1, severity=6 -> (1*8)+6=14
    396             @test contains(output, "2024-01-15T14:30:00")
    397             @test contains(output, "test message")
    398         end
    399 
    400         @testset "JsonFormat" begin
    401             buf = IOBuffer()
    402             BazerUtils.format_log(buf, BazerUtils.JsonFormat(), log_record, T;
    403                 displaysize=(50,100))
    404             output = strip(String(take!(buf)))
    405             @test startswith(output, "{")
    406             @test endswith(output, "}")
    407             @test contains(output, "\"timestamp\":\"2024-01-15T14:30:00\"")
    408             @test contains(output, "\"level\":\"Info\"")
    409             @test contains(output, "\"module\":\"BazerUtils\"")
    410             @test contains(output, "\"message\":\"test message\"")
    411             @test contains(output, "\"line\":42")
    412             # Verify it parses as valid JSON
    413             parsed = JSON.parse(output)
    414             @test parsed["level"] == "Info"
    415             @test parsed["line"] == 42
    416         end
    417 
    418         @testset "JsonFormat escaping" begin
    419             escape_record = (level=Base.CoreLogging.Warn, message="line1\nline2 \"quoted\"",
    420                 _module=nothing, file="test.jl", line=1, group=:test, id=:test)
    421             buf = IOBuffer()
    422             BazerUtils.format_log(buf, BazerUtils.JsonFormat(), escape_record, T;
    423                 displaysize=(50,100))
    424             output = strip(String(take!(buf)))
    425             parsed = JSON.parse(output)
    426             @test parsed["message"] == "line1\nline2 \"quoted\""
    427             @test parsed["module"] == "unknown"
    428         end
    429 
    430         @testset "LogfmtFormat" begin
    431             buf = IOBuffer()
    432             BazerUtils.format_log(buf, BazerUtils.LogfmtFormat(), log_record, T;
    433                 displaysize=(50,100))
    434             output = strip(String(take!(buf)))
    435             @test contains(output, "ts=2024-01-15T14:30:00")
    436             @test contains(output, "level=Info")
    437             @test contains(output, "module=BazerUtils")
    438             @test contains(output, "msg=\"test message\"")
    439         end
    440 
    441         @testset "LogfmtFormat _module=nothing" begin
    442             buf = IOBuffer()
    443             BazerUtils.format_log(buf, BazerUtils.LogfmtFormat(), nothing_record, T;
    444                 displaysize=(50,100))
    445             output = strip(String(take!(buf)))
    446             @test contains(output, "module=unknown")
    447         end
    448 
    449         @testset "SyslogFormat _module=nothing" begin
    450             buf = IOBuffer()
    451             BazerUtils.format_log(buf, BazerUtils.SyslogFormat(), nothing_record, T;
    452                 displaysize=(50,100))
    453             output = String(take!(buf))
    454             @test contains(output, "nothing mod")
    455             @test !contains(output, "nothing[")  # should not show "nothing" as module in brackets
    456         end
    457 
    458         @testset "Log4jStandardFormat" begin
    459             buf = IOBuffer()
    460             BazerUtils.format_log(buf, BazerUtils.Log4jStandardFormat(), log_record, T;
    461                 displaysize=(50,100))
    462             output = strip(String(take!(buf)))
    463             # Pattern: timestamp LEVEL [threadid] module - message
    464             @test contains(output, "2024-01-15 14:30:00,000")
    465             @test contains(output, "INFO ")
    466             @test contains(output, "BazerUtils")
    467             @test contains(output, " - ")
    468             @test contains(output, "test message")
    469         end
    470 
    471         @testset "Log4jStandardFormat _module=nothing" begin
    472             buf = IOBuffer()
    473             BazerUtils.format_log(buf, BazerUtils.Log4jStandardFormat(), nothing_record, T;
    474                 displaysize=(50,100))
    475             output = strip(String(take!(buf)))
    476             @test contains(output, "unknown")
    477             @test contains(output, "nothing mod")
    478         end
    479     end
    480 ```
    481 
    482 **IMPORTANT:** Also add `import Dates` to `test/runtests.jl` imports (after `import HTTP`).
    483 
    484 - [ ] **Step 2: Run tests to verify they fail**
    485 
    486 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    487 Expected: FAIL — `format_log` not defined
    488 
    489 - [ ] **Step 3: Implement all format_log methods**
    490 
    491 Add to `src/CustomLogger.jl` AFTER the `shorten_path_str` function (at the end of the file). Keep old format functions (`format_pretty`, `format_log4j`, `format_syslog`, `get_color`, etc.) in place for now — they will be removed in Task 7:
    492 
    493 ```julia
    494 # --- Constants ---
    495 
    496 const SYSLOG_SEVERITY = Dict(
    497     Logging.Info  => 6,  # Informational
    498     Logging.Warn  => 4,  # Warning
    499     Logging.Error => 3,  # Error
    500     Logging.Debug => 7   # Debug
    501 )
    502 
    503 const JULIA_BIN = Base.julia_cmd().exec[1]
    504 
    505 # --- ANSI color helpers (for PrettyFormat) ---
    506 
    507 function get_color(level)
    508     BOLD = "\033[1m"
    509     LIGHT_BLUE = "\033[94m"
    510     RED = "\033[31m"
    511     GREEN = "\033[32m"
    512     YELLOW = "\033[33m"
    513     return level == Logging.Debug ? LIGHT_BLUE :
    514            level == Logging.Info  ? GREEN :
    515            level == Logging.Warn  ? "$YELLOW$BOLD" :
    516            level == Logging.Error ? "$RED$BOLD" :
    517            "\033[0m"
    518 end
    519 
    520 
    521 # ==================================================================================================
    522 # format_log methods — one per LogFormat type
    523 # All write directly to `io`. All accept a pre-computed `timestamp::DateTime`.
    524 # ==================================================================================================
    525 
    526 function format_log(io, ::PrettyFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    527         displaysize::Tuple{Int,Int}=(50,100),
    528         log_date_format::AbstractString="yyyy-mm-dd",
    529         log_time_format::AbstractString="HH:MM:SS",
    530         kwargs...)
    531 
    532     BOLD = "\033[1m"
    533     EMPH = "\033[2m"
    534     RESET = "\033[0m"
    535 
    536     date = format(timestamp, log_date_format)
    537     time_str = format(timestamp, log_time_format)
    538     ts = "$BOLD$(time_str)$RESET $EMPH$date$RESET"
    539 
    540     level_str = string(log_record.level)
    541     color = get_color(log_record.level)
    542     mod_name = get_module_name(log_record._module)
    543     source = " @ $mod_name[$(log_record.file):$(log_record.line)]"
    544     first_line = "┌ [$ts] $color$level_str$RESET | $source"
    545 
    546     formatted = reformat_msg(log_record; displaysize=displaysize)
    547     lines = split(formatted, "\n")
    548 
    549     println(io, first_line)
    550     for (i, line) in enumerate(lines)
    551         prefix = i < length(lines) ? "│ " : "└ "
    552         println(io, prefix, line)
    553     end
    554 end
    555 
    556 function format_log(io, ::OnelineFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    557         displaysize::Tuple{Int,Int}=(50,100),
    558         shorten_path::Symbol=:relative_path,
    559         kwargs...)
    560 
    561     ts = format(timestamp, "yyyy-mm-dd HH:MM:SS")
    562     level = rpad(uppercase(string(log_record.level)), 5)
    563     mod_name = get_module_name(log_record._module)
    564     file = shorten_path_str(log_record.file; strategy=shorten_path)
    565     prefix = shorten_path === :relative_path ? "[$(pwd())] " : ""
    566     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    567 
    568     println(io, "$prefix$ts $level $mod_name[$file:$(log_record.line)] $msg")
    569 end
    570 
    571 function format_log(io, ::SyslogFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    572         displaysize::Tuple{Int,Int}=(50,100),
    573         kwargs...)
    574 
    575     ts = Dates.format(timestamp, ISODateTimeFormat)
    576     severity = get(SYSLOG_SEVERITY, log_record.level, 6)
    577     pri = (1 * 8) + severity  # facility=1 (user-level)
    578     hostname = gethostname()
    579     pid = getpid()
    580     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    581 
    582     println(io, "<$pri>1 $ts $hostname $JULIA_BIN $pid - - $msg")
    583 end
    584 
    585 function format_log(io, ::JsonFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    586         displaysize::Tuple{Int,Int}=(50,100),
    587         kwargs...)
    588 
    589     ts = Dates.format(timestamp, ISODateTimeFormat)
    590     level = json_escape(string(log_record.level))
    591     mod_name = json_escape(get_module_name(log_record._module))
    592     file = json_escape(string(log_record.file))
    593     line = log_record.line
    594     msg = json_escape(reformat_msg(log_record; displaysize=displaysize))
    595 
    596     println(io, "{\"timestamp\":\"$ts\",\"level\":\"$level\",\"module\":\"$mod_name\",\"file\":\"$file\",\"line\":$line,\"message\":\"$msg\"}")
    597 end
    598 
    599 function format_log(io, ::LogfmtFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    600         displaysize::Tuple{Int,Int}=(50,100),
    601         kwargs...)
    602 
    603     ts = Dates.format(timestamp, ISODateTimeFormat)
    604     level = string(log_record.level)
    605     mod_name = get_module_name(log_record._module)
    606     file = logfmt_escape(string(log_record.file))
    607     msg = logfmt_escape(reformat_msg(log_record; displaysize=displaysize))
    608 
    609     println(io, "ts=$ts level=$level module=$mod_name file=$file line=$(log_record.line) msg=$msg")
    610 end
    611 
    612 function format_log(io, ::Log4jStandardFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    613         displaysize::Tuple{Int,Int}=(50,100),
    614         kwargs...)
    615 
    616     # Apache Log4j PatternLayout: %d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%t] %c - %m%n
    617     ts = format(timestamp, "yyyy-mm-dd HH:MM:SS")
    618     millis = lpad(Dates.millisecond(timestamp), 3, '0')
    619     level = rpad(uppercase(string(log_record.level)), 5)
    620     thread_id = Threads.threadid()
    621     mod_name = get_module_name(log_record._module)
    622     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    623 
    624     println(io, "$ts,$millis $level [$thread_id] $mod_name - $msg")
    625 end
    626 ```
    627 
    628 - [ ] **Step 4: Update BazerUtils.jl imports**
    629 
    630 Add `Logging.Error` to the Logging import line in `src/BazerUtils.jl`:
    631 
    632 ```julia
    633 import Logging: global_logger, Logging, Logging.Debug, Logging.Info, Logging.Warn, Logging.Error
    634 ```
    635 
    636 - [ ] **Step 5: Run tests to verify format_log tests pass**
    637 
    638 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    639 Expected: All `format_log methods` tests pass.
    640 
    641 - [ ] **Step 6: Commit**
    642 
    643 ```bash
    644 git add src/CustomLogger.jl src/BazerUtils.jl test/UnitTests/customlogger.jl test/runtests.jl
    645 git commit -m "feat: implement format_log methods for all 6 formats"
    646 ```
    647 
    648 ---
    649 
    650 ### Task 4: Rewrite custom_format, create_demux_logger, and custom_logger
    651 
    652 This task replaces the core orchestration. `custom_format` uses dispatch instead of if/elseif. `create_demux_logger` gains `cascading_loglevels` and thread-safe locking. `custom_logger` gets updated kwargs.
    653 
    654 **Files:**
    655 - Modify: `src/CustomLogger.jl` (replace `custom_format`, `create_demux_logger`, `custom_logger`)
    656 
    657 **IMPORTANT: Steps 1-4 below are ATOMIC.** Apply all four before running tests. The intermediate states between steps will not compile because they reference each other's new signatures.
    658 
    659 - [ ] **Step 1: Rewrite custom_format**
    660 
    661 Replace the old `custom_format` function (the one with the if/elseif chain) with:
    662 
    663 ```julia
    664 # ==================================================================================================
    665 # custom_format — dispatch hub. Called by FormatLogger callbacks.
    666 # ==================================================================================================
    667 
    668 """
    669     custom_format(io, fmt::LogFormat, log_record::NamedTuple; kwargs...)
    670 
    671 Format and write a log record to `io` using the given format. Generates a single
    672 timestamp and delegates to the appropriate `format_log` method.
    673 """
    674 function custom_format(io, fmt::LogFormat, log_record::NamedTuple;
    675         displaysize::Tuple{Int,Int}=(50,100),
    676         log_date_format::AbstractString="yyyy-mm-dd",
    677         log_time_format::AbstractString="HH:MM:SS",
    678         shorten_path::Symbol=:relative_path)
    679 
    680     timestamp = now()
    681     format_log(io, fmt, log_record, timestamp;
    682         displaysize=displaysize,
    683         log_date_format=log_date_format,
    684         log_time_format=log_time_format,
    685         shorten_path=shorten_path)
    686 end
    687 ```
    688 
    689 - [ ] **Step 2: Rewrite create_demux_logger**
    690 
    691 Replace the old `create_demux_logger` with:
    692 
    693 ```julia
    694 # ==================================================================================================
    695 # create_demux_logger — builds the TeeLogger pipeline
    696 # ==================================================================================================
    697 
    698 function create_demux_logger(sink::FileSink,
    699         file_loggers::Vector{Symbol},
    700         module_absolute_message_filter,
    701         module_specific_message_filter,
    702         fmt_file::LogFormat,
    703         fmt_stdout::LogFormat,
    704         format_kwargs::NamedTuple;
    705         cascading_loglevels::Bool=false)
    706 
    707     logger_configs = Dict(
    708         :error => (module_absolute_message_filter, Logging.Error),
    709         :warn  => (module_absolute_message_filter, Logging.Warn),
    710         :info  => (module_specific_message_filter, Logging.Info),
    711         :debug => (module_absolute_message_filter, Logging.Debug)
    712     )
    713 
    714     logger_list = []
    715 
    716     for (io_index, logger_key) in enumerate(file_loggers)
    717         if !haskey(logger_configs, logger_key)
    718             @warn "Unknown logger type: $logger_key — skipping"
    719             continue
    720         end
    721         if io_index > length(sink.ios)
    722             error("Not enough IO streams in sink for logger: $logger_key")
    723         end
    724 
    725         message_filter, log_level = logger_configs[logger_key]
    726         io = sink.ios[io_index]
    727         lk = sink.locks[io_index]
    728 
    729         # Thread-safe format callback
    730         format_cb = (cb_io, log_record) -> lock(lk) do
    731             custom_format(cb_io, fmt_file, log_record; format_kwargs...)
    732         end
    733 
    734         inner = EarlyFilteredLogger(message_filter, FormatLogger(format_cb, io))
    735 
    736         if cascading_loglevels
    737             # Old behavior: MinLevelLogger catches this level and above
    738             push!(logger_list, MinLevelLogger(inner, log_level))
    739         else
    740             # New behavior: exact level only
    741             exact_filter = log -> log.level == log_level
    742             push!(logger_list, EarlyFilteredLogger(exact_filter, inner))
    743         end
    744     end
    745 
    746     # Stdout logger — always Info+, uses specific module filter, no file locking
    747     stdout_format_cb = (io, log_record) -> custom_format(io, fmt_stdout, log_record;
    748         format_kwargs...)
    749     stdout_logger = MinLevelLogger(
    750         EarlyFilteredLogger(module_specific_message_filter,
    751             FormatLogger(stdout_format_cb, stdout)),
    752         Logging.Info)
    753     push!(logger_list, stdout_logger)
    754 
    755     return TeeLogger(logger_list...)
    756 end
    757 ```
    758 
    759 - [ ] **Step 3: Rewrite custom_logger (main method)**
    760 
    761 Replace the old `custom_logger(sink::LogSink; ...)` with:
    762 
    763 ```julia
    764 # ==================================================================================================
    765 # custom_logger — public API
    766 # ==================================================================================================
    767 
    768 """
    769     custom_logger(filename; kw...)
    770 
    771 Set up a custom global logger with per-level file output, module filtering, and configurable formatting.
    772 
    773 When `create_log_files=true`, creates one log file per level (e.g. `filename_error.log`).
    774 Otherwise all levels write to the same file.
    775 
    776 # Arguments
    777 - `filename::AbstractString`: base name for the log files
    778 - `filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from stdout and info-level file logs
    779 - `filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from all logs
    780 - `file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug]`: which levels to capture
    781 - `log_date_format::AbstractString="yyyy-mm-dd"`: date format in timestamps
    782 - `log_time_format::AbstractString="HH:MM:SS"`: time format in timestamps
    783 - `displaysize::Tuple{Int,Int}=(50,100)`: display size for non-string messages
    784 - `log_format::Symbol=:oneline`: file log format (`:pretty`, `:oneline`, `:syslog`, `:json`, `:logfmt`, `:log4j_standard`)
    785 - `log_format_stdout::Symbol=:pretty`: stdout format (same options)
    786 - `shorten_path::Symbol=:relative_path`: path shortening strategy (`:oneline` format only)
    787 - `cascading_loglevels::Bool=false`: when `true`, each file captures its level and above; when `false`, each file captures only its exact level
    788 - `create_log_files::Bool=false`: create separate files per level
    789 - `overwrite::Bool=false`: overwrite existing log files
    790 - `create_dir::Bool=false`: create log directory if missing
    791 - `verbose::Bool=false`: warn about filtering non-imported modules
    792 
    793 # Example
    794 ```julia
    795 custom_logger("/tmp/myapp";
    796     filtered_modules_all=[:HTTP, :TranscodingStreams],
    797     create_log_files=true,
    798     overwrite=true,
    799     log_format=:oneline)
    800 ```
    801 """
    802 function custom_logger(
    803         sink::LogSink;
    804         filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing,
    805         filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing,
    806         file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug],
    807         log_date_format::AbstractString="yyyy-mm-dd",
    808         log_time_format::AbstractString="HH:MM:SS",
    809         displaysize::Tuple{Int,Int}=(50,100),
    810         log_format::Symbol=:oneline,
    811         log_format_stdout::Symbol=:pretty,
    812         shorten_path::Symbol=:relative_path,
    813         cascading_loglevels::Bool=false,
    814         verbose::Bool=false)
    815 
    816     # Resolve format types (validates symbols, handles :log4j deprecation)
    817     fmt_file = resolve_format(log_format)
    818     fmt_stdout = resolve_format(log_format_stdout)
    819 
    820     # Normalize file_loggers to Vector
    821     file_loggers_vec = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers)
    822 
    823     # Warn about filtering non-imported modules
    824     if verbose
    825         imported_modules = filter(
    826             x -> typeof(getfield(Main, x)) <: Module && x !== :Main,
    827             names(Main, imported=true))
    828         all_filters = Symbol[x for x in unique(vcat(
    829             something(filtered_modules_specific, Symbol[]),
    830             something(filtered_modules_all, Symbol[]))) if !isnothing(x)]
    831         if !isempty(all_filters)
    832             missing = filter(x -> x ∉ imported_modules, all_filters)
    833             if !isempty(missing)
    834                 @warn "Filtering non-imported modules: $(join(string.(missing), ", "))"
    835             end
    836         end
    837     end
    838 
    839     # Module filters
    840     module_absolute_filter = create_module_filter(filtered_modules_all)
    841     module_specific_filter = create_module_filter(filtered_modules_specific)
    842 
    843     format_kwargs = (displaysize=displaysize,
    844                      log_date_format=log_date_format,
    845                      log_time_format=log_time_format,
    846                      shorten_path=shorten_path)
    847 
    848     demux = create_demux_logger(sink, file_loggers_vec,
    849         module_absolute_filter, module_specific_filter,
    850         fmt_file, fmt_stdout, format_kwargs;
    851         cascading_loglevels=cascading_loglevels)
    852 
    853     global_logger(demux)
    854     return demux
    855 end
    856 
    857 """
    858     create_module_filter(modules) -> Function
    859 
    860 Return a filter function that drops log messages from the specified modules.
    861 Uses `startswith` to catch submodules (e.g. `:HTTP` catches `HTTP.ConnectionPool`).
    862 """
    863 function create_module_filter(modules)
    864     return function(log)
    865         isnothing(modules) && return true
    866         mod = string(log._module)
    867         for m in modules
    868             startswith(mod, string(m)) && return false
    869         end
    870         return true
    871     end
    872 end
    873 ```
    874 
    875 - [ ] **Step 4: Rewrite convenience constructors**
    876 
    877 Replace the old convenience constructors:
    878 
    879 ```julia
    880 # Convenience constructor: filename or vector of filenames
    881 function custom_logger(
    882         filename::Union{AbstractString, Vector{<:AbstractString}};
    883         create_log_files::Bool=false,
    884         overwrite::Bool=false,
    885         create_dir::Bool=false,
    886         file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug],
    887         kwargs...)
    888 
    889     file_loggers_array = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers)
    890 
    891     files = if filename isa AbstractString
    892         get_log_filenames(filename; file_loggers=file_loggers_array, create_files=create_log_files)
    893     else
    894         get_log_filenames(filename; file_loggers=file_loggers_array)
    895     end
    896 
    897     # Create directories if needed
    898     log_dirs = unique(dirname.(files))
    899     missing_dirs = filter(d -> !isempty(d) && !isdir(d), log_dirs)
    900     if !isempty(missing_dirs)
    901         if create_dir
    902             @warn "Creating log directories: $(join(missing_dirs, ", "))"
    903             mkpath.(missing_dirs)
    904         else
    905             @error "Log directories do not exist: $(join(missing_dirs, ", "))"
    906         end
    907     end
    908 
    909     overwrite && foreach(f -> rm(f, force=true), unique(files))
    910 
    911     sink = if filename isa AbstractString
    912         FileSink(filename; file_loggers=file_loggers_array, create_files=create_log_files)
    913     else
    914         FileSink(filename; file_loggers=file_loggers_array)
    915     end
    916 
    917     custom_logger(sink; file_loggers=file_loggers, kwargs...)
    918 end
    919 
    920 # Convenience for batch/script mode
    921 function custom_logger(; kwargs...)
    922     if !isempty(PROGRAM_FILE)
    923         logbase = splitext(abspath(PROGRAM_FILE))[1]
    924         custom_logger(logbase; kwargs...)
    925     else
    926         @error "custom_logger() with no arguments requires a script context (PROGRAM_FILE is empty in the REPL)"
    927     end
    928 end
    929 ```
    930 
    931 - [ ] **Step 5: Run full test suite**
    932 
    933 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
    934 Expected: The new unit tests pass. Some existing integration tests may fail due to the `:log4j` → `:oneline` default change and `cascading_loglevels=false` default. That's expected — we fix those in Task 6.
    935 
    936 - [ ] **Step 6: Commit**
    937 
    938 ```bash
    939 git add src/CustomLogger.jl
    940 git commit -m "feat: rewrite custom_format, create_demux_logger, custom_logger with dispatch and cascading_loglevels"
    941 ```
    942 
    943 ---
    944 
    945 ### Task 5: Write tests for new features (cascading_loglevels, new formats, thread safety)
    946 
    947 **Files:**
    948 - Modify: `test/UnitTests/customlogger.jl`
    949 
    950 - [ ] **Step 1: Add cascading_loglevels tests**
    951 
    952 Add after existing integration tests:
    953 
    954 ```julia
    955     # -- exact level filtering (default: cascading_loglevels=false)
    956     log_path_cl = joinpath(tempdir(), "log_cascading")
    957     logger_exact = custom_logger(
    958         log_path_cl;
    959         overwrite=true, create_log_files=true)
    960     @error "ONLY_ERROR"
    961     @warn "ONLY_WARN"
    962     @info "ONLY_INFO"
    963     @debug "ONLY_DEBUG"
    964     log_files_exact = get_log_names(logger_exact)
    965     content_exact = read.(log_files_exact, String)
    966     # Positive: each file has its own level
    967     @test contains(content_exact[1], "ONLY_ERROR")
    968     @test contains(content_exact[2], "ONLY_WARN")
    969     @test contains(content_exact[3], "ONLY_INFO")
    970     @test contains(content_exact[4], "ONLY_DEBUG")
    971     # Negative: each file does NOT have other levels
    972     @test !contains(content_exact[1], "ONLY_WARN")
    973     @test !contains(content_exact[1], "ONLY_INFO")
    974     @test !contains(content_exact[1], "ONLY_DEBUG")
    975     @test !contains(content_exact[2], "ONLY_ERROR")
    976     @test !contains(content_exact[2], "ONLY_INFO")
    977     @test !contains(content_exact[2], "ONLY_DEBUG")
    978     @test !contains(content_exact[3], "ONLY_ERROR")
    979     @test !contains(content_exact[3], "ONLY_WARN")
    980     @test !contains(content_exact[3], "ONLY_DEBUG")
    981     @test !contains(content_exact[4], "ONLY_ERROR")
    982     @test !contains(content_exact[4], "ONLY_WARN")
    983     @test !contains(content_exact[4], "ONLY_INFO")
    984     close_logger(logger_exact, remove_files=true)
    985 
    986     # -- cascading level filtering (cascading_loglevels=true, old behavior)
    987     logger_cascade = custom_logger(
    988         log_path_cl;
    989         overwrite=true, create_log_files=true,
    990         cascading_loglevels=true)
    991     @error "CASCADE_ERROR"
    992     @warn "CASCADE_WARN"
    993     @info "CASCADE_INFO"
    994     @debug "CASCADE_DEBUG"
    995     log_files_cascade = get_log_names(logger_cascade)
    996     content_cascade = read.(log_files_cascade, String)
    997     # Error file: only errors
    998     @test contains(content_cascade[1], "CASCADE_ERROR")
    999     @test !contains(content_cascade[1], "CASCADE_WARN")
   1000     # Warn file: warn + error
   1001     @test contains(content_cascade[2], "CASCADE_WARN")
   1002     @test contains(content_cascade[2], "CASCADE_ERROR")
   1003     # Info file: info + warn + error
   1004     @test contains(content_cascade[3], "CASCADE_INFO")
   1005     @test contains(content_cascade[3], "CASCADE_WARN")
   1006     @test contains(content_cascade[3], "CASCADE_ERROR")
   1007     # Debug file: everything
   1008     @test contains(content_cascade[4], "CASCADE_DEBUG")
   1009     @test contains(content_cascade[4], "CASCADE_INFO")
   1010     @test contains(content_cascade[4], "CASCADE_WARN")
   1011     @test contains(content_cascade[4], "CASCADE_ERROR")
   1012     close_logger(logger_cascade, remove_files=true)
   1013 ```
   1014 
   1015 - [ ] **Step 2: Add integration tests for new formats**
   1016 
   1017 ```julia
   1018     # -- JSON format logger
   1019     log_path_fmt = joinpath(tempdir(), "log_fmt")
   1020     logger_json = custom_logger(
   1021         log_path_fmt;
   1022         log_format=:json, overwrite=true)
   1023     @error "JSON_ERROR"
   1024     @info "JSON_INFO"
   1025     log_file_json = get_log_names(logger_json)[1]
   1026     json_lines = filter(!isempty, split(read(log_file_json, String), "\n"))
   1027     for line in json_lines
   1028         parsed = JSON.parse(line)
   1029         @test haskey(parsed, "timestamp")
   1030         @test haskey(parsed, "level")
   1031         @test haskey(parsed, "module")
   1032         @test haskey(parsed, "message")
   1033     end
   1034     close_logger(logger_json, remove_files=true)
   1035 
   1036     # -- logfmt format logger
   1037     logger_logfmt = custom_logger(
   1038         log_path_fmt;
   1039         log_format=:logfmt, overwrite=true)
   1040     @error "LOGFMT_ERROR"
   1041     @info "LOGFMT_INFO"
   1042     log_file_logfmt = get_log_names(logger_logfmt)[1]
   1043     logfmt_content = read(log_file_logfmt, String)
   1044     @test contains(logfmt_content, "level=Error")
   1045     @test contains(logfmt_content, "level=Info")
   1046     @test contains(logfmt_content, r"ts=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
   1047     @test contains(logfmt_content, "msg=")
   1048     close_logger(logger_logfmt, remove_files=true)
   1049 
   1050     # -- log4j_standard format logger
   1051     logger_l4js = custom_logger(
   1052         log_path_fmt;
   1053         log_format=:log4j_standard, overwrite=true)
   1054     @error "L4JS_ERROR"
   1055     @info "L4JS_INFO"
   1056     log_file_l4js = get_log_names(logger_l4js)[1]
   1057     l4js_content = read(log_file_l4js, String)
   1058     # Pattern: timestamp,millis LEVEL [threadid] module - message
   1059     @test contains(l4js_content, r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} ERROR")
   1060     @test contains(l4js_content, r"INFO .* - L4JS_INFO")
   1061     @test contains(l4js_content, " - ")
   1062     close_logger(logger_l4js, remove_files=true)
   1063 ```
   1064 
   1065 - [ ] **Step 3: Add unknown format and deprecation tests**
   1066 
   1067 ```julia
   1068     # -- unknown format throws
   1069     @test_throws ArgumentError custom_logger(
   1070         joinpath(tempdir(), "log_bad"); log_format=:banana, overwrite=true)
   1071 
   1072     # -- :log4j deprecated alias still works
   1073     logger_deprecated = custom_logger(
   1074         log_path_fmt;
   1075         log_format=:log4j, overwrite=true)
   1076     @info "DEPRECATED_TEST"
   1077     log_file_dep = get_log_names(logger_deprecated)[1]
   1078     dep_content = read(log_file_dep, String)
   1079     @test contains(dep_content, "DEPRECATED_TEST")
   1080     close_logger(logger_deprecated, remove_files=true)
   1081 ```
   1082 
   1083 - [ ] **Step 4: Add thread safety test**
   1084 
   1085 ```julia
   1086     # -- thread safety: concurrent logging produces complete lines
   1087     log_path_thread = joinpath(tempdir(), "log_thread")
   1088     logger_thread = custom_logger(
   1089         log_path_thread;
   1090         log_format=:json, overwrite=true)
   1091     n_tasks = 10
   1092     n_msgs = 50
   1093     @sync for t in 1:n_tasks
   1094         Threads.@spawn begin
   1095             for m in 1:n_msgs
   1096                 @info "task=$t msg=$m"
   1097             end
   1098         end
   1099     end
   1100     log_file_thread = get_log_names(logger_thread)[1]
   1101     flush(logger_thread.loggers[end].logger.logger.stream)  # flush stdout logger is irrelevant
   1102     # Flush all file streams
   1103     for lg in logger_thread.loggers
   1104         s = lg.logger.logger.stream
   1105         s isa IOStream && flush(s)
   1106     end
   1107     thread_lines = filter(!isempty, split(read(log_file_thread, String), "\n"))
   1108     # Every line should be valid JSON (no interleaving)
   1109     for line in thread_lines
   1110         @test startswith(line, "{")
   1111         @test endswith(line, "}")
   1112         parsed = JSON.parse(line)
   1113         @test haskey(parsed, "message")
   1114     end
   1115     close_logger(logger_thread, remove_files=true)
   1116 ```
   1117 
   1118 - [ ] **Step 5: Run tests (with threads for thread safety test)**
   1119 
   1120 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --threads=4 --project -e 'using Pkg; Pkg.test()'`
   1121 Expected: New tests pass. Some old integration tests may still need updating (Task 6). **Note:** `--threads=4` is required so the thread safety test actually exercises concurrent writes.
   1122 
   1123 - [ ] **Step 6: Commit**
   1124 
   1125 ```bash
   1126 git add test/UnitTests/customlogger.jl
   1127 git commit -m "test: add tests for cascading_loglevels, new formats, thread safety"
   1128 ```
   1129 
   1130 ---
   1131 
   1132 ### Task 6: Update existing tests for breaking changes
   1133 
   1134 The `cascading_loglevels=false` default changes behavior of the `[:debug, :info]` partial-loggers test. The `:log4j` format references in existing tests should use `:oneline` (or keep `:log4j` for the deprecation test, already covered).
   1135 
   1136 **Files:**
   1137 - Modify: `test/UnitTests/customlogger.jl`
   1138 
   1139 - [ ] **Step 1: Fix the partial file_loggers test**
   1140 
   1141 The test at the end that uses `file_loggers = [:debug, :info]` expects cascading behavior where the debug file contains INFO messages. With `cascading_loglevels=false`, each file is exact-level. Update the test:
   1142 
   1143 Old assertion:
   1144 ```julia
   1145 @test contains.(log_content, r"INFO .* INFO MESSAGE") == [true, true]
   1146 ```
   1147 
   1148 Change to:
   1149 ```julia
   1150 @test contains.(log_content, r"DEBUG .* DEBUG MESSAGE") == [true, false]
   1151 @test contains.(log_content, r"INFO .* INFO MESSAGE") == [false, true]
   1152 ```
   1153 
   1154 - [ ] **Step 2: Update existing format tests to use :oneline**
   1155 
   1156 In the "logger with formatting" test block, change `log_format=:log4j` to `log_format=:oneline`.
   1157 In the "logger with formatting and truncation" test block, change `log_format=:log4j` to `log_format=:oneline`.
   1158 
   1159 - [ ] **Step 3: Update _module=nothing test to use :oneline**
   1160 
   1161 Change `log_format=:log4j` to `log_format=:oneline` in the `_module=nothing` test. Also update the `custom_format` call to use the new dispatch signature:
   1162 
   1163 ```julia
   1164     # -- logger with _module=nothing (issue #10)
   1165     logger_single = custom_logger(
   1166         log_path;
   1167         log_format=:oneline,
   1168         overwrite=true)
   1169     log_record = (level=Base.CoreLogging.Info, message="test nothing module",
   1170         _module=nothing, file="test.jl", line=1, group=:test, id=:test)
   1171     buf = IOBuffer()
   1172     BazerUtils.custom_format(buf, BazerUtils.OnelineFormat(), log_record;
   1173         shorten_path=:no)
   1174     output = String(take!(buf))
   1175     @test contains(output, "unknown")
   1176     @test contains(output, "test nothing module")
   1177     close_logger(logger_single, remove_files=true)
   1178 ```
   1179 
   1180 - [ ] **Step 4: Run full test suite**
   1181 
   1182 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
   1183 Expected: ALL tests pass.
   1184 
   1185 - [ ] **Step 5: Commit**
   1186 
   1187 ```bash
   1188 git add test/UnitTests/customlogger.jl
   1189 git commit -m "test: update existing tests for :oneline rename and cascading_loglevels=false default"
   1190 ```
   1191 
   1192 ---
   1193 
   1194 ### Task 7: Clean up dead code and update docstrings
   1195 
   1196 **Files:**
   1197 - Modify: `src/CustomLogger.jl` — remove any leftover old code
   1198 - Modify: `src/BazerUtils.jl` — verify imports are clean
   1199 
   1200 - [ ] **Step 1: Remove old format functions and dead code**
   1201 
   1202 DELETE from `src/CustomLogger.jl`:
   1203 - Old `format_pretty` function (was kept alongside new `format_log` methods since Task 3)
   1204 - Old `format_log4j` function
   1205 - Old `format_syslog` function
   1206 - Old `custom_format` with if/elseif chain (replaced in Task 4)
   1207 - Old `create_demux_logger` signature (replaced in Task 4)
   1208 - Old `reformat_msg` with `log_format` kwarg (replaced in Task 1)
   1209 - Old `syslog_severity_map` dict with string keys (replaced by `SYSLOG_SEVERITY`)
   1210 - Old `julia_bin` const (replaced by `JULIA_BIN`)
   1211 - Old `get_color` function (replaced by new `get_color` in Task 3 — verify no duplication)
   1212 - Commented-out blocks in old `format_syslog`
   1213 - Orphaned section-separator comment blocks (`# ----...`)
   1214 
   1215 - [ ] **Step 2: Verify BazerUtils.jl imports**
   1216 
   1217 Ensure `src/BazerUtils.jl` imports include `Logging.Error` and that no unused imports remain:
   1218 
   1219 ```julia
   1220 import Dates: format, now, Dates, ISODateTimeFormat
   1221 import Logging: global_logger, Logging, Logging.Debug, Logging.Info, Logging.Warn, Logging.Error
   1222 import LoggingExtras: EarlyFilteredLogger, FormatLogger, MinLevelLogger, TeeLogger
   1223 import JSON: JSON
   1224 import Tables: Tables
   1225 import CodecZlib: CodecZlib
   1226 ```
   1227 
   1228 - [ ] **Step 3: Run full test suite**
   1229 
   1230 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
   1231 Expected: ALL tests pass.
   1232 
   1233 - [ ] **Step 4: Commit**
   1234 
   1235 ```bash
   1236 git add src/CustomLogger.jl src/BazerUtils.jl
   1237 git commit -m "chore: remove dead code and clean up imports"
   1238 ```
   1239 
   1240 ---
   1241 
   1242 ### Task 8: Version bump to v0.11.0
   1243 
   1244 **Files:**
   1245 - Modify: `Project.toml`
   1246 
   1247 - [ ] **Step 1: Bump version**
   1248 
   1249 Change `version = "0.10.1"` to `version = "0.11.0"` in `Project.toml`.
   1250 
   1251 - [ ] **Step 2: Add TODO comment for log4j deprecation timeline**
   1252 
   1253 Add a comment in `src/CustomLogger.jl` near the `resolve_format` function:
   1254 
   1255 ```julia
   1256 # TODO (March 2027): Remove :log4j alias for :oneline. Rename :log4j_standard to :log4j.
   1257 # This is a breaking change requiring a major version bump.
   1258 ```
   1259 
   1260 - [ ] **Step 3: Run full test suite one final time**
   1261 
   1262 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'`
   1263 Expected: ALL tests pass.
   1264 
   1265 - [ ] **Step 4: Commit**
   1266 
   1267 ```bash
   1268 git add Project.toml src/CustomLogger.jl
   1269 git commit -m "chore: bump version to v0.11.0 (breaking: cascading_loglevels default, :log4j renamed)"
   1270 ```