diff --git a/cmd/undo/main.go b/cmd/undo/main.go index de5a246..48378de 100644 --- a/cmd/undo/main.go +++ b/cmd/undo/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" + "errors" "fmt" "os" "strconv" @@ -141,17 +142,35 @@ func cmdList() { } } -func cmdShow(args []string) { - var s *session.Session - var err error +func getShowSession(args []string) (*session.Session, bool, error) { if len(args) > 0 { - s, err = session.Get(args[0]) - } else { - s, err = session.Latest() + s, err := session.Get(args[0]) + return s, false, err + } + s, err := session.Latest() + if errors.Is(err, os.ErrNotExist) { + // No non-undone session found. Fall back to the most recent + // session (including undone ones) so the user sees what happened + // rather than an opaque "no such session" error. + if all, lerr := session.List(); lerr == nil { + for _, candidate := range all { + if len(candidate.Entries) > 0 { + return candidate, true, nil + } + } + } } + return s, false, err +} + +func cmdShow(args []string) { + s, allUndone, err := getShowSession(args) if err != nil { fatal(fmt.Errorf("no such session")) } + if allUndone { + fmt.Fprintln(os.Stderr, "(all sessions have been undone; showing the most recent)") + } fmt.Printf("session %s (%s)\n$ %s\n\n", shortID(s.ID), when(s.ID), s.Cmd) for i, e := range s.Entries { fmt.Printf(" %2d %s\n", i+1, e.Describe()) diff --git a/cmd/undo/show_test.go b/cmd/undo/show_test.go new file mode 100644 index 0000000..6a75a9c --- /dev/null +++ b/cmd/undo/show_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/edaywalid/undo/internal/session" +) + +// Helper to create a test session with a specified command and optional journal entry +func createTestSession(t *testing.T, cmd string, withJournal bool) *session.Session { + t.Helper() + s, err := session.Create(cmd) + if err != nil { + t.Fatal(err) + } + if err := s.MarkDone(); err != nil { + t.Fatal(err) + } + if withJournal { + if err := os.WriteFile(filepath.Join(s.Dir, "journal"), []byte("unlink\t/tmp/file\t/tmp/file_bak\n"), 0o600); err != nil { + t.Fatal(err) + } + } + return s +} + +// 1. Edge Case: Empty Session Store +func TestGetShowSessionEmptyStore(t *testing.T) { + t.Setenv("UNDO_DATA_DIR", t.TempDir()) + + s, allUndone, err := getShowSession(nil) + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected os.ErrNotExist, got %v", err) + } + if allUndone { + t.Errorf("expected allUndone=false, got true") + } + if s != nil { + t.Errorf("expected s=nil, got %v", s) + } +} + +// 2. Edge Case: Only Empty Sessions (0 journal entries) +func TestGetShowSessionOnlyEmptySessions(t *testing.T) { + t.Setenv("UNDO_DATA_DIR", t.TempDir()) + + createTestSession(t, "ls -la", false) + createTestSession(t, "cat README.md", false) + + s, allUndone, err := getShowSession(nil) + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected os.ErrNotExist for empty sessions, got %v", err) + } + if allUndone { + t.Errorf("expected allUndone=false, got true") + } + if s != nil { + t.Errorf("expected s=nil, got %v", s) + } +} + +// 3. Edge Case: Explicit Session ID Argument Provided +func TestGetShowSessionExplicitID(t *testing.T) { + t.Setenv("UNDO_DATA_DIR", t.TempDir()) + + s1 := createTestSession(t, "rm -rf dir1", true) + if err := s1.MarkUndone(); err != nil { + t.Fatal(err) + } + + got, allUndone, err := getShowSession([]string{s1.ID}) + if err != nil { + t.Fatalf("unexpected error looking up explicit ID: %v", err) + } + if got.ID != s1.ID { + t.Errorf("got ID %s, want %s", got.ID, s1.ID) + } + if allUndone { + t.Errorf("expected allUndone=false for explicit ID lookup") + } +} + +// 4. Edge Case: Mix of Active and Undone Sessions +func TestGetShowSessionMixActiveAndUndone(t *testing.T) { + t.Setenv("UNDO_DATA_DIR", t.TempDir()) + + s1 := createTestSession(t, "rm file1", true) + if err := s1.MarkUndone(); err != nil { + t.Fatal(err) + } + + time.Sleep(10 * time.Millisecond) + s2 := createTestSession(t, "rm file2", true) + + got, allUndone, err := getShowSession(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ID != s2.ID { + t.Errorf("got session ID %s, want active session %s", got.ID, s2.ID) + } + if allUndone { + t.Errorf("expected allUndone=false when an active session exists") + } +} + +// 5. Edge Case: All Sessions Undone -> Fallback to Latest +func TestGetShowSessionAllUndone(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("UNDO_DATA_DIR", tmpDir) + + s1 := createTestSession(t, "rm -rf old_file", true) + if err := s1.MarkUndone(); err != nil { + t.Fatal(err) + } + + got, allUndone, err := getShowSession(nil) + if err != nil { + t.Fatalf("unexpected error on fallback: %v", err) + } + if !allUndone { + t.Errorf("expected allUndone=true when all sessions are undone") + } + if got.ID != s1.ID { + t.Errorf("got session %s, want %s", got.ID, s1.ID) + } +} + +// Full Output Verification Test for cmdShow +func TestCmdShowOutputFormatting(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("UNDO_DATA_DIR", tmpDir) + + s := createTestSession(t, "rm -rf target_folder", true) + if err := s.MarkUndone(); err != nil { + t.Fatal(err) + } + + oldStdout := os.Stdout + oldStderr := os.Stderr + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + os.Stdout = wOut + os.Stderr = wErr + + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + + cmdShow(nil) + + wOut.Close() + wErr.Close() + + var outBuf, errBuf bytes.Buffer + _, _ = io.Copy(&outBuf, rOut) + _, _ = io.Copy(&errBuf, rErr) + + stderrStr := errBuf.String() + stdoutStr := outBuf.String() + + if !strings.Contains(stderrStr, "(all sessions have been undone; showing the most recent)") { + t.Errorf("expected stderr warning, got: %q", stderrStr) + } + if !strings.Contains(stdoutStr, "rm -rf target_folder") { + t.Errorf("expected stdout command string, got: %q", stdoutStr) + } + if !strings.Contains(stdoutStr, "currently undone") { + t.Errorf("expected stdout undone footer, got: %q", stdoutStr) + } +}