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:
250
internal/ai/plan/apply.go
Normal file
250
internal/ai/plan/apply.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/prompt"
|
||||
"github.com/rogeecn/renamer/internal/history"
|
||||
)
|
||||
|
||||
// ApplyOptions describe the data required to apply an AI rename plan.
|
||||
type ApplyOptions struct {
|
||||
WorkingDir string
|
||||
Candidates []Candidate
|
||||
Response prompt.RenameResponse
|
||||
Policies prompt.NamingPolicyConfig
|
||||
PromptHash string
|
||||
}
|
||||
|
||||
// Apply executes the AI rename plan and records the outcome in the ledger.
|
||||
func Apply(ctx context.Context, opts ApplyOptions) (history.Entry, error) {
|
||||
entry := history.Entry{Command: "ai"}
|
||||
|
||||
if len(opts.Response.Items) == 0 {
|
||||
return entry, errors.New("ai apply: no items to apply")
|
||||
}
|
||||
|
||||
candidateMap := make(map[string]Candidate, len(opts.Candidates))
|
||||
for _, cand := range opts.Candidates {
|
||||
key := strings.ToLower(strings.TrimSpace(cand.OriginalPath))
|
||||
candidateMap[key] = cand
|
||||
}
|
||||
|
||||
type operation struct {
|
||||
sourceRel string
|
||||
targetRel string
|
||||
sourceAbs string
|
||||
targetAbs string
|
||||
depth int
|
||||
}
|
||||
|
||||
ops := make([]operation, 0, len(opts.Response.Items))
|
||||
seenTargets := make(map[string]string)
|
||||
|
||||
conflicts := make([]Conflict, 0)
|
||||
|
||||
for _, item := range opts.Response.Items {
|
||||
key := strings.ToLower(strings.TrimSpace(item.Original))
|
||||
cand, ok := candidateMap[key]
|
||||
if !ok {
|
||||
conflicts = append(conflicts, Conflict{
|
||||
OriginalPath: item.Original,
|
||||
Issue: "missing_candidate",
|
||||
Details: "original file not found in current scope",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(item.Proposed)
|
||||
if target == "" {
|
||||
conflicts = append(conflicts, Conflict{
|
||||
OriginalPath: item.Original,
|
||||
Issue: "empty_target",
|
||||
Details: "proposed name cannot be empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedTarget := filepath.ToSlash(filepath.Clean(target))
|
||||
if strings.HasPrefix(normalizedTarget, "../") {
|
||||
conflicts = append(conflicts, Conflict{
|
||||
OriginalPath: item.Original,
|
||||
Issue: "unsafe_target",
|
||||
Details: "proposed path escapes the working directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
targetKey := strings.ToLower(normalizedTarget)
|
||||
if existing, exists := seenTargets[targetKey]; exists && existing != item.Original {
|
||||
conflicts = append(conflicts, Conflict{
|
||||
OriginalPath: item.Original,
|
||||
Issue: "duplicate_target",
|
||||
Details: fmt.Sprintf("target %q reused", normalizedTarget),
|
||||
})
|
||||
continue
|
||||
}
|
||||
seenTargets[targetKey] = item.Original
|
||||
|
||||
sourceRel := filepath.ToSlash(cand.OriginalPath)
|
||||
sourceAbs := filepath.Join(opts.WorkingDir, filepath.FromSlash(sourceRel))
|
||||
targetAbs := filepath.Join(opts.WorkingDir, filepath.FromSlash(normalizedTarget))
|
||||
|
||||
if sameFile, err := isSameFile(sourceAbs, targetAbs); err != nil {
|
||||
return history.Entry{}, err
|
||||
} else if sameFile {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := os.Stat(targetAbs); err == nil {
|
||||
conflicts = append(conflicts, Conflict{
|
||||
OriginalPath: item.Original,
|
||||
Issue: "target_exists",
|
||||
Details: fmt.Sprintf("target %q already exists", normalizedTarget),
|
||||
})
|
||||
continue
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
op := operation{
|
||||
sourceRel: sourceRel,
|
||||
targetRel: normalizedTarget,
|
||||
sourceAbs: sourceAbs,
|
||||
targetAbs: targetAbs,
|
||||
depth: cand.Depth,
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
return history.Entry{}, ApplyConflictError{Conflicts: conflicts}
|
||||
}
|
||||
|
||||
if len(ops) == 0 {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
sort.SliceStable(ops, func(i, j int) bool {
|
||||
return ops[i].depth > ops[j].depth
|
||||
})
|
||||
|
||||
done := make([]history.Operation, 0, len(ops))
|
||||
|
||||
revert := func() error {
|
||||
for i := len(done) - 1; i >= 0; i-- {
|
||||
op := done[i]
|
||||
src := filepath.Join(opts.WorkingDir, filepath.FromSlash(op.To))
|
||||
dst := filepath.Join(opts.WorkingDir, filepath.FromSlash(op.From))
|
||||
if err := os.Rename(src, dst); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(op.targetAbs); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(op.sourceAbs, op.targetAbs); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
done = append(done, history.Operation{
|
||||
From: op.sourceRel,
|
||||
To: op.targetRel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(done) == 0 {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
entry.Operations = done
|
||||
|
||||
aiMetadata := history.AIMetadata{
|
||||
PromptHash: opts.PromptHash,
|
||||
Model: opts.Response.Model,
|
||||
Policies: prompt.NamingPolicyConfig{
|
||||
Prefix: opts.Policies.Prefix,
|
||||
Casing: opts.Policies.Casing,
|
||||
AllowSpaces: opts.Policies.AllowSpaces,
|
||||
KeepOriginalOrder: opts.Policies.KeepOriginalOrder,
|
||||
ForbiddenTokens: append([]string(nil), opts.Policies.ForbiddenTokens...),
|
||||
},
|
||||
BatchSize: len(done),
|
||||
}
|
||||
|
||||
if hash, err := ResponseDigest(opts.Response); err == nil {
|
||||
aiMetadata.ResponseHash = hash
|
||||
}
|
||||
|
||||
entry.AttachAIMetadata(aiMetadata)
|
||||
|
||||
if err := history.Append(opts.WorkingDir, entry); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ApplyConflictError signals that the plan contained conflicts that block apply.
|
||||
type ApplyConflictError struct {
|
||||
Conflicts []Conflict
|
||||
}
|
||||
|
||||
func (e ApplyConflictError) Error() string {
|
||||
if len(e.Conflicts) == 0 {
|
||||
return "ai apply: conflicts detected"
|
||||
}
|
||||
return fmt.Sprintf("ai apply: %d conflicts detected", len(e.Conflicts))
|
||||
}
|
||||
|
||||
// ResponseDigest returns a hash of the AI response payload for ledger metadata.
|
||||
func ResponseDigest(resp prompt.RenameResponse) (string, error) {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hashBytes(data), nil
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isSameFile(a, b string) (bool, error) {
|
||||
infoA, err := os.Stat(a)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
infoB, err := os.Stat(b)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return os.SameFile(infoA, infoB), nil
|
||||
}
|
||||
Reference in New Issue
Block a user