ReviewOS

also looking at this

craft-native/craft

feat(log): --log-file, and a logger that survives being used

#78
Merged chris wants to merge feat/log-file into main
5 files +691 -37
packages/zig/src/cli.zigmodified+84-0
Changes to packages/zig/src/cli.zig
@@ -50,6 +50,15 @@ pub const WindowOptions = struct {
5050 // — so every app launched through the shared `craft` binary called itself
5151 // "craft". macOS only.
5252 app_name: ?[]const u8 = null,
53 // Write craft's own log to this file, in addition to stderr. Null leaves
54 // logging where it has always been: stderr only.
55 log_file: ?[]const u8 = null,
56 // Emit each record as a JSON object rather than a human-readable line.
57 log_json: bool = false,
58 // Lowest level that gets recorded: debug, info, warn, error, fatal.
59 log_level: ?[]const u8 = null,
60 // Send records only to the log file, leaving the terminal quiet.
61 log_quiet: bool = false,
5362 // Keep the process alive after the last window closes, instead of quitting.
5463 // Null means craft decides from the shape of the app: a tray or
5564 // menubar-only app stays, an ordinary windowed app goes. See
@@ -86,6 +95,8 @@ pub fn freeOptionStrings(allocator: std.mem.Allocator, options: *WindowOptions)
8695 if (options.icon) |s| allocator.free(s);
8796 if (options.app_name) |s| allocator.free(s);
8897 if (options.frame_autosave) |s| allocator.free(s);
98 if (options.log_file) |s| allocator.free(s);
99 if (options.log_level) |s| allocator.free(s);
89100 if (options.eval_source) |s| allocator.free(s);
90101 if (options.eval_file) |s| allocator.free(s);
91102 options.* = WindowOptions{};
@@ -171,6 +182,9 @@ const BundleConfig = struct {
171182 titlebarHidden: ?bool = null,
172183 headless: ?bool = null,
173184 keepRunning: ?bool = null,
185 logFile: ?[]const u8 = null,
186 logJson: ?bool = null,
187 logLevel: ?[]const u8 = null,
174188};
175189
176190/// Absolute path of the running executable's directory.
@@ -245,6 +259,8 @@ fn loadBundleConfig(allocator: std.mem.Allocator) ?WindowOptions {
245259 if (cfg.title) |v| options.title = allocator.dupe(u8, v) catch options.title;
246260 if (cfg.appName) |v| options.app_name = allocator.dupe(u8, v) catch null;
247261 if (cfg.frameAutosave) |v| options.frame_autosave = allocator.dupe(u8, v) catch null;
262 if (cfg.logFile) |v| options.log_file = allocator.dupe(u8, v) catch null;
263 if (cfg.logLevel) |v| options.log_level = allocator.dupe(u8, v) catch null;
248264 if (cfg.icon) |v| options.icon = allocator.dupe(u8, v) catch null;
249265 applyScalarConfig(cfg, &options);
250266
@@ -271,6 +287,7 @@ fn applyScalarConfig(cfg: BundleConfig, options: *WindowOptions) void {
271287 if (cfg.titlebarHidden) |v| options.titlebar_hidden = v;
272288 if (cfg.headless) |v| options.headless = v;
273289 if (cfg.keepRunning) |v| options.keep_running = v;
290 if (cfg.logJson) |v| options.log_json = v;
274291 // A menubar app with a dock icon and no tray is not a menubar app, so the
275292 // one flag sets all three rather than making every manifest repeat them.
276293 if (cfg.menubarOnly) |v| {
@@ -497,6 +514,18 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind
497514 options.hide_dock_icon = true;
498515 } else if (std.mem.eql(u8, arg, "--headless")) {
499516 options.headless = true;
517 } else if (std.mem.eql(u8, arg, "--log-file")) {
518 i += 1;
519 if (i >= args.len) return CliError.MissingValue;
520 options.log_file = try allocator.dupe(u8, args[i]);
521 } else if (std.mem.eql(u8, arg, "--log-json")) {
522 options.log_json = true;
523 } else if (std.mem.eql(u8, arg, "--log-quiet")) {
524 options.log_quiet = true;
525 } else if (std.mem.eql(u8, arg, "--log-level")) {
526 i += 1;
527 if (i >= args.len) return CliError.MissingValue;
528 options.log_level = try allocator.dupe(u8, args[i]);
500529 } else if (std.mem.eql(u8, arg, "--keep-running")) {
501530 options.keep_running = true;
502531 } else if (std.mem.eql(u8, arg, "--quit-on-close")) {
@@ -606,6 +635,13 @@ fn printHelp() void {
606635 \\ --system-tray Show system tray icon
607636 \\ --hide-dock-icon Hide dock icon (menubar-only mode, macOS)
608637 \\ --menubar-only Menubar-only mode (no window, system tray only)
638 \\ --log-file <PATH> Append craft's own log to this file as well as
639 \\ stderr. Captures everything craft logs through
640 \\ std.log; debug-mode std.debug.print tracing is
641 \\ not included.
642 \\ --log-json One JSON object per record instead of a line.
643 \\ --log-level <LEVEL> debug, info, warn, error or fatal (default info).
644 \\ --log-quiet Write only to --log-file, leaving stderr alone.
609645 \\ --keep-running Stay running after the last window closes.
610646 \\ The default for tray and menubar-only apps;
611647 \\ an ordinary windowed app quits.
@@ -769,3 +805,51 @@ test "the last of the two flags wins" {
769805 const options = try parseArgs(std.testing.allocator, &args);
770806 try std.testing.expectEqual(@as(?bool, false), options.keep_running);
771807}
808
809test "the logging flags are parsed" {
810 var args = [_][:0]const u8{ "craft", "--log-file", "/tmp/craft.log", "--log-json", "--log-level", "debug", "--log-quiet" };
811 const options = try parseArgs(std.testing.allocator, &args);
812 defer {
813 var o = options;
814 freeOptionStrings(std.testing.allocator, &o);
815 }
816 try std.testing.expectEqualStrings("/tmp/craft.log", options.log_file.?);
817 try std.testing.expect(options.log_json);
818 try std.testing.expectEqualStrings("debug", options.log_level.?);
819 try std.testing.expect(options.log_quiet);
820}
821
822test "logging is off unless asked for" {
823 var args = [_][:0]const u8{"craft"};
824 const options = try parseArgs(std.testing.allocator, &args);
825 try std.testing.expectEqual(@as(?[]const u8, null), options.log_file);
826 try std.testing.expectEqual(@as(?[]const u8, null), options.log_level);
827 try std.testing.expect(!options.log_json);
828 try std.testing.expect(!options.log_quiet);
829}
830
831test "--log-file without a value is an error, not a silent default" {
832 var args = [_][:0]const u8{ "craft", "--log-file" };
833 try std.testing.expectError(CliError.MissingValue, parseArgs(std.testing.allocator, &args));
834}
835
836test "a manifest can ask for a log file" {
837 const source =
838 \\{"title":"Probe","logFile":"/var/log/app.log","logLevel":"warn","logJson":true}
839 ;
840 const parsed = try std.json.parseFromSlice(BundleConfig, std.testing.allocator, source, .{
841 .ignore_unknown_fields = true,
842 .allocate = .alloc_always,
843 });
844 defer parsed.deinit();
845
846 // The path and level are applied inside `loadBundleConfig`, which reads the
847 // filesystem; what is checkable here is that the manifest keys decode and
848 // that the scalar reaches the options.
849 try std.testing.expectEqualStrings("/var/log/app.log", parsed.value.logFile.?);
850 try std.testing.expectEqualStrings("warn", parsed.value.logLevel.?);
851
852 var options = WindowOptions{};
853 applyScalarConfig(parsed.value, &options);
854 try std.testing.expect(options.log_json);
855}