add ai feature

This commit is contained in:
2025-11-05 16:06:09 +08:00
parent 42bc9aff42
commit 13ca7ddbed
33 changed files with 2194 additions and 30 deletions

121
internal/ai/apply.go Normal file
View File

@@ -0,0 +1,121 @@
package ai
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/ai/flow"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/output"
)
// ApplyMetadata captures contextual information persisted alongside ledger entries.
type ApplyMetadata struct {
Prompt string
PromptHistory []string
Notes []string
Model string
SequenceSeparator string
}
// toMap converts metadata into a ledger-friendly map.
func (m ApplyMetadata) toMap(warnings []string) map[string]any {
data := history.BuildAIMetadata(m.Prompt, m.PromptHistory, m.Notes, m.Model, warnings)
if m.SequenceSeparator != "" {
data["sequenceSeparator"] = m.SequenceSeparator
}
return data
}
// Apply executes the rename suggestions, records a ledger entry, and emits progress updates.
func Apply(ctx context.Context, workingDir string, suggestions []flow.Suggestion, validation ValidationResult, meta ApplyMetadata, writer io.Writer) (history.Entry, error) {
entry := history.Entry{Command: "ai"}
if len(suggestions) == 0 {
return entry, nil
}
reporter := output.NewProgressReporter(writer, len(suggestions))
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Original > suggestions[j].Original
})
operations := make([]history.Operation, 0, len(suggestions))
revert := func() error {
for i := len(operations) - 1; i >= 0; i-- {
op := operations[i]
source := filepath.Join(workingDir, filepath.FromSlash(op.To))
destination := filepath.Join(workingDir, filepath.FromSlash(op.From))
if err := os.Rename(source, destination); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
for _, suggestion := range suggestions {
if err := ctx.Err(); err != nil {
_ = revert()
return history.Entry{}, err
}
fromRel := flowToKey(suggestion.Original)
toRel := flowToKey(suggestion.Suggested)
fromAbs := filepath.Join(workingDir, filepath.FromSlash(fromRel))
toAbs := filepath.Join(workingDir, filepath.FromSlash(toRel))
if fromAbs == toAbs {
continue
}
if err := ensureParentDir(toAbs); err != nil {
_ = revert()
return history.Entry{}, err
}
if err := os.Rename(fromAbs, toAbs); err != nil {
_ = revert()
return history.Entry{}, err
}
operations = append(operations, history.Operation{From: fromRel, To: toRel})
if err := reporter.Step(fromRel, toRel); err != nil {
_ = revert()
return history.Entry{}, err
}
}
if len(operations) == 0 {
return entry, reporter.Complete()
}
if err := reporter.Complete(); err != nil {
_ = revert()
return history.Entry{}, err
}
entry.Operations = operations
entry.Metadata = meta.toMap(validation.Warnings)
if err := history.Append(workingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}
func ensureParentDir(path string) error {
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}

59
internal/ai/client.go Normal file
View File

@@ -0,0 +1,59 @@
package ai
import (
"context"
"errors"
"github.com/rogeecn/renamer/internal/ai/flow"
)
// Runner executes the rename flow and returns structured suggestions.
type Runner func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error)
// Client orchestrates flow invocation for callers such as the CLI command.
type Client struct {
runner Runner
}
// ClientOption customises the AI client behaviour.
type ClientOption func(*Client)
// WithRunner overrides the flow runner implementation (useful for tests).
func WithRunner(r Runner) ClientOption {
return func(c *Client) {
c.runner = r
}
}
// NewClient constructs a Client with the default Genkit-backed runner.
func NewClient(opts ...ClientOption) *Client {
client := &Client{}
client.runner = func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
creds, err := LoadCredentials()
if err != nil {
return nil, err
}
return runRenameFlow(ctx, input, creds)
}
for _, opt := range opts {
opt(client)
}
return client
}
// Suggest executes the rename flow and returns structured suggestions.
func (c *Client) Suggest(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
if c == nil {
return nil, ErrClientNotInitialized
}
if c.runner == nil {
return nil, ErrRunnerNotConfigured
}
return c.runner(ctx, input)
}
// ErrClientNotInitialized indicates the client receiver was nil.
var ErrClientNotInitialized = errors.New("ai client not initialized")
// ErrRunnerNotConfigured indicates the client runner is missing.
var ErrRunnerNotConfigured = errors.New("ai client runner not configured")

