├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── image.png ├── cmd └── main.go ├── go.mod ├── go.sum ├── internal └── utils │ └── utils.go ├── pkg └── printer │ ├── printer.go │ ├── printer_bench_test.go │ └── printer_test.go └── scripts └── build.sh /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: Run Tests 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: '1.23.5' 24 | 25 | - name: Run tests 26 | run: go test -v ./... 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: "1.23.5" 19 | 20 | - name: Run build script 21 | run: ./scripts/build.sh 22 | 23 | - name: Upload binaries 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: binaries 27 | path: bin/ 28 | 29 | - name: Create GitHub Release 30 | uses: softprops/action-gh-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 33 | with: 34 | files: | 35 | bin/pr-linux-amd64 36 | bin/pr-darwin-amd64 37 | bin/pr.exe 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2025] [Ahmedhossamdev] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PrintLayout 🌳 2 | 3 | ![PrintLayout Logo](assets/image.png) 4 | 5 | PrintLayout is a powerful, customizable command-line tool for printing directory structures in a tree format. Built with Go for simplicity and performance, it offers extensive features for visualizing and exploring file systems. 6 | 7 | ## 🚀 Installation 8 | 9 | ### Pre-Built Binaries 10 | 11 | Download the appropriate binary for your operating system from the [Releases page](https://github.com/Ahmedhossamdev/PrintLayout/releases). 12 | 13 | #### Linux/macOS 14 | ```bash 15 | # Download binary 16 | chmod +x pr-linux-amd64 17 | sudo mv pr-linux-amd64 /usr/local/bin/pr 18 | 19 | # Run 20 | pr -dir /path/to/your/folder 21 | ``` 22 | 23 | #### Windows 24 | 1. Download `pr-windows-amd64.exe` 25 | 2. Move to a directory in your `PATH` 26 | 27 | ## 📋 Command-Line Flags 28 | 29 | ### Basic Flags 30 | 31 | | Flag | Description | Default | Example | 32 | |----------------|-------------|---------|-------| 33 | | `--dir` | Specify directory to print | Current directory | `pr --dir /path/to/folder` | 34 | | `--ext` | Filter files by extension | All files | `pr --ext .go` | 35 | | `--output` | Save output to file | Terminal output | `pr --output output.txt` | 36 | | `--no-color` | Disable colored output | Colors enabled | `pr --no-color` | 37 | | `--hidden` | Include hidden files | Not included | `pr --hidden` | 38 | | `--max-depth` | Limit directory traversal depth | No limit | `pr --max-depth 2` | 39 | 40 | ### Sorting Flags 41 | 42 | | Flag | Description | Options | Default | Example | 43 | |------|-------------|---------|---------|---------| 44 | | `--sort-by` | Sort criteria | `name`, `size`, `time` | `name` | `pr --sort-by size` | 45 | | `--order` | Sorting order | `asc`, `desc` | `asc` | `pr --sort-by time --order desc` | 46 | 47 | ### Exclusion Flags 48 | 49 | | Flag | Description | Default | Example | 50 | |------|-------------|---------|---------| 51 | | `--exclude` | Exclude files/dirs matching pattern | No exclusions | `pr --exclude "*.log"` | 52 | 53 | ### Output Format Flags 54 | 55 | | Flag | Description | Options | Default | Example | 56 | |------|-------------|---------|---------|---------| 57 | | `--format` | Output format | `text`, `json`, `xml`, `yaml` | `text` | `pr --format json` | 58 | 59 | ### Color Customization Flags 60 | 61 | | Flag | Description | Options | Default | Example | 62 | |------|-------------|---------|---------|---------| 63 | | `--dir-color` | Directory color | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` | `blue` | `pr --dir-color green` | 64 | | `--file-color` | File color | Same as above | `green` | `pr --file-color yellow` | 65 | | `--exec-color` | Executable file color | Same as above | `red` | `pr --exec-color magenta` | 66 | 67 | ### Supported Colors (We will add more) 68 | 69 | printLayout supports the following colors for customization: 70 | 71 | - `black` 72 | - `red` 73 | - `green` 74 | - `yellow` 75 | - `blue` 76 | - `magenta` 77 | - `cyan` 78 | - `white` 79 | 80 | ## 🔍 Basic Examples 81 | 82 | ### 1. Print the current directory structure 83 | ```bash 84 | pr 85 | ``` 86 | ### 2. Specify a directory to explore 87 | ```bash 88 | pr --dir ./path/to/project 89 | ``` 90 | 91 | ### 3.Combined flags 92 | ```bash 93 | pr --dir /path/to/project --ext .ts --sort-by size --order desc --exclude "node_modules" --exclude "*.test" --dir-color magenta --file-color cyan --output project_structure.txt 94 | ``` 95 | 96 | ## 🛠 Development 97 | 98 | ### Run Project 99 | ```bash 100 | go run ./cmd/main.go 101 | ``` 102 | 103 | ### Run Tests 104 | ```bash 105 | go test -v ./... 106 | ``` 107 | 108 | ### Build Project 109 | ```bash 110 | go build -o printlayout ./cmd/main.go 111 | ``` 112 | 113 | ## 🤝 Contributing 114 | 115 | 1. Fork the repository 116 | 2. Create a feature branch 117 | 3. Commit changes 118 | 4. Push and submit a pull request 119 | 120 | ## 📜 License 121 | 122 | I don't know about License stuff, but this project made by me 123 | 124 | ## 🙏 Acknowledgments 125 | 126 | - Built with Go 127 | - Inspired by GNU Tree 128 | 129 | 130 | ## License 131 | 132 | This project is licensed under the [MIT License](LICENSE). 133 | -------------------------------------------------------------------------------- /assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ahmedhossamdev/PrintLayout/89c44990f5912962309d5637cc5357d4366ece49/assets/image.png -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "PrintLayout/pkg/printer" 5 | "flag" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | config := printer.Config{} 12 | 13 | // Define flags here: 14 | flag.StringVar(&config.DirPath, "dir", ".", "Directory path to print the structure of") 15 | flag.StringVar(&config.OutputPath, "output", "", "Output file path") 16 | flag.StringVar(&config.ExtFilter, "ext", "", "File extension filter (e.g., .go, .js)") 17 | flag.BoolVar(&config.NoColor, "no-color", false, "Disable colorized output") 18 | flag.StringVar(&config.OutputFormat, "format", "text", "Output format (text, json, xml, yaml)") 19 | flag.StringVar(&config.DirColor, "dir-color", "blue", "Color for directories (e.g., blue, green, red)") 20 | flag.StringVar(&config.FileColor, "file-color", "green", "Color for files (e.g., yellow, cyan, magenta)") 21 | flag.StringVar(&config.ExecColor, "exec-color", "red", "Color for executables (e.g., red, green, blue)") 22 | flag.StringVar(&config.SortBy, "sort-by", "name", "Sort by 'name', 'size', or 'time'") 23 | flag.StringVar(&config.Order, "order", "asc", "Sort order 'asc' or 'desc'") 24 | flag.BoolVar(&config.IncludeHidden, "hidden", false, "Include hidden files and directories") 25 | flag.IntVar(&config.MaxDepth, "max-depth", -1, "Maximum depth of directory traversal") 26 | 27 | // Add --exclude flag to specify exclusion patterns 28 | flag.Func("exclude", "Exclude files/directories matching the pattern (can be specified multiple times)", func(pattern string) error { 29 | config.ExcludePatterns = append(config.ExcludePatterns, pattern) 30 | return nil 31 | }) 32 | 33 | // Parse flags 34 | flag.Parse() 35 | 36 | // Validate max-depth 37 | if config.MaxDepth < -1 { 38 | fmt.Fprintln(os.Stderr, "Error: --max-depth must be -1 (unlimited) or a non-negative integer.") 39 | return 40 | } 41 | 42 | printer.PrintProjectStructure( 43 | config.DirPath, 44 | config.OutputPath, 45 | config.ExtFilter, 46 | !config.NoColor, 47 | config.OutputFormat, 48 | config.DirColor, 49 | config.FileColor, 50 | config.ExecColor, 51 | config.ExcludePatterns, 52 | config.SortBy, 53 | config.Order, 54 | config.IncludeHidden, 55 | config.MaxDepth, 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module PrintLayout 2 | 3 | go 1.23.5 4 | 5 | require ( 6 | github.com/fatih/color v1.18.0 7 | gopkg.in/yaml.v3 v3.0.1 8 | ) 9 | 10 | require ( 11 | github.com/mattn/go-colorable v0.1.13 // indirect 12 | github.com/mattn/go-isatty v0.0.20 // indirect 13 | golang.org/x/sys v0.25.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 2 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 3 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 4 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 5 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 6 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 7 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 8 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 9 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 10 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 11 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 15 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | -------------------------------------------------------------------------------- /pkg/printer/printer.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/fatih/color" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // Config holds the flag values 17 | type Config struct { 18 | DirPath string 19 | OutputPath string 20 | ExtFilter string 21 | NoColor bool 22 | OutputFormat string 23 | DirColor string 24 | FileColor string 25 | ExecColor string 26 | ExcludePatterns []string 27 | SortBy string // "name", "size", "time" 28 | Order string // "asc", "desc" 29 | IncludeHidden bool 30 | MaxDepth int 31 | } 32 | 33 | var colorMap = map[string]color.Attribute{ 34 | "black": color.FgBlack, 35 | "red": color.FgRed, 36 | "green": color.FgGreen, 37 | "yellow": color.FgYellow, 38 | "blue": color.FgBlue, 39 | "magenta": color.FgMagenta, 40 | "cyan": color.FgCyan, 41 | "white": color.FgWhite, 42 | } 43 | 44 | // getColorFunc returns a color function based on the color name 45 | func getColorFunc(colorName string) func(a ...interface{}) string { 46 | if attr, ok := colorMap[colorName]; ok { 47 | return color.New(attr).SprintFunc() 48 | } 49 | return fmt.Sprint // Default to no color if the color name is invalid 50 | } 51 | 52 | // HandleFlags processes the configuration and prints the directory structure. 53 | func HandleFlags(config Config) { 54 | PrintProjectStructure( 55 | config.DirPath, 56 | config.OutputPath, 57 | config.ExtFilter, 58 | !config.NoColor, 59 | config.OutputFormat, 60 | config.DirColor, 61 | config.FileColor, 62 | config.ExecColor, 63 | config.ExcludePatterns, 64 | config.SortBy, 65 | config.Order, 66 | config.IncludeHidden, 67 | config.MaxDepth) 68 | } 69 | 70 | // PrintProjectStructure prints the directory structure of the given root directory. 71 | func PrintProjectStructure( 72 | root string, 73 | outputFile string, 74 | extFilter string, 75 | useColor bool, 76 | format string, 77 | dirColorName string, 78 | fileColorName string, 79 | execColorName string, 80 | excludePatterns []string, 81 | sortBy string, 82 | order string, 83 | includeHidden bool, 84 | maxDepth int) { 85 | absRoot, err := filepath.Abs(root) 86 | if err != nil { 87 | fmt.Println("Error getting absolute path:", err) 88 | return 89 | } 90 | 91 | var output string 92 | if format == "text" { 93 | output = getTreeOutput(absRoot, extFilter, useColor, dirColorName, fileColorName, execColorName, excludePatterns, sortBy, order, includeHidden, maxDepth) 94 | } else { 95 | tree := buildTree(absRoot, extFilter, excludePatterns, sortBy, order, includeHidden, maxDepth, 0) 96 | switch format { 97 | case "json": 98 | data, _ := json.MarshalIndent(tree, "", " ") 99 | output = string(data) 100 | case "xml": 101 | data, _ := xml.MarshalIndent(tree, "", " ") 102 | output = string(data) 103 | case "yaml": 104 | data, _ := yaml.Marshal(tree) 105 | output = string(data) 106 | default: 107 | fmt.Println("Unsupported format:", format) 108 | return 109 | } 110 | 111 | fmt.Println(output) 112 | } 113 | 114 | if outputFile != "" { 115 | writeToFile(output, outputFile) 116 | } 117 | } 118 | 119 | func getTreeOutput(root string, extFilter string, useColor bool, dirColorName string, fileColorName string, execColorName string, excludePatterns []string, sortBy string, order string, includeHidden bool, maxDepth int) string { 120 | output := "" 121 | dirCount := 0 122 | fileCount := 0 123 | 124 | dirColorFunc := getColorFunc(dirColorName) 125 | fileColorFunc := getColorFunc(fileColorName) 126 | execColorFunc := getColorFunc(execColorName) 127 | 128 | var traverse func(string, string, int) error 129 | traverse = func(currentDir string, prefix string, depth int) error { 130 | if maxDepth != -1 && depth >= maxDepth { 131 | return nil 132 | } 133 | dir, err := os.Open(currentDir) 134 | if err != nil { 135 | return err 136 | } 137 | defer dir.Close() 138 | 139 | entries, err := dir.Readdir(-1) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | // Sort entries based on the specified criteria and order 145 | sortEntries(entries, sortBy, order) 146 | 147 | for i, entry := range entries { 148 | if !includeHidden && strings.HasPrefix(entry.Name(), ".") { 149 | continue 150 | } 151 | 152 | if isExcluded(entry.Name(), excludePatterns) { 153 | if entry.IsDir() { 154 | continue 155 | } 156 | continue 157 | } 158 | 159 | isLast := i == len(entries)-1 160 | 161 | if entry.IsDir() { 162 | dirCount++ 163 | if useColor { 164 | fmt.Printf("%s%s/\n", prefix+getTreePrefix(isLast), dirColorFunc(entry.Name())) 165 | } else { 166 | fmt.Printf("%s%s/\n", prefix+getTreePrefix(isLast), entry.Name()) 167 | } 168 | output += fmt.Sprintf("%s%s/\n", prefix+getTreePrefix(isLast), entry.Name()) 169 | 170 | err := traverse(filepath.Join(currentDir, entry.Name()), prefix+getIndent(isLast), depth+1) 171 | if err != nil { 172 | return err 173 | } 174 | } else { 175 | if extFilter == "" || strings.HasSuffix(entry.Name(), extFilter) { 176 | fileCount++ 177 | if useColor { 178 | info, err := os.Stat(filepath.Join(currentDir, entry.Name())) 179 | if err != nil { 180 | fmt.Printf("%s%s\n", prefix+getTreePrefix(isLast), entry.Name()) 181 | } else if isExecutable(info) { 182 | fmt.Printf("%s%s\n", prefix+getTreePrefix(isLast), execColorFunc(entry.Name())) 183 | } else { 184 | fmt.Printf("%s%s\n", prefix+getTreePrefix(isLast), fileColorFunc(entry.Name())) 185 | } 186 | } else { 187 | fmt.Printf("%s%s\n", prefix+getTreePrefix(isLast), entry.Name()) 188 | } 189 | output += fmt.Sprintf("%s%s/\n", prefix+getTreePrefix(isLast), entry.Name()) 190 | } 191 | } 192 | } 193 | 194 | return nil 195 | } 196 | 197 | fmt.Printf("%s/\n", filepath.Base(root)) 198 | output += fmt.Sprintf("%s/\n", filepath.Base(root)) 199 | 200 | err := traverse(root, "", 0) 201 | if err != nil { 202 | fmt.Println("Error traversing directory:", err) 203 | output += fmt.Sprintf("Error traversing directory: %v\n", err) 204 | } 205 | fmt.Printf("\n%d directories, %d files\n", dirCount, fileCount) 206 | output += fmt.Sprintf("\n%d directories, %d files\n", dirCount, fileCount) 207 | 208 | return output 209 | } 210 | 211 | // sortEntries sorts the entries based on the specified criteria and order 212 | func sortEntries(entries []os.FileInfo, sortBy string, order string) { 213 | switch sortBy { 214 | case "name": 215 | sort.Slice(entries, func(i, j int) bool { 216 | if order == "asc" { 217 | return entries[i].Name() < entries[j].Name() 218 | } 219 | return entries[i].Name() > entries[j].Name() 220 | }) 221 | case "size": 222 | sort.Slice(entries, func(i, j int) bool { 223 | if order == "asc" { 224 | return entries[i].Size() < entries[j].Size() 225 | } 226 | return entries[i].Size() > entries[j].Size() 227 | }) 228 | case "time": 229 | sort.Slice(entries, func(i, j int) bool { 230 | if order == "asc" { 231 | return entries[i].ModTime().Before(entries[j].ModTime()) 232 | } 233 | return entries[i].ModTime().After(entries[j].ModTime()) 234 | }) 235 | } 236 | } 237 | 238 | // writeToFile writes the output to the specified file 239 | func writeToFile(output, outputFile string) { 240 | absOutputFile, err := filepath.Abs(outputFile) 241 | if err != nil { 242 | fmt.Println("Error getting absolute path:", err) 243 | return 244 | } 245 | err = os.WriteFile(absOutputFile, []byte(output), 0644) 246 | if err != nil { 247 | fmt.Println("Error writing to file:", err) 248 | } 249 | } 250 | 251 | // Node represents a directory or file in the tree structure 252 | type Node struct { 253 | Name string `json:"name" xml:"name"` 254 | IsDir bool `json:"is_dir" xml:"is_dir"` 255 | Children []*Node `json:"children,omitempty" xml:"children,omitempty"` 256 | } 257 | 258 | // buildTree constructs a tree of Nodes from the directory structure 259 | func buildTree(currentDir string, extFilter string, excludePatterns []string, sortBy string, order string, includeHidden bool, maxDepth int, depth int) *Node { 260 | if maxDepth != -1 && depth >= maxDepth { 261 | return nil 262 | } 263 | dir, err := os.Open(currentDir) 264 | if err != nil { 265 | return nil 266 | } 267 | defer dir.Close() 268 | 269 | entries, err := dir.Readdir(-1) 270 | if err != nil { 271 | return nil 272 | } 273 | 274 | // Sort entries based on the specified criteria and order 275 | sortEntries(entries, sortBy, order) 276 | 277 | node := &Node{ 278 | Name: filepath.Base(currentDir), 279 | IsDir: true, 280 | } 281 | 282 | for _, entry := range entries { 283 | if !includeHidden && strings.HasPrefix(entry.Name(), ".") { 284 | continue 285 | } 286 | 287 | // Check if the entry matches any exclusion pattern 288 | if isExcluded(entry.Name(), excludePatterns) { 289 | continue 290 | } 291 | 292 | if entry.IsDir() { 293 | child := buildTree(filepath.Join(currentDir, entry.Name()), extFilter, excludePatterns, sortBy, order, includeHidden, maxDepth, depth+1) 294 | if child != nil { 295 | node.Children = append(node.Children, child) 296 | } 297 | } else if extFilter == "" || strings.HasSuffix(entry.Name(), extFilter) { 298 | node.Children = append(node.Children, &Node{ 299 | Name: entry.Name(), 300 | IsDir: false, 301 | }) 302 | } 303 | } 304 | 305 | return node 306 | } 307 | 308 | // isExecutable checks if a file is executable 309 | func isExecutable(entry os.FileInfo) bool { 310 | return entry.Mode()&0111 != 0 // Check executable bits 311 | } 312 | 313 | // getTreePrefix returns the tree prefix for the current entry. 314 | func getTreePrefix(isLast bool) string { 315 | if isLast { 316 | return "└── " 317 | } 318 | return "├── " 319 | } 320 | 321 | // getIndent returns the indentation for the current level. 322 | func getIndent(isLast bool) string { 323 | if isLast { 324 | return " " 325 | } 326 | return "│ " 327 | } 328 | 329 | // isExcluded checks if a file/directory matches any of the exclusion patterns 330 | func isExcluded(name string, excludePatterns []string) bool { 331 | for _, pattern := range excludePatterns { 332 | matched, err := filepath.Match(pattern, name) 333 | if err != nil { 334 | fmt.Printf("Invalid exclude pattern: %s\n", pattern) 335 | continue 336 | } 337 | if matched { 338 | return true 339 | } 340 | } 341 | return false 342 | } 343 | -------------------------------------------------------------------------------- /pkg/printer/printer_bench_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | // BenchmarkPrintProjectStructure benchmarks the PrintProjectStructure function. 11 | func BenchmarkPrintProjectStructure(b *testing.B) { 12 | // Create a temporary directory for benchmarking 13 | tmpDir := b.TempDir() 14 | createTestProjectStructure(b, tmpDir) 15 | 16 | // Change to the temporary directory 17 | oldDir, err := os.Getwd() 18 | if err != nil { 19 | b.Fatalf("Failed to get current working directory: %v", err) 20 | } 21 | defer os.Chdir(oldDir) // Restore the original working directory 22 | os.Chdir(tmpDir) 23 | 24 | // Run the benchmark 25 | b.ResetTimer() // Reset the timer to exclude setup time 26 | for i := 0; i < b.N; i++ { 27 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, -1) 28 | } 29 | } 30 | 31 | // BenchmarkPrintProjectStructure_JSON benchmarks the JSON output format. 32 | func BenchmarkPrintProjectStructure_JSON(b *testing.B) { 33 | // Create a temporary directory for benchmarking 34 | tmpDir := b.TempDir() 35 | createTestProjectStructure(b, tmpDir) 36 | 37 | oldDir, err := os.Getwd() 38 | if err != nil { 39 | b.Fatalf("Failed to get current working directory: %v", err) 40 | } 41 | defer os.Chdir(oldDir) // Restore the original working directory 42 | os.Chdir(tmpDir) 43 | 44 | b.ResetTimer() 45 | for i := 0; i < b.N; i++ { 46 | PrintProjectStructure(".", "", "", false, "json", "blue", "green", "red", []string{}, "name", "asc", false, -1) 47 | } 48 | } 49 | 50 | // BenchmarkPrintProjectStructure_LargeDirectory benchmarks performance with a large directory. 51 | func BenchmarkPrintProjectStructure_LargeDirectory(b *testing.B) { 52 | tmpDir := b.TempDir() 53 | createLargeTestProjectStructure(b, tmpDir) 54 | 55 | oldDir, err := os.Getwd() 56 | if err != nil { 57 | b.Fatalf("Failed to get current working directory: %v", err) 58 | } 59 | defer os.Chdir(oldDir) // Restore the original working directory 60 | os.Chdir(tmpDir) 61 | 62 | b.ResetTimer() // Reset the timer to exclude setup time 63 | for i := 0; i < b.N; i++ { 64 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, -1) 65 | } 66 | } 67 | 68 | // createLargeTestProjectStructure creates a large directory structure for benchmarking. 69 | func createLargeTestProjectStructure(b *testing.B, root string) { 70 | // Create 100 directories, each containing 10 files 71 | for i := 0; i < 100; i++ { 72 | dir := filepath.Join(root, "dir"+strconv.Itoa(i)) 73 | err := os.MkdirAll(dir, 0755) 74 | if err != nil { 75 | b.Fatalf("Failed to create directory: %v", err) 76 | } 77 | 78 | for j := 0; j < 10; j++ { 79 | file := filepath.Join(dir, "file"+strconv.Itoa(j)) 80 | f, err := os.Create(file) 81 | if err != nil { 82 | b.Fatalf("Failed to create file: %v", err) 83 | } 84 | f.Close() 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/printer/printer_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // TestPrintProjectStructure tests the PrintProjectStructure function. 17 | func TestPrintProjectStructure(t *testing.T) { 18 | tmpDir := t.TempDir() 19 | 20 | createTestProjectStructure(t, tmpDir) 21 | 22 | oldDir, err := os.Getwd() 23 | if err != nil { 24 | t.Fatalf("Failed to get current working directory: %v", err) 25 | } 26 | defer os.Chdir(oldDir) // Restore the original working directory 27 | os.Chdir(tmpDir) 28 | 29 | // Test text output 30 | t.Run("TextOutput", func(t *testing.T) { 31 | output := captureOutput(func() { 32 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, -1) 33 | }) 34 | 35 | rootName := filepath.Base(tmpDir) 36 | 37 | expected := rootName + "/\n" + 38 | "├── cmd/\n" + 39 | "│ └── main.go\n" + 40 | "├── go.mod\n" + 41 | "├── internal/\n" + 42 | "│ └── utils/\n" + 43 | "│ └── utils.go\n" + 44 | "└── pkg/\n" + 45 | " └── printer/\n" + 46 | " ├── printer.go\n" + 47 | " └── printer_test.go\n" + 48 | "\n5 directories, 5 files\n" 49 | output = strings.TrimSpace(output) 50 | expected = strings.TrimSpace(expected) 51 | 52 | if output != expected { 53 | t.Errorf("Unexpected output:\nGot:\n%s\nExpected:\n%s", output, expected) 54 | } 55 | }) 56 | 57 | // Test JSON output 58 | t.Run("JSONOutput", func(t *testing.T) { 59 | output := captureOutput(func() { 60 | PrintProjectStructure(".", "", "", false, "json", "blue", "green", "red", []string{}, "name", "asc", false, -1) 61 | }) 62 | 63 | // Verify that the output is valid JSON 64 | var result interface{} 65 | if err := json.Unmarshal([]byte(output), &result); err != nil { 66 | t.Errorf("Output is not valid JSON: %v", err) 67 | } 68 | }) 69 | 70 | // Test XML output 71 | t.Run("XMLOutput", func(t *testing.T) { 72 | output := captureOutput(func() { 73 | PrintProjectStructure(".", "", "", false, "xml", "blue", "green", "red", []string{}, "name", "asc", false, -1) 74 | }) 75 | 76 | // Verify that the output is valid XML 77 | var result interface{} 78 | if err := xml.Unmarshal([]byte(output), &result); err != nil { 79 | t.Errorf("Output is not valid XML: %v", err) 80 | } 81 | }) 82 | 83 | // Test YAML output 84 | t.Run("YAMLOutput", func(t *testing.T) { 85 | output := captureOutput(func() { 86 | PrintProjectStructure(".", "", "", false, "yaml", "blue", "green", "red", []string{}, "name", "asc", false, -1) 87 | }) 88 | 89 | // Verify that the output is valid YAML 90 | var result interface{} 91 | if err := yaml.Unmarshal([]byte(output), &result); err != nil { 92 | t.Errorf("Output is not valid YAML: %v", err) 93 | } 94 | }) 95 | 96 | // Test exclusion patterns 97 | t.Run("ExclusionPatterns", func(t *testing.T) { 98 | output := captureOutput(func() { 99 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{"*.go"}, "name", "asc", false, -1) 100 | }) 101 | 102 | rootName := filepath.Base(tmpDir) 103 | 104 | expected := rootName + "/\n" + 105 | "├── cmd/\n" + 106 | "├── go.mod\n" + 107 | "├── internal/\n" + 108 | "│ └── utils/\n" + 109 | "└── pkg/\n" + 110 | " └── printer/\n" + 111 | "\n5 directories, 1 files\n" 112 | output = strings.TrimSpace(output) 113 | expected = strings.TrimSpace(expected) 114 | 115 | if output != expected { 116 | t.Errorf("Unexpected output:\nGot:\n%s\nExpected:\n%s", output, expected) 117 | } 118 | }) 119 | 120 | // Test sorting by name (ascending) 121 | t.Run("SortByNameAsc", func(t *testing.T) { 122 | output := captureOutput(func() { 123 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, -1) 124 | }) 125 | 126 | // Verify that the output is sorted by name in ascending order 127 | // You can add specific checks based on your expected output 128 | t.Log(output) 129 | }) 130 | 131 | // Test sorting by name (descending) 132 | t.Run("SortByNameDesc", func(t *testing.T) { 133 | output := captureOutput(func() { 134 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "desc", false, -1) 135 | }) 136 | 137 | // Verify that the output is sorted by name in descending order 138 | // You can add specific checks based on your expected output 139 | t.Log(output) 140 | }) 141 | 142 | // Test sorting by size (ascending) 143 | t.Run("SortBySizeAsc", func(t *testing.T) { 144 | output := captureOutput(func() { 145 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "size", "asc", false, -1) 146 | }) 147 | 148 | // Verify that the output is sorted by size in ascending order 149 | // You can add specific checks based on your expected output 150 | t.Log(output) 151 | }) 152 | 153 | // Test sorting by size (descending) 154 | t.Run("SortBySizeDesc", func(t *testing.T) { 155 | output := captureOutput(func() { 156 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "size", "desc", false, -1) 157 | }) 158 | 159 | // Verify that the output is sorted by size in descending order 160 | // You can add specific checks based on your expected output 161 | t.Log(output) 162 | }) 163 | 164 | // Test sorting by time (ascending) 165 | t.Run("SortByTimeAsc", func(t *testing.T) { 166 | output := captureOutput(func() { 167 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "time", "asc", false, -1) 168 | }) 169 | 170 | // Verify that the output is sorted by time in ascending order 171 | // You can add specific checks based on your expected output 172 | t.Log(output) 173 | }) 174 | 175 | // Test sorting by time (descending) 176 | t.Run("SortByTimeDesc", func(t *testing.T) { 177 | output := captureOutput(func() { 178 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "time", "desc", false, -1) 179 | }) 180 | 181 | // Verify that the output is sorted by time in descending order 182 | // You can add specific checks based on your expected output 183 | t.Log(output) 184 | }) 185 | 186 | // Test including hidden files 187 | t.Run("IncludeHidden", func(t *testing.T) { 188 | output := captureOutput(func() { 189 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", true, -1) 190 | }) 191 | 192 | // Verify that the output includes the hidden files 193 | // You can add specific checks based on your expected output 194 | t.Log(output) 195 | }) 196 | 197 | // Test maximum depth 0 (should only print the root directory) 198 | t.Run("MaxDepthZero", func(t *testing.T) { 199 | output := captureOutput(func() { 200 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, 0) 201 | }) 202 | 203 | rootName := filepath.Base(tmpDir) 204 | expected := rootName + "/\n" + 205 | "\n0 directories, 0 files\n" 206 | output = strings.TrimSpace(output) 207 | expected = strings.TrimSpace(expected) 208 | 209 | if output != expected { 210 | t.Errorf("Unexpected output:\nGot:\n%s\nExpected:\n%s", output, expected) 211 | } 212 | }) 213 | 214 | // Test maximum depth 2 215 | t.Run("MaxDepthTwo", func(t *testing.T) { 216 | output := captureOutput(func() { 217 | PrintProjectStructure(".", "", "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, 2) 218 | }) 219 | 220 | rootName := filepath.Base(tmpDir) 221 | 222 | expected := rootName + "/\n" + 223 | "├── cmd/\n" + 224 | "│ └── main.go\n" + 225 | "├── go.mod\n" + 226 | "├── internal/\n" + 227 | "│ └── utils/\n" + 228 | "└── pkg/\n" + 229 | " └── printer/\n" + 230 | "\n5 directories, 2 files\n" 231 | output = strings.TrimSpace(output) 232 | expected = strings.TrimSpace(expected) 233 | 234 | if output != expected { 235 | t.Errorf("Unexpected output:\nGot:\n%s\nExpected:\n%s", output, expected) 236 | } 237 | }) 238 | 239 | // Test text output file 240 | t.Run("TextOutputFile", func(t *testing.T) { 241 | filePath := filepath.Join(tmpDir, "output.txt") 242 | 243 | PrintProjectStructure(".", filePath, "", false, "text", "blue", "green", "red", []string{}, "name", "asc", false, -1) 244 | 245 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 246 | t.Fatalf("Output file was not created: %v", err) 247 | } 248 | 249 | fileContent, err := os.ReadFile(filePath) 250 | if err != nil { 251 | t.Fatalf("Failed to read output file: %v", err) 252 | } 253 | 254 | if len(fileContent) == 0 { 255 | t.Errorf("Output file is empty") 256 | } 257 | }) 258 | 259 | // Test JSON output file 260 | t.Run("JSONOutputFile", func(t *testing.T) { 261 | filePath := filepath.Join(tmpDir, "output.json") 262 | 263 | PrintProjectStructure(".", filePath, "", false, "json", "blue", "green", "red", []string{}, "name", "asc", false, -1) 264 | 265 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 266 | t.Fatalf("Output file was not created: %v", err) 267 | } 268 | 269 | fileContent, err := os.ReadFile(filePath) 270 | if err != nil { 271 | t.Fatalf("Failed to read output file: %v", err) 272 | } 273 | 274 | // Verify that the output is valid JSON 275 | var result interface{} 276 | if err := json.Unmarshal(fileContent, &result); err != nil { 277 | t.Errorf("Output is not valid JSON: %v", err) 278 | } 279 | }) 280 | 281 | // Test invalid format output file 282 | t.Run("InvalidOutputFile", func(t *testing.T) { 283 | filePath := filepath.Join(tmpDir, "output.txt") 284 | 285 | PrintProjectStructure(".", filePath, "", false, "xyz", "blue", "green", "red", []string{}, "name", "asc", false, -1) 286 | 287 | if _, err := os.Stat(filePath); os.IsExist(err) { 288 | t.Errorf("Format is not valid: %v", err) 289 | } 290 | }) 291 | } 292 | 293 | // createTestProjectStructure creates a sample project structure for testing. 294 | func createTestProjectStructure(tb testing.TB, root string) { 295 | // Define the directories to create 296 | dirs := []string{ 297 | "cmd", 298 | "internal/utils", 299 | "pkg/printer", 300 | } 301 | 302 | files := []string{ 303 | "cmd/main.go", 304 | "internal/utils/utils.go", 305 | "pkg/printer/printer.go", 306 | "pkg/printer/printer_test.go", 307 | "go.mod", 308 | } 309 | 310 | for _, dir := range dirs { 311 | err := os.MkdirAll(filepath.Join(root, dir), 0755) 312 | if err != nil { 313 | tb.Fatalf("Failed to create directory: %v", err) 314 | } 315 | } 316 | 317 | for _, file := range files { 318 | f, err := os.Create(filepath.Join(root, file)) 319 | if err != nil { 320 | tb.Fatalf("Failed to create file: %v", err) 321 | } 322 | f.Close() 323 | 324 | if strings.HasSuffix(file, ".go") { 325 | modTime := time.Now().Add(-time.Hour * 24) // Set to 24 hours ago 326 | os.Chtimes(filepath.Join(root, file), modTime, modTime) 327 | } 328 | } 329 | } 330 | 331 | // captureOutput captures the output printed to stdout. 332 | func captureOutput(f func()) string { 333 | rescueStdout := os.Stdout 334 | r, w, _ := os.Pipe() 335 | os.Stdout = w 336 | 337 | f() 338 | 339 | w.Close() 340 | out, _ := io.ReadAll(r) 341 | os.Stdout = rescueStdout 342 | 343 | return string(out) 344 | } 345 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | OUTPUT_DIR="bin" 4 | mkdir -p $OUTPUT_DIR 5 | 6 | PLATFORMS=( 7 | "windows/amd64" 8 | "linux/amd64" 9 | "darwin/amd64" 10 | ) 11 | 12 | for PLATFORM in "${PLATFORMS[@]}"; do 13 | OS=$(echo $PLATFORM | cut -d'/' -f1) 14 | ARCH=$(echo $PLATFORM | cut -d'/' -f2) 15 | 16 | if [ "$OS" = "windows" ]; then 17 | OUTPUT_NAME="pr.exe" 18 | else 19 | OUTPUT_NAME="pr-$OS-$ARCH" 20 | fi 21 | 22 | echo "Building for $OS/$ARCH..." 23 | env GOOS=$OS GOARCH=$ARCH go build -o "$OUTPUT_DIR/$OUTPUT_NAME" ./cmd/main.go 24 | done 25 | 26 | echo "Binaries built successfully in the $OUTPUT_DIR directory." 27 | --------------------------------------------------------------------------------