feat: add list command with global filters

This commit is contained in:
Rogee
2025-10-29 16:08:46 +08:00
parent 88563d48e2
commit fa57af8a26
29 changed files with 1892 additions and 25 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Build artifacts
*.exe
*.out
*.test
# Dependency directories
vendor/
# IDE and editor clutter
.vscode/
.idea/
# OS-generated files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.swp
# Environment files
.env
.env.*

31
AGENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# renamer Development Guidelines
Auto-generated from all feature plans. Last updated: 2025-10-29
## Active Technologies
- Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters)
## Project Structure
```text
src/
tests/
```
## Commands
- `renamer list` — preview rename scope with shared flags before executing changes.
- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`.
## Code Style
Go 1.24: Follow standard conventions
## Recent Changes
- 001-list-command-filters: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`
- 001-list-command-filters: Introduced `renamer list` command with shared scope flags and formatters
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

58
cmd/list.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/output"
)
func newListCommand() *cobra.Command {
var (
format string
maxDepth int
)
cmd := &cobra.Command{
Use: "list",
Short: "Display rename candidates matched by the active filters",
Long: "Enumerate files and directories using the same filters that the rename command will honor.",
RunE: func(cmd *cobra.Command, args []string) error {
req, err := listing.ScopeFromCmd(cmd)
if err != nil {
return err
}
req.Format = format
req.MaxDepth = maxDepth
formatter, err := output.NewFormatter(req.Format)
if err != nil {
return err
}
service := listing.NewService()
summary, err := service.List(cmd.Context(), req, formatter, cmd.OutOrStdout())
if err != nil {
return err
}
if summary.Total() == 0 {
_, err = fmt.Fprintln(cmd.OutOrStdout(), listing.EmptyResultMessage(req))
return err
}
return nil
},
}
cmd.Flags().StringVar(&format, "format", listing.FormatTable, "Output format: table or plain")
cmd.Flags().IntVar(&maxDepth, "max-depth", 0, "Maximum recursion depth (0 = unlimited)")
return cmd
}
func init() {
rootCmd.AddCommand(newListCommand())
}

View File

