diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65016f4 --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..77bb3e0 --- /dev/null +++ b/AGENTS.md @@ -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 + + + diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..e04ad1b --- /dev/null +++ b/cmd/list.go @@ -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()) +} diff --git a/cmd/root.go b/cmd/root.go index 3470088..de8778c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,5 @@ /* Copyright © 2025 NAME HERE - */ 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()) } - - diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..de54ce2 --- /dev/null +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/cli-flags.md b/docs/cli-flags.md new file mode 100644 index 0000000..84f1f83 --- /dev/null +++ b/docs/cli-flags.md @@ -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` diff --git a/internal/filters/extensions.go b/internal/filters/extensions.go new file mode 100644 index 0000000..8df9e66 --- /dev/null +++ b/internal/filters/extensions.go @@ -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 +} diff --git a/internal/listing/options.go b/internal/listing/options.go new file mode 100644 index 0000000..7e02035 --- /dev/null +++ b/internal/listing/options.go @@ -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) +} diff --git a/internal/listing/service.go b/internal/listing/service.go new file mode 100644 index 0000000..81f6d51 --- /dev/null +++ b/internal/listing/service.go @@ -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, + } +} diff --git a/internal/listing/summary.go b/internal/listing/summary.go new file mode 100644 index 0000000..af26572 --- /dev/null +++ b/internal/listing/summary.go @@ -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." +} diff --git a/internal/listing/types.go b/internal/listing/types.go new file mode 100644 index 0000000..cf9c3ad --- /dev/null +++ b/internal/listing/types.go @@ -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 +} diff --git a/internal/output/factory.go b/internal/output/factory.go new file mode 100644 index 0000000..1fd4a9c --- /dev/null +++ b/internal/output/factory.go @@ -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) + } +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go new file mode 100644 index 0000000..921e1a4 --- /dev/null +++ b/internal/output/formatter.go @@ -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) +} diff --git a/internal/output/plain.go b/internal/output/plain.go new file mode 100644 index 0000000..002ba76 --- /dev/null +++ b/internal/output/plain.go @@ -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 +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 0000000..47e2ab0 --- /dev/null +++ b/internal/output/table.go @@ -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 +} diff --git a/internal/traversal/walker.go b/internal/traversal/walker.go new file mode 100644 index 0000000..c2061b5 --- /dev/null +++ b/internal/traversal/walker.go @@ -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 +} diff --git a/scripts/smoke-test-list.sh b/scripts/smoke-test-list.sh new file mode 100755 index 0000000..89c91fc --- /dev/null +++ b/scripts/smoke-test-list.sh @@ -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" diff --git a/specs/001-list-command-filters/checklists/requirements.md b/specs/001-list-command-filters/checklists/requirements.md new file mode 100644 index 0000000..4134d74 --- /dev/null +++ b/specs/001-list-command-filters/checklists/requirements.md @@ -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` diff --git a/specs/001-list-command-filters/contracts/list-command.md b/specs/001-list-command-filters/contracts/list-command.md new file mode 100644 index 0000000..903ca0c --- /dev/null +++ b/specs/001-list-command-filters/contracts/list-command.md @@ -0,0 +1,59 @@ +# CLI Contract: `renamer list` + +## Command Synopsis + +```bash +renamer list [--path ] [-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. diff --git a/specs/001-list-command-filters/data-model.md b/specs/001-list-command-filters/data-model.md new file mode 100644 index 0000000..26229fd --- /dev/null +++ b/specs/001-list-command-filters/data-model.md @@ -0,0 +1,40 @@ +# Data Model: Cobra List Command with Global Filters + +## Entities + +### ListingRequest +- **Description**: Captures the user’s 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. diff --git a/specs/001-list-command-filters/plan.md b/specs/001-list-command-filters/plan.md new file mode 100644 index 0000000..6ee4839 --- /dev/null +++ b/specs/001-list-command-filters/plan.md @@ -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 + + + +**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) + + +```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. diff --git a/specs/001-list-command-filters/quickstart.md b/specs/001-list-command-filters/quickstart.md new file mode 100644 index 0000000..207e61a --- /dev/null +++ b/specs/001-list-command-filters/quickstart.md @@ -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. diff --git a/specs/001-list-command-filters/research.md b/specs/001-list-command-filters/research.md new file mode 100644 index 0000000..dc43bb8 --- /dev/null +++ b/specs/001-list-command-filters/research.md @@ -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. diff --git a/specs/001-list-command-filters/spec.md b/specs/001-list-command-filters/spec.md new file mode 100644 index 0000000..1439dd2 --- /dev/null +++ b/specs/001-list-command-filters/spec.md @@ -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. diff --git a/specs/001-list-command-filters/tasks.md b/specs/001-list-command-filters/tasks.md new file mode 100644 index 0000000..5b0863d --- /dev/null +++ b/specs/001-list-command-filters/tasks.md @@ -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 story’s 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 T005–T007 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 T011–T014. +- 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 (T009–T015) 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 (T004–T007) while Developer B updates documentation (T008). +- After Foundational checkpoint, Developer A implements listing service (T011–T014), Developer B authors tests (T009–T010). +- Once MVP ships, Developer A tackles global flag refactor (T016–T018) while Developer B extends integration tests (T019) and docs (T020). +- For User Story 3, split renderer work (T021 vs T022) before merging at T023. diff --git a/tests/contract/list_command_test.go b/tests/contract/list_command_test.go new file mode 100644 index 0000000..d6da1a9 --- /dev/null +++ b/tests/contract/list_command_test.go @@ -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) +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..3570d5a --- /dev/null +++ b/tests/fixtures/README.md @@ -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. diff --git a/tests/integration/global_flag_parity_test.go b/tests/integration/global_flag_parity_test.go new file mode 100644 index 0000000..789bbce --- /dev/null +++ b/tests/integration/global_flag_parity_test.go @@ -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) + } +} diff --git a/tests/integration/list_recursive_test.go b/tests/integration/list_recursive_test.go new file mode 100644 index 0000000..00b430f --- /dev/null +++ b/tests/integration/list_recursive_test.go @@ -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) + } +}