- 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.
202 lines
5.0 KiB
Go
202 lines
5.0 KiB
Go
package prompt
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultMaxSamples = 10
|
|
|
|
// SequenceRule captures the numbering instructions forwarded to the AI.
|
|
type SequenceRule struct {
|
|
Style string
|
|
Width int
|
|
Start int
|
|
Separator string
|
|
}
|
|
|
|
// PolicyConfig enumerates naming policy directives for the AI prompt.
|
|
type PolicyConfig struct {
|
|
Prefix string
|
|
Casing string
|
|
AllowSpaces bool
|
|
KeepOriginalOrder bool
|
|
ForbiddenTokens []string
|
|
}
|
|
|
|
// SampleCandidate represents a traversal sample considered for inclusion in the prompt.
|
|
type SampleCandidate struct {
|
|
RelativePath string
|
|
SizeBytes int64
|
|
Depth int
|
|
}
|
|
|
|
// BuildInput aggregates the contextual data required to assemble the AI prompt payload.
|
|
type BuildInput struct {
|
|
WorkingDir string
|
|
Samples []SampleCandidate
|
|
TotalCount int
|
|
Sequence SequenceRule
|
|
Policies PolicyConfig
|
|
BannedTerms []string
|
|
Metadata map[string]string
|
|
}
|
|
|
|
// Builder constructs RenamePrompt payloads from traversal context.
|
|
type Builder struct {
|
|
maxSamples int
|
|
clock func() time.Time
|
|
}
|
|
|
|
// Option mutates builder configuration.
|
|
type Option func(*Builder)
|
|
|
|
// WithMaxSamples overrides the number of sampled files emitted in the prompt (default 10).
|
|
func WithMaxSamples(n int) Option {
|
|
return func(b *Builder) {
|
|
if n > 0 {
|
|
b.maxSamples = n
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithClock injects a deterministic clock for metadata generation.
|
|
func WithClock(clock func() time.Time) Option {
|
|
return func(b *Builder) {
|
|
if clock != nil {
|
|
b.clock = clock
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewBuilder instantiates a Builder with default configuration.
|
|
func NewBuilder(opts ...Option) *Builder {
|
|
builder := &Builder{
|
|
maxSamples: defaultMaxSamples,
|
|
clock: time.Now().UTC,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(builder)
|
|
}
|
|
return builder
|
|
}
|
|
|
|
// Build produces a RenamePrompt populated with traversal context.
|
|
func (b *Builder) Build(input BuildInput) (RenamePrompt, error) {
|
|
if strings.TrimSpace(input.WorkingDir) == "" {
|
|
return RenamePrompt{}, errors.New("prompt builder: working directory required")
|
|
}
|
|
if input.TotalCount <= 0 {
|
|
return RenamePrompt{}, errors.New("prompt builder: total count must be positive")
|
|
}
|
|
if strings.TrimSpace(input.Sequence.Style) == "" {
|
|
return RenamePrompt{}, errors.New("prompt builder: sequence style required")
|
|
}
|
|
if input.Sequence.Width <= 0 {
|
|
return RenamePrompt{}, errors.New("prompt builder: sequence width must be positive")
|
|
}
|
|
if input.Sequence.Start <= 0 {
|
|
return RenamePrompt{}, errors.New("prompt builder: sequence start must be positive")
|
|
}
|
|
if strings.TrimSpace(input.Policies.Casing) == "" {
|
|
return RenamePrompt{}, errors.New("prompt builder: naming casing required")
|
|
}
|
|
|
|
samples := make([]SampleCandidate, 0, len(input.Samples))
|
|
for _, sample := range input.Samples {
|
|
if strings.TrimSpace(sample.RelativePath) == "" {
|
|
continue
|
|
}
|
|
samples = append(samples, sample)
|
|
}
|
|
|
|
sort.Slice(samples, func(i, j int) bool {
|
|
a := strings.ToLower(samples[i].RelativePath)
|
|
b := strings.ToLower(samples[j].RelativePath)
|
|
if a == b {
|
|
return samples[i].RelativePath < samples[j].RelativePath
|
|
}
|
|
return a < b
|
|
})
|
|
|
|
max := b.maxSamples
|
|
if max <= 0 || max > len(samples) {
|
|
max = len(samples)
|
|
}
|
|
|
|
promptSamples := make([]PromptSample, 0, max)
|
|
for i := 0; i < max; i++ {
|
|
sample := samples[i]
|
|
ext := filepath.Ext(sample.RelativePath)
|
|
promptSamples = append(promptSamples, PromptSample{
|
|
OriginalName: sample.RelativePath,
|
|
Extension: ext,
|
|
SizeBytes: sample.SizeBytes,
|
|
PathDepth: sample.Depth,
|
|
})
|
|
}
|
|
|
|
banned := normalizeBannedTerms(input.BannedTerms)
|
|
|
|
metadata := make(map[string]string, len(input.Metadata)+1)
|
|
for k, v := range input.Metadata {
|
|
if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
metadata[k] = v
|
|
}
|
|
metadata["generatedAt"] = b.clock().Format(time.RFC3339)
|
|
|
|
return RenamePrompt{
|
|
WorkingDir: promptAbs(input.WorkingDir),
|
|
Samples: promptSamples,
|
|
TotalCount: input.TotalCount,
|
|
SequenceRule: SequenceRuleConfig{
|
|
Style: input.Sequence.Style,
|
|
Width: input.Sequence.Width,
|
|
Start: input.Sequence.Start,
|
|
Separator: input.Sequence.Separator,
|
|
},
|
|
Policies: NamingPolicyConfig{
|
|
Prefix: input.Policies.Prefix,
|
|
Casing: input.Policies.Casing,
|
|
AllowSpaces: input.Policies.AllowSpaces,
|
|
KeepOriginalOrder: input.Policies.KeepOriginalOrder,
|
|
ForbiddenTokens: append([]string(nil), input.Policies.ForbiddenTokens...),
|
|
},
|
|
BannedTerms: banned,
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func promptAbs(dir string) string {
|
|
return strings.TrimSpace(dir)
|
|
}
|
|
|
|
func normalizeBannedTerms(values []string) []string {
|
|
unique := make(map[string]struct{})
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(trimmed)
|
|
if lower == "" {
|
|
continue
|
|
}
|
|
unique[lower] = struct{}{}
|
|
}
|
|
if len(unique) == 0 {
|
|
return nil
|
|
}
|
|
terms := make([]string, 0, len(unique))
|
|
for term := range unique {
|
|
terms = append(terms, term)
|
|
}
|
|
sort.Strings(terms)
|
|
return terms
|
|
}
|