diff --git a/cmd/ai.go b/cmd/ai.go deleted file mode 100644 index d26d7a2..0000000 --- a/cmd/ai.go +++ /dev/null @@ -1,449 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/spf13/cobra" - - "github.com/rogeecn/renamer/internal/ai/genkit" - "github.com/rogeecn/renamer/internal/ai/plan" - "github.com/rogeecn/renamer/internal/ai/prompt" - "github.com/rogeecn/renamer/internal/listing" - "github.com/rogeecn/renamer/internal/output" -) - -type aiCommandOptions struct { - Model string - Debug bool -} - -const aiPlanFilename = "renamer.plan.json" - -// newAICommand 构建 `renamer ai` 子命令,仅保留模型选择与调试标志,其他策略交由 AI 自行生成。 -func newAICommand() *cobra.Command { - ops := &aiCommandOptions{} - - cmd := &cobra.Command{ - Use: "ai", - Short: "Generate rename plans using the AI workflow", - Long: "Invoke the embedded AI workflow to generate, validate, and optionally apply rename plans.", - Example: strings.TrimSpace(` # Generate a plan for review in renamer.plan.json - renamer ai --path ./photos --dry-run - - # Apply the reviewed plan after confirming the preview - renamer ai --path ./photos --yes`), - RunE: func(cmd *cobra.Command, args []string) error { - options := collectAIOptions(cmd, ops) - return runAICommand(cmd.Context(), cmd, options) - }, - } - - bindAIFlags(cmd, ops) - - return cmd -} - -func bindAIFlags(cmd *cobra.Command, opts *aiCommandOptions) { - cmd.Flags(). - StringVar(&opts.Model, "genkit-model", genkit.DefaultModelName, fmt.Sprintf("OpenAI-compatible model identifier (default %s)", genkit.DefaultModelName)) - cmd.Flags().BoolVar(&opts.Debug, "debug-genkit", false, "Write Genkit prompt/response traces to the debug log") -} - -func collectAIOptions(cmd *cobra.Command, defaults *aiCommandOptions) aiCommandOptions { - result := aiCommandOptions{ - Model: genkit.DefaultModelName, - Debug: false, - } - - if defaults != nil { - if defaults.Model != "" { - result.Model = defaults.Model - } - result.Debug = defaults.Debug - } - - if flag := cmd.Flags().Lookup("genkit-model"); flag != nil { - if value, err := cmd.Flags().GetString("genkit-model"); err == nil && value != "" { - result.Model = value - } - } - - if flag := cmd.Flags().Lookup("debug-genkit"); flag != nil { - if value, err := cmd.Flags().GetBool("debug-genkit"); err == nil { - result.Debug = value - } - } - - return result -} - -// runAICommand 按以下顺序执行 AI 重命名流程: -// 1. 解析作用范围与是否需要立即应用; -// 2. 自动探测工作目录下的 renamer.plan.json,决定是加载人工调整还是生成新计划; -// 3. 收集候选文件并过滤生成过程中的辅助文件; -// 4. 通过 Genkit 工作流调用模型生成方案或读取既有方案; -// 5. 保存/更新本地计划文件,随后校验、渲染预览并输出冲突与告警; -// 6. 在用户确认后执行改名并记录账本。 -func runAICommand(ctx context.Context, cmd *cobra.Command, options aiCommandOptions) error { - scope, err := listing.ScopeFromCmd(cmd) - if err != nil { - return err - } - - applyRequested, err := getBool(cmd, "yes") - if err != nil { - return err - } - - // 探测当前目录下的计划文件,支持人工预处理后再运行。 - planPath := filepath.Join(scope.WorkingDir, aiPlanFilename) - planExists := false - if info, err := os.Stat(planPath); err == nil { - if info.IsDir() { - return fmt.Errorf("plan file %s is a directory", planPath) - } - planExists = true - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("plan file %s: %w", planPath, err) - } - - // 默认策略完全交由提示模板处理,仅保留基础禁止词。 - casing := "kebab" - bannedTerms := defaultBannedTerms() - - // 收集所有候选文件,剔除计划文件自身避免被改名。 - candidates, err := plan.CollectCandidates(ctx, scope) - if err != nil { - return err - } - ignoreSet := buildIgnoreSet(scope.WorkingDir, planPath) - if len(ignoreSet) > 0 { - candidates = filterIgnoredCandidates(candidates, ignoreSet) - } - if len(candidates) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No candidates found") - return nil - } - - samples := make([]prompt.SampleCandidate, 0, len(candidates)) - for _, candidate := range candidates { - samples = append(samples, prompt.SampleCandidate{ - RelativePath: candidate.OriginalPath, - SizeBytes: candidate.SizeBytes, - Depth: candidate.Depth, - }) - } - - sequence := prompt.SequenceRule{ - Style: "prefix", - Width: 3, - Start: 1, - Separator: "_", - } - - policies := prompt.PolicyConfig{ - Prefix: "", - Casing: casing, - AllowSpaces: false, - KeepOriginalOrder: false, - ForbiddenTokens: append([]string(nil), bannedTerms...), - } - validatorPolicy := prompt.NamingPolicyConfig{ - Prefix: policies.Prefix, - Casing: policies.Casing, - AllowSpaces: policies.AllowSpaces, - KeepOriginalOrder: policies.KeepOriginalOrder, - ForbiddenTokens: append([]string(nil), policies.ForbiddenTokens...), - } - - var response prompt.RenameResponse - var promptHash string - var model string - - if planExists { - // 若检测到已有计划,则优先加载人工编辑的方案继续校验/执行。 - resp, err := plan.LoadResponse(planPath) - if err != nil { - return err - } - response = resp - promptHash = strings.TrimSpace(resp.PromptHash) - model = strings.TrimSpace(resp.Model) - if model == "" { - model = options.Model - } - } else { - // 没有计划文件时,调用 Genkit 工作流生成全新方案。 - builder := prompt.NewBuilder() - promptPayload, err := builder.Build(prompt.BuildInput{ - WorkingDir: scope.WorkingDir, - Samples: samples, - TotalCount: len(candidates), - Sequence: sequence, - Policies: policies, - BannedTerms: bannedTerms, - Metadata: map[string]string{ - "cliVersion": "dev", - }, - }) - if err != nil { - return err - } - - instructions := composeInstructions(sequence, policies, bannedTerms) - client := genkit.NewClient(genkit.ClientOptions{Model: options.Model}) - invocationResult, err := client.Invoke(ctx, genkit.Invocation{ - Instructions: instructions, - Prompt: promptPayload, - Model: options.Model, - }) - if err != nil { - return err - } - response = invocationResult.Response - promptHash = invocationResult.PromptHash - model = invocationResult.Response.Model - } - - if promptHash == "" { - if hash, err := plan.ResponseDigest(response); err == nil { - promptHash = hash - } - } - if model == "" { - model = options.Model - } - response.PromptHash = promptHash - response.Model = model - - // 将生成或加载的计划写回本地,便于后续人工审核或复用。 - if err := plan.SaveResponse(planPath, response); err != nil { - return err - } - message := "AI plan saved to %s\n" - if planExists { - message = "AI plan updated at %s\n" - } - fmt.Fprintf(cmd.ErrOrStderr(), message, planPath) - - originals := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - originals = append(originals, candidate.OriginalPath) - } - - validator := plan.NewValidator(originals, validatorPolicy, bannedTerms) - validationResult, err := validator.Validate(response) - if err != nil { - var vErr *plan.ValidationError - if errors.As(err, &vErr) { - errorWriter := cmd.ErrOrStderr() - if len(vErr.PolicyViolations) > 0 { - messages := make([]output.PolicyViolationMessage, 0, len(vErr.PolicyViolations)) - for _, violation := range vErr.PolicyViolations { - messages = append(messages, output.PolicyViolationMessage{ - Original: violation.Original, - Proposed: violation.Proposed, - Rule: violation.Rule, - Message: violation.Message, - }) - } - output.WritePolicyViolations(errorWriter, messages) - } - } - return err - } - - previewPlan, err := plan.MapResponse(plan.MapInput{ - Candidates: candidates, - SequenceWidth: sequence.Width, - }, validationResult) - if err != nil { - return err - } - previewPlan.PromptHash = promptHash - if previewPlan.Model == "" { - previewPlan.Model = model - } - - // 输出预览表格与告警,帮助用户确认重命名提案。 - if err := renderAIPlan(cmd.OutOrStdout(), previewPlan); err != nil { - return err - } - - errorWriter := cmd.ErrOrStderr() - if len(previewPlan.Conflicts) > 0 { - for _, conflict := range previewPlan.Conflicts { - fmt.Fprintf(errorWriter, "Conflict (%s): %s %s\n", conflict.Issue, conflict.OriginalPath, conflict.Details) - } - } - - if options.Debug { - output.WriteAIPlanDebug(errorWriter, promptHash, previewPlan.Warnings) - } else if len(previewPlan.Warnings) > 0 { - output.WriteAIPlanDebug(errorWriter, "", previewPlan.Warnings) - } - - if !applyRequested { - return nil - } - - if len(previewPlan.Conflicts) > 0 { - return fmt.Errorf("cannot apply AI plan while conflicts remain") - } - - // 在无冲突且用户确认的情况下,按计划执行并记录到账本。 - applyEntry, err := plan.Apply(ctx, plan.ApplyOptions{ - WorkingDir: scope.WorkingDir, - Candidates: candidates, - Response: response, - Policies: validatorPolicy, - PromptHash: promptHash, - }) - if err != nil { - var conflictErr plan.ApplyConflictError - if errors.As(err, &conflictErr) { - for _, conflict := range conflictErr.Conflicts { - fmt.Fprintf( - errorWriter, - "Apply conflict (%s): %s %s\n", - conflict.Issue, - conflict.OriginalPath, - conflict.Details, - ) - } - } - return err - } - - fmt.Fprintf(cmd.OutOrStdout(), "Applied %d renames. Ledger updated.\n", len(applyEntry.Operations)) - return nil -} - -func renderAIPlan(w io.Writer, preview plan.PreviewPlan) error { - table := output.NewAIPlanTable() - if err := table.Begin(w); err != nil { - return err - } - for _, entry := range preview.Entries { - sanitized := "-" - if len(entry.SanitizedSegments) > 0 { - joined := strings.Join(entry.SanitizedSegments, " ") - sanitized = "removed: " + joined - } - if entry.Notes != "" { - if sanitized == "-" { - sanitized = entry.Notes - } else { - sanitized = fmt.Sprintf("%s (%s)", sanitized, entry.Notes) - } - } - row := output.AIPlanRow{ - Sequence: entry.SequenceLabel, - Original: entry.OriginalPath, - Proposed: entry.ProposedPath, - Sanitized: sanitized, - } - if err := table.WriteRow(row); err != nil { - return err - } - } - return table.End(w) -} - -func composeInstructions(sequence prompt.SequenceRule, policies prompt.PolicyConfig, bannedTerms []string) string { - lines := []string{ - "You are an AI assistant that proposes safe file rename plans.", - "Return JSON matching this schema: {\"items\":[{\"original\":string,\"proposed\":string,\"sequence\":number,\"notes\"?:string}],\"warnings\"?:[string]}.", - fmt.Sprintf( - "Use %s numbering with width %d starting at %d and separator %q.", - sequence.Style, - sequence.Width, - sequence.Start, - sequence.Separator, - ), - "Preserve original file extensions exactly as provided.", - fmt.Sprintf("Apply %s casing to filename stems and avoid promotional or banned terms.", policies.Casing), - "Ensure proposed names are unique and sequences remain contiguous.", - } - if policies.Prefix != "" { - lines = append( - lines, - fmt.Sprintf( - "Every proposed filename must begin with the prefix %q immediately before descriptive text.", - policies.Prefix, - ), - ) - } - if policies.AllowSpaces { - lines = append(lines, "Spaces in filenames are permitted when they improve clarity.") - } else { - lines = append(lines, "Do not include spaces in filenames; use separators consistent with the requested casing style.") - } - if policies.KeepOriginalOrder { - lines = append(lines, "Preserve the original ordering of meaningful words when generating new stems.") - } - if len(bannedTerms) > 0 { - lines = append( - lines, - fmt.Sprintf( - "Never include these banned tokens (case-insensitive) in any proposed filename: %s.", - strings.Join(bannedTerms, ", "), - ), - ) - } - return strings.Join(lines, "\n") -} - -func buildIgnoreSet(workingDir string, paths ...string) map[string]struct{} { - ignore := make(map[string]struct{}) - for _, path := range paths { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - continue - } - abs, err := filepath.Abs(trimmed) - if err != nil { - continue - } - rel, err := filepath.Rel(workingDir, abs) - if err != nil { - continue - } - if strings.HasPrefix(rel, "..") { - continue - } - ignore[strings.ToLower(filepath.ToSlash(rel))] = struct{}{} - } - return ignore -} - -func filterIgnoredCandidates(candidates []plan.Candidate, ignore map[string]struct{}) []plan.Candidate { - if len(ignore) == 0 { - return candidates - } - filtered := make([]plan.Candidate, 0, len(candidates)) - for _, cand := range candidates { - if _, skip := ignore[strings.ToLower(cand.OriginalPath)]; skip { - continue - } - filtered = append(filtered, cand) - } - return filtered -} - -func defaultBannedTerms() []string { - terms := []string{"promo", "sale", "free", "clickbait", "sponsored"} - sort.Strings(terms) - return terms -} - -func init() { - rootCmd.AddCommand(newAICommand()) -} diff --git a/cmd/root.go b/cmd/root.go index f7fc846..fbfc0b5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -53,7 +53,6 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newRegexCommand()) cmd.AddCommand(newSequenceCommand()) cmd.AddCommand(newUndoCommand()) - cmd.AddCommand(newAICommand()) return cmd } diff --git a/cmd/undo.go b/cmd/undo.go index ef70ff3..179972a 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -56,9 +56,6 @@ func newUndoCommand() *cobra.Command { fmt.Fprintf(out, "Template restored to %q\n", template) } } - if aiMeta, ok := entry.AIMetadata(); ok { - fmt.Fprintf(out, "AI batch restored (model=%s, promptHash=%s, files=%d)\n", aiMeta.Model, aiMeta.PromptHash, aiMeta.BatchSize) - } } return nil diff --git a/internal/ai/config/token_store.go b/internal/ai/config/token_store.go deleted file mode 100644 index e283719..0000000 --- a/internal/ai/config/token_store.go +++ /dev/null @@ -1,250 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "sync" - "unicode" - - "github.com/joho/godotenv" -) - -const ( - configDirEnvVar = "RENAMER_CONFIG_DIR" - configFileName = ".renamer" - defaultVendorSlug = "openai" - - vendorTokenSuffix = "_TOKEN" - - errTokenNotFoundFmt = "model token %q not found in %s or the process environment" -) - -// TokenProvider resolves API tokens for AI models. -type TokenProvider interface { - ResolveModelToken(model string) (string, error) -} - -// TokenStore loads model authentication tokens from ~/.config/.renamer. -type TokenStore struct { - configDir string - - once sync.Once - values map[string]string - err error -} - -// NewTokenStore constructs a TokenStore rooted at configDir. When configDir is -// empty the default path of `$HOME/.config/.renamer` is used. An environment -// override can be supplied via RENAMER_CONFIG_DIR. -func NewTokenStore(configDir string) (*TokenStore, error) { - root := configDir - if root == "" { - if override := strings.TrimSpace(os.Getenv(configDirEnvVar)); override != "" { - root = override - } else { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("resolve user home: %w", err) - } - root = filepath.Join(home, ".config", configFileName) - } - } - - return &TokenStore{ - configDir: root, - values: make(map[string]string), - }, nil -} - -// ConfigDir returns the directory the token store reads from. -func (s *TokenStore) ConfigDir() string { - return s.configDir -} - -// ResolveModelToken returns the token for the provided model name. Model names -// are normalized to match the `_TOKEN` convention documented -// for the CLI. Environment variables take precedence over file-based tokens. -func (s *TokenStore) ResolveModelToken(model string) (string, error) { - key := ModelTokenKey(model) - return s.lookup(key) -} - -// lookup loads the requested key from either the environment or cached tokens. -func (s *TokenStore) lookup(key string) (string, error) { - if strings.TrimSpace(key) == "" { - return "", errors.New("token key must not be empty") - } - - if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { - return strings.TrimSpace(val), nil - } - - if err := s.ensureLoaded(); err != nil { - return "", err - } - - if val, ok := s.values[key]; ok && strings.TrimSpace(val) != "" { - return strings.TrimSpace(val), nil - } - - return "", fmt.Errorf(errTokenNotFoundFmt, key, s.configFilePath()) -} - -func (s *TokenStore) ensureLoaded() error { - s.once.Do(func() { - s.err = s.loadConfigFile() - }) - return s.err -} - -func (s *TokenStore) loadConfigFile() error { - path := s.configFilePath() - envMap, err := godotenv.Read(path) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("load %s: %w", path, err) - } - for k, v := range envMap { - if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" { - continue - } - s.values[k] = strings.TrimSpace(v) - } - return nil -} - -func (s *TokenStore) configFilePath() string { - info, err := os.Stat(s.configDir) - if err == nil { - if info.IsDir() { - return filepath.Join(s.configDir, configFileName) - } - return s.configDir - } - if strings.HasSuffix(s.configDir, configFileName) { - return s.configDir - } - return filepath.Join(s.configDir, configFileName) -} - -// ModelTokenKey derives the vendor token key for the provided model, following -// the `_TOKEN` convention. When the vendor cannot be inferred the -// default OpenAI slug is returned. -func ModelTokenKey(model string) string { - slug := vendorSlugFromModel(model) - if slug == "" { - slug = defaultVendorSlug - } - return strings.ToUpper(slug) + vendorTokenSuffix -} - -func vendorSlugFromModel(model string) string { - normalized := strings.ToLower(strings.TrimSpace(model)) - if normalized == "" { - return defaultVendorSlug - } - - if explicit := explicitVendorPrefix(normalized); explicit != "" { - return explicit - } - - for _, mapping := range vendorHintTable { - for _, hint := range mapping.hints { - if strings.Contains(normalized, hint) { - return mapping.vendor - } - } - } - - if firstToken := leadingToken(normalized); firstToken != "" { - return slugify(firstToken) - } - - if slug := slugify(normalized); slug != "" { - return slug - } - - return defaultVendorSlug -} - -func explicitVendorPrefix(value string) string { - separators := func(r rune) bool { - switch r { - case '/', ':', '@': - return true - } - return false - } - parts := strings.FieldsFunc(value, separators) - if len(parts) > 1 { - if slug := slugify(parts[0]); slug != "" { - return slug - } - } - return "" -} - -func leadingToken(value string) string { - for i, r := range value { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - continue - } - if i == 0 { - return "" - } - return value[:i] - } - return value -} - -var vendorHintTable = []struct { - vendor string - hints []string -}{ - {vendor: "openai", hints: []string{"openai", "gpt", "o1", "chatgpt"}}, - {vendor: "anthropic", hints: []string{"anthropic", "claude"}}, - {vendor: "google", hints: []string{"google", "gemini", "learnlm", "palm"}}, - {vendor: "mistral", hints: []string{"mistral", "mixtral", "ministral"}}, - {vendor: "cohere", hints: []string{"cohere", "command", "r-plus"}}, - {vendor: "moonshot", hints: []string{"moonshot"}}, - {vendor: "zhipu", hints: []string{"zhipu", "glm"}}, - {vendor: "alibaba", hints: []string{"dashscope", "qwen"}}, - {vendor: "baidu", hints: []string{"wenxin", "ernie", "qianfan"}}, - {vendor: "minimax", hints: []string{"minimax", "abab"}}, - {vendor: "bytedance", hints: []string{"doubao", "bytedance"}}, - {vendor: "baichuan", hints: []string{"baichuan"}}, - {vendor: "deepseek", hints: []string{"deepseek"}}, - {vendor: "xai", hints: []string{"grok", "xai"}}, -} - -func slugify(input string) string { - input = strings.TrimSpace(input) - if input == "" { - return "" - } - - var b strings.Builder - b.Grow(len(input)) - - lastUnderscore := false - for _, r := range input { - switch { - case unicode.IsLetter(r) || unicode.IsDigit(r): - b.WriteRune(unicode.ToLower(r)) - lastUnderscore = false - default: - if !lastUnderscore && b.Len() > 0 { - b.WriteByte('_') - lastUnderscore = true - } - } - } - - return strings.Trim(b.String(), "_") -} diff --git a/internal/ai/genkit/client.go b/internal/ai/genkit/client.go deleted file mode 100644 index 275278e..0000000 --- a/internal/ai/genkit/client.go +++ /dev/null @@ -1,158 +0,0 @@ -package genkit - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - "sync" - - genaigo "github.com/firebase/genkit/go/ai" - "github.com/openai/openai-go/option" - - aiconfig "github.com/rogeecn/renamer/internal/ai/config" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -// WorkflowRunner executes a Genkit request and returns the structured response. -type WorkflowRunner interface { - Run(ctx context.Context, req Request) (Result, error) -} - -// WorkflowFactory constructs workflow runners. -type WorkflowFactory func(ctx context.Context, opts Options) (WorkflowRunner, error) - -var ( - factoryMu sync.RWMutex - defaultFactory = func(ctx context.Context, opts Options) (WorkflowRunner, error) { - return NewWorkflow(ctx, opts) - } - currentFactory WorkflowFactory = defaultFactory -) - -// OverrideWorkflowFactory allows tests to supply custom workflow implementations. -func OverrideWorkflowFactory(factory WorkflowFactory) { - factoryMu.Lock() - defer factoryMu.Unlock() - if factory == nil { - currentFactory = defaultFactory - return - } - currentFactory = factory -} - -// ResetWorkflowFactory restores the default workflow constructor. -func ResetWorkflowFactory() { - OverrideWorkflowFactory(nil) -} - -func getWorkflowFactory() WorkflowFactory { - factoryMu.RLock() - defer factoryMu.RUnlock() - return currentFactory -} - -// ClientOptions configure the Genkit client. -type ClientOptions struct { - Model string - TokenProvider aiconfig.TokenProvider - RequestOptions []option.RequestOption -} - -// Client orchestrates prompt execution against the configured workflow. -type Client struct { - model string - tokenProvider aiconfig.TokenProvider - requestOptions []option.RequestOption -} - -// NewClient constructs a client with optional overrides. -func NewClient(opts ClientOptions) *Client { - model := strings.TrimSpace(opts.Model) - if model == "" { - model = DefaultModelName - } - return &Client{ - model: model, - tokenProvider: opts.TokenProvider, - requestOptions: append([]option.RequestOption(nil), opts.RequestOptions...), - } -} - -// Invocation describes a single Genkit call. -type Invocation struct { - Instructions string - Prompt prompt.RenamePrompt - Model string -} - -// InvocationResult carries the parsed response alongside telemetry. -type InvocationResult struct { - PromptHash string - Model string - Response prompt.RenameResponse - ModelResponse *genaigo.ModelResponse - PromptJSON []byte -} - -// Invoke executes the workflow and returns the structured response. -func (c *Client) Invoke(ctx context.Context, inv Invocation) (InvocationResult, error) { - model := strings.TrimSpace(inv.Model) - if model == "" { - model = c.model - } - if model == "" { - model = DefaultModelName - } - - payload, err := json.Marshal(inv.Prompt) - if err != nil { - return InvocationResult{}, fmt.Errorf("marshal prompt payload: %w", err) - } - - factory := getWorkflowFactory() - runner, err := factory(ctx, Options{ - Model: model, - TokenProvider: c.tokenProvider, - RequestOptions: c.requestOptions, - }) - if err != nil { - return InvocationResult{}, err - } - - result, err := runner.Run(ctx, Request{ - Instructions: inv.Instructions, - Payload: inv.Prompt, - }) - if err != nil { - return InvocationResult{}, err - } - - if strings.TrimSpace(result.Response.Model) == "" { - result.Response.Model = model - } - - promptHash := hashPrompt(inv.Instructions, payload) - if strings.TrimSpace(result.Response.PromptHash) == "" { - result.Response.PromptHash = promptHash - } - - return InvocationResult{ - PromptHash: promptHash, - Model: result.Response.Model, - Response: result.Response, - ModelResponse: result.ModelResponse, - PromptJSON: payload, - }, nil -} - -func hashPrompt(instructions string, payload []byte) string { - hasher := sha256.New() - hasher.Write([]byte(strings.TrimSpace(instructions))) - hasher.Write([]byte{'\n'}) - hasher.Write(payload) - sum := hasher.Sum(nil) - return hex.EncodeToString(sum) -} diff --git a/internal/ai/genkit/doc.go b/internal/ai/genkit/doc.go deleted file mode 100644 index 1311005..0000000 --- a/internal/ai/genkit/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package genkit - -// Package genkit integrates the Google Genkit workflow with the CLI. diff --git a/internal/ai/genkit/workflow.go b/internal/ai/genkit/workflow.go deleted file mode 100644 index d514f7e..0000000 --- a/internal/ai/genkit/workflow.go +++ /dev/null @@ -1,166 +0,0 @@ -package genkit - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - - "github.com/firebase/genkit/go/ai" - gogenkit "github.com/firebase/genkit/go/genkit" - oai "github.com/firebase/genkit/go/plugins/compat_oai/openai" - "github.com/openai/openai-go/option" - - aiconfig "github.com/rogeecn/renamer/internal/ai/config" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -const ( - defaultModelName = "gpt-4o-mini" - // DefaultModelName exposes the default model identifier used by the CLI. - DefaultModelName = defaultModelName -) - -var ( - // ErrMissingToken indicates the workflow could not locate a model token. - ErrMissingToken = errors.New("genkit workflow: model token not available") - // ErrMissingInstructions indicates that no system instructions were provided for a run. - ErrMissingInstructions = errors.New("genkit workflow: instructions are required") -) - -// DataGenerator executes the Genkit request and decodes the structured response. -type DataGenerator func(ctx context.Context, g *gogenkit.Genkit, opts ...ai.GenerateOption) (*prompt.RenameResponse, *ai.ModelResponse, error) - -// Options configure a Workflow instance. -type Options struct { - Model string - TokenProvider aiconfig.TokenProvider - RequestOptions []option.RequestOption - Generator DataGenerator -} - -// Request captures the input necessary to execute the Genkit workflow. -type Request struct { - Instructions string - Payload prompt.RenamePrompt -} - -// Result bundles the typed response together with the raw Genkit metadata. -type Result struct { - Response prompt.RenameResponse - ModelResponse *ai.ModelResponse -} - -// Workflow orchestrates execution of the Genkit rename pipeline. -type Workflow struct { - modelName string - genkit *gogenkit.Genkit - model ai.Model - generate DataGenerator -} - -// NewWorkflow instantiates a Genkit workflow for the preferred model. When no -// model is provided it defaults to gpt-4o-mini. The workflow requires a token -// provider capable of resolving `_TOKEN` secrets. -func NewWorkflow(ctx context.Context, opts Options) (*Workflow, error) { - modelName := strings.TrimSpace(opts.Model) - if modelName == "" { - modelName = defaultModelName - } - - token, err := resolveToken(opts.TokenProvider, modelName) - if err != nil { - return nil, err - } - if strings.TrimSpace(token) == "" { - return nil, fmt.Errorf("%w for %q", ErrMissingToken, modelName) - } - - plugin := &oai.OpenAI{ - APIKey: token, - Opts: opts.RequestOptions, - } - - g := gogenkit.Init(ctx, gogenkit.WithPlugins(plugin)) - model := plugin.Model(g, modelName) - - generator := opts.Generator - if generator == nil { - generator = func(ctx context.Context, g *gogenkit.Genkit, opts ...ai.GenerateOption) (*prompt.RenameResponse, *ai.ModelResponse, error) { - return gogenkit.GenerateData[prompt.RenameResponse](ctx, g, opts...) - } - } - - return &Workflow{ - modelName: modelName, - genkit: g, - model: model, - generate: generator, - }, nil -} - -// Run executes the workflow with the provided request and decodes the response -// into the shared RenameResponse structure. -func (w *Workflow) Run(ctx context.Context, req Request) (Result, error) { - if w == nil { - return Result{}, errors.New("genkit workflow: nil receiver") - } - if strings.TrimSpace(req.Instructions) == "" { - return Result{}, ErrMissingInstructions - } - - payload, err := json.Marshal(req.Payload) - if err != nil { - return Result{}, fmt.Errorf("marshal workflow payload: %w", err) - } - - options := []ai.GenerateOption{ - ai.WithModel(w.model), - ai.WithSystem(req.Instructions), - ai.WithPrompt(string(payload)), - } - - response, raw, err := w.generate(ctx, w.genkit, options...) - if err != nil { - return Result{}, fmt.Errorf("genkit generate: %w", err) - } - - return Result{ - Response: deref(response), - ModelResponse: raw, - }, nil -} - -func resolveToken(provider aiconfig.TokenProvider, model string) (string, error) { - if provider != nil { - if token, err := provider.ResolveModelToken(model); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } else if err != nil { - return "", fmt.Errorf("resolve model token: %w", err) - } - } - - if direct := strings.TrimSpace(os.Getenv(aiconfig.ModelTokenKey(model))); direct != "" { - return direct, nil - } - - store, err := aiconfig.NewTokenStore("") - if err != nil { - return "", err - } - - token, err := store.ResolveModelToken(model) - if err != nil { - return "", err - } - return token, nil -} - -func deref(resp *prompt.RenameResponse) prompt.RenameResponse { - if resp == nil { - return prompt.RenameResponse{} - } - return *resp -} diff --git a/internal/ai/plan/apply.go b/internal/ai/plan/apply.go deleted file mode 100644 index 01bc230..0000000 --- a/internal/ai/plan/apply.go +++ /dev/null @@ -1,250 +0,0 @@ -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 -} diff --git a/internal/ai/plan/conflicts.go b/internal/ai/plan/conflicts.go deleted file mode 100644 index 9ef5675..0000000 --- a/internal/ai/plan/conflicts.go +++ /dev/null @@ -1,67 +0,0 @@ -package plan - -import ( - "fmt" - "sort" - "strings" - - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -// Conflict describes an issue detected in an AI rename plan. -type Conflict struct { - OriginalPath string - Issue string - Details string -} - -func detectConflicts(items []prompt.RenameItem) []Conflict { - conflicts := make([]Conflict, 0) - - if len(items) == 0 { - return conflicts - } - - targets := make(map[string][]prompt.RenameItem) - sequences := make([]int, 0, len(items)) - - for _, item := range items { - key := strings.ToLower(strings.TrimSpace(item.Proposed)) - if key != "" { - targets[key] = append(targets[key], item) - } - if item.Sequence > 0 { - sequences = append(sequences, item.Sequence) - } - } - - for _, entries := range targets { - if len(entries) <= 1 { - continue - } - for _, entry := range entries { - conflicts = append(conflicts, Conflict{ - OriginalPath: entry.Original, - Issue: "duplicate_target", - Details: fmt.Sprintf("target %q is used by multiple entries", entries[0].Proposed), - }) - } - } - - if len(sequences) > 0 { - sort.Ints(sequences) - expected := 1 - for _, seq := range sequences { - if seq != expected { - conflicts = append(conflicts, Conflict{ - Issue: "sequence_gap", - Details: fmt.Sprintf("expected sequence %d but found %d", expected, seq), - }) - expected = seq - } - expected++ - } - } - - return conflicts -} diff --git a/internal/ai/plan/doc.go b/internal/ai/plan/doc.go deleted file mode 100644 index 683eebe..0000000 --- a/internal/ai/plan/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package plan - -// Package plan handles AI rename plan validation, mapping, and persistence helpers. diff --git a/internal/ai/plan/editor.go b/internal/ai/plan/editor.go deleted file mode 100644 index f024c9b..0000000 --- a/internal/ai/plan/editor.go +++ /dev/null @@ -1,39 +0,0 @@ -package plan - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -// SaveResponse writes the AI rename response to disk for later editing. -func SaveResponse(path string, resp prompt.RenameResponse) error { - data, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal ai plan: %w", err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - return fmt.Errorf("write ai plan %s: %w", path, err) - } - return nil -} - -// LoadResponse reads an edited AI rename response from disk. -func LoadResponse(path string) (prompt.RenameResponse, error) { - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return prompt.RenameResponse{}, fmt.Errorf("plan file %s not found", path) - } - return prompt.RenameResponse{}, fmt.Errorf("read plan file %s: %w", path, err) - } - var resp prompt.RenameResponse - if err := json.Unmarshal(data, &resp); err != nil { - return prompt.RenameResponse{}, fmt.Errorf("parse plan file %s: %w", path, err) - } - return resp, nil -} diff --git a/internal/ai/plan/mapper.go b/internal/ai/plan/mapper.go deleted file mode 100644 index fee12ed..0000000 --- a/internal/ai/plan/mapper.go +++ /dev/null @@ -1,190 +0,0 @@ -package plan - -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -// Candidate represents a file considered for AI renaming. -type Candidate struct { - OriginalPath string - SizeBytes int64 - Depth int - Extension string -} - -// MapInput configures the mapping behaviour. -type MapInput struct { - Candidates []Candidate - SequenceWidth int -} - -// PreviewPlan aggregates entries ready for preview rendering. -type PreviewPlan struct { - Entries []PreviewEntry - Warnings []string - PromptHash string - Model string - Conflicts []Conflict -} - -// PreviewEntry is a single row in the preview table. -type PreviewEntry struct { - Sequence int - SequenceLabel string - OriginalPath string - ProposedPath string - SanitizedSegments []string - Notes string -} - -// MapResponse converts a validated response into a preview plan. -func MapResponse(input MapInput, validation ValidationResult) (PreviewPlan, error) { - if input.SequenceWidth <= 0 { - input.SequenceWidth = 3 - } - - itemByOriginal := make(map[string]struct { - item promptRenameItem - }, len(validation.Items)) - for _, item := range validation.Items { - key := normalizePath(item.Original) - itemByOriginal[key] = struct{ item promptRenameItem }{item: promptRenameItem{ - Original: item.Original, - Proposed: item.Proposed, - Sequence: item.Sequence, - Notes: item.Notes, - }} - } - - entries := make([]PreviewEntry, 0, len(input.Candidates)) - for _, candidate := range input.Candidates { - key := normalizePath(candidate.OriginalPath) - entryData, ok := itemByOriginal[key] - if !ok { - return PreviewPlan{}, fmt.Errorf("ai plan: missing response for %s", candidate.OriginalPath) - } - - item := entryData.item - label := formatSequence(item.Sequence, input.SequenceWidth) - sanitized := computeSanitizedSegments(candidate.OriginalPath, item.Proposed) - - entries = append(entries, PreviewEntry{ - Sequence: item.Sequence, - SequenceLabel: label, - OriginalPath: candidate.OriginalPath, - ProposedPath: item.Proposed, - SanitizedSegments: sanitized, - Notes: item.Notes, - }) - } - - return PreviewPlan{ - Entries: entries, - Warnings: append([]string(nil), validation.Warnings...), - PromptHash: validation.PromptHash, - Model: validation.Model, - Conflicts: detectConflicts(validation.Items), - }, nil -} - -type promptRenameItem struct { - Original string - Proposed string - Sequence int - Notes string -} - -func formatSequence(seq, width int) string { - if seq <= 0 { - return "" - } - label := fmt.Sprintf("%0*d", width, seq) - if len(label) < len(fmt.Sprintf("%d", seq)) { - return fmt.Sprintf("%d", seq) - } - return label -} - -func normalizePath(path string) string { - return strings.TrimSpace(strings.ReplaceAll(path, "\\", "/")) -} - -func computeSanitizedSegments(original, proposed string) []string { - origStem := stem(original) - propStem := stem(proposed) - - origTokens := tokenize(origStem) - propTokens := make(map[string]struct{}, len(origTokens)) - for _, token := range tokenize(propStem) { - propTokens[token] = struct{}{} - } - - var sanitized []string - seen := make(map[string]struct{}) - for _, token := range origTokens { - if _, ok := propTokens[token]; ok { - continue - } - if _, already := seen[token]; already { - continue - } - if isNumericToken(token) { - continue - } - seen[token] = struct{}{} - sanitized = append(sanitized, token) - } - if len(sanitized) == 0 { - return nil - } - sort.Strings(sanitized) - return sanitized -} - -func stem(path string) string { - base := filepath.Base(path) - ext := filepath.Ext(base) - if ext != "" { - return base[:len(base)-len(ext)] - } - return base -} - -func tokenize(value string) []string { - fields := strings.FieldsFunc(value, func(r rune) bool { - if r >= '0' && r <= '9' { - return false - } - if r >= 'a' && r <= 'z' { - return false - } - if r >= 'A' && r <= 'Z' { - return false - } - return true - }) - tokens := make([]string, 0, len(fields)) - for _, field := range fields { - normalized := strings.ToLower(field) - if normalized == "" { - continue - } - tokens = append(tokens, normalized) - } - return tokens -} - -func isNumericToken(token string) bool { - if token == "" { - return false - } - for _, r := range token { - if r < '0' || r > '9' { - return false - } - } - return true -} diff --git a/internal/ai/plan/scope.go b/internal/ai/plan/scope.go deleted file mode 100644 index fa92069..0000000 --- a/internal/ai/plan/scope.go +++ /dev/null @@ -1,76 +0,0 @@ -package plan - -import ( - "context" - "errors" - "io/fs" - "path/filepath" - "strings" - - "github.com/rogeecn/renamer/internal/listing" - "github.com/rogeecn/renamer/internal/traversal" -) - -// CollectCandidates walks the scope described by req and returns eligible file candidates. -func CollectCandidates(ctx context.Context, req *listing.ListingRequest) ([]Candidate, error) { - if req == nil { - return nil, errors.New("collect candidates: request cannot be nil") - } - if err := req.Validate(); err != nil { - return nil, err - } - - w := traversal.NewWalker() - extensions := make(map[string]struct{}, len(req.Extensions)) - for _, ext := range req.Extensions { - extensions[ext] = struct{}{} - } - - candidates := make([]Candidate, 0) - - err := w.Walk( - req.WorkingDir, - req.Recursive, - false, // directories are not considered candidates - req.IncludeHidden, - req.MaxDepth, - func(relPath string, entry fs.DirEntry, depth int) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - if entry.IsDir() { - return nil - } - - relSlash := filepath.ToSlash(relPath) - ext := strings.ToLower(filepath.Ext(entry.Name())) - if len(extensions) > 0 { - if _, match := extensions[ext]; !match { - return nil - } - } - - info, err := entry.Info() - if err != nil { - return err - } - - candidates = append(candidates, Candidate{ - OriginalPath: relSlash, - SizeBytes: info.Size(), - Depth: depth, - Extension: filepath.Ext(entry.Name()), - }) - - return nil - }, - ) - if err != nil { - return nil, err - } - - return candidates, nil -} diff --git a/internal/ai/plan/validator.go b/internal/ai/plan/validator.go deleted file mode 100644 index 1543bb0..0000000 --- a/internal/ai/plan/validator.go +++ /dev/null @@ -1,423 +0,0 @@ -package plan - -import ( - "fmt" - "path/filepath" - "sort" - "strings" - "unicode" - - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -// Validator checks the AI response for completeness and uniqueness rules. -type Validator struct { - expected []string - expectedSet map[string]struct{} - policies prompt.NamingPolicyConfig - bannedSet map[string]struct{} -} - -// ValidationResult captures the successfully decoded response data. -type ValidationResult struct { - Items []prompt.RenameItem - Warnings []string - PromptHash string - Model string -} - -// InvalidItem describes a single response entry that failed validation. -type InvalidItem struct { - Index int - Original string - Proposed string - Reason string -} - -// ValidationError aggregates the issues discovered during validation. -type ValidationError struct { - Result ValidationResult - MissingOriginals []string - UnexpectedOriginals []string - DuplicateOriginals map[string]int - DuplicateProposed map[string][]string - InvalidItems []InvalidItem - PolicyViolations []PolicyViolation -} - -// PolicyViolation captures a single naming-policy breach. -type PolicyViolation struct { - Original string - Proposed string - Rule string - Message string -} - -func (e *ValidationError) Error() string { - if e == nil { - return "" - } - - parts := make([]string, 0, 5) - if len(e.MissingOriginals) > 0 { - parts = append(parts, fmt.Sprintf("missing %d originals", len(e.MissingOriginals))) - } - if len(e.UnexpectedOriginals) > 0 { - parts = append(parts, fmt.Sprintf("unexpected %d originals", len(e.UnexpectedOriginals))) - } - if len(e.DuplicateOriginals) > 0 { - parts = append(parts, fmt.Sprintf("%d duplicate originals", len(e.DuplicateOriginals))) - } - if len(e.DuplicateProposed) > 0 { - parts = append(parts, fmt.Sprintf("%d duplicate proposed names", len(e.DuplicateProposed))) - } - if len(e.InvalidItems) > 0 { - parts = append(parts, fmt.Sprintf("%d invalid items", len(e.InvalidItems))) - } - if len(e.PolicyViolations) > 0 { - parts = append(parts, fmt.Sprintf("%d policy violations", len(e.PolicyViolations))) - } - - summary := strings.Join(parts, ", ") - if summary == "" { - summary = "response validation failed" - } - return fmt.Sprintf("ai response validation failed: %s", summary) -} - -// HasIssues indicates whether the validation error captured any rule breaks. -func (e *ValidationError) HasIssues() bool { - if e == nil { - return false - } - return len(e.MissingOriginals) > 0 || - len(e.UnexpectedOriginals) > 0 || - len(e.DuplicateOriginals) > 0 || - len(e.DuplicateProposed) > 0 || - len(e.InvalidItems) > 0 || - len(e.PolicyViolations) > 0 -} - -// NewValidator constructs a validator for the supplied original filenames. Any -// whitespace-only entries are discarded. Duplicate originals are collapsed to -// ensure consistent coverage checks. -func NewValidator(originals []string, policies prompt.NamingPolicyConfig, bannedTerms []string) Validator { - expectedSet := make(map[string]struct{}, len(originals)) - deduped := make([]string, 0, len(originals)) - for _, original := range originals { - trimmed := strings.TrimSpace(original) - if trimmed == "" { - continue - } - if _, exists := expectedSet[trimmed]; exists { - continue - } - expectedSet[trimmed] = struct{}{} - deduped = append(deduped, trimmed) - } - - bannedSet := make(map[string]struct{}) - for _, token := range bannedTerms { - lower := strings.ToLower(strings.TrimSpace(token)) - if lower == "" { - continue - } - bannedSet[lower] = struct{}{} - } - - policies.Casing = strings.ToLower(strings.TrimSpace(policies.Casing)) - policies.Prefix = strings.TrimSpace(policies.Prefix) - policies.ForbiddenTokens = append([]string(nil), policies.ForbiddenTokens...) - - return Validator{ - expected: deduped, - expectedSet: expectedSet, - policies: policies, - bannedSet: bannedSet, - } -} - -// Validate ensures the AI response covers each expected original exactly once -// and that the proposed filenames are unique. -func (v Validator) Validate(resp prompt.RenameResponse) (ValidationResult, error) { - result := ValidationResult{ - Items: cloneItems(resp.Items), - Warnings: append([]string(nil), resp.Warnings...), - PromptHash: resp.PromptHash, - Model: resp.Model, - } - - if len(resp.Items) == 0 { - err := &ValidationError{ - Result: result, - MissingOriginals: append([]string(nil), v.expected...), - } - return result, err - } - - seenOriginals := make(map[string]int, len(resp.Items)) - seenProposed := make(map[string][]string, len(resp.Items)) - unexpectedSet := map[string]struct{}{} - - invalidItems := make([]InvalidItem, 0) - policyViolations := make([]PolicyViolation, 0) - - for idx, item := range resp.Items { - original := strings.TrimSpace(item.Original) - proposed := strings.TrimSpace(item.Proposed) - - if original == "" { - invalidItems = append(invalidItems, InvalidItem{ - Index: idx, - Original: item.Original, - Proposed: item.Proposed, - Reason: "original is empty", - }) - } else { - seenOriginals[original]++ - if _, ok := v.expectedSet[original]; !ok { - unexpectedSet[original] = struct{}{} - } - } - - if proposed == "" { - invalidItems = append(invalidItems, InvalidItem{ - Index: idx, - Original: item.Original, - Proposed: item.Proposed, - Reason: "proposed is empty", - }) - } else { - seenProposed[proposed] = append(seenProposed[proposed], original) - } - - policyViolations = append(policyViolations, v.evaluatePolicies(item)...) - } - - missing := make([]string, 0) - for _, original := range v.expected { - if seenOriginals[original] == 0 { - missing = append(missing, original) - } - } - - duplicateOriginals := make(map[string]int) - for original, count := range seenOriginals { - if count > 1 { - duplicateOriginals[original] = count - } - } - - duplicateProposed := make(map[string][]string) - for proposed, sources := range seenProposed { - if len(sources) > 1 { - filtered := make([]string, 0, len(sources)) - for _, src := range sources { - if strings.TrimSpace(src) != "" { - filtered = append(filtered, src) - } - } - if len(filtered) > 1 { - duplicateProposed[proposed] = filtered - } - } - } - - unexpected := orderedKeys(unexpectedSet) - - if len(missing) == 0 && - len(unexpected) == 0 && - len(duplicateOriginals) == 0 && - len(duplicateProposed) == 0 && - len(invalidItems) == 0 && - len(policyViolations) == 0 { - return result, nil - } - - err := &ValidationError{ - Result: result, - MissingOriginals: missing, - UnexpectedOriginals: unexpected, - DuplicateOriginals: duplicateOriginals, - DuplicateProposed: duplicateProposed, - InvalidItems: invalidItems, - PolicyViolations: policyViolations, - } - - return result, err -} - -// Expectation returns a copy of the expected originals tracked by the validator. -func (v Validator) Expectation() []string { - return append([]string(nil), v.expected...) -} - -func cloneItems(items []prompt.RenameItem) []prompt.RenameItem { - if len(items) == 0 { - return nil - } - cp := make([]prompt.RenameItem, len(items)) - copy(cp, items) - return cp -} - -func orderedKeys(set map[string]struct{}) []string { - if len(set) == 0 { - return nil - } - out := make([]string, 0, len(set)) - for k := range set { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func (v Validator) evaluatePolicies(item prompt.RenameItem) []PolicyViolation { - violations := make([]PolicyViolation, 0) - proposed := strings.TrimSpace(item.Proposed) - if proposed == "" { - return violations - } - base := filepath.Base(proposed) - stem := base - if ext := filepath.Ext(base); ext != "" { - stem = base[:len(base)-len(ext)] - } - stemLower := strings.ToLower(stem) - - if v.policies.Prefix != "" { - prefixLower := strings.ToLower(v.policies.Prefix) - if !strings.HasPrefix(stemLower, prefixLower) { - violations = append(violations, PolicyViolation{ - Original: item.Original, - Proposed: item.Proposed, - Rule: "prefix", - Message: fmt.Sprintf("expected prefix %q", v.policies.Prefix), - }) - } - } - - if !v.policies.AllowSpaces && strings.Contains(stem, " ") { - violations = append(violations, PolicyViolation{ - Original: item.Original, - Proposed: item.Proposed, - Rule: "spaces", - Message: "spaces are not allowed", - }) - } - - if v.policies.Casing != "" { - if ok, message := matchesCasing(stem, v.policies); !ok { - violations = append(violations, PolicyViolation{ - Original: item.Original, - Proposed: item.Proposed, - Rule: "casing", - Message: message, - }) - } - } - - if len(v.bannedSet) > 0 { - tokens := tokenize(stemLower) - for _, token := range tokens { - if _, ok := v.bannedSet[token]; ok { - violations = append(violations, PolicyViolation{ - Original: item.Original, - Proposed: item.Proposed, - Rule: "banned", - Message: fmt.Sprintf("contains banned token %q", token), - }) - break - } - } - } - - return violations -} - -func matchesCasing(stem string, policies prompt.NamingPolicyConfig) (bool, string) { - core := coreStem(stem, policies.Prefix) - switch policies.Casing { - case "kebab": - if strings.Contains(core, " ") { - return false, "expected kebab-case (no spaces)" - } - if strings.ContainsAny(core, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { - return false, "expected kebab-case (use lowercase letters)" - } - return true, "" - case "snake": - if strings.Contains(core, " ") { - return false, "expected snake_case (no spaces)" - } - if strings.ContainsAny(core, "ABCDEFGHIJKLMNOPQRSTUVWXYZ-") { - return false, "expected snake_case (lowercase letters with underscores)" - } - return true, "" - case "camel": - if strings.ContainsAny(core, " -_") { - return false, "expected camelCase (no separators)" - } - runes := []rune(core) - if len(runes) == 0 { - return false, "expected camelCase descriptive text" - } - if !unicode.IsLower(runes[0]) { - return false, "expected camelCase (first letter lowercase)" - } - return true, "" - case "pascal": - if strings.ContainsAny(core, " -_") { - return false, "expected PascalCase (no separators)" - } - runes := []rune(core) - if len(runes) == 0 { - return false, "expected PascalCase descriptive text" - } - if !unicode.IsUpper(runes[0]) { - return false, "expected PascalCase (first letter uppercase)" - } - return true, "" - case "title": - words := strings.Fields(strings.ReplaceAll(core, "-", " ")) - if len(words) == 0 { - return false, "expected Title Case words" - } - for _, word := range words { - runes := []rune(word) - if len(runes) == 0 { - continue - } - if !unicode.IsUpper(runes[0]) { - return false, "expected Title Case (capitalize each word)" - } - } - return true, "" - default: - return true, "" - } -} - -func coreStem(stem, prefix string) string { - trimmed := stem - if prefix != "" { - lowerStem := strings.ToLower(trimmed) - lowerPrefix := strings.ToLower(prefix) - if strings.HasPrefix(lowerStem, lowerPrefix) { - trimmed = trimmed[len(prefix):] - trimmed = strings.TrimLeft(trimmed, "-_ ") - } - } - i := 0 - runes := []rune(trimmed) - for i < len(runes) { - r := runes[i] - if unicode.IsDigit(r) || r == '-' || r == '_' || r == ' ' { - i++ - continue - } - break - } - return string(runes[i:]) -} diff --git a/internal/ai/prompt/builder.go b/internal/ai/prompt/builder.go deleted file mode 100644 index d26d2e9..0000000 --- a/internal/ai/prompt/builder.go +++ /dev/null @@ -1,201 +0,0 @@ -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 -} diff --git a/internal/ai/prompt/doc.go b/internal/ai/prompt/doc.go deleted file mode 100644 index af9ca1d..0000000 --- a/internal/ai/prompt/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package prompt - -// Package prompt contains helpers for building AI prompt payloads. diff --git a/internal/ai/prompt/types.go b/internal/ai/prompt/types.go deleted file mode 100644 index 8c48595..0000000 --- a/internal/ai/prompt/types.go +++ /dev/null @@ -1,53 +0,0 @@ -package prompt - -// RenamePrompt captures the structured payload sent to the Genkit workflow. -type RenamePrompt struct { - WorkingDir string `json:"workingDir"` - Samples []PromptSample `json:"samples"` - TotalCount int `json:"totalCount"` - SequenceRule SequenceRuleConfig `json:"sequenceRule"` - Policies NamingPolicyConfig `json:"policies"` - BannedTerms []string `json:"bannedTerms,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -// PromptSample represents a sampled file from the traversal scope. -type PromptSample struct { - OriginalName string `json:"originalName"` - Extension string `json:"extension"` - SizeBytes int64 `json:"sizeBytes"` - PathDepth int `json:"pathDepth"` -} - -// SequenceRuleConfig captures numbering directives for the AI prompt. -type SequenceRuleConfig struct { - Style string `json:"style"` - Width int `json:"width"` - Start int `json:"start"` - Separator string `json:"separator"` -} - -// NamingPolicyConfig enumerates naming policies forwarded to the AI. -type NamingPolicyConfig struct { - Prefix string `json:"prefix,omitempty"` - Casing string `json:"casing"` - AllowSpaces bool `json:"allowSpaces,omitempty"` - KeepOriginalOrder bool `json:"keepOriginalOrder,omitempty"` - ForbiddenTokens []string `json:"forbiddenTokens,omitempty"` -} - -// RenameResponse is the structured payload expected from the AI model. -type RenameResponse struct { - Items []RenameItem `json:"items"` - Warnings []string `json:"warnings,omitempty"` - PromptHash string `json:"promptHash,omitempty"` - Model string `json:"model,omitempty"` -} - -// RenameItem maps an original path to the AI-proposed rename. -type RenameItem struct { - Original string `json:"original"` - Proposed string `json:"proposed"` - Sequence int `json:"sequence"` - Notes string `json:"notes,omitempty"` -} diff --git a/internal/history/history.go b/internal/history/history.go index 6e5a0c8..463671c 100644 --- a/internal/history/history.go +++ b/internal/history/history.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "time" - - "github.com/rogeecn/renamer/internal/ai/prompt" ) const ledgerFileName = ".renamer" @@ -28,57 +26,6 @@ type Entry struct { Metadata map[string]any `json:"metadata,omitempty"` } -const aiMetadataKey = "ai" - -// AIMetadata captures AI-specific ledger metadata for rename batches. -type AIMetadata struct { - PromptHash string `json:"promptHash"` - ResponseHash string `json:"responseHash"` - Model string `json:"model"` - Policies prompt.NamingPolicyConfig `json:"policies"` - BatchSize int `json:"batchSize"` - AppliedAt time.Time `json:"appliedAt"` -} - -// AttachAIMetadata records AI metadata alongside the ledger entry. -func (e *Entry) AttachAIMetadata(meta AIMetadata) { - if e.Metadata == nil { - e.Metadata = make(map[string]any) - } - if meta.AppliedAt.IsZero() { - meta.AppliedAt = time.Now().UTC() - } - e.Metadata[aiMetadataKey] = meta -} - -// AIMetadata extracts AI metadata from the ledger entry if present. -func (e Entry) AIMetadata() (AIMetadata, bool) { - if e.Metadata == nil { - return AIMetadata{}, false - } - raw, ok := e.Metadata[aiMetadataKey] - if !ok { - return AIMetadata{}, false - } - - switch value := raw.(type) { - case AIMetadata: - return value, true - case map[string]any: - var meta AIMetadata - if err := remarshal(value, &meta); err != nil { - return AIMetadata{}, false - } - return meta, true - default: - var meta AIMetadata - if err := remarshal(value, &meta); err != nil { - return AIMetadata{}, false - } - return meta, true - } -} - func remarshal(value any, target any) error { data, err := json.Marshal(value) if err != nil { diff --git a/tests/contract/ai_ledger_contract_test.go b/tests/contract/ai_ledger_contract_test.go deleted file mode 100644 index 6785c74..0000000 --- a/tests/contract/ai_ledger_contract_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package contract - -import ( - "bufio" - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/rogeecn/renamer/internal/ai/plan" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -func TestAIApplyLedgerMetadataContract(t *testing.T) { - root := t.TempDir() - writeFile(t, filepath.Join(root, "sample.txt")) - - candidate := plan.Candidate{ - OriginalPath: "sample.txt", - SizeBytes: 4, - Depth: 0, - } - - response := prompt.RenameResponse{ - Items: []prompt.RenameItem{ - { - Original: "sample.txt", - Proposed: "001_sample-final.txt", - Sequence: 1, - }, - }, - Model: "test-model", - PromptHash: "prompt-hash-123", - } - - policy := prompt.NamingPolicyConfig{Prefix: "proj", Casing: "kebab"} - - entry, err := plan.Apply(context.Background(), plan.ApplyOptions{ - WorkingDir: root, - Candidates: []plan.Candidate{candidate}, - Response: response, - Policies: policy, - PromptHash: response.PromptHash, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - - if len(entry.Operations) != 1 { - t.Fatalf("expected 1 operation, got %d", len(entry.Operations)) - } - - planFile := filepath.Join(root, "001_sample-final.txt") - if _, err := os.Stat(planFile); err != nil { - t.Fatalf("expected renamed file: %v", err) - } - - ledgerPath := filepath.Join(root, ".renamer") - file, err := os.Open(ledgerPath) - if err != nil { - t.Fatalf("open ledger: %v", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var lastLine string - for scanner.Scan() { - lastLine = scanner.Text() - } - if err := scanner.Err(); err != nil { - t.Fatalf("scan ledger: %v", err) - } - - var recorded map[string]any - if err := json.Unmarshal([]byte(lastLine), &recorded); err != nil { - t.Fatalf("unmarshal ledger entry: %v", err) - } - - metaRaw, ok := recorded["metadata"].(map[string]any) - if !ok { - t.Fatalf("expected metadata in ledger entry") - } - aiRaw, ok := metaRaw["ai"].(map[string]any) - if !ok { - t.Fatalf("expected ai metadata in ledger entry") - } - - if aiRaw["model"] != "test-model" { - t.Fatalf("expected model test-model, got %v", aiRaw["model"]) - } - if aiRaw["promptHash"] != "prompt-hash-123" { - t.Fatalf("expected prompt hash recorded, got %v", aiRaw["promptHash"]) - } - if aiRaw["batchSize"].(float64) != 1 { - t.Fatalf("expected batch size 1, got %v", aiRaw["batchSize"]) - } -} - -func writeFile(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("data"), 0o644); err != nil { - t.Fatalf("write file %s: %v", path, err) - } -} diff --git a/tests/contract/ai_policy_contract_test.go b/tests/contract/ai_policy_contract_test.go deleted file mode 100644 index 436cff1..0000000 --- a/tests/contract/ai_policy_contract_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package contract - -import ( - "bytes" - "context" - "os" - "path/filepath" - "testing" - - renamercmd "github.com/rogeecn/renamer/cmd" - "github.com/rogeecn/renamer/internal/ai/genkit" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -type captureWorkflow struct { - request genkit.Request -} - -func (c *captureWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) { - c.request = req - return genkit.Result{ - Response: prompt.RenameResponse{ - Items: []prompt.RenameItem{ - { - Original: "alpha.txt", - Proposed: "proj_001_sample_file.txt", - Sequence: 1, - }, - }, - Model: "test-model", - }, - }, nil -} - -func TestAICommandUsesDefaultPoliciesInPrompt(t *testing.T) { - genkit.ResetWorkflowFactory() - stub := &captureWorkflow{} - genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) { - return stub, nil - }) - t.Cleanup(genkit.ResetWorkflowFactory) - - rootDir := t.TempDir() - createPolicyTestFile(t, filepath.Join(rootDir, "alpha.txt")) - - rootCmd := renamercmd.NewRootCommand() - var stdout, stderr bytes.Buffer - rootCmd.SetOut(&stdout) - rootCmd.SetErr(&stderr) - rootCmd.SetArgs([]string{ - "ai", - "--path", rootDir, - "--dry-run", - }) - - if err := rootCmd.Execute(); err != nil { - if stdout.Len() > 0 { - t.Logf("stdout: %s", stdout.String()) - } - if stderr.Len() > 0 { - t.Logf("stderr: %s", stderr.String()) - } - t.Fatalf("command execute: %v", err) - } - - req := stub.request - policies := req.Payload.Policies - if policies.Prefix != "" { - t.Fatalf("expected empty prefix, got %q", policies.Prefix) - } - if policies.Casing != "kebab" { - t.Fatalf("expected default casing kebab, got %q", policies.Casing) - } - if policies.AllowSpaces { - t.Fatalf("expected allow spaces default false") - } - if policies.KeepOriginalOrder { - t.Fatalf("expected keep original order default false") - } - - banned := req.Payload.BannedTerms - containsDefault := false - for _, term := range banned { - if term == "clickbait" { - containsDefault = true - break - } - } - if !containsDefault { - t.Fatalf("expected default banned terms propagated, got %#v", banned) - } -} - -func createPolicyTestFile(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("demo"), 0o644); err != nil { - t.Fatalf("write file %s: %v", path, err) - } -} diff --git a/tests/contract/ai_prompt_contract_test.go b/tests/contract/ai_prompt_contract_test.go deleted file mode 100644 index 23656b4..0000000 --- a/tests/contract/ai_prompt_contract_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package contract - -import ( - "encoding/json" - "path/filepath" - "testing" - "time" - - aiprompt "github.com/rogeecn/renamer/internal/ai/prompt" -) - -func TestRenamePromptSchemaAlignment(t *testing.T) { - builder := aiprompt.NewBuilder( - aiprompt.WithClock(func() time.Time { - return time.Date(2025, 11, 3, 15, 4, 5, 0, time.UTC) - }), - aiprompt.WithMaxSamples(2), - ) - - input := aiprompt.BuildInput{ - WorkingDir: "/tmp/workspace", - TotalCount: 3, - Sequence: aiprompt.SequenceRule{ - Style: "prefix", - Width: 3, - Start: 1, - Separator: "_", - }, - Policies: aiprompt.PolicyConfig{ - Casing: "kebab", - }, - BannedTerms: []string{"Promo", " ", "promo", "ads"}, - Samples: []aiprompt.SampleCandidate{ - { - RelativePath: "promo SALE 01.JPG", - SizeBytes: 2048, - Depth: 0, - }, - { - RelativePath: filepath.ToSlash(filepath.Join("nested", "Report FINAL.PDF")), - SizeBytes: 1024, - Depth: 1, - }, - { - RelativePath: "notes.txt", - SizeBytes: 128, - Depth: 0, - }, - }, - Metadata: map[string]string{ - "cliVersion": "test-build", - }, - } - - promptPayload, err := builder.Build(input) - if err != nil { - t.Fatalf("Build error: %v", err) - } - - if promptPayload.WorkingDir != input.WorkingDir { - t.Fatalf("expected working dir %q, got %q", input.WorkingDir, promptPayload.WorkingDir) - } - - if promptPayload.TotalCount != input.TotalCount { - t.Fatalf("expected total count %d, got %d", input.TotalCount, promptPayload.TotalCount) - } - - if len(promptPayload.Samples) != 2 { - t.Fatalf("expected 2 samples after max cap, got %d", len(promptPayload.Samples)) - } - - first := promptPayload.Samples[0] - if first.OriginalName != "nested/Report FINAL.PDF" { - t.Fatalf("unexpected first sample name: %q", first.OriginalName) - } - if first.Extension != ".PDF" { - t.Fatalf("expected extension to remain case-sensitive, got %q", first.Extension) - } - if first.SizeBytes != 1024 { - t.Fatalf("expected size 1024, got %d", first.SizeBytes) - } - if first.PathDepth != 1 { - t.Fatalf("expected depth 1, got %d", first.PathDepth) - } - - seq := promptPayload.SequenceRule - if seq.Style != "prefix" || seq.Width != 3 || seq.Start != 1 || seq.Separator != "_" { - t.Fatalf("sequence rule mismatch: %#v", seq) - } - - if promptPayload.Policies.Casing != "kebab" { - t.Fatalf("expected casing kebab, got %q", promptPayload.Policies.Casing) - } - - expectedTerms := []string{"ads", "promo"} - if len(promptPayload.BannedTerms) != len(expectedTerms) { - t.Fatalf("expected %d banned terms, got %d", len(expectedTerms), len(promptPayload.BannedTerms)) - } - for i, term := range expectedTerms { - if promptPayload.BannedTerms[i] != term { - t.Fatalf("banned term at %d mismatch: expected %q got %q", i, term, promptPayload.BannedTerms[i]) - } - } - - if promptPayload.Metadata["cliVersion"] != "test-build" { - t.Fatalf("metadata cliVersion mismatch: %s", promptPayload.Metadata["cliVersion"]) - } - if promptPayload.Metadata["generatedAt"] != "2025-11-03T15:04:05Z" { - t.Fatalf("expected generatedAt timestamp preserved, got %q", promptPayload.Metadata["generatedAt"]) - } - - raw, err := json.Marshal(promptPayload) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - var decoded map[string]any - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("unmarshal round-trip error: %v", err) - } - - for _, key := range []string{"workingDir", "samples", "totalCount", "sequenceRule", "policies"} { - if _, ok := decoded[key]; !ok { - t.Fatalf("prompt JSON missing key %q", key) - } - } -} diff --git a/tests/integration/ai_apply_undo_test.go b/tests/integration/ai_apply_undo_test.go deleted file mode 100644 index cc458b7..0000000 --- a/tests/integration/ai_apply_undo_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - renamercmd "github.com/rogeecn/renamer/cmd" - "github.com/rogeecn/renamer/internal/ai/genkit" - "github.com/rogeecn/renamer/internal/ai/plan" - "github.com/rogeecn/renamer/internal/ai/prompt" - "github.com/rogeecn/renamer/internal/listing" -) - -func TestAIApplyAndUndoFlow(t *testing.T) { - initialWorkflow := stubWorkflow{ - response: prompt.RenameResponse{ - Items: []prompt.RenameItem{ - { - Original: "draft_one.txt", - Proposed: "001_initial.txt", - Sequence: 1, - }, - { - Original: "draft_two.txt", - Proposed: "002_initial.txt", - Sequence: 2, - }, - }, - Model: "test-model", - }, - } - - genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) { - return initialWorkflow, nil - }) - t.Cleanup(genkit.ResetWorkflowFactory) - - root := t.TempDir() - writeFile(t, filepath.Join(root, "draft_one.txt")) - writeFile(t, filepath.Join(root, "draft_two.txt")) - - planPath := filepath.Join(root, "renamer.plan.json") - - preview := renamercmd.NewRootCommand() - var previewOut, previewErr bytes.Buffer - preview.SetOut(&previewOut) - preview.SetErr(&previewErr) - preview.SetArgs([]string{ - "ai", - "--path", root, - "--dry-run", - }) - - if err := preview.Execute(); err != nil { - if previewOut.Len() > 0 { - t.Logf("preview stdout: %s", previewOut.String()) - } - if previewErr.Len() > 0 { - t.Logf("preview stderr: %s", previewErr.String()) - } - t.Fatalf("initial preview: %v", err) - } - - data, err := os.ReadFile(planPath) - if err != nil { - t.Fatalf("read plan: %v", err) - } - var exported prompt.RenameResponse - if err := json.Unmarshal(data, &exported); err != nil { - t.Fatalf("unmarshal plan: %v", err) - } - - if len(exported.Items) != 2 { - t.Fatalf("expected two plan items, got %d", len(exported.Items)) - } - // Simulate operator edit. - exported.Items[0].Proposed = "001_final-one.txt" - exported.Items[1].Proposed = "002_final-two.txt" - exported.Items[0].Notes = "custom edit" - - modified, err := json.MarshalIndent(exported, "", " ") - if err != nil { - t.Fatalf("marshal modified plan: %v", err) - } - if err := os.WriteFile(planPath, append(modified, '\n'), 0o644); err != nil { - t.Fatalf("write modified plan: %v", err) - } - - req := &listing.ListingRequest{WorkingDir: root} - if err := req.Validate(); err != nil { - t.Fatalf("validate listing request: %v", err) - } - currentCandidates, err := plan.CollectCandidates(context.Background(), req) - if err != nil { - t.Fatalf("collect candidates: %v", err) - } - filtered := make([]plan.Candidate, 0, len(currentCandidates)) - for _, cand := range currentCandidates { - if strings.EqualFold(cand.OriginalPath, filepath.Base(planPath)) { - continue - } - filtered = append(filtered, cand) - } - originals := make([]string, 0, len(filtered)) - for _, cand := range filtered { - originals = append(originals, cand.OriginalPath) - } - validator := plan.NewValidator(originals, prompt.NamingPolicyConfig{Casing: "kebab"}, nil) - if _, err := validator.Validate(exported); err != nil { - t.Fatalf("pre-validation of edited plan: %v", err) - } - - previewEdited := renamercmd.NewRootCommand() - var editedOut, editedErr bytes.Buffer - previewEdited.SetOut(&editedOut) - previewEdited.SetErr(&editedErr) - previewEdited.SetArgs([]string{ - "ai", - "--path", root, - "--dry-run", - }) - - if err := previewEdited.Execute(); err != nil { - if editedOut.Len() > 0 { - t.Logf("edited stdout: %s", editedOut.String()) - } - if editedErr.Len() > 0 { - t.Logf("edited stderr: %s", editedErr.String()) - } - t.Fatalf("preview edited plan: %v", err) - } - - if !strings.Contains(editedOut.String(), "001_final-one.txt") { - t.Fatalf("expected edited preview to show final name, got: %s", editedOut.String()) - } - - applyCmd := renamercmd.NewRootCommand() - var applyOut, applyErr bytes.Buffer - applyCmd.SetOut(&applyOut) - applyCmd.SetErr(&applyErr) - applyCmd.SetArgs([]string{ - "ai", - "--path", root, - "--yes", - }) - - if err := applyCmd.Execute(); err != nil { - if applyOut.Len() > 0 { - t.Logf("apply stdout: %s", applyOut.String()) - } - if applyErr.Len() > 0 { - t.Logf("apply stderr: %s", applyErr.String()) - } - t.Fatalf("apply plan: %v", err) - } - - if _, err := os.Stat(filepath.Join(root, "001_final-one.txt")); err != nil { - t.Fatalf("expected renamed file: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "002_final-two.txt")); err != nil { - t.Fatalf("expected renamed file: %v", err) - } - - undo := renamercmd.NewRootCommand() - var undoOut bytes.Buffer - undo.SetOut(&undoOut) - undo.SetErr(&undoOut) - undo.SetArgs([]string{"undo", "--path", root}) - - if err := undo.Execute(); err != nil { - t.Fatalf("undo command: %v", err) - } - - if _, err := os.Stat(filepath.Join(root, "draft_one.txt")); err != nil { - t.Fatalf("expected original file after undo: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "draft_two.txt")); err != nil { - t.Fatalf("expected original file after undo: %v", err) - } -} - -func writeFile(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("data"), 0o644); err != nil { - t.Fatalf("write file %s: %v", path, err) - } -} diff --git a/tests/integration/ai_policy_validation_test.go b/tests/integration/ai_policy_validation_test.go deleted file mode 100644 index d003030..0000000 --- a/tests/integration/ai_policy_validation_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "os" - "path/filepath" - "strings" - "testing" - - renamercmd "github.com/rogeecn/renamer/cmd" - "github.com/rogeecn/renamer/internal/ai/genkit" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -type violatingWorkflow struct{} - -func (violatingWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) { - return genkit.Result{ - Response: prompt.RenameResponse{ - Items: []prompt.RenameItem{ - { - Original: "video.mov", - Proposed: "001_clickbait-offer.mov", - Sequence: 1, - }, - }, - Warnings: []string{"model returned promotional phrasing"}, - }, - }, nil -} - -func TestAIPolicyValidationFailsWithActionableMessage(t *testing.T) { - genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) { - return violatingWorkflow{}, nil - }) - t.Cleanup(genkit.ResetWorkflowFactory) - - rootDir := t.TempDir() - createAIPolicyFixture(t, filepath.Join(rootDir, "video.mov")) - - rootCmd := renamercmd.NewRootCommand() - var stdout, stderr bytes.Buffer - rootCmd.SetOut(&stdout) - rootCmd.SetErr(&stderr) - rootCmd.SetArgs([]string{ - "ai", - "--path", rootDir, - "--dry-run", - }) - - err := rootCmd.Execute() - if err == nil { - t.Fatalf("expected policy violation error") - } - - lines := stderr.String() - if !strings.Contains(lines, "Policy violation (banned)") { - t.Fatalf("expected banned token message in stderr, got: %s", lines) - } - if !strings.Contains(err.Error(), "policy violations") { - t.Fatalf("expected error to mention policy violations, got: %v", err) - } - - if stdout.Len() != 0 { - t.Logf("stdout: %s", stdout.String()) - } -} - -func createAIPolicyFixture(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("demo"), 0o644); err != nil { - t.Fatalf("write file %s: %v", path, err) - } -} diff --git a/tests/integration/ai_preview_flow_test.go b/tests/integration/ai_preview_flow_test.go deleted file mode 100644 index 6968207..0000000 --- a/tests/integration/ai_preview_flow_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - renamercmd "github.com/rogeecn/renamer/cmd" - "github.com/rogeecn/renamer/internal/ai/genkit" - "github.com/rogeecn/renamer/internal/ai/prompt" -) - -type stubWorkflow struct { - response prompt.RenameResponse -} - -func (s stubWorkflow) Run(ctx context.Context, req genkit.Request) (genkit.Result, error) { - return genkit.Result{Response: s.response}, nil -} - -func TestAIPreviewFlowRendersSequenceTable(t *testing.T) { - workflow := stubWorkflow{ - response: prompt.RenameResponse{ - Items: []prompt.RenameItem{ - { - Original: "promo SALE 01.JPG", - Proposed: "001_summer-session.jpg", - Sequence: 1, - Notes: "Removed promotional flair", - }, - { - Original: "family_photo.png", - Proposed: "002_family-photo.png", - Sequence: 2, - Notes: "Normalized casing", - }, - }, - Warnings: []string{"AI warning: trimmed banned tokens"}, - PromptHash: "", - }, - } - - genkit.OverrideWorkflowFactory(func(ctx context.Context, opts genkit.Options) (genkit.WorkflowRunner, error) { - return workflow, nil - }) - defer genkit.ResetWorkflowFactory() - - root := t.TempDir() - createAIPreviewFile(t, filepath.Join(root, "promo SALE 01.JPG")) - createAIPreviewFile(t, filepath.Join(root, "family_photo.png")) - - t.Setenv("OPENAI_TOKEN", "test-token") - - rootCmd := renamercmd.NewRootCommand() - var stdout, stderr bytes.Buffer - rootCmd.SetOut(&stdout) - rootCmd.SetErr(&stderr) - exportPath := filepath.Join(root, "renamer.plan.json") - rootCmd.SetArgs([]string{ - "ai", - "--path", root, - "--dry-run", - "--debug-genkit", - }) - - if err := rootCmd.Execute(); err != nil { - t.Fatalf("command execute: %v", err) - } - - data, err := os.ReadFile(exportPath) - if err != nil { - t.Fatalf("read exported plan: %v", err) - } - - var exported prompt.RenameResponse - if err := json.Unmarshal(data, &exported); err != nil { - t.Fatalf("unmarshal exported plan: %v", err) - } - if len(exported.Items) != len(workflow.response.Items) { - t.Fatalf("expected exported items %d, got %d", len(workflow.response.Items), len(exported.Items)) - } - - out := stdout.String() - if !strings.Contains(out, "SEQ") || !strings.Contains(out, "ORIGINAL") || !strings.Contains(out, "SANITIZED") { - t.Fatalf("expected table headers in output, got:\n%s", out) - } - if !strings.Contains(out, "001") || !strings.Contains(out, "promo SALE 01.JPG") || !strings.Contains(out, "001_summer-session.jpg") { - t.Fatalf("expected first entry in output, got:\n%s", out) - } - if !strings.Contains(out, "removed: promo sale") { - t.Fatalf("expected sanitization notes in output, got:\n%s", out) - } - - errOut := stderr.String() - if !strings.Contains(errOut, "Prompt hash:") { - t.Fatalf("expected prompt hash in debug output, got:\n%s", errOut) - } - if !strings.Contains(errOut, "AI warning: trimmed banned tokens") { - t.Fatalf("expected warning surfaced in debug output, got:\n%s", errOut) - } -} - -func createAIPreviewFile(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write file %s: %v", path, err) - } -}