@@ -1,6 +1,5 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
@@ -8,23 +7,16 @@ import (
"os"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/listing"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "renamer",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
Short: "Safe, scriptable batch renaming utility",
Long: `Renamer provides preview-first, undoable rename operations for files and directories.
Use subcommands like "preview", "rename", and "list" with shared scope flags to target exactly
the paths you intend to change.`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
@@ -37,15 +29,5 @@ func Execute() {
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.renamer.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
listing.RegisterScopeFlags(rootCmd.PersistentFlags())
}

6
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,6 @@
# Changelog
## Unreleased
- Add `renamer list` subcommand with shared scope flags and plain/table output formats.
- Document global scope flags and hidden-file behavior.

29
docs/cli-flags.md Normal file
View File

@@ -0,0 +1,29 @@
# CLI Scope Flags
Renamer shares a consistent set of scope flags across every command that inspects or mutates the
filesystem. Use these options at the root command level so they apply to `list`, `preview`, and
`rename` alike.
| Flag | Default | Description |
|------|---------|-------------|
| `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. |
| `-d`, `--include-dirs` | `false` | Limit results to directories only (files and symlinks are suppressed). Directory traversal still occurs even when the flag is absent. |
| `-e`, `--extensions` | *(none)* | Pipe-separated list of file extensions (e.g. `.jpg|.mov`). Tokens must start with a dot, are lowercased internally, and duplicates are ignored. |
| `--hidden` | `false` | Include dot-prefixed files and directories. By default they are excluded from listings and rename previews. |
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
## Validation Rules
- Extension tokens that are empty or missing the leading `.` cause validation errors.
- Filters that match zero entries result in a friendly message and exit code `0`.
- Invalid flag combinations (e.g., unsupported `--format` values) cause the command to exit with a non-zero code.
- Recursive traversal honor `--hidden` and skips unreadable directories while logging warnings.
Keep this document updated whenever a new command is introduced or the global scope behavior
changes.
### Usage Examples
- Preview files recursively: `renamer --recursive preview`
- List JPEGs only: `renamer --extensions .jpg list`
- Include dotfiles: `renamer --hidden --extensions .env list`

View File

@@ -0,0 +1,66 @@
package filters
import (
"fmt"
"strings"
)
// ParseExtensions converts a raw `|`-delimited extension string into a
// normalized, deduplicated slice. Tokens must be prefixed with a dot.
func ParseExtensions(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
tokens := strings.Split(raw, "|")
seen := make(map[string]struct{}, len(tokens))
result := make([]string, 0, len(tokens))
for _, token := range tokens {
trimmed := strings.TrimSpace(token)
if trimmed == "" {
return nil, fmt.Errorf("extensions string contains empty token")
}
if !strings.HasPrefix(trimmed, ".") {
return nil, fmt.Errorf("extension %q must start with '.'", trimmed)
}
normalized := strings.ToLower(trimmed)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result, nil
}
// MergeExtensions merges two extension slices, deduplicating case-insensitively.
func MergeExtensions(base, extra []string) []string {
if len(extra) == 0 {
return base
}
seen := make(map[string]struct{}, len(base)+len(extra))
merged := make([]string, 0, len(base)+len(extra))
for _, ext := range base {
lower := strings.ToLower(ext)
if _, exists := seen[lower]; exists {
continue
}
seen[lower] = struct{}{}
merged = append(merged, lower)
}
for _, ext := range extra {
lower := strings.ToLower(ext)
if _, exists := seen[lower]; exists {
continue
}
seen[lower] = struct{}{}
merged = append(merged, lower)
}
return merged
}

102
internal/listing/options.go Normal file
View File

@@ -0,0 +1,102 @@
package listing
import (
"fmt"
"os"
"github.com/rogeecn/renamer/internal/filters"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
flagPath = "path"
flagRecursive = "recursive"
flagIncludeDirs = "include-dirs"
flagHidden = "hidden"
flagExtensions = "extensions"
)
// RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations.
func RegisterScopeFlags(flags *pflag.FlagSet) {
flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)")
flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories")
flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results")
flags.Bool(flagHidden, false, "Include hidden files and directories")
flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)")
}
// ScopeFromCmd builds a ListingRequest populated from scope flags on the provided command.
func ScopeFromCmd(cmd *cobra.Command) (*ListingRequest, error) {
path, err := getStringFlag(cmd, flagPath)
if err != nil {
return nil, err
}
if path == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
path = cwd
}
recursive, err := getBoolFlag(cmd, flagRecursive)
if err != nil {
return nil, err
}
includeDirs, err := getBoolFlag(cmd, flagIncludeDirs)
if err != nil {
return nil, err
}
includeHidden, err := getBoolFlag(cmd, flagHidden)
if err != nil {
return nil, err
}
extRaw, err := getStringFlag(cmd, flagExtensions)
if err != nil {
return nil, err
}
extensions, err := filters.ParseExtensions(extRaw)
if err != nil {
return nil, err
}
req := &ListingRequest{
WorkingDir: path,
IncludeDirectories: includeDirs,
Recursive: recursive,
IncludeHidden: includeHidden,
Extensions: extensions,
Format: FormatTable,
}
if err := req.Validate(); err != nil {
return nil, err
}
return req, nil
}
func getStringFlag(cmd *cobra.Command, name string) (string, error) {
if f := cmd.Flags().Lookup(name); f != nil {
return cmd.Flags().GetString(name)
}
if f := cmd.InheritedFlags().Lookup(name); f != nil {
return cmd.InheritedFlags().GetString(name)
}
return "", fmt.Errorf("flag %s not defined", name)
}
func getBoolFlag(cmd *cobra.Command, name string) (bool, error) {
if f := cmd.Flags().Lookup(name); f != nil {
return cmd.Flags().GetBool(name)
}
if f := cmd.InheritedFlags().Lookup(name); f != nil {
return cmd.InheritedFlags().GetBool(name)
}
return false, fmt.Errorf("flag %s not defined", name)
}

170
internal/listing/service.go Normal file
View File

@@ -0,0 +1,170 @@
package listing
import (
"context"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/rogeecn/renamer/internal/output"
"github.com/rogeecn/renamer/internal/traversal"
)
// walker abstracts traversal implementation for easier testing.
type walker interface {
Walk(root string, recursive bool, includeDirs bool, includeHidden bool, maxDepth int, fn func(relPath string, entry fs.DirEntry, depth int) error) error
}
// Service orchestrates filesystem traversal, filtering, and output formatting
// for the read-only `renamer list` command.
type Service struct {
walker walker
}
// Option configures optional dependencies for the Service.
type Option func(*Service)
// WithWalker provides a custom traversal walker (useful for tests).
func WithWalker(w walker) Option {
return func(s *Service) {
s.walker = w
}
}
// NewService initializes a listing Service with default dependencies.
func NewService(opts ...Option) *Service {
service := &Service{
walker: traversal.NewWalker(),
}
for _, opt := range opts {
opt(service)
}
return service
}
// List executes a listing request, writing formatted output to sink while
// returning the computed summary for downstream consumers.
func (s *Service) List(ctx context.Context, req *ListingRequest, formatter output.Formatter, sink io.Writer) (output.Summary, error) {
var summary output.Summary
if formatter == nil {
return summary, errors.New("formatter cannot be nil")
}
if sink == nil {
sink = io.Discard
}
if err := req.Validate(); err != nil {
return summary, err
}
if err := formatter.Begin(sink); err != nil {
return summary, err
}
extensions := make(map[string]struct{}, len(req.Extensions))
for _, ext := range req.Extensions {
extensions[ext] = struct{}{}
}
err := s.walker.Walk(
req.WorkingDir,
req.Recursive,
req.IncludeDirectories,
req.IncludeHidden,
req.MaxDepth,
func(relPath string, entry fs.DirEntry, depth int) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
listingEntry, err := s.toListingEntry(req.WorkingDir, relPath, entry, depth)
if err != nil {
return err
}
if req.IncludeDirectories && listingEntry.Type != EntryTypeDir {
return nil
}
// Apply extension filtering to files only.
if listingEntry.Type == EntryTypeFile && len(extensions) > 0 {
ext := strings.ToLower(filepath.Ext(entry.Name()))
if _, match := extensions[ext]; !match {
return nil
}
listingEntry.MatchedExtension = ext
}
outEntry := toOutputEntry(listingEntry)
if err := formatter.WriteEntry(sink, outEntry); err != nil {
return err
}
summary.Add(outEntry)
return nil
},
)
if err != nil {
return summary, err
}
if err := formatter.WriteSummary(sink, summary); err != nil {
return summary, err
}
return summary, nil
}
func (s *Service) toListingEntry(root, rel string, entry fs.DirEntry, depth int) (ListingEntry, error) {
fullPath := filepath.Join(root, rel)
entryType := classifyEntry(entry)
var size int64
if entryType == EntryTypeFile {
info, err := entry.Info()
if err != nil {
return ListingEntry{}, err
}
size = info.Size()
}
if entryType == EntryTypeSymlink {
info, err := os.Lstat(fullPath)
if err == nil {
size = info.Size()
}
}
return ListingEntry{
Path: filepath.ToSlash(rel),
Type: entryType,
SizeBytes: size,
Depth: depth,
}, nil
}
func classifyEntry(entry fs.DirEntry) EntryType {
if entry.Type()&os.ModeSymlink != 0 {
return EntryTypeSymlink
}
if entry.IsDir() {
return EntryTypeDir
}
return EntryTypeFile
}
func toOutputEntry(entry ListingEntry) output.Entry {
return output.Entry{
Path: entry.Path,
Type: string(entry.Type),
SizeBytes: entry.SizeBytes,
Depth: entry.Depth,
MatchedExtension: entry.MatchedExtension,
}
}

View File

@@ -0,0 +1,20 @@
package listing
import "strings"
// EmptyResultMessage returns a contextual message when no entries match.
func EmptyResultMessage(req *ListingRequest) string {
if req == nil {
return "No entries matched the provided filters."
}
if len(req.Extensions) > 0 {
return "No entries matched extensions: " + strings.Join(req.Extensions, ", ")
}
if req.IncludeHidden {
return "No entries matched the provided filters (including hidden files)."
}
return "No entries matched the provided filters."
}

97
internal/listing/types.go Normal file
View File

@@ -0,0 +1,97 @@
package listing
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
const (
// FormatTable renders output in a column-aligned table for human review.
FormatTable = "table"
// FormatPlain renders newline-delimited paths for scripting.
FormatPlain = "plain"
)
// ListingRequest captures scope and formatting preferences for a listing run.
type ListingRequest struct {
WorkingDir string
IncludeDirectories bool
Recursive bool
IncludeHidden bool
Extensions []string
Format string
MaxDepth int
}
// ListingEntry represents a single filesystem node discovered during traversal.
type ListingEntry struct {
Path string
Type EntryType
SizeBytes int64
Depth int
MatchedExtension string
}
// EntryType captures the classification of a filesystem node.
type EntryType string
const (
EntryTypeFile EntryType = "file"
EntryTypeDir EntryType = "directory"
EntryTypeSymlink EntryType = "symlink"
)
// Validate ensures the request is well-formed before execution.
func (r *ListingRequest) Validate() error {
if r == nil {
return errors.New("listing request cannot be nil")
}
if r.WorkingDir == "" {
return errors.New("working directory must be provided")
}
if !filepath.IsAbs(r.WorkingDir) {
abs, err := filepath.Abs(r.WorkingDir)
if err != nil {
return fmt.Errorf("resolve working directory: %w", err)
}
r.WorkingDir = abs
}
if r.MaxDepth < 0 {
return errors.New("max depth cannot be negative")
}
switch r.Format {
case "":
r.Format = FormatTable
case FormatTable, FormatPlain:
// ok
default:
return fmt.Errorf("unsupported format %q", r.Format)
}
seen := make(map[string]struct{})
filtered := r.Extensions[:0]
for _, ext := range r.Extensions {
trimmed := strings.TrimSpace(ext)
if trimmed == "" {
return errors.New("extensions cannot include empty values")
}
if !strings.HasPrefix(trimmed, ".") {
return fmt.Errorf("extension %q must start with '.'", ext)
}
lower := strings.ToLower(trimmed)
if _, exists := seen[lower]; exists {
continue
}
seen[lower] = struct{}{}
filtered = append(filtered, lower)
}
r.Extensions = filtered
return nil
}

View File

@@ -0,0 +1,21 @@
package output
import "fmt"
// Format identifiers mirrored from listing package to avoid import cycle.
const (
FormatTable = "table"
FormatPlain = "plain"
)
// NewFormatter selects the appropriate renderer based on format key.
func NewFormatter(format string) (Formatter, error) {
switch format {
case FormatPlain:
return NewPlainFormatter(), nil
case FormatTable, "":
return NewTableFormatter(), nil
default:
return nil, fmt.Errorf("unsupported format %q", format)
}
}

View File

@@ -0,0 +1,52 @@
package output
import (
"fmt"
"io"
)
// Formatter renders listing entries in a chosen representation.
type Formatter interface {
Begin(w io.Writer) error
WriteEntry(w io.Writer, entry Entry) error
WriteSummary(w io.Writer, summary Summary) error
}
// Entry represents a single listing output record in a formatter-agnostic form.
type Entry struct {
Path string
Type string
SizeBytes int64
Depth int
MatchedExtension string
}
// Summary aggregates counts for final reporting.
type Summary struct {
Files int
Directories int
Symlinks int
}
// Add records a new entry in the summary counters.
func (s *Summary) Add(entry Entry) {
switch entry.Type {
case "file":
s.Files++
case "directory":
s.Directories++
case "symlink":
s.Symlinks++
}
}
// Total returns the sum of all entry classifications.
func (s Summary) Total() int {
return s.Files + s.Directories + s.Symlinks
}
// DefaultSummaryLine produces a human-readable summary string for any format.
func DefaultSummaryLine(summary Summary) string {
return fmt.Sprintf("Total: %d entries (files: %d, directories: %d, symlinks: %d)",
summary.Total(), summary.Files, summary.Directories, summary.Symlinks)
}

28
internal/output/plain.go Normal file
View File

@@ -0,0 +1,28 @@
package output
import (
"fmt"
"io"
)
// plainFormatter emits one entry per line suitable for piping into other tools.
type plainFormatter struct{}
// NewPlainFormatter constructs a formatter for plain output.
func NewPlainFormatter() Formatter {
return &plainFormatter{}
}
func (plainFormatter) Begin(io.Writer) error {
return nil
}
func (plainFormatter) WriteEntry(w io.Writer, entry Entry) error {
_, err := fmt.Fprintln(w, entry.Path)
return err
}
func (plainFormatter) WriteSummary(w io.Writer, summary Summary) error {
_, err := fmt.Fprintln(w, DefaultSummaryLine(summary))
return err
}

48
internal/output/table.go Normal file
View File

@@ -0,0 +1,48 @@
package output
import (
"fmt"
"io"
"text/tabwriter"
)
// tableFormatter renders aligned columns for human-friendly review.
type tableFormatter struct {
writer *tabwriter.Writer
}
// NewTableFormatter constructs a table formatter.
func NewTableFormatter() Formatter {
return &tableFormatter{}
}
func (f *tableFormatter) Begin(w io.Writer) error {
f.writer = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
_, err := fmt.Fprintln(f.writer, "PATH\tTYPE\tSIZE")
return err
}
func (f *tableFormatter) WriteEntry(w io.Writer, entry Entry) error {
if f.writer == nil {
return fmt.Errorf("table formatter not initialized")
}
size := "-"
if entry.Type == "file" && entry.SizeBytes >= 0 {
size = fmt.Sprintf("%d", entry.SizeBytes)
}
_, err := fmt.Fprintf(f.writer, "%s\t%s\t%s\n", entry.Path, entry.Type, size)
return err
}
func (f *tableFormatter) WriteSummary(w io.Writer, summary Summary) error {
if f.writer == nil {
return fmt.Errorf("table formatter not initialized")
}
if err := f.writer.Flush(); err != nil {
return err
}
_, err := fmt.Fprintln(w, DefaultSummaryLine(summary))
return err
}

View File

@@ -0,0 +1,143 @@
package traversal
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Walker streams filesystem entries relative to a working directory.
type Walker struct{}
// NewWalker constructs a new Walker with default behavior.
func NewWalker() *Walker {
return &Walker{}
}
// Walk traverses starting at root and invokes fn for each matching entry.
//
// The callback receives the relative path, os.DirEntry metadata, and depth.
// Directories that are symbolic links are not descended into when recursive is true.
func (w *Walker) Walk(
root string,
recursive bool,
includeDirs bool,
includeHidden bool,
maxDepth int,
fn func(relPath string, entry fs.DirEntry, depth int) error,
) error {
if root == "" {
return errors.New("walk root cannot be empty")
}
info, err := os.Stat(root)
if err != nil {
return err
}
if !info.IsDir() {
return errors.New("walk root must be a directory")
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return err
}
walker := func(path string, d fs.DirEntry, err error) error {
if err != nil {
// propagate traversal errors to caller for logging/handling
return err
}
rel, relErr := filepath.Rel(rootAbs, path)
if relErr != nil {
return relErr
}
rel = filepath.Clean(rel)
depth := depthFor(rel)
if rel == "." {
// Skip emitting the root directory unless explicitly requested.
if includeDirs {
return fn(rel, d, depth)
}
return nil
}
if maxDepth > 0 && depth > maxDepth {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if !includeHidden && isHidden(rel) {
if d.IsDir() && recursive {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
if !recursive && depth > 0 {
return fs.SkipDir
}
if !includeDirs {
// continue traversal but don't emit directory
return nil
}
}
if d.Type()&os.ModeSymlink != 0 && d.IsDir() {
// emit symlink entry but do not traverse into it
if err := fn(rel, d, depth); err != nil {
return err
}
if recursive {
return nil
}
return fs.SkipDir
}
return fn(rel, d, depth)
}
if recursive {
return filepath.WalkDir(rootAbs, walker)
}
entries, err := os.ReadDir(rootAbs)
if err != nil {
return err
}
for _, entry := range entries {
path := filepath.Join(rootAbs, entry.Name())
if err := walker(path, entry, nil); err != nil {
if errors.Is(err, fs.SkipDir) {
continue
}
return err
}
}
return nil
}
func depthFor(rel string) int {
if rel == "." || rel == "" {
return 0
}
return strings.Count(rel, string(filepath.Separator))
}
func isHidden(rel string) bool {
parts := strings.Split(rel, string(filepath.Separator))
for _, part := range parts {
if len(part) > 0 && part[0] == '.' {
return true
}
}
return false
}

28
scripts/smoke-test-list.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="go run ."
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
mkdir -p "$TMP_DIR/nested"
touch "$TMP_DIR/root.txt"
touch "$TMP_DIR/nested/child.jpg"
LIST_OUTPUT="$($BIN list --path "$TMP_DIR" --recursive --format plain)"
LIST_TOTAL="$(printf '%s' "$LIST_OUTPUT" | awk '/^Total:/ {print $2}')"
if $BIN preview --help >/dev/null 2>&1; then
PREVIEW_OUTPUT="$($BIN preview --path "$TMP_DIR" --recursive)"
PREVIEW_TOTAL="$(printf '%s' "$PREVIEW_OUTPUT" | awk '/^Total:/ {print $2}')"
if [[ "$LIST_TOTAL" != "$PREVIEW_TOTAL" ]]; then
echo "Mismatch between list and preview candidate counts" >&2
exit 1
fi
else
echo "Preview command not available; parity check skipped." >&2
fi
echo "Smoke test completed. List total: $LIST_TOTAL"

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Cobra List Command with Global Filters
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-10-29
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,59 @@
# CLI Contract: `renamer list`
## Command Synopsis
```bash
renamer list [--path <dir>] [-r] [-d] [-e .ext|.ext2] [--format table|plain]
```
## Description
Enumerates filesystem entries that match the active global filters without applying any rename
operations. Output supports human-friendly table view and automation-friendly plain mode. Results
mirror the candidate set used by `renamer preview` and `renamer rename`.
## Arguments & Flags
| Flag | Type | Default | Required | Applies To | Description |
|------|------|---------|----------|------------|-------------|
| `--path` | string | current directory | No | root command | Directory to operate on; must exist and be readable |
| `-r`, `--recursive` | bool | `false` | No | root command | Enable depth-first traversal through subdirectories |
| `-d`, `--include-dirs` | bool | `false` | No | root command | Include directories in output alongside files |
| `-e`, `--extensions` | string | (none) | No | root command | `|`-delimited list of `.`-prefixed extensions used to filter files |
| `--format` | enum (`table`, `plain`) | `table` | No | list subcommand | Controls output rendering style |
| `--limit` | int | 0 (no limit) | No | list subcommand | Optional cap on number of entries returned; 0 means unlimited |
| `--no-progress` | bool | `false` | No | list subcommand | Suppress progress indicators for scripting |
## Exit Codes
| Code | Meaning | Example Trigger |
|------|---------|-----------------|
| `0` | Success | Listing completed even if zero entries matched |
| `2` | Validation error | Duplicate/empty extension token, unreadable path |
| `3` | Traversal failure | I/O error during directory walk |
## Output Formats
### Table (default)
```
PATH TYPE SIZE
photos/2024/event.jpg file 3.2 MB
photos/2024 directory —
```
### Plain (`--format plain`)
```
photos/2024/event.jpg
photos/2024
```
Both formats MUST include a trailing summary line:
```
Total: 42 entries (files: 38, directories: 4)
```
## Validation Rules
- Extensions MUST begin with `.` and be deduplicated case-insensitively.
- When no entries remain after filtering, the command prints `No entries matched the provided
filters.` and exits with code `0`.
- Symlinks MUST be reported with type `symlink` and NOT followed recursively unless future scope
explicitly enables it.

View File

@@ -0,0 +1,40 @@
# Data Model: Cobra List Command with Global Filters
## Entities
### ListingRequest
- **Description**: Captures the users desired listing scope and presentation options.
- **Fields**:
- `workingDir` (string): Absolute or relative path where traversal begins. Default: current dir.
- `includeDirectories` (bool): Mirrors `-d` flag; when true, directories appear in results.
- `recursive` (bool): Mirrors `-r` flag; enables depth-first traversal of subdirectories.
- `extensions` ([]string): Parsed, normalized list of `.`-prefixed extensions from `-e`.
- `format` (enum): Output format requested (`table`, `plain`).
- `maxDepth` (int, optional): Future-safe guard to prevent runaway recursion; defaults to unlimited.
- **Validations**:
- `extensions` MUST NOT contain empty strings or duplicates after normalization.
- `format` MUST be one of the supported enum values; invalid values trigger validation errors.
- `workingDir` MUST resolve to an accessible directory before traversal begins.
### ListingEntry
- **Description**: Represents a single filesystem node returned by the list command.
- **Fields**:
- `path` (string): Relative path from `workingDir`.
- `type` (enum): `file`, `directory`, or `symlink`.
- `sizeBytes` (int64): File size in bytes; directories report aggregated size only if available.
- `depth` (int): Depth level from the root directory (root = 0).
- `matchedExtension` (string, optional): Extension that satisfied the filter when applicable.
- **Validations**:
- `path` MUST be unique within a single command invocation.
- `type` MUST align with actual filesystem metadata; symlinks MUST be flagged to avoid confusion.
## Relationships
- `ListingRequest` produces a stream of `ListingEntry` items based on traversal rules.
- `ListingEntry` items may reference parent directories implicitly via `path` hierarchy; no explicit
parent pointer is stored to keep payload lightweight.
## State Transitions
1. **Initialization**: CLI parses flags into `ListingRequest` and validates inputs.
2. **Traversal**: Request feeds traversal engine, emitting raw filesystem metadata.
3. **Filtering**: Raw entries filtered against `includeDirectories`, `recursive`, and `extensions`.
4. **Formatting**: Filtered entries passed to output renderer selecting table or plain layout.

View File

@@ -0,0 +1,99 @@
# Implementation Plan: Cobra List Command with Global Filters
**Branch**: `001-list-command-filters` | **Date**: 2025-10-29 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-list-command-filters/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
Introduce a Cobra `list` subcommand that enumerates filesystem entries using the same global filter
flags (`-r`, `-d`, `-e`) shared with preview/rename flows. The command will reuse traversal and
validation utilities to guarantee consistent candidate sets, provide table/plain output formats, and
surface clear messaging for empty results or invalid filters.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: Go 1.24
**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`
**Storage**: Local filesystem (read-only listing)
**Testing**: Go `testing` package with CLI-focused integration tests
**Target Platform**: Cross-platform CLI (Linux, macOS, Windows)
**Project Type**: Single CLI project
**Performance Goals**: First page of 5k entries within 2 seconds via streaming output
**Constraints**: Deterministic ordering, no filesystem mutations, filters shared across commands
**Scale/Scope**: Operates on directories with tens of thousands of entries per invocation
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Preview flow MUST show deterministic rename mappings and require explicit confirmation (Preview-First Safety).
- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger).
- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine).
- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal).
- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship).
**Gate Alignment**:
- Listing command will remain a read-only helper; preview/rename confirmation flow stays unchanged.
- Ledger logic untouched; plan maintains append-only guarantees by reusing existing history modules.
- Filters, traversal, and rule composition will be centralized to avoid divergence between commands.
- Root-level flags (`-r`, `-d`, `-e`) will configure shared traversal services so all subcommands honor identical scope rules.
- Cobra command UX will include consistent help text, validation errors, and integration tests for list/preview parity.
## Project Structure
### Documentation (this feature)
```text
specs/001-list-command-filters/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
cmd/
├── root.go # Cobra root command with global flags
├── list.go # New list subcommand entry point
└── preview.go # Existing preview wiring (to be reconciled with shared filters)
internal/
├── traversal/ # Scope walking utilities (files, directories, recursion)
├── filters/ # Extension parsing/validation shared across commands
├── listing/ # New package composing traversal + formatting
└── output/ # Shared renderers for table/plain display
tests/
├── contract/
│ └── list_command_test.go
├── integration/
│ └── list_and_preview_parity_test.go
└── fixtures/ # Sample directory trees for CLI tests
```
**Structure Decision**: Single CLI repository rooted at `cmd/` with supporting packages under
`internal/`. Shared traversal and filter logic will move into dedicated packages to ensure the
global flags are consumed identically by `list`, `preview`, and rename workflows. Tests live under
`tests/` mirroring contract vs integration coverage.
## Complexity Tracking
No constitution gate violations identified; no additional complexity justifications required.

