summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/cli/cli_test.go436
-rw-r--r--internal/shell/shell_internal_test.go106
-rw-r--r--internal/store/store_test.go177
3 files changed, 719 insertions, 0 deletions
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..250601c
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,436 @@
+// Package cli (internal tests) exercise pure package-level helpers and
+// dispatch paths that do not require a full CLI setup (no cipher, no store,
+// no terminal).
+package cli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/foostore/internal/clipboard"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
+ "codeberg.org/snonux/foostore/internal/shell"
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// testCLI creates a fully wired CLI backed by temporary directories and a real
+// cipher so that dispatch paths that touch the store can be exercised.
+// No git repo is initialised, so tests must not attempt commits.
+func testCLI(t *testing.T) (*CLI, *config.Config) {
+ t.Helper()
+
+ dataDir := t.TempDir()
+ exportDir := t.TempDir()
+ keyDir := t.TempDir()
+ keyFile := filepath.Join(keyDir, "key")
+ if err := os.WriteFile(keyFile, []byte("testkey1234567890"), 0o600); err != nil {
+ t.Fatalf("write key file: %v", err)
+ }
+
+ cfg := &config.Config{
+ DataDir: dataDir,
+ ExportDir: exportDir,
+ KeyFile: keyFile,
+ KeyLength: 32,
+ AddToIV: "Hello world",
+ }
+
+ ciph, err := crypto.NewCipher(keyFile, 32, "testpin", "Hello world")
+ if err != nil {
+ t.Fatalf("NewCipher: %v", err)
+ }
+
+ g := git.New(dataDir)
+ st, err := store.New(cfg, ciph, g)
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+
+ // No-op completion function; no readline instance needed for dispatch tests.
+ sh, err := shell.New(func(string) []string { return nil })
+ if err != nil {
+ t.Logf("shell.New: %v (non-TTY, skipping test)", err)
+ t.Skip("shell.New requires a TTY")
+ }
+ t.Cleanup(func() { sh.Close() })
+
+ return &CLI{
+ cfg: cfg,
+ st: st,
+ g: g,
+ clip: clipboard.New("", ""),
+ sh: sh,
+ cipher: ciph,
+ }, cfg
+}
+
+// captureStdout redirects os.Stdout to a pipe, calls fn, then returns what was
+// written and restores the original Stdout.
+func captureStdout(fn func()) string {
+ orig := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ return ""
+ }
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = orig
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+// captureStderr redirects os.Stderr to a pipe, calls fn, then returns what was
+// written and restores the original Stderr.
+func captureStderr(fn func()) string {
+ orig := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ return ""
+ }
+ os.Stderr = w
+ fn()
+ w.Close()
+ os.Stderr = orig
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+// TestLogMsg verifies that logMsg writes "> <msg>\n" to stdout.
+func TestLogMsg(t *testing.T) {
+ got := captureStdout(func() { logMsg("hello world") })
+ want := "> hello world\n"
+ if got != want {
+ t.Errorf("logMsg output = %q; want %q", got, want)
+ }
+}
+
+// TestWarn verifies that warn writes "WARN <msg>\n" to stderr.
+func TestWarn(t *testing.T) {
+ got := captureStderr(func() { warn("something bad") })
+ want := "WARN something bad\n"
+ if got != want {
+ t.Errorf("warn output = %q; want %q", got, want)
+ }
+}
+
+// TestPrintHelp verifies that printHelp writes a non-empty help text to stdout
+// containing key command names.
+func TestPrintHelp(t *testing.T) {
+ got := captureStdout(func() { printHelp() })
+ for _, cmd := range []string{"ls", "cat", "add", "import", "sync", "version"} {
+ if !strings.Contains(got, cmd) {
+ t.Errorf("printHelp output missing %q; full output:\n%s", cmd, got)
+ }
+ }
+}
+
+// TestShredFileCli verifies that shredFile removes a temporary file.
+// It uses a temp file so no live data is affected.
+func TestShredFileCli(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "todelete.txt")
+ if err := os.WriteFile(target, []byte("sensitive"), 0o600); err != nil {
+ t.Fatalf("writing temp file: %v", err)
+ }
+
+ ctx := t.Context()
+ if err := shredFile(ctx, target); err != nil {
+ t.Fatalf("shredFile: %v", err)
+ }
+
+ if _, err := os.Stat(target); err == nil {
+ t.Errorf("file %q still exists after shredFile", target)
+ }
+}
+
+// ---- dispatch helpers -------------------------------------------------------
+// The following tests use a bare &CLI{} because the tested code paths in
+// dispatchSimple never dereference any struct fields — they only call package-
+// level helpers (logMsg, warn, printHelp) or read c.lastResult (zero value "").
+
+// TestDispatch_noopCommands runs the stateless dispatchSimple branches and
+// confirms they all return exit code 0.
+func TestDispatch_noopCommands(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+
+ for _, cmd := range []string{"version", "commands", "help", "shell", "exit", "last"} {
+ t.Run(cmd, func(t *testing.T) {
+ // Capture stdout/stderr to avoid noise in test output.
+ _ = captureStdout(func() {
+ _ = captureStderr(func() {
+ ec := c.dispatch(ctx, []string{cmd})
+ if ec != 0 {
+ t.Errorf("dispatch(%q) = %d; want 0", cmd, ec)
+ }
+ })
+ })
+ })
+ }
+}
+
+// TestDispatch_version_output confirms that dispatch("version") emits the
+// version string to stdout.
+func TestDispatch_version_output(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+ out := captureStdout(func() { c.dispatch(ctx, []string{"version"}) })
+ if !strings.Contains(out, "foostore") {
+ t.Errorf("version output %q does not contain 'foostore'", out)
+ }
+}
+
+// TestDispatch_commands_output confirms that dispatch("commands") lists all
+// commands to stdout, one per line.
+func TestDispatch_commands_output(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+ out := captureStdout(func() { c.dispatch(ctx, []string{"commands"}) })
+ for _, cmd := range []string{"ls", "cat", "add", "sync", "import"} {
+ if !strings.Contains(out, cmd) {
+ t.Errorf("commands output missing %q; output:\n%s", cmd, out)
+ }
+ }
+}
+
+// ---- cmd* error paths -------------------------------------------------------
+// The error paths for missing arguments do not access any struct fields.
+
+// TestCmdAdd_missingArgs verifies cmdAdd returns exit code 1 when no
+// description argument is supplied.
+func TestCmdAdd_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdAdd(context.Background(), []string{"add"})
+ if ec != 1 {
+ t.Errorf("cmdAdd with no args = %d; want 1", ec)
+ }
+}
+
+// TestCmdImport_missingArgs verifies cmdImport returns exit code 1 when no
+// file argument is supplied.
+func TestCmdImport_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdImport(context.Background(), []string{"import"})
+ if ec != 1 {
+ t.Errorf("cmdImport with no args = %d; want 1", ec)
+ }
+}
+
+// TestCmdImportR_missingArgs verifies cmdImportR returns exit code 1 when no
+// directory argument is supplied.
+func TestCmdImportR_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdImportR(context.Background(), []string{"import_r"})
+ if ec != 1 {
+ t.Errorf("cmdImportR with no args = %d; want 1", ec)
+ }
+}
+
+// ---- store-backed dispatch tests --------------------------------------------
+// These tests use testCLI() which provides a real but empty store.
+
+// TestDispatch_ls_empty confirms that dispatch("ls") on an empty store
+// returns exit code 0 and prints "0 entries".
+func TestDispatch_ls_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ out := captureStdout(func() {
+ ec := c.dispatch(context.Background(), []string{"ls"})
+ if ec != 0 {
+ t.Errorf("dispatch(ls) = %d; want 0", ec)
+ }
+ })
+ if !strings.Contains(out, "0 entries") {
+ t.Errorf("ls output %q does not contain '0 entries'", out)
+ }
+}
+
+// TestDispatch_shred_empty confirms that dispatch("shred") on an empty export
+// dir returns exit code 0.
+func TestDispatch_shred_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"shred"})
+ if ec != 0 {
+ t.Errorf("dispatch(shred) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_emptyArgv confirms that dispatch with an empty argv on an empty
+// store returns exit code 0 (no fzf entries, immediate return).
+func TestDispatch_emptyArgv(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{})
+ if ec != 0 {
+ t.Errorf("dispatch([]) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_search_empty confirms that dispatch("search", "foo") returns
+// exit code 0 when there are no matching entries.
+func TestDispatch_search_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"search", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(search,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_cat_empty confirms that dispatch("cat", "foo") returns exit
+// code 0 when there are no matching entries.
+func TestDispatch_cat_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"cat", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(cat,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_rm_empty confirms that dispatch("rm", "foo") returns exit code
+// 0 when there are no matching entries (nothing to remove).
+func TestDispatch_rm_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"rm", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(rm,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_unknownCommand confirms that an unrecognised command is treated
+// as a search term and returns exit code 0 on an empty store.
+func TestDispatch_unknownCommand(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"completelyunknown"})
+ if ec != 0 {
+ t.Errorf("dispatch(unknown) = %d; want 0", ec)
+ }
+}
+
+// ---- readPIN ----------------------------------------------------------------
+
+// TestReadPIN_envVar verifies that readPIN returns the $PIN value immediately
+// without touching the terminal.
+func TestReadPIN_envVar(t *testing.T) {
+ t.Setenv("PIN", "s3cret")
+ pin, err := readPIN()
+ if err != nil {
+ t.Fatalf("readPIN: %v", err)
+ }
+ if pin != "s3cret" {
+ t.Errorf("readPIN = %q; want %q", pin, "s3cret")
+ }
+}
+
+// ---- completionFn -----------------------------------------------------------
+
+// TestCompletionFn_commandsOnly verifies that completionFn (with $PIN unset)
+// returns matching command names and nothing else.
+func TestCompletionFn_commandsOnly(t *testing.T) {
+ t.Setenv("PIN", "") // ensure no store walk
+ c, _ := testCLI(t)
+
+ results := c.completionFn("sy")
+ if len(results) != 1 || results[0] != "sync" {
+ t.Errorf("completionFn(sy) = %v; want [sync]", results)
+ }
+
+ results = c.completionFn("")
+ if len(results) != len(CommandList) {
+ t.Errorf("completionFn('') returned %d results; want %d", len(results), len(CommandList))
+ }
+}
+
+// ---- makeActionFn -----------------------------------------------------------
+
+// TestMakeActionFn_nil verifies that makeActionFn returns nil for actions
+// handled directly by the store (cat, export, pathexport).
+func TestMakeActionFn_nil(t *testing.T) {
+ c, _ := testCLI(t)
+ ctx := context.Background()
+
+ for _, action := range []store.Action{store.ActionCat, store.ActionExport, store.ActionPathExport} {
+ fn := c.makeActionFn(ctx, action)
+ if fn != nil {
+ t.Errorf("makeActionFn(%v) = non-nil; want nil (store handles it internally)", action)
+ }
+ }
+}
+
+// ---- remaining dispatchSearch branches --------------------------------------
+
+// TestDispatch_searchActions exercises all SearchActions entries on an empty
+// store, confirming each returns exit code 0 without panicking.
+func TestDispatch_searchActions(t *testing.T) {
+ c, _ := testCLI(t)
+ ctx := context.Background()
+
+ // These commands go through dispatchSearch/cmdSearchAction.
+ // With an empty store they find no matching entries and return 0.
+ for _, cmd := range []string{"paste", "export", "pathexport", "open", "edit", "get"} {
+ t.Run(cmd, func(t *testing.T) {
+ ec := c.dispatch(ctx, []string{cmd, "nonexistent"})
+ if ec != 0 {
+ t.Errorf("dispatch(%q, nonexistent) = %d; want 0", cmd, ec)
+ }
+ })
+ }
+}
+
+// ---- add/import/import_r with real args (error paths) ----------------------
+
+// TestDispatch_add_noStdinData calls dispatch("add", "desc") when stdin is
+// empty (as in a non-interactive test run). cmdAdd should detect no data and
+// return exit code 1.
+func TestDispatch_add_noStdinData(t *testing.T) {
+ c, _ := testCLI(t)
+ // Redirect stdin to /dev/null so scanner.Scan returns false immediately.
+ null, err := os.Open(os.DevNull)
+ if err != nil {
+ t.Fatalf("open /dev/null: %v", err)
+ }
+ defer null.Close()
+ origStdin := os.Stdin
+ os.Stdin = null
+ defer func() { os.Stdin = origStdin }()
+
+ _ = captureStdout(func() {
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"add", "my/desc"})
+ if ec != 1 {
+ t.Errorf("dispatch(add,desc) with empty stdin = %d; want 1", ec)
+ }
+ })
+ })
+}
+
+// TestDispatch_import_missingFile calls dispatch("import", "/no/such/file")
+// which should return exit code 1 after failing to read the source file.
+func TestDispatch_import_missingFile(t *testing.T) {
+ c, _ := testCLI(t)
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"import", "/no/such/file.txt"})
+ if ec != 1 {
+ t.Errorf("dispatch(import, missing) = %d; want 1", ec)
+ }
+ })
+}
+
+// TestDispatch_importR_missingDir calls dispatch("import_r", "/no/such/dir")
+// which should return exit code 1 after failing to walk the directory.
+func TestDispatch_importR_missingDir(t *testing.T) {
+ c, _ := testCLI(t)
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"import_r", "/no/such/dir"})
+ if ec != 1 {
+ t.Errorf("dispatch(import_r, missing) = %d; want 1", ec)
+ }
+ })
+}
diff --git a/internal/shell/shell_internal_test.go b/internal/shell/shell_internal_test.go
new file mode 100644
index 0000000..73284d7
--- /dev/null
+++ b/internal/shell/shell_internal_test.go
@@ -0,0 +1,106 @@
+// package shell (internal test) exercises unexported types that cannot be
+// reached from the external shell_test package.
+package shell
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestPrefixCompleterDo exercises the Do method of prefixCompleter against a
+// fixed candidate list. No TTY is required because the method is pure logic.
+func TestPrefixCompleterDo(t *testing.T) {
+ candidates := []string{"add", "edit", "export", "ls", "search"}
+
+ p := &prefixCompleter{
+ fn: func(prefix string) []string {
+ var out []string
+ for _, c := range candidates {
+ if strings.HasPrefix(c, prefix) {
+ out = append(out, c)
+ }
+ }
+ return out
+ },
+ }
+
+ cases := []struct {
+ name string
+ line string // full line up to cursor
+ wantSuffix []string
+ wantLen int
+ }{
+ {
+ // Empty input: all candidates returned; each suffix is the full word + space.
+ name: "empty prefix",
+ line: "",
+ wantSuffix: []string{"add ", "edit ", "export ", "ls ", "search "},
+ wantLen: 0,
+ },
+ {
+ // "e" prefix: edit, export.
+ name: "single char prefix",
+ line: "e",
+ wantSuffix: []string{"dit ", "xport "},
+ wantLen: 1,
+ },
+ {
+ // "ex" prefix: only export.
+ name: "two char prefix",
+ line: "ex",
+ wantSuffix: []string{"port "},
+ wantLen: 2,
+ },
+ {
+ // "z" prefix: no matches.
+ name: "no match",
+ line: "z",
+ wantSuffix: nil,
+ wantLen: 1,
+ },
+ {
+ // Line has a space: prefix is the word after the last space.
+ // "cat e" → prefix is "e", completions for "e".
+ name: "prefix after space",
+ line: "cat e",
+ wantSuffix: []string{"dit ", "xport "},
+ wantLen: 1,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ line := []rune(tc.line)
+ pos := len(line)
+ newLine, length := p.Do(line, pos)
+
+ if length != tc.wantLen {
+ t.Errorf("length = %d; want %d", length, tc.wantLen)
+ }
+ if len(newLine) != len(tc.wantSuffix) {
+ t.Errorf("len(newLine) = %d; want %d (%v vs %v)",
+ len(newLine), len(tc.wantSuffix), toStrings(newLine), tc.wantSuffix)
+ return
+ }
+ // Build a set for order-independent comparison.
+ got := make(map[string]bool, len(newLine))
+ for _, r := range newLine {
+ got[string(r)] = true
+ }
+ for _, want := range tc.wantSuffix {
+ if !got[want] {
+ t.Errorf("missing suffix %q in completions %v", want, toStrings(newLine))
+ }
+ }
+ })
+ }
+}
+
+// toStrings converts a [][]rune to []string for readable error output.
+func toStrings(runes [][]rune) []string {
+ out := make([]string, len(runes))
+ for i, r := range runes {
+ out[i] = string(r)
+ }
+ return out
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 9670a01..47d4692 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -745,3 +745,180 @@ func TestCommitIndexSkipsExisting(t *testing.T) {
t.Errorf("index file was overwritten: got %q; want %q", got, sentinel)
}
}
+
+// --- TestSearchActionPathExport ----------------------------------------------
+
+// TestSearchActionPathExport verifies that ActionPathExport preserves the full
+// description path (not just the basename) when writing to the export dir.
+func TestSearchActionPathExport(t *testing.T) {
+ ctx, store, cfg, _, _ := testSetup(t)
+ initGitRepo(t, cfg.DataDir)
+
+ wantContent := "path export content\n"
+ if err := store.Add(ctx, "docs/subdir/report.txt", wantContent); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+
+ results, err := store.Search(ctx, "report.txt", ActionPathExport, nil)
+ if err != nil {
+ t.Fatalf("Search ActionPathExport: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result; got %d", len(results))
+ }
+
+ // ActionPathExport uses the full description as the export path.
+ exportedPath := filepath.Join(cfg.ExportDir, "docs/subdir/report.txt")
+ got, err := os.ReadFile(exportedPath)
+ if err != nil {
+ t.Fatalf("reading exported file at full path: %v", err)
+ }
+ if string(got) != wantContent {
+ t.Errorf("exported content = %q; want %q", got, wantContent)
+ }
+}
+
+// --- TestSearchActionWithCallback --------------------------------------------
+
+// TestSearchActionWithCallback verifies that Search passes the correct Index and
+// Data to the actionFn for actions that delegate to the caller (ActionPaste etc.).
+func TestSearchActionWithCallback(t *testing.T) {
+ ctx, store, cfg, _, _ := testSetup(t)
+ initGitRepo(t, cfg.DataDir)
+
+ wantContent := "callback content\n"
+ if err := store.Add(ctx, "cb/entry.txt", wantContent); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+
+ var gotDesc string
+ var gotContent string
+ actionFn := func(_ context.Context, idx *Index, d *Data) error {
+ gotDesc = idx.Description
+ gotContent = string(d.Content)
+ return nil
+ }
+
+ // ActionPaste falls through to the default/actionFn branch of applyAction.
+ results, err := store.Search(ctx, "cb/entry.txt", ActionPaste, actionFn)
+ if err != nil {
+ t.Fatalf("Search with callback: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result; got %d", len(results))
+ }
+ if gotDesc != "cb/entry.txt" {
+ t.Errorf("callback got description %q; want %q", gotDesc, "cb/entry.txt")
+ }
+ if gotContent != wantContent {
+ t.Errorf("callback got content %q; want %q", gotContent, wantContent)
+ }
+}
+
+// --- TestSearchActionNilCallback ---------------------------------------------
+
+// TestSearchActionNilCallback confirms that passing a nil actionFn for a
+// callback-delegated action (ActionPaste) is handled gracefully without panic.
+func TestSearchActionNilCallback(t *testing.T) {
+ ctx, store, cfg, _, _ := testSetup(t)
+ initGitRepo(t, cfg.DataDir)
+
+ if err := store.Add(ctx, "nil/cb.txt", "data"); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+
+ // nil actionFn: applyAction must return nil without calling anything.
+ _, err := store.Search(ctx, "nil/cb.txt", ActionPaste, nil)
+ if err != nil {
+ t.Fatalf("Search with nil callback: %v", err)
+ }
+}
+
+// --- TestFzfEmpty ------------------------------------------------------------
+
+// TestFzfEmpty calls Fzf on an empty store and confirms it returns ("", nil)
+// without attempting to launch fzf.
+func TestFzfEmpty(t *testing.T) {
+ ctx, store, _, _, _ := testSetup(t)
+ // No entries added — Fzf must return immediately.
+ result, err := store.Fzf(ctx)
+ if err != nil {
+ t.Fatalf("Fzf on empty store: %v", err)
+ }
+ if result != "" {
+ t.Errorf("Fzf on empty store = %q; want empty string", result)
+ }
+}
+
+// --- TestRemoveInteractiveInvalidThenDecline ---------------------------------
+
+// TestRemoveInteractiveInvalidThenDecline exercises the retry loop in
+// confirmAndRemove: an unrecognised answer causes a re-prompt; "n" then exits.
+func TestRemoveInteractiveInvalidThenDecline(t *testing.T) {
+ ctx, store, cfg, _, g := testSetup(t)
+ initGitRepo(t, cfg.DataDir)
+
+ if err := store.Add(ctx, "retry/entry", "data"); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+ if err := g.Commit(ctx); err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+
+ // "maybe\n" is not "y" or "n" → retry; "n\n" then declines.
+ input := strings.NewReader("maybe\nn\n")
+ if err := store.Remove(ctx, "retry/entry", input); err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+
+ // Entry must still be present because deletion was declined.
+ count := 0
+ if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil {
+ t.Fatalf("WalkIndexes: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("expected 1 entry after decline; got %d", count)
+ }
+}
+
+// --- TestImportForceOverwrite ------------------------------------------------
+
+// TestImportForceOverwrite confirms that force=true overwrites an existing entry
+// while force=false (the default) skips it silently.
+func TestImportForceOverwrite(t *testing.T) {
+ ctx, store, cfg, c, _ := testSetup(t)
+ initGitRepo(t, cfg.DataDir)
+
+ srcDir := t.TempDir()
+ src := filepath.Join(srcDir, "f.txt")
+ if err := os.WriteFile(src, []byte("original\n"), 0o600); err != nil {
+ t.Fatalf("write src: %v", err)
+ }
+
+ // First import.
+ if err := store.Import(ctx, src, "force/f.txt", false); err != nil {
+ t.Fatalf("first Import: %v", err)
+ }
+
+ // Overwrite source with new content.
+ if err := os.WriteFile(src, []byte("updated\n"), 0o600); err != nil {
+ t.Fatalf("write updated src: %v", err)
+ }
+
+ // Second import with force=true must overwrite.
+ if err := store.Import(ctx, src, "force/f.txt", true); err != nil {
+ t.Fatalf("second Import (force): %v", err)
+ }
+
+ var idx *Index
+ if err := store.WalkIndexes(ctx, "", func(i *Index) error { idx = i; return nil }); err != nil {
+ t.Fatalf("WalkIndexes: %v", err)
+ }
+ d, err := loadData(ctx, filepath.Join(cfg.DataDir, idx.DataFile), c)
+ if err != nil {
+ t.Fatalf("loadData: %v", err)
+ }
+ if string(d.Content) != "updated\n" {
+ t.Errorf("content after force import = %q; want %q", d.Content, "updated\n")
+ }
+}