feat: add list command with global filters
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
31
AGENTS.md
Normal 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
58
cmd/list.go
Normal 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())
|
||||
}
|
||||
32
cmd/root.go
32
cmd/root.go
@@ -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
6
docs/CHANGELOG.md
Normal 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
29
docs/cli-flags.md
Normal 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`
|
||||
66
internal/filters/extensions.go
Normal file
66
internal/filters/extensions.go
Normal 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
102
internal/listing/options.go
Normal 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
170
internal/listing/service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
20
internal/listing/summary.go
Normal file
20
internal/listing/summary.go
Normal 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
97
internal/listing/types.go
Normal 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
|
||||
}
|
||||
21
internal/output/factory.go
Normal file
21
internal/output/factory.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
internal/output/formatter.go
Normal file
52
internal/output/formatter.go
Normal 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
28
internal/output/plain.go
Normal 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
48
internal/output/table.go
Normal 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
|
||||
}
|
||||
143
internal/traversal/walker.go
Normal file
143
internal/traversal/walker.go
Normal 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
28
scripts/smoke-test-list.sh
Executable 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"
|
||||
34
specs/001-list-command-filters/checklists/requirements.md
Normal file
34
specs/001-list-command-filters/checklists/requirements.md
Normal 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`
|
||||
59
specs/001-list-command-filters/contracts/list-command.md
Normal file
59
specs/001-list-command-filters/contracts/list-command.md
Normal 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.
|
||||
40
specs/001-list-command-filters/data-model.md
Normal file
40
specs/001-list-command-filters/data-model.md
Normal file
@@ -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.
|
||||
99
specs/001-list-command-filters/plan.md
Normal file
99
specs/001-list-command-filters/plan.md
Normal 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.
|
||||
60
specs/001-list-command-filters/quickstart.md
Normal file
60
specs/001-list-command-filters/quickstart.md
Normal 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.
|
||||
33
specs/001-list-command-filters/research.md
Normal file
33
specs/001-list-command-filters/research.md
Normal 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.
|
||||
151
specs/001-list-command-filters/spec.md
Normal file
151
specs/001-list-command-filters/spec.md
Normal 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.
|
||||
175
specs/001-list-command-filters/tasks.md
Normal file
175
specs/001-list-command-filters/tasks.md
Normal 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 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.
|
||||
123
tests/contract/list_command_test.go
Normal file
123
tests/contract/list_command_test.go
Normal 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
17
tests/fixtures/README.md
vendored
Normal 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.
|
||||
69
tests/integration/global_flag_parity_test.go
Normal file
69
tests/integration/global_flag_parity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
103
tests/integration/list_recursive_test.go
Normal file
103
tests/integration/list_recursive_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user