View File

@@ -0,0 +1,60 @@
# Quickstart: Cobra List Command with Global Filters
## Goal
Learn how to preview rename scope safely by using the new `renamer list` subcommand with global
filter flags that apply across all commands.
## Prerequisites
- Go toolchain installed (>= 1.24) for building the CLI locally.
- Sample directory containing mixed file types to exercise filters.
## Steps
1. **Build the CLI**
```bash
go build -o renamer ./...
```
2. **Inspect available commands and global flags**
```bash
./renamer --help
./renamer list --help
```
Confirm `-r`, `-d`, and `-e` are listed as global flags.
3. **List JPEG assets recursively**
```bash
./renamer list -r -e .jpg
```
Verify output shows a table with relative paths, types, and sizes. The summary line should report
total entries found.
4. **Produce automation-friendly output**
```bash
./renamer list --format plain -e .mov|.mp4 > media-files.txt
```
Inspect `media-files.txt` to confirm one path per line.
5. **Validate filter parity with preview**
```bash
./renamer list -r -d -e .txt|.md
./renamer preview -r -d -e .txt|.md
```
Ensure both commands report the same number of directories, since `-d` suppresses files.
6. **Handle empty results gracefully**
```bash
./renamer list -e .doesnotexist
```
Expect a friendly message explaining that no entries matched the filters.
7. **Inspect hidden files when needed**
```bash
./renamer list --hidden -e .env
```
Hidden entries are excluded by default, so `--hidden` opts in explicitly when you need to audit dotfiles.
## Next Steps
- Integrate the list command into scripts that currently run `preview` to guard against unintended
rename scopes.
- Extend automated tests to cover new filters plus list/preview parity checks.