41
internal/ai/config.go Normal file
View File

@@ -0,0 +1,41 @@
package ai
import (
"errors"
"fmt"
"os"
)
var apiKeyEnvVars = []string{
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"RENAMER_AI_KEY",
}
// Credentials encapsulates the values required to authenticate with the AI provider.
type Credentials struct {
APIKey string
}
// LoadCredentials returns the AI credentials sourced from environment variables.
func LoadCredentials() (Credentials, error) {
for _, env := range apiKeyEnvVars {
if key, ok := os.LookupEnv(env); ok && key != "" {
return Credentials{APIKey: key}, nil
}
}
return Credentials{}, errors.New("AI provider key missing; set GOOGLE_API_KEY (recommended), GEMINI_API_KEY, or RENAMER_AI_KEY")
}
// MaskedCredentials returns a redacted view of the credentials for logging purposes.
func MaskedCredentials(creds Credentials) string {
if creds.APIKey == "" {
return "(empty)"
}
if len(creds.APIKey) <= 6 {
return "***"
}
return fmt.Sprintf("%s***", creds.APIKey[:3])
}

3
internal/ai/flow/doc.go Normal file
View File

@@ -0,0 +1,3 @@
package flow
// Package flow hosts the Genkit rename flow implementation.

View File

@@ -0,0 +1,7 @@
package flow_test
import "testing"
func TestRenameFlowStub(t *testing.T) {
t.Skip("rename flow implementation pending")
}

50
internal/ai/flow/json.go Normal file
View File

@@ -0,0 +1,50 @@
package flow
import (
"encoding/json"
"errors"
"fmt"
)
// Suggestion represents a single rename mapping emitted by the Genkit flow.
type Suggestion struct {
Original string `json:"original"`
Suggested string `json:"suggested"`
}
// Output wraps the list of suggestions returned by the flow.
type Output struct {
Suggestions []Suggestion `json:"suggestions"`
}
var (
errEmptyResponse = errors.New("genkit flow returned empty response")
errMissingSuggestions = errors.New("genkit flow response missing suggestions")
)
// ParseOutput converts the raw JSON payload into a structured Output.
func ParseOutput(raw []byte) (Output, error) {
if len(raw) == 0 {
return Output{}, errEmptyResponse
}
var out Output
if err := json.Unmarshal(raw, &out); err != nil {
return Output{}, fmt.Errorf("failed to decode genkit output: %w", err)
}
if len(out.Suggestions) == 0 {
return Output{}, errMissingSuggestions
}
return out, nil
}
// MarshalInput serialises the flow input for logging or replay.
func MarshalInput(input any) ([]byte, error) {
buf, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to encode genkit input: %w", err)
}
return buf, nil
}

View File

@@ -0,0 +1,29 @@
你是一个智能文件重命名助手。你的任务是根据用户提供的文件名列表和命名指令,为每个文件生成一个清晰、统一的新名称。
规则:
1. 保持原始文件的扩展名不变。
2. 新文件名中不允许包含非法字符,如 / \\ : * ? \" < > |。
3. 如果需要添加序列号,请先按文件所在的目录维度分组,对每个目录内部的文件进行稳定排序(建议使用原始文件名自然序),序列号放在文件名的开头(例如 "01.假期照片.jpg"),不要放在结尾。序列号和名称之间默认使用句点 (.) 分隔,如果调用方提供了其他分隔符,则使用对应字符。
4. 严格按照以下 JSON 格式返回你的建议,不要包含任何额外的解释或 Markdown 标记。
[INPUT]
用户命名指令: "{{ .UserPrompt }}"
文件名列表:
{{- range .FileNames }}
- {{ . }}
{{- end }}
[OUTPUT]
请在这里输出你的 JSON 结果,格式如下:
{
"suggestions": [
{
"original": "原始文件名1.ext",
"suggested": "建议的新文件名1.ext"
},
{
"original": "原始文件名2.ext",
"suggested": "建议的新文件名2.ext"
}
]
}

View File

