From f0414ec32b5c6701ec836868cdd14760cd3f1d39 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 31 Oct 2025 14:49:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=BB=A5=E6=94=AF=E6=8C=81=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E5=8F=98=E4=BD=93=E7=9A=84=E5=A4=84=E7=90=86=E5=92=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cli-flags.md | 3 + internal/extension/engine.go | 14 +-- internal/extension/parser.go | 2 +- tests/contract/extension_command_test.go | 102 +++++++++++++++----- tests/contract/extension_ledger_test.go | 39 +++----- tests/contract/extension_validation_test.go | 23 +++-- tests/integration/extension_flow_test.go | 47 +++++++++ 7 files changed, 161 insertions(+), 69 deletions(-) diff --git a/docs/cli-flags.md b/docs/cli-flags.md index 6585d6d..788a8b5 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -87,6 +87,9 @@ renamer extension [flags] fails if any token omits the leading dot or repeats the target exactly. - Source extensions are normalized case-insensitively; duplicates and no-op tokens are surfaced as warnings in the preview rather than silently ignored. +- Case variants of the target extension (for example `.JPG` when targeting `.jpg`) remain untouched + unless you include them in the source list, ensuring casing changes happen only when explicitly + requested. - Preview output lists every candidate with `changed`, `no change`, or `skipped` status so scripts can detect conflicts before applying. Conflicting targets block apply and exit with a non-zero code. diff --git a/internal/extension/engine.go b/internal/extension/engine.go index c804958..4eb3756 100644 --- a/internal/extension/engine.go +++ b/internal/extension/engine.go @@ -48,13 +48,6 @@ func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) sourceSet[CanonicalExtension(source)] = struct{}{} } - // Include the target extension so preview surfaces existing matches as no-ops. - matchable := make(map[string]struct{}, len(sourceSet)+1) - for ext := range sourceSet { - matchable[ext] = struct{}{} - } - matchable[targetCanonical] = struct{}{} - filterSet := make(map[string]struct{}, len(req.ExtensionFilter)) for _, filter := range req.ExtensionFilter { filterSet[CanonicalExtension(filter)] = struct{}{} @@ -94,7 +87,10 @@ func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) } } - if _, ok := matchable[canonicalExt]; !ok { + _, sourceMatch := sourceSet[canonicalExt] + targetMatch := canonicalExt == targetCanonical && rawExt == targetExt + + if !sourceMatch && !targetMatch { return nil } @@ -105,7 +101,7 @@ func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) targetRelative := relative targetAbsolute := originalAbsolute - if canonicalExt == targetCanonical && rawExt == targetExt { + if targetMatch { status = PreviewStatusNoChange } diff --git a/internal/extension/parser.go b/internal/extension/parser.go index ea5229f..f2b5c4e 100644 --- a/internal/extension/parser.go +++ b/internal/extension/parser.go @@ -55,7 +55,7 @@ func ParseArgs(args []string) (ParseResult, error) { filteredDisplay := make([]string, 0, len(display)) noOps := make([]string, 0) for i, canon := range canonical { - if canon == targetCanonical { + if canon == targetCanonical && display[i] == target { noOps = append(noOps, display[i]) continue } diff --git a/tests/contract/extension_command_test.go b/tests/contract/extension_command_test.go index 480c8c3..bfed5fd 100644 --- a/tests/contract/extension_command_test.go +++ b/tests/contract/extension_command_test.go @@ -37,29 +37,12 @@ func TestExtensionPreviewAndApply(t *testing.T) { req := extension.NewRequest(scope) req.SetExecutionMode(true, false) - sources := []string{".jpeg", ".JPG", ".jpg"} - canonical, display, duplicates := extension.NormalizeSourceExtensions(sources) - target := extension.NormalizeTargetExtension(".jpg") - targetCanonical := extension.CanonicalExtension(target) - - filteredCanonical := make([]string, 0, len(canonical)) - filteredDisplay := make([]string, 0, len(display)) - noOps := make([]string, 0) - for i, canon := range canonical { - if canon == targetCanonical { - noOps = append(noOps, display[i]) - continue - } - filteredCanonical = append(filteredCanonical, canon) - filteredDisplay = append(filteredDisplay, display[i]) + parsed, err := extension.ParseArgs([]string{".jpeg", ".JPG", ".jpg"}) + if err != nil { + t.Fatalf("parse args: %v", err) } - - if len(filteredCanonical) == 0 { - t.Fatalf("expected canonical sources after filtering") - } - - req.SetExtensions(filteredCanonical, filteredDisplay, target) - req.SetWarnings(duplicates, noOps) + req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target) + req.SetWarnings(parsed.Duplicates, parsed.NoOps) var buf bytes.Buffer summary, planned, err := extension.Preview(context.Background(), req, &buf) @@ -94,8 +77,8 @@ func TestExtensionPreviewAndApply(t *testing.T) { t.Fatalf("expected summary line, output: %s", output) } - if len(summary.Warnings) == 0 { - t.Fatalf("expected warnings for duplicates/no-ops") + if len(summary.Warnings) != 0 { + t.Fatalf("did not expect preview warnings, got %#v", summary.Warnings) } req.SetExecutionMode(false, true) @@ -126,6 +109,77 @@ func TestExtensionPreviewAndApply(t *testing.T) { } } +func TestExtensionSkipsCaseVariantsWithoutSource(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeTestFile(t, filepath.Join(tmp, "photo.jpeg")) + writeTestFile(t, filepath.Join(tmp, "poster.JPG")) + writeTestFile(t, filepath.Join(tmp, "logo.jpg")) + + scope := &listing.ListingRequest{ + WorkingDir: tmp, + IncludeDirectories: false, + Recursive: false, + IncludeHidden: false, + Extensions: nil, + Format: listing.FormatTable, + } + if err := scope.Validate(); err != nil { + t.Fatalf("validate scope: %v", err) + } + + req := extension.NewRequest(scope) + req.SetExecutionMode(true, false) + + parsed, err := extension.ParseArgs([]string{".jpeg", ".jpg"}) + if err != nil { + t.Fatalf("parse args: %v", err) + } + req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target) + req.SetWarnings(parsed.Duplicates, parsed.NoOps) + + var buf bytes.Buffer + summary, planned, err := extension.Preview(context.Background(), req, &buf) + if err != nil { + t.Fatalf("preview error: %v", err) + } + + if summary.TotalCandidates != 2 { + t.Fatalf("expected 2 candidates, got %d", summary.TotalCandidates) + } + if summary.TotalChanged != 1 { + t.Fatalf("expected 1 changed entry, got %d", summary.TotalChanged) + } + if summary.NoChange != 1 { + t.Fatalf("expected 1 no-change entry, got %d", summary.NoChange) + } + if len(planned) != 1 { + t.Fatalf("expected exactly one planned rename, got %d", len(planned)) + } + + output := buf.String() + if strings.Contains(output, "poster.JPG -> poster.jpg") { + t.Fatalf("expected poster.JPG to be excluded from plan, output: %s", output) + } + + req.SetExecutionMode(false, true) + entry, err := extension.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("apply error: %v", err) + } + if len(entry.Operations) != len(planned) { + t.Fatalf("expected %d ledger operations, got %d", len(planned), len(entry.Operations)) + } + + if _, err := os.Stat(filepath.Join(tmp, "photo.jpg")); err != nil { + t.Fatalf("expected photo.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "poster.JPG")); err != nil { + t.Fatalf("expected poster.JPG to remain unchanged: %v", err) + } +} + func writeTestFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/tests/contract/extension_ledger_test.go b/tests/contract/extension_ledger_test.go index ca8362d..4a34d92 100644 --- a/tests/contract/extension_ledger_test.go +++ b/tests/contract/extension_ledger_test.go @@ -37,24 +37,12 @@ func TestExtensionApplyMetadataCaptured(t *testing.T) { req := extension.NewRequest(scope) req.SetExecutionMode(true, false) - sources := []string{".jpeg", ".JPG", ".jpg"} - canonical, display, duplicates := extension.NormalizeSourceExtensions(sources) - target := extension.NormalizeTargetExtension(".jpg") - targetCanonical := extension.CanonicalExtension(target) - - filteredCanonical := make([]string, 0, len(canonical)) - filteredDisplay := make([]string, 0, len(display)) - noOps := make([]string, 0) - for i, canon := range canonical { - if canon == targetCanonical { - noOps = append(noOps, display[i]) - continue - } - filteredCanonical = append(filteredCanonical, canon) - filteredDisplay = append(filteredDisplay, display[i]) + parsed, err := extension.ParseArgs([]string{".jpeg", ".JPG", ".jpg"}) + if err != nil { + t.Fatalf("parse args: %v", err) } - req.SetExtensions(filteredCanonical, filteredDisplay, target) - req.SetWarnings(duplicates, noOps) + req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target) + req.SetWarnings(parsed.Duplicates, parsed.NoOps) summary, planned, err := extension.Preview(context.Background(), req, nil) if err != nil { @@ -72,15 +60,15 @@ func TestExtensionApplyMetadataCaptured(t *testing.T) { } sourcesMeta, ok := entry.Metadata["sourceExtensions"].([]string) - if !ok || len(sourcesMeta) != len(filteredDisplay) { + if !ok || len(sourcesMeta) != len(parsed.SourcesDisplay) { t.Fatalf("sourceExtensions metadata mismatch: %#v", entry.Metadata["sourceExtensions"]) } - if sourcesMeta[0] != ".jpeg" { - t.Fatalf("expected .jpeg in source metadata, got %v", sourcesMeta) + if sourcesMeta[0] != parsed.SourcesDisplay[0] || sourcesMeta[1] != parsed.SourcesDisplay[1] { + t.Fatalf("expected display sources preserved in metadata, got %v", sourcesMeta) } targetMeta, ok := entry.Metadata["targetExtension"].(string) - if !ok || targetMeta != target { + if !ok || targetMeta != parsed.Target { t.Fatalf("targetExtension metadata mismatch: %v", targetMeta) } @@ -107,13 +95,8 @@ func TestExtensionApplyMetadataCaptured(t *testing.T) { t.Fatalf("includeHidden should be false, got %v", includeHidden) } - warnings, ok := entry.Metadata["warnings"].([]string) - if !ok || len(warnings) == 0 { - t.Fatalf("warnings metadata missing: %#v", entry.Metadata["warnings"]) - } - joined := strings.Join(warnings, " ") - if !strings.Contains(joined, "duplicate source extension") { - t.Fatalf("expected duplicate warning in metadata: %v", warnings) + if _, ok := entry.Metadata["warnings"]; ok { + t.Fatalf("did not expect warnings metadata when no duplicates were provided: %#v", entry.Metadata["warnings"]) } ledger := filepath.Join(tmp, ".renamer") diff --git a/tests/contract/extension_validation_test.go b/tests/contract/extension_validation_test.go index 43047cc..242eda8 100644 --- a/tests/contract/extension_validation_test.go +++ b/tests/contract/extension_validation_test.go @@ -28,20 +28,29 @@ func TestParseArgsValidation(t *testing.T) { } } - _, err := extension.ParseArgs([]string{".jpg", ".JPG"}) - if err == nil { - t.Fatalf("expected error when all sources match target") + if _, err := extension.ParseArgs([]string{".jpg", ".jpg"}); err == nil { + t.Fatalf("expected error when source exactly matches target") + } + + if _, err := extension.ParseArgs([]string{".jpg", ".JPG"}); err != nil { + t.Fatalf("expected case-variant source to be accepted: %v", err) } parsed, err := extension.ParseArgs([]string{".jpeg", ".JPG", ".jpg"}) if err != nil { t.Fatalf("unexpected error for valid args: %v", err) } - if len(parsed.SourcesCanonical) != 1 || parsed.SourcesCanonical[0] != ".jpeg" { - t.Fatalf("expected canonical list to contain .jpeg only, got %#v", parsed.SourcesCanonical) + if len(parsed.SourcesCanonical) != 2 { + t.Fatalf("expected canonical list to contain two entries, got %#v", parsed.SourcesCanonical) } - if len(parsed.NoOps) != 1 { - t.Fatalf("expected .jpg to be treated as no-op") + if parsed.SourcesCanonical[0] != ".jpeg" || parsed.SourcesCanonical[1] != ".jpg" { + t.Fatalf("unexpected canonical ordering: %#v", parsed.SourcesCanonical) + } + if len(parsed.SourcesDisplay) != 2 || parsed.SourcesDisplay[1] != ".JPG" { + t.Fatalf("expected display list to preserve .JPG, got %#v", parsed.SourcesDisplay) + } + if len(parsed.NoOps) != 0 { + t.Fatalf("expected no-ops to be empty, got %#v", parsed.NoOps) } } diff --git a/tests/integration/extension_flow_test.go b/tests/integration/extension_flow_test.go index 56217c2..8da31c4 100644 --- a/tests/integration/extension_flow_test.go +++ b/tests/integration/extension_flow_test.go @@ -76,3 +76,50 @@ func TestExtensionCommandFlow(t *testing.T) { t.Fatalf("expected poster.JPG after undo: %v", err) } } + +func TestExtensionCommandSkipsCaseVariantsWithoutSource(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "image.jpeg")) + createFile(t, filepath.Join(tmp, "poster.JPG")) + createFile(t, filepath.Join(tmp, "logo.jpg")) + + var previewOut bytes.Buffer + preview := renamercmd.NewRootCommand() + preview.SetOut(&previewOut) + preview.SetErr(&previewOut) + preview.SetArgs([]string{"extension", ".jpeg", ".jpg", "--dry-run", "--path", tmp}) + + if err := preview.Execute(); err != nil { + t.Fatalf("preview command failed: %v\noutput: %s", err, previewOut.String()) + } + + output := previewOut.String() + if !strings.Contains(output, "image.jpeg -> image.jpg") { + t.Fatalf("expected preview output to include image rename, got:\n%s", output) + } + if strings.Contains(output, "poster.JPG -> poster.jpg") { + t.Fatalf("expected poster.JPG to be excluded from preview, got:\n%s", output) + } + + var applyOut bytes.Buffer + apply := renamercmd.NewRootCommand() + apply.SetOut(&applyOut) + apply.SetErr(&applyOut) + apply.SetArgs([]string{"extension", ".jpeg", ".jpg", "--yes", "--path", tmp}) + + if err := apply.Execute(); err != nil { + t.Fatalf("apply command failed: %v\noutput: %s", err, applyOut.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "image.jpg")); err != nil { + t.Fatalf("expected image.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "poster.JPG")); err != nil { + t.Fatalf("expected poster.JPG to remain uppercase: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "poster.jpg")); !os.IsNotExist(err) { + t.Fatalf("expected poster.jpg not to exist, err=%v", err) + } +}