diff --git a/.gitignore b/.gitignore index c47b60d..190b479 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ go.work # Sandworm specific .sandworm .sandworm*.txt + +# VSCode +.vscode/ diff --git a/README.md b/README.md index 59f6543..fd4cc62 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ whole project as context. ### Usage -``` +```text Project file concatenator Usage: @@ -63,6 +63,7 @@ Usage: Available Commands: completion Generate the autocompletion script for the specified shell + config Manage project configuration generate Generate concatenated file only help Help about any command purge Remove all files from Claude project @@ -70,11 +71,13 @@ Available Commands: setup Configure Claude project Flags: - -h, --help help for sandworm - --ignore string Ignore file (default: .gitignore) - -k, --keep Keep the generated file after pushing - -o, --output string Output file - -v, --version version for sandworm + -h, --help help for sandworm + --ignore string Ignore file (default: .gitignore) + -k, --keep Keep the generated file after pushing + -L, --follow-symlinks Follow symbolic links when traversing directories + -n, --line-numbers Show line numbers in output (can also be set via config) + -o, --output string Output file + -v, --version version for sandworm Use "sandworm [command] --help" for more information about a command. ``` @@ -106,6 +109,24 @@ Keep the uploaded file for inspection: sandworm push -k ``` +Follow symbolic links when traversing directories: + +```bash +sandworm -L +``` + +Show line numbers in output: + +```bash +sandworm -n +``` + +Configure project to always follow symbolic links: + +```bash +sandworm config set processor.follow_symlinks true +``` + Generate only, don't push to Claude Project: ```bash @@ -121,13 +142,32 @@ Sandworm maintains configuration in two places: The first is used for global configuration, like your Claude session key. The latter is project-specific, and stores your Claude organization ID, project ID, -and the document ID for the file that holds your condensed project. +the document ID for the file that holds your condensed project, and other +project-specific settings. + +#### Project Configuration Options + +- `processor.follow_symlinks`: Set to `true` to always follow symbolic links when traversing directories + +```bash +# Enable following symlinks for this project +sandworm config set processor.follow_symlinks true + +# Disable following symlinks for this project +sandworm config set processor.follow_symlinks false + +# Check current setting +sandworm config get processor.follow_symlinks + +# List all available configuration options +sandworm config list +``` ### Output Format The generated file will have the structure: -``` +```text PROJECT STRUCTURE: ================ diff --git a/cmd/sandworm/main.go b/cmd/sandworm/main.go index 987afa4..25f4224 100644 --- a/cmd/sandworm/main.go +++ b/cmd/sandworm/main.go @@ -8,7 +8,8 @@ import ( ) func main() { - if err := cli.NewRootCmd().Execute(); err != nil { + opts := &cli.Options{} + if err := cli.NewRootCmd(opts).Execute(); err != nil { os.Exit(1) } } diff --git a/go.mod b/go.mod index 8c54892..58375f7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.5 require ( github.com/go-git/go-git/v5 v5.16.2 + github.com/karrick/godirwalk v1.17.0 github.com/spf13/cobra v1.9.1 ) diff --git a/go.sum b/go.sum index ef01efb..76a9d9b 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,21 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= +github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= +github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -17,6 +24,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= @@ -25,9 +34,18 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5bd456f..c2c568b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -13,9 +13,10 @@ var ( ) // NewRootCmd creates the root command with all subcommands -func NewRootCmd() *cobra.Command { - opts := &Options{} - +func NewRootCmd(opts *Options) *cobra.Command { + if opts == nil { + opts = &Options{} + } rootCmd := &cobra.Command{ Use: "sandworm [directory]", Short: "Project file concatenator", @@ -33,6 +34,9 @@ func NewRootCmd() *cobra.Command { rootCmd.PersistentFlags().StringVarP(&opts.IgnoreFile, "ignore", "i", "", "Ignore file (default: .gitignore)") rootCmd.PersistentFlags().BoolVarP(&opts.KeepFile, "keep", "k", false, "Keep the generated file after pushing") + var followSymlinks bool + rootCmd.PersistentFlags().BoolVarP(&followSymlinks, "follow-symlinks", "L", false, "Follow symbolic links when traversing directories") + var showLineNumbers bool rootCmd.PersistentFlags().BoolVarP(&showLineNumbers, "line-numbers", "n", false, "Show line numbers in output (overrides config setting)") rootCmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { @@ -41,6 +45,9 @@ func NewRootCmd() *cobra.Command { if cmd.Flags().Changed("line-numbers") { opts.ShowLineNumbers = &showLineNumbers } + if cmd.Flags().Changed("follow-symlinks") { + opts.FollowSymlinks = &followSymlinks + } return nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..b12a7ac --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGenerateCmd_Flags(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "sandworm-cli-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + opts := &Options{} + rootCmd := NewRootCmd(opts) + rootCmd.SetArgs([]string{"generate", tmpDir, "--line-numbers", "--follow-symlinks"}) + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Command failed: %v", err) + } + + if opts.ShowLineNumbers == nil || !*opts.ShowLineNumbers { + t.Errorf("Expected ShowLineNumbers to be true, got %v", opts.ShowLineNumbers) + } + if opts.FollowSymlinks == nil || !*opts.FollowSymlinks { + t.Errorf("Expected FollowSymlinks to be true, got %v", opts.FollowSymlinks) + } + if opts.Directory != tmpDir { + t.Errorf("Expected Directory to be '%v', got %v", tmpDir, opts.Directory) + } + + // Clean up generated output file + os.Remove("sandworm.txt") +} + +func TestGenerateCmd_FlagsOverrideConfig(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "sandworm-cli-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + opts := &Options{} + rootCmd := NewRootCmd(opts) + rootCmd.SetArgs([]string{"generate", tmpDir, "--line-numbers=false", "--follow-symlinks=false"}) + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Command failed: %v", err) + } + + if opts.ShowLineNumbers == nil || *opts.ShowLineNumbers { + t.Errorf("Expected ShowLineNumbers to be false, got %v", opts.ShowLineNumbers) + } + if opts.FollowSymlinks == nil || *opts.FollowSymlinks { + t.Errorf("Expected FollowSymlinks to be false, got %v", opts.FollowSymlinks) + } + + // Clean up generated output file + os.Remove("sandworm.txt") +} + +func TestGenerateCmd_OutputIgnoreKeepFlags(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "sandworm-cli-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + outputFile := "myoutput.txt" + ignoreFile := "myignore.txt" + ignorePath := filepath.Join(tmpDir, ignoreFile) + if err := os.WriteFile(ignorePath, []byte("*.tmp\n"), 0o644); err != nil { + t.Fatalf("Failed to create dummy ignore file: %v", err) + } + + opts := &Options{} + rootCmd := NewRootCmd(opts) + rootCmd.SetArgs([]string{"generate", tmpDir, "--output", outputFile, "--ignore", ignorePath, "--keep"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Command failed: %v", err) + } + // Clean up generated output file + os.Remove(outputFile) + + if opts.OutputFile != outputFile { + t.Errorf("Expected OutputFile to be '%v', got '%v'", outputFile, opts.OutputFile) + } + if opts.IgnoreFile != ignorePath { + t.Errorf("Expected IgnoreFile to be '%v', got '%v'", ignorePath, opts.IgnoreFile) + } + if !opts.KeepFile { + t.Errorf("Expected KeepFile to be true, got %v", opts.KeepFile) + } +} diff --git a/internal/cli/cmd_generate.go b/internal/cli/cmd_generate.go index 4687cdf..0a044b1 100644 --- a/internal/cli/cmd_generate.go +++ b/internal/cli/cmd_generate.go @@ -39,25 +39,44 @@ func runGenerate(opts *Options) (int64, error) { opts.Directory = "." } - printLineNumbers := false - if opts.ShowLineNumbers != nil { - printLineNumbers = *opts.ShowLineNumbers - } else { - // If line-numbers flag wasn't explicitly set, load & check the project's settings. - cfg, err := config.New(opts.Directory) - if err != nil { - return 0, fmt.Errorf("unable to load config: %w", err) - } + // Resolve all processor options from flags/config/defaults + cfg, err := config.New(opts.Directory) + if err != nil { + return 0, fmt.Errorf("unable to load config: %w", err) + } + if opts.ShowLineNumbers == nil { if cfg.Has("processor.print_line_numbers") { value := cfg.Get("processor.print_line_numbers") - if value == "true" { - printLineNumbers = true - } + b := value == "true" + opts.ShowLineNumbers = &b + } + } + + if opts.FollowSymlinks == nil { + if cfg.Has("processor.follow_symlinks") { + value := cfg.Get("processor.follow_symlinks") + b := value == "true" + opts.FollowSymlinks = &b } } - p, err := processor.New(opts.Directory, opts.OutputFile, opts.IgnoreFile, printLineNumbers) + printLineNumbers := false + if opts.ShowLineNumbers != nil { + printLineNumbers = *opts.ShowLineNumbers + } + followSymlinks := false + if opts.FollowSymlinks != nil { + followSymlinks = *opts.FollowSymlinks + } + + // Resolve processor options from CLI options + procOpts := processor.SandwormOptions{ + PrintLineNumbers: printLineNumbers, + FollowSymlinks: followSymlinks, + } + + p, err := processor.NewWithOptions(opts.Directory, opts.OutputFile, opts.IgnoreFile, procOpts) if err != nil { return 0, fmt.Errorf("unable to create processor: %w", err) } diff --git a/internal/cli/options.go b/internal/cli/options.go index 96d486f..fbb9350 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -7,6 +7,7 @@ import ( ) // Options holds the command-line options shared across commands +// If a value is nil, it will be resolved from config; otherwise, the CLI value overrides config. type Options struct { // OutputFile specifies the path where the concatenated project file will be written. // If empty, defaults are applied based on the command context. @@ -30,6 +31,10 @@ type Options struct { // ShowLineNumbers determines whether to show line numbers in the output. // If nil, the value from config will be used. If set, it overrides the config. ShowLineNumbers *bool + + // FollowSymlinks determines whether to follow symbolic links when traversing directories. + // If nil, the value from config will be used. If set, it overrides the config. + FollowSymlinks *bool } // SetDefaults sets default values for options based on the command context diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 850fc30..9e500fb 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -14,6 +14,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/holonoms/sandworm/internal/filetree" + "github.com/karrick/godirwalk" ) const separator = "================================================================================" @@ -83,24 +84,42 @@ go.sum *.bin ` +// FileInfo represents a file to be included in the output +type FileInfo struct { + RelativePath string // The path to display in the output (relative to root) + AbsolutePath string // The actual path to read the file from (resolved symlinks) +} + // Processor handles the concatenation of project files into a single document +// All options are set via SandwormOptions, which is constructed from CLI flags and config. +// Symlink and line number logic are fully configurable via CLI flags or config file. type Processor struct { - rootDir string - outputFile string - ignoreFile string - matcher gitignore.Matcher + rootDir string + outputFile string + ignoreFile string + matcher gitignore.Matcher + followSymlinks bool printLineNumbers bool } -// New creates a new Processor instance -func New(rootDir, outputFile, ignoreFile string, printLineNumbers bool) (*Processor, error) { +// SandwormOptions holds the options for the Processor +// Add new options here as needed; always map from cli.Options. +// If an option is nil in cli.Options, the value is resolved from config. +type SandwormOptions struct { + PrintLineNumbers bool + FollowSymlinks bool +} + +// NewWithOptions creates a new Processor instance with all options +func NewWithOptions(rootDir, outputFile, ignoreFile string, opts SandwormOptions) (*Processor, error) { rootDir = filepath.Clean(rootDir) p := &Processor{ rootDir: rootDir, outputFile: outputFile, ignoreFile: ignoreFile, - printLineNumbers: printLineNumbers, + printLineNumbers: opts.PrintLineNumbers, + followSymlinks: opts.FollowSymlinks, } // Initialize patterns with EXTRA_IGNORES @@ -164,6 +183,11 @@ func New(rootDir, outputFile, ignoreFile string, printLineNumbers bool) (*Proces return p, nil } +// SetFollowSymlinks enables or disables following symbolic links during traversal +func (p *Processor) SetFollowSymlinks(follow bool) { + p.followSymlinks = follow +} + // Process concatenates all project files into a single document func (p *Processor) Process() (int64, error) { files, err := p.collectFiles() @@ -203,35 +227,58 @@ func (p *Processor) Process() (int64, error) { } // collectFiles walks the directory tree and returns a list of files to include -func (p *Processor) collectFiles() ([]string, error) { - var files []string +func (p *Processor) collectFiles() ([]FileInfo, error) { + var files []FileInfo - err := filepath.Walk(p.rootDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + callback := func(osPathname string, de *godirwalk.Dirent) error { + // Skip directories (but not symbolic links to files) + if de.IsDir() && !de.IsSymlink() { + return nil } - // Skip directories - if info.IsDir() { - return nil + // For symbolic links, check what they point to + if de.IsSymlink() { + isDir, err := de.IsDirOrSymlinkToDir() + if err != nil { + // Can't determine target, skip it + return nil + } + if isDir { + // It's a symbolic link to a directory, skip it from the file list + // (godirwalk will still traverse into it if FollowSymbolicLinks is true) + return nil + } } + // Get relative path and normalize separators for cross-platform consistency - relPath, err := filepath.Rel(p.rootDir, path) + relPath, err := filepath.Rel(p.rootDir, osPathname) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Normalize to forward slashes for consistent processing - // This ensures gitignore patterns work and output is uniform across platforms normalizedPath := filepath.ToSlash(relPath) - // Check gitignore patterns using normalized path if p.matcher != nil && p.matcher.Match(strings.Split(normalizedPath, "/"), false) { return nil } - // Store normalized path for consistent cross-platform output - files = append(files, normalizedPath) + // Store both the display path and actual path + files = append(files, FileInfo{ + RelativePath: normalizedPath, + AbsolutePath: osPathname, + }) return nil + } + + errorCallback := func(_ string, _ error) godirwalk.ErrorAction { + // Skip files/directories that can't be accessed + return godirwalk.SkipNode + } + + err := godirwalk.Walk(p.rootDir, &godirwalk.Options{ + FollowSymbolicLinks: p.followSymlinks, + Callback: callback, + ErrorCallback: errorCallback, }) if err != nil { @@ -242,13 +289,19 @@ func (p *Processor) collectFiles() ([]string, error) { } // writeStructure writes the directory tree structure to the output. -func (p *Processor) writeStructure(w *bufio.Writer, files []string) error { +func (p *Processor) writeStructure(w *bufio.Writer, files []FileInfo) error { _, err := w.WriteString("PROJECT STRUCTURE:\n==================\n\n") if err != nil { return err } - tree := filetree.Build(files, "") + // Extract just the relative paths for the tree structure + paths := make([]string, len(files)) + for i, file := range files { + paths[i] = file.RelativePath + } + + tree := filetree.Build(paths, "") _, err = w.WriteString(tree) if err != nil { return err @@ -259,17 +312,17 @@ func (p *Processor) writeStructure(w *bufio.Writer, files []string) error { } // writeContents writes the contents of each file to the output. -func (p *Processor) writeContents(w *bufio.Writer, files []string) error { +func (p *Processor) writeContents(w *bufio.Writer, files []FileInfo) error { for _, file := range files { - // Write file header - if _, err := fmt.Fprintf(w, "%s\nFILE: %s\n%s\n", separator, file, separator); err != nil { + // Write file header using the relative path for display + if _, err := fmt.Fprintf(w, "%s\nFILE: %s\n%s\n", separator, file.RelativePath, separator); err != nil { return err } - // Read file contents - content, err := os.ReadFile(filepath.Join(p.rootDir, file)) + // Read file contents from the actual path (handles symlinks automatically) + content, err := os.ReadFile(file.AbsolutePath) if err != nil { - return fmt.Errorf("failed to read file %s: %w", file, err) + return fmt.Errorf("failed to read file %s: %w", file.RelativePath, err) } // Write file contents with optional line numbers @@ -287,6 +340,7 @@ func (p *Processor) writeContents(w *bufio.Writer, files []string) error { return err } } + return nil } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 4143fe2..12337d4 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -34,7 +34,7 @@ func TestProcessor(t *testing.T) { createFile("dir1/file2.txt", "Content 2") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "", false) + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -81,7 +81,7 @@ func TestProcessor(t *testing.T) { createFile("keep.txt", "Should be kept") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, ".gitignore"), false) + p, err := NewWithOptions(tmpDir, outputFile, filepath.Join(tmpDir, ".gitignore"), SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -116,7 +116,7 @@ func TestProcessor(t *testing.T) { createFile("text.txt", "Regular text file") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "", false) + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -151,7 +151,7 @@ func TestProcessor(t *testing.T) { createFile("keep.txt", "Should be kept") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, "custom.ignore"), false) + p, err := NewWithOptions(tmpDir, outputFile, filepath.Join(tmpDir, "custom.ignore"), SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -208,7 +208,7 @@ func TestProcessor(t *testing.T) { // Process the files outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "", false) + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -240,6 +240,136 @@ func TestProcessor(t *testing.T) { } }) + t.Run("symbolic link following", func(t *testing.T) { + // Create test files + createFile("file1.txt", "Content 1") + createFile("dir1/file2.txt", "Content 2") + createFile("target/file3.txt", "Content 3") + + // Create symbolic link to directory (if supported by OS) + symlinkDir := filepath.Join(tmpDir, "symlink_dir") + targetDir := filepath.Join(tmpDir, "target") + err := os.Symlink(targetDir, symlinkDir) + if err != nil { + t.Skipf("Symbolic links not supported on this system: %v", err) + } + + outputFile := filepath.Join(tmpDir, "output_symlinks.txt") + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: false, FollowSymlinks: false}) + if err != nil { + t.Fatalf("Failed to create processor: %v", err) + } + // Test without following symlinks + files, err := p.collectFiles() + if err != nil { + t.Fatalf("collectFiles failed: %v", err) + } + + // Should not include symlinked content + foundSymlinkedContent := false + for _, file := range files { + if strings.Contains(file.RelativePath, "symlink_dir/file3.txt") { + foundSymlinkedContent = true + break + } + } + if foundSymlinkedContent { + t.Error("Expected symlinked directory content to be excluded when not following symlinks") + } + + // Test with following symlinks + p.followSymlinks = true + files, err = p.collectFiles() + if err != nil { + t.Fatalf("collectFiles with symlinks failed: %v", err) + } + + // Should include symlinked content + foundSymlinkedContent = false + for _, file := range files { + if strings.Contains(file.RelativePath, "symlink_dir/file3.txt") { + foundSymlinkedContent = true + break + } + } + if !foundSymlinkedContent { + t.Error("Expected symlinked directory content to be included when following symlinks") + } + + // Process the files + size, err := p.Process() + if err != nil { + t.Fatalf("Process with symlinks failed: %v", err) + } + + if size == 0 { + t.Error("Expected non-zero file size with symlinks") + } + }) + + t.Run("symbolic link cycle prevention", func(t *testing.T) { + // Create directories that will have circular symlinks + dir1 := filepath.Join(tmpDir, "dir1") + dir2 := filepath.Join(tmpDir, "dir2") + err := os.MkdirAll(dir1, 0o755) + if err != nil { + t.Fatalf("Failed to create dir1: %v", err) + } + err = os.MkdirAll(dir2, 0o755) + if err != nil { + t.Fatalf("Failed to create dir2: %v", err) + } + + // Create a file in each directory + createFile("dir1/file1.txt", "Dir 1 content") + createFile("dir2/file2.txt", "Dir 2 content") + + // Create circular symlinks + symlink1 := filepath.Join(dir1, "link_to_dir2") + symlink2 := filepath.Join(dir2, "link_to_dir1") + + err = os.Symlink(dir2, symlink1) + if err != nil { + t.Skipf("Symbolic links not supported on this system: %v", err) + } + err = os.Symlink(dir1, symlink2) + if err != nil { + t.Skipf("Symbolic links not supported on this system: %v", err) + } + + outputFile := filepath.Join(tmpDir, "output_cycles.txt") + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: false, FollowSymlinks: true}) + if err != nil { + t.Fatalf("Failed to create processor: %v", err) + } + + p.SetFollowSymlinks(true) + + // This should not hang or crash due to infinite recursion + files, err := p.collectFiles() + if err != nil { + t.Fatalf("collectFiles with cycles failed: %v", err) + } + // Should include files from both directories but handle cycles gracefully + foundFile1 := false + foundFile2 := false + for _, file := range files { + if strings.Contains(file.RelativePath, "dir1/file1.txt") { + foundFile1 = true + } + if strings.Contains(file.RelativePath, "dir2/file2.txt") { + foundFile2 = true + } + } + + if !foundFile1 { + t.Error("Expected to find file1.txt from dir1 directory") + } + if !foundFile2 { + t.Error("Expected to find file2.txt from dir2 directory") + } + }) + t.Run("line numbers", func(t *testing.T) { // Reset temp directory os.RemoveAll(tmpDir) @@ -250,7 +380,7 @@ func TestProcessor(t *testing.T) { createFile("dir1/file2.txt", "First line\nSecond line") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "", true) // Enable line numbers + p, err := NewWithOptions(tmpDir, outputFile, "", SandwormOptions{PrintLineNumbers: true, FollowSymlinks: false}) if err != nil { t.Fatalf("Failed to create processor: %v", err) }