Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Turn any git repository into your terminal notebook.
- `zemo` — open the scratch memo (`<memo>/scratch.txt`).
- `zemo <topic>` — open `<memo>/topics/<topic>.md`. Created with a `# <topic>` header on first use.
- `zemo sync` — pull, stage, commit, and push the memo repository.
- `zemo ls` — list topic names from `<memo>/topics/` (alphabetical).
- Auto pull-on-open / commit-on-close when `<memo>/.git` exists.
- Conventional Commits messages: `docs(<scope>): YYYY-MM-DD HH:MM` for edits, `chore: sync ...` for manual sync.
- Single static binary on Linux / macOS / Windows.
Expand Down Expand Up @@ -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
```
Expand All @@ -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`:

Expand All @@ -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

Expand Down
94 changes: 91 additions & 3 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub const Command = union(enum) {
open_scratch,
open_topic: []const u8,
sync,
ls,
help,
version,
too_many_args,
Expand All @@ -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"))
Expand All @@ -35,6 +37,7 @@ const HELP_TEXT =
\\ zemo Open scratch memo
\\ zemo <topic> Open topics/<topic>.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
\\
Expand Down Expand Up @@ -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),
};
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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" }));
Expand All @@ -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());
}
34 changes: 31 additions & 3 deletions src/paths.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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"));
}
Loading