Files
renamer/internal/ai/plan/apply.go
Rogee 3867736858 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.
2025-11-03 18:08:14 +08:00

251 lines
6.1 KiB
Go

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
}