@@ -0,0 +1,34 @@
package flow_test
import (
"strings"
"testing"
"github.com/rogeecn/renamer/internal/ai/flow"
)
func TestRenderPromptIncludesFilesAndPrompt(t *testing.T) {
input := flow.RenameFlowInput{
FileNames: []string{"IMG_0001.jpg", "albums/Day 1.png"},
UserPrompt: "按地点重新命名",
}
rendered, err := flow.RenderPrompt(input)
if err != nil {
t.Fatalf("RenderPrompt error: %v", err)
}
for _, expected := range []string{"IMG_0001.jpg", "albums/Day 1.png"} {
if !strings.Contains(rendered, expected) {
t.Fatalf("prompt missing filename %q: %s", expected, rendered)
}
}
if !strings.Contains(rendered, "按地点重新命名") {
t.Fatalf("prompt missing user guidance: %s", rendered)
}
if !strings.Contains(rendered, "suggestions") {
t.Fatalf("prompt missing JSON structure guidance: %s", rendered)
}
}

View File

@@ -0,0 +1,197 @@
package flow
import (
"context"
_ "embed"
"errors"
"fmt"
"path"
"sort"
"strings"
"text/template"
"unicode"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"
)
//go:embed prompt.tmpl
var promptTemplateSource string
var (
promptTemplate = template.Must(template.New("renameFlowPrompt").Parse(promptTemplateSource))
)
// RenameFlowInput mirrors the JSON payload passed into the Genkit flow.
type RenameFlowInput struct {
FileNames []string `json:"fileNames"`
UserPrompt string `json:"userPrompt"`
SequenceSeparator string `json:"sequenceSeparator,omitempty"`
}
// Validate ensures the flow input is well formed.
func (in *RenameFlowInput) Validate() error {
if in == nil {
return errors.New("rename flow input cannot be nil")
}
if len(in.FileNames) == 0 {
return errors.New("no file names provided to rename flow")
}
if len(in.FileNames) > 200 {
return fmt.Errorf("rename flow supports up to 200 files per invocation (received %d)", len(in.FileNames))
}
normalized := make([]string, len(in.FileNames))
for i, name := range in.FileNames {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return fmt.Errorf("file name at index %d is empty", i)
}
normalized[i] = toSlash(trimmed)
}
// Ensure no duplicates to simplify downstream validation.
if dup := firstDuplicate(normalized); dup != "" {
return fmt.Errorf("duplicate file name %q detected in flow input", dup)
}
in.FileNames = normalized
sep := strings.TrimSpace(in.SequenceSeparator)
if sep == "" {
sep = "."
}
if strings.ContainsAny(sep, "/\\") {
return fmt.Errorf("sequence separator %q cannot contain path separators", sep)
}
if strings.ContainsAny(sep, "\n\r") {
return errors.New("sequence separator cannot contain newline characters")
}
in.SequenceSeparator = sep
return nil
}
// RenderPrompt materialises the prompt template for the provided input.
func RenderPrompt(input RenameFlowInput) (string, error) {
if err := input.Validate(); err != nil {
return "", err
}
var builder strings.Builder
if err := promptTemplate.Execute(&builder, input); err != nil {
return "", fmt.Errorf("render rename prompt: %w", err)
}
return builder.String(), nil
}
// Define registers the rename flow on the supplied Genkit instance.
func Define(g *genkit.Genkit) *core.Flow[*RenameFlowInput, *Output, struct{}] {
if g == nil {
panic("genkit instance cannot be nil")
}
return genkit.DefineFlow(g, "renameFlow", flowFn)
}
func flowFn(ctx context.Context, input *RenameFlowInput) (*Output, error) {
if err := input.Validate(); err != nil {
return nil, err
}
prefix := slugify(input.UserPrompt)
suggestions := make([]Suggestion, 0, len(input.FileNames))
dirCounters := make(map[string]int)
for _, name := range input.FileNames {
suggestion := deterministicSuggestion(name, prefix, dirCounters, input.SequenceSeparator)
suggestions = append(suggestions, Suggestion{
Original: name,
Suggested: suggestion,
})
}
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].Original < suggestions[j].Original
})
return &Output{Suggestions: suggestions}, nil
}
func deterministicSuggestion(rel string, promptPrefix string, dirCounters map[string]int, separator string) string {
rel = toSlash(rel)
dir := path.Dir(rel)
if dir == "." {
dir = ""
}
base := path.Base(rel)
ext := path.Ext(base)
name := strings.TrimSuffix(base, ext)
sanitizedName := slugify(name)
candidate := sanitizedName
if promptPrefix != "" {
switch {
case candidate == "":
candidate = promptPrefix
default:
candidate = fmt.Sprintf("%s-%s", promptPrefix, candidate)
}
}
if candidate == "" {
candidate = "renamed"
}
counterKey := dir
dirCounters[counterKey]++
seq := dirCounters[counterKey]
sep := separator
if sep == "" {
sep = "."
}
numbered := fmt.Sprintf("%02d%s%s", seq, sep, candidate)
proposed := numbered + ext
if dir != "" {
return path.Join(dir, proposed)
}
return proposed
}
func slugify(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
b.Grow(len(value))
lastHyphen := false
for _, r := range value {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
lastHyphen = false
case r == ' ' || r == '-' || r == '_' || r == '.':
if !lastHyphen && b.Len() > 0 {
b.WriteRune('-')
lastHyphen = true
}
}
}
result := strings.Trim(b.String(), "-")
return result
}
func toSlash(pathStr string) string {
return strings.ReplaceAll(pathStr, "\\", "/")
}
func firstDuplicate(values []string) string {
seen := make(map[string]struct{}, len(values))
for _, v := range values {
lower := strings.ToLower(v)
if _, exists := seen[lower]; exists {
return v
}
seen[lower] = struct{}{}
}
return ""
}

