├── .github
├── FUNDING.yml
├── workflows
│ ├── release.yml
│ ├── ci.yml
│ └── docs.yml
└── copilot-instructions.md
├── screenshots
├── add.png
├── query.png
├── start.png
├── tree.png
└── record.png
├── .releaserc.json
├── docpress.json
├── docs
├── README.md
├── installation.md
├── home.md
├── contributing.md
├── development.md
├── changelog.md
└── usage.md
├── internal
├── tui
│ ├── rainbow.go
│ ├── viewport_consistency_test.go
│ ├── gradient_test.go
│ ├── bubblezone_fix_test.go
│ ├── tree_highlighting_test.go
│ ├── update_check_test.go
│ ├── tree_pagination_test.go
│ ├── tree_viewport_test.go
│ ├── width_fix_test.go
│ ├── start_connect_test.go
│ ├── model_connect_test.go
│ ├── tree_timer_test.go
│ ├── start_config_test.go
│ ├── viewport_height_fix_test.go
│ ├── start_test.go
│ ├── tree_click_test.go
│ ├── start_error_handling_test.go
│ ├── viewport_consistency_test.go.bak
│ ├── view_helpers.go
│ ├── model_test.go
│ ├── tree.go
│ └── query_test.go
├── version
│ ├── version_test.go
│ └── version.go
├── updater
│ ├── updater_test.go
│ ├── updater.go
│ └── updater_mock_test.go
└── ldap
│ ├── client_test.go
│ └── client.go
├── package.json
├── .gitignore
├── LICENSE.md
├── CHANGELOG.md
├── go.mod
├── .goreleaser.yaml
├── config
└── example.yaml
├── scripts
├── README.md
├── install.sh
├── install-macos.sh
└── install.ps1
├── Makefile
├── cmd
└── moribito
│ └── main.go
└── go.sum
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | buy_me_a_coffee: ericschmar
2 |
--------------------------------------------------------------------------------
/screenshots/add.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericschmar/moribito/HEAD/screenshots/add.png
--------------------------------------------------------------------------------
/screenshots/query.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericschmar/moribito/HEAD/screenshots/query.png
--------------------------------------------------------------------------------
/screenshots/start.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericschmar/moribito/HEAD/screenshots/start.png
--------------------------------------------------------------------------------
/screenshots/tree.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericschmar/moribito/HEAD/screenshots/tree.png
--------------------------------------------------------------------------------
/screenshots/record.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericschmar/moribito/HEAD/screenshots/record.png
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "main"
4 | ],
5 | "plugins": [
6 | "@semantic-release/commit-analyzer",
7 | "@semantic-release/release-notes-generator",
8 | "@semantic-release/changelog",
9 | "@semantic-release/github"
10 | ]
11 | }
--------------------------------------------------------------------------------
/docpress.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Moribito",
3 | "description": "Terminal-based LDAP server explorer documentation",
4 | "github": "ericschmar/moribito",
5 | "plugins": {
6 | "docpress-core": {},
7 | "docpress-base": {}
8 | },
9 | "markdown": {
10 | "typographer": true
11 | },
12 | "src": "docs",
13 | "dest": "_docpress"
14 | }
15 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Moribito
2 |
3 | Welcome to the documentation for Moribito (森人), a terminal-based LDAP server explorer built with Go and BubbleTea.
4 |
5 | - [Moribito](home.md)
6 | - [Installation](installation.md)
7 | - [Usage Guide](usage.md)
8 | - Advanced
9 | - [Changelog](changelog.md)
10 | - Development
11 | - [Contributing](contributing.md)
12 | - [Development Setup](development.md)
13 |
--------------------------------------------------------------------------------
/internal/tui/rainbow.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "image/color"
5 |
6 | "github.com/charmbracelet/lipgloss"
7 | "github.com/lucasb-eyer/go-colorful"
8 | )
9 |
10 | func Rainbow(base lipgloss.Style, s string, colors []color.Color) string {
11 | var str string
12 | for i, ss := range s {
13 | color, _ := colorful.MakeColor(colors[i%len(colors)])
14 | str = str + base.Foreground(lipgloss.Color(color.Hex())).Render(string(ss))
15 | }
16 | return str
17 | }
18 |
--------------------------------------------------------------------------------
/internal/tui/viewport_consistency_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestViewportConsistency(t *testing.T) {
8 | t.Skip("Skipping viewport consistency test - methods not available in current QueryView")
9 | }
10 |
11 | func TestViewportScrolling(t *testing.T) {
12 | t.Skip("Skipping viewport scrolling test - methods not available in current TreeView")
13 | }
14 |
15 | func TestQueryViewNavigation(t *testing.T) {
16 | t.Skip("Skipping query view navigation test - methods not available in current QueryView")
17 | }
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ldap-cli",
3 | "version": "1.0.0",
4 | "description": "A terminal-based LDAP server explorer built with Go and BubbleTea, providing an interactive interface for browsing LDAP directory trees, viewing records, and executing custom queries.",
5 | "main": "index.js",
6 | "directories": {
7 | "doc": "docs"
8 | },
9 | "scripts": {
10 | "test": "echo \"Error: no test specified\" && exit 1",
11 | "docs:build": "docpress build",
12 | "docs:serve": "docpress serve",
13 | "docs:clean": "rm -rf _docpress"
14 | },
15 | "keywords": [],
16 | "author": "",
17 | "license": "ISC",
18 | "devDependencies": {
19 | "docpress": "0.8.2"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docs/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | ## Homebrew (Recommended for macOS/Linux)
4 |
5 | ```bash
6 | brew install moribito
7 | ```
8 |
9 | ## Windows
10 |
11 | ### From Source
12 |
13 | ```bash
14 | git clone https://github.com/ericschmar/moribito
15 | cd moribito
16 | go build -o moribito.exe cmd/moribito/main.go
17 | ```
18 |
19 | ## Verification
20 |
21 | After installation, verify the installation by checking the version:
22 |
23 | ```bash
24 | moribito --version
25 | ```
26 |
27 | ## Configuration Setup
28 |
29 | After installation, create your configuration file:
30 |
31 | ```bash
32 | moribito --create-config
33 | ```
34 |
35 | This will create a configuration file in the appropriate OS-specific location:
36 | - **Linux**: `~/.config/moribito/config.yaml`
37 | - **macOS**: `~/.moribito/config.yaml`
38 | - **Windows**: `%APPDATA%\moribito\config.yaml`
39 |
40 | Edit the configuration file with your LDAP server details and run:
41 |
42 | ```bash
43 | moribito
44 | ```
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | # Go workspace file
18 | go.work
19 |
20 | # IDE files
21 | .vscode/
22 | .idea/
23 |
24 | # OS generated files
25 | .DS_Store
26 | .DS_Store?
27 | ._*
28 | .Spotlight-V100
29 | .Trashes
30 | ehthumbs.db
31 | Thumbs.db
32 |
33 | # Build output
34 | /bin/
35 | /dist/
36 |
37 | # Config files with secrets (keep sample configs)
38 | config/local.yaml
39 | config/production.yaml
40 | config/config.yaml
41 |
42 | # Node.js dependencies and build artifacts
43 | node_modules/
44 | npm-debug.log*
45 |
46 | # DocPress build output
47 | _docpress/
48 |
49 | # Homebrew testing files
50 | *.bottle.tar.gz
51 | .homebrew_build_deps/
52 | _docpress
53 | .env
54 |
--------------------------------------------------------------------------------
/internal/version/version_test.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import "testing"
4 |
5 | func TestVersionInfo(t *testing.T) {
6 | info := Get()
7 |
8 | // Test that basic fields exist
9 | if info.Version == "" {
10 | t.Error("Version should not be empty")
11 | }
12 |
13 | if info.Commit == "" {
14 | t.Error("Commit should not be empty")
15 | }
16 |
17 | if info.Date == "" {
18 | t.Error("Date should not be empty")
19 | }
20 |
21 | if info.GoVersion == "" {
22 | t.Error("GoVersion should not be empty")
23 | }
24 |
25 | if info.Platform == "" {
26 | t.Error("Platform should not be empty")
27 | }
28 |
29 | // Test string methods
30 | longVersion := info.String()
31 | shortVersion := info.ShortString()
32 |
33 | if longVersion == "" {
34 | t.Error("String() should not be empty")
35 | }
36 |
37 | if shortVersion == "" {
38 | t.Error("ShortString() should not be empty")
39 | }
40 |
41 | if shortVersion != info.Version {
42 | t.Error("ShortString() should equal Version field")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | main:
10 | runs-on: ubuntu-latest
11 | environment:
12 | name: Release
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | with:
17 | persist-credentials: false
18 | - name: Semantic Release
19 | uses: cycjimmy/semantic-release-action@v4
20 | with:
21 | extra_plugins: |
22 | @semantic-release/exec
23 | @semantic-release/changelog
24 | env:
25 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
26 | - name: Run GoReleaser
27 | env:
28 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
29 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GH_TOKEN }}
30 | run: |
31 | curl -sL https://git.io/goreleaser | bash
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 ericschmar
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 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | pull_request:
7 | branches: [ main, develop ]
8 |
9 | jobs:
10 | build-all:
11 | name: Build All Platforms
12 | runs-on: ubuntu-latest
13 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - name: Set up Go
18 | uses: actions/setup-go@v5
19 | with:
20 | go-version: '1.24'
21 | cache: true
22 |
23 | - name: Cache Go modules
24 | uses: actions/cache@v4
25 | with:
26 | path: |
27 | ~/.cache/go-build
28 | ~/go/pkg/mod
29 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
30 | restore-keys: |
31 | ${{ runner.os }}-go-
32 |
33 | - name: Download dependencies
34 | run: go mod download
35 |
36 | - name: Build all platforms
37 | run: make build-all
38 |
39 | - name: Upload build artifacts
40 | uses: actions/upload-artifact@v4
41 | with:
42 | name: binaries
43 | path: bin/
44 | retention-days: 30
45 |
--------------------------------------------------------------------------------
/docs/home.md:
--------------------------------------------------------------------------------
1 | # 森人 - Mori-bito (forest-person)
2 |
3 |
4 |
5 |
6 |
7 | A terminal-based LDAP server explorer built with Go and BubbleTea, providing an interactive interface for browsing LDAP directory trees, viewing records, and executing custom queries.
8 |
9 | ## Features
10 |
11 | - 🌲 **Interactive Tree Navigation**: Browse LDAP directory structure with keyboard/mouse
12 | - 📄 **Record Viewer**: View detailed LDAP entry attributes
13 | - 📋 **Clipboard Integration**: Copy attribute values to system clipboard
14 | - 🔍 **Custom Query Interface**: Execute custom LDAP queries with real-time results
15 | - 📖 **Paginated Results**: Efficient pagination for large result sets with automatic loading
16 | - ⚙️ **Flexible Configuration**: Support for config files and command-line options
17 | - 🔐 **Secure Authentication**: Support for SSL/TLS and various authentication methods
18 | - 🔄 **Auto-Update Notifications**: Optional checking for newer releases from GitHub
19 | - 🎨 **Modern TUI**: Clean, intuitive interface built with BubbleTea
20 | - 🔀 **Multiple Connections**: Save and switch between multiple LDAP server configurations
21 |
--------------------------------------------------------------------------------
/internal/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import (
4 | "fmt"
5 | "runtime"
6 | )
7 |
8 | // Build information. These will be populated at build time via -ldflags.
9 | var (
10 | // Version is the semantic version of the application
11 | Version = "dev"
12 | // Commit is the git commit hash
13 | Commit = "unknown"
14 | // Date is the build date
15 | Date = "unknown"
16 | )
17 |
18 | // Info contains version information
19 | type Info struct {
20 | Version string `json:"version"`
21 | Commit string `json:"commit"`
22 | Date string `json:"date"`
23 | GoVersion string `json:"go_version"`
24 | Platform string `json:"platform"`
25 | }
26 |
27 | // Get returns the current version information
28 | func Get() Info {
29 | return Info{
30 | Version: Version,
31 | Commit: Commit,
32 | Date: Date,
33 | GoVersion: runtime.Version(),
34 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
35 | }
36 | }
37 |
38 | // String returns a formatted version string
39 | func (i Info) String() string {
40 | return fmt.Sprintf("moribito version %s (commit %s, built %s, %s, %s)",
41 | i.Version, i.Commit, i.Date, i.GoVersion, i.Platform)
42 | }
43 |
44 | // ShortString returns a short version string
45 | func (i Info) ShortString() string {
46 | return i.Version
47 | }
48 |
--------------------------------------------------------------------------------
/internal/tui/gradient_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestGetGradientColor(t *testing.T) {
8 | testCases := []struct {
9 | position float64
10 | expected string
11 | }{
12 | {0.0, "#0066CC"}, // Pure blue
13 | {0.3, "#0066A4"}, // Used in RecordView
14 | {0.5, "#006690"}, // Used in TreeView
15 | {0.6, "#00667C"}, // Used in QueryView
16 | {0.7, "#00667C"}, // Used in StartView (should be same as 0.6)
17 | {1.0, "#008080"}, // Pure teal
18 | }
19 |
20 | for _, tc := range testCases {
21 | t.Run("", func(t *testing.T) {
22 | result := GetGradientColor(tc.position)
23 | if result != tc.expected {
24 | t.Errorf("GetGradientColor(%.1f) = %s, expected %s", tc.position, result, tc.expected)
25 | }
26 | })
27 | }
28 |
29 | // Test edge cases
30 | t.Run("negative position", func(t *testing.T) {
31 | result := GetGradientColor(-0.5)
32 | expected := "#0066CC" // Should clamp to 0
33 | if result != expected {
34 | t.Errorf("GetGradientColor(-0.5) = %s, expected %s", result, expected)
35 | }
36 | })
37 |
38 | t.Run("position over 1", func(t *testing.T) {
39 | result := GetGradientColor(1.5)
40 | expected := "#008080" // Should clamp to 1
41 | if result != expected {
42 | t.Errorf("GetGradientColor(1.5) = %s, expected %s", result, expected)
43 | }
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We welcome contributions to the Moribito project! This guide outlines how to contribute to the project.
4 |
5 | ## Getting Started
6 |
7 | 1. Fork the repository
8 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
9 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
10 | 4. Push to the branch (`git push origin feature/amazing-feature`)
11 | 5. Open a Pull Request
12 |
13 | ## Development Setup
14 |
15 | See [Development Setup](development.md) for detailed instructions on setting up the development environment.
16 |
17 | ## Code Guidelines
18 |
19 | - Follow Go best practices and idiomatic patterns
20 | - Run `make fmt` before committing any changes to ensure proper code formatting
21 | - Write tests for new functionality using table-driven tests when possible
22 | - Document public APIs and complex logic
23 | - Use meaningful commit messages
24 |
25 | ## Testing
26 |
27 | Always test your changes:
28 |
29 | ```bash
30 | # Run tests
31 | make test
32 |
33 | # Run all CI checks (includes formatting, linting, testing, and building)
34 | make ci
35 | ```
36 |
37 | ## Documentation
38 |
39 | When making changes that affect user-facing functionality:
40 |
41 | 1. Update the relevant documentation in the `docs/` folder
42 | 2. Update the main README.md if necessary
43 | 3. Suggest changes to documentation when appropriate
44 |
45 | ## License
46 |
47 | By contributing, you agree that your contributions will be licensed under the MIT License.
--------------------------------------------------------------------------------
/internal/tui/bubblezone_fix_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ericschmar/moribito/internal/config"
7 | "github.com/ericschmar/moribito/internal/ldap"
8 | )
9 |
10 | func TestModel_ViewWithoutPanic(t *testing.T) {
11 | // This test specifically verifies that the bubblezone manager initialization
12 | // prevents the panic reported in the issue: "manager not initialized"
13 |
14 | // Create a model without an LDAP client (simulating the failure case)
15 | var client *ldap.Client
16 | cfg := config.Default()
17 | model := NewModelWithPageSize(client, cfg)
18 |
19 | // Initialize the model (this should initialize bubblezone)
20 | model.Init()
21 |
22 | // This call should not panic with "manager not initialized"
23 | // Previously this would fail on the zone.Clear("") call in View()
24 | view := model.View()
25 |
26 | // The view should contain some content (not empty)
27 | if view == "" {
28 | t.Error("View should return some content, got empty string")
29 | }
30 |
31 | // The view should not contain panic or error messages
32 | if view == "Goodbye!\n" {
33 | t.Error("View should not show goodbye message immediately")
34 | }
35 | }
36 |
37 | func TestModel_BubblezoneManagerInitialized(t *testing.T) {
38 | // Test that bubblezone manager is properly initialized in Init()
39 | var client *ldap.Client
40 | cfg := config.Default()
41 | model := NewModelWithPageSize(client, cfg)
42 |
43 | // Initialize the model
44 | model.Init()
45 |
46 | // Try multiple View() calls to ensure bubblezone is stable
47 | for i := 0; i < 3; i++ {
48 | view := model.View()
49 | if view == "" {
50 | t.Errorf("View call %d returned empty string", i+1)
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
1 | # Development Setup
2 |
3 | This guide covers setting up the development environment for Moribito.
4 |
5 | ## Prerequisites
6 |
7 | - Go 1.24.6 or later
8 | - Git
9 | - Make (for build automation)
10 |
11 | ## Building
12 |
13 | ```bash
14 | # Build for current platform
15 | make build
16 |
17 | # Build for all platforms
18 | make build-all
19 |
20 | # Clean build artifacts
21 | make clean
22 | ```
23 |
24 | ## Code Quality
25 |
26 | ```bash
27 | # Format code
28 | make fmt
29 |
30 | # Run linter
31 | make lint
32 |
33 | # Run tests
34 | make test
35 |
36 | # Run all CI checks (format, lint, test, build)
37 | make ci
38 | ```
39 |
40 | ## Testing
41 |
42 | ```bash
43 | # Run all tests
44 | go test ./...
45 |
46 | # Run tests with verbose output
47 | make test
48 | ```
49 |
50 | ## Dependencies
51 |
52 | - [BubbleTea](https://github.com/charmbracelet/bubbletea) - TUI framework
53 | - [Lipgloss](https://github.com/charmbracelet/lipgloss) - Styling
54 | - [go-ldap](https://github.com/go-ldap/ldap) - LDAP client
55 | - [golang.org/x/term](https://golang.org/x/term) - Terminal utilities
56 |
57 | ## Documentation
58 |
59 | This project uses DocPress for documentation generation. To build and serve the documentation locally:
60 |
61 | ```bash
62 | # Build documentation
63 | make docs
64 |
65 | # Serve documentation locally
66 | make docs-serve
67 | ```
68 |
69 | ### Automatic Deployment
70 |
71 | Documentation is automatically built and deployed to GitHub Pages when:
72 |
73 | - Changes are pushed to the `main` branch that affect documentation files
74 | - Changes are made to `docs/**`, `docpress.json`, or `package.json`
75 | - The workflow can also be triggered manually from the Actions tab
76 |
77 | The deployed documentation is available at: https://ericschmar.github.io/moribito
78 |
--------------------------------------------------------------------------------
/docs/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/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ### Added
11 | - GitHub Actions workflows for CI/CD and automated releases
12 | - Multi-platform builds (Linux amd64/arm64, macOS amd64/arm64, Windows amd64)
13 | - Automated binary releases on GitHub
14 | - Clipboard integration for copying attribute values in Record View
15 | - 'c' key binding in Record View to copy current line's attribute value to clipboard
16 | - Status feedback when copy operations succeed or fail
17 | - Cross-platform clipboard support using github.com/atotto/clipboard
18 | - Auto-update feature with GitHub releases integration
19 | - `--check-updates` CLI flag to enable automatic update checking
20 | - Status bar notifications when updates are available
21 | - **Homebrew distribution support with custom tap and formula**
22 | - **Homebrew installation instructions and documentation**
23 | - **Helper script for maintaining Homebrew formula with automatic SHA256 generation**
24 |
25 | ### Changed
26 | - **Fixed binary naming in GitHub releases (from ldap-cli-* to moribito-*)**
27 | - **Updated release workflow to include Homebrew installation instructions**
28 |
29 | ### Fixed
30 |
31 | ### Removed
32 |
33 |
--------------------------------------------------------------------------------
/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/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ### Added
11 | - GitHub Actions workflows for CI/CD and automated releases
12 | - Multi-platform builds (Linux amd64/arm64, macOS amd64/arm64, Windows amd64)
13 | - Automated binary releases on GitHub
14 | - Clipboard integration for copying attribute values in Record View
15 | - 'c' key binding in Record View to copy current line's attribute value to clipboard
16 | - Status feedback when copy operations succeed or fail
17 | - Cross-platform clipboard support using github.com/atotto/clipboard
18 | - Auto-update feature with GitHub releases integration
19 | - `--check-updates` CLI flag to enable automatic update checking
20 | - Status bar notifications when updates are available
21 | - **Homebrew distribution support with custom tap and formula**
22 | - **Homebrew installation instructions and documentation**
23 | - **Helper script for maintaining Homebrew formula with automatic SHA256 generation**
24 |
25 | ### Changed
26 | - **Fixed binary naming in GitHub releases (from ldap-cli-* to moribito-*)**
27 | - **Updated release workflow to include Homebrew installation instructions**
28 |
29 | ### Fixed
30 |
31 | ### Removed
32 |
33 | ## Example Release Format
34 |
35 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ericschmar/moribito
2 |
3 | go 1.24.6
4 |
5 | require (
6 | github.com/atotto/clipboard v0.1.4
7 | github.com/charmbracelet/bubbles v0.21.0
8 | github.com/charmbracelet/bubbletea v1.3.6
9 | github.com/charmbracelet/lipgloss v1.1.0
10 | github.com/go-ldap/ldap/v3 v3.4.11
11 | github.com/lrstanley/bubblezone v1.0.0
12 | github.com/lucasb-eyer/go-colorful v1.2.0
13 | github.com/muesli/gamut v0.3.1
14 | gopkg.in/yaml.v3 v3.0.1
15 | )
16 |
17 | require (
18 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
20 | github.com/charmbracelet/colorprofile v0.3.1 // indirect
21 | github.com/charmbracelet/x/ansi v0.9.3 // indirect
22 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
23 | github.com/charmbracelet/x/term v0.2.1 // indirect
24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
26 | github.com/google/uuid v1.6.0 // indirect
27 | github.com/mattn/go-isatty v0.0.20 // indirect
28 | github.com/mattn/go-localereader v0.0.1 // indirect
29 | github.com/mattn/go-runewidth v0.0.16 // indirect
30 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
31 | github.com/muesli/cancelreader v0.2.2 // indirect
32 | github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 // indirect
33 | github.com/muesli/kmeans v0.3.1 // indirect
34 | github.com/muesli/termenv v0.16.0 // indirect
35 | github.com/rivo/uniseg v0.4.7 // indirect
36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
37 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
38 | golang.org/x/crypto v0.36.0 // indirect
39 | golang.org/x/sync v0.15.0 // indirect
40 | golang.org/x/sys v0.35.0 // indirect
41 | golang.org/x/text v0.24.0 // indirect
42 | )
43 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | project_name: moribito
4 |
5 | before:
6 | hooks:
7 | # You may remove this if you don't use go modules.
8 | - go mod tidy
9 | # you may remove this if you don't need go generate
10 | - go generate ./...
11 |
12 | builds:
13 | - env:
14 | - CGO_ENABLED=0
15 | goos:
16 | - linux
17 | - windows
18 | - darwin
19 | goarch:
20 | - amd64
21 | main: ./cmd/moribito
22 |
23 | archives:
24 | - format: tar.gz
25 | name_template: >-
26 | {{ .ProjectName }}_
27 | {{- title .Os }}_
28 | {{- if eq .Arch "amd64" }}x86_64
29 | {{- else if eq .Arch "386" }}i386
30 | {{- else }}{{ .Arch }}{{ end }}
31 | {{- if .Arm }}v{{ .Arm }}{{ end }}
32 | # use zip for windows archives
33 | format_overrides:
34 | - goos: windows
35 | format: zip
36 |
37 | changelog:
38 | sort: asc
39 | filters:
40 | exclude:
41 | - "^docs:"
42 | - "^test:"
43 | - "^chore:"
44 | - "^ci:"
45 |
46 | checksum:
47 | name_template: "checksums.txt"
48 |
49 | snapshot:
50 | name_template: "{{ incpatch .Version }}-next"
51 |
52 | brews:
53 | - name: moribito
54 | commit_author:
55 | name: ericschmar
56 | email: eric.schmar@gmail.com
57 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
58 | directory: Formula
59 | description: "LDAP CLI Explorer - Interactive terminal-based LDAP client with TUI"
60 | homepage: "https://github.com/ericschmar/moribito"
61 | license: "MIT"
62 | install: |
63 | bin.install "moribito"
64 | test: |
65 | system "#{bin}/moribito --version"
66 | # Repository to push the generated files to.
67 | repository:
68 | owner: ericschmar
69 | name: homebrew-tap
70 | branch: main
71 | token: "{{ .Env.HOMEBREW_GITHUB_API_TOKEN }}"
72 |
73 | release:
74 | github:
75 | owner: ericschmar
76 | name: moribito
77 | name_template: "Release {{.Tag}}"
78 | draft: false
79 | prerelease: auto
80 |
--------------------------------------------------------------------------------
/internal/tui/tree_highlighting_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ericschmar/moribito/internal/ldap"
7 | )
8 |
9 | func TestTreeView_HighlightingConsistency(t *testing.T) {
10 | // Test to verify the highlighting issue is fixed
11 | var client *ldap.Client
12 | tv := NewTreeView(client)
13 | tv.SetSize(80, 8) // Small height to force scrolling
14 |
15 | // Create mock tree data - enough to require scrolling
16 | tv.FlattenedTree = make([]*TreeItem, 20)
17 | for i := 0; i < 20; i++ {
18 | tv.FlattenedTree[i] = &TreeItem{
19 | Node: &ldap.TreeNode{
20 | DN: "test",
21 | Name: "test",
22 | Children: nil,
23 | IsLoaded: true,
24 | },
25 | Level: 0,
26 | IsLast: i == 19,
27 | }
28 | }
29 |
30 | // Test highlighting at different cursor positions to verify viewport consistency
31 | testCases := []struct {
32 | cursor int
33 | description string
34 | }{
35 | {0, "cursor at top"},
36 | {5, "cursor in middle of first view"},
37 | {10, "cursor requiring scroll"},
38 | {15, "cursor further down"},
39 | {19, "cursor at end"},
40 | }
41 |
42 | for _, tc := range testCases {
43 | t.Run(tc.description, func(t *testing.T) {
44 | // Set cursor position and adjust viewport
45 | tv.cursor = tc.cursor
46 | tv.adjustViewport()
47 |
48 | // Get content height consistently
49 | _, contentHeight := tv.container.GetContentDimensions()
50 | if tv.container == nil {
51 | contentHeight = tv.height
52 | }
53 |
54 | // Verify cursor is within visible range after viewport adjustment
55 | if tc.cursor < tv.viewport || tc.cursor >= tv.viewport+contentHeight {
56 | t.Errorf("Cursor %d should be visible within viewport %d to %d (content height %d)",
57 | tc.cursor, tv.viewport, tv.viewport+contentHeight-1, contentHeight)
58 | }
59 |
60 | // Verify viewport bounds are reasonable
61 | if tv.viewport < 0 {
62 | t.Errorf("Viewport should not be negative, got %d", tv.viewport)
63 | }
64 |
65 | if tv.viewport >= len(tv.FlattenedTree) {
66 | t.Errorf("Viewport %d should be less than tree length %d", tv.viewport, len(tv.FlattenedTree))
67 | }
68 | })
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | This is a Go based repository with a Ruby client for certain API endpoints. It is primarily responsible for ingesting metered usage for GitHub and recording that usage. Please follow these guidelines when contributing:
2 |
3 | ## Code Standards
4 |
5 | ### Required Before Each Commit
6 | - Run `make fmt` before committing any changes to ensure proper code formatting
7 | - This will run gofmt on all Go files to maintain consistent style
8 |
9 | ### Development Flow
10 | - Build: `make build`
11 | - Test: `make test`
12 | - Full CI check: `make ci` (includes build, fmt, lint, test)
13 |
14 | ## Repository Structure
15 | - `cmd/`: Main service entry points and executables
16 | - `internal/`: Logic related to interactions with other GitHub services
17 | - `lib/`: Core Go packages for billing logic
18 | - `admin/`: Admin interface components
19 | - `config/`: Configuration files and templates
20 | - `docs/`: Documentation
21 | - `proto/`: Protocol buffer definitions. Run `make proto` after making updates here.
22 | - `ruby/`: Ruby implementation components. Updates to this folder should include incrementing this version file using semantic versioning: `ruby/lib/billing-platform/version.rb`
23 | - `testing/`: Test helpers and fixtures
24 |
25 | ## Key Guidelines
26 | 1. Follow Go best practices and idiomatic patterns
27 | 2. Maintain existing code structure and organization
28 | 3. Use dependency injection patterns where appropriate
29 | 4. Write unit tests for new functionality. Use table-driven unit tests when possible.
30 | 5. Document public APIs and complex logic. Suggest changes to the `docs/` folder when appropriate
31 | 6. Follow semver semantics on commits
32 |
33 | Be extraordinarily skeptical of your own correctness or stated assumptions. You aren't a cynic, you are a highly critical thinker and this is tempered by your self-doubt: you absolutely hate being wrong but you live in constant fear of it
34 | When appropriate, broaden the scope of inquiry beyond the stated assumptions to think through unconvenitional opportunities, risks, and pattern-matching to widen the aperture of solutions
35 | Before calling anything "done" or "working", take a second look at it ("red team" it) to critically analyze that you really are done or it really is working
36 |
--------------------------------------------------------------------------------
/config/example.yaml:
--------------------------------------------------------------------------------
1 | # Moribito Configuration Example
2 | # Copy this file to one of the following locations:
3 | # - ./config.yaml (current directory)
4 | # - ~/.moribito.yaml (home directory)
5 | # - ~/.config/moribito/config.yaml (XDG config directory)
6 |
7 | ldap:
8 | # Default/current LDAP connection settings
9 | # These are used when no saved connections exist or when using legacy configs
10 | host: "ldap.example.com"
11 | port: 389 # Use 636 for LDAPS
12 | base_dn: "dc=example,dc=com"
13 |
14 | # Security settings
15 | use_ssl: false # Use LDAPS (port 636)
16 | use_tls: false # Use StartTLS (recommended for port 389)
17 |
18 | # Authentication (leave empty for anonymous bind)
19 | bind_user: "cn=admin,dc=example,dc=com"
20 | bind_pass: "your-password-here"
21 |
22 | # Alternative OU-based authentication examples:
23 | # bind_user: "uid=john,ou=users,dc=example,dc=com"
24 | # bind_user: "john@example.com" # For AD-style authentication
25 |
26 | # Multiple saved connections (new feature!)
27 | # You can save multiple LDAP connection profiles and switch between them
28 | selected_connection: 0 # Index of currently selected connection (-1 for default)
29 | saved_connections:
30 | - name: "Production"
31 | host: "ldap.prod.example.com"
32 | port: 636
33 | base_dn: "dc=prod,dc=example,dc=com"
34 | use_ssl: true
35 | use_tls: false
36 | bind_user: "cn=admin,dc=prod,dc=example,dc=com"
37 | bind_pass: "prod-password"
38 |
39 | - name: "Development"
40 | host: "ldap.dev.example.com"
41 | port: 389
42 | base_dn: "dc=dev,dc=example,dc=com"
43 | use_ssl: false
44 | use_tls: true
45 | bind_user: "cn=admin,dc=dev,dc=example,dc=com"
46 | bind_pass: "dev-password"
47 |
48 | - name: "Staging"
49 | host: "ldap.staging.example.com"
50 | port: 389
51 | base_dn: "dc=staging,dc=example,dc=com"
52 | use_ssl: false
53 | use_tls: true
54 | bind_user: "cn=admin,dc=staging,dc=example,dc=com"
55 | bind_pass: "" # Will prompt for password
56 |
57 | # Pagination settings for query results
58 | pagination:
59 | # Number of entries to load per page (default: 50)
60 | # Adjust this based on your server capabilities and performance needs
61 | page_size: 50
62 |
63 | # Retry settings for LDAP operations
64 | retry:
65 | enabled: true
66 | max_attempts: 3
67 | initial_delay_ms: 500
68 | max_delay_ms: 5000
--------------------------------------------------------------------------------
/scripts/README.md:
--------------------------------------------------------------------------------
1 | # Installation Scripts
2 |
3 | This directory contains platform-specific installation scripts for Moribito.
4 |
5 | ## Scripts
6 |
7 | ### `install.sh` - Linux/Unix Installation
8 | Installs Moribito on Linux and other Unix-like systems following XDG Base Directory Specification.
9 |
10 | **Usage:**
11 | ```bash
12 | curl -sSL https://raw.githubusercontent.com/ericschmar/moribito/main/scripts/install.sh | bash
13 |
14 | # Or for local installation:
15 | ./scripts/install.sh --local
16 | ```
17 |
18 | **Features:**
19 | - Downloads appropriate binary for system architecture (amd64/arm64)
20 | - Installs to `/usr/local/bin` (configurable via `INSTALL_DIR`)
21 | - Creates XDG-compliant config directory: `~/.config/moribito/`
22 | - Generates sample configuration file
23 |
24 | ### `install-macos.sh` - macOS Installation
25 | Installs Moribito on macOS with macOS-specific conventions.
26 |
27 | **Usage:**
28 | ```bash
29 | curl -sSL https://raw.githubusercontent.com/ericschmar/moribito/main/scripts/install-macos.sh | bash
30 |
31 | # Or for local installation:
32 | ./scripts/install-macos.sh --local
33 | ```
34 |
35 | **Features:**
36 | - Downloads appropriate binary for system architecture (Intel/Apple Silicon)
37 | - Installs to `/usr/local/bin` (configurable via `INSTALL_DIR`)
38 | - Creates macOS-style config directory: `~/.moribito/`
39 | - Provides Homebrew installation suggestions
40 | - Generates sample configuration file
41 |
42 | ### `install.ps1` - Windows Installation
43 | Installs Moribito on Windows using PowerShell.
44 |
45 | **Usage:**
46 | ```powershell
47 | # Run as Administrator
48 | irm https://raw.githubusercontent.com/ericschmar/moribito/main/scripts/install.ps1 | iex
49 |
50 | # Or for local installation:
51 | .\scripts\install.ps1 -Local
52 | ```
53 |
54 | **Features:**
55 | - Downloads Windows binary (amd64)
56 | - Installs to `%ProgramFiles%\moribito` (configurable via `-InstallDir`)
57 | - Adds installation directory to system PATH
58 | - Creates Windows-style config directory: `%APPDATA%\moribito\`
59 | - Generates sample configuration file
60 |
61 | ## Configuration Locations
62 |
63 | After installation, configuration files are created at:
64 |
65 | | Platform | Location |
66 | |----------|----------|
67 | | Linux | `~/.config/moribito/config.yaml` |
68 | | macOS | `~/.moribito/config.yaml` |
69 | | Windows | `%APPDATA%\moribito\config.yaml` |
70 |
71 | ## Manual Configuration
72 |
73 | You can also create configuration files manually using:
74 | ```bash
75 | moribito --create-config
76 | ```
77 |
78 | This will create the appropriate configuration file for your operating system.
--------------------------------------------------------------------------------
/internal/tui/update_check_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | )
9 |
10 | // TestCheckForUpdatesCmdDelay verifies that the update check includes a delay
11 | // to prevent blocking UI startup
12 | func TestCheckForUpdatesCmdDelay(t *testing.T) {
13 | // Create the update check command
14 | cmd := checkForUpdatesCmd()
15 |
16 | // Measure how long it takes for the command function to return
17 | start := time.Now()
18 |
19 | // Execute the command in a goroutine since it will block
20 | done := make(chan tea.Msg, 1)
21 | go func() {
22 | msg := cmd()
23 | done <- msg
24 | }()
25 |
26 | // Wait for the command to complete with a timeout
27 | select {
28 | case <-done:
29 | elapsed := time.Since(start)
30 | // The command should take at least 1.5 seconds due to the delay
31 | // Allow some margin for execution time
32 | if elapsed < 1400*time.Millisecond {
33 | t.Errorf("Update check returned too quickly (%v), expected at least 1400ms delay for background execution", elapsed)
34 | }
35 | // But shouldn't take too long (network call has 10s timeout, but usually much faster)
36 | if elapsed > 15*time.Second {
37 | t.Errorf("Update check took too long (%v), possible network issues or excessive delay", elapsed)
38 | }
39 | case <-time.After(20 * time.Second):
40 | t.Fatal("Update check timed out - this suggests the delay or network call is taking too long")
41 | }
42 | }
43 |
44 | // TestCheckForUpdatesCmdReturnsMessage verifies that the command returns a proper message
45 | func TestCheckForUpdatesCmdReturnsMessage(t *testing.T) {
46 | // Create the update check command
47 | cmd := checkForUpdatesCmd()
48 |
49 | // Execute the command in a goroutine
50 | done := make(chan tea.Msg, 1)
51 | go func() {
52 | msg := cmd()
53 | done <- msg
54 | }()
55 |
56 | // Wait for result
57 | select {
58 | case msg := <-done:
59 | // Should return an updateCheckMsg
60 | updateMsg, ok := msg.(updateCheckMsg)
61 | if !ok {
62 | t.Errorf("Expected updateCheckMsg, got %T", msg)
63 | return
64 | }
65 |
66 | // The message should have some content (either error or update info)
67 | // We can't predict the exact outcome since it depends on network/GitHub state
68 | // But we can verify the structure is correct
69 | t.Logf("Update check result: available=%v, version=%s, error=%v",
70 | updateMsg.available, updateMsg.version, updateMsg.err)
71 |
72 | // This is mainly a smoke test - as long as we get a proper updateCheckMsg, it's working
73 |
74 | case <-time.After(20 * time.Second):
75 | t.Fatal("Update check timed out")
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/internal/updater/updater_test.go:
--------------------------------------------------------------------------------
1 | package updater
2 |
3 | import (
4 | "context"
5 | "testing"
6 | )
7 |
8 | func TestNew(t *testing.T) {
9 | checker := New("ericschmar", "moribito")
10 | if checker == nil {
11 | t.Fatal("Expected non-nil checker")
12 | }
13 |
14 | if checker.owner != "ericschmar" {
15 | t.Errorf("Expected owner 'ericschmar', got '%s'", checker.owner)
16 | }
17 |
18 | if checker.repo != "moribito" {
19 | t.Errorf("Expected repo 'moribito', got '%s'", checker.repo)
20 | }
21 |
22 | if checker.client == nil {
23 | t.Error("Expected non-nil HTTP client")
24 | }
25 | }
26 |
27 | func TestIsNewerVersion(t *testing.T) {
28 | tests := []struct {
29 | name string
30 | latest string
31 | current string
32 | expected bool
33 | }{
34 | {
35 | name: "dev version should always need update",
36 | latest: "v0.0.1",
37 | current: "dev",
38 | expected: true,
39 | },
40 | {
41 | name: "same version should not need update",
42 | latest: "v0.0.1",
43 | current: "v0.0.1",
44 | expected: false,
45 | },
46 | {
47 | name: "newer version available",
48 | latest: "v0.1.0",
49 | current: "v0.0.1",
50 | expected: true,
51 | },
52 | {
53 | name: "current version is newer",
54 | latest: "v0.0.1",
55 | current: "v0.1.0",
56 | expected: false,
57 | },
58 | {
59 | name: "versions without v prefix",
60 | latest: "0.1.0",
61 | current: "0.0.1",
62 | expected: true,
63 | },
64 | }
65 |
66 | for _, tt := range tests {
67 | t.Run(tt.name, func(t *testing.T) {
68 | result := isNewerVersion(tt.latest, tt.current)
69 | if result != tt.expected {
70 | t.Errorf("isNewerVersion(%s, %s) = %v, want %v",
71 | tt.latest, tt.current, result, tt.expected)
72 | }
73 | })
74 | }
75 | }
76 |
77 | func TestCheckForUpdate_Integration(t *testing.T) {
78 | // This is an integration test - only run if we have network access
79 | // Skip in CI environments or if no network is available
80 | if testing.Short() {
81 | t.Skip("Skipping integration test in short mode")
82 | }
83 |
84 | checker := New("ericschmar", "moribito")
85 | ctx := context.Background()
86 |
87 | // Test with dev version - should always return update available
88 | release, err := checker.CheckForUpdate(ctx, "dev")
89 | if err != nil {
90 | t.Errorf("Unexpected error: %v", err)
91 | }
92 |
93 | if release == nil {
94 | t.Error("Expected update available for dev version")
95 | } else {
96 | if release.TagName == "" {
97 | t.Error("Expected non-empty tag name")
98 | }
99 | if release.Name == "" {
100 | t.Error("Expected non-empty name")
101 | }
102 | if release.URL == "" {
103 | t.Error("Expected non-empty URL")
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/internal/tui/tree_pagination_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 |
8 | zone "github.com/lrstanley/bubblezone"
9 |
10 | "github.com/ericschmar/moribito/internal/ldap"
11 | )
12 |
13 | func TestTreeViewPagination(t *testing.T) {
14 | // Initialize bubblezone for test environment
15 | zone.NewGlobal()
16 |
17 | // Create a tree view with many items
18 | var client *ldap.Client
19 | tv := NewTreeView(client)
20 | tv.SetSize(80, 10) // Small height to force pagination
21 |
22 | // Create mock tree items - more than can fit on screen
23 | tv.FlattenedTree = make([]*TreeItem, 20)
24 | for i := 0; i < 20; i++ {
25 | tv.FlattenedTree[i] = &TreeItem{
26 | Node: &ldap.TreeNode{
27 | DN: fmt.Sprintf("ou=test%d,dc=example,dc=com", i),
28 | Name: fmt.Sprintf("test%d", i),
29 | Children: nil,
30 | IsLoaded: true,
31 | },
32 | Level: 0,
33 | IsLast: i == 19,
34 | }
35 | }
36 |
37 | // Test that pagination info appears in view
38 | view := tv.View()
39 | if !strings.Contains(view, "Showing") || !strings.Contains(view, "entries") {
40 | t.Errorf("Expected pagination info in view, got: %s", view)
41 | }
42 |
43 | // Test that pagination shows correct range for first page
44 | if !strings.Contains(view, "Showing 1-") {
45 | t.Errorf("Expected to show from entry 1, got: %s", view)
46 | }
47 |
48 | // Test cursor movement and viewport adjustment
49 | tv.cursor = 15
50 | tv.adjustViewport()
51 | view2 := tv.View()
52 |
53 | // Should show a range that includes cursor position 15
54 | if !strings.Contains(view2, "entries") {
55 | t.Errorf("Expected pagination info after cursor movement, got: %s", view2)
56 | }
57 |
58 | t.Logf("Initial tree view with pagination:\n%s\n", view)
59 | t.Logf("After cursor movement:\n%s\n", view2)
60 | }
61 |
62 | func TestTreeViewNoPaginationWhenFewItems(t *testing.T) {
63 | // Initialize bubblezone for test environment
64 | zone.NewGlobal()
65 |
66 | // Create a tree view with few items
67 | var client *ldap.Client
68 | tv := NewTreeView(client)
69 | tv.SetSize(80, 20) // Large height, few items
70 |
71 | // Create few mock tree items - less than screen height
72 | tv.FlattenedTree = make([]*TreeItem, 5)
73 | for i := 0; i < 5; i++ {
74 | tv.FlattenedTree[i] = &TreeItem{
75 | Node: &ldap.TreeNode{
76 | DN: fmt.Sprintf("ou=test%d,dc=example,dc=com", i),
77 | Name: fmt.Sprintf("test%d", i),
78 | Children: nil,
79 | IsLoaded: true,
80 | },
81 | Level: 0,
82 | IsLast: i == 4,
83 | }
84 | }
85 |
86 | // Test that pagination info does NOT appear
87 | view := tv.View()
88 | if strings.Contains(view, "Showing") && strings.Contains(view, "entries") {
89 | t.Errorf("Expected NO pagination info when few items, got: %s", view)
90 | }
91 |
92 | t.Logf("Tree view without pagination:\n%s\n", view)
93 | }
94 |
--------------------------------------------------------------------------------
/internal/tui/tree_viewport_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ericschmar/moribito/internal/ldap"
7 | zone "github.com/lrstanley/bubblezone"
8 | )
9 |
10 | func TestTreeView_ViewportIntegration(t *testing.T) {
11 | var client *ldap.Client
12 | tv := NewTreeView(client)
13 |
14 | // Test viewport initialization
15 | if tv.viewport != 0 {
16 | t.Error("Viewport should be initialized to 0")
17 | }
18 |
19 | // Test SetSize configures dimensions
20 | tv.SetSize(80, 24)
21 | if tv.width != 80 {
22 | t.Errorf("Expected width 80, got %d", tv.width)
23 | }
24 | if tv.height != 24 {
25 | t.Errorf("Expected height 24, got %d", tv.height)
26 | }
27 |
28 | // Test that View() returns expected content when loading
29 | tv.loading = true
30 | view := tv.View()
31 | if view == "" {
32 | t.Error("View should return loading message when loading")
33 | }
34 |
35 | // Test that View() returns expected content when no entries
36 | tv.loading = false
37 | tv.FlattenedTree = nil
38 | view = tv.View()
39 | if view == "" {
40 | t.Error("View should return 'No entries found' message when no tree")
41 | }
42 | }
43 |
44 | func TestTreeView_ViewportContentUpdate(t *testing.T) {
45 | // Initialize bubblezone for test environment
46 | zone.NewGlobal()
47 |
48 | var client *ldap.Client
49 | tv := NewTreeView(client)
50 | tv.SetSize(80, 24)
51 |
52 | // Create some mock tree data
53 | tv.root = &ldap.TreeNode{
54 | DN: "dc=example,dc=com",
55 | Name: "example.com",
56 | Children: nil,
57 | IsLoaded: true,
58 | }
59 | tv.rebuildFlattenedTree()
60 |
61 | // Test that view content is updated
62 | view := tv.View()
63 | if view == "" {
64 | t.Error("View should have content after rebuilding tree")
65 | }
66 |
67 | // Test cursor movement updates viewport
68 | tv.cursor = 0
69 | tv.adjustViewport()
70 | // No errors should occur
71 | }
72 |
73 | func TestTreeView_CursorVisibility(t *testing.T) {
74 | var client *ldap.Client
75 | tv := NewTreeView(client)
76 | tv.SetSize(80, 10) // Small height to test scrolling
77 |
78 | // Create multiple mock tree items
79 | tv.FlattenedTree = make([]*TreeItem, 20)
80 | for i := 0; i < 20; i++ {
81 | tv.FlattenedTree[i] = &TreeItem{
82 | Node: &ldap.TreeNode{
83 | DN: "test",
84 | Name: "test",
85 | Children: nil,
86 | IsLoaded: true,
87 | },
88 | Level: 0,
89 | IsLast: i == 19,
90 | }
91 | }
92 |
93 | // Test cursor at top
94 | tv.cursor = 0
95 | tv.adjustViewport()
96 | if tv.viewport != 0 {
97 | t.Errorf("Expected viewport 0 when cursor at top, got %d", tv.viewport)
98 | }
99 |
100 | // Test cursor past visible area
101 | tv.cursor = 15
102 | tv.adjustViewport()
103 | if tv.viewport < 0 {
104 | t.Errorf("Viewport should not be negative, got %d", tv.viewport)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/internal/updater/updater.go:
--------------------------------------------------------------------------------
1 | package updater
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // GitHubRelease represents a GitHub release response
13 | type GitHubRelease struct {
14 | TagName string `json:"tag_name"`
15 | Name string `json:"name"`
16 | URL string `json:"html_url"`
17 | }
18 |
19 | // Checker handles update checking functionality
20 | type Checker struct {
21 | owner string
22 | repo string
23 | client *http.Client
24 | }
25 |
26 | // New creates a new update checker
27 | func New(owner, repo string) *Checker {
28 | return &Checker{
29 | owner: owner,
30 | repo: repo,
31 | client: &http.Client{
32 | Timeout: 10 * time.Second,
33 | },
34 | }
35 | }
36 |
37 | // CheckForUpdate checks if a new version is available
38 | func (c *Checker) CheckForUpdate(ctx context.Context, currentVersion string) (*GitHubRelease, error) {
39 | latestRelease, err := c.getLatestRelease(ctx)
40 | if err != nil {
41 | return nil, fmt.Errorf("failed to get latest release: %w", err)
42 | }
43 |
44 | // Compare versions - if current version is "dev", always show update available
45 | if currentVersion == "dev" || isNewerVersion(latestRelease.TagName, currentVersion) {
46 | return latestRelease, nil
47 | }
48 |
49 | return nil, nil // No update available
50 | }
51 |
52 | // getLatestRelease fetches the latest release from GitHub API
53 | func (c *Checker) getLatestRelease(ctx context.Context) (*GitHubRelease, error) {
54 | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", c.owner, c.repo)
55 |
56 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
57 | if err != nil {
58 | return nil, err
59 | }
60 |
61 | // Set User-Agent to avoid GitHub API rate limiting
62 | req.Header.Set("User-Agent", "moribito-updater")
63 |
64 | resp, err := c.client.Do(req)
65 | if err != nil {
66 | return nil, err
67 | }
68 | defer resp.Body.Close()
69 |
70 | if resp.StatusCode != http.StatusOK {
71 | return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
72 | }
73 |
74 | var release GitHubRelease
75 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
76 | return nil, fmt.Errorf("failed to decode response: %w", err)
77 | }
78 |
79 | return &release, nil
80 | }
81 |
82 | // isNewerVersion compares two version strings to determine if the first is newer
83 | // Simple implementation that works with semantic versioning (vX.Y.Z)
84 | func isNewerVersion(latest, current string) bool {
85 | // Remove 'v' prefix if present
86 | latest = strings.TrimPrefix(latest, "v")
87 | current = strings.TrimPrefix(current, "v")
88 |
89 | // If current is "dev", any release version is newer
90 | if current == "dev" {
91 | return true
92 | }
93 |
94 | // Simple string comparison for now - works for semantic versioning
95 | // In a production environment, you'd want to use a proper semver library
96 | return latest > current
97 | }
98 |
--------------------------------------------------------------------------------
/internal/tui/width_fix_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "github.com/ericschmar/moribito/internal/ldap"
5 | "strings"
6 | "testing"
7 | )
8 |
9 | // Test that demonstrates the fix for the query box formatting issue
10 | func TestQueryView_WidthCalculationFix(t *testing.T) {
11 | var client *ldap.Client
12 | qv := NewQueryView(client)
13 |
14 | // Set a specific size to test width calculations
15 | qv.SetSize(100, 30)
16 |
17 | // Verify that the container is set up properly
18 | if qv.container == nil {
19 | t.Fatal("Container should be initialized after SetSize")
20 | }
21 |
22 | // Get content dimensions to verify they're calculated correctly
23 | contentWidth, _ := qv.container.GetContentDimensions()
24 |
25 | // contentWidth should be less than total width due to padding
26 | if contentWidth >= 100 {
27 | t.Errorf("contentWidth (%d) should be less than total width (100) due to padding", contentWidth)
28 | }
29 |
30 | // Verify the contentWidth calculation matches expected (width - 2*padding = 100 - 2*1 = 98)
31 | expectedContentWidth := 98
32 | if contentWidth != expectedContentWidth {
33 | t.Errorf("Expected contentWidth to be %d, got %d", expectedContentWidth, contentWidth)
34 | }
35 | }
36 |
37 | // Test that the textarea height was increased as requested
38 | func TestQueryView_IncreasedTextareaHeight(t *testing.T) {
39 | var client *ldap.Client
40 | qv := NewQueryView(client)
41 |
42 | // Set size to initialize the textarea
43 | qv.SetSize(100, 30)
44 |
45 | // The textarea height should now be 6 (increased from 3)
46 | if qv.textarea.Height() != 6 {
47 | t.Errorf("Expected textarea height to be 6 (increased from 3), got %d", qv.textarea.Height())
48 | }
49 | }
50 |
51 | // Test that width calculation for truncation uses contentWidth consistently
52 | func TestQueryView_WidthCalculationLogic(t *testing.T) {
53 | var client *ldap.Client
54 | qv := NewQueryView(client)
55 |
56 | // Set size
57 | qv.SetSize(80, 25)
58 |
59 | // Get contentWidth to verify our calculations
60 | contentWidth, _ := qv.container.GetContentDimensions()
61 |
62 | // Test the truncation logic that we fixed
63 | testLine := "This is a very long line that should definitely be truncated because it exceeds the available width"
64 |
65 | var truncatedLine string
66 | if contentWidth > 5 && len(testLine) > contentWidth-2 {
67 | truncatedLine = testLine[:contentWidth-5] + "..."
68 | } else {
69 | truncatedLine = testLine
70 | }
71 |
72 | // Verify that the truncated line is within expected bounds
73 | maxExpectedLength := contentWidth
74 | if len(truncatedLine) > maxExpectedLength {
75 | t.Errorf("Truncated line length (%d) exceeds expected maximum (%d)", len(truncatedLine), maxExpectedLength)
76 | }
77 |
78 | // Verify that truncation actually occurred for a long line
79 | if len(testLine) > contentWidth-2 && !strings.HasSuffix(truncatedLine, "...") {
80 | t.Error("Long line should be truncated with '...'")
81 | }
82 |
83 | // Verify that the truncated line is shorter than the original
84 | if len(testLine) > contentWidth && len(truncatedLine) >= len(testLine) {
85 | t.Error("Truncated line should be shorter than original long line")
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/internal/updater/updater_mock_test.go:
--------------------------------------------------------------------------------
1 | package updater
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "strings"
8 | "testing"
9 | )
10 |
11 | // mockHTTPClient is a mock HTTP client for testing
12 | type mockHTTPClient struct {
13 | response string
14 | status int
15 | err error
16 | }
17 |
18 | func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
19 | if m.err != nil {
20 | return nil, m.err
21 | }
22 |
23 | return &http.Response{
24 | StatusCode: m.status,
25 | Body: io.NopCloser(strings.NewReader(m.response)),
26 | }, nil
27 | }
28 |
29 | func TestCheckForUpdate_MockSuccess(t *testing.T) {
30 | // Mock a successful GitHub API response
31 | mockResponse := `{
32 | "tag_name": "v0.1.0",
33 | "name": "Release v0.1.0",
34 | "html_url": "https://github.com/ericschmar/moribito/releases/tag/v0.1.0"
35 | }`
36 |
37 | checker := &Checker{
38 | owner: "ericschmar",
39 | repo: "moribito",
40 | client: &http.Client{
41 | Transport: &mockTransport{
42 | response: mockResponse,
43 | status: 200,
44 | },
45 | },
46 | }
47 |
48 | ctx := context.Background()
49 | release, err := checker.CheckForUpdate(ctx, "dev")
50 |
51 | if err != nil {
52 | t.Fatalf("Expected no error, got: %v", err)
53 | }
54 |
55 | if release == nil {
56 | t.Fatal("Expected update available for dev version")
57 | }
58 |
59 | if release.TagName != "v0.1.0" {
60 | t.Errorf("Expected tag_name 'v0.1.0', got '%s'", release.TagName)
61 | }
62 |
63 | if release.Name != "Release v0.1.0" {
64 | t.Errorf("Expected name 'Release v0.1.0', got '%s'", release.Name)
65 | }
66 |
67 | if !strings.Contains(release.URL, "releases/tag/v0.1.0") {
68 | t.Errorf("Expected URL to contain 'releases/tag/v0.1.0', got '%s'", release.URL)
69 | }
70 | }
71 |
72 | func TestCheckForUpdate_NoUpdateNeeded(t *testing.T) {
73 | // Mock a successful GitHub API response with same version
74 | mockResponse := `{
75 | "tag_name": "v0.1.0",
76 | "name": "Release v0.1.0",
77 | "html_url": "https://github.com/ericschmar/moribito/releases/tag/v0.1.0"
78 | }`
79 |
80 | checker := &Checker{
81 | owner: "ericschmar",
82 | repo: "moribito",
83 | client: &http.Client{
84 | Transport: &mockTransport{
85 | response: mockResponse,
86 | status: 200,
87 | },
88 | },
89 | }
90 |
91 | ctx := context.Background()
92 | release, err := checker.CheckForUpdate(ctx, "v0.1.0")
93 |
94 | if err != nil {
95 | t.Fatalf("Expected no error, got: %v", err)
96 | }
97 |
98 | if release != nil {
99 | t.Error("Expected no update available for same version")
100 | }
101 | }
102 |
103 | // mockTransport implements http.RoundTripper for testing
104 | type mockTransport struct {
105 | response string
106 | status int
107 | err error
108 | }
109 |
110 | func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
111 | if m.err != nil {
112 | return nil, m.err
113 | }
114 |
115 | return &http.Response{
116 | StatusCode: m.status,
117 | Body: io.NopCloser(strings.NewReader(m.response)),
118 | Request: req,
119 | }, nil
120 | }
121 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Documentation
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | paths:
7 | - 'docs/**'
8 | - 'docpress.json'
9 | - 'package.json'
10 | - '.github/workflows/docs.yml'
11 | workflow_dispatch:
12 |
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | concurrency:
19 | group: "pages"
20 | cancel-in-progress: false
21 |
22 | jobs:
23 | build:
24 | name: Build Documentation
25 | runs-on: ubuntu-latest
26 | steps:
27 | - uses: actions/checkout@v4
28 |
29 | - name: Setup Node.js
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: '18'
33 | cache: 'npm'
34 |
35 | - name: Install dependencies
36 | run: npm install
37 |
38 | - name: Build documentation
39 | run: make docs
40 |
41 | - name: Check build output and create index.html if needed
42 | run: |
43 | echo "Contents of _docpress directory:"
44 | find _docpress -type f | head -20
45 | echo "Looking for HTML files:"
46 | find . -name "*.html" -not -path "./node_modules/*" | head -10
47 |
48 | # If no index.html exists, create a simple one that loads the SPA
49 | if [ ! -f "_docpress/index.html" ]; then
50 | echo "Creating index.html for SPA-style DocPress deployment..."
51 | cat > _docpress/index.html << 'EOF'
52 |
53 |
54 |
55 |
56 | Moribito Documentation
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | EOF
66 | fi
67 |
68 | echo "Final _docpress contents:"
69 | find _docpress -type f | sort
70 |
71 | # Validate that we have the required files
72 | if [ ! -f "_docpress/index.html" ]; then
73 | echo "ERROR: index.html not found after build"
74 | exit 1
75 | fi
76 | if [ ! -f "_docpress/assets/style.css" ]; then
77 | echo "ERROR: style.css not found after build"
78 | exit 1
79 | fi
80 | if [ ! -f "_docpress/assets/script.js" ]; then
81 | echo "ERROR: script.js not found after build"
82 | exit 1
83 | fi
84 | echo "Build validation successful!"
85 |
86 | - name: Setup Pages
87 | uses: actions/configure-pages@v4
88 |
89 | - name: Upload artifact
90 | uses: actions/upload-pages-artifact@v3
91 | with:
92 | path: '_docpress'
93 |
94 | deploy:
95 | name: Deploy to GitHub Pages
96 | environment:
97 | name: github-pages
98 | url: ${{ steps.deployment.outputs.page_url }}
99 | runs-on: ubuntu-latest
100 | needs: build
101 | steps:
102 | - name: Deploy to GitHub Pages
103 | id: deployment
104 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/internal/tui/start_connect_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strings"
7 | "testing"
8 |
9 | tea "github.com/charmbracelet/bubbletea"
10 | "github.com/ericschmar/moribito/internal/config"
11 | )
12 |
13 | // TestStartView_ConnectButton tests that the Connect button works correctly
14 | func TestStartView_ConnectButton(t *testing.T) {
15 | cfg := config.Default()
16 | cfg.LDAP.Host = "ldap.example.com"
17 | cfg.LDAP.Port = 389
18 | cfg.LDAP.BaseDN = "dc=example,dc=com"
19 |
20 | tempDir := t.TempDir()
21 | configPath := filepath.Join(tempDir, "config.yaml")
22 |
23 | sv := NewStartViewWithConfigPath(cfg, configPath)
24 | sv.width = 100
25 | sv.height = 40
26 |
27 | // Navigate to the Connect button (it's the last field)
28 | sv.cursor = FieldConnect
29 |
30 | // Press enter to trigger connect
31 | model, cmd := sv.Update(tea.KeyMsg{Type: tea.KeyEnter})
32 |
33 | // Should return a command (the connection attempt)
34 | if cmd == nil {
35 | t.Error("Expected command to be returned when Connect is pressed, got nil")
36 | }
37 |
38 | // Model should still be StartView
39 | if _, ok := model.(*StartView); !ok {
40 | t.Errorf("Expected model to be *StartView, got %T", model)
41 | }
42 |
43 | // Check that config was saved
44 | if _, err := os.Stat(configPath); os.IsNotExist(err) {
45 | t.Error("Expected config file to be created when Connect is pressed")
46 | }
47 | }
48 |
49 | // TestStartView_ConnectWithoutRequiredFields tests error handling
50 | func TestStartView_ConnectWithoutRequiredFields(t *testing.T) {
51 | cfg := config.Default()
52 | // Empty host and BaseDN - should fail validation
53 | cfg.LDAP.Host = ""
54 | cfg.LDAP.BaseDN = ""
55 |
56 | sv := NewStartViewWithConfigPath(cfg, "/tmp/test-config.yaml")
57 | sv.width = 100
58 | sv.height = 40
59 |
60 | // Navigate to Connect button
61 | sv.cursor = FieldConnect
62 |
63 | // Press enter
64 | _, cmd := sv.Update(tea.KeyMsg{Type: tea.KeyEnter})
65 |
66 | // Should still return a command (error message)
67 | if cmd == nil {
68 | t.Error("Expected command to be returned even when validation fails")
69 | }
70 |
71 | // Execute the command to get the message
72 | if cmd != nil {
73 | msg := cmd()
74 | if statusMsg, ok := msg.(StatusMsg); ok {
75 | if !strings.Contains(statusMsg.Message, "Error") {
76 | t.Errorf("Expected error message, got: %s", statusMsg.Message)
77 | }
78 | }
79 | }
80 | }
81 |
82 | // TestStartView_ConnectButtonAvailable tests that Connect field exists
83 | func TestStartView_ConnectButtonAvailable(t *testing.T) {
84 | cfg := config.Default()
85 | cfg.LDAP.Host = "ldap.example.com"
86 | cfg.LDAP.Port = 389
87 | cfg.LDAP.BaseDN = "dc=example,dc=com"
88 |
89 | sv := NewStartViewWithConfigPath(cfg, "/tmp/test-config.yaml")
90 |
91 | // Check that FieldConnect exists and has correct value
92 | value := sv.getFieldValue(FieldConnect)
93 | if value != "Connect to LDAP" {
94 | t.Errorf("Expected FieldConnect value to be 'Connect to LDAP', got: %s", value)
95 | }
96 |
97 | // Verify Connect field is in bounds
98 | if FieldConnect >= FieldCount {
99 | t.Error("FieldConnect index is out of bounds")
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build test fmt lint clean run help install build-all docs docs-serve docs-clean
2 |
3 | # Version information
4 | VERSION ?= dev
5 | COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
6 | DATE ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S_UTC' 2>/dev/null || echo "unknown")
7 |
8 | # Build flags for version injection
9 | LDFLAGS = -ldflags "-X github.com/ericschmar/moribito/internal/version.Version=$(VERSION) -X github.com/ericschmar/moribito/internal/version.Commit=$(COMMIT) -X github.com/ericschmar/moribito/internal/version.Date=$(DATE)"
10 |
11 | # Build the application
12 | build: bin
13 | go build $(LDFLAGS) -o bin/moribito cmd/moribito/main.go
14 |
15 | # Run tests
16 | test:
17 | go test -v ./...
18 |
19 | # Format code
20 | fmt:
21 | gofmt -s -w .
22 | go mod tidy
23 |
24 | # Lint code (install golangci-lint if not present)
25 | lint:
26 | @which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
27 | golangci-lint run
28 |
29 | # Clean build artifacts
30 | clean:
31 | rm -rf bin/
32 | go clean
33 |
34 | # Clean documentation build artifacts
35 | docs-clean:
36 | rm -rf _docpress/
37 |
38 | # Run the application with example config
39 | run:
40 | go run cmd/moribito/main.go -config config/example.yaml
41 |
42 | # Install the application to GOPATH/bin
43 | install:
44 | go install $(LDFLAGS) cmd/moribito/main.go
45 |
46 | # Run full CI checks
47 | ci: fmt lint test build
48 |
49 | # Create binary directory
50 | bin:
51 | mkdir -p bin
52 |
53 | # Build for multiple platforms
54 | build-all: bin
55 | GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/moribito-linux-amd64 cmd/moribito/main.go
56 | GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/moribito-linux-arm64 cmd/moribito/main.go
57 | GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/moribito-darwin-amd64 cmd/moribito/main.go
58 | GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/moribito-darwin-arm64 cmd/moribito/main.go
59 | GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/moribito-windows-amd64.exe cmd/moribito/main.go
60 |
61 | # Build documentation website
62 | docs:
63 | @which npm > /dev/null || (echo "npm is required for documentation generation" && exit 1)
64 | @[ -d node_modules ] || npm install
65 | ./node_modules/.bin/docpress build
66 |
67 | # Serve documentation locally
68 | docs-serve:
69 | @which npm > /dev/null || (echo "npm is required for documentation generation" && exit 1)
70 | @[ -d node_modules ] || npm install
71 | ./node_modules/.bin/docpress serve
72 |
73 | # Show help
74 | help:
75 | @echo "Available commands:"
76 | @echo " build - Build the application"
77 | @echo " test - Run tests"
78 | @echo " fmt - Format code"
79 | @echo " lint - Lint code"
80 | @echo " clean - Clean build artifacts"
81 | @echo " run - Run with example config"
82 | @echo " install - Install to GOPATH/bin"
83 | @echo " ci - Run full CI checks (fmt, lint, test, build)"
84 | @echo " build-all - Build for multiple platforms"
85 | @echo " docs - Build documentation website"
86 | @echo " docs-serve - Serve documentation locally"
87 | @echo " docs-clean - Clean documentation build artifacts"
88 | @echo " help - Show this help"
--------------------------------------------------------------------------------
/internal/tui/model_connect_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/ericschmar/moribito/internal/config"
9 | )
10 |
11 | // TestModel_ConnectFlow tests the complete connection flow from StartView to Model
12 | func TestModel_ConnectFlow(t *testing.T) {
13 | cfg := config.Default()
14 | cfg.LDAP.Host = "ldap.example.com"
15 | cfg.LDAP.Port = 389
16 | cfg.LDAP.BaseDN = "dc=example,dc=com"
17 | cfg.LDAP.BindUser = "cn=admin,dc=example,dc=com"
18 | cfg.LDAP.BindPass = "password"
19 |
20 | m := NewModelWithUpdateCheckAndConfigPath(nil, cfg, false, "/tmp/test-config.yaml")
21 |
22 | // Initialize bubblezone
23 | m.Init()
24 |
25 | // Simulate window size
26 | _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
27 |
28 | // Navigate to Connect button in StartView
29 | m.startView.cursor = FieldConnect
30 |
31 | // Simulate pressing Enter on Connect button
32 | updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
33 |
34 | if cmd == nil {
35 | t.Fatal("Expected command to be returned when Connect is pressed")
36 | }
37 |
38 | // Execute the command to get the ConnectMsg
39 | msg := cmd()
40 |
41 | // Check the message type
42 | switch msg := msg.(type) {
43 | case ConnectMsg:
44 | t.Log("Received ConnectMsg - connection would succeed in real environment")
45 | case StatusMsg:
46 | // Check if it's an error or success
47 | if strings.Contains(msg.Message, "Error") || strings.Contains(msg.Message, "failed") {
48 | t.Logf("Received expected error for test LDAP server: %s", msg.Message)
49 | } else if strings.Contains(msg.Message, "timeout") {
50 | t.Logf("Received expected timeout for test LDAP server: %s", msg.Message)
51 | } else {
52 | t.Errorf("Unexpected status message: %s", msg.Message)
53 | }
54 | default:
55 | t.Errorf("Expected ConnectMsg or StatusMsg, got: %T", msg)
56 | }
57 |
58 | // Verify the model state
59 | resultModel := updatedModel.(*Model)
60 | if resultModel.currentView != ViewModeStart {
61 | t.Error("Should still be in Start view until connection succeeds")
62 | }
63 | }
64 |
65 | // TestModel_ConnectMsgHandling tests that Model properly handles ConnectMsg
66 | func TestModel_ConnectMsgHandling(t *testing.T) {
67 | cfg := config.Default()
68 | m := NewModelWithUpdateCheckAndConfigPath(nil, cfg, false, "/tmp/test-config.yaml")
69 |
70 | m.Init()
71 | _, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
72 |
73 | // Verify we start in StartView
74 | if m.currentView != ViewModeStart {
75 | t.Errorf("Expected to start in ViewModeStart, got: %v", m.currentView)
76 | }
77 |
78 | // Note: We can't actually create a real LDAP client in tests,
79 | // but we can verify the handler exists and would work
80 | // The handler is at model.go:299-323
81 |
82 | t.Log("ConnectMsg handler exists and is ready to process connections")
83 | }
84 |
85 | // TestModel_StatusMsgHandling tests that Model properly handles StatusMsg
86 | func TestModel_StatusMsgHandling(t *testing.T) {
87 | cfg := config.Default()
88 | m := NewModelWithUpdateCheckAndConfigPath(nil, cfg, false, "/tmp/test-config.yaml")
89 |
90 | m.Init()
91 |
92 | // Send a StatusMsg
93 | testMsg := StatusMsg{Message: "Test status message"}
94 | updatedModel, _ := m.Update(testMsg)
95 |
96 | resultModel := updatedModel.(*Model)
97 | if resultModel.statusMsg != "Test status message" {
98 | t.Errorf("Expected statusMsg to be set to 'Test status message', got: %s", resultModel.statusMsg)
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/internal/tui/tree_timer_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | "time"
7 |
8 | "github.com/ericschmar/moribito/internal/ldap"
9 | )
10 |
11 | func TestTreeView_LoadingTimer(t *testing.T) {
12 | // Create a new tree view
13 | var client *ldap.Client
14 | tv := NewTreeView(client)
15 | tv.SetSize(80, 24)
16 |
17 | // Test initial state - not loading
18 | if tv.loading {
19 | t.Error("TreeView should not be loading initially")
20 | }
21 |
22 | // Test timer message when not loading - should not continue timer
23 | tickMsg := LoadingTimerTickMsg{Time: time.Now()}
24 | _, cmd := tv.Update(tickMsg)
25 | if cmd != nil {
26 | t.Error("Timer tick should return nil command when not loading")
27 | }
28 | }
29 |
30 | func TestTreeView_LoadingTimerDisplay(t *testing.T) {
31 | // Create a new tree view
32 | var client *ldap.Client
33 | tv := NewTreeView(client)
34 | tv.SetSize(80, 24)
35 |
36 | // Set loading state manually to test display
37 | tv.loading = true
38 | tv.loadingStartTime = time.Now().Add(-2 * time.Second) // 2 seconds ago
39 | tv.loadingElapsed = 2 * time.Second
40 |
41 | // Get the view and check if it contains elapsed time
42 | view := tv.View()
43 | t.Logf("Loading view output:\n%s", view)
44 |
45 | if !strings.Contains(view, "Loading LDAP tree...") {
46 | t.Error("Loading view should contain loading message")
47 | }
48 | if !strings.Contains(view, "2.0s") {
49 | t.Errorf("Loading view should contain elapsed time '2.0s', got: %s", view)
50 | }
51 | }
52 |
53 | func TestTreeView_TimerTickMessage(t *testing.T) {
54 | // Create a new tree view
55 | var client *ldap.Client
56 | tv := NewTreeView(client)
57 | tv.SetSize(80, 24)
58 |
59 | // Set loading state
60 | tv.loading = true
61 | startTime := time.Now().Add(-1 * time.Second) // 1 second ago
62 | tv.loadingStartTime = startTime
63 |
64 | // Send a timer tick message
65 | tickTime := time.Now()
66 | tickMsg := LoadingTimerTickMsg{Time: tickTime}
67 |
68 | _, cmd := tv.Update(tickMsg)
69 |
70 | // Should continue the timer when loading
71 | if cmd == nil {
72 | t.Error("Timer tick should return a command to continue ticking when loading")
73 | }
74 |
75 | // Check that elapsed time was updated
76 | expectedElapsed := tickTime.Sub(startTime)
77 | if tv.loadingElapsed < expectedElapsed-10*time.Millisecond ||
78 | tv.loadingElapsed > expectedElapsed+10*time.Millisecond {
79 | t.Errorf("Elapsed time should be approximately %v, got %v", expectedElapsed, tv.loadingElapsed)
80 | }
81 | }
82 |
83 | func TestTreeView_LoadingTimerDisplayFormats(t *testing.T) {
84 | // Create a new tree view
85 | var client *ldap.Client
86 | tv := NewTreeView(client)
87 | tv.SetSize(80, 24)
88 |
89 | testCases := []struct {
90 | name string
91 | elapsed time.Duration
92 | expected string
93 | }{
94 | {"0.0 seconds", 0 * time.Millisecond, "(0.0s)"},
95 | {"0.1 seconds", 100 * time.Millisecond, "(0.1s)"},
96 | {"0.5 seconds", 500 * time.Millisecond, "(0.5s)"},
97 | {"1.0 seconds", 1000 * time.Millisecond, "(1.0s)"},
98 | {"1.2 seconds", 1200 * time.Millisecond, "(1.2s)"},
99 | {"10.0 seconds", 10000 * time.Millisecond, "(10.0s)"},
100 | {"59.9 seconds", 59900 * time.Millisecond, "(59.9s)"},
101 | }
102 |
103 | for _, tc := range testCases {
104 | t.Run(tc.name, func(t *testing.T) {
105 | tv.loading = true
106 | tv.loadingStartTime = time.Now().Add(-tc.elapsed)
107 | tv.loadingElapsed = tc.elapsed
108 |
109 | view := tv.View()
110 | if !strings.Contains(view, tc.expected) {
111 | t.Errorf("Expected view to contain %s, got: %s", tc.expected, view)
112 | }
113 |
114 | t.Logf("Display for %s: 'Loading LDAP tree... %s'", tc.name, tc.expected)
115 | })
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/internal/tui/start_config_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "testing"
7 |
8 | "github.com/ericschmar/moribito/internal/config"
9 | )
10 |
11 | func TestStartView_SaveConfigToDisk(t *testing.T) {
12 | // Create a temporary config file
13 | tempDir, err := os.MkdirTemp("", "moribito-test")
14 | if err != nil {
15 | t.Fatalf("Failed to create temp dir: %v", err)
16 | }
17 | defer os.RemoveAll(tempDir)
18 |
19 | configPath := filepath.Join(tempDir, "test-config.yaml")
20 |
21 | // Create initial config
22 | cfg := config.Default()
23 | cfg.LDAP.Host = "initial.example.com"
24 |
25 | // Save initial config
26 | if err := cfg.Save(configPath); err != nil {
27 | t.Fatalf("Failed to save initial config: %v", err)
28 | }
29 |
30 | // Create StartView with config path
31 | sv := NewStartViewWithConfigPath(cfg, configPath)
32 |
33 | // Simulate editing the host field
34 | sv.editing = true
35 | sv.editingField = FieldHost
36 | sv.textInput.SetValue("modified.example.com")
37 |
38 | // Call saveValue (which should save to disk)
39 | sv.saveValue()
40 |
41 | // Verify the config was saved to disk by reloading
42 | reloadedCfg, _, err := config.Load(configPath)
43 | if err != nil {
44 | t.Fatalf("Failed to reload config: %v", err)
45 | }
46 |
47 | if reloadedCfg.LDAP.Host != "modified.example.com" {
48 | t.Errorf("Expected host to be 'modified.example.com', got '%s'", reloadedCfg.LDAP.Host)
49 | }
50 | }
51 |
52 | func TestStartView_BackwardCompatibilityWithoutConfigPath(t *testing.T) {
53 | // Create StartView without config path (old style)
54 | cfg := config.Default()
55 | cfg.LDAP.Host = "test.example.com"
56 |
57 | sv := NewStartView(cfg) // Old constructor without config path
58 |
59 | // Simulate editing
60 | sv.editing = true
61 | sv.editingField = FieldHost
62 | sv.textInput.SetValue("modified.example.com")
63 |
64 | // Call saveValue - should not panic and should update in-memory config
65 | sv.saveValue()
66 |
67 | // Verify in-memory config was updated
68 | if sv.config.LDAP.Host != "modified.example.com" {
69 | t.Errorf("Expected host to be 'modified.example.com', got '%s'", sv.config.LDAP.Host)
70 | }
71 | }
72 |
73 | func TestStartView_ConnectionManagementSaving(t *testing.T) {
74 | // Create a temporary config file
75 | tempDir, err := os.MkdirTemp("", "moribito-test")
76 | if err != nil {
77 | t.Fatalf("Failed to create temp dir: %v", err)
78 | }
79 | defer os.RemoveAll(tempDir)
80 |
81 | configPath := filepath.Join(tempDir, "test-config.yaml")
82 |
83 | // Create initial config
84 | cfg := config.Default()
85 | cfg.LDAP.Host = "test.example.com"
86 |
87 | // Save initial config
88 | if err := cfg.Save(configPath); err != nil {
89 | t.Fatalf("Failed to save initial config: %v", err)
90 | }
91 |
92 | // Create StartView with config path
93 | sv := NewStartViewWithConfigPath(cfg, configPath)
94 |
95 | // Simulate adding a new connection manually
96 | newConn := config.SavedConnection{
97 | Name: "Test Connection",
98 | Host: cfg.LDAP.Host,
99 | Port: cfg.LDAP.Port,
100 | BaseDN: cfg.LDAP.BaseDN,
101 | UseSSL: cfg.LDAP.UseSSL,
102 | UseTLS: cfg.LDAP.UseTLS,
103 | BindUser: cfg.LDAP.BindUser,
104 | BindPass: cfg.LDAP.BindPass,
105 | }
106 | sv.config.AddSavedConnection(newConn)
107 | sv.config.SetActiveConnection(0)
108 |
109 | // Call saveConfigToDisk to simulate what would happen in the dialog
110 | sv.saveConfigToDisk()
111 |
112 | // Verify the config was saved to disk by reloading
113 | reloadedCfg, _, err := config.Load(configPath)
114 | if err != nil {
115 | t.Fatalf("Failed to reload config: %v", err)
116 | }
117 |
118 | if len(reloadedCfg.LDAP.SavedConnections) != 1 {
119 | t.Errorf("Expected 1 saved connection, got %d", len(reloadedCfg.LDAP.SavedConnections))
120 | }
121 |
122 | if reloadedCfg.LDAP.SavedConnections[0].Name != "Test Connection" {
123 | t.Errorf("Expected connection name 'Test Connection', got '%s'", reloadedCfg.LDAP.SavedConnections[0].Name)
124 | }
125 | }
126 |
127 | // Test helper - remove the keyMsg type since it's not needed
--------------------------------------------------------------------------------
/internal/tui/viewport_height_fix_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/ericschmar/moribito/internal/ldap"
8 | zone "github.com/lrstanley/bubblezone"
9 | )
10 |
11 | // TestQueryViewHeightConstraint tests that QueryView respects height constraints
12 | // This test verifies the fix for the tab bar cutoff issue
13 | func TestQueryViewHeightConstraint(t *testing.T) {
14 | // Initialize bubblezone for testing
15 | zone.NewGlobal()
16 |
17 | var client *ldap.Client
18 | qv := NewQueryView(client)
19 |
20 | // Test different height constraints
21 | testCases := []struct {
22 | name string
23 | width int
24 | height int
25 | }{
26 | {"Small viewport", 80, 10},
27 | {"Medium viewport", 100, 15},
28 | {"Large viewport", 120, 25},
29 | {"Very large viewport", 150, 35},
30 | }
31 |
32 | for _, tc := range testCases {
33 | t.Run(tc.name, func(t *testing.T) {
34 | qv.SetSize(tc.width, tc.height)
35 |
36 | // Get the rendered view
37 | view := qv.View()
38 | lines := strings.Split(view, "\n")
39 |
40 | // The view should never exceed the allocated height
41 | if len(lines) > tc.height {
42 | t.Errorf("QueryView exceeded height constraint: got %d lines, max allowed %d",
43 | len(lines), tc.height)
44 | t.Logf("This would cause the tab bar to be cut off!")
45 |
46 | // Log the content for debugging
47 | t.Logf("View content (first 10 lines):")
48 | for i := 0; i < min(10, len(lines)); i++ {
49 | t.Logf(" %d: %s", i+1, lines[i])
50 | }
51 | }
52 | })
53 | }
54 | }
55 |
56 | // TestTreeViewHeightConstraint tests that TreeView respects height constraints
57 | func TestTreeViewHeightConstraint(t *testing.T) {
58 | // Initialize bubblezone for testing
59 | zone.NewGlobal()
60 |
61 | var client *ldap.Client
62 | tv := NewTreeView(client)
63 |
64 | // Create mock tree data that would potentially overflow
65 | tv.FlattenedTree = make([]*TreeItem, 50)
66 | for i := 0; i < 50; i++ {
67 | tv.FlattenedTree[i] = &TreeItem{
68 | Node: &ldap.TreeNode{
69 | DN: "ou=test,dc=example,dc=com",
70 | Name: "Test Entry",
71 | Children: nil,
72 | IsLoaded: true,
73 | },
74 | Level: 0,
75 | IsLast: i == 49,
76 | }
77 | }
78 |
79 | // Test different height constraints
80 | testCases := []struct {
81 | name string
82 | width int
83 | height int
84 | }{
85 | {"Small viewport", 80, 10},
86 | {"Medium viewport", 100, 15},
87 | {"Large viewport", 120, 25},
88 | {"Very large viewport", 150, 35},
89 | }
90 |
91 | for _, tc := range testCases {
92 | t.Run(tc.name, func(t *testing.T) {
93 | tv.SetSize(tc.width, tc.height)
94 |
95 | // Get the rendered view
96 | view := tv.View()
97 | lines := strings.Split(view, "\n")
98 |
99 | // The view should never exceed the allocated height
100 | if len(lines) > tc.height {
101 | t.Errorf("TreeView exceeded height constraint: got %d lines, max allowed %d",
102 | len(lines), tc.height)
103 | t.Logf("This would cause the tab bar to be cut off!")
104 | }
105 | })
106 | }
107 | }
108 |
109 | // TestQueryViewWithResults tests QueryView with simulated results
110 | func TestQueryViewWithResults(t *testing.T) {
111 | // Initialize bubblezone for testing
112 | zone.NewGlobal()
113 |
114 | var client *ldap.Client
115 | qv := NewQueryView(client)
116 | qv.SetSize(120, 25)
117 |
118 | // Simulate having results by directly setting the ResultLines
119 | // This bypasses the need for an actual LDAP connection
120 | mockResultLines := make([]string, 30)
121 | for i := 0; i < 30; i++ {
122 | mockResultLines[i] = "DN: cn=user" + string(rune(i+'0')) + ",ou=people,dc=example,dc=com"
123 | }
124 | qv.ResultLines = mockResultLines
125 |
126 | // Simulate having actual results (private field access via reflection would be complex,
127 | // so we'll just test the basic case here)
128 | view := qv.View()
129 | lines := strings.Split(view, "\n")
130 |
131 | if len(lines) > 25 {
132 | t.Errorf("QueryView with results exceeded height constraint: got %d lines, max allowed 25", len(lines))
133 | t.Logf("This confirms the tab bar cutoff issue would occur with results")
134 | }
135 | }
136 |
137 | func min(a, b int) int {
138 | if a < b {
139 | return a
140 | }
141 | return b
142 | }
143 |
--------------------------------------------------------------------------------
/internal/tui/start_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/charmbracelet/bubbletea"
8 | "github.com/ericschmar/moribito/internal/config"
9 | zone "github.com/lrstanley/bubblezone"
10 | )
11 |
12 | func TestStartView_TextInputIntegration(t *testing.T) {
13 | cfg := &config.Config{
14 | LDAP: config.LDAPConfig{
15 | Host: "localhost",
16 | Port: 389,
17 | },
18 | Pagination: config.PaginationConfig{
19 | PageSize: 50,
20 | },
21 | }
22 |
23 | sv := NewStartView(cfg)
24 | sv.editing = true
25 | sv.editingField = FieldHost
26 | sv.textInput.SetValue("ldap.example.com")
27 |
28 | // Call saveValue to ensure textInput value is saved
29 | sv.saveValue()
30 |
31 | expected := "ldap.example.com"
32 | if sv.config.LDAP.Host != expected {
33 | t.Errorf("Expected host to be '%s' after save, got '%s'", expected, sv.config.LDAP.Host)
34 | }
35 | }
36 |
37 | func TestStartView_NewConnectionDialog(t *testing.T) {
38 | cfg := &config.Config{
39 | LDAP: config.LDAPConfig{
40 | Host: "localhost",
41 | Port: 389,
42 | },
43 | }
44 |
45 | sv := NewStartView(cfg)
46 | sv.showNewConnectionDialog = true
47 | sv.newConnInput.SetValue("test-connection")
48 |
49 | // Simulate pressing enter to create connection
50 | keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
51 | _, _ = sv.handleNewConnectionDialog(keyMsg)
52 |
53 | // Verify connection was created
54 | if len(sv.config.LDAP.SavedConnections) != 1 {
55 | t.Errorf("Expected 1 saved connection, got %d", len(sv.config.LDAP.SavedConnections))
56 | }
57 |
58 | if sv.config.LDAP.SavedConnections[0].Name != "test-connection" {
59 | t.Errorf("Expected connection name 'test-connection', got '%s'", sv.config.LDAP.SavedConnections[0].Name)
60 | }
61 | }
62 |
63 | func TestStartView_BooleanToggle(t *testing.T) {
64 | cfg := &config.Config{
65 | LDAP: config.LDAPConfig{
66 | Host: "localhost",
67 | Port: 389,
68 | UseSSL: false,
69 | },
70 | }
71 |
72 | sv := NewStartView(cfg)
73 | sv.editing = true
74 | sv.editingField = FieldUseSSL
75 |
76 | // Test toggling with space key
77 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}
78 | _, _ = sv.handleEditMode(keyMsg)
79 |
80 | if !sv.config.LDAP.UseSSL {
81 | t.Error("Expected UseSSL to be toggled to true")
82 | }
83 |
84 | // Toggle back
85 | _, _ = sv.handleEditMode(keyMsg)
86 | if sv.config.LDAP.UseSSL {
87 | t.Error("Expected UseSSL to be toggled back to false")
88 | }
89 | }
90 |
91 | func TestStartView_LayoutAndAlignment(t *testing.T) {
92 | // Initialize bubblezone for tests
93 | zone.NewGlobal()
94 |
95 | cfg := &config.Config{
96 | LDAP: config.LDAPConfig{
97 | Host: "ldap.example.com",
98 | Port: 636,
99 | BaseDN: "dc=example,dc=com",
100 | UseSSL: true,
101 | UseTLS: false,
102 | BindUser: "cn=admin,dc=example,dc=com",
103 | BindPass: "secretpassword",
104 | },
105 | Pagination: config.PaginationConfig{
106 | PageSize: 100,
107 | },
108 | }
109 |
110 | sv := NewStartView(cfg)
111 | sv.SetSize(120, 25)
112 |
113 | // Test that all fields are present in the output
114 | output := sv.View()
115 |
116 | // Should contain all field names (including new connection management fields)
117 | expectedFields := []string{
118 | "Connection Management", "Save", "Delete",
119 | "Host:", "Port:", "Base DN:", "Use SSL:", "Use TLS:",
120 | "Bind User:", "Bind Password:", "Page Size:",
121 | }
122 |
123 | for _, field := range expectedFields {
124 | if !strings.Contains(output, field) {
125 | t.Errorf("Expected field '%s' to be present in output", field)
126 | }
127 | }
128 |
129 | // Should contain the configuration values
130 | expectedValues := []string{
131 | "ldap.example.com", "636", "dc=example,dc=com",
132 | "true", "false", "cn=admin,dc=example,dc=com", "********", "100",
133 | }
134 |
135 | for _, value := range expectedValues {
136 | if !strings.Contains(output, value) {
137 | t.Errorf("Expected value '%s' to be present in output", value)
138 | }
139 | }
140 |
141 | // Check that title is present
142 | if !strings.Contains(output, "Configure your LDAP connection settings") {
143 | t.Error("Expected configuration title to be present in output")
144 | }
145 |
146 | // Check that instructions are present
147 | if !strings.Contains(output, "navigate") {
148 | t.Error("Expected navigation instructions to be present in output")
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/internal/tui/tree_click_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | "github.com/ericschmar/moribito/internal/config"
8 | "github.com/ericschmar/moribito/internal/ldap"
9 | zone "github.com/lrstanley/bubblezone"
10 | )
11 |
12 | func TestTreeView_ClickNavigation(t *testing.T) {
13 | // Initialize bubblezone for tests
14 | zone.NewGlobal()
15 |
16 | var client *ldap.Client
17 | cfg := &config.Config{} // Empty config for test
18 | model := NewModel(client, cfg)
19 |
20 | // Create a tree view with mock data
21 | tv := NewTreeView(client)
22 | tv.SetSize(80, 24)
23 |
24 | // Create mock tree data
25 | tv.FlattenedTree = make([]*TreeItem, 5)
26 | for i := 0; i < 5; i++ {
27 | tv.FlattenedTree[i] = &TreeItem{
28 | Node: &ldap.TreeNode{
29 | DN: "cn=user" + string(rune(i+'0')) + ",dc=example,dc=com",
30 | Name: "user" + string(rune(i+'0')),
31 | Children: nil,
32 | IsLoaded: true,
33 | },
34 | Level: 0,
35 | IsLast: i == 4,
36 | }
37 | }
38 |
39 | model.tree = tv
40 | model.currentView = ViewModeTree
41 | model.SetSize(80, 24)
42 |
43 | // Generate the view to create the zones
44 | view := model.View()
45 | if view == "" {
46 | t.Fatal("View should not be empty")
47 | }
48 |
49 | // Test clicking on tree item 2
50 | zoneID := "tree-item-2"
51 |
52 | // Simulate zone click
53 | newModel, cmd := model.handleTreeViewClick(zoneID)
54 | if newModel == nil {
55 | t.Fatal("Expected model to be returned")
56 | }
57 |
58 | // Update model from returned model
59 | updatedModel := newModel.(*Model)
60 |
61 | // Check that cursor moved to clicked item
62 | if updatedModel.tree.cursor != 2 {
63 | t.Errorf("Expected cursor to be at position 2, got %d", updatedModel.tree.cursor)
64 | }
65 |
66 | // Check that a command was returned (should simulate Enter key)
67 | if cmd == nil {
68 | t.Error("Expected a command to be returned for node interaction")
69 | }
70 |
71 | // Test that invalid zone IDs are handled gracefully
72 | invalidModel, invalidCmd := model.handleTreeViewClick("invalid-zone")
73 | if invalidModel != model {
74 | t.Error("Expected original model to be returned for invalid zone")
75 | }
76 | if invalidCmd != nil {
77 | t.Error("Expected no command for invalid zone")
78 | }
79 |
80 | // Test out of bounds index
81 | outOfBoundsModel, outOfBoundsCmd := model.handleTreeViewClick("tree-item-10")
82 | if outOfBoundsModel != model {
83 | t.Error("Expected original model to be returned for out of bounds index")
84 | }
85 | if outOfBoundsCmd != nil {
86 | t.Error("Expected no command for out of bounds index")
87 | }
88 | }
89 |
90 | func TestTreeView_ClickWithMouseEvent(t *testing.T) {
91 | // Initialize bubblezone for tests
92 | zone.NewGlobal()
93 |
94 | var client *ldap.Client
95 | cfg := &config.Config{}
96 | model := NewModel(client, cfg)
97 |
98 | // Create tree view with mock data
99 | tv := NewTreeView(client)
100 | tv.SetSize(80, 24)
101 | tv.FlattenedTree = make([]*TreeItem, 3)
102 | for i := 0; i < 3; i++ {
103 | tv.FlattenedTree[i] = &TreeItem{
104 | Node: &ldap.TreeNode{
105 | DN: "ou=group" + string(rune(i+'0')) + ",dc=test,dc=com",
106 | Name: "group" + string(rune(i+'0')),
107 | Children: nil,
108 | IsLoaded: false,
109 | },
110 | Level: 0,
111 | IsLast: i == 2,
112 | }
113 | }
114 |
115 | model.tree = tv
116 | model.currentView = ViewModeTree
117 | model.SetSize(80, 24)
118 |
119 | // Generate the view to create zones
120 | view := model.View()
121 | if view == "" {
122 | t.Fatal("View should not be empty")
123 | }
124 |
125 | // Create a mock mouse event for testing zone bounds
126 | mouseEvent := tea.MouseMsg{
127 | X: 10,
128 | Y: 5, // Should be within tree content area
129 | Type: tea.MouseLeft,
130 | Button: tea.MouseButtonLeft,
131 | }
132 |
133 | // Create zone message
134 | zoneMsg := zone.MsgZoneInBounds{
135 | Event: mouseEvent,
136 | }
137 |
138 | // Test zone message handling
139 | newModel, cmd := model.handleZoneMessage(zoneMsg)
140 |
141 | // Since we can't easily mock the exact zone bounds without complex setup,
142 | // we just verify the handler doesn't crash and returns a valid model
143 | if newModel == nil {
144 | t.Fatal("Expected model to be returned from zone message handler")
145 | }
146 |
147 | // The command could be nil if no zone was actually clicked, which is fine for this test
148 | _ = cmd
149 | }
150 |
--------------------------------------------------------------------------------
/internal/tui/start_error_handling_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "testing"
7 | "time"
8 |
9 | "github.com/ericschmar/moribito/internal/config"
10 | )
11 |
12 | // TestStartView_ErrorHandlingOnSave tests that save errors are captured and displayed
13 | func TestStartView_ErrorHandlingOnSave(t *testing.T) {
14 | cfg := config.Default()
15 |
16 | // Create a StartView with an invalid path (directory that doesn't exist and can't be created)
17 | invalidPath := "/root/nonexistent/path/that/cannot/be/created/config.yaml"
18 | sv := NewStartViewWithConfigPath(cfg, invalidPath)
19 |
20 | // Try to save config
21 | sv.saveConfigToDisk()
22 |
23 | // Verify error was captured
24 | if sv.saveError == nil {
25 | t.Error("Expected save error to be set when saving to invalid path")
26 | }
27 |
28 | // Verify error time was set
29 | if sv.saveErrorTime.IsZero() {
30 | t.Error("Expected saveErrorTime to be set when error occurs")
31 | }
32 |
33 | // Verify error is recent (within last second)
34 | if time.Since(sv.saveErrorTime) > time.Second {
35 | t.Error("Expected saveErrorTime to be recent")
36 | }
37 | }
38 |
39 | // TestStartView_ErrorClearedOnSuccessfulSave tests that errors are cleared on successful save
40 | func TestStartView_ErrorClearedOnSuccessfulSave(t *testing.T) {
41 | cfg := config.Default()
42 |
43 | // Create temp directory for test
44 | tempDir := t.TempDir()
45 | configPath := filepath.Join(tempDir, "config.yaml")
46 |
47 | sv := NewStartViewWithConfigPath(cfg, configPath)
48 |
49 | // Set a fake error
50 | sv.saveError = os.ErrPermission
51 | sv.saveErrorTime = time.Now()
52 |
53 | // Save config successfully
54 | sv.saveConfigToDisk()
55 |
56 | // Verify error was cleared
57 | if sv.saveError != nil {
58 | t.Errorf("Expected error to be cleared after successful save, got: %v", sv.saveError)
59 | }
60 |
61 | // Verify error time was cleared
62 | if !sv.saveErrorTime.IsZero() {
63 | t.Error("Expected saveErrorTime to be cleared after successful save")
64 | }
65 | }
66 |
67 | // TestStartView_NoConfigPathError tests that missing config path is reported
68 | func TestStartView_NoConfigPathError(t *testing.T) {
69 | cfg := config.Default()
70 |
71 | // Create StartView with empty config path
72 | sv := &StartView{
73 | config: cfg,
74 | configPath: "",
75 | cursor: 0,
76 | }
77 |
78 | // Try to save
79 | sv.saveConfigToDisk()
80 |
81 | // Verify error was set
82 | if sv.saveError == nil {
83 | t.Error("Expected error when config path is empty")
84 | }
85 |
86 | // Verify error message mentions config path
87 | if sv.saveError != nil && sv.saveError.Error() != "no config file path set - changes will not persist" {
88 | t.Errorf("Expected error about missing config path, got: %v", sv.saveError)
89 | }
90 | }
91 |
92 | // TestConfig_ValidateAndRepair tests that config validation detects and repairs issues
93 | func TestConfig_ValidateAndRepair(t *testing.T) {
94 | cfg := config.Default()
95 |
96 | // Add some saved connections
97 | cfg.LDAP.SavedConnections = []config.SavedConnection{
98 | {Name: "Conn1", Host: "host1", Port: 389},
99 | {Name: "Conn2", Host: "host2", Port: 389},
100 | }
101 |
102 | // Set invalid connection index
103 | cfg.LDAP.SelectedConnection = 5
104 |
105 | // Validate and repair
106 | warnings := cfg.ValidateAndRepair()
107 |
108 | // Should have warnings
109 | if len(warnings) == 0 {
110 | t.Error("Expected warnings about invalid connection index")
111 | }
112 |
113 | // Index should be repaired to 0
114 | if cfg.LDAP.SelectedConnection != 0 {
115 | t.Errorf("Expected connection index to be repaired to 0, got %d", cfg.LDAP.SelectedConnection)
116 | }
117 | }
118 |
119 | // TestStartView_ConfigWarningsDisplayed tests that config warnings are captured on initialization
120 | func TestStartView_ConfigWarningsDisplayed(t *testing.T) {
121 | cfg := config.Default()
122 |
123 | // Add some saved connections
124 | cfg.LDAP.SavedConnections = []config.SavedConnection{
125 | {Name: "Conn1", Host: "host1", Port: 389},
126 | {Name: "Conn2", Host: "host2", Port: 389},
127 | }
128 |
129 | // Set invalid connection index
130 | cfg.LDAP.SelectedConnection = 10
131 |
132 | // Create StartView (should validate and capture warnings)
133 | sv := NewStartViewWithConfigPath(cfg, "/tmp/config.yaml")
134 |
135 | // Verify warnings were captured
136 | if len(sv.configWarnings) == 0 {
137 | t.Error("Expected config warnings to be captured on initialization")
138 | }
139 |
140 | // Verify warning time was set
141 | if sv.configWarningsTime.IsZero() {
142 | t.Error("Expected configWarningsTime to be set when warnings exist")
143 | }
144 |
145 | // Verify config was repaired
146 | if cfg.LDAP.SelectedConnection != 0 {
147 | t.Errorf("Expected connection index to be repaired, got %d", cfg.LDAP.SelectedConnection)
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/internal/tui/viewport_consistency_test.go.bak:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/charmbracelet/bubbletea"
7 | "github.com/ericschmar/ldap-cli/internal/ldap"
8 | )
9 |
10 | func TestViewportConsistency(t *testing.T) {
11 | t.Skip("Skipping viewport consistency test - methods not available in current QueryView")
12 | }
13 | var client *ldap.Client
14 |
15 | // Test TreeView
16 | tv := NewTreeView(client)
17 | tv.SetSize(80, 24)
18 |
19 | if tv.width != 80 || tv.height != 24 {
20 | t.Errorf("TreeView dimensions not set correctly: got %dx%d, want 80x24", tv.width, tv.height)
21 | }
22 |
23 | // Create mock tree data for testing scrolling
24 | tv.FlattenedTree = make([]*TreeItem, 50) // More items than can fit on screen
25 | for i := 0; i < 50; i++ {
26 | tv.FlattenedTree[i] = &TreeItem{
27 | Node: &ldap.TreeNode{
28 | DN: "ou=test" + string(rune(i+'0')) + ",dc=example,dc=com",
29 | Name: "test" + string(rune(i+'0')),
30 | Children: nil,
31 | IsLoaded: true,
32 | },
33 | Level: 0,
34 | IsLast: i == 49,
35 | }
36 | }
37 |
38 | // Test viewport adjustment at top
39 | tv.cursor = 0
40 | tv.adjustViewport()
41 | if tv.viewport != 0 {
42 | t.Errorf("Viewport should be 0 when cursor at top, got %d", tv.viewport)
43 | }
44 |
45 | // Test viewport adjustment when cursor moves beyond visible area
46 | tv.cursor = 30 // Move cursor beyond visible area (assuming 24 lines visible)
47 | tv.adjustViewport()
48 | expectedViewport := tv.cursor - tv.height + 1
49 | if expectedViewport < 0 {
50 | expectedViewport = 0
51 | }
52 | if tv.viewport < 0 || tv.viewport > tv.cursor {
53 | t.Errorf("Viewport should keep cursor visible, cursor=%d, viewport=%d, height=%d", tv.cursor, tv.viewport, tv.height)
54 | }
55 |
56 | // Test QueryView
57 | qv := NewQueryView(client)
58 | qv.SetSize(80, 24)
59 |
60 | if qv.width != 80 || qv.height != 24 {
61 | t.Errorf("QueryView dimensions not set correctly: got %dx%d, want 80x24", qv.width, qv.height)
62 | }
63 |
64 | // Create mock result data
65 | qv.results = make([]*ldap.Entry, 30)
66 | for i := 0; i < 30; i++ {
67 | qv.results[i] = &ldap.Entry{
68 | DN: "cn=user" + string(rune(i+'0')) + ",ou=users,dc=example,dc=com",
69 | Attributes: map[string][]string{
70 | "cn": {"user" + string(rune(i+'0'))},
71 | "mail": {"user" + string(rune(i+'0')) + "@example.com"},
72 | },
73 | }
74 | }
75 | qv.buildResultLines()
76 |
77 | // Test viewport adjustment
78 | qv.cursor = 0
79 | qv.adjustViewport()
80 | if qv.viewport != 0 {
81 | t.Errorf("QueryView viewport should be 0 when cursor at top, got %d", qv.viewport)
82 | }
83 |
84 | // Test cursor beyond visible area
85 | qv.cursor = len(qv.ResultLines) - 1
86 | qv.adjustViewport()
87 | if qv.viewport < 0 {
88 | t.Errorf("QueryView viewport should not be negative, got %d", qv.viewport)
89 | }
90 | }
91 |
92 | func TestViewportScrolling(t *testing.T) {
93 | var client *ldap.Client
94 | tv := NewTreeView(client)
95 | tv.SetSize(80, 10) // Small height for easier testing
96 |
97 | // Create tree items
98 | tv.FlattenedTree = make([]*TreeItem, 20)
99 | for i := 0; i < 20; i++ {
100 | tv.FlattenedTree[i] = &TreeItem{
101 | Node: &ldap.TreeNode{
102 | DN: "test",
103 | Name: "test",
104 | Children: nil,
105 | IsLoaded: true,
106 | },
107 | Level: 0,
108 | IsLast: i == 19,
109 | }
110 | }
111 |
112 | // Test scrolling down
113 | tv.cursor = 0
114 | tv.adjustViewport()
115 | initialViewport := tv.viewport
116 |
117 | // Move cursor to middle
118 | tv.cursor = 5
119 | tv.adjustViewport()
120 | // Viewport should still be 0 since cursor is still visible
121 | if tv.viewport != initialViewport {
122 | t.Errorf("Viewport changed unnecessarily when cursor moved to %d: viewport %d -> %d", tv.cursor, initialViewport, tv.viewport)
123 | }
124 |
125 | // Move cursor past visible area
126 | tv.cursor = 15
127 | tv.adjustViewport()
128 | // Viewport should adjust to keep cursor visible
129 | if tv.viewport == initialViewport {
130 | t.Errorf("Viewport should have adjusted when cursor moved beyond visible area (cursor=%d, viewport=%d)", tv.cursor, tv.viewport)
131 | }
132 |
133 | // Test scrolling up
134 | tv.cursor = 5
135 | tv.adjustViewport()
136 | if tv.cursor < tv.viewport {
137 | t.Errorf("Cursor should be visible after scrolling up: cursor=%d, viewport=%d", tv.cursor, tv.viewport)
138 | }
139 | }
140 |
141 | func TestQueryViewNavigation(t *testing.T) {
142 | t.Skip("Skipping query view navigation test - methods not available in current QueryView")
143 | }
144 | qv := NewQueryView(client)
145 | qv.SetSize(80, 20)
146 |
147 | // Create test data
148 | qv.results = make([]*ldap.Entry, 5)
149 | for i := 0; i < 5; i++ {
150 | qv.results[i] = &ldap.Entry{
151 | DN: "cn=user" + string(rune(i+'0')) + ",dc=example,dc=com",
152 | Attributes: map[string][]string{
153 | "cn": {"user" + string(rune(i+'0'))},
154 | },
155 | }
156 | }
157 | qv.buildResultLines()
158 | qv.inputMode = false // Set to browse mode
159 |
160 | // Test navigation
161 | initialCursor := qv.cursor
162 |
163 | // Test down navigation
164 | downMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}
165 | _, _ = qv.Update(downMsg)
166 |
167 | if qv.cursor != initialCursor+1 {
168 | t.Errorf("Down navigation failed: cursor should be %d, got %d", initialCursor+1, qv.cursor)
169 | }
170 |
171 | // Test up navigation
172 | upMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}
173 | _, _ = qv.Update(upMsg)
174 |
175 | if qv.cursor != initialCursor {
176 | t.Errorf("Up navigation failed: cursor should be %d, got %d", initialCursor, qv.cursor)
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/internal/tui/view_helpers.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/charmbracelet/lipgloss"
7 | )
8 |
9 | // ViewContainer provides consistent sizing and basic rendering for all views
10 | type ViewContainer struct {
11 | width int
12 | height int
13 | padding int
14 | }
15 |
16 | // NewViewContainer creates a new view container with consistent sizing
17 | func NewViewContainer(width, height int) *ViewContainer {
18 | return &ViewContainer{
19 | width: width,
20 | height: height,
21 | padding: 1, // Consistent horizontal padding for all views
22 | }
23 | }
24 |
25 | // RenderWithPadding renders content with minimal processing, letting child components handle their own styling
26 | func (vc *ViewContainer) RenderWithPadding(content string) string {
27 | // For properly styled content (like our refactored StartView),
28 | // we should avoid aggressive processing and let LipGloss handle the layout
29 |
30 | // Only apply container styling for content that doesn't have proper styling already
31 | if vc.shouldApplyContainerStyling(content) {
32 | style := lipgloss.NewStyle().
33 | MaxWidth(vc.width).
34 | MaxHeight(vc.height).
35 | Padding(0, vc.padding)
36 | content = style.Render(content)
37 | }
38 |
39 | // CRITICAL: Enforce height constraints AFTER styling to prevent content from pushing UI elements off screen
40 | // This accounts for borders, padding, and other styling that lipgloss adds
41 | lines := strings.Split(content, "\n")
42 |
43 | // Enforce maximum height to prevent UI overflow
44 | if len(lines) > vc.height {
45 | lines = lines[:vc.height]
46 | // Add truncation indicator if content was cut off
47 | if vc.height > 0 {
48 | lastLine := lines[vc.height-1]
49 | // Find the actual content in the line (skip ANSI codes and box drawing)
50 | visibleLen := lipgloss.Width(lastLine)
51 | if visibleLen > 3 {
52 | // Truncate visible content and add ellipsis
53 | lines[vc.height-1] = truncateWithEllipsis(lastLine, visibleLen-3)
54 | } else {
55 | lines[vc.height-1] = "..."
56 | }
57 | }
58 | }
59 |
60 | // Return the height-enforced content
61 | return strings.Join(lines, "\n")
62 | }
63 |
64 | // truncateWithEllipsis truncates a line to the specified visible width and adds ellipsis
65 | func truncateWithEllipsis(line string, targetWidth int) string {
66 | // Simple truncation - for styled text, we keep the beginning which usually has the ANSI codes
67 | currentWidth := lipgloss.Width(line)
68 | if currentWidth <= targetWidth+3 {
69 | return line + "..."
70 | }
71 |
72 | // Truncate by removing characters from the end until we fit
73 | runes := []rune(line)
74 | for len(runes) > 0 && lipgloss.Width(string(runes)) > targetWidth {
75 | runes = runes[:len(runes)-1]
76 | }
77 | return string(runes) + "..."
78 | }
79 |
80 | // shouldApplyContainerStyling determines if the container should apply additional styling
81 | func (vc *ViewContainer) shouldApplyContainerStyling(content string) bool {
82 | // If content already has borders (indicated by box drawing characters),
83 | // don't apply additional container styling
84 | boxDrawingChars := "┌┐└┘─│┬┴├┤┼╭╮╰╯"
85 | for _, char := range boxDrawingChars {
86 | if strings.ContainsRune(content, char) {
87 | return false
88 | }
89 | }
90 |
91 | // Also check for rounded border characters
92 | roundedBorderChars := "╭╮╰╯"
93 | for _, char := range roundedBorderChars {
94 | if strings.ContainsRune(content, char) {
95 | return false
96 | }
97 | }
98 |
99 | return true
100 | }
101 |
102 | // RenderCentered renders content centered in the view
103 | func (vc *ViewContainer) RenderCentered(content string) string {
104 | style := lipgloss.NewStyle().
105 | Width(vc.width).
106 | MaxHeight(vc.height). // Use MaxHeight instead of Height
107 | AlignHorizontal(lipgloss.Center).
108 | AlignVertical(lipgloss.Center).
109 | Padding(0, vc.padding)
110 |
111 | return style.Render(content)
112 | }
113 |
114 | // RenderPlain renders content with basic truncation for simple views
115 | func (vc *ViewContainer) RenderPlain(content string) string {
116 | lines := strings.Split(content, "\n")
117 | contentWidth := vc.width - (vc.padding * 2)
118 | if contentWidth < 1 {
119 | contentWidth = 1
120 | }
121 |
122 | var processedLines []string
123 | for _, line := range lines {
124 | if len(line) <= contentWidth {
125 | processedLines = append(processedLines, line)
126 | } else {
127 | // Only truncate for plain content
128 | processedLines = append(processedLines, line[:contentWidth-3]+"...")
129 | }
130 | }
131 |
132 | // Enforce height limit for plain content
133 | if len(processedLines) > vc.height {
134 | processedLines = processedLines[:vc.height]
135 | }
136 |
137 | finalContent := strings.Join(processedLines, "\n")
138 |
139 | style := lipgloss.NewStyle().
140 | MaxWidth(vc.width).
141 | MaxHeight(vc.height).
142 | Padding(0, vc.padding)
143 |
144 | return style.Render(finalContent)
145 | }
146 |
147 | // GetContentDimensions returns the available content area dimensions
148 | func (vc *ViewContainer) GetContentDimensions() (width, height int) {
149 | contentWidth := vc.width - (vc.padding * 2)
150 | if contentWidth < 1 {
151 | contentWidth = 1
152 | }
153 | return contentWidth, vc.height
154 | }
155 |
156 | // GetGradientColor returns a blue-to-teal gradient color based on position
157 | // position should be 0-1 where 0 is pure blue and 1 is pure teal
158 | func GetGradientColor(position float64) string {
159 | // Clamp position between 0 and 1
160 | if position < 0 {
161 | position = 0
162 | }
163 | if position > 1 {
164 | position = 1
165 | }
166 |
167 | // Define blue-to-teal gradient color progression
168 | // Using hex colors that transition from blue (#0066CC) to teal (#008080)
169 | colors := []string{
170 | "#0066CC", // Blue
171 | "#0066B8", // Blue-teal 1
172 | "#0066A4", // Blue-teal 2
173 | "#006690", // Blue-teal 3
174 | "#00667C", // Blue-teal 4
175 | "#006668", // Blue-teal 5
176 | "#006654", // Blue-teal 6
177 | "#008080", // Teal
178 | }
179 |
180 | // Calculate which color to use based on position
181 | index := int(position * float64(len(colors)-1))
182 | if index >= len(colors) {
183 | index = len(colors) - 1
184 | }
185 |
186 | return colors[index]
187 | }
188 |
--------------------------------------------------------------------------------
/cmd/moribito/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 |
8 | "github.com/charmbracelet/bubbletea"
9 |
10 | "github.com/ericschmar/moribito/internal/config"
11 | "github.com/ericschmar/moribito/internal/ldap"
12 | "github.com/ericschmar/moribito/internal/tui"
13 | "github.com/ericschmar/moribito/internal/version"
14 | )
15 |
16 | func main() {
17 | var (
18 | configPath = flag.String("config", "", "Path to configuration file")
19 | host = flag.String("host", "", "LDAP server host")
20 | port = flag.Int("port", 0, "LDAP server port")
21 | baseDN = flag.String("base-dn", "", "Base DN for LDAP operations")
22 | useSSL = flag.Bool("ssl", false, "Use SSL/LDAPS")
23 | useTLS = flag.Bool("tls", false, "Use StartTLS")
24 | bindUser = flag.String("user", "", "Bind user DN")
25 | bindPass = flag.String("password", "", "Bind password")
26 | pageSize = flag.Uint("page-size", 0, "Number of entries per page (0 for default)")
27 | help = flag.Bool("help", false, "Show help")
28 | showVersion = flag.Bool("version", false, "Show version information")
29 | checkUpdates = flag.Bool("check-updates", false, "Enable automatic update checking")
30 | createConfig = flag.Bool("create-config", false, "Create default configuration file in OS-appropriate location")
31 | )
32 |
33 | flag.Parse()
34 |
35 | if *showVersion {
36 | fmt.Println(version.Get().String())
37 | return
38 | }
39 |
40 | if *help {
41 | printHelp()
42 | return
43 | }
44 |
45 | if *createConfig {
46 | if err := config.CreateDefaultConfig(); err != nil {
47 | log.Fatalf("Failed to create config: %v", err)
48 | }
49 | fmt.Printf("Configuration file created at: %s\n", config.GetDefaultConfigPath())
50 | fmt.Println("Please edit the file with your LDAP server details.")
51 | return
52 | }
53 |
54 | // Load configuration
55 | var cfg *config.Config
56 | var err error
57 | var actualConfigPath string
58 |
59 | if *configPath != "" || (*host == "" && *baseDN == "") {
60 | // Try to load from config file
61 | cfg, actualConfigPath, err = config.Load(*configPath)
62 | if err != nil {
63 | if *configPath != "" {
64 | log.Fatalf("Failed to load config file: %v", err)
65 | }
66 | // No config file specified and none found, use defaults
67 | cfg = config.Default()
68 | actualConfigPath = config.GetDefaultConfigPath()
69 | }
70 | } else {
71 | // Use command line arguments
72 | cfg = config.Default()
73 | actualConfigPath = config.GetDefaultConfigPath()
74 | }
75 |
76 | // Override config with command line arguments if provided
77 | if *host != "" {
78 | cfg.LDAP.Host = *host
79 | }
80 | if *port != 0 {
81 | cfg.LDAP.Port = *port
82 | }
83 | if *baseDN != "" {
84 | cfg.LDAP.BaseDN = *baseDN
85 | }
86 | if *useSSL {
87 | cfg.LDAP.UseSSL = true
88 | }
89 | if *useTLS {
90 | cfg.LDAP.UseTLS = true
91 | }
92 | if *bindUser != "" {
93 | cfg.LDAP.BindUser = *bindUser
94 | }
95 | if *bindPass != "" {
96 | cfg.LDAP.BindPass = *bindPass
97 | }
98 | if *pageSize != 0 {
99 | cfg.Pagination.PageSize = uint32(*pageSize)
100 | }
101 |
102 | // Get the active connection for validation display
103 | activeConn := cfg.GetActiveConnection()
104 |
105 | // Note: Password prompting is now handled in the start view when connecting
106 |
107 | // Validate configuration (but allow for start page testing)
108 | if activeConn.Host == "" || activeConn.BaseDN == "" {
109 | fmt.Println("Warning: LDAP host and/or Base DN not configured.")
110 | fmt.Println("You can configure these in the start page.")
111 | }
112 |
113 | // Skip immediate LDAP connection - user will connect from start view
114 | var client *ldap.Client = nil
115 | fmt.Println("Starting in configuration mode - use the start screen to connect to LDAP...")
116 |
117 | // Create and run the TUI
118 | model := tui.NewModelWithUpdateCheckAndConfigPath(client, cfg, *checkUpdates, actualConfigPath)
119 | program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
120 |
121 | if _, err := program.Run(); err != nil {
122 | log.Fatalf("Error running program: %v", err)
123 | }
124 | }
125 |
126 | func printHelp() {
127 | fmt.Println("Moribito - Interactive LDAP Explorer")
128 | fmt.Println()
129 | fmt.Println("Usage:")
130 | fmt.Println(" moribito [options]")
131 | fmt.Println()
132 | fmt.Println("Options:")
133 | fmt.Println(" -config string Path to configuration file")
134 | fmt.Println(" -host string LDAP server host")
135 | fmt.Println(" -port int LDAP server port (default: 389 for LDAP, 636 for LDAPS)")
136 | fmt.Println(" -base-dn string Base DN for LDAP operations")
137 | fmt.Println(" -ssl Use SSL/LDAPS")
138 | fmt.Println(" -tls Use StartTLS")
139 | fmt.Println(" -user string Bind user DN")
140 | fmt.Println(" -password string Bind password (will prompt if user provided but password not)")
141 | fmt.Println(" -page-size int Number of entries per page for paginated queries (default: 50)")
142 | fmt.Println(" -check-updates Enable automatic update checking")
143 | fmt.Println(" -create-config Create default configuration file in OS-appropriate location")
144 | fmt.Println(" -version Show version information")
145 | fmt.Println(" -help Show this help message")
146 | fmt.Println()
147 | fmt.Println("Configuration file example:")
148 | fmt.Println(" ldap:")
149 | fmt.Println(" host: ldap.example.com")
150 | fmt.Println(" port: 389")
151 | fmt.Println(" base_dn: dc=example,dc=com")
152 | fmt.Println(" use_ssl: false")
153 | fmt.Println(" use_tls: false")
154 | fmt.Println(" bind_user: cn=admin,dc=example,dc=com")
155 | fmt.Println(" bind_pass: password")
156 | fmt.Println(" pagination:")
157 | fmt.Println(" page_size: 50")
158 | fmt.Println(" retry:")
159 | fmt.Println(" enabled: true")
160 | fmt.Println(" max_attempts: 3")
161 | fmt.Println(" initial_delay_ms: 500")
162 | fmt.Println(" max_delay_ms: 5000")
163 | fmt.Println()
164 | fmt.Println("Navigation:")
165 | fmt.Println(" Tab - Switch between views")
166 | fmt.Println(" 1/2/3 - Switch directly to Tree/Record/Query view")
167 | fmt.Println(" ↑/↓ - Navigate up/down")
168 | fmt.Println(" →/← - Expand/collapse in tree view")
169 | fmt.Println(" Enter - Select/view record")
170 | fmt.Println(" q - Quit")
171 | }
172 |
--------------------------------------------------------------------------------
/scripts/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Moribito Installation Script for Linux/Unix
4 | # This script sets up the configuration directory according to XDG Base Directory Specification
5 |
6 | set -e
7 |
8 | PROGRAM_NAME="moribito"
9 | CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/$PROGRAM_NAME"
10 | CONFIG_FILE="$CONFIG_DIR/config.yaml"
11 | BINARY_NAME="moribito"
12 | INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
13 |
14 | # Colors for output
15 | RED='\033[0;31m'
16 | GREEN='\033[0;32m'
17 | YELLOW='\033[1;33m'
18 | BLUE='\033[0;34m'
19 | NC='\033[0m' # No Color
20 |
21 | # Print colored output
22 | print_info() {
23 | echo -e "${BLUE}[INFO]${NC} $1"
24 | }
25 |
26 | print_success() {
27 | echo -e "${GREEN}[SUCCESS]${NC} $1"
28 | }
29 |
30 | print_warning() {
31 | echo -e "${YELLOW}[WARNING]${NC} $1"
32 | }
33 |
34 | print_error() {
35 | echo -e "${RED}[ERROR]${NC} $1"
36 | }
37 |
38 | # Detect architecture
39 | detect_arch() {
40 | local arch
41 | arch=$(uname -m)
42 | case $arch in
43 | x86_64|amd64) echo "amd64" ;;
44 | aarch64|arm64) echo "arm64" ;;
45 | *)
46 | print_error "Unsupported architecture: $arch"
47 | exit 1
48 | ;;
49 | esac
50 | }
51 |
52 | # Get latest release version from GitHub
53 | get_latest_version() {
54 | local version
55 | version=$(curl -s https://api.github.com/repos/ericschmar/moribito/releases/latest | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/')
56 | if [ -z "$version" ]; then
57 | print_error "Failed to get latest version"
58 | exit 1
59 | fi
60 | echo "$version"
61 | }
62 |
63 | # Download and install binary
64 | install_binary() {
65 | local version="$1"
66 | local arch="$2"
67 | local binary_url="https://github.com/ericschmar/moribito/releases/download/$version/${BINARY_NAME}-linux-$arch"
68 | local temp_file="/tmp/${BINARY_NAME}-$arch"
69 |
70 | print_info "Downloading $PROGRAM_NAME $version for linux-$arch..."
71 |
72 | if ! curl -L "$binary_url" -o "$temp_file"; then
73 | print_error "Failed to download binary"
74 | exit 1
75 | fi
76 |
77 | chmod +x "$temp_file"
78 |
79 | print_info "Installing binary to $INSTALL_DIR..."
80 |
81 | # Check if we need sudo for installation
82 | if [ ! -w "$INSTALL_DIR" ]; then
83 | if command -v sudo >/dev/null 2>&1; then
84 | sudo mv "$temp_file" "$INSTALL_DIR/$BINARY_NAME"
85 | else
86 | print_error "Need write permission to $INSTALL_DIR and sudo not available"
87 | exit 1
88 | fi
89 | else
90 | mv "$temp_file" "$INSTALL_DIR/$BINARY_NAME"
91 | fi
92 |
93 | print_success "Binary installed to $INSTALL_DIR/$BINARY_NAME"
94 | }
95 |
96 | # Create configuration directory and sample config
97 | setup_config() {
98 | print_info "Setting up configuration directory at $CONFIG_DIR..."
99 |
100 | # Create config directory
101 | mkdir -p "$CONFIG_DIR"
102 |
103 | # Create sample config file if it doesn't exist
104 | if [ ! -f "$CONFIG_FILE" ]; then
105 | cat > "$CONFIG_FILE" << 'EOF'
106 | # Moribito Configuration
107 | # XDG-compliant configuration for Linux
108 |
109 | ldap:
110 | # LDAP server connection settings
111 | host: "ldap.example.com"
112 | port: 389 # Use 636 for LDAPS
113 | base_dn: "dc=example,dc=com"
114 |
115 | # Security settings
116 | use_ssl: false # Use LDAPS (port 636)
117 | use_tls: false # Use StartTLS (recommended for port 389)
118 |
119 | # Authentication (leave empty for anonymous bind)
120 | bind_user: "cn=admin,dc=example,dc=com"
121 | bind_pass: "your-password-here"
122 |
123 | # Multiple saved connections (optional)
124 | # Uncomment and configure to save multiple connection profiles:
125 | # selected_connection: 0
126 | # saved_connections:
127 | # - name: "Production"
128 | # host: "ldap.prod.example.com"
129 | # port: 636
130 | # base_dn: "dc=prod,dc=example,dc=com"
131 | # use_ssl: true
132 | # bind_user: "cn=admin,dc=prod,dc=example,dc=com"
133 | # bind_pass: "prod-password"
134 |
135 | # Pagination settings for query results
136 | pagination:
137 | # Number of entries to load per page (default: 50)
138 | page_size: 50
139 |
140 | # Retry settings for LDAP operations
141 | retry:
142 | enabled: true
143 | max_attempts: 3
144 | initial_delay_ms: 500
145 | max_delay_ms: 5000
146 | EOF
147 | print_success "Sample configuration created at $CONFIG_FILE"
148 | print_info "Please edit $CONFIG_FILE with your LDAP server details"
149 | else
150 | print_warning "Configuration file already exists at $CONFIG_FILE"
151 | fi
152 | }
153 |
154 | # Main installation function
155 | main() {
156 | print_info "Installing $PROGRAM_NAME for Linux..."
157 |
158 | # Check if we're installing from a local binary or downloading
159 | if [ "$1" = "--local" ] && [ -f "./bin/${BINARY_NAME}" ]; then
160 | print_info "Installing from local binary..."
161 | if [ ! -w "$INSTALL_DIR" ]; then
162 | sudo cp "./bin/${BINARY_NAME}" "$INSTALL_DIR/${BINARY_NAME}"
163 | else
164 | cp "./bin/${BINARY_NAME}" "$INSTALL_DIR/${BINARY_NAME}"
165 | fi
166 | chmod +x "$INSTALL_DIR/${BINARY_NAME}"
167 | print_success "Local binary installed to $INSTALL_DIR/$BINARY_NAME"
168 | else
169 | # Download and install from GitHub releases
170 | local arch
171 | local version
172 |
173 | arch=$(detect_arch)
174 | version=$(get_latest_version)
175 |
176 | install_binary "$version" "$arch"
177 | fi
178 |
179 | # Setup configuration
180 | setup_config
181 |
182 | print_success "Installation completed successfully!"
183 | print_info ""
184 | print_info "Next steps:"
185 | print_info "1. Edit your configuration: $CONFIG_FILE"
186 | print_info "2. Run the application: $BINARY_NAME"
187 | print_info "3. Or run with specific config: $BINARY_NAME -config $CONFIG_FILE"
188 | print_info ""
189 | print_info "Configuration will be automatically detected from:"
190 | print_info " - $CONFIG_FILE"
191 | print_info " - ~/.moribito.yaml"
192 | print_info " - ./config.yaml"
193 | }
194 |
195 | # Show help
196 | show_help() {
197 | cat << EOF
198 | Moribito Installation Script for Linux/Unix
199 |
200 | Usage:
201 | $0 [options]
202 |
203 | Options:
204 | --local Install from local binary (./bin/moribito)
205 | --help Show this help message
206 |
207 | Environment Variables:
208 | INSTALL_DIR Installation directory (default: /usr/local/bin)
209 | XDG_CONFIG_HOME XDG config directory (default: ~/.config)
210 |
211 | Examples:
212 | $0 # Download and install latest release
213 | $0 --local # Install from local build
214 | INSTALL_DIR=~/bin $0 # Install to custom directory
215 | EOF
216 | }
217 |
218 | # Parse command line arguments
219 | case "${1:-}" in
220 | --help|-h)
221 | show_help
222 | exit 0
223 | ;;
224 | *)
225 | main "$@"
226 | ;;
227 | esac
--------------------------------------------------------------------------------
/scripts/install-macos.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Moribito Installation Script for macOS
4 | # This script sets up the configuration directory using macOS conventions
5 |
6 | set -e
7 |
8 | PROGRAM_NAME="moribito"
9 | CONFIG_DIR="$HOME/.moribito"
10 | CONFIG_FILE="$CONFIG_DIR/config.yaml"
11 | BINARY_NAME="moribito"
12 | INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
13 |
14 | # Colors for output
15 | RED='\033[0;31m'
16 | GREEN='\033[0;32m'
17 | YELLOW='\033[1;33m'
18 | BLUE='\033[0;34m'
19 | NC='\033[0m' # No Color
20 |
21 | # Print colored output
22 | print_info() {
23 | echo -e "${BLUE}[INFO]${NC} $1"
24 | }
25 |
26 | print_success() {
27 | echo -e "${GREEN}[SUCCESS]${NC} $1"
28 | }
29 |
30 | print_warning() {
31 | echo -e "${YELLOW}[WARNING]${NC} $1"
32 | }
33 |
34 | print_error() {
35 | echo -e "${RED}[ERROR]${NC} $1"
36 | }
37 |
38 | # Detect architecture
39 | detect_arch() {
40 | local arch
41 | arch=$(uname -m)
42 | case $arch in
43 | x86_64|amd64) echo "amd64" ;;
44 | arm64) echo "arm64" ;;
45 | *)
46 | print_error "Unsupported architecture: $arch"
47 | exit 1
48 | ;;
49 | esac
50 | }
51 |
52 | # Get latest release version from GitHub
53 | get_latest_version() {
54 | local version
55 | version=$(curl -s https://api.github.com/repos/ericschmar/moribito/releases/latest | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/')
56 | if [ -z "$version" ]; then
57 | print_error "Failed to get latest version"
58 | exit 1
59 | fi
60 | echo "$version"
61 | }
62 |
63 | # Download and install binary
64 | install_binary() {
65 | local version="$1"
66 | local arch="$2"
67 | local binary_url="https://github.com/ericschmar/moribito/releases/download/$version/${BINARY_NAME}-darwin-$arch"
68 | local temp_file="/tmp/${BINARY_NAME}-$arch"
69 |
70 | print_info "Downloading $PROGRAM_NAME $version for darwin-$arch..."
71 |
72 | if ! curl -L "$binary_url" -o "$temp_file"; then
73 | print_error "Failed to download binary"
74 | exit 1
75 | fi
76 |
77 | chmod +x "$temp_file"
78 |
79 | print_info "Installing binary to $INSTALL_DIR..."
80 |
81 | # Check if we need sudo for installation
82 | if [ ! -w "$INSTALL_DIR" ]; then
83 | if command -v sudo >/dev/null 2>&1; then
84 | sudo mv "$temp_file" "$INSTALL_DIR/$BINARY_NAME"
85 | else
86 | print_error "Need write permission to $INSTALL_DIR and sudo not available"
87 | exit 1
88 | fi
89 | else
90 | mv "$temp_file" "$INSTALL_DIR/$BINARY_NAME"
91 | fi
92 |
93 | print_success "Binary installed to $INSTALL_DIR/$BINARY_NAME"
94 | }
95 |
96 | # Create configuration directory and sample config
97 | setup_config() {
98 | print_info "Setting up configuration directory at $CONFIG_DIR..."
99 |
100 | # Create config directory
101 | mkdir -p "$CONFIG_DIR"
102 |
103 | # Create sample config file if it doesn't exist
104 | if [ ! -f "$CONFIG_FILE" ]; then
105 | cat > "$CONFIG_FILE" << 'EOF'
106 | # Moribito Configuration for macOS
107 | # Located in ~/.moribito/ following macOS conventions
108 |
109 | ldap:
110 | # LDAP server connection settings
111 | host: "ldap.example.com"
112 | port: 389 # Use 636 for LDAPS
113 | base_dn: "dc=example,dc=com"
114 |
115 | # Security settings
116 | use_ssl: false # Use LDAPS (port 636)
117 | use_tls: false # Use StartTLS (recommended for port 389)
118 |
119 | # Authentication (leave empty for anonymous bind)
120 | bind_user: "cn=admin,dc=example,dc=com"
121 | bind_pass: "your-password-here"
122 |
123 | # Multiple saved connections (optional)
124 | # Uncomment and configure to save multiple connection profiles:
125 | # selected_connection: 0
126 | # saved_connections:
127 | # - name: "Production"
128 | # host: "ldap.prod.example.com"
129 | # port: 636
130 | # base_dn: "dc=prod,dc=example,dc=com"
131 | # use_ssl: true
132 | # bind_user: "cn=admin,dc=prod,dc=example,dc=com"
133 | # bind_pass: "prod-password"
134 |
135 | # Pagination settings for query results
136 | pagination:
137 | # Number of entries to load per page (default: 50)
138 | page_size: 50
139 |
140 | # Retry settings for LDAP operations
141 | retry:
142 | enabled: true
143 | max_attempts: 3
144 | initial_delay_ms: 500
145 | max_delay_ms: 5000
146 | EOF
147 | print_success "Sample configuration created at $CONFIG_FILE"
148 | print_info "Please edit $CONFIG_FILE with your LDAP server details"
149 | else
150 | print_warning "Configuration file already exists at $CONFIG_FILE"
151 | fi
152 | }
153 |
154 | # Check and install Homebrew if requested
155 | install_via_homebrew() {
156 | if command -v brew >/dev/null 2>&1; then
157 | print_info "Homebrew detected. You can also install via:"
158 | print_info " brew install moribito"
159 | else
160 | print_info "Consider installing Homebrew for easier package management:"
161 | print_info " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
162 | fi
163 | }
164 |
165 | # Main installation function
166 | main() {
167 | print_info "Installing $PROGRAM_NAME for macOS..."
168 |
169 | # Check if we're on macOS
170 | if [ "$(uname)" != "Darwin" ]; then
171 | print_error "This script is designed for macOS. Use install.sh for Linux/Unix."
172 | exit 1
173 | fi
174 |
175 | # Check if we're installing from a local binary or downloading
176 | if [ "$1" = "--local" ] && [ -f "./bin/${BINARY_NAME}" ]; then
177 | print_info "Installing from local binary..."
178 | if [ ! -w "$INSTALL_DIR" ]; then
179 | sudo cp "./bin/${BINARY_NAME}" "$INSTALL_DIR/${BINARY_NAME}"
180 | else
181 | cp "./bin/${BINARY_NAME}" "$INSTALL_DIR/${BINARY_NAME}"
182 | fi
183 | chmod +x "$INSTALL_DIR/${BINARY_NAME}"
184 | print_success "Local binary installed to $INSTALL_DIR/$BINARY_NAME"
185 | else
186 | # Download and install from GitHub releases
187 | local arch
188 | local version
189 |
190 | arch=$(detect_arch)
191 | version=$(get_latest_version)
192 |
193 | install_binary "$version" "$arch"
194 | fi
195 |
196 | # Setup configuration
197 | setup_config
198 |
199 | # Show Homebrew info
200 | install_via_homebrew
201 |
202 | print_success "Installation completed successfully!"
203 | print_info ""
204 | print_info "Next steps:"
205 | print_info "1. Edit your configuration: $CONFIG_FILE"
206 | print_info "2. Run the application: $BINARY_NAME"
207 | print_info "3. Or run with specific config: $BINARY_NAME -config $CONFIG_FILE"
208 | print_info ""
209 | print_info "Configuration will be automatically detected from:"
210 | print_info " - $CONFIG_FILE"
211 | print_info " - ~/.moribito.yaml"
212 | print_info " - ~/.config/moribito/config.yaml"
213 | print_info " - ./config.yaml"
214 | }
215 |
216 | # Show help
217 | show_help() {
218 | cat << EOF
219 | Moribito Installation Script for macOS
220 |
221 | Usage:
222 | $0 [options]
223 |
224 | Options:
225 | --local Install from local binary (./bin/moribito)
226 | --help Show this help message
227 |
228 | Environment Variables:
229 | INSTALL_DIR Installation directory (default: /usr/local/bin)
230 |
231 | Examples:
232 | $0 # Download and install latest release
233 | $0 --local # Install from local build
234 | INSTALL_DIR=~/bin $0 # Install to custom directory
235 | EOF
236 | }
237 |
238 | # Parse command line arguments
239 | case "${1:-}" in
240 | --help|-h)
241 | show_help
242 | exit 0
243 | ;;
244 | *)
245 | main "$@"
246 | ;;
247 | esac
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Usage Guide
2 |
3 | ## Features Overview
4 |
5 | - 🌲 **Interactive Tree Navigation**: Browse LDAP directory structure with keyboard/mouse
6 | - 📄 **Record Viewer**: View detailed LDAP entry attributes
7 | - 📋 **Clipboard Integration**: Copy attribute values to system clipboard
8 | - 🔍 **Custom Query Interface**: Execute custom LDAP queries with real-time results
9 | - 📖 **Paginated Results**: Efficient pagination for large result sets with automatic loading
10 | - ⚙️ **Flexible Configuration**: Support for config files and command-line options
11 | - 🔐 **Secure Authentication**: Support for SSL/TLS and various authentication methods
12 | - 🔄 **Auto-Update Notifications**: Optional checking for newer releases from GitHub
13 | - 🎨 **Modern TUI**: Clean, intuitive interface built with BubbleTea
14 | - 🔀 **Multiple Connections**: Save and switch between multiple LDAP server configurations
15 |
16 | ## Command Line Options
17 |
18 | ```bash
19 | # Connect with command line options
20 | moribito --host ldap.example.com --port 389 --base-dn "dc=example,dc=com"
21 |
22 | # Use SSL/TLS
23 | moribito --host ldap.example.com --ssl --base-dn "dc=example,dc=com"
24 |
25 | # With authentication
26 | moribito --host ldap.example.com --bind-user "cn=admin,dc=example,dc=com" --bind-password "password" --base-dn "dc=example,dc=com"
27 |
28 | # Enable auto-update checking
29 | moribito --check-updates
30 |
31 | # Combine options
32 | moribito --host ldap.example.com --ssl --check-updates --base-dn "dc=example,dc=com"
33 | ```
34 |
35 | ## Configuration File
36 |
37 | Create a YAML configuration file to avoid typing connection details repeatedly:
38 |
39 | ### Basic Configuration (Single Connection)
40 |
41 | ```yaml
42 | # ~/.moribito.yaml or ./config.yaml
43 | ldap:
44 | host: ldap.example.com
45 | port: 389
46 | base_dn: dc=example,dc=com
47 | use_ssl: false
48 | use_tls: true
49 | bind_user: cn=admin,dc=example,dc=com
50 | bind_pass: password
51 | pagination:
52 | page_size: 100
53 | ```
54 |
55 | ### Creating Configuration Files
56 |
57 | You can create a configuration file in several ways:
58 |
59 | 1. **Automatic Creation**: Use the `--create-config` flag to create a default configuration file:
60 | ```bash
61 | moribito --create-config
62 | ```
63 |
64 | 2. **UI Configuration**: Configure settings through the start screen interface. All changes made in the UI are automatically saved to the config file.
65 |
66 | 3. **Manual Creation**: Create a YAML file manually at one of these locations:
67 | - **Linux/macOS**: `~/.config/moribito/config.yaml` or `~/.moribito.yaml`
68 | - **Windows**: `%APPDATA%\moribito\config.yaml` or `%USERPROFILE%\.moribito.yaml`
69 | - **Current Directory**: `./config.yaml`
70 |
71 | **Note**: Configuration changes made through the UI (Start View) are automatically saved to the config file and persist across application restarts.
72 |
73 | ### Advanced Configuration (Multiple Saved Connections)
74 |
75 | For environments with multiple LDAP servers, you can save multiple connection profiles:
76 |
77 | ```yaml
78 | ldap:
79 | # Default connection settings (used when no saved connections exist)
80 | host: ldap.example.com
81 | port: 389
82 | base_dn: dc=example,dc=com
83 | use_ssl: false
84 | use_tls: true
85 | bind_user: cn=admin,dc=example,dc=com
86 | bind_pass: password
87 |
88 | # Multiple saved connections
89 | selected_connection: 0 # Index of currently active connection (-1 for default)
90 | saved_connections:
91 | - name: "Production"
92 | host: ldap.prod.example.com
93 | port: 636
94 | base_dn: dc=prod,dc=example,dc=com
95 | use_ssl: true
96 | use_tls: false
97 | bind_user: cn=admin,dc=prod,dc=example,dc=com
98 | bind_pass: prod-password
99 |
100 | - name: "Development"
101 | host: ldap.dev.example.com
102 | port: 389
103 | base_dn: dc=dev,dc=example,dc=com
104 | use_ssl: false
105 | use_tls: true
106 | bind_user: cn=admin,dc=dev,dc=example,dc=com
107 | bind_pass: "" # Will prompt for password
108 |
109 | pagination:
110 | page_size: 50
111 | retry:
112 | enabled: true
113 | max_attempts: 3
114 | ```
115 |
116 | Then simply run:
117 |
118 | ```bash
119 | moribito -config ~/.moribito.yaml
120 | ```
121 |
122 | ### Managing Multiple Connections
123 |
124 | When using multiple saved connections:
125 |
126 | 1. **In the Start View**: Navigate to the "Saved Connections" section to:
127 |
128 | - Switch between saved connections using ←/→ arrow keys
129 | - Add new connections with "Add New Connection"
130 | - Delete connections with "Delete Connection"
131 | - Save current settings as a new connection
132 |
133 | 2. **Connection Selection**: The `selected_connection` field determines which saved connection is active:
134 |
135 | - `-1` or omitted: Use default connection settings
136 | - `0`, `1`, `2`, etc.: Use the corresponding saved connection by index
137 |
138 | 3. **Backward Compatibility**: Old configuration files without saved connections continue to work exactly as before.
139 |
140 | ## Navigation
141 |
142 | ### General Controls
143 |
144 | - **Tab** - Switch between Tree, Query, and Record views
145 | - **1/2/3** - Jump directly to Start/Tree/Query/Record view
146 | - **Ctrl+C** or **q** - Exit application
147 | - **?** - Toggle help modal (context-sensitive)
148 | - **Ctrl+R** - Refresh/reconnect to server
149 |
150 | ### Start/Configuration View
151 |
152 | - **↑/↓** or **j/k** - Navigate through configuration fields
153 | - **Enter** - Edit field value or execute action
154 | - **←/→** or **h/l** - Navigate between saved connections (when in connection list)
155 | - **Escape** - Cancel editing or dialog
156 |
157 | #### Connection Management
158 |
159 | - **Add New Connection** - Save current settings as a new named connection
160 | - **Delete Connection** - Remove the selected saved connection
161 | - **Save Current as New** - Create a new connection from current configuration
162 | - Navigate between saved connections and press **Enter** to switch to that connection
163 |
164 | ### Tree View
165 |
166 | - **↑/↓** or **j/k** - Navigate up/down in tree
167 | - **Page Up/Down** - Navigate by page
168 | - **Enter** or **→** - Expand folder or view record
169 | - **←** - Collapse folder or go up one level
170 | - **/** - Focus search/filter input
171 | - **Escape** - Clear search, return to tree navigation
172 | - **Home/End** - Jump to beginning/end of current level
173 |
174 | ### Record View
175 |
176 | - **↑/↓** or **j/k** - Navigate through attributes
177 | - **Page Up/Down** - Navigate by page
178 | - **Enter** - Copy selected attribute value to clipboard
179 | - **Escape** or **←** - Return to previous view
180 | - **/** - Focus search/filter for attributes
181 |
182 | ### Query View
183 |
184 | - **/** or **Escape** - Focus query input
185 | - **Ctrl+Enter** or **Ctrl+J** - Execute query
186 | - **Ctrl+F** - Format query with proper indentation
187 | - **Escape** - Clear query
188 | - **Ctrl+V** - Paste from clipboard
189 | - **↑/↓** - Navigate results (when not in input mode)
190 | - **Page Up/Down** - Navigate by page (automatically loads more results)
191 | - **Enter** - View selected record
192 |
193 | > **Note**: The Query View uses automatic pagination to efficiently handle large result sets. When you scroll near the end of loaded results, the next page is automatically fetched from the LDAP server.
194 |
195 | ### Query Formatting
196 |
197 | The **Ctrl+F** key combination formats complex LDAP queries with proper indentation for better readability:
198 |
199 | ```
200 | # Before formatting:
201 | (&(objectClass=person)(|(cn=john*)(sn=smith*))(department=engineering))
202 |
203 | # After formatting (Ctrl+F):
204 | (&
205 | (objectClass=person)
206 | (|
207 | (cn=john*)
208 | (sn=smith*)
209 | )
210 | (department=engineering)
211 | )
212 | ```
213 |
--------------------------------------------------------------------------------
/scripts/install.ps1:
--------------------------------------------------------------------------------
1 | # Moribito Installation Script for Windows
2 | # This script sets up the configuration directory using Windows conventions
3 |
4 | param(
5 | [switch]$Local,
6 | [switch]$Help,
7 | [string]$InstallDir = "$env:ProgramFiles\moribito"
8 | )
9 |
10 | $ProgramName = "moribito"
11 | $ConfigDir = "$env:APPDATA\moribito"
12 | $ConfigFile = "$ConfigDir\config.yaml"
13 | $BinaryName = "moribito.exe"
14 |
15 | # Colors for output (if supported)
16 | function Write-Info($Message) {
17 | Write-Host "[INFO] $Message" -ForegroundColor Blue
18 | }
19 |
20 | function Write-Success($Message) {
21 | Write-Host "[SUCCESS] $Message" -ForegroundColor Green
22 | }
23 |
24 | function Write-Warning($Message) {
25 | Write-Host "[WARNING] $Message" -ForegroundColor Yellow
26 | }
27 |
28 | function Write-Error($Message) {
29 | Write-Host "[ERROR] $Message" -ForegroundColor Red
30 | }
31 |
32 | # Check if running as administrator
33 | function Test-Administrator {
34 | $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
35 | $principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
36 | return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
37 | }
38 |
39 | # Get latest release version from GitHub
40 | function Get-LatestVersion {
41 | try {
42 | $response = Invoke-RestMethod -Uri "https://api.github.com/repos/ericschmar/moribito/releases/latest"
43 | return $response.tag_name
44 | }
45 | catch {
46 | Write-Error "Failed to get latest version: $_"
47 | exit 1
48 | }
49 | }
50 |
51 | # Download and install binary
52 | function Install-Binary($Version) {
53 | $binaryUrl = "https://github.com/ericschmar/moribito/releases/download/$Version/moribito-windows-amd64.exe"
54 | $tempFile = "$env:TEMP\$BinaryName"
55 |
56 | Write-Info "Downloading $ProgramName $Version for Windows..."
57 |
58 | try {
59 | Invoke-WebRequest -Uri $binaryUrl -OutFile $tempFile
60 | }
61 | catch {
62 | Write-Error "Failed to download binary: $_"
63 | exit 1
64 | }
65 |
66 | Write-Info "Installing binary to $InstallDir..."
67 |
68 | # Create install directory if it doesn't exist
69 | if (!(Test-Path $InstallDir)) {
70 | try {
71 | New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
72 | }
73 | catch {
74 | Write-Error "Failed to create install directory: $_"
75 | exit 1
76 | }
77 | }
78 |
79 | # Copy binary to install location
80 | $destinationPath = "$InstallDir\$BinaryName"
81 | try {
82 | Copy-Item $tempFile $destinationPath -Force
83 | Remove-Item $tempFile
84 | }
85 | catch {
86 | Write-Error "Failed to install binary: $_"
87 | exit 1
88 | }
89 |
90 | Write-Success "Binary installed to $destinationPath"
91 |
92 | # Add to PATH if not already there
93 | $currentPath = [Environment]::GetEnvironmentVariable("PATH", "Machine")
94 | if ($currentPath -notlike "*$InstallDir*") {
95 | try {
96 | Write-Info "Adding $InstallDir to system PATH..."
97 | [Environment]::SetEnvironmentVariable("PATH", "$currentPath;$InstallDir", "Machine")
98 | Write-Success "Added to system PATH. Restart your terminal to use 'moribito' command."
99 | }
100 | catch {
101 | Write-Warning "Failed to add to system PATH. You may need to add $InstallDir manually."
102 | }
103 | }
104 | }
105 |
106 | # Create configuration directory and sample config
107 | function Setup-Config {
108 | Write-Info "Setting up configuration directory at $ConfigDir..."
109 |
110 | # Create config directory
111 | if (!(Test-Path $ConfigDir)) {
112 | New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
113 | }
114 |
115 | # Create sample config file if it doesn't exist
116 | if (!(Test-Path $ConfigFile)) {
117 | $configContent = @"
118 | # Moribito Configuration for Windows
119 | # Located in %APPDATA%\moribito\ following Windows conventions
120 |
121 | ldap:
122 | # LDAP server connection settings
123 | host: "ldap.example.com"
124 | port: 389 # Use 636 for LDAPS
125 | base_dn: "dc=example,dc=com"
126 |
127 | # Security settings
128 | use_ssl: false # Use LDAPS (port 636)
129 | use_tls: false # Use StartTLS (recommended for port 389)
130 |
131 | # Authentication (leave empty for anonymous bind)
132 | bind_user: "cn=admin,dc=example,dc=com"
133 | bind_pass: "your-password-here"
134 |
135 | # Pagination settings for query results
136 | pagination:
137 | # Number of entries to load per page (default: 50)
138 | page_size: 50
139 |
140 | # Retry settings for LDAP operations
141 | retry:
142 | enabled: true
143 | max_attempts: 3
144 | initial_delay_ms: 500
145 | max_delay_ms: 5000
146 | "@
147 |
148 | Set-Content -Path $ConfigFile -Value $configContent
149 | Write-Success "Sample configuration created at $ConfigFile"
150 | Write-Info "Please edit $ConfigFile with your LDAP server details"
151 | }
152 | else {
153 | Write-Warning "Configuration file already exists at $ConfigFile"
154 | }
155 | }
156 |
157 | # Main installation function
158 | function Install-Moribito {
159 | Write-Info "Installing $ProgramName for Windows..."
160 |
161 | # Check if we need administrator privileges for system-wide installation
162 | if ($InstallDir -like "$env:ProgramFiles*" -and !(Test-Administrator)) {
163 | Write-Error "Administrator privileges required for system-wide installation."
164 | Write-Info "Please run PowerShell as Administrator, or use a user directory:"
165 | Write-Info " ./install.ps1 -InstallDir `"$env:LOCALAPPDATA\Programs\moribito`""
166 | exit 1
167 | }
168 |
169 | # Check if we're installing from a local binary or downloading
170 | if ($Local -and (Test-Path ".\bin\$BinaryName")) {
171 | Write-Info "Installing from local binary..."
172 |
173 | # Create install directory if it doesn't exist
174 | if (!(Test-Path $InstallDir)) {
175 | New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
176 | }
177 |
178 | Copy-Item ".\bin\$BinaryName" "$InstallDir\$BinaryName" -Force
179 | Write-Success "Local binary installed to $InstallDir\$BinaryName"
180 | }
181 | else {
182 | # Download and install from GitHub releases
183 | $version = Get-LatestVersion
184 | Install-Binary $version
185 | }
186 |
187 | # Setup configuration
188 | Setup-Config
189 |
190 | Write-Success "Installation completed successfully!"
191 | Write-Info ""
192 | Write-Info "Next steps:"
193 | Write-Info "1. Edit your configuration: $ConfigFile"
194 | Write-Info "2. Run the application: moribito"
195 | Write-Info "3. Or run with specific config: moribito -config `"$ConfigFile`""
196 | Write-Info ""
197 | Write-Info "Configuration will be automatically detected from:"
198 | Write-Info " - $ConfigFile"
199 | Write-Info " - $env:USERPROFILE\.moribito.yaml"
200 | Write-Info " - .\config.yaml"
201 | }
202 |
203 | # Show help
204 | function Show-Help {
205 | Write-Host @"
206 | Moribito Installation Script for Windows
207 |
208 | Usage:
209 | .\install.ps1 [options]
210 |
211 | Options:
212 | -Local Install from local binary (.\bin\moribito.exe)
213 | -InstallDir Installation directory (default: $env:ProgramFiles\moribito)
214 | -Help Show this help message
215 |
216 | Examples:
217 | .\install.ps1 # Download and install latest release
218 | .\install.ps1 -Local # Install from local build
219 | .\install.ps1 -InstallDir "$env:LOCALAPPDATA\Programs\moribito" # Install to user directory
220 | "@
221 | }
222 |
223 | # Main execution
224 | if ($Help) {
225 | Show-Help
226 | exit 0
227 | }
228 |
229 | Install-Moribito
--------------------------------------------------------------------------------
/internal/ldap/client_test.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "errors"
5 | "net"
6 | "testing"
7 | "time"
8 |
9 | "github.com/go-ldap/ldap/v3"
10 | )
11 |
12 | func TestSearchPage(t *testing.T) {
13 | // Test SearchPage struct initialization
14 | page := &SearchPage{
15 | Entries: []*Entry{},
16 | HasMore: false,
17 | Cookie: []byte{},
18 | PageSize: 50,
19 | TotalCount: -1,
20 | }
21 |
22 | if page.PageSize != 50 {
23 | t.Errorf("Expected PageSize to be 50, got %d", page.PageSize)
24 | }
25 |
26 | if page.HasMore != false {
27 | t.Errorf("Expected HasMore to be false, got %t", page.HasMore)
28 | }
29 |
30 | if page.TotalCount != -1 {
31 | t.Errorf("Expected TotalCount to be -1, got %d", page.TotalCount)
32 | }
33 | }
34 |
35 | func TestEntry(t *testing.T) {
36 | // Test Entry struct initialization
37 | entry := &Entry{
38 | DN: "cn=test,dc=example,dc=com",
39 | Attributes: make(map[string][]string),
40 | }
41 |
42 | entry.Attributes["cn"] = []string{"test"}
43 |
44 | if entry.DN != "cn=test,dc=example,dc=com" {
45 | t.Errorf("Expected DN to be 'cn=test,dc=example,dc=com', got %s", entry.DN)
46 | }
47 |
48 | if len(entry.Attributes["cn"]) != 1 || entry.Attributes["cn"][0] != "test" {
49 | t.Errorf("Expected cn attribute to be ['test'], got %v", entry.Attributes["cn"])
50 | }
51 | }
52 |
53 | func TestIsRetryableError(t *testing.T) {
54 | config := Config{
55 | Host: "localhost",
56 | Port: 389,
57 | RetryEnabled: true,
58 | MaxRetries: 3,
59 | InitialDelayMs: 100,
60 | MaxDelayMs: 1000,
61 | }
62 |
63 | client := &Client{
64 | config: config,
65 | }
66 |
67 | tests := []struct {
68 | name string
69 | err error
70 | shouldRetry bool
71 | }{
72 | {
73 | name: "nil error",
74 | err: nil,
75 | shouldRetry: false,
76 | },
77 | {
78 | name: "network timeout",
79 | err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("timeout")},
80 | shouldRetry: true,
81 | },
82 | {
83 | name: "connection refused",
84 | err: errors.New("connection refused"),
85 | shouldRetry: true,
86 | },
87 | {
88 | name: "connection closed",
89 | err: errors.New("connection closed"),
90 | shouldRetry: true,
91 | },
92 | {
93 | name: "ldap connection closed",
94 | err: errors.New("LDAP: connection closed"),
95 | shouldRetry: true,
96 | },
97 | {
98 | name: "broken pipe",
99 | err: errors.New("broken pipe"),
100 | shouldRetry: true,
101 | },
102 | {
103 | name: "LDAP server down",
104 | err: &ldap.Error{
105 | ResultCode: ldap.LDAPResultServerDown,
106 | Err: errors.New("server down"),
107 | },
108 | shouldRetry: true,
109 | },
110 | {
111 | name: "LDAP unavailable",
112 | err: &ldap.Error{
113 | ResultCode: ldap.LDAPResultUnavailable,
114 | Err: errors.New("unavailable"),
115 | },
116 | shouldRetry: true,
117 | },
118 | {
119 | name: "LDAP authentication error",
120 | err: &ldap.Error{
121 | ResultCode: ldap.LDAPResultInvalidCredentials,
122 | Err: errors.New("invalid credentials"),
123 | },
124 | shouldRetry: false,
125 | },
126 | {
127 | name: "generic error",
128 | err: errors.New("some other error"),
129 | shouldRetry: false,
130 | },
131 | }
132 |
133 | for _, tt := range tests {
134 | t.Run(tt.name, func(t *testing.T) {
135 | result := client.isRetryableError(tt.err)
136 | if result != tt.shouldRetry {
137 | t.Errorf("isRetryableError() = %v, want %v for error: %v", result, tt.shouldRetry, tt.err)
138 | }
139 | })
140 | }
141 | }
142 |
143 | func TestWithRetryDisabled(t *testing.T) {
144 | config := Config{
145 | RetryEnabled: false,
146 | MaxRetries: 3,
147 | InitialDelayMs: 100,
148 | MaxDelayMs: 1000,
149 | }
150 |
151 | client := &Client{
152 | config: config,
153 | }
154 |
155 | callCount := 0
156 | testErr := errors.New("test error")
157 |
158 | err := client.withRetry(func() error {
159 | callCount++
160 | return testErr
161 | })
162 |
163 | // Should only call once when retry is disabled
164 | if callCount != 1 {
165 | t.Errorf("Expected 1 call, got %d", callCount)
166 | }
167 |
168 | if err != testErr {
169 | t.Errorf("Expected original error, got %v", err)
170 | }
171 | }
172 |
173 | func TestWithRetrySuccess(t *testing.T) {
174 | config := Config{
175 | RetryEnabled: true,
176 | MaxRetries: 3,
177 | InitialDelayMs: 10,
178 | MaxDelayMs: 100,
179 | }
180 |
181 | client := &Client{
182 | config: config,
183 | }
184 |
185 | callCount := 0
186 |
187 | err := client.withRetry(func() error {
188 | callCount++
189 | return nil // Success on first try
190 | })
191 |
192 | // Should only call once on success
193 | if callCount != 1 {
194 | t.Errorf("Expected 1 call, got %d", callCount)
195 | }
196 |
197 | if err != nil {
198 | t.Errorf("Expected no error, got %v", err)
199 | }
200 | }
201 |
202 | func TestWithRetryNonRetryableError(t *testing.T) {
203 | config := Config{
204 | RetryEnabled: true,
205 | MaxRetries: 3,
206 | InitialDelayMs: 10,
207 | MaxDelayMs: 100,
208 | }
209 |
210 | client := &Client{
211 | config: config,
212 | }
213 |
214 | callCount := 0
215 | nonRetryableErr := errors.New("authentication failed")
216 |
217 | err := client.withRetry(func() error {
218 | callCount++
219 | return nonRetryableErr
220 | })
221 |
222 | // Should only call once for non-retryable errors
223 | if callCount != 1 {
224 | t.Errorf("Expected 1 call, got %d", callCount)
225 | }
226 |
227 | if err != nonRetryableErr {
228 | t.Errorf("Expected original error, got %v", err)
229 | }
230 | }
231 |
232 | func TestWithRetryExponentialBackoff(t *testing.T) {
233 | config := Config{
234 | RetryEnabled: true,
235 | MaxRetries: 2,
236 | InitialDelayMs: 10,
237 | MaxDelayMs: 50,
238 | }
239 |
240 | callCount := 0
241 | reconnectCount := 0
242 | retryableErr := errors.New("connection refused")
243 | start := time.Now()
244 |
245 | // Create a client with mocked methods using function variables
246 | client := &Client{
247 | config: config,
248 | }
249 |
250 | // Override the withRetry method behavior for testing
251 | var lastErr error
252 | delay := time.Duration(config.InitialDelayMs) * time.Millisecond
253 | maxDelay := time.Duration(config.MaxDelayMs) * time.Millisecond
254 |
255 | operation := func() error {
256 | callCount++
257 | if callCount <= 2 { // Fail first 2 attempts
258 | return retryableErr
259 | }
260 | return nil // Success on 3rd attempt
261 | }
262 |
263 | // Simulate the retry logic manually
264 | for attempt := 0; attempt <= config.MaxRetries; attempt++ {
265 | err := operation()
266 | if err == nil {
267 | lastErr = nil
268 | break // Success
269 | }
270 |
271 | lastErr = err
272 |
273 | // Don't retry if it's the last attempt or error is not retryable
274 | if attempt == config.MaxRetries || !client.isRetryableError(err) {
275 | break
276 | }
277 |
278 | // Simulate reconnect
279 | reconnectCount++
280 |
281 | // Wait before retrying
282 | time.Sleep(delay)
283 |
284 | // Exponential backoff
285 | delay *= 2
286 | if delay > maxDelay {
287 | delay = maxDelay
288 | }
289 | }
290 |
291 | duration := time.Since(start)
292 |
293 | // Should call 3 times (initial + 2 retries)
294 | if callCount != 3 {
295 | t.Errorf("Expected 3 calls, got %d", callCount)
296 | }
297 |
298 | // Should succeed eventually
299 | if lastErr != nil {
300 | t.Errorf("Expected success after retries, got %v", lastErr)
301 | }
302 |
303 | // Should have attempted reconnect twice
304 | if reconnectCount != 2 {
305 | t.Errorf("Expected 2 reconnect attempts, got %d", reconnectCount)
306 | }
307 |
308 | // Should have some delay due to exponential backoff
309 | expectedMinDelay := time.Duration(10+20) * time.Millisecond // Initial delay + doubled delay
310 | if duration < expectedMinDelay {
311 | t.Errorf("Expected at least %v delay, got %v", expectedMinDelay, duration)
312 | }
313 | }
314 |
315 | func TestWithRetryFailureAfterMaxAttempts(t *testing.T) {
316 | config := Config{
317 | RetryEnabled: true,
318 | MaxRetries: 2,
319 | InitialDelayMs: 1, // Very fast for testing
320 | MaxDelayMs: 5,
321 | }
322 |
323 | client := &Client{
324 | config: config,
325 | }
326 |
327 | callCount := 0
328 | nonRetryableErr := errors.New("invalid credentials") // Non-retryable error
329 |
330 | err := client.withRetry(func() error {
331 | callCount++
332 | return nonRetryableErr
333 | })
334 |
335 | // Should only call once for non-retryable errors
336 | if callCount != 1 {
337 | t.Errorf("Expected 1 call for non-retryable error, got %d", callCount)
338 | }
339 |
340 | // Should return the original error
341 | if err != nonRetryableErr {
342 | t.Errorf("Expected original error, got %v", err)
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/internal/tui/model_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/charmbracelet/bubbletea"
7 | "github.com/ericschmar/moribito/internal/config"
8 | "github.com/ericschmar/moribito/internal/ldap"
9 | )
10 |
11 | func TestModel_NavigationKeysWithQueryInputMode(t *testing.T) {
12 | // Create a model with a mock client
13 | var client *ldap.Client
14 | cfg := config.Default()
15 | model := NewModel(client, cfg)
16 |
17 | // Since client is nil, manually create queryView for testing
18 | model.queryView = NewQueryView(client)
19 |
20 | // Set to query view
21 | model.currentView = ViewModeQuery
22 |
23 | // Ensure query view is in input mode
24 | if !model.queryView.IsInputMode() {
25 | t.Fatal("QueryView should be in input mode for this test")
26 | }
27 |
28 | // Test that number keys don't trigger navigation when in query input mode
29 | testCases := []string{"1", "2", "3"}
30 |
31 | for _, key := range testCases {
32 | originalView := model.currentView
33 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(key[0])}}
34 |
35 | // Update the model
36 | _, _ = model.Update(keyMsg)
37 |
38 | // View should not have changed
39 | if model.currentView != originalView {
40 | t.Errorf("View should not change when pressing '%s' in query input mode, expected %d, got %d",
41 | key, originalView, model.currentView)
42 | }
43 | }
44 | }
45 |
46 | func TestModel_QuitKeyDuringStartViewEditing(t *testing.T) {
47 | // Create a model
48 | var client *ldap.Client
49 | cfg := config.Default()
50 | model := NewModel(client, cfg)
51 |
52 | // Set to start view (configuration mode)
53 | model.currentView = ViewModeStart
54 |
55 | // Start editing a field
56 | model.startView.editing = true
57 | model.startView.editingField = FieldHost
58 | model.startView.textInput.SetValue("localhost")
59 |
60 | // Verify we're in editing mode
61 | if !model.startView.IsEditing() {
62 | t.Fatal("StartView should be in editing mode for this test")
63 | }
64 |
65 | // Test that 'q' key doesn't quit when in editing mode
66 | originalQuitting := model.quitting
67 | qKeyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
68 |
69 | // Update the model
70 | _, cmd := model.Update(qKeyMsg)
71 |
72 | // Application should NOT quit when in editing mode
73 | if model.quitting != originalQuitting {
74 | t.Error("Application should not quit when pressing 'q' in editing mode")
75 | }
76 |
77 | // Command should not be tea.Quit
78 | if cmd != nil {
79 | // We need to check if the command would quit the app
80 | // Since we can't easily inspect the command, we check if quitting flag changed
81 | if model.quitting {
82 | t.Error("Quit command should not be returned when in editing mode")
83 | }
84 | }
85 | }
86 |
87 | func TestModel_QuitKeyWhenNotEditing(t *testing.T) {
88 | // Create a model
89 | var client *ldap.Client
90 | cfg := config.Default()
91 | model := NewModel(client, cfg)
92 |
93 | // Set to start view but NOT editing
94 | model.currentView = ViewModeStart
95 | model.startView.editing = false
96 |
97 | // Verify we're NOT in editing mode
98 | if model.startView.IsEditing() {
99 | t.Fatal("StartView should NOT be in editing mode for this test")
100 | }
101 |
102 | // Test that 'q' key DOES quit when not in editing mode
103 | qKeyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
104 |
105 | // Update the model
106 | _, cmd := model.Update(qKeyMsg)
107 |
108 | // Application SHOULD quit when not in editing mode
109 | if !model.quitting {
110 | t.Error("Application should quit when pressing 'q' when not in editing mode")
111 | }
112 |
113 | // Command should be tea.Quit (we can't easily check this, but quitting flag should be true)
114 | if cmd == nil {
115 | t.Error("Quit command should be returned when not in editing mode")
116 | }
117 | }
118 |
119 | func TestModel_QuitKeyDuringQueryViewEditing(t *testing.T) {
120 | // Create a model
121 | var client *ldap.Client
122 | cfg := config.Default()
123 | model := NewModel(client, cfg)
124 |
125 | // Create query view manually since client is nil
126 | model.queryView = NewQueryView(client)
127 |
128 | // Set to query view
129 | model.currentView = ViewModeQuery
130 |
131 | // Verify query view is in input mode (should be by default)
132 | if !model.queryView.IsInputMode() {
133 | t.Fatal("QueryView should be in input mode for this test")
134 | }
135 |
136 | // Test that 'q' key doesn't quit when in query input mode
137 | originalQuitting := model.quitting
138 | qKeyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
139 |
140 | // Update the model
141 | _, cmd := model.Update(qKeyMsg)
142 |
143 | // Application should NOT quit when in query input mode
144 | if model.quitting != originalQuitting {
145 | t.Error("Application should not quit when pressing 'q' in query input mode")
146 | }
147 |
148 | // Command should not be tea.Quit
149 | if cmd != nil && model.quitting {
150 | t.Error("Quit command should not be returned when in query input mode")
151 | }
152 | }
153 |
154 | func TestModel_NavigationKeysWithQueryBrowseMode(t *testing.T) {
155 | // Create a model with a mock client
156 | var client *ldap.Client
157 | cfg := config.Default()
158 | model := NewModel(client, cfg)
159 |
160 | // Since client is nil, manually create queryView for testing
161 | model.queryView = NewQueryView(client)
162 |
163 | // Set to query view but in browse mode
164 | model.currentView = ViewModeQuery
165 | model.queryView.inputMode = false // Set to browse mode
166 |
167 | // Test that number keys DO trigger navigation when NOT in query input mode
168 | testCases := []struct {
169 | key string
170 | expectedView ViewMode
171 | }{
172 | {"1", ViewModeTree},
173 | {"2", ViewModeRecord},
174 | {"3", ViewModeQuery},
175 | }
176 |
177 | for _, tc := range testCases {
178 | // Reset to query view
179 | model.currentView = ViewModeQuery
180 |
181 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(tc.key[0])}}
182 |
183 | // Update the model
184 | _, _ = model.Update(keyMsg)
185 |
186 | // View should have changed to expected view
187 | if model.currentView != tc.expectedView {
188 | t.Errorf("View should change when pressing '%s' in query browse mode, expected %d, got %d",
189 | tc.key, tc.expectedView, model.currentView)
190 | }
191 | }
192 | }
193 |
194 | func TestModel_NavigationKeysInOtherViews(t *testing.T) {
195 | // Create a model with a mock client
196 | var client *ldap.Client
197 | cfg := config.Default()
198 | model := NewModel(client, cfg)
199 |
200 | // Test that number keys work normally in other views
201 | testCases := []struct {
202 | initialView ViewMode
203 | key string
204 | expectedView ViewMode
205 | }{
206 | {ViewModeTree, "2", ViewModeRecord},
207 | {ViewModeTree, "3", ViewModeQuery},
208 | {ViewModeRecord, "1", ViewModeTree},
209 | {ViewModeRecord, "3", ViewModeQuery},
210 | }
211 |
212 | for _, tc := range testCases {
213 | model.currentView = tc.initialView
214 |
215 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(tc.key[0])}}
216 |
217 | // Update the model
218 | _, _ = model.Update(keyMsg)
219 |
220 | // View should have changed to expected view
221 | if model.currentView != tc.expectedView {
222 | t.Errorf("Navigation should work normally in other views: pressing '%s' from view %d should go to %d, got %d",
223 | tc.key, tc.initialView, tc.expectedView, model.currentView)
224 | }
225 | }
226 | }
227 |
228 | func TestModel_TreeLoadingHandledRegardlessOfCurrentView(t *testing.T) {
229 | // Create a model with a mock client
230 | var client *ldap.Client
231 | cfg := config.Default()
232 | model := NewModel(client, cfg)
233 |
234 | // Ensure tree exists and is in loading state initially
235 | if model.tree == nil {
236 | t.Fatal("Tree should exist")
237 | }
238 |
239 | // Set tree to loading state
240 | model.tree.loading = true
241 |
242 | // Switch to a different view (not tree view)
243 | model.currentView = ViewModeRecord
244 |
245 | // Simulate RootNodeLoadedMsg arriving while not on tree view
246 | mockTreeNode := &ldap.TreeNode{
247 | DN: "dc=example,dc=com",
248 | Name: "example.com",
249 | Children: nil,
250 | IsLoaded: false,
251 | }
252 | rootLoadedMsg := RootNodeLoadedMsg{Node: mockTreeNode}
253 |
254 | // Update the model with the message
255 | _, _ = model.Update(rootLoadedMsg)
256 |
257 | // Tree should have received and processed the message even though we're not on tree view
258 | if model.tree.loading {
259 | t.Error("Tree should not be in loading state after RootNodeLoadedMsg")
260 | }
261 |
262 | if model.tree.root != mockTreeNode {
263 | t.Error("Tree root should have been set to the loaded node")
264 | }
265 |
266 | // Switch to tree view to verify it displays properly
267 | model.currentView = ViewModeTree
268 | view := model.tree.View()
269 |
270 | // Should not show loading or "No entries found" since root was loaded
271 | if view == "Loading LDAP tree..." {
272 | t.Error("Tree should not show loading message after root was loaded")
273 | }
274 | }
275 |
276 | func TestModel_NodeChildrenLoadingHandledRegardlessOfCurrentView(t *testing.T) {
277 | // Create a model with a mock client
278 | var client *ldap.Client
279 | cfg := config.Default()
280 | model := NewModel(client, cfg)
281 |
282 | // Set tree to loading state
283 | model.tree.loading = true
284 |
285 | // Switch to a different view (not tree view)
286 | model.currentView = ViewModeStart
287 |
288 | // Create a mock tree structure
289 | mockTreeNode := &ldap.TreeNode{
290 | DN: "ou=users,dc=example,dc=com",
291 | Name: "users",
292 | Children: nil,
293 | IsLoaded: true,
294 | }
295 |
296 | // Simulate NodeChildrenLoadedMsg arriving while not on tree view
297 | childrenLoadedMsg := NodeChildrenLoadedMsg{Node: mockTreeNode}
298 |
299 | // Update the model with the message
300 | _, _ = model.Update(childrenLoadedMsg)
301 |
302 | // Tree should have processed the message even though we're not on tree view
303 | if model.tree.loading {
304 | t.Error("Tree should not be in loading state after NodeChildrenLoadedMsg")
305 | }
306 | }
307 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
2 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
3 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
5 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
6 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
11 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
12 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
13 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
14 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
15 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
16 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
17 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
18 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
19 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
20 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
21 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
22 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
23 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
24 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
25 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
26 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
27 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
28 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
33 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
34 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
35 | github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
36 | github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
37 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
38 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
39 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
40 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
41 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
42 | github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
43 | github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
44 | github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
45 | github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
46 | github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
47 | github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
48 | github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
49 | github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
50 | github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
51 | github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
52 | github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
53 | github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
54 | github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
55 | github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8=
56 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
57 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
58 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
59 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
60 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
61 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
62 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
63 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
64 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
65 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
66 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
67 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
68 | github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY=
69 | github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 h1:p4A2Jx7Lm3NV98VRMKlyWd3nqf8obft8NfXlAUmqd3I=
70 | github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY=
71 | github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo=
72 | github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k=
73 | github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY=
74 | github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc=
75 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
76 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
77 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
78 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
79 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
80 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
81 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
82 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
83 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
84 | github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
85 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
86 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
87 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
88 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
89 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
90 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
91 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
92 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
93 | golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
94 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
95 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
96 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
97 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
98 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
101 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
102 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
103 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
104 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
109 |
--------------------------------------------------------------------------------
/internal/tui/tree.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | "github.com/ericschmar/moribito/internal/ldap"
11 | zone "github.com/lrstanley/bubblezone"
12 | )
13 |
14 | // TreeView represents the LDAP tree view
15 | type TreeView struct {
16 | client *ldap.Client
17 | root *ldap.TreeNode
18 | FlattenedTree []*TreeItem
19 | cursor int
20 | viewport int
21 | width int
22 | height int
23 | loading bool
24 | container *ViewContainer
25 | // Timer fields for loading display
26 | loadingStartTime time.Time
27 | loadingElapsed time.Duration
28 | }
29 |
30 | // TreeItem represents a flattened tree item for display
31 | type TreeItem struct {
32 | Node *ldap.TreeNode
33 | Level int
34 | IsLast bool
35 | }
36 |
37 | // NewTreeView creates a new tree view
38 | func NewTreeView(client *ldap.Client) *TreeView {
39 | return &TreeView{
40 | client: client,
41 | cursor: 0,
42 | viewport: 0,
43 | }
44 | }
45 |
46 | // Init initializes the tree view
47 | func (tv *TreeView) Init() tea.Cmd {
48 | return tv.loadRootNode()
49 | }
50 |
51 | func (tv *TreeView) SetSize(width, height int) {
52 | tv.width = width
53 | tv.height = height
54 | tv.container = NewViewContainer(width, height)
55 | }
56 |
57 | // Update handles messages for the tree view
58 | func (tv *TreeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 | switch msg := msg.(type) {
60 | case tea.KeyMsg:
61 | switch msg.String() {
62 | case "up", "k":
63 | if tv.cursor > 0 {
64 | tv.cursor--
65 | tv.adjustViewport()
66 | }
67 | case "down", "j":
68 | if tv.cursor < len(tv.FlattenedTree)-1 {
69 | tv.cursor++
70 | tv.adjustViewport()
71 | }
72 | case "page_up":
73 | _, contentHeight := tv.container.GetContentDimensions()
74 | if tv.container == nil {
75 | contentHeight = tv.height
76 | }
77 | // Adjust for pagination line if needed
78 | availableHeight := contentHeight
79 | if len(tv.FlattenedTree) > contentHeight {
80 | availableHeight = contentHeight - 1
81 | }
82 | tv.cursor -= availableHeight
83 | if tv.cursor < 0 {
84 | tv.cursor = 0
85 | }
86 | tv.adjustViewport()
87 | case "page_down":
88 | _, contentHeight := tv.container.GetContentDimensions()
89 | if tv.container == nil {
90 | contentHeight = tv.height
91 | }
92 | // Adjust for pagination line if needed
93 | availableHeight := contentHeight
94 | if len(tv.FlattenedTree) > contentHeight {
95 | availableHeight = contentHeight - 1
96 | }
97 | tv.cursor += availableHeight
98 | if tv.cursor >= len(tv.FlattenedTree) {
99 | tv.cursor = len(tv.FlattenedTree) - 1
100 | }
101 | tv.adjustViewport()
102 | case "home":
103 | tv.cursor = 0
104 | tv.adjustViewport()
105 | case "end":
106 | tv.cursor = len(tv.FlattenedTree) - 1
107 | tv.adjustViewport()
108 | case "right", "l":
109 | return tv, tv.expandNode()
110 | case "left", "h":
111 | return tv, tv.collapseNode()
112 | case "enter":
113 | return tv, tv.viewRecord()
114 | }
115 |
116 | case RootNodeLoadedMsg:
117 | tv.root = msg.Node
118 | tv.loading = false
119 | tv.rebuildFlattenedTree()
120 | // Automatically expand the root node to show immediate children
121 | if tv.root != nil && !tv.root.IsLoaded {
122 | return tv, tv.loadChildren(tv.root)
123 | }
124 | return tv, SendStatus("Tree loaded")
125 |
126 | case NodeChildrenLoadedMsg:
127 | tv.rebuildFlattenedTree()
128 | tv.loading = false
129 | return tv, SendStatus(fmt.Sprintf("Loaded children for %s", msg.Node.Name))
130 |
131 | case LoadingTimerTickMsg:
132 | if tv.loading {
133 | // Update elapsed time for display and continue the timer
134 | tv.loadingElapsed = msg.Time.Sub(tv.loadingStartTime)
135 | return tv, tv.timerTickCmd()
136 | }
137 | // If not loading anymore, don't continue the timer
138 | return tv, nil
139 |
140 | case tea.Msg:
141 | // Handle other message types
142 | }
143 |
144 | return tv, nil
145 | }
146 |
147 | func (tv *TreeView) View() string {
148 | if tv.container == nil {
149 | tv.container = NewViewContainer(tv.width, tv.height)
150 | }
151 |
152 | if tv.loading {
153 | // Calculate elapsed time and format the message
154 | elapsed := tv.loadingElapsed
155 | if !tv.loadingStartTime.IsZero() {
156 | elapsed = time.Since(tv.loadingStartTime)
157 | }
158 |
159 | // Format elapsed time as seconds with 1 decimal place
160 | elapsedSeconds := elapsed.Seconds()
161 | loadingMsg := fmt.Sprintf("Loading LDAP tree... (%0.1fs)", elapsedSeconds)
162 |
163 | return tv.container.RenderCentered(loadingMsg)
164 | }
165 |
166 | if len(tv.FlattenedTree) == 0 {
167 | return tv.container.RenderCentered("No entries found")
168 | }
169 |
170 | // Get content dimensions
171 | contentWidth, contentHeight := tv.container.GetContentDimensions()
172 |
173 | // Reserve space for pagination info if there are more items than fit on screen
174 | availableHeight := contentHeight
175 | showPagination := len(tv.FlattenedTree) > contentHeight
176 | if showPagination {
177 | availableHeight = contentHeight - 1 // Reserve 1 line for pagination info
178 | }
179 |
180 | var lines []string
181 | visibleStart := tv.viewport
182 | visibleEnd := visibleStart + availableHeight
183 | if visibleEnd > len(tv.FlattenedTree) {
184 | visibleEnd = len(tv.FlattenedTree)
185 | }
186 |
187 | for i := visibleStart; i < visibleEnd; i++ {
188 | item := tv.FlattenedTree[i]
189 | line := tv.renderTreeItem(item, i == tv.cursor, contentWidth)
190 |
191 | // Wrap with clickable zone
192 | zoneID := fmt.Sprintf("tree-item-%d", i)
193 | line = zone.Mark(zoneID, line)
194 |
195 | lines = append(lines, line)
196 | }
197 |
198 | content := strings.Join(lines, "\n")
199 |
200 | // Add pagination info if applicable
201 | if showPagination {
202 | paginationInfo := lipgloss.NewStyle().
203 | Foreground(lipgloss.Color("8")).
204 | Italic(true).
205 | Render(fmt.Sprintf("Showing %d-%d of %d entries", visibleStart+1, visibleEnd, len(tv.FlattenedTree)))
206 | content += "\n" + paginationInfo
207 | }
208 |
209 | return tv.container.RenderWithPadding(content)
210 | }
211 |
212 | // renderTreeItem renders a single tree item
213 | func (tv *TreeView) renderTreeItem(item *TreeItem, isCursor bool, contentWidth int) string {
214 | indent := strings.Repeat(" ", item.Level)
215 |
216 | var prefix string
217 | if item.Node.Children != nil {
218 | if len(item.Node.Children) > 0 {
219 | prefix = "[-] "
220 | } else if item.Node.IsLoaded {
221 | prefix = "[·] "
222 | } else {
223 | prefix = "[+] "
224 | }
225 | } else {
226 | prefix = "[+] "
227 | }
228 |
229 | name := item.Node.Name
230 | if name == "" {
231 | name = item.Node.DN
232 | }
233 |
234 | content := indent + prefix + name
235 |
236 | style := lipgloss.NewStyle()
237 | if isCursor {
238 | style = style.Background(lipgloss.Color(GetGradientColor(0.5))).Foreground(lipgloss.Color("15"))
239 | }
240 |
241 | // Truncate if too long
242 | if contentWidth > 5 && len(content) > contentWidth-2 {
243 | content = content[:contentWidth-5] + "..."
244 | }
245 |
246 | return style.Width(contentWidth).Render(content)
247 | }
248 |
249 | // loadRootNode loads the root node of the tree
250 | func (tv *TreeView) loadRootNode() tea.Cmd {
251 | tv.loading = true
252 | tv.loadingStartTime = time.Now()
253 | tv.loadingElapsed = 0
254 |
255 | // Return both the loading operation and the timer tick
256 | return tea.Batch(
257 | func() tea.Msg {
258 | root, err := tv.client.BuildTree()
259 | if err != nil {
260 | return ErrorMsg{Err: err}
261 | }
262 | return RootNodeLoadedMsg{Node: root}
263 | },
264 | tv.timerTickCmd(),
265 | )
266 | }
267 |
268 | // expandNode expands the current node
269 | func (tv *TreeView) expandNode() tea.Cmd {
270 | if tv.cursor >= len(tv.FlattenedTree) {
271 | return nil
272 | }
273 |
274 | item := tv.FlattenedTree[tv.cursor]
275 | node := item.Node
276 |
277 | if node.IsLoaded && len(node.Children) == 0 {
278 | return SendStatus("No children to expand")
279 | }
280 |
281 | if node.IsLoaded {
282 | return SendStatus("Node already expanded")
283 | }
284 |
285 | tv.loading = true
286 | tv.loadingStartTime = time.Now()
287 | tv.loadingElapsed = 0
288 |
289 | // Return both the loading operation and the timer tick
290 | return tea.Batch(
291 | func() tea.Msg {
292 | err := tv.client.LoadChildren(node)
293 | if err != nil {
294 | return ErrorMsg{Err: err}
295 | }
296 | return NodeChildrenLoadedMsg{Node: node}
297 | },
298 | tv.timerTickCmd(),
299 | )
300 | }
301 |
302 | // collapseNode collapses the current node
303 | func (tv *TreeView) collapseNode() tea.Cmd {
304 | if tv.cursor >= len(tv.FlattenedTree) {
305 | return nil
306 | }
307 |
308 | item := tv.FlattenedTree[tv.cursor]
309 | node := item.Node
310 |
311 | if !node.IsLoaded || len(node.Children) == 0 {
312 | return SendStatus("No children to collapse")
313 | }
314 |
315 | // Mark as not loaded to collapse
316 | node.IsLoaded = false
317 | tv.rebuildFlattenedTree()
318 |
319 | return SendStatus("Node collapsed")
320 | }
321 |
322 | // viewRecord shows the record for the current node
323 | func (tv *TreeView) viewRecord() tea.Cmd {
324 | if tv.cursor >= len(tv.FlattenedTree) {
325 | return nil
326 | }
327 |
328 | item := tv.FlattenedTree[tv.cursor]
329 | node := item.Node
330 |
331 | return func() tea.Msg {
332 | entry, err := tv.client.GetEntry(node.DN)
333 | if err != nil {
334 | return ErrorMsg{Err: err}
335 | }
336 | return ShowRecordMsg{Entry: entry}
337 | }
338 | }
339 |
340 | // rebuildFlattenedTree rebuilds the flattened tree for display
341 | func (tv *TreeView) rebuildFlattenedTree() {
342 | tv.FlattenedTree = nil
343 | if tv.root != nil {
344 | tv.flattenTreeNode(tv.root, 0, true)
345 | }
346 | }
347 |
348 | // adjustViewport adjusts the viewport to keep the cursor visible
349 | func (tv *TreeView) adjustViewport() {
350 | // Use content height for viewport calculations
351 | _, contentHeight := tv.container.GetContentDimensions()
352 | if tv.container == nil {
353 | contentHeight = tv.height
354 | }
355 |
356 | // Adjust for pagination line if needed
357 | availableHeight := contentHeight
358 | if len(tv.FlattenedTree) > contentHeight {
359 | availableHeight = contentHeight - 1 // Reserve 1 line for pagination info
360 | }
361 |
362 | if tv.cursor < tv.viewport {
363 | tv.viewport = tv.cursor
364 | } else if tv.cursor >= tv.viewport+availableHeight {
365 | tv.viewport = tv.cursor - availableHeight + 1
366 | }
367 |
368 | if tv.viewport < 0 {
369 | tv.viewport = 0
370 | }
371 | }
372 |
373 | // flattenTreeNode recursively flattens the tree structure
374 | func (tv *TreeView) flattenTreeNode(node *ldap.TreeNode, level int, isLast bool) {
375 | item := &TreeItem{
376 | Node: node,
377 | Level: level,
378 | IsLast: isLast,
379 | }
380 | tv.FlattenedTree = append(tv.FlattenedTree, item)
381 |
382 | if node.IsLoaded && node.Children != nil {
383 | for i, child := range node.Children {
384 | isLastChild := i == len(node.Children)-1
385 | tv.flattenTreeNode(child, level+1, isLastChild)
386 | }
387 | }
388 | }
389 |
390 | // Custom messages for tree view
391 | type RootNodeLoadedMsg struct {
392 | Node *ldap.TreeNode
393 | }
394 |
395 | type NodeChildrenLoadedMsg struct {
396 | Node *ldap.TreeNode
397 | }
398 |
399 | type LoadingTimerTickMsg struct {
400 | Time time.Time
401 | }
402 |
403 | // timerTickCmd returns a command that sends timer tick messages every 100ms
404 | func (tv *TreeView) timerTickCmd() tea.Cmd {
405 | return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
406 | return LoadingTimerTickMsg{Time: t}
407 | })
408 | }
409 |
410 | // loadChildren loads children for a specific node
411 | func (tv *TreeView) loadChildren(node *ldap.TreeNode) tea.Cmd {
412 | if node == nil || node.IsLoaded {
413 | return nil
414 | }
415 |
416 | tv.loading = true
417 | tv.loadingStartTime = time.Now()
418 | tv.loadingElapsed = 0
419 |
420 | // Return both the loading operation and the timer tick
421 | return tea.Batch(
422 | func() tea.Msg {
423 | err := tv.client.LoadChildren(node)
424 | if err != nil {
425 | return ErrorMsg{Err: err}
426 | }
427 | return NodeChildrenLoadedMsg{Node: node}
428 | },
429 | tv.timerTickCmd(),
430 | )
431 | }
432 |
--------------------------------------------------------------------------------
/internal/ldap/client.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "crypto/tls"
5 | "errors"
6 | "fmt"
7 | "net"
8 | "strings"
9 | "time"
10 |
11 | "github.com/go-ldap/ldap/v3"
12 | )
13 |
14 | // Client wraps the LDAP connection and provides higher-level operations
15 | type Client struct {
16 | conn *ldap.Conn
17 | baseDN string
18 | config Config // Store the configuration for reconnection
19 | }
20 |
21 | // Config contains LDAP connection parameters
22 | type Config struct {
23 | Host string
24 | Port int
25 | BaseDN string
26 | UseSSL bool
27 | UseTLS bool
28 | BindUser string
29 | BindPass string
30 | RetryEnabled bool
31 | MaxRetries int
32 | InitialDelayMs int
33 | MaxDelayMs int
34 | }
35 |
36 | // Entry represents an LDAP entry with its attributes
37 | type Entry struct {
38 | DN string
39 | Attributes map[string][]string
40 | }
41 |
42 | // SearchPage represents a page of search results with pagination info
43 | type SearchPage struct {
44 | Entries []*Entry
45 | HasMore bool
46 | Cookie []byte
47 | PageSize uint32
48 | TotalCount int // -1 if unknown
49 | }
50 |
51 | // TreeNode represents a node in the LDAP tree
52 | type TreeNode struct {
53 | DN string
54 | Name string
55 | Children []*TreeNode
56 | IsLoaded bool
57 | }
58 |
59 | // NewClient creates a new LDAP client
60 | func NewClient(config Config) (*Client, error) {
61 | var conn *ldap.Conn
62 | var err error
63 |
64 | address := fmt.Sprintf("%s:%d", config.Host, config.Port)
65 |
66 | if config.UseSSL {
67 | conn, err = ldap.DialTLS("tcp", address, &tls.Config{InsecureSkipVerify: true})
68 | } else {
69 | conn, err = ldap.Dial("tcp", address)
70 | }
71 |
72 | if err != nil {
73 | return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
74 | }
75 |
76 | if config.UseTLS && !config.UseSSL {
77 | err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
78 | if err != nil {
79 | conn.Close()
80 | return nil, fmt.Errorf("failed to start TLS: %w", err)
81 | }
82 | }
83 |
84 | client := &Client{
85 | conn: conn,
86 | baseDN: config.BaseDN,
87 | config: config, // Store config for reconnection
88 | }
89 |
90 | // Bind with provided credentials
91 | if config.BindUser != "" {
92 | err = conn.Bind(config.BindUser, config.BindPass)
93 | if err != nil {
94 | conn.Close()
95 | return nil, fmt.Errorf("failed to bind: %w", err)
96 | }
97 | }
98 |
99 | return client, nil
100 | }
101 |
102 | // isRetryableError checks if an error is retryable (connection-related)
103 | func (c *Client) isRetryableError(err error) bool {
104 | if err == nil {
105 | return false
106 | }
107 |
108 | // Check for common connection errors
109 | var netErr net.Error
110 | if errors.As(err, &netErr) {
111 | return true // Network errors are retryable
112 | }
113 |
114 | // Check for LDAP-specific connection errors
115 | if ldapErr, ok := err.(*ldap.Error); ok {
116 | switch ldapErr.ResultCode {
117 | case ldap.LDAPResultServerDown,
118 | ldap.LDAPResultConnectError,
119 | ldap.LDAPResultUnavailable,
120 | ldap.LDAPResultUnwillingToPerform:
121 | return true
122 | }
123 | }
124 |
125 | // Check for common error strings that indicate connection issues
126 | errStr := strings.ToLower(err.Error())
127 | retryableErrors := []string{
128 | "connection closed",
129 | "connection reset",
130 | "broken pipe",
131 | "connection refused",
132 | "network is unreachable",
133 | "timeout",
134 | "server down",
135 | "ldap: connection closed",
136 | }
137 |
138 | for _, retryErr := range retryableErrors {
139 | if strings.Contains(errStr, retryErr) {
140 | return true
141 | }
142 | }
143 |
144 | return false
145 | }
146 |
147 | // reconnect attempts to re-establish the LDAP connection
148 | func (c *Client) reconnect() error {
149 | // Close existing connection if any
150 | if c.conn != nil {
151 | c.conn.Close()
152 | }
153 |
154 | // Re-establish connection using stored config
155 | var conn *ldap.Conn
156 | var err error
157 |
158 | address := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
159 |
160 | if c.config.UseSSL {
161 | conn, err = ldap.DialTLS("tcp", address, &tls.Config{InsecureSkipVerify: true})
162 | } else {
163 | conn, err = ldap.Dial("tcp", address)
164 | }
165 |
166 | if err != nil {
167 | return fmt.Errorf("failed to reconnect to LDAP server: %w", err)
168 | }
169 |
170 | if c.config.UseTLS && !c.config.UseSSL {
171 | err = conn.StartTLS(&tls.Config{InsecureSkipVerify: true})
172 | if err != nil {
173 | conn.Close()
174 | return fmt.Errorf("failed to start TLS on reconnect: %w", err)
175 | }
176 | }
177 |
178 | // Re-bind with credentials if needed
179 | if c.config.BindUser != "" {
180 | err = conn.Bind(c.config.BindUser, c.config.BindPass)
181 | if err != nil {
182 | conn.Close()
183 | return fmt.Errorf("failed to bind on reconnect: %w", err)
184 | }
185 | }
186 |
187 | c.conn = conn
188 | return nil
189 | }
190 |
191 | // withRetry executes an operation with retry logic
192 | func (c *Client) withRetry(operation func() error) error {
193 | if !c.config.RetryEnabled {
194 | return operation()
195 | }
196 |
197 | var lastErr error
198 | delay := time.Duration(c.config.InitialDelayMs) * time.Millisecond
199 | maxDelay := time.Duration(c.config.MaxDelayMs) * time.Millisecond
200 |
201 | for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
202 | err := operation()
203 | if err == nil {
204 | return nil // Success
205 | }
206 |
207 | lastErr = err
208 |
209 | // Don't retry if it's the last attempt or error is not retryable
210 | if attempt == c.config.MaxRetries || !c.isRetryableError(err) {
211 | break
212 | }
213 |
214 | // Try to reconnect for retryable errors
215 | if reconnectErr := c.reconnect(); reconnectErr != nil {
216 | // If reconnection fails, continue with the original error
217 | // but don't attempt more retries
218 | break
219 | }
220 |
221 | // Wait before retrying
222 | time.Sleep(delay)
223 |
224 | // Exponential backoff with jitter (double the delay, cap at maxDelay)
225 | delay *= 2
226 | if delay > maxDelay {
227 | delay = maxDelay
228 | }
229 | }
230 |
231 | return lastErr
232 | }
233 |
234 | // Close closes the LDAP connection
235 | func (c *Client) Close() {
236 | if c.conn != nil {
237 | c.conn.Close()
238 | }
239 | }
240 |
241 | // Search performs an LDAP search
242 | func (c *Client) Search(baseDN, filter string, scope int, attributes []string) ([]*Entry, error) {
243 | var result *ldap.SearchResult
244 | var entries []*Entry
245 |
246 | err := c.withRetry(func() error {
247 | searchRequest := ldap.NewSearchRequest(
248 | baseDN,
249 | scope,
250 | ldap.NeverDerefAliases,
251 | 0, // No size limit
252 | 0, // No time limit
253 | false,
254 | filter,
255 | attributes,
256 | nil,
257 | )
258 |
259 | var err error
260 | result, err = c.conn.Search(searchRequest)
261 | if err != nil {
262 | return fmt.Errorf("search failed: %w", err)
263 | }
264 |
265 | // Process results inside the retry function to ensure consistency
266 | entries = make([]*Entry, 0, len(result.Entries))
267 | for _, entry := range result.Entries {
268 | e := &Entry{
269 | DN: entry.DN,
270 | Attributes: make(map[string][]string),
271 | }
272 |
273 | for _, attr := range entry.Attributes {
274 | e.Attributes[attr.Name] = attr.Values
275 | }
276 |
277 | entries = append(entries, e)
278 | }
279 |
280 | return nil
281 | })
282 |
283 | return entries, err
284 | }
285 |
286 | // SearchPaged performs a paginated LDAP search
287 | func (c *Client) SearchPaged(baseDN, filter string, scope int, attributes []string, pageSize uint32, cookie []byte) (*SearchPage, error) {
288 | var result *ldap.SearchResult
289 | var searchPage *SearchPage
290 |
291 | err := c.withRetry(func() error {
292 | // Create paging control
293 | pagingControl := ldap.NewControlPaging(pageSize)
294 | if cookie != nil {
295 | pagingControl.SetCookie(cookie)
296 | }
297 |
298 | searchRequest := ldap.NewSearchRequest(
299 | baseDN,
300 | scope,
301 | ldap.NeverDerefAliases,
302 | 0, // No size limit - controlled by paging
303 | 0, // No time limit
304 | false,
305 | filter,
306 | attributes,
307 | []ldap.Control{pagingControl},
308 | )
309 |
310 | var err error
311 | result, err = c.conn.Search(searchRequest)
312 | if err != nil {
313 | return fmt.Errorf("paged search failed: %w", err)
314 | }
315 |
316 | // Parse entries inside the retry function
317 | entries := make([]*Entry, 0, len(result.Entries))
318 | for _, entry := range result.Entries {
319 | e := &Entry{
320 | DN: entry.DN,
321 | Attributes: make(map[string][]string),
322 | }
323 |
324 | for _, attr := range entry.Attributes {
325 | e.Attributes[attr.Name] = attr.Values
326 | }
327 |
328 | entries = append(entries, e)
329 | }
330 |
331 | // Extract paging control from response
332 | var nextCookie []byte
333 | hasMore := false
334 |
335 | for _, control := range result.Controls {
336 | if control.GetControlType() == ldap.ControlTypePaging {
337 | if pagingResult, ok := control.(*ldap.ControlPaging); ok {
338 | nextCookie = pagingResult.Cookie
339 | hasMore = len(nextCookie) > 0
340 | }
341 | break
342 | }
343 | }
344 |
345 | searchPage = &SearchPage{
346 | Entries: entries,
347 | HasMore: hasMore,
348 | Cookie: nextCookie,
349 | PageSize: pageSize,
350 | TotalCount: -1, // LDAP doesn't provide total count
351 | }
352 |
353 | return nil
354 | })
355 |
356 | return searchPage, err
357 | }
358 |
359 | // GetChildren returns immediate children of a DN
360 | func (c *Client) GetChildren(dn string) ([]*TreeNode, error) {
361 | searchDN := dn
362 | if searchDN == "" {
363 | searchDN = c.baseDN
364 | }
365 |
366 | entries, err := c.Search(searchDN, "(objectClass=*)", ldap.ScopeSingleLevel, []string{"dn"})
367 | if err != nil {
368 | return nil, err
369 | }
370 |
371 | nodes := make([]*TreeNode, 0, len(entries))
372 | for _, entry := range entries {
373 | name := extractName(entry.DN, searchDN)
374 | node := &TreeNode{
375 | DN: entry.DN,
376 | Name: name,
377 | Children: nil,
378 | IsLoaded: false,
379 | }
380 | nodes = append(nodes, node)
381 | }
382 |
383 | return nodes, nil
384 | }
385 |
386 | // GetEntry retrieves a specific LDAP entry with all its attributes
387 | func (c *Client) GetEntry(dn string) (*Entry, error) {
388 | entries, err := c.Search(dn, "(objectClass=*)", ldap.ScopeBaseObject, []string{"*", "+"})
389 | if err != nil {
390 | return nil, err
391 | }
392 |
393 | if len(entries) == 0 {
394 | return nil, fmt.Errorf("entry not found: %s", dn)
395 | }
396 |
397 | return entries[0], nil
398 | }
399 |
400 | // BuildTree builds the complete LDAP tree starting from baseDN
401 | func (c *Client) BuildTree() (*TreeNode, error) {
402 | root := &TreeNode{
403 | DN: c.baseDN,
404 | Name: extractName(c.baseDN, ""),
405 | Children: nil,
406 | IsLoaded: false,
407 | }
408 |
409 | return root, nil
410 | }
411 |
412 | // LoadChildren loads children for a tree node if not already loaded
413 | func (c *Client) LoadChildren(node *TreeNode) error {
414 | if node.IsLoaded {
415 | return nil
416 | }
417 |
418 | children, err := c.GetChildren(node.DN)
419 | if err != nil {
420 | return err
421 | }
422 |
423 | node.Children = children
424 | node.IsLoaded = true
425 | return nil
426 | }
427 |
428 | // CustomSearch performs a custom LDAP search with user-provided filter
429 | func (c *Client) CustomSearch(filter string) ([]*Entry, error) {
430 | return c.Search(c.baseDN, filter, ldap.ScopeWholeSubtree, []string{"*"})
431 | }
432 |
433 | // CustomSearchPaged performs a paginated custom LDAP search with user-provided filter
434 | func (c *Client) CustomSearchPaged(filter string, pageSize uint32, cookie []byte) (*SearchPage, error) {
435 | return c.SearchPaged(c.baseDN, filter, ldap.ScopeWholeSubtree, []string{"*"}, pageSize, cookie)
436 | }
437 |
438 | // extractName extracts the relative name from a DN
439 | func extractName(dn, baseDN string) string {
440 | if baseDN != "" && strings.HasSuffix(dn, baseDN) {
441 | relativeDN := strings.TrimSuffix(dn, ","+baseDN)
442 | if relativeDN == baseDN {
443 | return dn // This is the base DN itself
444 | }
445 | // Extract the first component
446 | parts := strings.Split(relativeDN, ",")
447 | if len(parts) > 0 {
448 | return strings.TrimSpace(parts[0])
449 | }
450 | }
451 |
452 | // If we can't extract relative name, use the first component of the DN
453 | parts := strings.Split(dn, ",")
454 | if len(parts) > 0 {
455 | return strings.TrimSpace(parts[0])
456 | }
457 |
458 | return dn
459 | }
460 |
--------------------------------------------------------------------------------
/internal/tui/query_test.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/atotto/clipboard"
8 | "github.com/charmbracelet/bubbletea"
9 | "github.com/ericschmar/moribito/internal/ldap"
10 | )
11 |
12 | func TestQueryView_NumberInputInQueryMode(t *testing.T) {
13 | // Create a mock client (nil is fine for this test as we're not executing queries)
14 | var client *ldap.Client
15 | qv := NewQueryView(client)
16 |
17 | // Ensure we start in input mode
18 | if !qv.IsInputMode() {
19 | t.Fatal("QueryView should start in input mode")
20 | }
21 |
22 | // Test that number keys are added to the query when in input mode
23 | testCases := []struct {
24 | key string
25 | expected string
26 | }{
27 | {"1", "1"},
28 | {"2", "2"},
29 | {"3", "3"},
30 | {"0", "0"},
31 | }
32 |
33 | for _, tc := range testCases {
34 | // Reset query
35 | qv.textarea.SetValue("")
36 |
37 | // Create key message
38 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(tc.key[0])}}
39 |
40 | // Update should handle the key and add it to query
41 | _, _ = qv.handleInputMode(keyMsg)
42 |
43 | if qv.textarea.Value() != tc.expected {
44 | t.Errorf("Expected query to contain '%s' after pressing '%s', got '%s'", tc.expected, tc.key, qv.textarea.Value())
45 | }
46 | }
47 | }
48 |
49 | func TestQueryView_IsInputMode(t *testing.T) {
50 | var client *ldap.Client
51 | qv := NewQueryView(client)
52 |
53 | // Should start in input mode
54 | if !qv.IsInputMode() {
55 | t.Error("QueryView should start in input mode")
56 | }
57 |
58 | // Simulate switching to browse mode (this happens after query execution)
59 | qv.inputMode = false
60 |
61 | if qv.IsInputMode() {
62 | t.Error("IsInputMode should return false when inputMode is false")
63 | }
64 | }
65 |
66 | func TestQueryView_NumberKeysInBrowseMode(t *testing.T) {
67 | var client *ldap.Client
68 | qv := NewQueryView(client)
69 | qv.inputMode = false // Set to browse mode
70 |
71 | // In browse mode, number keys should not affect the query
72 | originalQuery := qv.textarea.Value()
73 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}
74 |
75 | _, _ = qv.handleBrowseMode(keyMsg)
76 |
77 | if qv.textarea.Value() != originalQuery {
78 | t.Error("Query should not be modified by number keys in browse mode")
79 | }
80 | }
81 |
82 | func TestQueryView_PasteWithCmdVInQueryMode(t *testing.T) {
83 | var client *ldap.Client
84 | qv := NewQueryView(client)
85 | originalValue := qv.textarea.Value()
86 |
87 | // Set clipboard content for testing
88 | testContent := "person)"
89 | err := clipboard.WriteAll(testContent)
90 | if err != nil {
91 | t.Skipf("Clipboard not available in test environment: %v", err)
92 | }
93 |
94 | // Test that cmd+v case is included in the switch statement for query mode
95 | testKeyString := "cmd+v"
96 |
97 | // Simulate what happens in handleInputMode switch statement for cmd+v
98 | switch testKeyString {
99 | case "ctrl+v", "cmd+v", "shift+insert", "insert":
100 | if clipboardText, err := clipboard.ReadAll(); err == nil {
101 | qv.textarea.SetValue(qv.textarea.Value() + clipboardText)
102 | }
103 | }
104 |
105 | expected := originalValue + testContent
106 | if qv.textarea.Value() != expected {
107 | t.Errorf("Expected query to be '%s' after cmd+v paste, got '%s'", expected, qv.textarea.Value())
108 | }
109 | }
110 |
111 | func TestQueryView_PasteInQueryMode(t *testing.T) {
112 | var client *ldap.Client
113 | qv := NewQueryView(client)
114 | qv.textarea.SetValue("(objectClass=")
115 |
116 | // Set clipboard content for testing
117 | testContent := "person)"
118 | err := clipboard.WriteAll(testContent)
119 | if err != nil {
120 | t.Skipf("Clipboard not available in test environment: %v", err)
121 | }
122 |
123 | // Create ctrl+v key message
124 | keyMsg := tea.KeyMsg{Type: tea.KeyCtrlV}
125 |
126 | // Update should handle the paste
127 | _, _ = qv.handleInputMode(keyMsg)
128 |
129 | expected := "(objectClass=person)"
130 | if qv.textarea.Value() != expected {
131 | t.Errorf("Expected query to be '%s' after paste, got '%s'", expected, qv.textarea.Value())
132 | }
133 | }
134 |
135 | func TestQueryView_PasteWithInsertKey(t *testing.T) {
136 | var client *ldap.Client
137 | qv := NewQueryView(client)
138 | qv.textarea.SetValue("(objectClass=")
139 |
140 | // Set clipboard content for testing
141 | testContent := "person)"
142 | err := clipboard.WriteAll(testContent)
143 | if err != nil {
144 | t.Skipf("Clipboard not available in test environment: %v", err)
145 | }
146 |
147 | // Test insert key message
148 | keyMsg := tea.KeyMsg{Type: tea.KeyInsert}
149 |
150 | // Update should handle the paste
151 | _, _ = qv.handleInputMode(keyMsg)
152 |
153 | expected := "(objectClass=person)"
154 | if qv.textarea.Value() != expected {
155 | t.Errorf("Expected query to be '%s' after insert paste, got '%s'", expected, qv.textarea.Value())
156 | }
157 | }
158 |
159 | func TestQueryView_PasteWithInsert(t *testing.T) {
160 | var client *ldap.Client
161 | qv := NewQueryView(client)
162 | qv.textarea.SetValue("(objectClass=")
163 |
164 | // Set clipboard content for testing
165 | testContent := "person)"
166 | err := clipboard.WriteAll(testContent)
167 | if err != nil {
168 | t.Skipf("Clipboard not available in test environment: %v", err)
169 | }
170 |
171 | // Test insert key message
172 | keyMsg := tea.KeyMsg{Type: tea.KeyInsert}
173 |
174 | // Update should handle the paste
175 | _, _ = qv.handleInputMode(keyMsg)
176 |
177 | expected := "(objectClass=person)"
178 | if qv.textarea.Value() != expected {
179 | t.Errorf("Expected query to be '%s' after insert paste, got '%s'", expected, qv.textarea.Value())
180 | }
181 | }
182 |
183 | func TestQueryView_ExistingFunctionalityPreserved(t *testing.T) {
184 | var client *ldap.Client
185 | qv := NewQueryView(client)
186 | qv.textarea.SetValue("test")
187 |
188 | // Test that ctrl+u still works (clears the textarea)
189 | keyMsg := tea.KeyMsg{Type: tea.KeyCtrlU}
190 | _, _ = qv.handleInputMode(keyMsg)
191 | if qv.textarea.Value() != "" {
192 | t.Errorf("Expected query to be empty after ctrl+u, got '%s'", qv.textarea.Value())
193 | }
194 |
195 | // Test that regular character input still works
196 | keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
197 | _, _ = qv.handleInputMode(keyMsg)
198 | if qv.textarea.Value() != "a" {
199 | t.Errorf("Expected query to be 'a' after character input, got '%s'", qv.textarea.Value())
200 | }
201 | }
202 |
203 | func TestQueryView_ReturnToInputModeFromBrowseMode(t *testing.T) {
204 | var client *ldap.Client
205 | qv := NewQueryView(client)
206 |
207 | // Start in input mode
208 | if !qv.IsInputMode() {
209 | t.Fatal("QueryView should start in input mode")
210 | }
211 |
212 | // Simulate query execution by switching to browse mode (like the Update method does)
213 | qv.inputMode = false
214 | qv.results = []*ldap.Entry{{DN: "test=example,dc=test"}}
215 | qv.buildResultLines()
216 |
217 | // Verify we're in browse mode
218 | if qv.IsInputMode() {
219 | t.Fatal("Should be in browse mode after simulating query execution")
220 | }
221 |
222 | // Test escape key to return to input mode
223 | escapeMsg := tea.KeyMsg{Type: tea.KeyEscape}
224 | _, _ = qv.handleBrowseMode(escapeMsg)
225 |
226 | if !qv.IsInputMode() {
227 | t.Error("Should be back in input mode after pressing escape")
228 | }
229 |
230 | // Test slash key to return to input mode
231 | qv.inputMode = false // Set back to browse mode
232 | slashMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
233 | _, _ = qv.handleBrowseMode(slashMsg)
234 |
235 | if !qv.IsInputMode() {
236 | t.Error("Should be back in input mode after pressing '/'")
237 | }
238 |
239 | // Test that we can type in the search box after returning to input mode
240 | qv.textarea.SetValue("")
241 | testMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t', 'e', 's', 't'}}
242 | for _, r := range testMsg.Runes {
243 | charMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}
244 | _, _ = qv.handleInputMode(charMsg)
245 | }
246 |
247 | if qv.textarea.Value() != "test" {
248 | t.Errorf("Should be able to type in search box after returning to input mode, got '%s'", qv.textarea.Value())
249 | }
250 | }
251 |
252 | func TestQueryView_MultiLineQuerySupport(t *testing.T) {
253 | var client *ldap.Client
254 | qv := NewQueryView(client)
255 |
256 | // Test multi-line query input
257 | multiLineQuery := "(&\n (objectClass=person)\n (cn=test*)\n)"
258 | qv.textarea.SetValue(multiLineQuery)
259 |
260 | // Verify the textarea contains the multi-line query
261 | if qv.textarea.Value() != multiLineQuery {
262 | t.Errorf("Expected textarea to contain multi-line query, got: %q", qv.textarea.Value())
263 | }
264 |
265 | // Test that query execution works with multi-line input
266 | // We don't actually execute since we don't have a real LDAP client,
267 | // but we can verify that the executeQuery method gets the right value
268 | query := strings.TrimSpace(qv.textarea.Value())
269 | expectedQuery := strings.TrimSpace(multiLineQuery)
270 | if query != expectedQuery {
271 | t.Errorf("Expected trimmed query to be %q, got %q", expectedQuery, query)
272 | }
273 | }
274 |
275 | func TestQueryView_TextareaKeyBindings(t *testing.T) {
276 | var client *ldap.Client
277 | qv := NewQueryView(client)
278 |
279 | // Test that Enter adds a newline (handled by textarea)
280 | qv.textarea.SetValue("line1")
281 | enterMsg := tea.KeyMsg{Type: tea.KeyEnter}
282 | _, _ = qv.handleInputMode(enterMsg)
283 |
284 | // The textarea should handle the enter and add a newline
285 | if !strings.Contains(qv.textarea.Value(), "line1\n") && qv.textarea.Value() != "line1" {
286 | // Either it added a newline or it's still the original value
287 | // This is dependent on the textarea implementation
288 | }
289 |
290 | // Test that Ctrl+Enter is used for execution (not regular Enter)
291 | qv.textarea.SetValue("(objectClass=*)")
292 | // We can't easily test execution without mocking, but we can verify
293 | // that ctrl+enter doesn't get passed to textarea
294 | ctrlEnterMsg := tea.KeyMsg{Type: tea.KeyCtrlJ} // Ctrl+J is an alternative
295 | _, cmd := qv.handleInputMode(ctrlEnterMsg)
296 |
297 | // Should return a command for query execution, not nil
298 | if cmd == nil {
299 | t.Error("Ctrl+J should trigger query execution")
300 | }
301 | }
302 |
303 | func TestQueryView_FormatLdapQuery(t *testing.T) {
304 | var client *ldap.Client
305 | qv := NewQueryView(client)
306 |
307 | testCases := []struct {
308 | name string
309 | input string
310 | expected string
311 | }{
312 | {
313 | name: "Simple filter - no formatting needed",
314 | input: "(objectClass=person)",
315 | expected: "(objectClass=person)",
316 | },
317 | {
318 | name: "Empty query",
319 | input: "",
320 | expected: "",
321 | },
322 | {
323 | name: "Whitespace only",
324 | input: " ",
325 | expected: "",
326 | },
327 | {
328 | name: "Simple AND filter",
329 | input: "(&(objectClass=person)(cn=john))",
330 | expected: "(&\n (objectClass=person)\n (cn=john)\n)",
331 | },
332 | {
333 | name: "Simple OR filter",
334 | input: "(|(cn=john)(sn=smith))",
335 | expected: "(|\n (cn=john)\n (sn=smith)\n)",
336 | },
337 | {
338 | name: "Complex nested filter",
339 | input: "(&(objectClass=person)(|(cn=john*)(sn=smith*))(department=engineering))",
340 | expected: "(&\n (objectClass=person)\n (|\n (cn=john*)\n (sn=smith*)\n )\n (department=engineering)\n)",
341 | },
342 | {
343 | name: "NOT filter",
344 | input: "(!((objectClass=computer)))",
345 | expected: "(!\n ((objectClass=computer))\n)",
346 | },
347 | {
348 | name: "Invalid filter - no parentheses",
349 | input: "objectClass=person",
350 | expected: "objectClass=person",
351 | },
352 | {
353 | name: "Malformed filter - return as-is",
354 | input: "(&(objectClass=person",
355 | expected: "(&(objectClass=person",
356 | },
357 | }
358 |
359 | for _, tc := range testCases {
360 | t.Run(tc.name, func(t *testing.T) {
361 | result := qv.formatLdapQuery(tc.input)
362 | if result != tc.expected {
363 | t.Errorf("Expected:\n%s\nGot:\n%s", tc.expected, result)
364 | }
365 | })
366 | }
367 | }
368 |
369 | func TestQueryView_FormatKeyBinding(t *testing.T) {
370 | var client *ldap.Client
371 | qv := NewQueryView(client)
372 |
373 | // Set a complex query
374 | complexQuery := "(&(objectClass=person)(|(cn=john*)(sn=smith*))(department=engineering))"
375 | qv.textarea.SetValue(complexQuery)
376 |
377 | // Create ctrl+f key message
378 | ctrlFMsg := tea.KeyMsg{Type: tea.KeyCtrlF}
379 |
380 | // Handle the key
381 | _, _ = qv.handleInputMode(ctrlFMsg)
382 |
383 | // Check that the query was formatted
384 | expected := "(&\n (objectClass=person)\n (|\n (cn=john*)\n (sn=smith*)\n )\n (department=engineering)\n)"
385 | if qv.textarea.Value() != expected {
386 | t.Errorf("Expected formatted query:\n%s\nGot:\n%s", expected, qv.textarea.Value())
387 | }
388 | }
389 |
390 | func TestQueryView_FormatPreservesSimpleQueries(t *testing.T) {
391 | var client *ldap.Client
392 | qv := NewQueryView(client)
393 |
394 | simpleQueries := []string{
395 | "(objectClass=person)",
396 | "(cn=john)",
397 | "(mail=*@example.com)",
398 | }
399 |
400 | for _, query := range simpleQueries {
401 | qv.textarea.SetValue(query)
402 |
403 | // Create ctrl+f key message
404 | ctrlFMsg := tea.KeyMsg{Type: tea.KeyCtrlF}
405 |
406 | // Handle the key
407 | _, _ = qv.handleInputMode(ctrlFMsg)
408 |
409 | // Simple queries should remain unchanged
410 | if qv.textarea.Value() != query {
411 | t.Errorf("Simple query '%s' should remain unchanged, got '%s'", query, qv.textarea.Value())
412 | }
413 | }
414 | }
415 |
--------------------------------------------------------------------------------