View File

@@ -0,0 +1,33 @@
# Phase 0 Research: Cobra List Command with Global Filters
## Decision: Promote scope flags to Cobra root command persistent flags
- **Rationale**: Persistent flags defined on the root command automatically apply to all
subcommands, ensuring a single source of truth for recursion (`-r`), directory inclusion (`-d`),
and extension filters (`-e`). This prevents divergence between `list`, `preview`, and future
rename commands.
- **Alternatives considered**:
- *Duplicate flag definitions per subcommand*: rejected because it risks inconsistent validation
and requires keeping help text in sync.
- *Environment variables for shared filters*: rejected because CLI users expect flag-driven scope
control and env vars complicate scripting.
## Decision: Stream directory traversal using `filepath.WalkDir` with early emission
- **Rationale**: `WalkDir` supports depth-first traversal with built-in symlink detection and allows
emitting entries as they are encountered, keeping memory usage bounded even for directories with
>10k items.
- **Alternatives considered**:
- *Preloading all entries into slices before formatting*: rejected because it inflates memory and
delays first output, violating the 2-second responsiveness goal.
- *Shelling out to `find` or OS-specific tools*: rejected due to portability concerns and reduced
testability inside Go.
## Decision: Provide dual output renderers (table + plain) via shared formatter interface
- **Rationale**: Implementing a small formatter interface allows easy expansion of output modes and
keeps the list command decoupled from presentation details. A table renderer can rely on
text/tabwriter, while the plain renderer writes newline-delimited paths to satisfy scripting use
cases.
- **Alternatives considered**:
- *Hard-coding formatted strings inside the command*: rejected because it complicates testing and
future format additions (JSON, CSV).
- *Introducing a heavy templating library*: rejected as unnecessary overhead for simple CLI
output.