55
internal/ai/preview.go Normal file
View File

@@ -0,0 +1,55 @@
package ai
import (
"fmt"
"io"
"github.com/rogeecn/renamer/internal/ai/flow"
"github.com/rogeecn/renamer/internal/output"
)
// PrintPreview renders suggestions in a tabular format alongside validation results.
func PrintPreview(w io.Writer, suggestions []flow.Suggestion, validation ValidationResult) error {
table := output.NewAIPlanTable()
if err := table.Begin(w); err != nil {
return err
}
for idx, suggestion := range suggestions {
if err := table.WriteRow(output.AIPlanRow{
Sequence: fmt.Sprintf("%02d", idx+1),
Original: suggestion.Original,
Proposed: suggestion.Suggested,
Sanitized: flowToKey(suggestion.Suggested),
}); err != nil {
return err
}
}
if err := table.End(w); err != nil {
return err
}
for _, warn := range validation.Warnings {
if _, err := fmt.Fprintf(w, "Warning: %s\n", warn); err != nil {
return err
}
}
if len(validation.Conflicts) > 0 {
if _, err := fmt.Fprintln(w, "Conflicts detected:"); err != nil {
return err
}
for _, conflict := range validation.Conflicts {
if _, err := fmt.Fprintf(w, " - %s -> %s (%s)\n", conflict.Original, conflict.Suggested, conflict.Reason); err != nil {
return err
}
}
}
if _, err := fmt.Fprintf(w, "Previewed %d suggestion(s)\n", len(suggestions)); err != nil {
return err
}
return nil
}

54
internal/ai/runtime.go Normal file
View File

@@ -0,0 +1,54 @@
package ai
import (
"context"
"os"
"sync"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"
"github.com/firebase/genkit/go/plugins/googlegenai"
"google.golang.org/genai"
"github.com/rogeecn/renamer/internal/ai/flow"
)
var (
runtimeOnce sync.Once
runtimeErr error
runtimeFlow *core.Flow[*flow.RenameFlowInput, *flow.Output, struct{}]
)
func ensureRuntime(creds Credentials) error {
runtimeOnce.Do(func() {
ctx := context.Background()
geminiBase := os.Getenv("GOOGLE_GEMINI_BASE_URL")
vertexBase := os.Getenv("GOOGLE_VERTEX_BASE_URL")
if geminiBase != "" || vertexBase != "" {
genai.SetDefaultBaseURLs(genai.BaseURLParameters{
GeminiURL: geminiBase,
VertexURL: vertexBase,
})
}
plugin := &googlegenai.GoogleAI{APIKey: creds.APIKey}
g := genkit.Init(ctx,
genkit.WithPlugins(plugin),
genkit.WithDefaultModel(defaultModelID),
)
runtimeFlow = flow.Define(g)
})
return runtimeErr
}
func runRenameFlow(ctx context.Context, input *flow.RenameFlowInput, creds Credentials) (*flow.Output, error) {
if err := ensureRuntime(creds); err != nil {
return nil, err
}
if runtimeFlow == nil {
return nil, runtimeErr
}
return runtimeFlow.Run(ctx, input)
}

138
internal/ai/session.go Normal file
View File

