├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cmd └── mkproj │ └── main.go ├── doc ├── screenshot_1.png └── screenshot_2.png ├── go.mod ├── go.sum └── internal ├── editor ├── editor.go └── editor_test.go ├── project ├── project.go └── project_test.go └── tree ├── tree.go └── tree_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: stable 22 | # More assembly might be required: Docker logins, GPG, etc. 23 | # It all depends on your needs. 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | # either 'goreleaser' (default) or 'goreleaser-pro' 28 | distribution: goreleaser 29 | # 'latest', 'nightly', or a semver 30 | version: "~> v2" 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.bin 7 | *.out 8 | 9 | # Go specific 10 | *.log 11 | *.test 12 | *.prof 13 | *.pprof 14 | *.cov 15 | *.coverage 16 | *.coverprofile 17 | 18 | # Output of `go build` and `go test` 19 | /bin/ 20 | /pkg/ 21 | /out/ 22 | /dist/ 23 | 24 | # Dependency directories (vendor/) 25 | vendor/ 26 | 27 | # Go workspace file 28 | go.work 29 | 30 | # Generated files 31 | gen/ 32 | *.gen.go 33 | 34 | # IDE/editor specific (Optional) 35 | .vscode/ 36 | .idea/ 37 | *.swp 38 | *.swo 39 | 40 | # MacOS specific 41 | .DS_Store 42 | 43 | # Homebrew 44 | /usr/local/Cellar/ 45 | 46 | # mkproj binary 47 | /binaries 48 | 49 | dist/ 50 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | main: ./cmd/mkproj/main.go 22 | binary: mkproj 23 | goos: 24 | - linux 25 | - windows 26 | - darwin 27 | 28 | archives: 29 | - format: tar.gz 30 | # this name template makes the OS and Arch compatible with the results of `uname`. 31 | name_template: >- 32 | {{ .ProjectName }}_ 33 | {{- title .Os }}_ 34 | {{- if eq .Arch "amd64" }}x86_64 35 | {{- else if eq .Arch "386" }}i386 36 | {{- else }}{{ .Arch }}{{ end }} 37 | {{- if .Arm }}v{{ .Arm }}{{ end }} 38 | # use zip for windows archives 39 | format_overrides: 40 | - goos: windows 41 | format: zip 42 | files: 43 | - README.md 44 | - LICENSE 45 | 46 | changelog: 47 | sort: asc 48 | filters: 49 | exclude: 50 | - "^docs:" 51 | - "^test:" 52 | - ".github:" 53 | - 'README' 54 | - Merge pull request 55 | - Merge branch 56 | 57 | brews: 58 | - name: mkproj 59 | homepage: "https://github.com/jobehi/mkproj" 60 | description: "A simple CLI tool to create project directories." 61 | 62 | commit_author: 63 | name: jobehi 64 | email: youssef.elbehi@gmail.com 65 | commit_msg_template: "Brew release {{ .Tag }}" 66 | directory: Formula 67 | license: "MIT" 68 | repository: 69 | owner: jobehi 70 | name: homebrew-mkproj 71 | token: "{{ .Env.GITHUB_TOKEN }}" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.0] - 2024-10-13 8 | 9 | ### Added 10 | - Initial release of `mkproj`, an interactive CLI tool to set up project trees. 11 | - Interactive mode for manually building project structures with editing keys. 12 | - Ability to create project structures from text files or piped input. 13 | - Command to display the current directory tree (`tree` command) with options to include or exclude hidden files. 14 | - Support for specifying root directories and input files using command-line flags (`--root`, `--file`). 15 | - Basic usage examples and documentation. 16 | - Initial version of contributing guidelines for community involvement. 17 | 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to mkproj 2 | 3 | Thank you for your interest in contributing to `mkproj`! Contributions are a vital part of the open-source community and help make the tool better for everyone. Below are some guidelines to help you get started. 4 | 5 | ## How Can You Contribute? 6 | 7 | There are many ways to contribute to `mkproj`: 8 | 9 | - **Report Bugs**: If you find any issues while using `mkproj`, please open an issue with a detailed description of the bug and how to reproduce it. 10 | - **Suggest New Features**: Have an idea for a new feature? Feel free to suggest it by opening an issue. We love to hear new ideas that can improve `mkproj`! 11 | - **Fix Issues**: Browse the list of [open issues](https://github.com/jobehi/mkproj/issues) and see if there is anything you'd like to work on. 12 | - **Improve Documentation**: Documentation is an important part of any software. Feel free to submit changes to improve the README, usage examples, or other related documentation. 13 | 14 | ## Getting Started 15 | 16 | 1. **Fork the Repository**: Start by forking the `mkproj` repository on GitHub to your own account. 17 | 2. **Clone the Repository**: Clone your forked repository to your local machine. 18 | ```sh 19 | git clone https://github.com/your-username/mkproj.git 20 | ``` 21 | 3. **Create a Branch**: Create a feature or bugfix branch to make your changes. 22 | ```sh 23 | git checkout -b feature/new-feature 24 | ``` 25 | 4. **Make Your Changes**: Implement your changes or fixes. Make sure to follow the code style and best practices already in the codebase. 26 | 5. **Test Your Changes**: Ensure that your changes do not break existing functionality by thoroughly testing. 27 | 6. **Commit and Push**: Commit your changes with a meaningful commit message. 28 | ```sh 29 | git commit -m "Add feature: description of feature" 30 | git push origin feature/new-feature 31 | ``` 32 | 7. **Open a Pull Request**: Go to the GitHub repository and open a pull request with a detailed description of your changes. 33 | 34 | ## Code Style Guidelines 35 | 36 | - Follow the existing code style to keep the codebase consistent. 37 | - Use meaningful variable and function names. 38 | - Document your code where necessary, especially for complex logic. 39 | 40 | ## Reporting Issues 41 | 42 | When reporting an issue, please provide as much detail as possible, including: 43 | 44 | - The version of `mkproj` you are using. 45 | - Steps to reproduce the issue. 46 | - Expected behavior vs. actual behavior. 47 | - Any error messages or logs. 48 | 49 | ## Community Guidelines 50 | 51 | Please be respectful and considerate to other members of the community. We value inclusivity and constructive feedback, and we want everyone to feel welcome when contributing to `mkproj`. 52 | 53 | ## Contact 54 | 55 | If you have any questions or need further clarification, feel free to reach out by opening an issue or joining discussions on the GitHub repository. 56 | 57 | Thank you for helping make `mkproj` better! 58 | 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Youssef El Behi & contributors 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 | # mkproj: An Interactive CLI Tool to Setup Your Project Trees 🌳 2 | 3 | `mkproj` is a simple and effective command-line interface (CLI) tool designed to help developers quickly create, visualize, and manage their project structures. With an intuitive interactive mode and flexible commands, `mkproj` provides a fast way to organize your project files and folders. 4 | 5 | ## Features 6 | 7 | 8 | Interactive Mode | Interactive Mode output 9 | :-------------------------:|:-------------------------: 10 | ![](doc/screenshot_1.png) | ![](doc/screenshot_2.png) 11 | 12 | 13 | - **Interactive Mode**: Easily create and modify your project structure interactively using standard editing keys. Build your ideal layout with minimal friction. 14 | - **Text-Based Structure Creation**: Create a project structure from a text file or piped input. 15 | - **Tree View**: Display the current directory structure, with the option to include or exclude hidden files. 16 | 17 | ## Installation 18 | 19 | `mkproj` is available on Homebrew for macOS and can be installed from source for other platforms. 20 | 21 | ### Install mkproj on macOS using Homebrew 22 | 23 | To install `mkproj` on macOS using Homebrew, run: 24 | 25 | ```sh 26 | brew tap jobehi/mkproj 27 | 28 | brew install mkproj 29 | ``` 30 | 31 | For other platforms, you can download and build the binary from the [source code repository](#setup-mkproj-globally-from-source-code). 32 | 33 | 34 | ## Usage 35 | 36 | ### Command Overview 37 | 38 | ```sh 39 | mkproj [command] [options] 40 | ``` 41 | 42 | - **create**: Create a project structure from a text file or piped input. 43 | - **tree**: Display the current directory structure. 44 | - **help**: Display this help message. 45 | 46 | ### Options 47 | 48 | - `--root=`: Specify the root directory for your project structure (default is the current directory). 49 | - `--file=`: Provide a file that contains the project structure (used with `create`). 50 | 51 | ### Interactive Mode 52 | 53 | By default, `mkproj` starts in interactive mode, where you can manually build your project structure: 54 | 55 | - Use standard editing keys to modify the structure. 56 | - Press **F2** to save and create the structure. 57 | - Press **Esc** to exit without saving. 58 | 59 | ### Examples 60 | 61 | - **Start in Interactive Mode**: 62 | ```sh 63 | mkproj 64 | ``` 65 | This launches `mkproj` in an interactive environment where you can create and edit your project structure on the fly. 66 | 67 | - **Create a Project Structure from a Text File**: 68 | ```sh 69 | mkproj create --file=structure.txt --root=./new_project 70 | ``` 71 | This command reads the project structure from `structure.txt` and creates it in the specified root directory. 72 | 73 | - **Display the Current Directory Tree**: 74 | ```sh 75 | mkproj tree --root=./my_project 76 | ``` 77 | Displays the directory structure of `./my_project` without showing hidden files. 78 | 79 | - **Display the Directory Tree Including Hidden Files**: 80 | ```sh 81 | mkproj tree --root=./my_project --all 82 | ``` 83 | Displays the directory tree of `./my_project`, including hidden files. 84 | 85 | ## Project Structure Input Format 86 | 87 | The input structure can be created interactively or provided as a text file. You can use dashes (`-`) for depth and suffix `:file` to mark an entry as a file (for files with no extensions). For example: 88 | 89 | ```txt 90 | project-root 91 | - src 92 | -- main.go 93 | -- utils 94 | --- helper.go 95 | - README.md 96 | - .gitignore:file 97 | ``` 98 | 99 | ### Setup mkproj Globally From Source Code 100 | 101 | To set up `mkproj` globally on macOS from the source code, follow these steps: 102 | 103 | 1. **Clone the Repository**: 104 | ```sh 105 | git clone https://github.com/jobehi/mkproj.git 106 | cd mkproj 107 | ``` 108 | 109 | 2. **Build the Binary**: 110 | Build the `mkproj` binary using Go: 111 | ```sh 112 | go build -o mkproj 113 | ``` 114 | 115 | 3. **Move the Binary to `/usr/local/bin`**: 116 | Move the compiled binary to `/usr/local/bin` to make it accessible globally: 117 | ```sh 118 | sudo mv mkproj /usr/local/bin/ 119 | sudo chmod +x /usr/local/bin/mkproj 120 | ``` 121 | 122 | 4. **Verify Installation**: 123 | You can now use `mkproj` from anywhere in your terminal: 124 | ```sh 125 | mkproj help 126 | ``` 127 | 128 | 129 | ## Contributing 130 | 131 | Contributions are welcome! Please open an issue or a pull request on the [GitHub repository](https://github.com/jobehi/mkproj) to report bugs or suggest improvements. 132 | Refer to the [Contributing Guidelines](CONTRIBUTING.md) for more information. 133 | 134 | ## License 135 | 136 | `mkproj` is released under the [MIT License](LICENSE). 137 | -------------------------------------------------------------------------------- /cmd/mkproj/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/jobehi/mkproj/internal/editor" 12 | "github.com/jobehi/mkproj/internal/project" 13 | "github.com/jobehi/mkproj/internal/tree" 14 | "github.com/rivo/tview" 15 | ) 16 | 17 | var rootDir string 18 | var inputFile string 19 | 20 | func main() { 21 | // Parse flags but not immediately 22 | rootFlag := flag.String("root", ".", "Root directory for project structure") 23 | fileFlag := flag.String("file", "", "Input file with project structure") 24 | flag.Usage = printHelp 25 | 26 | // Parse the command (e.g., "tree", "create", etc.) 27 | if len(os.Args) < 2 { 28 | runInteractiveMode(*rootFlag) 29 | return 30 | } 31 | 32 | command := os.Args[1] 33 | args := os.Args[2:] 34 | flag.CommandLine.Parse(args) // Parse the flags after the command 35 | 36 | rootDir = *rootFlag 37 | inputFile = *fileFlag 38 | 39 | // Handle help command 40 | if command == "help" { 41 | printHelp() 42 | return 43 | } 44 | 45 | // Handle tree command 46 | if command == "tree" { 47 | treeFlags := flag.NewFlagSet("tree", flag.ExitOnError) 48 | allFlag := treeFlags.Bool("all", false, "Include hidden files and directories") 49 | allFlagShort := treeFlags.Bool("a", false, "Include hidden files and directories (shorthand)") 50 | rootFlag := treeFlags.String("root", ".", "Root directory for project structure") 51 | treeFlags.Parse(args) 52 | 53 | rootDir = *rootFlag 54 | showHidden := *allFlag || *allFlagShort 55 | 56 | tree.DisplayDirectoryTree(rootDir, showHidden) 57 | return 58 | } 59 | 60 | // Handle create command 61 | if command == "create" { 62 | if inputFile != "" { 63 | // Read from input file 64 | file, err := os.Open(inputFile) 65 | if err != nil { 66 | fmt.Printf("Error reading input file %s: %v\n", inputFile, err) 67 | return 68 | } 69 | defer file.Close() 70 | 71 | scanner := bufio.NewScanner(file) 72 | var structure []string 73 | for scanner.Scan() { 74 | structure = append(structure, scanner.Text()) 75 | } 76 | project.BuildProjectStructure(structure, rootDir) 77 | return 78 | } 79 | 80 | // Handle piped input 81 | if isPipedInput() { 82 | reader := bufio.NewReader(os.Stdin) 83 | var structure []string 84 | for { 85 | line, err := reader.ReadString('\n') 86 | if err != nil { 87 | break 88 | } 89 | structure = append(structure, strings.TrimSpace(line)) 90 | } 91 | project.BuildProjectStructure(structure, rootDir) 92 | return 93 | } 94 | } 95 | 96 | // If no command matches, run the interactive mode by default 97 | runInteractiveMode(rootDir) 98 | } 99 | 100 | // isPipedInput detects if there is piped input from stdin 101 | func isPipedInput() bool { 102 | info, err := os.Stdin.Stat() 103 | if err != nil { 104 | return false 105 | } 106 | return info.Mode()&os.ModeCharDevice == 0 107 | } 108 | 109 | // runInteractiveMode launches the interactive mode for project structure building 110 | func runInteractiveMode(rootDir string) { 111 | app := tview.NewApplication() 112 | 113 | // Status bar for feedback messages 114 | statusBar := tview.NewTextView().SetDynamicColors(true).SetText("Ready").SetTextAlign(tview.AlignLeft) 115 | 116 | // Instructions 117 | instructions := tview.NewTextView(). 118 | SetText(fmt.Sprintf("Root Directory: %s\n", rootDir) + 119 | "Welcome to mkproj\n" + 120 | "Enter your project structure below.\n" + 121 | "Use tabs for depth and filename:file for files without extensions.\n" + 122 | "Press F2 to save and create the structure, Esc to quit."). 123 | SetDynamicColors(true) 124 | 125 | // Create the editor 126 | ed := editor.NewEditor(statusBar) 127 | 128 | // Capture inputs like F2 (save) and Esc (exit) 129 | app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 130 | switch event.Key() { 131 | case tcell.KeyF2: 132 | if err := ed.ValidateStructure(); err != nil { 133 | statusBar.SetText(fmt.Sprintf("Validation error: %v", err)) 134 | return nil 135 | } 136 | app.Stop() 137 | project.BuildProjectStructure(ed.Lines, rootDir) 138 | return nil 139 | case tcell.KeyEsc: 140 | app.Stop() 141 | return nil 142 | } 143 | return event 144 | }) 145 | 146 | // Layout: instructions at top, editor in the middle, and status bar at the bottom 147 | layout := tview.NewFlex().SetDirection(tview.FlexRow). 148 | AddItem(instructions, 8, 1, false). 149 | AddItem(ed, 0, 1, true). 150 | AddItem(statusBar, 1, 1, false) 151 | 152 | // Run the interactive mode application 153 | if err := app.SetRoot(layout, true).Run(); err != nil { 154 | fmt.Fprintf(os.Stderr, "Error running application: %v\n", err) 155 | os.Exit(1) 156 | } 157 | } 158 | 159 | func printHelp() { 160 | fmt.Println(`mkproj: A Simple CLI Tool to Grow Your Project Trees 🌳 161 | 162 | Usage: 163 | mkproj [command] [options] 164 | 165 | Commands: 166 | create Create a project structure from a text file or piped input 167 | tree Display the current directory structure 168 | help Display this help message 169 | 170 | Options: 171 | --root= Specify the root directory for your project structure (default is current directory) 172 | --file= Provide a file that contains the project structure (used with 'create') 173 | 174 | Interactive Mode: 175 | By default, mkproj starts in interactive mode where you can manually build your project structure. 176 | Use standard editing keys to modify the structure. 177 | Press F2 to save and create the structure. 178 | Press Esc to exit without saving. 179 | 180 | Examples: 181 | # Start mkproj in interactive mode 182 | mkproj 183 | 184 | # Create a project structure from a text file 185 | mkproj create --file=structure.txt --root=./new_project 186 | 187 | # Display the current directory tree without hidden files 188 | mkproj tree --root=./my_project 189 | 190 | # Display the current directory tree including hidden files 191 | mkproj tree --root=./my_project --all`) 192 | } 193 | -------------------------------------------------------------------------------- /doc/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobehi/mkproj/1c983e7f539e2d1d95420eb801f88807298c1406/doc/screenshot_1.png -------------------------------------------------------------------------------- /doc/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobehi/mkproj/1c983e7f539e2d1d95420eb801f88807298c1406/doc/screenshot_2.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jobehi/mkproj 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/gdamore/tcell/v2 v2.7.4 7 | github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654 8 | ) 9 | 10 | require ( 11 | github.com/gdamore/encoding v1.0.0 // indirect 12 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 13 | github.com/mattn/go-runewidth v0.0.15 // indirect 14 | github.com/rivo/uniseg v0.4.7 // indirect 15 | golang.org/x/sys v0.17.0 // indirect 16 | golang.org/x/term v0.17.0 // indirect 17 | golang.org/x/text v0.14.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= 4 | github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 5 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 6 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 7 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 8 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 9 | github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654 h1:oa+fljZiaJUVyiT7WgIM3OhirtwBm0LJA97LvWUlBu8= 10 | github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= 11 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 12 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 13 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 14 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 15 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 16 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 17 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 18 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 19 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 20 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 21 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 22 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 23 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 24 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 28 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 34 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 35 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 36 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 37 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 38 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 39 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 40 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 41 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 42 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 43 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 44 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 45 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 46 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 47 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 48 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 49 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 50 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | -------------------------------------------------------------------------------- /internal/editor/editor.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | ) 10 | 11 | type Editor struct { 12 | *tview.Box 13 | Lines []string 14 | cursorX, cursorY int 15 | statusBar *tview.TextView 16 | } 17 | 18 | // NewEditor creates a new Editor instance. 19 | func NewEditor(statusBar *tview.TextView) *Editor { 20 | return &Editor{ 21 | Box: tview.NewBox(), 22 | Lines: []string{""}, 23 | statusBar: statusBar, 24 | } 25 | } 26 | 27 | // Draw renders the editor on the screen. 28 | func (e *Editor) Draw(screen tcell.Screen) { 29 | e.Box.DrawForSubclass(screen, e) 30 | defStyle := tcell.StyleDefault 31 | x, y, width, height := e.GetInnerRect() 32 | for row := 0; row < height; row++ { 33 | for col := 0; col < width; col++ { 34 | screen.SetContent(x+col, y+row, ' ', nil, defStyle) 35 | } 36 | } 37 | startLine := 0 38 | if len(e.Lines) > height { 39 | startLine = len(e.Lines) - height 40 | } 41 | for i := startLine; i < len(e.Lines); i++ { 42 | line := e.Lines[i] 43 | tview.Print(screen, line, x, y+i-startLine, width, tview.AlignLeft, tcell.ColorWhite) 44 | } 45 | screen.ShowCursor(x+e.cursorX, y+e.cursorY) 46 | } 47 | 48 | // InputHandler handles key events for the editor. 49 | func (e *Editor) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 50 | return e.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 51 | line := e.Lines[e.cursorY] 52 | switch event.Key() { 53 | case tcell.KeyTab: 54 | if e.cursorX > len(line) { 55 | e.cursorX = len(line) 56 | } 57 | line = line[:e.cursorX] + "-" + line[e.cursorX:] 58 | e.cursorX++ 59 | e.Lines[e.cursorY] = e.enforceDepth(line) 60 | case tcell.KeyRune: 61 | ch := event.Rune() 62 | if ch == '\t' { 63 | ch = '-' 64 | } else if ch == ' ' { 65 | return 66 | } 67 | if e.cursorX > len(line) { 68 | e.cursorX = len(line) 69 | } 70 | line = line[:e.cursorX] + string(ch) + line[e.cursorX:] 71 | e.cursorX++ 72 | e.Lines[e.cursorY] = e.enforceDepth(line) 73 | case tcell.KeyBackspace, tcell.KeyBackspace2: 74 | if e.cursorX > len(line) { 75 | e.cursorX = len(line) 76 | } 77 | if e.cursorX > 0 { 78 | line = line[:e.cursorX-1] + line[e.cursorX:] 79 | e.Lines[e.cursorY] = line 80 | e.cursorX-- 81 | } else if e.cursorY > 0 { 82 | prevLine := e.Lines[e.cursorY-1] 83 | e.cursorX = len(prevLine) 84 | e.Lines[e.cursorY-1] = prevLine + line 85 | e.Lines = append(e.Lines[:e.cursorY], e.Lines[e.cursorY+1:]...) 86 | e.cursorY-- 87 | } 88 | case tcell.KeyDelete: 89 | if e.cursorX < len(line) { 90 | line = line[:e.cursorX] + line[e.cursorX+1:] 91 | e.Lines[e.cursorY] = line 92 | } else if e.cursorY < len(e.Lines)-1 { 93 | nextLine := e.Lines[e.cursorY+1] 94 | e.Lines[e.cursorY] = line + nextLine 95 | e.Lines = append(e.Lines[:e.cursorY+1], e.Lines[e.cursorY+2:]...) 96 | } 97 | case tcell.KeyLeft: 98 | if e.cursorX > 0 { 99 | e.cursorX-- 100 | } else if e.cursorY > 0 { 101 | e.cursorY-- 102 | e.cursorX = len(e.Lines[e.cursorY]) 103 | } 104 | case tcell.KeyRight: 105 | if e.cursorX < len(line) { 106 | e.cursorX++ 107 | } else if e.cursorY < len(e.Lines)-1 { 108 | e.cursorY++ 109 | e.cursorX = 0 110 | } 111 | case tcell.KeyUp: 112 | if e.cursorY > 0 { 113 | e.cursorY-- 114 | if e.cursorX > len(e.Lines[e.cursorY]) { 115 | e.cursorX = len(e.Lines[e.cursorY]) 116 | } 117 | } 118 | case tcell.KeyDown: 119 | if e.cursorY < len(e.Lines)-1 { 120 | e.cursorY++ 121 | if e.cursorX > len(e.Lines[e.cursorY]) { 122 | e.cursorX = len(e.Lines[e.cursorY]) 123 | } 124 | } 125 | case tcell.KeyEnter: 126 | if e.cursorX > len(line) { 127 | e.cursorX = len(line) 128 | } 129 | if e.isLineIncomplete(e.Lines[e.cursorY]) { 130 | e.statusBar.SetText("Cannot add a new line after an incomplete line.") 131 | return 132 | } 133 | newLine := e.Lines[e.cursorY][e.cursorX:] 134 | e.Lines[e.cursorY] = e.Lines[e.cursorY][:e.cursorX] 135 | e.Lines = append(e.Lines[:e.cursorY+1], append([]string{newLine}, e.Lines[e.cursorY+1:]...)...) 136 | e.cursorY++ 137 | e.cursorX = 0 138 | } 139 | e.Lines[e.cursorY] = e.enforceDepth(e.Lines[e.cursorY]) 140 | e.statusBar.SetText("") 141 | }) 142 | } 143 | 144 | // enforceDepth enforces depth restrictions. 145 | func (e *Editor) enforceDepth(line string) string { 146 | line = strings.TrimLeft(line, " ") 147 | line = strings.ReplaceAll(line, "\t", "-") 148 | maxDepth := e.getMaxAllowedDepth(e.cursorY) 149 | if maxDepth < 0 { 150 | maxDepth = 0 151 | } 152 | dashCount := countLeadingDashes(line) 153 | if dashCount > maxDepth { 154 | line = strings.TrimLeft(line, "-") 155 | line = strings.Repeat("-", maxDepth) + line 156 | } 157 | return line 158 | } 159 | 160 | // getMaxAllowedDepth returns the maximum allowed depth. 161 | func (e *Editor) getMaxAllowedDepth(currentIndex int) int { 162 | if currentIndex == 0 { 163 | return 0 164 | } 165 | for i := currentIndex - 1; i >= 0; i-- { 166 | lineContent := e.Lines[i] 167 | if lineContent == "" { 168 | continue 169 | } 170 | depth := countLeadingDashes(lineContent) 171 | isFile, _ := isFileLine(lineContent) 172 | if !isFile { 173 | return depth + 1 174 | } else { 175 | return depth 176 | } 177 | } 178 | return 0 179 | } 180 | 181 | // isLineIncomplete checks if a line is incomplete. 182 | func (e *Editor) isLineIncomplete(line string) bool { 183 | trimmed := strings.TrimLeft(line, "-") 184 | return strings.TrimSpace(trimmed) == "" 185 | } 186 | 187 | // ValidateStructure checks if the structure is valid. 188 | func (e *Editor) ValidateStructure() error { 189 | for i, line := range e.Lines { 190 | if e.isLineIncomplete(line) { 191 | return fmt.Errorf("line %d is incomplete", i+1) 192 | } 193 | } 194 | return nil 195 | } 196 | 197 | // countLeadingDashes counts the number of leading dashes. 198 | func countLeadingDashes(s string) int { 199 | count := 0 200 | for _, char := range s { 201 | if char == '-' { 202 | count++ 203 | } else if char == ' ' || char == '\t' { 204 | continue 205 | } else { 206 | break 207 | } 208 | } 209 | return count 210 | } 211 | 212 | // isFileLine checks if a line represents a file. 213 | func isFileLine(line string) (bool, string) { 214 | name := strings.TrimLeft(line, "-") 215 | name = strings.TrimSpace(name) 216 | isFile := false 217 | if strings.HasSuffix(name, ":file") { 218 | isFile = true 219 | name = strings.TrimSuffix(name, ":file") 220 | name = strings.TrimSpace(name) 221 | } else if strings.Contains(name, ".") { 222 | isFile = true 223 | } 224 | return isFile, name 225 | } 226 | -------------------------------------------------------------------------------- /internal/editor/editor_test.go: -------------------------------------------------------------------------------- 1 | package editor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestCountLeadingDashes tests the countLeadingDashes function. 8 | func TestCountLeadingDashes(t *testing.T) { 9 | tests := []struct { 10 | input string 11 | expected int 12 | }{ 13 | {"---file.go", 3}, 14 | {"- dir", 1}, 15 | {"--nested", 2}, 16 | {"no-dashes", 0}, 17 | {" -mixed", 1}, 18 | {"", 0}, 19 | {"\t-tab", 1}, 20 | } 21 | 22 | for _, test := range tests { 23 | result := countLeadingDashes(test.input) 24 | if result != test.expected { 25 | t.Errorf("countLeadingDashes(%q) = %d; want %d", test.input, result, test.expected) 26 | } 27 | } 28 | } 29 | 30 | // TestIsFileLine tests the isFileLine function. 31 | func TestIsFileLine(t *testing.T) { 32 | tests := []struct { 33 | input string 34 | expectedIs bool 35 | expectedName string 36 | }{ 37 | {"-main.go", true, "main.go"}, 38 | {"--utils.go", true, "utils.go"}, 39 | {"-README:file", true, "README"}, 40 | {"--LICENSE:file", true, "LICENSE"}, 41 | {"-docs", false, "docs"}, 42 | {"--src", false, "src"}, 43 | {"-config", false, "config"}, 44 | {"-script.sh", true, "script.sh"}, 45 | {"-noextension:file", true, "noextension"}, 46 | {"-invalid:fileextra", false, "invalid:fileextra"}, // Edge case 47 | {"", false, ""}, 48 | {"---", false, ""}, 49 | } 50 | 51 | for _, test := range tests { 52 | isFile, name := isFileLine(test.input) 53 | if isFile != test.expectedIs || name != test.expectedName { 54 | t.Errorf("isFileLine(%q) = (%v, %q); want (%v, %q)", test.input, isFile, name, test.expectedIs, test.expectedName) 55 | } 56 | } 57 | } 58 | 59 | // TestIsLineIncomplete tests the isLineIncomplete method. 60 | func TestIsLineIncomplete(t *testing.T) { 61 | tests := []struct { 62 | name string 63 | line string 64 | expected bool 65 | }{ 66 | {"Complete line with content", "----file.go", false}, 67 | {"Incomplete line with only dashes", "---", true}, 68 | {"Incomplete line with dashes and spaces", "--- ", true}, 69 | {"Complete line with mixed content", "---main.go", false}, 70 | {"Empty line", "", true}, 71 | {"Line with spaces only", " ", true}, 72 | {"Line with tabs and dashes", "\t--file.go", false}, 73 | } 74 | 75 | for _, test := range tests { 76 | editor := NewEditor(nil) // statusBar is not needed for this test 77 | editor.Lines = []string{test.line} 78 | result := editor.isLineIncomplete(editor.Lines[0]) 79 | if result != test.expected { 80 | t.Errorf("isLineIncomplete(%q) = %v; want %v", test.line, result, test.expected) 81 | } 82 | } 83 | } 84 | 85 | // TestEnforceDepth tests the enforceDepth method. 86 | func TestEnforceDepth(t *testing.T) { 87 | tests := []struct { 88 | name string 89 | lines []string 90 | currentY int 91 | line string 92 | expected string 93 | }{ 94 | { 95 | name: "No depth enforcement needed", 96 | lines: []string{"-src", "--main.go"}, 97 | currentY: 1, 98 | line: "--main.go", 99 | expected: "--main.go", 100 | }, 101 | { 102 | name: "Exceeds max depth", 103 | lines: []string{"-src", "--main.go"}, 104 | currentY: 1, 105 | line: "---extra.go", 106 | expected: "--extra.go", 107 | }, 108 | { 109 | name: "Reduce depth", 110 | lines: []string{"-src", "--main.go"}, 111 | currentY: 1, 112 | line: "----utils.go", 113 | expected: "--utils.go", 114 | }, 115 | { 116 | name: "Adjust depth based on parent", 117 | lines: []string{"-src", "--main.go", "-docs"}, 118 | currentY: 2, 119 | line: "-docs", 120 | expected: "-docs", 121 | }, 122 | { 123 | name: "Initial line with no dashes", 124 | lines: []string{""}, 125 | currentY: 0, 126 | line: "src", 127 | expected: "src", 128 | }, 129 | } 130 | 131 | for _, test := range tests { 132 | editor := NewEditor(nil) // statusBar is not needed for this test 133 | editor.Lines = test.lines 134 | editor.cursorY = test.currentY 135 | result := editor.enforceDepth(test.line) 136 | if result != test.expected { 137 | t.Errorf("enforceDepth(%q) = %q; want %q", test.line, result, test.expected) 138 | } 139 | } 140 | } 141 | 142 | // TestValidateStructure tests the ValidateStructure method. 143 | func TestValidateStructure(t *testing.T) { 144 | tests := []struct { 145 | name string 146 | lines []string 147 | hasError bool 148 | errorMsg string 149 | }{ 150 | { 151 | name: "Valid structure", 152 | lines: []string{"-src", "--main.go", "-docs", "--README.md"}, 153 | hasError: false, 154 | }, 155 | { 156 | name: "Incomplete line", 157 | lines: []string{"-src", "--", "-docs"}, 158 | hasError: true, 159 | errorMsg: "line 2 is incomplete", 160 | }, 161 | { 162 | name: "Empty lines", 163 | lines: []string{"-src", "", "-docs"}, 164 | hasError: true, 165 | errorMsg: "line 2 is incomplete", 166 | }, 167 | { 168 | name: "All complete lines", 169 | lines: []string{"-src", "--main.go", "--utils.go", "-docs", "--README.md"}, 170 | hasError: false, 171 | }, 172 | { 173 | name: "Multiple incomplete lines", 174 | lines: []string{"-", "--", "---file.go"}, 175 | hasError: true, 176 | errorMsg: "line 1 is incomplete", 177 | }, 178 | { 179 | name: "No lines", 180 | lines: []string{}, 181 | hasError: false, // Depending on desired behavior; assuming no error 182 | }, 183 | } 184 | 185 | for _, test := range tests { 186 | editor := NewEditor(nil) 187 | editor.Lines = test.lines 188 | err := editor.ValidateStructure() 189 | if test.hasError { 190 | if err == nil { 191 | t.Errorf("ValidateStructure(%q) expected error but got none", test.name) 192 | } else if err.Error() != test.errorMsg { 193 | t.Errorf("ValidateStructure(%q) error = %q; want %q", test.name, err.Error(), test.errorMsg) 194 | } 195 | } else { 196 | if err != nil { 197 | t.Errorf("ValidateStructure(%q) unexpected error: %v", test.name, err) 198 | } 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /internal/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // BuildProjectStructure builds the project structure from lines 11 | func BuildProjectStructure(lines []string, rootDir string) { 12 | fmt.Println("Building project structure... Hold on tight! 🛠️") 13 | err := os.MkdirAll(rootDir, 0755) 14 | if err != nil { 15 | fmt.Printf("Error creating root directory %s: %v\n", rootDir, err) 16 | return 17 | } 18 | pathStack := []string{rootDir} 19 | for _, line := range lines { 20 | content := strings.TrimRight(line, "\r\n") 21 | if content == "" { 22 | continue 23 | } 24 | depth := countLeadingDashes(content) 25 | if depth < 0 { 26 | depth = 0 27 | } 28 | if depth > len(pathStack)-1 { 29 | depth = len(pathStack) - 1 30 | } 31 | pathStack = pathStack[:depth+1] 32 | parentDir := pathStack[len(pathStack)-1] 33 | isFile, name := isFileLine(content) 34 | if name == "" { 35 | fmt.Printf("Invalid name at line: %s\n", content) 36 | continue 37 | } 38 | fullPath := filepath.Join(parentDir, name) 39 | if isFile { 40 | file, err := os.Create(fullPath) 41 | if err != nil { 42 | fmt.Printf("Error creating file %s: %v\n", fullPath, err) 43 | continue 44 | } 45 | file.Close() 46 | fmt.Printf("Created file: %s\n", fullPath) 47 | } else { 48 | err := os.Mkdir(fullPath, 0755) 49 | if err != nil { 50 | fmt.Printf("Error creating directory %s: %v\n", fullPath, err) 51 | continue 52 | } 53 | fmt.Printf("Created directory: %s\n", fullPath) 54 | pathStack = append(pathStack, fullPath) 55 | } 56 | } 57 | displayFinalStructure(rootDir) 58 | } 59 | 60 | // displayFinalStructure shows the final structure. 61 | func displayFinalStructure(rootDir string) { 62 | fmt.Println("\nFinal Project Structure:") 63 | err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { 64 | if err != nil { 65 | fmt.Printf("Error accessing path %s: %v\n", path, err) 66 | return err 67 | } 68 | relativePath, _ := filepath.Rel(rootDir, path) 69 | if relativePath != "." { 70 | depth := strings.Count(relativePath, string(os.PathSeparator)) 71 | fmt.Printf("%s%s\n", strings.Repeat(" ", depth), info.Name()) 72 | } 73 | return nil 74 | }) 75 | if err != nil { 76 | fmt.Printf("Error walking the path %s: %v\n", rootDir, err) 77 | } 78 | } 79 | 80 | // countLeadingDashes counts the number of leading dashes. 81 | func countLeadingDashes(s string) int { 82 | count := 0 83 | for _, char := range s { 84 | if char == '-' { 85 | count++ 86 | } else if char == ' ' || char == '\t' { 87 | continue 88 | } else { 89 | break 90 | } 91 | } 92 | return count 93 | } 94 | 95 | // isFileLine checks if a line represents a file. 96 | func isFileLine(line string) (bool, string) { 97 | name := strings.TrimLeft(line, "-") 98 | name = strings.TrimSpace(name) 99 | isFile := false 100 | if strings.HasSuffix(name, ":file") { 101 | isFile = true 102 | name = strings.TrimSuffix(name, ":file") 103 | name = strings.TrimSpace(name) 104 | } else if strings.Contains(name, ".") { 105 | isFile = true 106 | } 107 | return isFile, name 108 | } 109 | -------------------------------------------------------------------------------- /internal/project/project_test.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | // TestBuildProjectStructure_Basic tests the creation of a basic project structure. 10 | func TestBuildProjectStructure_Basic(t *testing.T) { 11 | rootDir := setupTestRootDir(t) 12 | defer os.RemoveAll(rootDir) // Clean up after the test 13 | 14 | lines := []string{ 15 | "src", 16 | "-main.go", 17 | "-utils.go", 18 | "README.md", 19 | } 20 | 21 | // Call BuildProjectStructure 22 | BuildProjectStructure(lines, rootDir) 23 | 24 | // Validate the expected directory structure 25 | expectedDirs := []string{ 26 | filepath.Join(rootDir, "src"), 27 | } 28 | expectedFiles := []string{ 29 | filepath.Join(rootDir, "src", "main.go"), 30 | filepath.Join(rootDir, "src", "utils.go"), 31 | filepath.Join(rootDir, "README.md"), 32 | } 33 | 34 | validateStructure(t, expectedDirs, expectedFiles) 35 | } 36 | 37 | // TestBuildProjectStructure_FileWithoutExtension tests the creation of a file without an extension. 38 | func TestBuildProjectStructure_FileWithoutExtension(t *testing.T) { 39 | rootDir := setupTestRootDir(t) 40 | defer os.RemoveAll(rootDir) // Clean up after the test 41 | 42 | lines := []string{ 43 | "docs", 44 | "-README:file", 45 | "-LICENSE:file", 46 | } 47 | 48 | // Call BuildProjectStructure 49 | BuildProjectStructure(lines, rootDir) 50 | 51 | // Validate the expected directory structure 52 | expectedDirs := []string{ 53 | filepath.Join(rootDir, "docs"), 54 | } 55 | expectedFiles := []string{ 56 | filepath.Join(rootDir, "docs", "README"), 57 | filepath.Join(rootDir, "docs", "LICENSE"), 58 | } 59 | 60 | validateStructure(t, expectedDirs, expectedFiles) 61 | } 62 | 63 | // TestBuildProjectStructure_EmptyInput tests the behavior when given an empty input. 64 | func TestBuildProjectStructure_EmptyInput(t *testing.T) { 65 | rootDir := setupTestRootDir(t) 66 | defer os.RemoveAll(rootDir) // Clean up after the test 67 | 68 | lines := []string{} 69 | 70 | // Call BuildProjectStructure 71 | BuildProjectStructure(lines, rootDir) 72 | 73 | // Ensure no directories or files were created 74 | if _, err := os.Stat(rootDir); os.IsNotExist(err) { 75 | t.Errorf("Expected root directory %s to exist", rootDir) 76 | } 77 | 78 | files, err := os.ReadDir(rootDir) 79 | if err != nil { 80 | t.Fatalf("Error reading root directory: %v", err) 81 | } 82 | if len(files) != 0 { 83 | t.Errorf("Expected no files or directories in root directory, but found %d", len(files)) 84 | } 85 | } 86 | 87 | // Helper function to set up the test root directory 88 | func setupTestRootDir(t *testing.T) string { 89 | rootDir, err := os.MkdirTemp("", "project_test") 90 | if err != nil { 91 | t.Fatalf("Failed to create temporary directory: %v", err) 92 | } 93 | return rootDir 94 | } 95 | 96 | // Helper function to validate the created directories and files 97 | func validateStructure(t *testing.T, expectedDirs []string, expectedFiles []string) { 98 | // Check directories 99 | for _, dir := range expectedDirs { 100 | if stat, err := os.Stat(dir); os.IsNotExist(err) || !stat.IsDir() { 101 | t.Errorf("Expected directory %s to exist", dir) 102 | } 103 | } 104 | 105 | // Check files 106 | for _, file := range expectedFiles { 107 | if stat, err := os.Stat(file); os.IsNotExist(err) || stat.IsDir() { 108 | t.Errorf("Expected file %s to exist", file) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/tree/tree.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // DisplayDirectoryTree shows the directory tree. 11 | func DisplayDirectoryTree(rootDir string, showHidden bool) { 12 | fmt.Println("Current Directory Structure:") 13 | err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { 14 | if err != nil { 15 | fmt.Printf("Error accessing path %s: %v\n", path, err) 16 | return err 17 | } 18 | if path == rootDir { 19 | return nil 20 | } 21 | relativePath, _ := filepath.Rel(rootDir, path) 22 | if !showHidden { 23 | // Skip hidden files and directories 24 | parts := strings.Split(relativePath, string(os.PathSeparator)) 25 | for _, part := range parts { 26 | if strings.HasPrefix(part, ".") { 27 | if info.IsDir() && info.Name() == part { 28 | return filepath.SkipDir 29 | } 30 | return nil 31 | } 32 | } 33 | } 34 | depth := strings.Count(relativePath, string(os.PathSeparator)) 35 | indent := strings.Repeat("-", depth) 36 | name := info.Name() 37 | if info.IsDir() { 38 | fmt.Printf("%s%s\n", indent, name) 39 | } else { 40 | if filepath.Ext(name) == "" { 41 | fmt.Printf("%s%s:file\n", indent, name) 42 | } else { 43 | fmt.Printf("%s%s\n", indent, name) 44 | } 45 | } 46 | return nil 47 | }) 48 | if err != nil { 49 | fmt.Printf("Error displaying directory tree: %v\n", err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/tree/tree_test.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | // Utility function to capture stdout for testing purposes. 11 | func captureOutput(f func()) string { 12 | var buf bytes.Buffer 13 | // Save the original stdout 14 | stdout := os.Stdout 15 | defer func() { os.Stdout = stdout }() 16 | 17 | // Temporarily redirect stdout 18 | r, w, _ := os.Pipe() 19 | os.Stdout = w 20 | 21 | // Execute the function to capture output 22 | f() 23 | 24 | // Restore stdout and get the output 25 | w.Close() 26 | _, _ = buf.ReadFrom(r) 27 | return buf.String() 28 | } 29 | 30 | // TestDisplayDirectoryTree_EmptyDirectory tests that an empty directory outputs nothing after "Current Directory Structure:" 31 | func TestDisplayDirectoryTree_EmptyDirectory(t *testing.T) { 32 | // Setup: Create a temporary empty directory 33 | rootDir := setupEmptyTestDirectory(t) 34 | defer os.RemoveAll(rootDir) // Cleanup after test 35 | 36 | // Capture the output of the DisplayDirectoryTree function 37 | output := captureOutput(func() { 38 | DisplayDirectoryTree(rootDir, false) 39 | }) 40 | 41 | // Expected directory structure output for an empty directory 42 | expected := "Current Directory Structure:" 43 | 44 | if strings.TrimSpace(output) != strings.TrimSpace(expected) { 45 | t.Errorf("Expected:\n%s\nGot:\n%s", expected, output) 46 | } 47 | } 48 | 49 | // Helper function to create an empty temporary directory for testing 50 | func setupEmptyTestDirectory(t *testing.T) string { 51 | rootDir, err := os.MkdirTemp("", "emptydir") 52 | if err != nil { 53 | t.Fatalf("Failed to create temp dir: %v", err) 54 | } 55 | return rootDir 56 | } 57 | --------------------------------------------------------------------------------