- 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.
251 lines
6.1 KiB
Go
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
|
|
}
|