@@ -0,0 +1,138 @@
package ai
import (
"context"
"strings"
flowpkg "github.com/rogeecn/renamer/internal/ai/flow"
)
const defaultModelID = "googleai/gemini-1.5-flash"
// Session tracks prompt history and guidance notes for a single AI preview loop.
type Session struct {
files []string
client *Client
prompts []string
notes []string
model string
sequenceSeparator string
lastOutput *flowpkg.Output
lastValidation ValidationResult
}
// NewSession builds a session with the provided scope, initial prompt, and client.
func NewSession(files []string, initialPrompt string, sequenceSeparator string, client *Client) *Session {
prompts := []string{strings.TrimSpace(initialPrompt)}
if prompts[0] == "" {
prompts[0] = ""
}
if client == nil {
client = NewClient()
}
sep := strings.TrimSpace(sequenceSeparator)
if sep == "" {
sep = "."
}
return &Session{
files: append([]string(nil), files...),
client: client,
prompts: prompts,
notes: make([]string, 0),
model: defaultModelID,
sequenceSeparator: sep,
}
}
// Generate executes the flow and returns structured suggestions with validation.
func (s *Session) Generate(ctx context.Context) (*flowpkg.Output, ValidationResult, error) {
prompt := s.CurrentPrompt()
input := &flowpkg.RenameFlowInput{
FileNames: append([]string(nil), s.files...),
UserPrompt: prompt,
SequenceSeparator: s.sequenceSeparator,
}
output, err := s.client.Suggest(ctx, input)
if err != nil {
return nil, ValidationResult{}, err
}
validation := ValidateSuggestions(s.files, output.Suggestions)
s.lastOutput = output
s.lastValidation = validation
return output, validation, nil
}
// CurrentPrompt returns the most recent prompt in the session.
func (s *Session) CurrentPrompt() string {
if len(s.prompts) == 0 {
return ""
}
return s.prompts[len(s.prompts)-1]
}
// UpdatePrompt records a new prompt and adds a note for auditing.
func (s *Session) UpdatePrompt(prompt string) {
trimmed := strings.TrimSpace(prompt)
s.prompts = append(s.prompts, trimmed)
s.notes = append(s.notes, "prompt updated")
}
// RecordRegeneration appends an audit note for regenerations.
func (s *Session) RecordRegeneration() {
s.notes = append(s.notes, "regenerated suggestions")
}
// RecordAcceptance stores an audit note for accepted previews.
func (s *Session) RecordAcceptance() {
s.notes = append(s.notes, "accepted preview")
}
// PromptHistory returns a copy of the recorded prompts.
func (s *Session) PromptHistory() []string {
history := make([]string, len(s.prompts))
copy(history, s.prompts)
return history
}
// Notes returns audit notes collected during the session.
func (s *Session) Notes() []string {
copied := make([]string, len(s.notes))
copy(copied, s.notes)
return copied
}
// Files returns the original scoped filenames.
func (s *Session) Files() []string {
copied := make([]string, len(s.files))
copy(copied, s.files)
return copied
}
// SequenceSeparator returns the configured sequence separator.
func (s *Session) SequenceSeparator() string {
return s.sequenceSeparator
}
// LastOutput returns the most recent flow output.
func (s *Session) LastOutput() *flowpkg.Output {
return s.lastOutput
}
// LastValidation returns the validation result for the most recent output.
func (s *Session) LastValidation() ValidationResult {
return s.lastValidation
}
// Model returns the model identifier associated with the session.
func (s *Session) Model() string {
if s.model == "" {
return defaultModelID
}
return s.model
}

169
internal/ai/validation.go Normal file
View File