View File

@@ -0,0 +1,151 @@
# Feature Specification: Cobra List Command with Global Filters
**Feature Branch**: `001-list-command-filters`
**Created**: 2025-10-29
**Status**: Draft
**Input**: User description: "实现文件列表遍历展示cobra 子命令list支持当前系统要求的过滤参数过滤参数为全局生效所以应该指定到root command上。"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Discover Filtered Files Before Renaming (Priority: P1)
As a command-line user preparing a batch rename, I want to run `renamer list` with the same filters
that the rename command will honor so I can preview the exact files that will be affected.
**Why this priority**: Prevents accidental renames by making scope explicit before any destructive
action.
**Independent Test**: Execute `renamer list -e .jpg|.png` in a sample directory and verify the output
lists only matching files without performing any rename.
**Acceptance Scenarios**:
1. **Given** a directory containing mixed file types, **When** the user runs `renamer list -e .jpg`,
**Then** the output only includes `.jpg` files and reports the total count.
2. **Given** a directory tree with nested folders, **When** the user runs `renamer list -r`,
**Then** results include files from subdirectories with each entry showing the relative path.
---
### User Story 2 - Apply Global Filters Consistently (Priority: P2)
As an operator scripting renamer commands, I want filter flags (`-r`, `-d`, `-e`) to be defined on
the root command so they apply consistently to `list`, `preview`, and future subcommands without
redundant configuration.
**Why this priority**: Ensures a single source of truth for scope filters, reducing user error and
documentation complexity.
**Independent Test**: Run `renamer list` and `renamer preview` with the same global flags in a
script, confirming both commands interpret scope identically.
**Acceptance Scenarios**:
1. **Given** the root command defines global filter flags, **When** the user specifies `--extensions
.mov|.mp4` with `renamer list`, **Then** running `renamer preview` in the same shell session with
identical flags produces the same candidate set.
---
### User Story 3 - Review Listing Output Comfortably (Priority: P3)
As a user reviewing large directories, I want the `list` output to provide human-readable columns
and an optional machine-friendly format so I can spot issues quickly or pipe results into other
tools.
**Why this priority**: Good ergonomics encourage the list command to become part of every workflow,
increasing safety and adoption.
**Independent Test**: Run `renamer list --format table` and `renamer list --format plain` to confirm
both modes display the same entries in different formats without extra configuration.
**Acceptance Scenarios**:
1. **Given** the list command is executed with default settings, **When** multiple files are
returned, **Then** the output presents aligned columns containing path, type (file/directory),
and size.
2. **Given** the user supplies `--format plain`, **When** the command runs, **Then** the output
emits one path per line suitable for piping into other commands.
---
### Edge Cases
- Directory contains no items after filters are applied; command must exit gracefully with zero
results and a clear message.
- Filters include duplicate or malformed extensions (e.g., `-e .jpg||.png`); command must reject the
input and surface a descriptive validation error.
- Listing directories requires read permissions; command must skip unreadable paths, log warnings,
and continue scanning allowed paths.
- File system contains symbolic links or junctions; traversal must avoid infinite loops and clearly
mark symlinked entries.
- Large directories (>10k entries); command must stream results without excessive memory usage and
display progress feedback when execution exceeds a user-friendly threshold.
- Hidden files may be unintentionally included; unless `--hidden` is provided, they must remain
excluded and the help text should explain how to opt in.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The root Cobra command MUST expose global filter flags for recursion (`-r`), directory
inclusion (`-d`), and extension filtering (`-e .ext|.ext2`) that apply to all subcommands.
- **FR-002**: The `list` subcommand MUST enumerate files and directories matching the active filters
within the current (or user-specified) working directory without modifying the filesystem.
- **FR-003**: Listing results MUST be deterministic: entries sorted lexicographically by relative
path with directories identified distinctly from files.
- **FR-004**: The command MUST support at least two output formats: a human-readable table (default)
and a plain-text list (`--format plain`) for automation.
- **FR-005**: When filters exclude all entries, the CLI MUST communicate that zero results were
found and suggest reviewing filter parameters.
- **FR-006**: The `list` subcommand MUST share validation and traversal utilities with preview and
rename flows to guarantee identical scope resolution across commands.
- **FR-007**: The command MUST return a non-zero exit code when input validation fails and zero when
execution completes successfully, enabling scripting.
- **FR-008**: Hidden files and directories MUST be excluded by default and only included when users
explicitly pass a `--hidden` flag.
### Key Entities *(include if feature involves data)*
- **ListingRequest**: Captures active filters (recursion, directory inclusion, extensions, path) and
desired output format for a listing invocation.
- **ListingEntry**: Represents a single file or directory discovered during traversal, including its
relative path, type, size (bytes), and depth.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can execute `renamer list` with filters on a directory containing up to 5,000
entries and receive the first page of results within 2 seconds.
- **SC-002**: In usability testing, 90% of participants correctly predict which files will be renamed
after reviewing `renamer list` output.
- **SC-003**: Automated regression tests confirm that `list`, `preview`, and `rename` commands return
identical candidate counts for the same filter combinations in 100% of tested scenarios.
- **SC-004**: Support requests related to "unexpected files being renamed" decrease by 30% in the
first release cycle following launch.
## Assumptions
- Default output format is a table suitable for terminals with ANSI support; plain text is available
for scripting without additional flags beyond `--format`.
- Users run commands from the directory they intend to operate on; specifying alternative roots will
follow existing conventions (e.g., `--path`) if already provided by the tool.
- Existing preview and rename workflows already rely on shared traversal utilities that can be
extended for the list command.
## Dependencies & Risks
- Requires traversal utilities to handle large directories efficiently; performance optimizations may
be needed if current implementation does not stream results.
- Depends on existing validation logic for extension filtering and directory scopes; any divergence
introduces inconsistency between commands.
- Risk of confusing users if help documentation is not updated to emphasize using `list` before
running rename operations.
## Clarifications
### Session 2025-10-29
- Q: Should the list command include hidden files by default or require an explicit opt-in? → A:
Exclude hidden files by default; add a `--hidden` flag to include them.

