diff --git a/cmd/logos/index.go b/cmd/logos/index.go index f33143b..cba1a6d 100644 --- a/cmd/logos/index.go +++ b/cmd/logos/index.go @@ -193,6 +193,10 @@ func indexPass(ix *index.Index, w io.Writer, p *provider.Provider, embed string) // nothing" cannot depend on a model being reachable. mems, rescuedMems, edits, err := ix.SyncMemoryEdits(p, embed) if err != nil { + // What the scan found before it refused is still true, and an + // unreadable file left out of this run would otherwise go unnamed + // behind the refusal. + printConflictCopies(w, "", nil, edits) return err } printMemoryEdits(w, edits) @@ -395,6 +399,13 @@ func printConflictCopies(w io.Writer, prefix string, walked []string, e memory.E fmt.Fprintf(w, "%sset aside %d sync conflict %s of a memory that is no longer current — delete %s, or rename one to its original name to bring the memory back: %s\n", prefix, n, plural2(n, "copy", "copies"), plural2(n, "it", "them"), terminalSafe(strings.Join(e.Orphaned, ", "), 2000)) } + // Said here, with the other files an index left out, because it is the + // same promise: a file on disk that is not in memory is named, or the + // counts read as a vault that simply has fewer memories. + if n := len(e.Unreadable); n > 0 { + fmt.Fprintf(w, "%scould not read %d memory %s; a memory the index already has is kept, but none is read from %s until %s readable: %s\n", + prefix, n, plural2(n, "file", "files"), plural2(n, "it", "them"), plural2(n, "it is", "they are"), terminalSafe(strings.Join(e.Unreadable, ", "), 2000)) + } } func plural2(n int, one, many string) string { diff --git a/cmd/logos/index_unreadable_memory_test.go b/cmd/logos/index_unreadable_memory_test.go new file mode 100644 index 0000000..03ef7fc --- /dev/null +++ b/cmd/logos/index_unreadable_memory_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// A memory file the index cannot read was skipped with a bare continue: after +// a rebuild `logos index` counted one memory fewer, the digest was rewritten +// without it, and nothing named the file still sitting on disk. +func TestAnUnreadableMemoryFileIsNamedByTheIndexThatLeftItOut(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a file permission that stops its owner reading it") + } + vaultDir := t.TempDir() + t.Setenv("LOGOS_VAULT", vaultDir) + t.Setenv("LOGOS_EMBED", "off") + for _, text := range []string{"Decided to use Stripe for payments", "Deploys go out on Tuesdays"} { + if err := memoryCmd([]string{"add", "--project", "shop", text}); err != nil { + t.Fatalf("memory add: %v", err) + } + } + matches, err := filepath.Glob(filepath.Join(vaultDir, "memories", "fact", "*.md")) + if err != nil || len(matches) != 2 { + t.Fatalf("got %v per-memory fact files (%v), want 2", matches, err) + } + locked := matches[0] + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0o644) }) + if err := os.RemoveAll(filepath.Join(vaultDir, ".logos")); err != nil { + t.Fatal(err) + } + + out := captureStdout(t, func() { + if err := runIndex(false); err != nil { + t.Fatal(err) + } + }) + want := "could not read 1 memory file; a memory the index already has is kept, but none is read from it until it is readable: memories/fact/" + filepath.Base(locked) + ": permission denied" + if !strings.Contains(out, want) { + t.Errorf("index did not name the file it could not read; want %q in:\n%s", want, out) + } +} + +// Over an index that already had the memory, an unreadable file read as a line +// the user deleted: the index forgot the memory and removed the file it could +// not read, so fixing the permission afterwards brought nothing back. +func TestAnUnreadableMemoryFileIsNotForgottenByTheNextIndex(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a file permission that stops its owner reading it") + } + vaultDir := t.TempDir() + t.Setenv("LOGOS_VAULT", vaultDir) + t.Setenv("LOGOS_EMBED", "off") + for _, text := range []string{"Decided to use Stripe for payments", "Deploys go out on Tuesdays"} { + if err := memoryCmd([]string{"add", "--project", "shop", text}); err != nil { + t.Fatalf("memory add: %v", err) + } + } + matches, err := filepath.Glob(filepath.Join(vaultDir, "memories", "fact", "*.md")) + if err != nil || len(matches) != 2 { + t.Fatalf("got %v per-memory fact files (%v), want 2", matches, err) + } + locked := matches[0] + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0o644) }) + + out := captureStdout(t, func() { + if err := runIndex(false); err != nil { + t.Fatal(err) + } + }) + if strings.Contains(out, "forgot") { + t.Errorf("index forgot the memory whose file it could not read:\n%s", out) + } + if _, err := os.Lstat(locked); err != nil { + t.Fatalf("index removed the memory file it could not read: %v", err) + } + if err := os.Chmod(locked, 0o644); err != nil { + t.Fatal(err) + } + listed := captureStdout(t, func() { + if err := memoryCmd(nil); err != nil { + t.Fatal(err) + } + }) + for _, text := range []string{"Stripe", "Tuesdays"} { + if !strings.Contains(listed, text) { + t.Errorf("%q is not listed after the file was readable again:\n%s", text, listed) + } + } +} + +// A damaged digest elsewhere makes the index refuse, and the refusal returned +// before the notice: the unreadable file the same scan found went unnamed, so +// the one run that had found it said nothing about it. +func TestAnUnreadableMemoryFileIsNamedWhenTheIndexRefuses(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a file permission that stops its owner reading it") + } + vaultDir := t.TempDir() + t.Setenv("LOGOS_VAULT", vaultDir) + t.Setenv("LOGOS_EMBED", "off") + if err := memoryCmd([]string{"add", "--project", "shop", "Deploys go out on Tuesdays"}); err != nil { + t.Fatalf("memory add: %v", err) + } + matches, err := filepath.Glob(filepath.Join(vaultDir, "memories", "fact", "*.md")) + if err != nil || len(matches) != 1 { + t.Fatalf("got %v per-memory fact files (%v), want 1", matches, err) + } + locked := matches[0] + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0o644) }) + torn := filepath.Join(vaultDir, "memories", "context.md") + if err := os.WriteFile(torn, []byte("- Shipping in November