@@ -0,0 +1,169 @@
package ai
import (
"fmt"
"path"
"strings"
"github.com/rogeecn/renamer/internal/ai/flow"
)
var invalidCharacters = []rune{'/', '\\', ':', '*', '?', '"', '<', '>', '|'}
// Conflict captures a validation failure for a proposed rename.
type Conflict struct {
Original string
Suggested string
Reason string
}
// ValidationResult aggregates conflicts and warnings.
type ValidationResult struct {
Conflicts []Conflict
Warnings []string
}
// ValidateSuggestions enforces rename safety rules before applying suggestions.
func ValidateSuggestions(expected []string, suggestions []flow.Suggestion) ValidationResult {
result := ValidationResult{}
expectedSet := make(map[string]struct{}, len(expected))
for _, name := range expected {
expectedSet[strings.ToLower(flowToKey(name))] = struct{}{}
}
seenTargets := make(map[string]string)
for _, suggestion := range suggestions {
key := strings.ToLower(flowToKey(suggestion.Original))
if _, ok := expectedSet[key]; !ok {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "original file not present in scope",
})
continue
}
cleaned := strings.TrimSpace(suggestion.Suggested)
if cleaned == "" {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name is empty",
})
continue
}
normalizedOriginal := flowToKey(suggestion.Original)
normalizedSuggested := flowToKey(cleaned)
if strings.HasPrefix(normalizedSuggested, "/") {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name must be relative",
})
continue
}
if containsParentSegment(normalizedSuggested) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name cannot traverse directories",
})
continue
}
base := path.Base(cleaned)
if containsInvalidCharacter(base) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "suggested name contains invalid characters",
})
continue
}
if !extensionsMatch(suggestion.Original, cleaned) {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "file extension changed",
})
continue
}
if path.Dir(normalizedOriginal) != path.Dir(normalizedSuggested) {
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q moves file to a different directory", suggestion.Original))
}
targetKey := strings.ToLower(normalizedSuggested)
if existing, ok := seenTargets[targetKey]; ok && existing != suggestion.Original {
result.Conflicts = append(result.Conflicts, Conflict{
Original: suggestion.Original,
Suggested: suggestion.Suggested,
Reason: "duplicate target generated",
})
continue
}
seenTargets[targetKey] = suggestion.Original
if normalizedOriginal == normalizedSuggested {
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q does not change the filename", suggestion.Original))
}
}
if len(suggestions) != len(expected) {
result.Warnings = append(result.Warnings, fmt.Sprintf("expected %d suggestions but received %d", len(expected), len(suggestions)))
}
return result
}
func flowToKey(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
}
func containsInvalidCharacter(value string) bool {
for _, ch := range invalidCharacters {
if strings.ContainsRune(value, ch) {
return true
}
}
return false
}
func extensionsMatch(original, proposed string) bool {
origExt := strings.ToLower(path.Ext(original))
propExt := strings.ToLower(path.Ext(proposed))
return origExt == propExt
}
// SummarizeConflicts renders a human-readable summary of conflicts.
func SummarizeConflicts(conflicts []Conflict) string {
if len(conflicts) == 0 {
return ""
}
builder := strings.Builder{}
for _, c := range conflicts {
builder.WriteString(fmt.Sprintf("%s -> %s (%s); ", c.Original, c.Suggested, c.Reason))
}
return strings.TrimSpace(builder.String())
}
// SummarizeWarnings renders warnings as a delimited string.
func SummarizeWarnings(warnings []string) string {
return strings.Join(warnings, "; ")
}
func containsParentSegment(value string) bool {
parts := strings.Split(value, "/")
for _, part := range parts {
if part == ".." {
return true
}
}
return false
}

View File

@@ -0,0 +1,21 @@
package history
// BuildAIMetadata constructs ledger metadata for AI-driven rename batches.
func BuildAIMetadata(prompt string, promptHistory []string, notes []string, model string, warnings []string) map[string]any {
data := map[string]any{
"prompt": prompt,
"model": model,
"flow": "renameFlow",
"warnings": warnings,
}
if len(promptHistory) > 0 {
data["promptHistory"] = append([]string(nil), promptHistory...)
}
if len(notes) > 0 {
data["notes"] = append([]string(nil), notes...)
}
return data
}

View File

@@ -0,0 +1,40 @@
package output
import (
"fmt"
"io"
)
// ProgressReporter prints textual progress for rename operations.
type ProgressReporter struct {
writer io.Writer
total int
count int
}
// NewProgressReporter constructs a reporter bound to the supplied writer.
func NewProgressReporter(w io.Writer, total int) *ProgressReporter {
if w == nil {
w = io.Discard
}
return &ProgressReporter{writer: w, total: total}
}
// Step registers a completed operation and prints the progress.
func (r *ProgressReporter) Step(from, to string) error {
if r == nil {
return nil
}
r.count++
_, err := fmt.Fprintf(r.writer, "[%d/%d] %s -> %s\n", r.count, r.total, from, to)
return err
}
// Complete emits a summary line after all operations finish.
func (r *ProgressReporter) Complete() error {
if r == nil {
return nil
}
_, err := fmt.Fprintf(r.writer, "Completed %d rename(s).\n", r.count)
return err
}