feat: implement AI-assisted rename prompting feature
- Added data model for AI-assisted renaming including structures for prompts, responses, and policies. - Created implementation plan detailing the integration of Google Genkit into the CLI for renaming tasks. - Developed quickstart guide for setting up and using the new AI rename functionality. - Documented research decisions regarding Genkit orchestration and prompt composition. - Established tasks for phased implementation, including setup, foundational work, and user stories. - Implemented contract tests to ensure AI rename policies and ledger metadata are correctly applied. - Developed integration tests for validating AI rename flows, including preview, apply, and undo functionalities. - Added tooling to pin Genkit dependency for consistent builds.
This commit is contained in:
108
tests/contract/ai_ledger_contract_test.go
Normal file
108
tests/contract/ai_ledger_contract_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/plan"
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
)
|
||||
|
||||
func TestAIApplyLedgerMetadataContract(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "sample.txt"))
|
||||
|
||||
candidate := plan.Candidate{
|
||||
OriginalPath: "sample.txt",
|
||||
SizeBytes: 4,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
response := prompt.RenameResponse{
|
||||
Items: []prompt.RenameItem{
|
||||
{
|
||||
Original: "sample.txt",
|
||||
Proposed: "001_sample-final.txt",
|
||||
Sequence: 1,
|
||||
},
|
||||
},
|
||||
Model: "test-model",
|
||||
PromptHash: "prompt-hash-123",
|
||||
}
|
||||
|
||||
policy := prompt.NamingPolicyConfig{Prefix: "proj", Casing: "kebab"}
|
||||
|
||||
entry, err := plan.Apply(context.Background(), plan.ApplyOptions{
|
||||
WorkingDir: root,
|
||||
Candidates: []plan.Candidate{candidate},
|
||||
Response: response,
|
||||
Policies: policy,
|
||||
PromptHash: response.PromptHash,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
|
||||
if len(entry.Operations) != 1 {
|
||||
t.Fatalf("expected 1 operation, got %d", len(entry.Operations))
|
||||
}
|
||||
|
||||
planFile := filepath.Join(root, "001_sample-final.txt")
|
||||
if _, err := os.Stat(planFile); err != nil {
|
||||
t.Fatalf("expected renamed file: %v", err)
|
||||
}
|
||||
|
||||
ledgerPath := filepath.Join(root, ".renamer")
|
||||
file, err := os.Open(ledgerPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open ledger: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var lastLine string
|
||||
for scanner.Scan() {
|
||||
lastLine = scanner.Text()
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan ledger: %v", err)
|
||||
}
|
||||
|
||||
var recorded map[string]any
|
||||
if err := json.Unmarshal([]byte(lastLine), &recorded); err != nil {
|
||||
t.Fatalf("unmarshal ledger entry: %v", err)
|
||||
}
|
||||
|
||||
metaRaw, ok := recorded["metadata"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata in ledger entry")
|
||||
}
|
||||
aiRaw, ok := metaRaw["ai"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ai metadata in ledger entry")
|
||||
}
|
||||
|
||||
if aiRaw["model"] != "test-model" {
|
||||
t.Fatalf("expected model test-model, got %v", aiRaw["model"])
|
||||
}
|
||||
if aiRaw["promptHash"] != "prompt-hash-123" {
|
||||
t.Fatalf("expected prompt hash recorded, got %v", aiRaw["promptHash"])
|
||||
}
|
||||
if aiRaw["batchSize"].(float64) != 1 {
|
||||
t.Fatalf("expected batch size 1, got %v", aiRaw["batchSize"])
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
116
tests/contract/ai_policy_contract_test.go
Normal file
116
tests/contract/ai_policy_contract_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
"github.com/rogeecn/renamer/internal/ai/genkit"
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
)
|
||||
|
||||
type captureWorkflow struct {
|
||||
request genkit.Request
|
||||
}
|
||||
|
||||
func (c *captureWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) {
|
||||
c.request = req
|
||||
return genkit.Result{
|
||||
Response: prompt.RenameResponse{
|
||||
Items: []prompt.RenameItem{
|
||||
{
|
||||
Original: "alpha.txt",
|
||||
Proposed: "proj_001_sample_file.txt",
|
||||
Sequence: 1,
|
||||
},
|
||||
},
|
||||
Model: "test-model",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAICommandAppliesNamingPoliciesToPrompt(t *testing.T) {
|
||||
genkit.ResetWorkflowFactory()
|
||||
stub := &captureWorkflow{}
|
||||
genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) {
|
||||
return stub, nil
|
||||
})
|
||||
t.Cleanup(genkit.ResetWorkflowFactory)
|
||||
|
||||
rootDir := t.TempDir()
|
||||
createPolicyTestFile(t, filepath.Join(rootDir, "alpha.txt"))
|
||||
|
||||
rootCmd := renamercmd.NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootCmd.SetOut(&stdout)
|
||||
rootCmd.SetErr(&stderr)
|
||||
rootCmd.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", rootDir,
|
||||
"--dry-run",
|
||||
"--naming-casing", "snake",
|
||||
"--naming-prefix", "proj",
|
||||
"--naming-allow-spaces",
|
||||
"--naming-keep-order",
|
||||
"--banned", "alpha",
|
||||
})
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
if stdout.Len() > 0 {
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Logf("stderr: %s", stderr.String())
|
||||
}
|
||||
t.Fatalf("command execute: %v", err)
|
||||
}
|
||||
|
||||
req := stub.request
|
||||
policies := req.Payload.Policies
|
||||
if policies.Prefix != "proj" {
|
||||
t.Fatalf("expected prefix proj, got %q", policies.Prefix)
|
||||
}
|
||||
if policies.Casing != "snake" {
|
||||
t.Fatalf("expected casing snake, got %q", policies.Casing)
|
||||
}
|
||||
if !policies.AllowSpaces {
|
||||
t.Fatalf("expected allow spaces flag to propagate")
|
||||
}
|
||||
if !policies.KeepOriginalOrder {
|
||||
t.Fatalf("expected keep original order flag to propagate")
|
||||
}
|
||||
if len(policies.ForbiddenTokens) != 1 || policies.ForbiddenTokens[0] != "alpha" {
|
||||
t.Fatalf("expected forbidden tokens to capture user list, got %#v", policies.ForbiddenTokens)
|
||||
}
|
||||
|
||||
banned := req.Payload.BannedTerms
|
||||
containsDefault := false
|
||||
containsUser := false
|
||||
for _, term := range banned {
|
||||
switch term {
|
||||
case "alpha":
|
||||
containsUser = true
|
||||
case "clickbait":
|
||||
containsDefault = true
|
||||
}
|
||||
}
|
||||
if !containsUser {
|
||||
t.Fatalf("expected banned terms to include user-provided token")
|
||||
}
|
||||
if !containsDefault {
|
||||
t.Fatalf("expected banned terms to retain default tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func createPolicyTestFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("demo"), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
126
tests/contract/ai_prompt_contract_test.go
Normal file
126
tests/contract/ai_prompt_contract_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
aiprompt "github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
)
|
||||
|
||||
func TestRenamePromptSchemaAlignment(t *testing.T) {
|
||||
builder := aiprompt.NewBuilder(
|
||||
aiprompt.WithClock(func() time.Time {
|
||||
return time.Date(2025, 11, 3, 15, 4, 5, 0, time.UTC)
|
||||
}),
|
||||
aiprompt.WithMaxSamples(2),
|
||||
)
|
||||
|
||||
input := aiprompt.BuildInput{
|
||||
WorkingDir: "/tmp/workspace",
|
||||
TotalCount: 3,
|
||||
Sequence: aiprompt.SequenceRule{
|
||||
Style: "prefix",
|
||||
Width: 3,
|
||||
Start: 1,
|
||||
Separator: "_",
|
||||
},
|
||||
Policies: aiprompt.PolicyConfig{
|
||||
Casing: "kebab",
|
||||
},
|
||||
BannedTerms: []string{"Promo", " ", "promo", "ads"},
|
||||
Samples: []aiprompt.SampleCandidate{
|
||||
{
|
||||
RelativePath: "promo SALE 01.JPG",
|
||||
SizeBytes: 2048,
|
||||
Depth: 0,
|
||||
},
|
||||
{
|
||||
RelativePath: filepath.ToSlash(filepath.Join("nested", "Report FINAL.PDF")),
|
||||
SizeBytes: 1024,
|
||||
Depth: 1,
|
||||
},
|
||||
{
|
||||
RelativePath: "notes.txt",
|
||||
SizeBytes: 128,
|
||||
Depth: 0,
|
||||
},
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"cliVersion": "test-build",
|
||||
},
|
||||
}
|
||||
|
||||
promptPayload, err := builder.Build(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Build error: %v", err)
|
||||
}
|
||||
|
||||
if promptPayload.WorkingDir != input.WorkingDir {
|
||||
t.Fatalf("expected working dir %q, got %q", input.WorkingDir, promptPayload.WorkingDir)
|
||||
}
|
||||
|
||||
if promptPayload.TotalCount != input.TotalCount {
|
||||
t.Fatalf("expected total count %d, got %d", input.TotalCount, promptPayload.TotalCount)
|
||||
}
|
||||
|
||||
if len(promptPayload.Samples) != 2 {
|
||||
t.Fatalf("expected 2 samples after max cap, got %d", len(promptPayload.Samples))
|
||||
}
|
||||
|
||||
first := promptPayload.Samples[0]
|
||||
if first.OriginalName != "nested/Report FINAL.PDF" {
|
||||
t.Fatalf("unexpected first sample name: %q", first.OriginalName)
|
||||
}
|
||||
if first.Extension != ".PDF" {
|
||||
t.Fatalf("expected extension to remain case-sensitive, got %q", first.Extension)
|
||||
}
|
||||
if first.SizeBytes != 1024 {
|
||||
t.Fatalf("expected size 1024, got %d", first.SizeBytes)
|
||||
}
|
||||
if first.PathDepth != 1 {
|
||||
t.Fatalf("expected depth 1, got %d", first.PathDepth)
|
||||
}
|
||||
|
||||
seq := promptPayload.SequenceRule
|
||||
if seq.Style != "prefix" || seq.Width != 3 || seq.Start != 1 || seq.Separator != "_" {
|
||||
t.Fatalf("sequence rule mismatch: %#v", seq)
|
||||
}
|
||||
|
||||
if promptPayload.Policies.Casing != "kebab" {
|
||||
t.Fatalf("expected casing kebab, got %q", promptPayload.Policies.Casing)
|
||||
}
|
||||
|
||||
expectedTerms := []string{"ads", "promo"}
|
||||
if len(promptPayload.BannedTerms) != len(expectedTerms) {
|
||||
t.Fatalf("expected %d banned terms, got %d", len(expectedTerms), len(promptPayload.BannedTerms))
|
||||
}
|
||||
for i, term := range expectedTerms {
|
||||
if promptPayload.BannedTerms[i] != term {
|
||||
t.Fatalf("banned term at %d mismatch: expected %q got %q", i, term, promptPayload.BannedTerms[i])
|
||||
}
|
||||
}
|
||||
|
||||
if promptPayload.Metadata["cliVersion"] != "test-build" {
|
||||
t.Fatalf("metadata cliVersion mismatch: %s", promptPayload.Metadata["cliVersion"])
|
||||
}
|
||||
if promptPayload.Metadata["generatedAt"] != "2025-11-03T15:04:05Z" {
|
||||
t.Fatalf("expected generatedAt timestamp preserved, got %q", promptPayload.Metadata["generatedAt"])
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(promptPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal round-trip error: %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{"workingDir", "samples", "totalCount", "sequenceRule", "policies"} {
|
||||
if _, ok := decoded[key]; !ok {
|
||||
t.Fatalf("prompt JSON missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
tests/integration/ai_apply_undo_test.go
Normal file
198
tests/integration/ai_apply_undo_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
"github.com/rogeecn/renamer/internal/ai/genkit"
|
||||
"github.com/rogeecn/renamer/internal/ai/plan"
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
"github.com/rogeecn/renamer/internal/listing"
|
||||
)
|
||||
|
||||
func TestAIApplyAndUndoFlow(t *testing.T) {
|
||||
initialWorkflow := stubWorkflow{
|
||||
response: prompt.RenameResponse{
|
||||
Items: []prompt.RenameItem{
|
||||
{
|
||||
Original: "draft_one.txt",
|
||||
Proposed: "001_initial.txt",
|
||||
Sequence: 1,
|
||||
},
|
||||
{
|
||||
Original: "draft_two.txt",
|
||||
Proposed: "002_initial.txt",
|
||||
Sequence: 2,
|
||||
},
|
||||
},
|
||||
Model: "test-model",
|
||||
},
|
||||
}
|
||||
|
||||
genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) {
|
||||
return initialWorkflow, nil
|
||||
})
|
||||
t.Cleanup(genkit.ResetWorkflowFactory)
|
||||
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "draft_one.txt"))
|
||||
writeFile(t, filepath.Join(root, "draft_two.txt"))
|
||||
|
||||
planPath := filepath.Join(root, "ai-plan.json")
|
||||
|
||||
preview := renamercmd.NewRootCommand()
|
||||
var previewOut, previewErr bytes.Buffer
|
||||
preview.SetOut(&previewOut)
|
||||
preview.SetErr(&previewErr)
|
||||
preview.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", root,
|
||||
"--dry-run",
|
||||
"--export-plan", planPath,
|
||||
})
|
||||
|
||||
if err := preview.Execute(); err != nil {
|
||||
if previewOut.Len() > 0 {
|
||||
t.Logf("preview stdout: %s", previewOut.String())
|
||||
}
|
||||
if previewErr.Len() > 0 {
|
||||
t.Logf("preview stderr: %s", previewErr.String())
|
||||
}
|
||||
t.Fatalf("initial preview: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(planPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read plan: %v", err)
|
||||
}
|
||||
var exported prompt.RenameResponse
|
||||
if err := json.Unmarshal(data, &exported); err != nil {
|
||||
t.Fatalf("unmarshal plan: %v", err)
|
||||
}
|
||||
|
||||
if len(exported.Items) != 2 {
|
||||
t.Fatalf("expected two plan items, got %d", len(exported.Items))
|
||||
}
|
||||
// Simulate operator edit.
|
||||
exported.Items[0].Proposed = "001_final-one.txt"
|
||||
exported.Items[1].Proposed = "002_final-two.txt"
|
||||
exported.Items[0].Notes = "custom edit"
|
||||
|
||||
modified, err := json.MarshalIndent(exported, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal modified plan: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(planPath, append(modified, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write modified plan: %v", err)
|
||||
}
|
||||
|
||||
req := &listing.ListingRequest{WorkingDir: root}
|
||||
if err := req.Validate(); err != nil {
|
||||
t.Fatalf("validate listing request: %v", err)
|
||||
}
|
||||
currentCandidates, err := plan.CollectCandidates(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("collect candidates: %v", err)
|
||||
}
|
||||
filtered := make([]plan.Candidate, 0, len(currentCandidates))
|
||||
for _, cand := range currentCandidates {
|
||||
if strings.EqualFold(cand.OriginalPath, filepath.Base(planPath)) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, cand)
|
||||
}
|
||||
originals := make([]string, 0, len(filtered))
|
||||
for _, cand := range filtered {
|
||||
originals = append(originals, cand.OriginalPath)
|
||||
}
|
||||
validator := plan.NewValidator(originals, prompt.NamingPolicyConfig{Casing: "kebab"}, nil)
|
||||
if _, err := validator.Validate(exported); err != nil {
|
||||
t.Fatalf("pre-validation of edited plan: %v", err)
|
||||
}
|
||||
|
||||
previewEdited := renamercmd.NewRootCommand()
|
||||
var editedOut, editedErr bytes.Buffer
|
||||
previewEdited.SetOut(&editedOut)
|
||||
previewEdited.SetErr(&editedErr)
|
||||
previewEdited.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", root,
|
||||
"--dry-run",
|
||||
"--import-plan", planPath,
|
||||
})
|
||||
|
||||
if err := previewEdited.Execute(); err != nil {
|
||||
if editedOut.Len() > 0 {
|
||||
t.Logf("edited stdout: %s", editedOut.String())
|
||||
}
|
||||
if editedErr.Len() > 0 {
|
||||
t.Logf("edited stderr: %s", editedErr.String())
|
||||
}
|
||||
t.Fatalf("preview edited plan: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(editedOut.String(), "001_final-one.txt") {
|
||||
t.Fatalf("expected edited preview to show final name, got: %s", editedOut.String())
|
||||
}
|
||||
|
||||
applyCmd := renamercmd.NewRootCommand()
|
||||
var applyOut, applyErr bytes.Buffer
|
||||
applyCmd.SetOut(&applyOut)
|
||||
applyCmd.SetErr(&applyErr)
|
||||
applyCmd.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", root,
|
||||
"--import-plan", planPath,
|
||||
"--yes",
|
||||
})
|
||||
|
||||
if err := applyCmd.Execute(); err != nil {
|
||||
if applyOut.Len() > 0 {
|
||||
t.Logf("apply stdout: %s", applyOut.String())
|
||||
}
|
||||
if applyErr.Len() > 0 {
|
||||
t.Logf("apply stderr: %s", applyErr.String())
|
||||
}
|
||||
t.Fatalf("apply plan: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "001_final-one.txt")); err != nil {
|
||||
t.Fatalf("expected renamed file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "002_final-two.txt")); err != nil {
|
||||
t.Fatalf("expected renamed file: %v", err)
|
||||
}
|
||||
|
||||
undo := renamercmd.NewRootCommand()
|
||||
var undoOut bytes.Buffer
|
||||
undo.SetOut(&undoOut)
|
||||
undo.SetErr(&undoOut)
|
||||
undo.SetArgs([]string{"undo", "--path", root})
|
||||
|
||||
if err := undo.Execute(); err != nil {
|
||||
t.Fatalf("undo command: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "draft_one.txt")); err != nil {
|
||||
t.Fatalf("expected original file after undo: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "draft_two.txt")); err != nil {
|
||||
t.Fatalf("expected original file after undo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
84
tests/integration/ai_policy_validation_test.go
Normal file
84
tests/integration/ai_policy_validation_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
"github.com/rogeecn/renamer/internal/ai/genkit"
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
)
|
||||
|
||||
type violatingWorkflow struct{}
|
||||
|
||||
func (violatingWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) {
|
||||
return genkit.Result{
|
||||
Response: prompt.RenameResponse{
|
||||
Items: []prompt.RenameItem{
|
||||
{
|
||||
Original: "video.mov",
|
||||
Proposed: "001_clickbait-offer.mov",
|
||||
Sequence: 1,
|
||||
},
|
||||
},
|
||||
Warnings: []string{"model returned promotional phrasing"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAIPolicyValidationFailsWithActionableMessage(t *testing.T) {
|
||||
genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) {
|
||||
return violatingWorkflow{}, nil
|
||||
})
|
||||
t.Cleanup(genkit.ResetWorkflowFactory)
|
||||
|
||||
rootDir := t.TempDir()
|
||||
createAIPolicyFixture(t, filepath.Join(rootDir, "video.mov"))
|
||||
|
||||
rootCmd := renamercmd.NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootCmd.SetOut(&stdout)
|
||||
rootCmd.SetErr(&stderr)
|
||||
rootCmd.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", rootDir,
|
||||
"--dry-run",
|
||||
"--naming-casing", "kebab",
|
||||
"--naming-prefix", "proj",
|
||||
"--banned", "offer",
|
||||
})
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected policy violation error")
|
||||
}
|
||||
|
||||
lines := stderr.String()
|
||||
if !strings.Contains(lines, "Policy violation (prefix)") {
|
||||
t.Fatalf("expected prefix violation message in stderr, got: %s", lines)
|
||||
}
|
||||
if !strings.Contains(lines, "Policy violation (banned)") {
|
||||
t.Fatalf("expected banned token message in stderr, got: %s", lines)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "policy violations") {
|
||||
t.Fatalf("expected error to mention policy violations, got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func createAIPolicyFixture(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("demo"), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
116
tests/integration/ai_preview_flow_test.go
Normal file
116
tests/integration/ai_preview_flow_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
"github.com/rogeecn/renamer/internal/ai/genkit"
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
)
|
||||
|
||||
type stubWorkflow struct {
|
||||
response prompt.RenameResponse
|
||||
}
|
||||
|
||||
func (s stubWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) {
|
||||
return genkit.Result{Response: s.response}, nil
|
||||
}
|
||||
|
||||
func TestAIPreviewFlowRendersSequenceTable(t *testing.T) {
|
||||
workflow := stubWorkflow{
|
||||
response: prompt.RenameResponse{
|
||||
Items: []prompt.RenameItem{
|
||||
{
|
||||
Original: "promo SALE 01.JPG",
|
||||
Proposed: "001_summer-session.jpg",
|
||||
Sequence: 1,
|
||||
Notes: "Removed promotional flair",
|
||||
},
|
||||
{
|
||||
Original: "family_photo.png",
|
||||
Proposed: "002_family-photo.png",
|
||||
Sequence: 2,
|
||||
Notes: "Normalized casing",
|
||||
},
|
||||
},
|
||||
Warnings: []string{"AI warning: trimmed banned tokens"},
|
||||
PromptHash: "",
|
||||
},
|
||||
}
|
||||
|
||||
genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) {
|
||||
return workflow, nil
|
||||
})
|
||||
defer genkit.ResetWorkflowFactory()
|
||||
|
||||
root := t.TempDir()
|
||||
createAIPreviewFile(t, filepath.Join(root, "promo SALE 01.JPG"))
|
||||
createAIPreviewFile(t, filepath.Join(root, "family_photo.png"))
|
||||
|
||||
t.Setenv("default_MODEL_AUTH_TOKEN", "test-token")
|
||||
|
||||
rootCmd := renamercmd.NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootCmd.SetOut(&stdout)
|
||||
rootCmd.SetErr(&stderr)
|
||||
exportPath := filepath.Join(root, "plan.json")
|
||||
rootCmd.SetArgs([]string{
|
||||
"ai",
|
||||
"--path", root,
|
||||
"--dry-run",
|
||||
"--debug-genkit",
|
||||
"--export-plan", exportPath,
|
||||
})
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("command execute: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(exportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read exported plan: %v", err)
|
||||
}
|
||||
|
||||
var exported prompt.RenameResponse
|
||||
if err := json.Unmarshal(data, &exported); err != nil {
|
||||
t.Fatalf("unmarshal exported plan: %v", err)
|
||||
}
|
||||
if len(exported.Items) != len(workflow.response.Items) {
|
||||
t.Fatalf("expected exported items %d, got %d", len(workflow.response.Items), len(exported.Items))
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SEQ") || !strings.Contains(out, "ORIGINAL") || !strings.Contains(out, "SANITIZED") {
|
||||
t.Fatalf("expected table headers in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "001") || !strings.Contains(out, "promo SALE 01.JPG") || !strings.Contains(out, "001_summer-session.jpg") {
|
||||
t.Fatalf("expected first entry in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "removed: promo sale") {
|
||||
t.Fatalf("expected sanitization notes in output, got:\n%s", out)
|
||||
}
|
||||
|
||||
errOut := stderr.String()
|
||||
if !strings.Contains(errOut, "Prompt hash:") {
|
||||
t.Fatalf("expected prompt hash in debug output, got:\n%s", errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "AI warning: trimmed banned tokens") {
|
||||
t.Fatalf("expected warning surfaced in debug output, got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func createAIPreviewFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user