diff --git a/.github/workflows/gesttalt.yml b/.github/workflows/gesttalt.yml index 9a80e61..4fe33c5 100644 --- a/.github/workflows/gesttalt.yml +++ b/.github/workflows/gesttalt.yml @@ -18,7 +18,7 @@ jobs: uses: jdx/mise-action@v2 - name: Check formatting - run: zig fmt --check src/ + run: find src -name '*.zig' -not -path 'src/templates/*' -print0 | xargs -0 zig fmt --check build: name: Build (${{ matrix.target }}) diff --git a/SPEC.md b/SPEC.md index e339ce6..ac015bf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -451,6 +451,257 @@ gesttalt snippets [OPTIONS] - `-b, --body ` - Snippet body (env: `GESTTALT_SNIPPET_BODY`) - `-f, --filename ` - Display filename (env: `GESTTALT_SNIPPET_FILENAME`) +#### `gesttalt post` + +Manage blog posts. + +**Usage:** +```bash +gesttalt post [OPTIONS] +``` + +**Commands:** +- `create` - Create a new blog post +- `list` - List blog posts +- `read` - Read a blog post +- `update` - Update a blog post +- `delete` - Delete a blog post + +##### `gesttalt post create` + +Create a new blog post. + +**Usage:** +```bash +gesttalt post create [OPTIONS] +``` + +**Options:** +- `--title ` - Post title (required) +- `--description <DESCRIPTION>` - Post description (required) +- `--tags <TAG1,TAG2>` - Comma-separated tags (optional) +- `--date <YYYY-MM-DD>` - Publication date (default: today) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_POST_TITLE` - Post title +- `GESTTALT_POST_DESCRIPTION` - Post description +- `GESTTALT_POST_TAGS` - Comma-separated tags +- `GESTTALT_POST_DATE` - Publication date +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +**Behavior:** +- Creates `content/blog/YYYY/MM/DD/` +- Creates `<slug>.md` with TOML frontmatter +- Opens the file in `$EDITOR` when available + +##### `gesttalt post list` + +List blog posts. + +**Usage:** +```bash +gesttalt post list [OPTIONS] +``` + +**Options:** +- `--format <FORMAT>` - Output format: `table`, `json`, `csv`, `toon` (default: `table`) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_POST_LIST_FORMAT` - Output format +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +**Formats:** +- `toon` - Newline-delimited JSON objects (one post per line) + +##### `gesttalt post read` + +Read a blog post by slug. + +**Usage:** +```bash +gesttalt post read <slug> [OPTIONS] +``` + +**Options:** +- `--format <FORMAT>` - Output format: `markdown`, `json` (default: `markdown`) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_POST_READ_FORMAT` - Output format +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +##### `gesttalt post update` + +Update a blog post by slug. + +**Usage:** +```bash +gesttalt post update <slug> [OPTIONS] +``` + +**Options:** +- `--title <TITLE>` - Update title +- `--description <DESCRIPTION>` - Update description +- `--tags <TAG1,TAG2>` - Replace tags +- `--add-tags <TAG1,TAG2>` - Add tags +- `--remove-tags <TAG1,TAG2>` - Remove tags +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_POST_TITLE` - Update title +- `GESTTALT_POST_DESCRIPTION` - Update description +- `GESTTALT_POST_TAGS` - Replace tags +- `GESTTALT_POST_ADD_TAGS` - Add tags +- `GESTTALT_POST_REMOVE_TAGS` - Remove tags +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +##### `gesttalt post delete` + +Delete a blog post by slug. + +**Usage:** +```bash +gesttalt post delete <slug> [OPTIONS] +``` + +**Options:** +- `-f, --force` - Skip confirmation prompt +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_POST_FORCE` - Skip confirmation prompt +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +#### `gesttalt note` + +Manage notes. + +**Usage:** +```bash +gesttalt note <command> [OPTIONS] +``` + +**Commands:** +- `create` - Create a new note +- `list` - List notes +- `read` - Read a note +- `update` - Update a note +- `delete` - Delete a note + +##### `gesttalt note create` + +Create a new note. + +**Usage:** +```bash +gesttalt note create [OPTIONS] +``` + +**Options:** +- `-t, --timestamp <I64>` - Unix timestamp (default: now) +- `--slug <STR>` - Slug override (optional) +- `--body <STR>` - Note body (optional) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_NOTE_TIMESTAMP` - Unix timestamp +- `GESTTALT_NOTE_SLUG` - Slug override +- `GESTTALT_NOTE_BODY` - Note body +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +**Behavior:** +- Creates `content/notes/{timestamp}.md` +- Writes optional TOML frontmatter for the slug override +- Opens the file in `$EDITOR` when no body is provided + +##### `gesttalt note list` + +List notes. + +**Usage:** +```bash +gesttalt note list [OPTIONS] +``` + +**Options:** +- `--format <FORMAT>` - Output format: `table`, `json`, `csv`, `toon` (default: `table`) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_NOTE_LIST_FORMAT` - Output format +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +**Formats:** +- `toon` - Newline-delimited JSON objects (one note per line) + +##### `gesttalt note read` + +Read a note by id (timestamp or slug). + +**Usage:** +```bash +gesttalt note read <id> [OPTIONS] +``` + +**Options:** +- `--format <FORMAT>` - Output format: `markdown`, `json` (default: `markdown`) +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_NOTE_READ_FORMAT` - Output format +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +##### `gesttalt note update` + +Update a note by id (timestamp or slug). + +**Usage:** +```bash +gesttalt note update <id> [OPTIONS] +``` + +**Options:** +- `--slug <STR>` - Update slug +- `--clear-slug` - Remove slug override +- `--body <STR>` - Update body +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_NOTE_SLUG` - Update slug +- `GESTTALT_NOTE_CLEAR_SLUG` - Remove slug override +- `GESTTALT_NOTE_BODY` - Update body +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + +##### `gesttalt note delete` + +Delete a note by id (timestamp or slug). + +**Usage:** +```bash +gesttalt note delete <id> [OPTIONS] +``` + +**Options:** +- `-f, --force` - Skip confirmation prompt +- `-d, --dir <DIR>` - Project directory (default: current directory) +- `-h, --help` - Display help message + +**Environment Variables:** +- `GESTTALT_NOTE_FORCE` - Skip confirmation prompt +- `GESTTALT_DIR` - Project directory (overridden by `--dir`) + + ### Global Options ```bash diff --git a/build.zig b/build.zig index b5cc037..1f88fe1 100644 --- a/build.zig +++ b/build.zig @@ -76,6 +76,7 @@ pub fn build(b: *std.Build) void { }, }), }); + lib_tests.linkLibC(); const run_lib_tests = b.addRunArtifact(lib_tests); const exe_tests = b.addTest(.{ @@ -90,6 +91,7 @@ pub fn build(b: *std.Build) void { }, }), }); + exe_tests.linkLibC(); const run_exe_tests = b.addRunArtifact(exe_tests); const test_step = b.step("test", "Run unit tests"); diff --git a/build.zig.zon b/build.zig.zon index 6fea144..877fc2a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ .{ .name = .gesttalt, .version = "0.1.0", - .fingerprint = 0xd40e2bbb31f5f844, + .fingerprint = 0xd40e2bbb254b513f, .minimum_zig_version = "0.15.0", .paths = .{ "build.zig", diff --git a/src/cli/common.zig b/src/cli/common.zig new file mode 100644 index 0000000..f257b6b --- /dev/null +++ b/src/cli/common.zig @@ -0,0 +1,306 @@ +const std = @import("std"); +const post_frontmatter = @import("../core/post_frontmatter.zig"); + +pub fn getProjectDir(allocator: std.mem.Allocator, arg_value: ?[]const u8) ![]const u8 { + if (arg_value) |dir| { + return try allocator.dupe(u8, dir); + } + return std.process.getEnvVarOwned(allocator, "GESTTALT_DIR") catch try allocator.dupe(u8, "."); +} + +pub fn getPort(allocator: std.mem.Allocator, arg_value: ?u16) u16 { + if (arg_value) |port| { + return port; + } + const env_port = std.process.getEnvVarOwned(allocator, "GESTTALT_PORT") catch return 3000; + defer allocator.free(env_port); + return std.fmt.parseInt(u16, env_port, 10) catch 3000; +} + +pub fn getOptionalStringOption(allocator: std.mem.Allocator, arg_value: ?[]const u8, env_name: []const u8) !?[]const u8 { + if (arg_value) |value| { + return try allocator.dupe(u8, value); + } + return std.process.getEnvVarOwned(allocator, env_name) catch null; +} + +pub fn getBoolEnv(allocator: std.mem.Allocator, env_name: []const u8) bool { + const env_value = std.process.getEnvVarOwned(allocator, env_name) catch return false; + defer allocator.free(env_value); + const trimmed = std.mem.trim(u8, env_value, " \t\r\n"); + return std.ascii.eqlIgnoreCase(trimmed, "1") or + std.ascii.eqlIgnoreCase(trimmed, "true") or + std.ascii.eqlIgnoreCase(trimmed, "yes") or + std.ascii.eqlIgnoreCase(trimmed, "y"); +} + +pub fn getOptionalIntOption(allocator: std.mem.Allocator, arg_value: ?i64, env_name: []const u8) ?i64 { + if (arg_value) |value| return value; + const env_value = std.process.getEnvVarOwned(allocator, env_name) catch return null; + defer allocator.free(env_value); + return std.fmt.parseInt(i64, env_value, 10) catch null; +} + +pub const DateParts = struct { + year: u16, + month: u8, + day: u8, +}; + +pub const DateStrings = struct { + year: []const u8, + month: []const u8, + day: []const u8, +}; + +pub fn parseDateParts(input: []const u8) ?DateParts { + var parts = std.mem.splitScalar(u8, input, '-'); + const year_str = parts.next() orelse return null; + const month_str = parts.next() orelse return null; + const day_str = parts.next() orelse return null; + if (parts.next() != null) return null; + if (year_str.len != 4 or month_str.len != 2 or day_str.len != 2) return null; + + const year = std.fmt.parseInt(u16, year_str, 10) catch return null; + const month = std.fmt.parseInt(u8, month_str, 10) catch return null; + const day = std.fmt.parseInt(u8, day_str, 10) catch return null; + if (month < 1 or month > 12) return null; + const month_enum: std.time.epoch.Month = @enumFromInt(month); + const max_day = std.time.epoch.getDaysInMonth(year, month_enum); + if (day < 1 or day > max_day) return null; + + return .{ .year = year, .month = month, .day = day }; +} + +pub fn getTodayDateParts() DateParts { + const ts = std.time.timestamp(); + const secs = if (ts < 0) @as(u64, 0) else @as(u64, @intCast(ts)); + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = secs }; + const year_day = epoch_seconds.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + return .{ + .year = year_day.year, + .month = month_day.month.numeric(), + .day = @as(u8, @intCast(month_day.day_index + 1)), + }; +} + +pub fn dateStringsFromParts(allocator: std.mem.Allocator, parts: DateParts) !DateStrings { + return .{ + .year = try std.fmt.allocPrint(allocator, "{d:0>4}", .{parts.year}), + .month = try std.fmt.allocPrint(allocator, "{d:0>2}", .{parts.month}), + .day = try std.fmt.allocPrint(allocator, "{d:0>2}", .{parts.day}), + }; +} + +pub fn freeDateStrings(allocator: std.mem.Allocator, dates: DateStrings) void { + allocator.free(dates.year); + allocator.free(dates.month); + allocator.free(dates.day); +} + +pub fn parseTagsList(allocator: std.mem.Allocator, input: []const u8) ![]const []const u8 { + var tags = std.ArrayList([]const u8){}; + errdefer { + for (tags.items) |tag| allocator.free(tag); + tags.deinit(allocator); + } + + var iter = std.mem.splitScalar(u8, input, ','); + while (iter.next()) |raw| { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0) continue; + try tags.append(allocator, try allocator.dupe(u8, trimmed)); + } + + return tags.toOwnedSlice(allocator); +} + +pub fn freeTagsList(allocator: std.mem.Allocator, tags: []const []const u8) void { + for (tags) |tag| allocator.free(tag); + if (tags.len > 0) allocator.free(tags); +} + +fn appendTomlEscaped(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value: []const u8) !void { + for (value) |ch| { + if (ch == '\\' or ch == '"') { + try out.append(allocator, '\\'); + } + try out.append(allocator, ch); + } +} + +fn appendTomlField(out: *std.ArrayList(u8), allocator: std.mem.Allocator, key: []const u8, value: []const u8) !void { + try out.appendSlice(allocator, key); + try out.appendSlice(allocator, " = \""); + try appendTomlEscaped(out, allocator, value); + try out.appendSlice(allocator, "\"\n"); +} + +pub fn buildPostFrontmatter( + allocator: std.mem.Allocator, + title: []const u8, + description: []const u8, + tags: []const []const u8, + slug: ?[]const u8, +) ![]const u8 { + var out = std.ArrayList(u8){}; + errdefer out.deinit(allocator); + + try out.appendSlice(allocator, "+++\n"); + try appendTomlField(&out, allocator, "title", title); + try appendTomlField(&out, allocator, "description", description); + if (slug) |value| { + try appendTomlField(&out, allocator, "slug", value); + } + if (tags.len > 0) { + try out.appendSlice(allocator, "tags = ["); + for (tags, 0..) |tag, idx| { + if (idx > 0) try out.appendSlice(allocator, ", "); + try out.append(allocator, '"'); + try appendTomlEscaped(&out, allocator, tag); + try out.append(allocator, '"'); + } + try out.appendSlice(allocator, "]\n"); + } + try out.appendSlice(allocator, "+++\n"); + + return out.toOwnedSlice(allocator); +} + +pub fn buildNoteFrontmatter(allocator: std.mem.Allocator, slug: ?[]const u8) ![]const u8 { + if (slug == null) { + return allocator.dupe(u8, ""); + } + + var out = std.ArrayList(u8){}; + errdefer out.deinit(allocator); + + try out.appendSlice(allocator, "+++\n"); + try appendTomlField(&out, allocator, "slug", slug.?); + try out.appendSlice(allocator, "+++\n"); + + return out.toOwnedSlice(allocator); +} + +pub fn splitPostContent(source: []const u8) post_frontmatter.ParseError![]const u8 { + const delimiter = "+++"; + if (!std.mem.startsWith(u8, source, delimiter)) { + return post_frontmatter.ParseError.MissingFrontmatter; + } + const after_first = source[delimiter.len..]; + const closing_pos = std.mem.indexOf(u8, after_first, delimiter) orelse { + return post_frontmatter.ParseError.UnclosedFrontmatter; + }; + const body_start = delimiter.len + closing_pos + delimiter.len; + if (body_start >= source.len) return ""; + return source[body_start..]; +} + +pub fn openEditor(allocator: std.mem.Allocator, path: []const u8) !void { + const editor = std.process.getEnvVarOwned(allocator, "EDITOR") catch return; + defer allocator.free(editor); + + var args = std.ArrayList([]const u8){}; + defer args.deinit(allocator); + + var iter = std.mem.tokenizeAny(u8, editor, " \t"); + while (iter.next()) |token| { + try args.append(allocator, token); + } + if (args.items.len == 0) return; + try args.append(allocator, path); + + var child = std.process.Child.init(args.items, allocator); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + _ = try child.spawnAndWait(); +} + +pub fn formatTags(allocator: std.mem.Allocator, tags: []const []const u8) ![]const u8 { + if (tags.len == 0) { + return allocator.dupe(u8, ""); + } + var out = std.ArrayList(u8){}; + errdefer out.deinit(allocator); + for (tags, 0..) |tag, idx| { + if (idx > 0) try out.append(allocator, ','); + try out.appendSlice(allocator, tag); + } + return out.toOwnedSlice(allocator); +} + +pub fn writeJsonString(writer: anytype, value: []const u8) !void { + try writer.writeByte('"'); + for (value) |ch| { + switch (ch) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => { + if (ch < 0x20) { + try writer.print("\\u{X:0>4}", .{ch}); + } else { + try writer.writeByte(ch); + } + }, + } + } + try writer.writeByte('"'); +} + +pub fn writeCsvField(writer: anytype, value: []const u8) !void { + try writer.writeByte('"'); + for (value) |ch| { + if (ch == '"') { + try writer.writeAll("\"\""); + } else { + try writer.writeByte(ch); + } + } + try writer.writeByte('"'); +} + +test "parseTagsList trims and skips empty" { + const allocator = std.testing.allocator; + const tags = try parseTagsList(allocator, " zig, ,static-site, ,zig "); + defer freeTagsList(allocator, tags); + + try std.testing.expectEqual(@as(usize, 3), tags.len); + try std.testing.expectEqualStrings("zig", tags[0]); + try std.testing.expectEqualStrings("static-site", tags[1]); + try std.testing.expectEqualStrings("zig", tags[2]); +} + +test "formatTags joins with commas" { + const allocator = std.testing.allocator; + const tags = &[_][]const u8{ "zig", "notes" }; + const result = try formatTags(allocator, tags); + defer allocator.free(result); + + try std.testing.expectEqualStrings("zig,notes", result); +} + +test "buildPostFrontmatter includes tags" { + const allocator = std.testing.allocator; + const tags = &[_][]const u8{ "zig", "static" }; + const fm = try buildPostFrontmatter(allocator, "Title", "Desc", tags, null); + defer allocator.free(fm); + + try std.testing.expect(std.mem.containsAtLeast(u8, fm, 1, "title = \"Title\"")); + try std.testing.expect(std.mem.containsAtLeast(u8, fm, 1, "description = \"Desc\"")); + try std.testing.expect(std.mem.containsAtLeast(u8, fm, 1, "tags = [\"zig\", \"static\"]")); +} + +test "splitPostContent extracts body" { + const source = "+++\n" ++ + "title = \"Hello\"\n" ++ + "description = \"World\"\n" ++ + "+++\n" ++ + "Body"; + const body = try splitPostContent(source); + try std.testing.expectEqualStrings("Body", body); +} diff --git a/src/cli/logger.zig b/src/cli/logger.zig new file mode 100644 index 0000000..c2c8517 --- /dev/null +++ b/src/cli/logger.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const Logger = struct { + // ANSI colors - will be no-op on platforms without terminal support + const Color = if (builtin.os.tag == .wasi or builtin.os.tag == .emscripten) + struct { + const reset = ""; + const blue = ""; + const green = ""; + const red = ""; + const yellow = ""; + const dim = ""; + } + else + struct { + const reset = "\x1b[0m"; + const blue = "\x1b[34m"; + const green = "\x1b[32m"; + const red = "\x1b[31m"; + const yellow = "\x1b[33m"; + const dim = "\x1b[2m"; + }; + + pub fn info(comptime fmt: []const u8, args: anytype) void { + std.debug.print(Color.blue ++ "[INFO]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); + } + + pub fn success(comptime fmt: []const u8, args: anytype) void { + std.debug.print(Color.green ++ "[ OK ]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); + } + + pub fn err(comptime fmt: []const u8, args: anytype) void { + std.debug.print(Color.red ++ "[ERROR]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); + } + + pub fn request(status: u16, path: []const u8) void { + if (status == 200) { + std.debug.print(Color.green ++ "[ OK ]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); + } else if (status == 404) { + std.debug.print(Color.red ++ "[FAIL]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); + } else { + std.debug.print(Color.yellow ++ "[WARN]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); + } + } +}; diff --git a/src/cli/note_cmd.zig b/src/cli/note_cmd.zig new file mode 100644 index 0000000..a899695 --- /dev/null +++ b/src/cli/note_cmd.zig @@ -0,0 +1,547 @@ +const std = @import("std"); +const clap = @import("clap"); +const notes = @import("../core/notes.zig"); +const note_frontmatter = @import("../core/note_frontmatter.zig"); +const post_frontmatter = @import("../core/post_frontmatter.zig"); +const Config = @import("../core/config.zig").Config; +const common = @import("common.zig"); +const logger = @import("logger.zig"); + +const Logger = logger.Logger; + +const NoteCommand = enum { + create, + list, + read, + update, + delete, + help, +}; + +const note_parsers = .{ + .command = clap.parsers.enumeration(NoteCommand), +}; + +pub const NoteListFormat = enum { + table, + json, + csv, + toon, +}; + +pub const NoteReadFormat = enum { + markdown, + json, +}; + +pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const note_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\<command> Note command (create, list, read, update, delete) + \\ + ); + + var note_res = clap.parseEx(clap.Help, ¬e_params, note_parsers, iter, .{ + .diagnostic = diag, + .allocator = allocator, + .terminating_positional = 0, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer note_res.deinit(); + + if (note_res.args.help != 0) { + printHelp(); + return; + } + + const note_cmd = note_res.positionals[0] orelse { + printHelp(); + return; + }; + + switch (note_cmd) { + .create => try runNoteCreate(allocator, iter, diag), + .list => try runNoteList(allocator, iter, diag), + .read => try runNoteRead(allocator, iter, diag), + .update => try runNoteUpdate(allocator, iter, diag), + .delete => try runNoteDelete(allocator, iter, diag), + .help => printHelp(), + } +} + +fn printHelp() void { + std.debug.print( + \\gesttalt note - Manage notes + \\ + \\Usage: gesttalt note <command> [options] + \\ + \\Commands: + \\ create Create a new note + \\ list List notes + \\ read Read a note + \\ update Update a note + \\ delete Delete a note + \\ + \\Run 'gesttalt note <command> --help' for more information on a command. + \\ + , .{}); +} + +fn parseNoteListFormat(value: []const u8) ?NoteListFormat { + if (std.ascii.eqlIgnoreCase(value, "table")) return .table; + if (std.ascii.eqlIgnoreCase(value, "json")) return .json; + if (std.ascii.eqlIgnoreCase(value, "csv")) return .csv; + if (std.ascii.eqlIgnoreCase(value, "toon")) return .toon; + return null; +} + +fn parseNoteReadFormat(value: []const u8) ?NoteReadFormat { + if (std.ascii.eqlIgnoreCase(value, "markdown")) return .markdown; + if (std.ascii.eqlIgnoreCase(value, "json")) return .json; + return null; +} + +fn noteMatchesId(note: *notes.Note, id: []const u8) bool { + if (std.mem.eql(u8, note.id, id)) return true; + const basename = std.fs.path.basename(note.source_path); + if (std.mem.endsWith(u8, basename, ".md")) { + const timestamp = basename[0 .. basename.len - 3]; + if (std.mem.eql(u8, timestamp, id)) return true; + } + return false; +} + +fn findNoteById(notes_list: *std.ArrayList(*notes.Note), id: []const u8) ?*notes.Note { + for (notes_list.items) |note| { + if (noteMatchesId(note, id)) return note; + } + return null; +} + +fn runNoteCreate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const create_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-t, --timestamp <i64> Unix timestamp (default: now, env: GESTTALT_NOTE_TIMESTAMP) + \\--slug <str> Slug override (optional, env: GESTTALT_NOTE_SLUG) + \\--body <str> Note body (optional, env: GESTTALT_NOTE_BODY) + \\ + ); + + var create_res = clap.parseEx(clap.Help, &create_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer create_res.deinit(); + + if (create_res.args.help != 0) { + std.debug.print("Usage: gesttalt note create [options]\n\nCreate a new note.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &create_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, create_res.args.dir); + defer allocator.free(project_dir); + + const timestamp = common.getOptionalIntOption(allocator, create_res.args.timestamp, "GESTTALT_NOTE_TIMESTAMP") orelse std.time.timestamp(); + if (timestamp < 0) { + Logger.err("Timestamp must be a positive Unix timestamp", .{}); + return; + } + + const slug_opt = try common.getOptionalStringOption(allocator, create_res.args.slug, "GESTTALT_NOTE_SLUG"); + defer if (slug_opt) |value| allocator.free(value); + if (slug_opt) |slug| { + if (!post_frontmatter.isValidSlug(slug)) { + Logger.err("Invalid slug '{s}' (use letters, numbers, '-' or '_')", .{slug}); + return; + } + } + + const body_opt = try common.getOptionalStringOption(allocator, create_res.args.body, "GESTTALT_NOTE_BODY"); + defer if (body_opt) |value| allocator.free(value); + + const notes_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "notes" }); + defer allocator.free(notes_dir); + try std.fs.cwd().makePath(notes_dir); + + const filename = try std.fmt.allocPrint(allocator, "{d}.md", .{timestamp}); + defer allocator.free(filename); + const file_path = try std.fs.path.join(allocator, &.{ notes_dir, filename }); + defer allocator.free(file_path); + + const frontmatter = try common.buildNoteFrontmatter(allocator, slug_opt); + defer allocator.free(frontmatter); + + const file = std.fs.cwd().createFile(file_path, .{ .exclusive = true }) catch |err| { + if (err == error.PathAlreadyExists) { + Logger.err("Note already exists at {s}", .{file_path}); + return; + } + return err; + }; + defer file.close(); + + if (frontmatter.len > 0) { + try file.writeAll(frontmatter); + } + if (body_opt) |body| { + if (frontmatter.len > 0) { + try file.writeAll("\n"); + } + try file.writeAll(body); + } + + Logger.success("Note created: {s}", .{file_path}); + if (body_opt == null) { + try common.openEditor(allocator, file_path); + } +} + +fn runNoteList(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const list_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--format <str> Output format: table, json, csv, toon (default: table, env: GESTTALT_NOTE_LIST_FORMAT) + \\ + ); + + var list_res = clap.parseEx(clap.Help, &list_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer list_res.deinit(); + + if (list_res.args.help != 0) { + std.debug.print("Usage: gesttalt note list [options]\n\nList notes.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &list_params, .{}) catch {}; + return; + } + + const format_opt = try common.getOptionalStringOption(allocator, list_res.args.format, "GESTTALT_NOTE_LIST_FORMAT"); + defer if (format_opt) |value| allocator.free(value); + const format = if (format_opt) |value| parseNoteListFormat(value) orelse { + Logger.err("Invalid format '{s}' (use table, json, csv, toon)", .{value}); + return; + } else .table; + + const project_dir = try common.getProjectDir(allocator, list_res.args.dir); + defer allocator.free(project_dir); + const notes_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "notes" }); + defer allocator.free(notes_dir); + + var notes_list = try notes.loadNotes(allocator, notes_dir); + defer { + for (notes_list.items) |note| { + note.deinit(); + allocator.destroy(note); + } + notes_list.deinit(allocator); + } + + const stdout = std.fs.File.stdout().deprecatedWriter(); + switch (format) { + .table => { + try stdout.writeAll("DATE\tID\tSLUG\n"); + for (notes_list.items) |note| { + const slug = note.frontmatter.slug orelse ""; + try stdout.print("{s}\t{s}\t{s}\n", .{ note.date, note.id, slug }); + } + }, + .json => { + try stdout.writeAll("["); + for (notes_list.items, 0..) |note, idx| { + if (idx > 0) try stdout.writeAll(","); + try writeNoteJson(stdout, note); + } + try stdout.writeAll("]\n"); + }, + .csv => { + try stdout.writeAll("date,id,slug\n"); + for (notes_list.items) |note| { + const slug = note.frontmatter.slug orelse ""; + try common.writeCsvField(stdout, note.date); + try stdout.writeAll(","); + try common.writeCsvField(stdout, note.id); + try stdout.writeAll(","); + try common.writeCsvField(stdout, slug); + try stdout.writeAll("\n"); + } + }, + .toon => { + for (notes_list.items) |note| { + try writeNoteJson(stdout, note); + try stdout.writeAll("\n"); + } + }, + } +} + +fn writeNoteJson(writer: anytype, note: *notes.Note) !void { + try writer.writeAll("{\"date\":"); + try common.writeJsonString(writer, note.date); + try writer.writeAll(",\"id\":"); + try common.writeJsonString(writer, note.id); + try writer.writeAll(",\"slug\":"); + if (note.frontmatter.slug) |slug| { + try common.writeJsonString(writer, slug); + } else { + try writer.writeAll("null"); + } + try writer.writeAll("}"); +} + +fn runNoteRead(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const read_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--format <str> Output format: markdown, json (default: markdown, env: GESTTALT_NOTE_READ_FORMAT) + \\<str> Note id (timestamp or slug) + \\ + ); + + var read_res = clap.parseEx(clap.Help, &read_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer read_res.deinit(); + + if (read_res.args.help != 0) { + std.debug.print("Usage: gesttalt note read <id> [options]\n\nRead a note.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &read_params, .{}) catch {}; + return; + } + + const id = read_res.positionals[0] orelse { + Logger.err("Note id is required", .{}); + return; + }; + + const format_opt = try common.getOptionalStringOption(allocator, read_res.args.format, "GESTTALT_NOTE_READ_FORMAT"); + defer if (format_opt) |value| allocator.free(value); + const format = if (format_opt) |value| parseNoteReadFormat(value) orelse { + Logger.err("Invalid format '{s}' (use markdown, json)", .{value}); + return; + } else .markdown; + + const project_dir = try common.getProjectDir(allocator, read_res.args.dir); + defer allocator.free(project_dir); + const notes_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "notes" }); + defer allocator.free(notes_dir); + + var notes_list = try notes.loadNotes(allocator, notes_dir); + defer { + for (notes_list.items) |note| { + note.deinit(); + allocator.destroy(note); + } + notes_list.deinit(allocator); + } + + const note = findNoteById(¬es_list, id) orelse { + Logger.err("Note not found: {s}", .{id}); + return; + }; + + const stdout = std.fs.File.stdout().deprecatedWriter(); + switch (format) { + .markdown => try stdout.writeAll(note.raw_source), + .json => { + try stdout.writeAll("{\"date\":"); + try common.writeJsonString(stdout, note.date); + try stdout.writeAll(",\"id\":"); + try common.writeJsonString(stdout, note.id); + try stdout.writeAll(",\"slug\":"); + if (note.frontmatter.slug) |slug| { + try common.writeJsonString(stdout, slug); + } else { + try stdout.writeAll("null"); + } + try stdout.writeAll(",\"content\":"); + try common.writeJsonString(stdout, note.markdown_content); + try stdout.writeAll("}\n"); + }, + } +} + +fn runNoteUpdate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const update_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--slug <str> Update slug (env: GESTTALT_NOTE_SLUG) + \\--clear-slug Remove slug override (env: GESTTALT_NOTE_CLEAR_SLUG) + \\--body <str> Update body (env: GESTTALT_NOTE_BODY) + \\<str> Note id (timestamp or slug) + \\ + ); + + var update_res = clap.parseEx(clap.Help, &update_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer update_res.deinit(); + + if (update_res.args.help != 0) { + std.debug.print("Usage: gesttalt note update <id> [options]\n\nUpdate a note.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &update_params, .{}) catch {}; + return; + } + + const id = update_res.positionals[0] orelse { + Logger.err("Note id is required", .{}); + return; + }; + + const slug_opt = try common.getOptionalStringOption(allocator, update_res.args.slug, "GESTTALT_NOTE_SLUG"); + defer if (slug_opt) |value| allocator.free(value); + if (slug_opt) |slug| { + if (!post_frontmatter.isValidSlug(slug)) { + Logger.err("Invalid slug '{s}' (use letters, numbers, '-' or '_')", .{slug}); + return; + } + } + const clear_slug = @field(update_res.args, "clear-slug") != 0 or common.getBoolEnv(allocator, "GESTTALT_NOTE_CLEAR_SLUG"); + if (clear_slug and slug_opt != null) { + Logger.err("Choose either --slug or --clear-slug", .{}); + return; + } + + const body_opt = try common.getOptionalStringOption(allocator, update_res.args.body, "GESTTALT_NOTE_BODY"); + defer if (body_opt) |value| allocator.free(value); + + if (slug_opt == null and !clear_slug and body_opt == null) { + Logger.err("No updates specified", .{}); + return; + } + + const project_dir = try common.getProjectDir(allocator, update_res.args.dir); + defer allocator.free(project_dir); + const notes_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "notes" }); + defer allocator.free(notes_dir); + + var notes_list = try notes.loadNotes(allocator, notes_dir); + defer { + for (notes_list.items) |note| { + note.deinit(); + allocator.destroy(note); + } + notes_list.deinit(allocator); + } + + const note = findNoteById(¬es_list, id) orelse { + Logger.err("Note not found: {s}", .{id}); + return; + }; + + const parsed = note_frontmatter.parse(allocator, note.raw_source) catch |err| { + Logger.err("Failed to parse note: {}", .{err}); + return; + }; + defer { + var fm = parsed.frontmatter; + fm.deinit(); + } + + const updated_slug = if (clear_slug) null else (slug_opt orelse parsed.frontmatter.slug); + const updated_body = body_opt orelse parsed.content; + + const frontmatter = try common.buildNoteFrontmatter(allocator, updated_slug); + defer allocator.free(frontmatter); + + const file = try std.fs.cwd().createFile(note.source_path, .{ .truncate = true }); + defer file.close(); + + if (frontmatter.len > 0) { + try file.writeAll(frontmatter); + } + if (updated_body.len > 0) { + if (frontmatter.len > 0) { + try file.writeAll("\n"); + } + try file.writeAll(updated_body); + } + + Logger.success("Note updated: {s}", .{note.source_path}); +} + +fn runNoteDelete(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const delete_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-f, --force Skip confirmation prompt (env: GESTTALT_NOTE_FORCE) + \\<str> Note id (timestamp or slug) + \\ + ); + + var delete_res = clap.parseEx(clap.Help, &delete_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer delete_res.deinit(); + + if (delete_res.args.help != 0) { + std.debug.print("Usage: gesttalt note delete <id> [options]\n\nDelete a note.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &delete_params, .{}) catch {}; + return; + } + + const id = delete_res.positionals[0] orelse { + Logger.err("Note id is required", .{}); + return; + }; + + const project_dir = try common.getProjectDir(allocator, delete_res.args.dir); + defer allocator.free(project_dir); + const notes_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "notes" }); + defer allocator.free(notes_dir); + + var notes_list = try notes.loadNotes(allocator, notes_dir); + defer { + for (notes_list.items) |note| { + note.deinit(); + allocator.destroy(note); + } + notes_list.deinit(allocator); + } + + const note = findNoteById(¬es_list, id) orelse { + Logger.err("Note not found: {s}", .{id}); + return; + }; + + const force = delete_res.args.force != 0 or common.getBoolEnv(allocator, "GESTTALT_NOTE_FORCE"); + if (!force) { + const stdout = std.fs.File.stdout().deprecatedWriter(); + try stdout.print("Delete note '{s}'? [y/N] ", .{note.id}); + const response = std.fs.File.stdin().deprecatedReader().readByte() catch return; + if (response != 'y' and response != 'Y') { + Logger.info("Aborted", .{}); + return; + } + } + + try std.fs.cwd().deleteFile(note.source_path); + Logger.success("Note deleted: {s}", .{note.source_path}); +} + +test "parseNoteListFormat supports toon" { + try std.testing.expectEqual(NoteListFormat.toon, parseNoteListFormat("toon").?); + try std.testing.expectEqual(NoteListFormat.csv, parseNoteListFormat("csv").?); +} diff --git a/src/cli/post_cmd.zig b/src/cli/post_cmd.zig new file mode 100644 index 0000000..099aef0 --- /dev/null +++ b/src/cli/post_cmd.zig @@ -0,0 +1,629 @@ +const std = @import("std"); +const clap = @import("clap"); +const posts = @import("../core/posts.zig"); +const post_frontmatter = @import("../core/post_frontmatter.zig"); +const Config = @import("../core/config.zig").Config; +const common = @import("common.zig"); +const logger = @import("logger.zig"); + +const Logger = logger.Logger; + +const PostCommand = enum { + create, + list, + read, + update, + delete, + help, +}; + +pub const PostListFormat = enum { + table, + json, + csv, + toon, +}; + +pub const PostReadFormat = enum { + markdown, + json, +}; + +const post_parsers = .{ + .command = clap.parsers.enumeration(PostCommand), +}; + +pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const post_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\<command> Post command (create, list, read, update, delete) + \\ + ); + + var post_res = clap.parseEx(clap.Help, &post_params, post_parsers, iter, .{ + .diagnostic = diag, + .allocator = allocator, + .terminating_positional = 0, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer post_res.deinit(); + + if (post_res.args.help != 0) { + printHelp(); + return; + } + + const post_cmd = post_res.positionals[0] orelse { + printHelp(); + return; + }; + + switch (post_cmd) { + .create => try runPostCreate(allocator, iter, diag), + .list => try runPostList(allocator, iter, diag), + .read => try runPostRead(allocator, iter, diag), + .update => try runPostUpdate(allocator, iter, diag), + .delete => try runPostDelete(allocator, iter, diag), + .help => printHelp(), + } +} + +fn printHelp() void { + std.debug.print( + \\gesttalt post - Manage blog posts + \\ + \\Usage: gesttalt post <command> [options] + \\ + \\Commands: + \\ create Create a new blog post + \\ list List blog posts + \\ read Read a blog post + \\ update Update a blog post + \\ delete Delete a blog post + \\ + \\Run 'gesttalt post <command> --help' for more information on a command. + \\ + , .{}); +} + +fn parsePostListFormat(value: []const u8) ?PostListFormat { + if (std.ascii.eqlIgnoreCase(value, "table")) return .table; + if (std.ascii.eqlIgnoreCase(value, "json")) return .json; + if (std.ascii.eqlIgnoreCase(value, "csv")) return .csv; + if (std.ascii.eqlIgnoreCase(value, "toon")) return .toon; + return null; +} + +fn parsePostReadFormat(value: []const u8) ?PostReadFormat { + if (std.ascii.eqlIgnoreCase(value, "markdown")) return .markdown; + if (std.ascii.eqlIgnoreCase(value, "json")) return .json; + return null; +} + +fn findPostBySlug(posts_list: *std.ArrayList(*posts.Post), slug: []const u8) ?*posts.Post { + for (posts_list.items) |post| { + if (std.mem.eql(u8, post.slug, slug)) return post; + } + return null; +} + +fn runPostCreate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const create_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\--title <str> Post title (required, env: GESTTALT_POST_TITLE) + \\--description <str> Post description (required, env: GESTTALT_POST_DESCRIPTION) + \\--tags <str> Comma-separated tags (optional, env: GESTTALT_POST_TAGS) + \\--date <str> Publication date YYYY-MM-DD (default: today, env: GESTTALT_POST_DATE) + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\<str> Post slug (required) + \\ + ); + + var create_res = clap.parseEx(clap.Help, &create_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer create_res.deinit(); + + if (create_res.args.help != 0) { + std.debug.print("Usage: gesttalt post create <slug> [options]\n\nCreate a new blog post.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &create_params, .{}) catch {}; + return; + } + + const slug = create_res.positionals[0] orelse { + Logger.err("Post slug is required", .{}); + return; + }; + if (!post_frontmatter.isValidSlug(slug)) { + Logger.err("Invalid slug '{s}' (use letters, numbers, '-' or '_')", .{slug}); + return; + } + + const title_opt = try common.getOptionalStringOption(allocator, create_res.args.title, "GESTTALT_POST_TITLE"); + defer if (title_opt) |value| allocator.free(value); + const description_opt = try common.getOptionalStringOption(allocator, create_res.args.description, "GESTTALT_POST_DESCRIPTION"); + defer if (description_opt) |value| allocator.free(value); + const tags_opt = try common.getOptionalStringOption(allocator, create_res.args.tags, "GESTTALT_POST_TAGS"); + defer if (tags_opt) |value| allocator.free(value); + const date_opt = try common.getOptionalStringOption(allocator, create_res.args.date, "GESTTALT_POST_DATE"); + defer if (date_opt) |value| allocator.free(value); + + const title = title_opt orelse { + Logger.err("Post title is required", .{}); + return; + }; + const description = description_opt orelse { + Logger.err("Post description is required", .{}); + return; + }; + + const project_dir = try common.getProjectDir(allocator, create_res.args.dir); + defer allocator.free(project_dir); + + const date_parts = if (date_opt) |value| common.parseDateParts(value) orelse { + Logger.err("Invalid date '{s}' (expected YYYY-MM-DD)", .{value}); + return; + } else common.getTodayDateParts(); + + const date_strings = try common.dateStringsFromParts(allocator, date_parts); + defer common.freeDateStrings(allocator, date_strings); + + const blog_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "blog" }); + defer allocator.free(blog_dir); + const post_dir = try std.fs.path.join(allocator, &.{ blog_dir, date_strings.year, date_strings.month, date_strings.day }); + defer allocator.free(post_dir); + try std.fs.cwd().makePath(post_dir); + + const filename = try std.fmt.allocPrint(allocator, "{s}.md", .{slug}); + defer allocator.free(filename); + const file_path = try std.fs.path.join(allocator, &.{ post_dir, filename }); + defer allocator.free(file_path); + + var tags_list: []const []const u8 = &.{}; + if (tags_opt) |value| { + tags_list = try common.parseTagsList(allocator, value); + defer common.freeTagsList(allocator, tags_list); + } + + const frontmatter = try common.buildPostFrontmatter(allocator, title, description, tags_list, null); + defer allocator.free(frontmatter); + + const file = std.fs.cwd().createFile(file_path, .{ .exclusive = true }) catch |err| { + if (err == error.PathAlreadyExists) { + Logger.err("Post already exists at {s}", .{file_path}); + return; + } + return err; + }; + defer file.close(); + + try file.writeAll(frontmatter); + try file.writeAll("\n"); + + Logger.success("Post created: {s}", .{file_path}); + try common.openEditor(allocator, file_path); +} + +fn runPostList(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const list_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--format <str> Output format: table, json, csv, toon (default: table, env: GESTTALT_POST_LIST_FORMAT) + \\ + ); + + var list_res = clap.parseEx(clap.Help, &list_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer list_res.deinit(); + + if (list_res.args.help != 0) { + std.debug.print("Usage: gesttalt post list [options]\n\nList blog posts.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &list_params, .{}) catch {}; + return; + } + + const format_opt = try common.getOptionalStringOption(allocator, list_res.args.format, "GESTTALT_POST_LIST_FORMAT"); + defer if (format_opt) |value| allocator.free(value); + const format = if (format_opt) |value| parsePostListFormat(value) orelse { + Logger.err("Invalid format '{s}' (use table, json, csv, toon)", .{value}); + return; + } else .table; + + const project_dir = try common.getProjectDir(allocator, list_res.args.dir); + defer allocator.free(project_dir); + const blog_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "blog" }); + defer allocator.free(blog_dir); + + var posts_list = try posts.loadPosts(allocator, blog_dir); + defer { + for (posts_list.items) |post| { + post.deinit(); + allocator.destroy(post); + } + posts_list.deinit(allocator); + } + + const stdout = std.fs.File.stdout().deprecatedWriter(); + switch (format) { + .table => { + try stdout.writeAll("DATE\tSLUG\tTITLE\tTAGS\n"); + for (posts_list.items) |post| { + const tags_joined = try common.formatTags(allocator, post.frontmatter.tags); + defer allocator.free(tags_joined); + try stdout.print("{s}\t{s}\t{s}\t{s}\n", .{ + post.date, + post.slug, + post.frontmatter.title, + tags_joined, + }); + } + }, + .json => { + try stdout.writeAll("["); + for (posts_list.items, 0..) |post, idx| { + if (idx > 0) try stdout.writeAll(","); + try writePostJson(stdout, post, false); + } + try stdout.writeAll("]\n"); + }, + .csv => { + try stdout.writeAll("date,slug,title,tags\n"); + for (posts_list.items) |post| { + const tags_joined = try common.formatTags(allocator, post.frontmatter.tags); + defer allocator.free(tags_joined); + try common.writeCsvField(stdout, post.date); + try stdout.writeAll(","); + try common.writeCsvField(stdout, post.slug); + try stdout.writeAll(","); + try common.writeCsvField(stdout, post.frontmatter.title); + try stdout.writeAll(","); + try common.writeCsvField(stdout, tags_joined); + try stdout.writeAll("\n"); + } + }, + .toon => { + for (posts_list.items) |post| { + try writePostJson(stdout, post, true); + try stdout.writeAll("\n"); + } + }, + } +} + +fn writePostJson(writer: anytype, post: *posts.Post, include_newline: bool) !void { + _ = include_newline; + try writer.writeAll("{\"date\":"); + try common.writeJsonString(writer, post.date); + try writer.writeAll(",\"slug\":"); + try common.writeJsonString(writer, post.slug); + try writer.writeAll(",\"title\":"); + try common.writeJsonString(writer, post.frontmatter.title); + try writer.writeAll(",\"tags\":["); + for (post.frontmatter.tags, 0..) |tag, tag_idx| { + if (tag_idx > 0) try writer.writeAll(","); + try common.writeJsonString(writer, tag); + } + try writer.writeAll("]}"); +} + +fn runPostRead(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const read_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--format <str> Output format: markdown, json (default: markdown, env: GESTTALT_POST_READ_FORMAT) + \\<str> Post slug (required) + \\ + ); + + var read_res = clap.parseEx(clap.Help, &read_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer read_res.deinit(); + + if (read_res.args.help != 0) { + std.debug.print("Usage: gesttalt post read <slug> [options]\n\nRead a blog post.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &read_params, .{}) catch {}; + return; + } + + const slug = read_res.positionals[0] orelse { + Logger.err("Post slug is required", .{}); + return; + }; + + const format_opt = try common.getOptionalStringOption(allocator, read_res.args.format, "GESTTALT_POST_READ_FORMAT"); + defer if (format_opt) |value| allocator.free(value); + const format = if (format_opt) |value| parsePostReadFormat(value) orelse { + Logger.err("Invalid format '{s}' (use markdown, json)", .{value}); + return; + } else .markdown; + + const project_dir = try common.getProjectDir(allocator, read_res.args.dir); + defer allocator.free(project_dir); + const blog_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "blog" }); + defer allocator.free(blog_dir); + + var posts_list = try posts.loadPosts(allocator, blog_dir); + defer { + for (posts_list.items) |post| { + post.deinit(); + allocator.destroy(post); + } + posts_list.deinit(allocator); + } + + const post = findPostBySlug(&posts_list, slug) orelse { + Logger.err("Post not found: {s}", .{slug}); + return; + }; + + const stdout = std.fs.File.stdout().deprecatedWriter(); + switch (format) { + .markdown => try stdout.writeAll(post.raw_source), + .json => { + try stdout.writeAll("{\"date\":"); + try common.writeJsonString(stdout, post.date); + try stdout.writeAll(",\"slug\":"); + try common.writeJsonString(stdout, post.slug); + try stdout.writeAll(",\"title\":"); + try common.writeJsonString(stdout, post.frontmatter.title); + try stdout.writeAll(",\"description\":"); + try common.writeJsonString(stdout, post.frontmatter.description); + try stdout.writeAll(",\"tags\":["); + for (post.frontmatter.tags, 0..) |tag, idx| { + if (idx > 0) try stdout.writeAll(","); + try common.writeJsonString(stdout, tag); + } + try stdout.writeAll("],\"content\":"); + try common.writeJsonString(stdout, post.markdown_content); + try stdout.writeAll("}\n"); + }, + } +} + +fn runPostUpdate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const update_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\--title <str> Update title (env: GESTTALT_POST_TITLE) + \\--description <str> Update description (env: GESTTALT_POST_DESCRIPTION) + \\--tags <str> Replace tags (env: GESTTALT_POST_TAGS) + \\--add-tags <str> Add tags (env: GESTTALT_POST_ADD_TAGS) + \\--remove-tags <str> Remove tags (env: GESTTALT_POST_REMOVE_TAGS) + \\<str> Post slug (required) + \\ + ); + + var update_res = clap.parseEx(clap.Help, &update_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer update_res.deinit(); + + if (update_res.args.help != 0) { + std.debug.print("Usage: gesttalt post update <slug> [options]\n\nUpdate a blog post.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &update_params, .{}) catch {}; + return; + } + + const slug = update_res.positionals[0] orelse { + Logger.err("Post slug is required", .{}); + return; + }; + + const title_opt = try common.getOptionalStringOption(allocator, update_res.args.title, "GESTTALT_POST_TITLE"); + defer if (title_opt) |value| allocator.free(value); + const description_opt = try common.getOptionalStringOption(allocator, update_res.args.description, "GESTTALT_POST_DESCRIPTION"); + defer if (description_opt) |value| allocator.free(value); + const tags_replace_opt = try common.getOptionalStringOption(allocator, update_res.args.tags, "GESTTALT_POST_TAGS"); + defer if (tags_replace_opt) |value| allocator.free(value); + const tags_add_opt = try common.getOptionalStringOption(allocator, update_res.args.@"add-tags", "GESTTALT_POST_ADD_TAGS"); + defer if (tags_add_opt) |value| allocator.free(value); + const tags_remove_opt = try common.getOptionalStringOption(allocator, update_res.args.@"remove-tags", "GESTTALT_POST_REMOVE_TAGS"); + defer if (tags_remove_opt) |value| allocator.free(value); + + if (title_opt == null and description_opt == null and tags_replace_opt == null and tags_add_opt == null and tags_remove_opt == null) { + Logger.err("No updates specified", .{}); + return; + } + + const project_dir = try common.getProjectDir(allocator, update_res.args.dir); + defer allocator.free(project_dir); + const blog_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "blog" }); + defer allocator.free(blog_dir); + + var posts_list = try posts.loadPosts(allocator, blog_dir); + defer { + for (posts_list.items) |post| { + post.deinit(); + allocator.destroy(post); + } + posts_list.deinit(allocator); + } + + const post = findPostBySlug(&posts_list, slug) orelse { + Logger.err("Post not found: {s}", .{slug}); + return; + }; + + const content = common.splitPostContent(post.raw_source) catch |err| { + Logger.err("Failed to parse post content: {}", .{err}); + return; + }; + + const title = title_opt orelse post.frontmatter.title; + const description = description_opt orelse post.frontmatter.description; + const slug_override = if (post.frontmatter.slug.len > 0) post.frontmatter.slug else null; + + var updated_tags = std.ArrayList([]const u8){}; + errdefer { + for (updated_tags.items) |tag| allocator.free(tag); + updated_tags.deinit(allocator); + } + + if (tags_replace_opt) |value| { + const tags = try common.parseTagsList(allocator, value); + defer common.freeTagsList(allocator, tags); + for (tags) |tag| { + try updated_tags.append(allocator, try allocator.dupe(u8, tag)); + } + } else { + for (post.frontmatter.tags) |tag| { + try updated_tags.append(allocator, try allocator.dupe(u8, tag)); + } + } + + if (tags_add_opt) |value| { + const tags = try common.parseTagsList(allocator, value); + defer common.freeTagsList(allocator, tags); + for (tags) |tag| { + var exists = false; + for (updated_tags.items) |existing| { + if (std.mem.eql(u8, existing, tag)) { + exists = true; + break; + } + } + if (!exists) { + try updated_tags.append(allocator, try allocator.dupe(u8, tag)); + } + } + } + + if (tags_remove_opt) |value| { + const tags = try common.parseTagsList(allocator, value); + defer common.freeTagsList(allocator, tags); + var idx: usize = 0; + while (idx < updated_tags.items.len) { + var remove = false; + for (tags) |tag| { + if (std.mem.eql(u8, updated_tags.items[idx], tag)) { + remove = true; + break; + } + } + if (remove) { + allocator.free(updated_tags.items[idx]); + _ = updated_tags.orderedRemove(idx); + } else { + idx += 1; + } + } + } + + const final_tags = updated_tags.toOwnedSlice(allocator) catch { + for (updated_tags.items) |tag| allocator.free(tag); + updated_tags.deinit(allocator); + return error.OutOfMemory; + }; + defer common.freeTagsList(allocator, final_tags); + + const frontmatter = try common.buildPostFrontmatter(allocator, title, description, final_tags, slug_override); + defer allocator.free(frontmatter); + + const file = try std.fs.cwd().createFile(post.source_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(frontmatter); + try file.writeAll(content); + + Logger.success("Post updated: {s}", .{post.source_path}); +} + +fn runPostDelete(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const delete_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-f, --force Skip confirmation prompt (env: GESTTALT_POST_FORCE) + \\<str> Post slug (required) + \\ + ); + + var delete_res = clap.parseEx(clap.Help, &delete_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer delete_res.deinit(); + + if (delete_res.args.help != 0) { + std.debug.print("Usage: gesttalt post delete <slug> [options]\n\nDelete a blog post.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &delete_params, .{}) catch {}; + return; + } + + const slug = delete_res.positionals[0] orelse { + Logger.err("Post slug is required", .{}); + return; + }; + + const project_dir = try common.getProjectDir(allocator, delete_res.args.dir); + defer allocator.free(project_dir); + const blog_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "blog" }); + defer allocator.free(blog_dir); + + var posts_list = try posts.loadPosts(allocator, blog_dir); + defer { + for (posts_list.items) |post| { + post.deinit(); + allocator.destroy(post); + } + posts_list.deinit(allocator); + } + + const post = findPostBySlug(&posts_list, slug) orelse { + Logger.err("Post not found: {s}", .{slug}); + return; + }; + + const force = delete_res.args.force != 0 or common.getBoolEnv(allocator, "GESTTALT_POST_FORCE"); + if (!force) { + const stdout = std.fs.File.stdout().deprecatedWriter(); + try stdout.print("Delete post '{s}'? [y/N] ", .{post.slug}); + const response = std.fs.File.stdin().deprecatedReader().readByte() catch return; + if (response != 'y' and response != 'Y') { + Logger.info("Aborted", .{}); + return; + } + } + + try std.fs.cwd().deleteFile(post.source_path); + + var current = std.fs.path.dirname(post.source_path); + var depth: u8 = 0; + while (current != null and depth < 3) : (depth += 1) { + const dir_path = current.?; + std.fs.cwd().deleteDir(dir_path) catch |err| switch (err) { + error.DirNotEmpty => return, + error.FileNotFound => return, + else => return err, + }; + current = std.fs.path.dirname(dir_path); + } + + Logger.success("Post deleted: {s}", .{post.source_path}); +} + +test "parsePostListFormat supports toon" { + try std.testing.expectEqual(PostListFormat.toon, parsePostListFormat("toon").?); + try std.testing.expectEqual(PostListFormat.json, parsePostListFormat("JSON").?); +} diff --git a/src/cli/snippet_cmd.zig b/src/cli/snippet_cmd.zig new file mode 100644 index 0000000..c57d60f --- /dev/null +++ b/src/cli/snippet_cmd.zig @@ -0,0 +1,300 @@ +const std = @import("std"); +const clap = @import("clap"); +const snippets = @import("../core/snippets.zig"); +const Config = @import("../core/config.zig").Config; +const common = @import("common.zig"); +const logger = @import("logger.zig"); + +const Logger = logger.Logger; + +const SnippetCommand = enum { list, create, read, update, delete, help }; + +const snippets_parsers = .{ + .subcommand = clap.parsers.enumeration(SnippetCommand), +}; + +pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const snippets_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\<subcommand> Subcommand (list, create, read, update, delete) + \\ + ); + + var snippets_res = clap.parseEx(clap.Help, &snippets_params, snippets_parsers, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer snippets_res.deinit(); + + if (snippets_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets <subcommand> [options]\n\nManage code snippets.\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &snippets_params, .{}) catch {}; + return; + } + + const subcmd = snippets_res.positionals[0] orelse { + std.debug.print("Usage: gesttalt snippets <subcommand> [options]\n", .{}); + return; + }; + + switch (subcmd) { + .list => try runList(allocator, iter, diag), + .create => try runCreate(allocator, iter, diag), + .read => try runRead(allocator, iter, diag), + .update => try runUpdate(allocator, iter, diag), + .delete => try runDelete(allocator, iter, diag), + .help => { + std.debug.print("Usage: gesttalt snippets <subcommand> [options]\n", .{}); + }, + } +} + +fn runList(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const list_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\ + ); + + var list_res = clap.parseEx(clap.Help, &list_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer list_res.deinit(); + + if (list_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets list [options]\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &list_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, list_res.args.dir); + defer allocator.free(project_dir); + + const snippets_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "snippets" }); + defer allocator.free(snippets_dir); + + var all_snippets = try snippets.loadSnippets(allocator, snippets_dir); + defer { + for (all_snippets.items) |snippet| { + snippet.deinit(); + allocator.destroy(snippet); + } + all_snippets.deinit(allocator); + } + + for (all_snippets.items) |snippet| { + std.debug.print("{s} - {s} ({s})\n", .{ snippet.id, snippet.frontmatter.description, snippet.filename }); + } +} + +fn runCreate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const create_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) + \\-D, --description <str> Snippet description (env: GESTTALT_SNIPPET_DESCRIPTION) + \\-b, --body <str> Snippet body (env: GESTTALT_SNIPPET_BODY) + \\-f, --filename <str> Display filename (env: GESTTALT_SNIPPET_FILENAME) + \\ + ); + + var create_res = clap.parseEx(clap.Help, &create_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer create_res.deinit(); + + if (create_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets create [options]\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &create_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, create_res.args.dir); + defer allocator.free(project_dir); + + const timestamp = common.getOptionalIntOption(allocator, create_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { + Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); + return; + }; + + const description = try common.getOptionalStringOption(allocator, create_res.args.description, "GESTTALT_SNIPPET_DESCRIPTION"); + defer if (description) |value| allocator.free(value); + if (description == null) { + Logger.err("Description is required (env: GESTTALT_SNIPPET_DESCRIPTION)", .{}); + return; + } + + const body = try common.getOptionalStringOption(allocator, create_res.args.body, "GESTTALT_SNIPPET_BODY"); + defer if (body) |value| allocator.free(value); + if (body == null) { + Logger.err("Body is required (env: GESTTALT_SNIPPET_BODY)", .{}); + return; + } + + const filename = try common.getOptionalStringOption(allocator, create_res.args.filename, "GESTTALT_SNIPPET_FILENAME"); + defer if (filename) |value| allocator.free(value); + if (filename == null) { + Logger.err("Filename is required (env: GESTTALT_SNIPPET_FILENAME)", .{}); + return; + } + + const created_path = try snippets.createSnippet( + allocator, + project_dir, + timestamp, + description.?, + body.?, + filename.?, + ); + defer allocator.free(created_path); + + Logger.success("Snippet created at {s}", .{created_path}); +} + +fn runRead(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const read_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) + \\ + ); + + var read_res = clap.parseEx(clap.Help, &read_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer read_res.deinit(); + + if (read_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets read [options]\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &read_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, read_res.args.dir); + defer allocator.free(project_dir); + + const timestamp = common.getOptionalIntOption(allocator, read_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { + Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); + return; + }; + + const snippet = try snippets.readSnippet(allocator, project_dir, timestamp); + defer { + snippet.deinit(); + allocator.destroy(snippet); + } + + std.debug.print("id: {s}\n", .{snippet.id}); + std.debug.print("date: {s}\n", .{snippet.date}); + std.debug.print("description: {s}\n", .{snippet.frontmatter.description}); + std.debug.print("filename: {s}\n\n", .{snippet.filename}); + std.debug.print("{s}\n", .{snippet.code_content}); +} + +fn runUpdate(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const update_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) + \\-D, --description <str> Snippet description (env: GESTTALT_SNIPPET_DESCRIPTION) + \\-b, --body <str> Snippet body (env: GESTTALT_SNIPPET_BODY) + \\-f, --filename <str> Display filename (env: GESTTALT_SNIPPET_FILENAME) + \\ + ); + + var update_res = clap.parseEx(clap.Help, &update_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer update_res.deinit(); + + if (update_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets update [options]\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &update_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, update_res.args.dir); + defer allocator.free(project_dir); + + const timestamp = common.getOptionalIntOption(allocator, update_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { + Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); + return; + }; + + const description = try common.getOptionalStringOption(allocator, update_res.args.description, "GESTTALT_SNIPPET_DESCRIPTION"); + defer if (description) |value| allocator.free(value); + + const body = try common.getOptionalStringOption(allocator, update_res.args.body, "GESTTALT_SNIPPET_BODY"); + defer if (body) |value| allocator.free(value); + + const filename = try common.getOptionalStringOption(allocator, update_res.args.filename, "GESTTALT_SNIPPET_FILENAME"); + defer if (filename) |value| allocator.free(value); + + if (description == null and body == null and filename == null) { + Logger.err("No updates provided", .{}); + return; + } + + const update = snippets.SnippetUpdate{ + .description = description, + .body = body, + .filename = filename, + }; + + try snippets.updateSnippet(allocator, project_dir, timestamp, update); + Logger.success("Snippet updated", .{}); +} + +fn runDelete(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, diag: *clap.Diagnostic) !void { + const delete_params = comptime clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) + \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) + \\ + ); + + var delete_res = clap.parseEx(clap.Help, &delete_params, clap.parsers.default, iter, .{ + .diagnostic = diag, + .allocator = allocator, + }) catch |err| { + diag.reportToFile(.stderr(), err) catch {}; + return; + }; + defer delete_res.deinit(); + + if (delete_res.args.help != 0) { + std.debug.print("Usage: gesttalt snippets delete [options]\n\nOptions:\n", .{}); + clap.helpToFile(std.fs.File.stderr(), clap.Help, &delete_params, .{}) catch {}; + return; + } + + const project_dir = try common.getProjectDir(allocator, delete_res.args.dir); + defer allocator.free(project_dir); + + const timestamp = common.getOptionalIntOption(allocator, delete_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { + Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); + return; + }; + + try snippets.deleteSnippet(allocator, project_dir, timestamp); + Logger.success("Snippet deleted", .{}); +} diff --git a/src/core/post_frontmatter.zig b/src/core/post_frontmatter.zig index 8d49a95..76ba565 100644 --- a/src/core/post_frontmatter.zig +++ b/src/core/post_frontmatter.zig @@ -64,7 +64,7 @@ pub const ParseResult = struct { /// Validate that a slug contains only URL-safe characters /// Valid characters: a-z, A-Z, 0-9, hyphen, underscore -fn isValidSlug(slug: []const u8) bool { +pub fn isValidSlug(slug: []const u8) bool { if (slug.len == 0) return false; for (slug) |c| { const valid = (c >= 'a' and c <= 'z') or @@ -394,3 +394,11 @@ test "frontmatter empty tags array - no memory leaks" { try std.testing.expectEqual(@as(usize, 0), result.frontmatter.tags.len); } + +test "isValidSlug validates slugs" { + try std.testing.expect(isValidSlug("hello-world")); + try std.testing.expect(isValidSlug("Hello_World-123")); + try std.testing.expect(!isValidSlug("")); + try std.testing.expect(!isValidSlug("invalid slug")); + try std.testing.expect(!isValidSlug("invalid/slug")); +} diff --git a/src/main.zig b/src/main.zig index 33abb36..68e7d75 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,59 +5,22 @@ const Config = @import("core/config.zig").Config; const dev_server = @import("core/dev_server.zig"); const http_server = @import("core/http_server.zig"); const init = @import("core/init.zig"); -const snippets = @import("core/snippets.zig"); +const common = @import("cli/common.zig"); +const logger = @import("cli/logger.zig"); +const note_cmd = @import("cli/note_cmd.zig"); +const post_cmd = @import("cli/post_cmd.zig"); +const snippet_cmd = @import("cli/snippet_cmd.zig"); +const Logger = logger.Logger; const version = "0.1.0"; -const Logger = struct { - // ANSI colors - will be no-op on platforms without terminal support - const Color = if (@import("builtin").os.tag == .wasi or @import("builtin").os.tag == .emscripten) - struct { - const reset = ""; - const blue = ""; - const green = ""; - const red = ""; - const yellow = ""; - const dim = ""; - } - else - struct { - const reset = "\x1b[0m"; - const blue = "\x1b[34m"; - const green = "\x1b[32m"; - const red = "\x1b[31m"; - const yellow = "\x1b[33m"; - const dim = "\x1b[2m"; - }; - - fn info(comptime fmt: []const u8, args: anytype) void { - std.debug.print(Color.blue ++ "[INFO]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); - } - - fn success(comptime fmt: []const u8, args: anytype) void { - std.debug.print(Color.green ++ "[ OK ]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); - } - - fn err(comptime fmt: []const u8, args: anytype) void { - std.debug.print(Color.red ++ "[ERROR]" ++ Color.reset ++ " " ++ fmt ++ "\n", args); - } - - fn request(status: u16, path: []const u8) void { - if (status == 200) { - std.debug.print(Color.green ++ "[ OK ]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); - } else if (status == 404) { - std.debug.print(Color.red ++ "[FAIL]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); - } else { - std.debug.print(Color.yellow ++ "[WARN]" ++ Color.reset ++ " {d} " ++ Color.dim ++ "{s}" ++ Color.reset ++ "\n", .{ status, path }); - } - } -}; - const Command = enum { build, dev, init, snippets, + post, + note, version, help, }; @@ -68,40 +31,10 @@ const main_parsers = .{ const main_params = clap.parseParamsComptime( \\-h, --help Display this help and exit. - \\<command> Command to run (build, dev, init, snippets, version) + \\<command> Command to run (build, dev, init, snippets, post, note, version) \\ ); -fn getProjectDir(allocator: std.mem.Allocator, arg_value: ?[]const u8) ![]const u8 { - if (arg_value) |dir| { - return try allocator.dupe(u8, dir); - } - return std.process.getEnvVarOwned(allocator, "GESTTALT_DIR") catch try allocator.dupe(u8, "."); -} - -fn getPort(allocator: std.mem.Allocator, arg_value: ?u16) u16 { - if (arg_value) |port| { - return port; - } - const env_port = std.process.getEnvVarOwned(allocator, "GESTTALT_PORT") catch return 3000; - defer allocator.free(env_port); - return std.fmt.parseInt(u16, env_port, 10) catch 3000; -} - -fn getEnvString(allocator: std.mem.Allocator, arg_value: ?[]const u8, env_name: []const u8) !?[]const u8 { - if (arg_value) |value| { - return try allocator.dupe(u8, value); - } - return std.process.getEnvVarOwned(allocator, env_name) catch null; -} - -fn getEnvInt(allocator: std.mem.Allocator, arg_value: ?i64, env_name: []const u8) ?i64 { - if (arg_value) |value| return value; - const env_value = std.process.getEnvVarOwned(allocator, env_name) catch return null; - defer allocator.free(env_value); - return std.fmt.parseInt(i64, env_value, 10) catch null; -} - pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -157,7 +90,7 @@ pub fn main() !void { return; } - const project_dir = try getProjectDir(allocator, build_res.args.dir); + const project_dir = try common.getProjectDir(allocator, build_res.args.dir); defer allocator.free(project_dir); try runBuild(allocator, project_dir); }, @@ -184,9 +117,9 @@ pub fn main() !void { return; } - const project_dir = try getProjectDir(allocator, dev_res.args.dir); + const project_dir = try common.getProjectDir(allocator, dev_res.args.dir); defer allocator.free(project_dir); - const port = getPort(allocator, dev_res.args.port); + const port = common.getPort(allocator, dev_res.args.port); try runDev(allocator, project_dir, port); }, .init => { @@ -231,284 +164,9 @@ pub fn main() !void { Logger.info(" cd {s}", .{name}); Logger.info(" gesttalt dev", .{}); }, - .snippets => { - const SnippetCommand = enum { list, create, read, update, delete }; - - const snippets_parsers = .{ - .subcommand = clap.parsers.enumeration(SnippetCommand), - }; - - const snippets_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\<subcommand> Subcommand (list, create, read, update, delete) - \\ - ); - - var snippets_res = clap.parseEx(clap.Help, &snippets_params, snippets_parsers, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer snippets_res.deinit(); - - if (snippets_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets <subcommand> [options]\n\nManage code snippets.\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &snippets_params, .{}) catch {}; - return; - } - - const subcmd = snippets_res.positionals[0] orelse { - std.debug.print("Usage: gesttalt snippets <subcommand> [options]\n", .{}); - return; - }; - - switch (subcmd) { - .list => { - const list_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) - \\ - ); - - var list_res = clap.parseEx(clap.Help, &list_params, clap.parsers.default, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer list_res.deinit(); - - if (list_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets list [options]\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &list_params, .{}) catch {}; - return; - } - - const project_dir = try getProjectDir(allocator, list_res.args.dir); - defer allocator.free(project_dir); - - const snippets_dir = try std.fs.path.join(allocator, &.{ project_dir, Config.content_dir, "snippets" }); - defer allocator.free(snippets_dir); - - var all_snippets = try snippets.loadSnippets(allocator, snippets_dir); - defer { - for (all_snippets.items) |snippet| { - snippet.deinit(); - allocator.destroy(snippet); - } - all_snippets.deinit(allocator); - } - - for (all_snippets.items) |snippet| { - std.debug.print("{s} - {s} ({s})\n", .{ snippet.id, snippet.frontmatter.description, snippet.filename }); - } - }, - .create => { - const create_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) - \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) - \\-D, --description <str> Snippet description (env: GESTTALT_SNIPPET_DESCRIPTION) - \\-b, --body <str> Snippet body (env: GESTTALT_SNIPPET_BODY) - \\-f, --filename <str> Display filename (env: GESTTALT_SNIPPET_FILENAME) - \\ - ); - - var create_res = clap.parseEx(clap.Help, &create_params, clap.parsers.default, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer create_res.deinit(); - - if (create_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets create [options]\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &create_params, .{}) catch {}; - return; - } - - const project_dir = try getProjectDir(allocator, create_res.args.dir); - defer allocator.free(project_dir); - - const timestamp = getEnvInt(allocator, create_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { - Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); - return; - }; - - const description = try getEnvString(allocator, create_res.args.description, "GESTTALT_SNIPPET_DESCRIPTION"); - defer if (description) |value| allocator.free(value); - if (description == null) { - Logger.err("Description is required (env: GESTTALT_SNIPPET_DESCRIPTION)", .{}); - return; - } - - const body = try getEnvString(allocator, create_res.args.body, "GESTTALT_SNIPPET_BODY"); - defer if (body) |value| allocator.free(value); - if (body == null) { - Logger.err("Body is required (env: GESTTALT_SNIPPET_BODY)", .{}); - return; - } - - const filename = try getEnvString(allocator, create_res.args.filename, "GESTTALT_SNIPPET_FILENAME"); - defer if (filename) |value| allocator.free(value); - if (filename == null) { - Logger.err("Filename is required (env: GESTTALT_SNIPPET_FILENAME)", .{}); - return; - } - - const created_path = try snippets.createSnippet( - allocator, - project_dir, - timestamp, - description.?, - body.?, - filename.?, - ); - defer allocator.free(created_path); - - Logger.success("Snippet created at {s}", .{created_path}); - }, - .read => { - const read_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) - \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) - \\ - ); - - var read_res = clap.parseEx(clap.Help, &read_params, clap.parsers.default, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer read_res.deinit(); - - if (read_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets read [options]\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &read_params, .{}) catch {}; - return; - } - - const project_dir = try getProjectDir(allocator, read_res.args.dir); - defer allocator.free(project_dir); - - const timestamp = getEnvInt(allocator, read_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { - Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); - return; - }; - - const snippet = try snippets.readSnippet(allocator, project_dir, timestamp); - defer { - snippet.deinit(); - allocator.destroy(snippet); - } - - std.debug.print("id: {s}\n", .{snippet.id}); - std.debug.print("date: {s}\n", .{snippet.date}); - std.debug.print("description: {s}\n", .{snippet.frontmatter.description}); - std.debug.print("filename: {s}\n\n", .{snippet.filename}); - std.debug.print("{s}\n", .{snippet.code_content}); - }, - .update => { - const update_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) - \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) - \\-D, --description <str> Snippet description (env: GESTTALT_SNIPPET_DESCRIPTION) - \\-b, --body <str> Snippet body (env: GESTTALT_SNIPPET_BODY) - \\-f, --filename <str> Display filename (env: GESTTALT_SNIPPET_FILENAME) - \\ - ); - - var update_res = clap.parseEx(clap.Help, &update_params, clap.parsers.default, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer update_res.deinit(); - - if (update_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets update [options]\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &update_params, .{}) catch {}; - return; - } - - const project_dir = try getProjectDir(allocator, update_res.args.dir); - defer allocator.free(project_dir); - - const timestamp = getEnvInt(allocator, update_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { - Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); - return; - }; - - const description = try getEnvString(allocator, update_res.args.description, "GESTTALT_SNIPPET_DESCRIPTION"); - defer if (description) |value| allocator.free(value); - - const body = try getEnvString(allocator, update_res.args.body, "GESTTALT_SNIPPET_BODY"); - defer if (body) |value| allocator.free(value); - - const filename = try getEnvString(allocator, update_res.args.filename, "GESTTALT_SNIPPET_FILENAME"); - defer if (filename) |value| allocator.free(value); - - if (description == null and body == null and filename == null) { - Logger.err("No updates provided", .{}); - return; - } - - const update = snippets.SnippetUpdate{ - .description = description, - .body = body, - .filename = filename, - }; - - try snippets.updateSnippet(allocator, project_dir, timestamp, update); - Logger.success("Snippet updated", .{}); - }, - .delete => { - const delete_params = comptime clap.parseParamsComptime( - \\-h, --help Display this help and exit. - \\-d, --dir <str> Project directory (default: current directory, env: GESTTALT_DIR) - \\-t, --timestamp <i64> Unix timestamp (env: GESTTALT_SNIPPET_TIMESTAMP) - \\ - ); - - var delete_res = clap.parseEx(clap.Help, &delete_params, clap.parsers.default, &iter, .{ - .diagnostic = &diag, - .allocator = allocator, - }) catch |err| { - diag.reportToFile(.stderr(), err) catch {}; - return; - }; - defer delete_res.deinit(); - - if (delete_res.args.help != 0) { - std.debug.print("Usage: gesttalt snippets delete [options]\n\nOptions:\n", .{}); - clap.helpToFile(std.fs.File.stderr(), clap.Help, &delete_params, .{}) catch {}; - return; - } - - const project_dir = try getProjectDir(allocator, delete_res.args.dir); - defer allocator.free(project_dir); - - const timestamp = getEnvInt(allocator, delete_res.args.timestamp, "GESTTALT_SNIPPET_TIMESTAMP") orelse { - Logger.err("Timestamp is required (env: GESTTALT_SNIPPET_TIMESTAMP)", .{}); - return; - }; - - try snippets.deleteSnippet(allocator, project_dir, timestamp); - Logger.success("Snippet deleted", .{}); - }, - } - }, + .snippets => try snippet_cmd.run(allocator, &iter, &diag), + .post => try post_cmd.run(allocator, &iter, &diag), + .note => try note_cmd.run(allocator, &iter, &diag), .version => { std.debug.print("gesttalt v{s}\n", .{version}); }, @@ -529,6 +187,8 @@ fn printHelp() void { \\ dev Start development server with hot-reload \\ init Create a new Gesttalt project \\ snippets Manage code snippets + \\ post Manage blog posts (create, list, read, update, delete) + \\ note Manage notes (create, list, read, update, delete) \\ version Print version and exit \\ \\Run 'gesttalt <command> --help' for more information on a command. @@ -583,19 +243,19 @@ fn runDev(allocator: std.mem.Allocator, project_dir: []const u8, port: u16) !voi var reload_state = dev_server.HotReloadState{}; // Start file watcher thread - const logger = dev_server.Logger{ + const logger_iface = dev_server.Logger{ .err_fn = Logger.err, .info_fn = Logger.info, .success_fn = Logger.success, .request_fn = Logger.request, }; - const watcher_thread = try std.Thread.spawn(.{}, dev_server.watchFiles, .{ allocator, project_dir, &pipeline, &reload_state, logger }); + const watcher_thread = try std.Thread.spawn(.{}, dev_server.watchFiles, .{ allocator, project_dir, &pipeline, &reload_state, logger_iface }); watcher_thread.detach(); const output_path = try std.fs.path.join(allocator, &.{ project_dir, Config.build_dir }); defer allocator.free(output_path); - try http_server.serve(allocator, output_path, port, &reload_state, logger); + try http_server.serve(allocator, output_path, port, &reload_state, logger_iface); } test "main module compiles" {