Files
renamer/internal/ai/validation.go
2025-11-05 16:06:09 +08:00

170 lines
4.6 KiB
Go

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
}