ReviewOS

also looking at this

craft-native/craft

fix(log): keep the terminal's colour, and let logging be turned off

#81
Merged glennmichael123 wants to merge fix/log-colour-and-off into main
4 files +126 -16
packages/zig/src/log.zigmodified+115-14
Changes to packages/zig/src/log.zig
@@ -9,6 +9,15 @@ pub const LogLevel = enum {
99 Warning,
1010 Error,
1111 Fatal,
12 /// Record nothing.
13 ///
14 /// Last, so the existing `level >= min_level` comparison silences
15 /// everything when it is the minimum — no branch anywhere else has to know
16 /// about it. `logging.zig` has had an `off` for as long as it has existed;
17 /// this side had no way to say the same thing, so an app that wanted
18 /// quiet had to not configure logging at all, which is not the same as
19 /// asking for it.
20 Off,
1221
1322 pub fn toString(self: LogLevel) []const u8 {
1423 return switch (self) {
@@ -17,6 +26,7 @@ pub const LogLevel = enum {
1726 .Warning => "WARN",
1827 .Error => "ERROR",
1928 .Fatal => "FATAL",
29 .Off => "OFF",
2030 };
2131 }
2232
@@ -27,6 +37,8 @@ pub const LogLevel = enum {
2737 .Warning => "\x1B[33m", // Yellow
2838 .Error => "\x1B[31m", // Red
2939 .Fatal => "\x1B[35m", // Magenta
40 // Never emitted; a record at this level is filtered before formatting.
41 .Off => "",
3042 };
3143 }
3244};
@@ -96,6 +108,12 @@ threadlocal var in_log: bool = false;
96108/// reachable from inside a log call. Captured once instead.
97109var log_io: ?std.Io = null;
98110
111/// Whether stderr is a terminal, decided once at `init`.
112///
113/// Per-record would mean an `isatty` on every line, and the answer cannot
114/// change for a running process anyway.
115var stderr_is_tty: bool = false;
116
99117pub fn init(config: LogConfig) !void {
100118 log_mutex.lock();
101119 defer log_mutex.unlock();
@@ -110,6 +128,7 @@ pub fn init(config: LogConfig) !void {
110128 current_config = config;
111129 log_file_offset = 0;
112130 log_io = io_context.get();
131 stderr_is_tty = std.Io.File.stderr().isTty(log_io.?) catch false;
113132
114133 // Take copies before anything else can free the originals.
115134 if (config.output_file) |path| {
@@ -297,28 +316,41 @@ pub fn log(
297316 }
298317
299318 output_len = formatTruncating(&output_buf, "\n", .{ timestamp, level.toString(), esc_buf[0..esc_len] }).len;
319 } else if (current_config.enable_timestamps) {
320 output_len = formatTruncating(&output_buf, "[{s}] {s} {s}\n", .{ timestamp, level.toString(), message }).len;
300321 } else {
301 // Standard output
302 const reset = "\x1B[0m";
303 const dim = "\x1B[2m";
304
305 if (current_config.enable_timestamps and current_config.enable_colors) {
306 output_len = formatTruncating(&output_buf, "{s}[{s}]{s} {s}{s}{s} {s}\n", .{ dim, timestamp, reset, level.color(), level.toString(), reset, message }).len;
307 } else if (current_config.enable_timestamps) {
308 output_len = formatTruncating(&output_buf, "[{s}] {s} {s}\n", .{ timestamp, level.toString(), message }).len;
309 } else if (current_config.enable_colors) {
310 output_len = formatTruncating(&output_buf, "{s}{s}{s} {s}\n", .{ level.color(), level.toString(), reset, message }).len;
311 } else {
312 output_len = formatTruncating(&output_buf, "{s} {s}\n", .{ level.toString(), message }).len;
313 }
322 output_len = formatTruncating(&output_buf, "{s} {s}\n", .{ level.toString(), message }).len;
314323 }
315324
316325 const output = output_buf[0..output_len];
317326
327 // Colour is formatted separately, for stderr only.
328 //
329 // Both sinks used to receive the same bytes, so a run with `--log-file`
330 // either put ANSI escapes in the file or — the workaround this replaces —
331 // turned colour off for the terminal as well, to keep the file clean. A
332 // log file full of `\x1b[2m` is unreadable to `grep`, and a terminal that
333 // loses its colours because a file was requested is a strange trade.
334 //
335 // Only when stderr is a terminal, decided once at `init` rather than per
336 // record: colour written into a redirected stderr is the same problem as
337 // colour written into the file.
338 var colour_buf: [4096]u8 = undefined;
339 var colour_len: usize = 0;
340 if (current_config.enable_colors and stderr_is_tty and !current_config.json_output) {
341 const reset = "\x1B[0m";
342 const dim = "\x1B[2m";
343 colour_len = if (current_config.enable_timestamps)
344 formatTruncating(&colour_buf, "{s}[{s}]{s} {s}{s}{s} {s}\n", .{ dim, timestamp, reset, level.color(), level.toString(), reset, message }).len
345 else
346 formatTruncating(&colour_buf, "{s}{s}{s} {s}\n", .{ level.color(), level.toString(), reset, message }).len;
347 }
348
318349 const io = log_io orelse io_context.get();
319350
320351 if (current_config.mirror_to_stderr) {
321 _ = std.Io.File.stderr().writeStreamingAll(io, output) catch {};
352 const for_stderr = if (colour_len > 0) colour_buf[0..colour_len] else output;
353 _ = std.Io.File.stderr().writeStreamingAll(io, for_stderr) catch {};
322354 }
323355
324356 // Appended at the tracked offset. `writeStreamingAll` writes from position
@@ -661,3 +693,72 @@ test "logging from inside logging drops the inner record instead of hanging" {
661693 resetInLogForTesting();
662694 try testing.expect(!in_log);
663695}
696
697test "off silences every level" {
698 // `logging.zig` has always had an `off`; this side had no way to say the
699 // same thing, so an app that wanted quiet had to not configure logging at
700 // all — which is not the same as asking for it.
701 const io = io_context.get();
702 const path = "craft-log-off-test.txt";
703 std.Io.Dir.cwd().deleteFile(io, path) catch {};
704 defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
705
706 try init(.{ .output_file = path, .min_level = .Off, .mirror_to_stderr = false });
707 log(.Debug, "no", .{});
708 log(.Info, "no", .{});
709 log(.Warning, "no", .{});
710 log(.Error, "no", .{});
711 log(.Fatal, "not even this", .{});
712 deinit();
713
714 var read_buf: [256]u8 = undefined;
715 const file = try std.Io.Dir.cwd().openFile(io, path, .{});
716 defer file.close(io);
717 const n = try file.readPositionalAll(io, &read_buf, 0);
718 try testing.expectEqual(@as(usize, 0), n);
719}
720
721test "off is the highest level, so the ordering check silences by itself" {
722 // Placed last in the enum on purpose: `level >= min_level` then does the
723 // work and no other branch has to know the concept exists.
724 const values = std.enums.values(LogLevel);
725 try testing.expectEqual(LogLevel.Off, values[values.len - 1]);
726 for (values) |level| {
727 if (level == .Off) continue;
728 try testing.expect(@backingInt(level) < @backingInt(LogLevel.Off));
729 }
730}
731
732test "the file never receives colour, even when the terminal is getting it" {
733 // Both sinks used to be handed the same bytes, so a run with --log-file
734 // either wrote ANSI escapes into the file or turned colour off for the
735 // terminal too. A log full of \x1b[2m is unreadable to grep.
736 //
737 // `stderr_is_tty` is forced, because a test process is piped and would
738 // otherwise produce no coloured line at all — the first version of this
739 // test passed against a deliberately reverted fix for exactly that reason.
740 const io = io_context.get();
741 const path = "craft-log-colour-test.txt";
742 std.Io.Dir.cwd().deleteFile(io, path) catch {};
743 defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
744
745 try init(.{
746 .output_file = path,
747 .min_level = .Debug,
748 .enable_colors = true, // asked for, and still must not reach the file
749 .mirror_to_stderr = false,
750 });
751 stderr_is_tty = true; // after init, which computes it
752 defer stderr_is_tty = false;
753 log(.Error, "a red line on a terminal", .{});
754 deinit();
755
756 var read_buf: [1024]u8 = undefined;
757 const file = try std.Io.Dir.cwd().openFile(io, path, .{});
758 defer file.close(io);
759 const n = try file.readPositionalAll(io, &read_buf, 0);
760 const contents = read_buf[0..n];
761
762 try testing.expect(std.mem.indexOf(u8, contents, "a red line on a terminal") != null);
763 try testing.expect(std.mem.indexOfScalar(u8, contents, 0x1B) == null);
764}