diff --git a/README.md b/README.md index 3f4e442..56dee53 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Turn any git repository into your terminal notebook. - `zemo` — open the scratch memo (`/scratch.txt`). - `zemo ` — open `/topics/.md`. Created with a `# ` header on first use. - `zemo sync` — pull, stage, commit, and push the memo repository. +- `zemo ls` — list topic names from `/topics/` (alphabetical). - Auto pull-on-open / commit-on-close when `/.git` exists. - Conventional Commits messages: `docs(): YYYY-MM-DD HH:MM` for edits, `chore: sync ...` for manual sync. - Single static binary on Linux / macOS / Windows. @@ -98,6 +99,7 @@ Override the location with `ZEMO_DIR` (see [Configuration](#configuration)). zemo # open the scratch memo zemo journal # open ~/memo/topics/journal.md zemo sync # manual pull / commit / push +zemo ls # list topic names zemo --help zemo --version ``` @@ -106,12 +108,12 @@ Topic names must match `[a-zA-Z0-9_-]` (slashes, dots, spaces, and multibyte cha ## Configuration -| Variable | Purpose | Default | -|---|---|---| -| `ZEMO_DIR` | Memo directory root | `$HOME/memo` (Unix), `%USERPROFILE%\memo` (Windows) | -| `ZEMO_EDITOR` | Editor command (highest priority) | — | -| `VISUAL` | Editor command | — | -| `EDITOR` | Editor command | — | +| Variable | Purpose | Default | +| ------------- | --------------------------------- | --------------------------------------------------- | +| `ZEMO_DIR` | Memo directory root | `$HOME/memo` (Unix), `%USERPROFILE%\memo` (Windows) | +| `ZEMO_EDITOR` | Editor command (highest priority) | — | +| `VISUAL` | Editor command | — | +| `EDITOR` | Editor command | — | If none of the editor variables are set, `zemo` falls back to the first available editor in `PATH`: @@ -130,11 +132,11 @@ Editor strings are split on whitespace, so `ZEMO_EDITOR="code --wait"` works. Sh ## Exit codes -| Code | Meaning | -|---|---| -| 0 | Success (including "no local changes" on `sync`) | -| 1 | Runtime error (git failure, editor failure, missing editor, invalid topic name, …) | -| 2 | CLI usage error (too many arguments, etc.) | +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------------- | +| 0 | Success (including "no local changes" on `sync`) | +| 1 | Runtime error (git failure, editor failure, missing editor, invalid topic name, …) | +| 2 | CLI usage error (too many arguments, etc.) | ## Development diff --git a/src/cli.zig b/src/cli.zig index be49ab1..6ddd8d4 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -10,6 +10,7 @@ pub const Command = union(enum) { open_scratch, open_topic: []const u8, sync, + ls, help, version, too_many_args, @@ -20,6 +21,7 @@ pub fn parseArgs(args: []const []const u8) Command { if (args.len <= 1) return .open_scratch; const a = args[1]; if (std.mem.eql(u8, a, "sync")) return .sync; + if (std.mem.eql(u8, a, "ls")) return .ls; if (std.mem.eql(u8, a, "help") or std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return .help; if (std.mem.eql(u8, a, "version") or std.mem.eql(u8, a, "--version") or std.mem.eql(u8, a, "-V")) @@ -35,6 +37,7 @@ const HELP_TEXT = \\ zemo Open scratch memo \\ zemo Open topics/.md \\ zemo sync Manually pull/commit/push the memo repo + \\ zemo ls List topic names (alphabetical) \\ zemo help Show this help \\ zemo version Show version \\ @@ -78,6 +81,7 @@ pub fn run( .open_scratch => try openScratch(allocator, io, env, stderr), .open_topic => |t| try openTopic(allocator, io, env, stderr, t), .sync => try doSync(allocator, io, env, stderr, stdout), + .ls => try doLs(allocator, io, env, stdout), }; } @@ -87,7 +91,7 @@ fn openScratch( env: *const std.process.Environ.Map, stderr: *Io.Writer, ) !u8 { - const dir = try paths.memoDir(allocator, env); + const dir = try paths.memoDir(allocator, io, env); defer allocator.free(dir); try ensureDir(io, dir); @@ -119,7 +123,7 @@ fn openTopic( return 1; } - const dir = try paths.memoDir(allocator, env); + const dir = try paths.memoDir(allocator, io, env); defer allocator.free(dir); try ensureDir(io, dir); @@ -151,7 +155,7 @@ fn doSync( stderr: *Io.Writer, stdout: *Io.Writer, ) !u8 { - const dir = try paths.memoDir(allocator, env); + const dir = try paths.memoDir(allocator, io, env); defer allocator.free(dir); if (!git.isGitRepo(allocator, io, dir)) { @@ -180,6 +184,52 @@ fn doSync( return 0; } +/// lsコマンド実行関数 +fn doLs(allocator: std.mem.Allocator, io: Io, env: *const std.process.Environ.Map, stdout: *Io.Writer) !u8 { + const memo = try paths.memoDir(allocator, io, env); + defer allocator.free(memo); + + const topics_dir_path = try std.fs.path.join(allocator, &.{ memo, "topics" }); + defer allocator.free(topics_dir_path); + + return listTopics(allocator, io, topics_dir_path, stdout); +} + +fn listTopics(allocator: std.mem.Allocator, io: Io, topics_dir_path: []const u8, stdout: *Io.Writer) !u8 { + var topics_dir = Io.Dir.openDirAbsolute(io, topics_dir_path, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return 0, // topics/ 無し → 何も出さず終了 + else => return err, + }; + defer topics_dir.close(io); + + var names: std.ArrayList([]const u8) = .empty; + defer { + for (names.items) |s| allocator.free(s); + names.deinit(allocator); + } + + var it = topics_dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".md")) continue; + const stem = entry.name[0 .. entry.name.len - ".md".len]; + if (stem.len == 0) continue; // ".md" だけのファイルは弾く + + const owned = try allocator.dupe(u8, stem); + try names.append(allocator, owned); + } + + std.mem.sort([]const u8, names.items, {}, lessThanString); + for (names.items) |name| { + try stdout.print("{s}\n", .{name}); + } + return 0; +} + +fn lessThanString(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); +} + /// エディタ起動 → post-sync の共通フロー。 /// pre-sync (pull) は呼び出し側でファイル作成より前に実行済みであること。 fn editAndPostSync( @@ -268,6 +318,10 @@ test "parseArgs: sync subcommand" { try std.testing.expectEqual(Command.sync, parseArgs(&.{ "zemo", "sync" })); } +test "parseArgs: ls subcommand" { + try std.testing.expectEqual(Command.ls, parseArgs(&.{ "zemo", "ls" })); +} + test "parseArgs: help variants" { try std.testing.expectEqual(Command.help, parseArgs(&.{ "zemo", "help" })); try std.testing.expectEqual(Command.help, parseArgs(&.{ "zemo", "--help" })); @@ -283,3 +337,37 @@ test "parseArgs: version variants" { test "parseArgs: too many args" { try std.testing.expectEqual(Command.too_many_args, parseArgs(&.{ "zemo", "a", "b" })); } + +// ---- listTopics ---- +test "listTopics: alphabetical order, only .md files" { + const io = std.testing.io; + const a = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // topics/ + .md ファイル + ノイズを仕込む + try tmp.dir.createDir(io, "topics", .default_dir); + var topics = try tmp.dir.openDir(io, "topics", .{}); + defer topics.close(io); + + // ヘルパで .md / .txt / サブディレクトリを作る + inline for (.{ "foo.md", "bar.md", "ignore.txt" }) |name| { + const f = try topics.createFile(io, name, .{}); + f.close(io); + } + try topics.createDir(io, "should-skip-dir", .default_dir); + + // realPath で topics/ のフルパスを取る + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const len = try topics.realPath(io, &path_buf); + const topics_path = path_buf[0..len]; + + // in-memory writer + var alloc_w = std.Io.Writer.Allocating.init(a); + defer alloc_w.deinit(); + + _ = try listTopics(a, io, topics_path, &alloc_w.writer); + try alloc_w.writer.flush(); + + try std.testing.expectEqualStrings("bar\nfoo\n", alloc_w.written()); +} diff --git a/src/paths.zig b/src/paths.zig index d170f4a..8cd4f14 100644 --- a/src/paths.zig +++ b/src/paths.zig @@ -39,12 +39,22 @@ pub fn resolveMemoDir( return std.fs.path.join(allocator, &.{ h, "memo" }); } -/// I/O 側: 環境マップから値を読んで resolveMemoDir に渡すだけ -pub fn memoDir(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) ![]u8 { +/// I/O 側: 環境マップから値を読んで resolveMemoDir に渡し、絶対パス化する。 +pub fn memoDir(allocator: std.mem.Allocator, io: std.Io, env: *const std.process.Environ.Map) ![]u8 { const zemo_dir = env.get("ZEMO_DIR"); const home_var = if (@import("builtin").os.tag == .windows) "USERPROFILE" else "HOME"; const home = env.get(home_var); - return resolveMemoDir(allocator, zemo_dir, home); + const dir = try resolveMemoDir(allocator, zemo_dir, home); + defer allocator.free(dir); + return absolutePath(allocator, io, dir); +} + +fn absolutePath(allocator: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 { + if (std.fs.path.isAbsolute(path)) return allocator.dupe(u8, path); + + const cwd = try std.process.currentPathAlloc(io, allocator); + defer allocator.free(cwd); + return std.fs.path.resolve(allocator, &.{ cwd, path }); } test "isValidTopic: accepts alphanumerics, underscore, hyphen" { @@ -108,3 +118,21 @@ test "resolveMemoDir: errors when no env available" { const a = std.testing.allocator; try std.testing.expectError(error.HomeNotSet, resolveMemoDir(a, null, null)); } + +test "absolutePath: keeps absolute paths" { + const a = std.testing.allocator; + const cwd = try std.process.currentPathAlloc(std.testing.io, a); + defer a.free(cwd); + + const got = try absolutePath(a, std.testing.io, cwd); + defer a.free(got); + try std.testing.expectEqualStrings(cwd, got); +} + +test "absolutePath: resolves relative paths from cwd" { + const a = std.testing.allocator; + const got = try absolutePath(a, std.testing.io, "relative-memo"); + defer a.free(got); + try std.testing.expect(std.fs.path.isAbsolute(got)); + try std.testing.expect(std.mem.endsWith(u8, got, std.fs.path.sep_str ++ "relative-memo")); +}