diff --git a/internal/session/log_test.go b/internal/session/log_test.go new file mode 100644 index 0000000..64175ad --- /dev/null +++ b/internal/session/log_test.go @@ -0,0 +1,169 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {5 * time.Second, "5s"}, + {65 * time.Second, "1m 5s"}, + {3661 * time.Second, "1h 1m 1s"}, + {0, "0s"}, + } + + for _, tt := range tests { + got := FormatDuration(tt.d) + if got != tt.want { + t.Errorf("FormatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestFormatSessionEntry(t *testing.T) { + start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC) + end := time.Date(2026, 4, 5, 15, 45, 10, 0, time.UTC) + + info := &SessionInfo{ + Agent: "claude", + Mode: "local", + StartTime: start, + EndTime: end, + Diff: &ManifestDiff{ + Added: []string{"output/migration-plan.md", "output/schema.sql"}, + Modified: []string{"notes.md", "CLAUDE.md"}, + Deleted: nil, + }, + } + + entry := FormatSessionEntry(info) + + // Check required fields + checks := []string{ + "Session 2026-04-05 14:30:22", + "Agent: claude", + "Mode: local", + "Start: 2026-04-05 14:30:22", + "End: 2026-04-05 15:45:10", + "Duration: 1h 14m 48s", + "output/migration-plan.md", + "output/schema.sql", + "notes.md", + "CLAUDE.md", + "Notes updated: yes", + } + for _, check := range checks { + if !strings.Contains(entry, check) { + t.Errorf("entry missing %q", check) + } + } + + // Deleted should show (none) + if !strings.Contains(entry, "Deleted:\n (none)") { + t.Error("expected '(none)' for empty deleted section") + } +} + +func TestFormatSessionEntryShortNoChanges(t *testing.T) { + start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC) + end := start.Add(2 * time.Second) + + info := &SessionInfo{ + Agent: "claude", + Mode: "local", + StartTime: start, + EndTime: end, + Diff: &ManifestDiff{}, + } + + entry := FormatSessionEntry(info) + if !strings.Contains(entry, "No changes detected") { + t.Error("short session with no changes should have 'No changes detected' note") + } +} + +func TestAppendSessionLog(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "logs"), 0755) + + start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC) + end := time.Date(2026, 4, 5, 15, 0, 0, 0, time.UTC) + + info := &SessionInfo{ + Agent: "claude", + Mode: "local", + StartTime: start, + EndTime: end, + Diff: &ManifestDiff{ + Added: []string{"output/result.md"}, + }, + } + + if err := AppendSessionLog(dir, info); err != nil { + t.Fatalf("AppendSessionLog: %v", err) + } + + // Append a second entry + info2 := &SessionInfo{ + Agent: "shell", + Mode: "local", + StartTime: end, + EndTime: end.Add(time.Hour), + Diff: &ManifestDiff{}, + } + + if err := AppendSessionLog(dir, info2); err != nil { + t.Fatalf("AppendSessionLog 2: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "logs", "sessions.log")) + content := string(data) + + // Should contain both sessions + if strings.Count(content, "── Session") != 2 { + t.Errorf("expected 2 session entries, got %d", strings.Count(content, "── Session")) + } +} + +func TestManifestCleanupOnSuccess(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".ctask"), 0755) + os.MkdirAll(filepath.Join(dir, "logs"), 0755) + + mPath := filepath.Join(dir, ".ctask", "manifest-start.json") + + now := time.Now().UTC().Truncate(time.Second) + m := &Manifest{ + CapturedAt: now, + Files: []FileEntry{{Path: "notes.md", Size: 100, Mtime: now}}, + } + WriteManifest(mPath, m) + + // Simulate successful logging + info := &SessionInfo{ + Agent: "claude", + Mode: "local", + StartTime: now, + EndTime: now.Add(time.Minute), + Diff: &ManifestDiff{}, + } + + err := AppendSessionLog(dir, info) + if err != nil { + t.Fatalf("AppendSessionLog: %v", err) + } + + // Clean up manifest (as session.Run does) + os.Remove(mPath) + + if _, err := os.Stat(mPath); !os.IsNotExist(err) { + t.Error("manifest should be cleaned up after successful logging") + } +} diff --git a/internal/session/manifest.go b/internal/session/manifest.go index 5912518..e6b4e38 100644 --- a/internal/session/manifest.go +++ b/internal/session/manifest.go @@ -32,7 +32,7 @@ func ignoredPath(relPath string) bool { return true } // Ignore sessions.log (ctask's own logging) - if relPath == filepath.Join("logs", "sessions.log") { + if relPath == "logs/sessions.log" || relPath == "logs\\sessions.log" { return true } return false diff --git a/internal/session/manifest_test.go b/internal/session/manifest_test.go new file mode 100644 index 0000000..13c309a --- /dev/null +++ b/internal/session/manifest_test.go @@ -0,0 +1,179 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCaptureManifest(t *testing.T) { + dir := t.TempDir() + + // Create some files + os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644) + os.WriteFile(filepath.Join(dir, "task.yaml"), []byte("meta"), 0644) + os.MkdirAll(filepath.Join(dir, "output"), 0755) + os.WriteFile(filepath.Join(dir, "output", "result.txt"), []byte("result"), 0644) + + m, err := CaptureManifest(dir) + if err != nil { + t.Fatalf("CaptureManifest: %v", err) + } + + if len(m.Files) != 3 { + t.Errorf("expected 3 files, got %d", len(m.Files)) + } + + // Verify paths are forward-slash normalized + for _, f := range m.Files { + if filepath.IsAbs(f.Path) { + t.Errorf("expected relative path, got %q", f.Path) + } + } +} + +func TestCaptureManifestExcludesCtaskDir(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644) + os.MkdirAll(filepath.Join(dir, ".ctask"), 0755) + os.WriteFile(filepath.Join(dir, ".ctask", "manifest-start.json"), []byte("{}"), 0644) + + m, err := CaptureManifest(dir) + if err != nil { + t.Fatalf("CaptureManifest: %v", err) + } + + for _, f := range m.Files { + if f.Path == ".ctask/manifest-start.json" { + t.Error(".ctask/ contents should be excluded from manifest") + } + } +} + +func TestWriteAndReadManifest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".ctask", "manifest-start.json") + + now := time.Now().UTC().Truncate(time.Second) + m := &Manifest{ + CapturedAt: now, + Files: []FileEntry{ + {Path: "notes.md", Size: 100, Mtime: now}, + }, + } + + if err := WriteManifest(path, m); err != nil { + t.Fatalf("WriteManifest: %v", err) + } + + got, err := ReadManifest(path) + if err != nil { + t.Fatalf("ReadManifest: %v", err) + } + + if len(got.Files) != 1 { + t.Fatalf("expected 1 file, got %d", len(got.Files)) + } + if got.Files[0].Path != "notes.md" { + t.Errorf("path: got %q", got.Files[0].Path) + } +} + +func TestDiffManifests(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + later := now.Add(time.Minute) + + start := &Manifest{ + CapturedAt: now, + Files: []FileEntry{ + {Path: "notes.md", Size: 100, Mtime: now}, + {Path: "CLAUDE.md", Size: 200, Mtime: now}, + {Path: "old-file.txt", Size: 50, Mtime: now}, + }, + } + + end := &Manifest{ + CapturedAt: later, + Files: []FileEntry{ + {Path: "notes.md", Size: 150, Mtime: later}, // modified (size + mtime) + {Path: "CLAUDE.md", Size: 200, Mtime: now}, // unchanged + {Path: "output/result.md", Size: 300, Mtime: later}, // added + }, + } + + diff := DiffManifests(start, end) + + if len(diff.Added) != 1 || diff.Added[0] != "output/result.md" { + t.Errorf("Added: got %v, want [output/result.md]", diff.Added) + } + + if len(diff.Modified) != 1 || diff.Modified[0] != "notes.md" { + t.Errorf("Modified: got %v, want [notes.md]", diff.Modified) + } + + if len(diff.Deleted) != 1 || diff.Deleted[0] != "old-file.txt" { + t.Errorf("Deleted: got %v, want [old-file.txt]", diff.Deleted) + } +} + +func TestDiffManifestsIgnoreRules(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + later := now.Add(time.Minute) + + start := &Manifest{ + CapturedAt: now, + Files: []FileEntry{ + {Path: "notes.md", Size: 100, Mtime: now}, + {Path: "task.yaml", Size: 200, Mtime: now}, + {Path: "logs/sessions.log", Size: 50, Mtime: now}, + {Path: ".ctask/manifest-start.json", Size: 300, Mtime: now}, + }, + } + + end := &Manifest{ + CapturedAt: later, + Files: []FileEntry{ + {Path: "notes.md", Size: 100, Mtime: now}, // unchanged + {Path: "task.yaml", Size: 250, Mtime: later}, // changed but ignored + {Path: "logs/sessions.log", Size: 100, Mtime: later}, // changed but ignored + {Path: ".ctask/manifest-start.json", Size: 300, Mtime: now}, // ignored + }, + } + + diff := DiffManifests(start, end) + + if len(diff.Added) != 0 { + t.Errorf("Added should be empty (ignore rules), got %v", diff.Added) + } + if len(diff.Modified) != 0 { + t.Errorf("Modified should be empty (ignore rules), got %v", diff.Modified) + } + if len(diff.Deleted) != 0 { + t.Errorf("Deleted should be empty (ignore rules), got %v", diff.Deleted) + } +} + +func TestNotesUpdated(t *testing.T) { + diff := &ManifestDiff{ + Modified: []string{"CLAUDE.md", "notes.md"}, + } + if !NotesUpdated(diff) { + t.Error("expected NotesUpdated=true when notes.md in Modified") + } + + diff2 := &ManifestDiff{ + Modified: []string{"CLAUDE.md"}, + } + if NotesUpdated(diff2) { + t.Error("expected NotesUpdated=false when notes.md not changed") + } + + diff3 := &ManifestDiff{ + Added: []string{"notes.md"}, + } + if !NotesUpdated(diff3) { + t.Error("expected NotesUpdated=true when notes.md in Added") + } +}