View File

@@ -0,0 +1,175 @@
# Tasks: Cobra List Command with Global Filters
**Input**: Design documents from `/specs/001-list-command-filters/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Tests are optional; include them only where they support the user storys independent validation.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare Cobra project for new subcommand and tests
- [X] T001 Normalize root command metadata and remove placeholder toggle flag in `cmd/root.go`
- [X] T002 Scaffold listing service package with stub struct in `internal/listing/service.go`
- [X] T003 Add fixture guidance for sample directory trees in `tests/fixtures/README.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core utilities shared across list, preview, and rename flows
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [X] T004 Define `ListingRequest`/`ListingEntry` structs with validators in `internal/listing/types.go`
- [X] T005 [P] Implement extension filter parser/normalizer in `internal/filters/extensions.go`
- [X] T006 [P] Implement streaming traversal walker with symlink guard in `internal/traversal/walker.go`
- [X] T007 [P] Declare formatter interface and summary helpers in `internal/output/formatter.go`
- [X] T008 Document global filter contract expectations in `docs/cli-flags.md`
**Checkpoint**: Foundation ready—user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - Discover Filtered Files Before Renaming (Priority: P1) 🎯 MVP
**Goal**: Provide a read-only `renamer list` command that mirrors rename scope
**Independent Test**: Run `renamer list -e .jpg|.png` against fixtures and verify results/summary without filesystem changes
### Tests for User Story 1 (OPTIONAL - included for confidence)
- [X] T009 [P] [US1] Add contract test for filtered listing summary in `tests/contract/list_command_test.go`
- [X] T010 [P] [US1] Add integration test covering recursive listing in `tests/integration/list_recursive_test.go`
### Implementation for User Story 1
- [X] T011 [US1] Implement listing pipeline combining traversal, filters, and summary in `internal/listing/service.go`
- [X] T012 [US1] Implement zero-result messaging helper in `internal/listing/summary.go`
- [X] T013 [US1] Add Cobra `list` command entry point in `cmd/list.go`
- [X] T014 [US1] Register `list` command and write help text in `cmd/root.go`
- [X] T015 [US1] Update quickstart usage section for `renamer list` workflow in `specs/001-list-command-filters/quickstart.md`
**Checkpoint**: User Story 1 delivers a safe, filter-aware listing command
---
## Phase 4: User Story 2 - Apply Global Filters Consistently (Priority: P2)
**Goal**: Ensure scope flags (`-r`, `-d`, `-e`) live on the root command and hydrate shared request builders
**Independent Test**: Execute `renamer list` twice—once via command parsing, once via helper—and confirm identical candidate counts
### Implementation for User Story 2
- [X] T016 [P] [US2] Promote scope flags to persistent flags on the root command in `cmd/root.go`
- [X] T017 [US2] Create shared flag extraction helper returning `ListingRequest` in `internal/listing/options.go`
- [X] T018 [US2] Refactor `cmd/list.go` to consume shared helper and root-level flags
- [X] T019 [P] [US2] Add integration test validating flag parity in `tests/integration/global_flag_parity_test.go`
- [X] T020 [US2] Expand CLI flag documentation for global usage patterns in `docs/cli-flags.md`
**Checkpoint**: User Story 2 guarantees consistent scope interpretation across commands
---
## Phase 5: User Story 3 - Review Listing Output Comfortably (Priority: P3)
**Goal**: Offer table and plain output modes for human review and scripting
**Independent Test**: Run `renamer list --format table` vs `--format plain` and ensure entries match across formats
### Implementation for User Story 3
- [X] T021 [P] [US3] Implement table renderer using `text/tabwriter` in `internal/output/table.go`
- [X] T022 [P] [US3] Implement plain renderer emitting newline-delimited paths in `internal/output/plain.go`
- [X] T023 [US3] Wire format selection into listing service dispatcher in `internal/listing/service.go`
- [X] T024 [US3] Extend contract tests to verify format output parity in `tests/contract/list_command_test.go`
- [X] T025 [US3] Update quickstart to demonstrate `--format` options in `specs/001-list-command-filters/quickstart.md`
**Checkpoint**: User Story 3 delivers ergonomic output formats for all audiences
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Final validation, documentation, and release notes
- [X] T026 Update agent guidance with list command summary in `AGENTS.md`
- [X] T027 Add release note entry describing new list command in `docs/CHANGELOG.md`
- [X] T028 Create smoke-test script exercising list + preview parity in `scripts/smoke-test-list.sh`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Complete before foundational utilities to ensure project scaffolding exists.
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories because shared utilities power every command.
- **User Story 1 (Phase 3)**: Depends on Foundational; delivers MVP list command.
- **User Story 2 (Phase 4)**: Depends on User Story 1 (reuses command structure) and Foundational.
- **User Story 3 (Phase 5)**: Depends on User Story 1 (listing pipeline) and Foundational (formatter interface).
- **Polish (Final Phase)**: Runs after desired user stories finish.
### User Story Dependencies
- **User Story 1 (P1)**: Requires Foundational utilities; no other story prerequisites.
- **User Story 2 (P2)**: Requires User Story 1 to expose list command behaviors before sharing flags.
- **User Story 3 (P3)**: Requires User Story 1 to deliver base listing plus Foundational formatter interface.
### Within Each User Story
- Tests marked [P] should be authored before or alongside implementation; ensure they fail prior to feature work.
- Service implementations depend on validated request structs and traversal utilities.
- CLI command wiring depends on service implementation and helper functions.
- Documentation tasks finalize once command behavior is stable.
### Parallel Opportunities
- Foundational tasks T005T007 operate on different packages and can proceed in parallel after T004 validates data structures.
- User Story 1 tests (T009, T010) can be developed in parallel before implementation tasks T011T014.
- User Story 3 renderers (T021, T022) can be implemented concurrently, converging at T023 for integration.
---
## Parallel Example: User Story 1
```bash
# In one terminal: author contract test
go test ./tests/contract -run TestListCommandFilters
# In another terminal: implement listing service
go test ./internal/listing -run TestService
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Setup + Foundational phases.
2. Implement User Story 1 tasks (T009T015) to deliver a working `renamer list`.
3. Validate with contract/integration tests and quickstart instructions.
### Incremental Delivery
1. Deliver MVP (User Story 1).
2. Add User Story 2 to guarantee global flag consistency.
3. Add User Story 3 to enhance output ergonomics.
4. Finalize polish tasks for documentation and smoke testing.
### Parallel Team Strategy
- Developer A handles Foundational tasks (T004T007) while Developer B updates documentation (T008).
- After Foundational checkpoint, Developer A implements listing service (T011T014), Developer B authors tests (T009T010).
- Once MVP ships, Developer A tackles global flag refactor (T016T018) while Developer B extends integration tests (T019) and docs (T020).
- For User Story 3, split renderer work (T021 vs T022) before merging at T023.

View File

@@ -0,0 +1,123 @@
package contract
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/output"
)
type captureFormatter struct {
entries []output.Entry
summary output.Summary
}
func (f *captureFormatter) Begin(io.Writer) error {
return nil
}
func (f *captureFormatter) WriteEntry(_ io.Writer, entry output.Entry) error {
f.entries = append(f.entries, entry)
return nil
}
func (f *captureFormatter) WriteSummary(_ io.Writer, summary output.Summary) error {
f.summary = summary
return nil
}
func TestListServiceFiltersByExtension(t *testing.T) {
tmp := t.TempDir()
mustWriteFile(t, filepath.Join(tmp, "keep.jpg"))
mustWriteFile(t, filepath.Join(tmp, "skip.txt"))
formatter := &captureFormatter{}
svc := listing.NewService()
req := &listing.ListingRequest{
WorkingDir: tmp,
Extensions: []string{".jpg"},
Format: listing.FormatPlain,
}
summary, err := svc.List(context.Background(), req, formatter, io.Discard)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(formatter.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(formatter.entries))
}
entry := formatter.entries[0]
if entry.Path != "keep.jpg" {
t.Fatalf("expected path keep.jpg, got %q", entry.Path)
}
if entry.MatchedExtension != ".jpg" {
t.Fatalf("expected matched extension .jpg, got %q", entry.MatchedExtension)
}
if summary.Files != 1 || summary.Total() != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
}
func TestListServiceFormatParity(t *testing.T) {
tmp := t.TempDir()
mustWriteFile(t, filepath.Join(tmp, "a.txt"))
svc := listing.NewService()
plainReq := &listing.ListingRequest{
WorkingDir: tmp,
Format: listing.FormatPlain,
}
plainSummary, err := svc.List(context.Background(), plainReq, output.NewPlainFormatter(), io.Discard)
if err != nil {
t.Fatalf("plain list error: %v", err)
}
tableReq := &listing.ListingRequest{
WorkingDir: tmp,
Format: listing.FormatTable,
}
var buf bytes.Buffer
tableSummary, err := svc.List(context.Background(), tableReq, output.NewTableFormatter(), &buf)
if err != nil {
t.Fatalf("table list error: %v", err)
}
if plainSummary.Total() != tableSummary.Total() {
t.Fatalf("summary total mismatch: plain %d vs table %d", plainSummary.Total(), tableSummary.Total())
}
header := buf.String()
if !strings.Contains(header, "PATH") || !strings.Contains(header, "TYPE") {
t.Fatalf("expected table header in output, got: %s", header)
}
}
func mustWriteFile(t *testing.T, path string) {
t.Helper()
if err := ensureParent(path); err != nil {
t.Fatalf("ensure parent: %v", err)
}
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}
func ensureParent(path string) error {
dir := filepath.Dir(path)
return os.MkdirAll(dir, 0o755)
}

17
tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,17 @@
# Test Fixtures
This directory stores sample filesystem layouts used by CLI integration and contract
tests. Keep fixtures small and descriptive so test output remains easy to reason
about.
## Naming Conventions
- Use one subdirectory per scenario (e.g., `basic-mixed-types`, `nested-hidden-files`).
- Include a `README.md` inside complex scenarios to explain intent when necessary.
- Avoid binary assets larger than a few kilobytes; prefer small text placeholders.
## Maintenance Tips
- Regenerate fixture trees with helper scripts instead of manual editing whenever possible.
- Document any platform-specific quirks (case sensitivity, symlinks) alongside the fixture.
- Update this file when adding new conventions or shared assumptions.

View File

@@ -0,0 +1,69 @@
package integration
import (
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/listing"
)
func TestScopeFlagsProduceConsistentRequests(t *testing.T) {
root := &cobra.Command{Use: "renamer"}
listing.RegisterScopeFlags(root.PersistentFlags())
listCmd := &cobra.Command{Use: "list"}
previewCmd := &cobra.Command{Use: "preview"}
root.AddCommand(listCmd, previewCmd)
tmp := t.TempDir()
mustSet := func(name, value string) {
if err := root.PersistentFlags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
mustSet("path", tmp)
mustSet("recursive", "true")
mustSet("include-dirs", "true")
mustSet("hidden", "true")
mustSet("extensions", ".jpg|.png")
reqList, err := listing.ScopeFromCmd(listCmd)
if err != nil {
t.Fatalf("list request: %v", err)
}
reqPreview, err := listing.ScopeFromCmd(previewCmd)
if err != nil {
t.Fatalf("preview request: %v", err)
}
if reqList.WorkingDir != reqPreview.WorkingDir {
t.Fatalf("working dir mismatch: %s vs %s", reqList.WorkingDir, reqPreview.WorkingDir)
}
if reqList.Recursive != reqPreview.Recursive {
t.Fatalf("recursive mismatch")
}
if reqList.IncludeDirectories != reqPreview.IncludeDirectories {
t.Fatalf("include-dirs mismatch")
}
if reqList.IncludeHidden != reqPreview.IncludeHidden {
t.Fatalf("hidden mismatch")
}
if len(reqList.Extensions) != len(reqPreview.Extensions) {
t.Fatalf("extension length mismatch: %d vs %d", len(reqList.Extensions), len(reqPreview.Extensions))
}
for i := range reqList.Extensions {
if reqList.Extensions[i] != reqPreview.Extensions[i] {
t.Fatalf("extension mismatch at %d: %s vs %s", i, reqList.Extensions[i], reqPreview.Extensions[i])
}
}
if filepath.Clean(reqList.WorkingDir) != reqList.WorkingDir {
t.Fatalf("expected cleaned working dir, got %s", reqList.WorkingDir)
}
}

View File

@@ -0,0 +1,103 @@
package integration
import (
"context"
"io"
"os"
"path/filepath"
"sort"
"testing"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/output"
)
type captureFormatter struct {
paths []string
}
func (f *captureFormatter) Begin(io.Writer) error { return nil }
func (f *captureFormatter) WriteEntry(_ io.Writer, entry output.Entry) error {
f.paths = append(f.paths, entry.Path)
return nil
}
func (f *captureFormatter) WriteSummary(io.Writer, output.Summary) error { return nil }
func TestListServiceRecursiveTraversal(t *testing.T) {
tmp := t.TempDir()
mustWriteFile(t, filepath.Join(tmp, "root.txt"))
mustWriteFile(t, filepath.Join(tmp, "nested", "child.txt"))
mustWriteDir(t, filepath.Join(tmp, "nested", "inner"))
svc := listing.NewService()
req := &listing.ListingRequest{
WorkingDir: tmp,
Recursive: true,
Format: listing.FormatPlain,
}
formatter := &captureFormatter{}
summary, err := svc.List(context.Background(), req, formatter, io.Discard)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
sort.Strings(formatter.paths)
expected := []string{"nested/child.txt", "root.txt"}
if len(formatter.paths) != len(expected) {
t.Fatalf("expected %d paths, got %d (%v)", len(expected), len(formatter.paths), formatter.paths)
}
for i, path := range expected {
if formatter.paths[i] != path {
t.Fatalf("expected path %q at index %d, got %q", path, i, formatter.paths[i])
}
}
if summary.Total() != len(expected) {
t.Fatalf("unexpected summary total: %d", summary.Total())
}
}
func TestListServiceDirectoryOnlyMode(t *testing.T) {
tmp := t.TempDir()
mustWriteFile(t, filepath.Join(tmp, "file.txt"))
mustWriteDir(t, filepath.Join(tmp, "folder"))
svc := listing.NewService()
req := &listing.ListingRequest{
WorkingDir: tmp,
IncludeDirectories: true,
Format: listing.FormatPlain,
}
formatter := &captureFormatter{}
_, err := svc.List(context.Background(), req, formatter, io.Discard)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(formatter.paths) != 1 || formatter.paths[0] != "folder" {
t.Fatalf("expected only directory entry, got %v", formatter.paths)
}
}
func mustWriteFile(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)
}
}
func mustWriteDir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir dir %s: %v", path, err)
}
}