From 0d50e2c6e074a492a6c7f60c733951ed6272fef4 Mon Sep 17 00:00:00 2001 From: jmanai Date: Tue, 28 Apr 2026 10:16:38 +0200 Subject: [PATCH] feat(attachments): add upload/delete/list_attachments tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 MCP tools for binary file management on the Obsidian backend, for consumers that prefer an MCP-driven write path over direct filesystem fallback for attachments (PDFs, images, audio): - upload_attachment(path, contentBase64) — atomic temp+rename, parent dirs auto-created, .md rejected (use create_page). - delete_attachment(path) — .md rejected (use delete_page), error if missing or directory. - list_attachments(folder, recursive?) — non-.md filter, sorted by path, returns size + RFC3339 mtime. Tools sit on *vault.Client directly (Obsidian-only by design); not added to the backend.Backend interface — Logseq has no filesystem concept. Registered in server.go alongside the existing Obsidian "reload" tool via type-assertion (skipped in --read-only mode). Path traversal protection reuses safePath() so attachments inherit the same boundary checks as pages. Tests: 14 vault layer + 10 tools layer + end-to-end smoke (stdio JSON-RPC: initialize, tools/list shows 35 tools incl. 3 new, upload →list→delete→list-empty chain, .md rejection). Co-Authored-By: Claude Opus 4.7 (1M context) --- server.go | 15 +++ tools/attachments.go | 70 +++++++++++ tools/attachments_test.go | 160 +++++++++++++++++++++++++ types/tools.go | 16 +++ vault/attachments.go | 181 +++++++++++++++++++++++++++++ vault/attachments_test.go | 238 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 680 insertions(+) create mode 100644 tools/attachments.go create mode 100644 tools/attachments_test.go create mode 100644 vault/attachments.go create mode 100644 vault/attachments_test.go diff --git a/server.go b/server.go index 62c518c..2968307 100644 --- a/server.go +++ b/server.go @@ -302,6 +302,21 @@ func newServer(b backend.Backend, readOnly bool) *mcp.Server { Content: []mcp.Content{&mcp.TextContent{Text: "Vault reloaded successfully"}}, }, nil }) + + // --- Attachment tools (Obsidian-specific, write enabled) --- + attachments := tools.NewAttachments(vaultClient) + mcp.AddTool(srv, &mcp.Tool{ + Name: "upload_attachment", + Description: "Upload a binary file (PDF, image, audio, etc) to the vault at the given path. Parent directories are auto-created. The path must be relative to the vault root and cannot end in .md (use create_page for markdown). Content is base64-encoded.", + }, attachments.Upload) + mcp.AddTool(srv, &mcp.Tool{ + Name: "delete_attachment", + Description: "Delete a binary attachment by path (relative to vault root). Cannot delete .md files (use delete_page).", + }, attachments.Delete) + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_attachments", + Description: "List non-.md files (binaries, attachments) in a folder relative to vault root. Set recursive=true to walk sub-folders. Returns entries with path, size, and modification time.", + }, attachments.List) } return srv diff --git a/tools/attachments.go b/tools/attachments.go new file mode 100644 index 0000000..f655d43 --- /dev/null +++ b/tools/attachments.go @@ -0,0 +1,70 @@ +package tools + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/skridlevsky/graphthulhu/types" + "github.com/skridlevsky/graphthulhu/vault" +) + +// Attachments implements binary attachment MCP tools (Obsidian-only). +type Attachments struct { + client *vault.Client +} + +// NewAttachments creates a new Attachments tool handler. +func NewAttachments(c *vault.Client) *Attachments { + return &Attachments{client: c} +} + +// Upload writes a binary attachment to the vault. +func (a *Attachments) Upload(ctx context.Context, req *mcp.CallToolRequest, input types.UploadAttachmentInput) (*mcp.CallToolResult, any, error) { + if input.Path == "" { + return errorResult("path is required"), nil, nil + } + data, err := base64.StdEncoding.DecodeString(input.ContentBase64) + if err != nil { + return errorResult(fmt.Sprintf("invalid base64 content: %v", err)), nil, nil + } + result, err := a.client.UploadAttachment(input.Path, data) + if err != nil { + return errorResult(fmt.Sprintf("upload_attachment failed: %v", err)), nil, nil + } + res, err := jsonTextResult(result) + return res, nil, err +} + +// Delete removes a binary attachment from the vault. +func (a *Attachments) Delete(ctx context.Context, req *mcp.CallToolRequest, input types.DeleteAttachmentInput) (*mcp.CallToolResult, any, error) { + if input.Path == "" { + return errorResult("path is required"), nil, nil + } + if err := a.client.DeleteAttachment(input.Path); err != nil { + return errorResult(fmt.Sprintf("delete_attachment failed: %v", err)), nil, nil + } + res, err := jsonTextResult(map[string]any{ + "deleted": true, + "path": input.Path, + }) + return res, nil, err +} + +// List returns non-.md files in a folder. +func (a *Attachments) List(ctx context.Context, req *mcp.CallToolRequest, input types.ListAttachmentsInput) (*mcp.CallToolResult, any, error) { + if input.Folder == "" { + return errorResult("folder is required"), nil, nil + } + entries, err := a.client.ListAttachments(input.Folder, input.Recursive) + if err != nil { + return errorResult(fmt.Sprintf("list_attachments failed: %v", err)), nil, nil + } + res, err := jsonTextResult(map[string]any{ + "folder": input.Folder, + "count": len(entries), + "entries": entries, + }) + return res, nil, err +} diff --git a/tools/attachments_test.go b/tools/attachments_test.go new file mode 100644 index 0000000..3a8ba8c --- /dev/null +++ b/tools/attachments_test.go @@ -0,0 +1,160 @@ +package tools + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/skridlevsky/graphthulhu/types" + "github.com/skridlevsky/graphthulhu/vault" +) + +func newTestAttachments(t *testing.T) (*Attachments, *vault.Client) { + t.Helper() + dir := t.TempDir() + c := vault.New(dir) + return NewAttachments(c), c +} + +func resultText(t *testing.T, r *mcp.CallToolResult) string { + t.Helper() + if r == nil || len(r.Content) == 0 { + return "" + } + tc, ok := r.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content[0] is not TextContent: %T", r.Content[0]) + } + return tc.Text +} + +func TestAttachmentsUpload_HappyPath(t *testing.T) { + a, _ := newTestAttachments(t) + input := types.UploadAttachmentInput{ + Path: "Attach/note.png", + ContentBase64: base64.StdEncoding.EncodeToString([]byte("fake-png-bytes")), + } + res, _, err := a.Upload(context.Background(), nil, input) + if err != nil { + t.Fatalf("err: %v", err) + } + if res.IsError { + t.Fatalf("IsError=true: %s", resultText(t, res)) + } + text := resultText(t, res) + if !strings.Contains(text, `"path": "Attach/note.png"`) { + t.Errorf("response missing path: %s", text) + } + if !strings.Contains(text, `"size": 14`) { + t.Errorf("response missing size: %s", text) + } +} + +func TestAttachmentsUpload_InvalidBase64(t *testing.T) { + a, _ := newTestAttachments(t) + input := types.UploadAttachmentInput{ + Path: "Attach/note.png", + ContentBase64: "!!!not-base64!!!", + } + res, _, _ := a.Upload(context.Background(), nil, input) + if !res.IsError { + t.Fatal("IsError should be true on invalid base64") + } + if !strings.Contains(resultText(t, res), "invalid base64") { + t.Errorf("error message missing 'invalid base64': %s", resultText(t, res)) + } +} + +func TestAttachmentsUpload_EmptyPath(t *testing.T) { + a, _ := newTestAttachments(t) + res, _, _ := a.Upload(context.Background(), nil, types.UploadAttachmentInput{Path: ""}) + if !res.IsError { + t.Fatal("IsError should be true on empty path") + } +} + +func TestAttachmentsUpload_RejectsMarkdown(t *testing.T) { + a, _ := newTestAttachments(t) + input := types.UploadAttachmentInput{ + Path: "page.md", + ContentBase64: base64.StdEncoding.EncodeToString([]byte("x")), + } + res, _, _ := a.Upload(context.Background(), nil, input) + if !res.IsError { + t.Fatal("IsError should be true on .md path") + } + if !strings.Contains(resultText(t, res), ".md") { + t.Errorf("error message should mention .md: %s", resultText(t, res)) + } +} + +func TestAttachmentsDelete_HappyPath(t *testing.T) { + a, c := newTestAttachments(t) + if _, err := c.UploadAttachment("Attach/note.png", []byte("x")); err != nil { + t.Fatalf("seed: %v", err) + } + + res, _, err := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: "Attach/note.png"}) + if err != nil || res.IsError { + t.Fatalf("delete: err=%v isError=%v body=%s", err, res.IsError, resultText(t, res)) + } + if !strings.Contains(resultText(t, res), `"deleted": true`) { + t.Errorf("response missing deleted=true: %s", resultText(t, res)) + } +} + +func TestAttachmentsDelete_NotFound(t *testing.T) { + a, _ := newTestAttachments(t) + res, _, _ := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: "missing.bin"}) + if !res.IsError { + t.Fatal("IsError should be true when not found") + } +} + +func TestAttachmentsDelete_EmptyPath(t *testing.T) { + a, _ := newTestAttachments(t) + res, _, _ := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: ""}) + if !res.IsError { + t.Fatal("IsError should be true on empty path") + } +} + +func TestAttachmentsList_HappyPath(t *testing.T) { + a, c := newTestAttachments(t) + if _, err := c.UploadAttachment("Attach/a.png", []byte("a")); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := c.UploadAttachment("Attach/b.pdf", []byte("bb")); err != nil { + t.Fatalf("seed: %v", err) + } + + res, _, err := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: "Attach"}) + if err != nil || res.IsError { + t.Fatalf("list: err=%v isError=%v body=%s", err, res.IsError, resultText(t, res)) + } + text := resultText(t, res) + if !strings.Contains(text, `"count": 2`) { + t.Errorf("response missing count=2: %s", text) + } + if !strings.Contains(text, `"path": "Attach/a.png"`) || !strings.Contains(text, `"path": "Attach/b.pdf"`) { + t.Errorf("entries missing in response: %s", text) + } +} + +func TestAttachmentsList_EmptyFolder(t *testing.T) { + a, _ := newTestAttachments(t) + res, _, _ := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: ""}) + if !res.IsError { + t.Fatal("IsError should be true on empty folder") + } +} + +func TestAttachmentsList_FolderNotFound(t *testing.T) { + a, _ := newTestAttachments(t) + res, _, _ := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: "missing"}) + if !res.IsError { + t.Fatal("IsError should be true when folder missing") + } +} diff --git a/types/tools.go b/types/tools.go index 8adffe6..a80197e 100644 --- a/types/tools.go +++ b/types/tools.go @@ -216,3 +216,19 @@ type JournalSearchInput struct { From string `json:"from,omitempty" jsonschema:"Start date filter (YYYY-MM-DD)"` To string `json:"to,omitempty" jsonschema:"End date filter (YYYY-MM-DD)"` } + +// --- Attachment tool inputs (Obsidian-only) --- + +type UploadAttachmentInput struct { + Path string `json:"path" jsonschema:"Path relative to vault root, e.g. '00_Inbox/email-2026-04-28/Attachements/invoice.pdf'. Parent directories are auto-created. Cannot end in .md (use create_page for markdown)."` + ContentBase64 string `json:"contentBase64" jsonschema:"File content, base64-encoded (standard alphabet, not URL-safe)."` +} + +type DeleteAttachmentInput struct { + Path string `json:"path" jsonschema:"Path relative to vault root. Cannot end in .md (use delete_page)."` +} + +type ListAttachmentsInput struct { + Folder string `json:"folder" jsonschema:"Folder path relative to vault root. Required (no default global scan)."` + Recursive bool `json:"recursive,omitempty" jsonschema:"Walk sub-folders. Default: false."` +} diff --git a/vault/attachments.go b/vault/attachments.go new file mode 100644 index 0000000..0e7ef66 --- /dev/null +++ b/vault/attachments.go @@ -0,0 +1,181 @@ +package vault + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// AttachmentResult is returned by UploadAttachment. +type AttachmentResult struct { + Path string `json:"path"` + Size int64 `json:"size"` +} + +// AttachmentEntry is one item in a list_attachments response. +type AttachmentEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + Mtime string `json:"mtime"` +} + +// ErrAttachmentIsMarkdown is returned when an attachment operation targets a .md file. +var ErrAttachmentIsMarkdown = fmt.Errorf("attachment path cannot end in .md (use create_page/delete_page)") + +// UploadAttachment writes a binary file at relPath (relative to vault root). +// Parent directories are auto-created. Atomic via temp+rename. +// Refuses .md paths (use CreatePage instead). +func (c *Client) UploadAttachment(relPath string, data []byte) (*AttachmentResult, error) { + if strings.HasSuffix(strings.ToLower(relPath), ".md") { + return nil, ErrAttachmentIsMarkdown + } + + c.mu.Lock() + defer c.mu.Unlock() + + absPath, err := c.safePath(relPath) + if err != nil { + return nil, err + } + + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return nil, fmt.Errorf("create directory: %w", err) + } + + if err := atomicWriteBytes(absPath, data); err != nil { + return nil, fmt.Errorf("write attachment: %w", err) + } + + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("stat attachment: %w", err) + } + + return &AttachmentResult{ + Path: filepath.ToSlash(relPath), + Size: info.Size(), + }, nil +} + +// DeleteAttachment removes a binary file from the vault. +// Refuses .md paths (use DeletePage instead). +func (c *Client) DeleteAttachment(relPath string) error { + if strings.HasSuffix(strings.ToLower(relPath), ".md") { + return ErrAttachmentIsMarkdown + } + + c.mu.Lock() + defer c.mu.Unlock() + + absPath, err := c.safePath(relPath) + if err != nil { + return err + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("attachment not found: %s", relPath) + } + return fmt.Errorf("stat attachment: %w", err) + } + if info.IsDir() { + return fmt.Errorf("path is a directory, not a file: %s", relPath) + } + + if err := os.Remove(absPath); err != nil { + return fmt.Errorf("delete attachment: %w", err) + } + return nil +} + +// ListAttachments returns non-.md files under relFolder (relative to vault root). +// recursive=true walks sub-folders. Sorted by path. +func (c *Client) ListAttachments(relFolder string, recursive bool) ([]AttachmentEntry, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + absFolder, err := c.safePath(relFolder) + if err != nil { + return nil, err + } + + info, err := os.Stat(absFolder) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("folder not found: %s", relFolder) + } + return nil, fmt.Errorf("stat folder: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("path is not a directory: %s", relFolder) + } + + entries := []AttachmentEntry{} + + walk := func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return nil + } + if fi.IsDir() { + if !recursive && path != absFolder { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(strings.ToLower(fi.Name()), ".md") { + return nil + } + rel, err := filepath.Rel(c.vaultPath, path) + if err != nil { + return nil + } + entries = append(entries, AttachmentEntry{ + Path: filepath.ToSlash(rel), + Size: fi.Size(), + Mtime: fi.ModTime().UTC().Format(time.RFC3339), + }) + return nil + } + + if err := filepath.Walk(absFolder, walk); err != nil { + return nil, fmt.Errorf("walk folder: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + + return entries, nil +} + +// atomicWriteBytes writes raw bytes atomically: temp file in same dir, then rename. +func atomicWriteBytes(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + cleanup := func() { _ = os.Remove(tmpName) } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp file: %w", err) + } + + if err := os.Rename(tmpName, path); err != nil { + cleanup() + return fmt.Errorf("rename temp file: %w", err) + } + return nil +} diff --git a/vault/attachments_test.go b/vault/attachments_test.go new file mode 100644 index 0000000..be6a755 --- /dev/null +++ b/vault/attachments_test.go @@ -0,0 +1,238 @@ +package vault + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// rwVault returns a Client rooted at a fresh t.TempDir, suitable for +// tests that exercise UploadAttachment / DeleteAttachment / ListAttachments. +func rwVault(t *testing.T) *Client { + t.Helper() + dir := t.TempDir() + return New(dir) +} + +func TestUploadAttachment_HappyPath(t *testing.T) { + c := rwVault(t) + + res, err := c.UploadAttachment("00_Inbox/email-x/Attachements/invoice.pdf", []byte("PDF-1.4 fake")) + if err != nil { + t.Fatalf("upload: %v", err) + } + if res.Path != "00_Inbox/email-x/Attachements/invoice.pdf" { + t.Errorf("path = %q, want forward-slash relative path", res.Path) + } + if res.Size != int64(len("PDF-1.4 fake")) { + t.Errorf("size = %d, want %d", res.Size, len("PDF-1.4 fake")) + } + + abs := filepath.Join(c.vaultPath, "00_Inbox", "email-x", "Attachements", "invoice.pdf") + got, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != "PDF-1.4 fake" { + t.Errorf("content = %q", got) + } +} + +func TestUploadAttachment_AutoCreatesParent(t *testing.T) { + c := rwVault(t) + rel := "deep/nested/path/that/does/not/exist/file.bin" + + if _, err := c.UploadAttachment(rel, []byte{0x01, 0x02, 0x03}); err != nil { + t.Fatalf("upload: %v", err) + } + if _, err := os.Stat(filepath.Join(c.vaultPath, filepath.FromSlash(rel))); err != nil { + t.Fatalf("parent dirs not created: %v", err) + } +} + +func TestUploadAttachment_OverwritesIdempotently(t *testing.T) { + c := rwVault(t) + rel := "data/file.bin" + + if _, err := c.UploadAttachment(rel, []byte("first")); err != nil { + t.Fatalf("first upload: %v", err) + } + if _, err := c.UploadAttachment(rel, []byte("second-longer")); err != nil { + t.Fatalf("second upload: %v", err) + } + + got, err := os.ReadFile(filepath.Join(c.vaultPath, filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "second-longer" { + t.Errorf("content = %q, want second-longer (atomic replace)", got) + } +} + +func TestUploadAttachment_RejectsMarkdown(t *testing.T) { + c := rwVault(t) + + cases := []string{"foo.md", "deep/path/file.MD", "weird.Md"} + for _, p := range cases { + t.Run(p, func(t *testing.T) { + _, err := c.UploadAttachment(p, []byte("x")) + if !errors.Is(err, ErrAttachmentIsMarkdown) { + t.Errorf("err = %v, want ErrAttachmentIsMarkdown", err) + } + }) + } +} + +func TestUploadAttachment_RejectsTraversal(t *testing.T) { + c := rwVault(t) + + _, err := c.UploadAttachment("../escape.bin", []byte("x")) + if err == nil || !errors.Is(err, ErrPathEscape) { + t.Errorf("err = %v, want ErrPathEscape", err) + } +} + +func TestDeleteAttachment_HappyPath(t *testing.T) { + c := rwVault(t) + rel := "data/file.bin" + if _, err := c.UploadAttachment(rel, []byte("x")); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := c.DeleteAttachment(rel); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := os.Stat(filepath.Join(c.vaultPath, filepath.FromSlash(rel))); !os.IsNotExist(err) { + t.Errorf("file still exists after delete: %v", err) + } +} + +func TestDeleteAttachment_NotFound(t *testing.T) { + c := rwVault(t) + + err := c.DeleteAttachment("does/not/exist.bin") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("err = %v, want 'not found'", err) + } +} + +func TestDeleteAttachment_RejectsMarkdown(t *testing.T) { + c := rwVault(t) + err := c.DeleteAttachment("notes/page.md") + if !errors.Is(err, ErrAttachmentIsMarkdown) { + t.Errorf("err = %v, want ErrAttachmentIsMarkdown", err) + } +} + +func TestDeleteAttachment_RejectsDirectory(t *testing.T) { + c := rwVault(t) + if err := os.MkdirAll(filepath.Join(c.vaultPath, "somedir"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + err := c.DeleteAttachment("somedir") + if err == nil || !strings.Contains(err.Error(), "directory") { + t.Errorf("err = %v, want directory rejection", err) + } +} + +func TestListAttachments_HappyPath_NonRecursive(t *testing.T) { + c := rwVault(t) + mustUpload(t, c, "Attach/a.png", "a") + mustUpload(t, c, "Attach/b.pdf", "bb") + mustUpload(t, c, "Attach/sub/c.jpg", "ccc") // shouldn't appear (non-recursive) + + entries, err := c.ListAttachments("Attach", false) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 2 { + t.Fatalf("count = %d, want 2 (got: %v)", len(entries), entries) + } + if entries[0].Path != "Attach/a.png" || entries[1].Path != "Attach/b.pdf" { + t.Errorf("entries not sorted by path: %v", entries) + } + if entries[0].Size != 1 || entries[1].Size != 2 { + t.Errorf("sizes wrong: %v", entries) + } + if entries[0].Mtime == "" { + t.Error("mtime empty") + } +} + +func TestListAttachments_HappyPath_Recursive(t *testing.T) { + c := rwVault(t) + mustUpload(t, c, "Attach/a.png", "a") + mustUpload(t, c, "Attach/sub/b.jpg", "bb") + mustUpload(t, c, "Attach/sub/deep/c.gif", "ccc") + + entries, err := c.ListAttachments("Attach", true) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 3 { + t.Fatalf("count = %d, want 3 (got: %v)", len(entries), entries) + } + expected := []string{"Attach/a.png", "Attach/sub/b.jpg", "Attach/sub/deep/c.gif"} + for i, e := range expected { + if entries[i].Path != e { + t.Errorf("entry[%d].Path = %q, want %q", i, entries[i].Path, e) + } + } +} + +func TestListAttachments_FiltersOutMarkdown(t *testing.T) { + c := rwVault(t) + mustUpload(t, c, "Mixed/binary.png", "x") + if err := os.WriteFile(filepath.Join(c.vaultPath, "Mixed", "note.md"), []byte("# md"), 0o644); err != nil { + t.Fatalf("seed md: %v", err) + } + + entries, err := c.ListAttachments("Mixed", false) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 1 || entries[0].Path != "Mixed/binary.png" { + t.Errorf("entries = %v, want only binary.png", entries) + } +} + +func TestListAttachments_FolderNotFound(t *testing.T) { + c := rwVault(t) + _, err := c.ListAttachments("does/not/exist", false) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("err = %v, want 'not found'", err) + } +} + +func TestListAttachments_NotADirectory(t *testing.T) { + c := rwVault(t) + mustUpload(t, c, "data/file.bin", "x") + _, err := c.ListAttachments("data/file.bin", false) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Errorf("err = %v, want 'not a directory'", err) + } +} + +func TestListAttachments_EmptyFolder(t *testing.T) { + c := rwVault(t) + if err := os.MkdirAll(filepath.Join(c.vaultPath, "Empty"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + entries, err := c.ListAttachments("Empty", false) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 0 { + t.Errorf("entries = %v, want empty", entries) + } +} + +func mustUpload(t *testing.T, c *Client, rel, content string) { + t.Helper() + if _, err := c.UploadAttachment(rel, []byte(content)); err != nil { + t.Fatalf("seed upload %s: %v", rel, err) + } +}