detached 780aff1

Merge branch '001-init-project'

commit
780aff17f21136bf43adc200eacf0a82be55bb28
author
bamen · · 2025-09-08 17:11 UTC
committer
bamen · · 2025-09-08 20:11 UTC
merge of
c1e4d71 + f83599b · diff against the first parent
signature
unsigned
53 files · +8649 -35
A .gitignore +59 -0
@@ -0,0 +1,59 @@
1+# Build outputs
2+dist/
3+*.exe
4+*.exe~
5+*.dll
6+*.so
7+*.dylib
8+
9+# Binaries for programs and plugins
10+dumber
11+dumber.exe
12+
13+# Test binary, built with `go test -c`
14+*.test
15+
16+# Output of the go coverage tool
17+*.out
18+
19+# Dependency directories (remove the comment below to include it)
20+# vendor/
21+
22+# Go workspace file
23+go.work
24+
25+# IDE files
26+.vscode/
27+.idea/
28+*.swp
29+*.swo
30+*~
31+
32+# OS generated files
33+.DS_Store
34+.DS_Store?
35+._*
36+.Spotlight-V100
37+.Trashes
38+ehthumbs.db
39+Thumbs.db
40+
41+# Temporary files
42+*.tmp
43+*.temp
44+*.log
45+
46+# Database files
47+*.db
48+*.sqlite
49+*.sqlite3
50+
51+# Configuration files that might contain secrets
52+config.local.*
53+.env
54+.env.local
55+.env.*.local
56+
57+# Build artifacts and caches
58+.cache/
59+*.prof⏎̸
A .golangci.yml +126 -0
@@ -0,0 +1,126 @@
1+version: "2"
2+
3+linters:
4+ default: none
5+ enable:
6+ - bodyclose # checks whether HTTP response body is closed successfully
7+ - copyloopvar # detects places where loop variables are copied
8+# - depguard # checks for unwanted dependencies (disabled for CLI apps)
9+ - dogsled # checks assignments with too many blank identifiers
10+ - dupl # tool for code clone detection
11+ - errcheck # checking for unchecked errors
12+ - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme
13+ - funlen # tool for detection of long functions
14+ - gocheckcompilerdirectives # validates go compiler directive comments (//go:)
15+# - gochecknoinits # checks that no init functions are present in Go code (disabled for CLI apps)
16+ - goconst # finds repeated strings that could be replaced by a constant
17+ - gocritic # provides diagnostics that check for bugs, performance and style issues
18+ - gocyclo # computes and checks the cyclomatic complexity of functions
19+ - godox # tool for detection of FIXME, TODO and other comment keywords
20+ - mnd # detects magic numbers
21+ - goprintffuncname # checks that printf-like functions are named with `f` at the end
22+ - gosec # inspects source code for security problems
23+ - govet # examines Go source code and reports suspicious constructs
24+ - ineffassign # detects when assignments to existing variables are not used
25+ - lll # reports long lines
26+ - misspell # finds commonly misspelled English words in comments
27+ - nakedret # finds naked returns in functions greater than a specified function length
28+ - nolintlint # reports ill-formed or insufficient nolint directives
29+ - revive # fast, configurable, extensible, flexible, and beautiful linter for Go
30+ - staticcheck # advanced Go linter
31+ - testifylint # checks usage of github.com/stretchr/testify
32+ - unconvert # removes unnecessary type conversions
33+ - unparam # reports unused function parameters
34+ - unused # checks for unused constants, variables, functions and types
35+ - whitespace # detects leading and trailing whitespace
36+
37+linters-settings:
38+ dupl:
39+ threshold: 100
40+ funlen:
41+ lines: -1 # don't check line count
42+ statements: 50
43+ goconst:
44+ min-len: 2
45+ min-occurrences: 3
46+ gocritic:
47+ enabled-tags:
48+ - diagnostic
49+ - experimental
50+ - opinionated
51+ - performance
52+ - style
53+ disabled-checks:
54+ - dupImport
55+ - ifElseChain
56+ - octalLiteral
57+ - whyNoLint
58+ gocyclo:
59+ min-complexity: 15
60+ godox:
61+ keywords:
62+ - FIXME
63+ - TODO
64+ - BUG
65+ mnd:
66+ checks:
67+ - argument
68+ - case
69+ - condition
70+ - return
71+ ignored-numbers:
72+ - '0'
73+ - '1'
74+ - '2'
75+ - '3'
76+ ignored-functions:
77+ - strings.SplitN
78+ govet:
79+ enable:
80+ - nilness
81+ - shadow
82+ errorlint:
83+ asserts: false
84+ lll:
85+ line-length: 120
86+ misspell:
87+ locale: US
88+ nolintlint:
89+ allow-unused: false
90+ require-explanation: true
91+ require-specific: true
92+ revive:
93+ rules:
94+ - name: indent-error-flow
95+ - name: unused-parameter
96+ - name: unused-receiver
97+
98+issues:
99+ exclude-rules:
100+ # Exclude some linters from running on tests files
101+ - path: _test\.go
102+ linters:
103+ - dupl
104+ - mnd
105+ - lll
106+ - funlen
107+
108+ # Exclude SQLC generated files from linting
109+ - path: internal/db/.*\.sql\.go
110+ linters:
111+ - all
112+
113+ # Exclude SQLC generated models from some linters
114+ - path: internal/db/models\.go
115+ linters:
116+ - unused
117+ - deadcode
118+
119+ # Allow long lines in generated code
120+ - path: internal/db/
121+ linters:
122+ - lll
123+
124+run:
125+ timeout: 5m
126+ modules-download-mode: readonly⏎̸
A Makefile +100 -0
@@ -0,0 +1,100 @@
1+# Makefile for dumber
2+
3+.PHONY: build test lint clean install-tools dev generate help
4+
5+# Variables
6+BINARY_NAME=dumber
7+MAIN_PATH=./cmd/dumber
8+DIST_DIR=dist
9+
10+# Version information from git
11+VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev")
12+COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
13+BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
14+
15+# Linker flags
16+LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
17+
18+# Default target
19+help: ## Show this help message
20+ @echo "Available targets:"
21+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
22+
23+# Build targets
24+build: ## Build the binary
25+ @echo "Building $(BINARY_NAME) $(VERSION)..."
26+ @mkdir -p $(DIST_DIR)
27+ CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
28+
29+build-wails: ## Build with CGO enabled for Wails (when needed)
30+ @echo "Building $(BINARY_NAME) $(VERSION) with CGO enabled..."
31+ @mkdir -p $(DIST_DIR)
32+ CGO_ENABLED=1 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
33+
34+# Development targets
35+dev: ## Run in development mode
36+ @echo "Running in development mode..."
37+ go run $(MAIN_PATH)
38+
39+generate: ## Generate code (SQLC)
40+ @echo "Generating code with SQLC..."
41+ sqlc generate
42+ @echo "Code generation complete"
43+
44+# Testing
45+test: ## Run tests
46+ @echo "Running tests..."
47+ CGO_ENABLED=0 go test -v ./...
48+
49+test-race: ## Run tests with race detection
50+ @echo "Running tests with race detection..."
51+ CGO_ENABLED=1 go test -race -v ./...
52+
53+# Linting
54+lint: ## Run golangci-lint
55+ @echo "Running golangci-lint..."
56+ golangci-lint run
57+
58+lint-fix: ## Run golangci-lint with --fix
59+ @echo "Running golangci-lint with --fix..."
60+ golangci-lint run --fix
61+
62+# Tools installation
63+install-tools: ## Install development tools
64+ @echo "Installing development tools..."
65+ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
66+ go install github.com/wailsapp/wails/v3/cmd/wails3@latest
67+ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
68+ @echo "Tools installed successfully"
69+
70+# Cleanup
71+clean: ## Clean build artifacts
72+ @echo "Cleaning build artifacts..."
73+ rm -rf $(DIST_DIR)
74+ rm -f $(BINARY_NAME) # Remove any old binaries in root
75+ go clean -cache
76+ go clean -testcache
77+
78+# Project initialization
79+init: install-tools ## Initialize project dependencies and tools
80+ @echo "Project dependencies and tools installed!"
81+ @echo "Ready for development. Run 'make help' for available commands."
82+
83+# Check setup
84+check: ## Check that all tools and dependencies are working
85+ @echo "Checking project setup..."
86+ @echo "Go version:"
87+ @go version
88+ @echo "\nSQLC version:"
89+ @sqlc version
90+ @echo "\nWails version:"
91+ @wails3 version
92+ @echo "\nGolangci-lint version:"
93+ @golangci-lint version
94+ @echo "\nBuilding project..."
95+ @make build
96+ @echo "\nTesting built binary..."
97+ @$(DIST_DIR)/$(BINARY_NAME) version
98+ @echo "\nRunning tests..."
99+ @make test
100+ @echo "\n✅ All checks passed! Project is ready for development."⏎̸
A Taskfile.yml +49 -0
@@ -0,0 +1,49 @@
1+version: '3'
2+
3+vars:
4+ APP_NAME: dumber
5+ VERSION: 0.1.0
6+ COMMIT:
7+ sh: git rev-parse --short HEAD 2>/dev/null || echo "dev"
8+ BUILD_DATE:
9+ sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
10+
11+tasks:
12+ build:
13+ desc: Build the unified Dumber application (CLI + Browser)
14+ cmds:
15+ - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go
16+
17+ dev:
18+ desc: Run the application in development mode (GUI)
19+ cmds:
20+ - go run main.go
21+
22+ dev-cli:
23+ desc: Run the application in CLI development mode
24+ cmds:
25+ - go run main.go version
26+
27+ test:
28+ desc: Run tests
29+ cmds:
30+ - go test ./...
31+
32+ clean:
33+ desc: Clean build artifacts
34+ cmds:
35+ - rm -rf dist/
36+ - mkdir -p dist
37+
38+ demo:
39+ desc: Show different usage modes
40+ cmds:
41+ - echo "Building unified application..."
42+ - task build
43+ - echo ""
44+ - echo "Usage examples:"
45+ - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}"
46+ - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version"
47+ - echo " History{{":"}} ./dist/{{.APP_NAME}} history"
48+ - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu"
49+ - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸
A configs/sqlc.yaml +15 -0
@@ -0,0 +1,15 @@
1+version: "2"
2+sql:
3+ - engine: "sqlite"
4+ queries: "./internal/db/queries/"
5+ schema: "./migrations/"
6+ gen:
7+ go:
8+ package: "db"
9+ out: "./internal/db"
10+ sql_package: "database/sql"
11+ emit_json_tags: true
12+ emit_empty_slices: true
13+ emit_exported_queries: true
14+ emit_interface: true
15+ query_parameter_limit: 5⏎̸
A dumb-browser +0 -0 binary file: no text diff

binary file: no text diff

A go.mod +66 -0
@@ -0,0 +1,66 @@
1+module github.com/bnema/dumber
2+
3+go 1.25.1
4+
5+require (
6+ github.com/fsnotify/fsnotify v1.9.0
7+ github.com/ncruces/go-sqlite3 v0.29.0
8+ github.com/spf13/cobra v1.10.1
9+ github.com/spf13/viper v1.21.0
10+ github.com/wailsapp/wails/v3 v3.0.0-alpha.28
11+)
12+
13+require (
14+ dario.cat/mergo v1.0.1 // indirect
15+ github.com/Microsoft/go-winio v0.6.2 // indirect
16+ github.com/ProtonMail/go-crypto v1.1.6 // indirect
17+ github.com/adrg/xdg v0.5.3 // indirect
18+ github.com/bep/debounce v1.2.1 // indirect
19+ github.com/cloudflare/circl v1.6.0 // indirect
20+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
21+ github.com/ebitengine/purego v0.8.2 // indirect
22+ github.com/emirpasic/gods v1.18.1 // indirect
23+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
24+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
25+ github.com/go-git/go-git/v5 v5.13.2 // indirect
26+ github.com/go-ole/go-ole v1.3.0 // indirect
27+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
28+ github.com/godbus/dbus/v5 v5.1.0 // indirect
29+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
30+ github.com/google/uuid v1.6.0 // indirect
31+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
32+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
33+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
34+ github.com/kevinburke/ssh_config v1.2.0 // indirect
35+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
36+ github.com/leaanthony/u v1.1.1 // indirect
37+ github.com/lmittmann/tint v1.0.7 // indirect
38+ github.com/mattn/go-colorable v0.1.14 // indirect
39+ github.com/mattn/go-isatty v0.0.20 // indirect
40+ github.com/ncruces/julianday v1.0.0 // indirect
41+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
42+ github.com/pjbgf/sha1cd v0.3.2 // indirect
43+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
44+ github.com/pkg/errors v0.9.1 // indirect
45+ github.com/rivo/uniseg v0.4.7 // indirect
46+ github.com/sagikazarmark/locafero v0.11.0 // indirect
47+ github.com/samber/lo v1.49.1 // indirect
48+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
49+ github.com/skeema/knownhosts v1.3.1 // indirect
50+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
51+ github.com/spf13/afero v1.15.0 // indirect
52+ github.com/spf13/cast v1.10.0 // indirect
53+ github.com/spf13/pflag v1.0.10 // indirect
54+ github.com/subosito/gotenv v1.6.0 // indirect
55+ github.com/tetratelabs/wazero v1.9.0 // indirect
56+ github.com/wailsapp/go-webview2 v1.0.21 // indirect
57+ github.com/wailsapp/mimetype v1.4.1 // indirect
58+ github.com/xanzy/ssh-agent v0.3.3 // indirect
59+ go.yaml.in/yaml/v3 v3.0.4 // indirect
60+ golang.org/x/crypto v0.41.0 // indirect
61+ golang.org/x/net v0.42.0 // indirect
62+ golang.org/x/sys v0.36.0 // indirect
63+ golang.org/x/text v0.29.0 // indirect
64+ gopkg.in/ini.v1 v1.67.0 // indirect
65+ gopkg.in/warnings.v0 v0.1.2 // indirect
66+)
A go.sum +185 -0 generated file: collapsed
A internal/cli/browse.go +189 -0
@@ -0,0 +1,189 @@
1+package cli
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "net/url"
8+ "os"
9+ "os/exec"
10+ "regexp"
11+ "strings"
12+
13+ "github.com/bnema/dumber/internal/config"
14+
15+ "github.com/spf13/cobra"
16+)
17+
18+// NewBrowseCmd creates the browse command
19+func NewBrowseCmd() *cobra.Command {
20+ cmd := &cobra.Command{
21+ Use: "browse <url|query>",
22+ Short: "Browse a URL or search query",
23+ Long: `Browse a URL directly or use search shortcuts like:
24+ g:golang -> Google search for "golang"
25+ gh:cobra -> GitHub search for "cobra"
26+ yt:tutorials -> YouTube search for "tutorials"
27+ w:go language -> Wikipedia search for "go language"
28+
29+Direct URLs are also supported:
30+ https://example.com
31+ example.com (automatically adds https://)`,
32+ Args: cobra.ExactArgs(1),
33+ RunE: func(_ *cobra.Command, args []string) error {
34+ cli, err := NewCLI()
35+ if err != nil {
36+ return fmt.Errorf("failed to initialize CLI: %w", err)
37+ }
38+ defer func() {
39+ if closeErr := cli.Close(); closeErr != nil {
40+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
41+ }
42+ }()
43+
44+ return browse(cli, args[0])
45+ },
46+ }
47+
48+ return cmd
49+}
50+
51+// browse handles the core browsing logic
52+func browse(cli *CLI, input string) error {
53+ ctx := context.Background()
54+
55+ // Parse the input to determine URL or shortcut
56+ finalURL, err := parseInput(ctx, cli, input)
57+ if err != nil {
58+ return fmt.Errorf("failed to parse input: %w", err)
59+ }
60+
61+ // Record in history
62+ if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
63+ // Don't fail the browse operation if history recording fails
64+ fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
65+ }
66+
67+ // Open URL using configuration
68+ return openURLWithConfig(finalURL, cli.Config)
69+}
70+
71+// parseInput processes user input and returns the final URL to browse
72+func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
73+ // Check if it's a shortcut (format: "prefix:query")
74+ if strings.Contains(input, ":") && !strings.Contains(input, "://") {
75+ parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
76+ if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
77+ shortcut := strings.TrimSpace(parts[0])
78+ query := strings.TrimSpace(parts[1])
79+
80+ if query == "" {
81+ return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
82+ }
83+
84+ // First check configuration-based shortcuts
85+ if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
86+ return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
87+ }
88+
89+ // Fallback to database shortcuts for backward compatibility
90+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
91+ if err != nil {
92+ return "", fmt.Errorf("failed to get shortcuts: %w", err)
93+ }
94+
95+ for _, s := range shortcuts {
96+ if s.Shortcut == shortcut {
97+ return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
98+ }
99+ }
100+
101+ return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
102+ }
103+ }
104+
105+ // Check if it's already a valid URL
106+ if isValidURL(input) {
107+ return input, nil
108+ }
109+
110+ // Try to make it a URL by adding https://
111+ if !strings.Contains(input, "://") {
112+ candidate := "https://" + input
113+ if isValidURL(candidate) {
114+ return candidate, nil
115+ }
116+ }
117+
118+ // If all else fails, search Google for it
119+ return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
120+}
121+
122+// isValidURL checks if a string is a valid URL
123+func isValidURL(str string) bool {
124+ u, err := url.Parse(str)
125+ if err != nil {
126+ return false
127+ }
128+
129+ // Must have a scheme and host
130+ if u.Scheme == "" || u.Host == "" {
131+ return false
132+ }
133+
134+ // Must be http or https
135+ if u.Scheme != "http" && u.Scheme != "https" {
136+ return false
137+ }
138+
139+ // Basic domain validation
140+ domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
141+ `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
142+ return domainRegex.MatchString(u.Host)
143+}
144+
145+// recordVisit adds or updates a URL visit in the history
146+func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
147+ var sqlTitle sql.NullString
148+ if title != "" {
149+ sqlTitle = sql.NullString{String: title, Valid: true}
150+ }
151+
152+ return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
153+}
154+
155+// openURL opens a URL using the configured browser
156+func openURL(url string) error {
157+ return openURLWithConfig(url, nil)
158+}
159+
160+// openURLWithConfig opens a URL using our built-in Wails browser
161+func openURLWithConfig(url string, cfg *config.Config) error {
162+ // Get the path to our own executable
163+ executable, err := os.Executable()
164+ if err != nil {
165+ return fmt.Errorf("failed to get executable path: %w", err)
166+ }
167+
168+ // Launch our own browser in browse mode with the URL directly
169+ cmd := exec.Command(executable, "browse", url)
170+
171+ // Start the browser in detached mode
172+ if err := cmd.Start(); err != nil {
173+ return fmt.Errorf("failed to open URL in built-in browser: %w", err)
174+ }
175+
176+ // Don't wait for the browser process to exit
177+ go func() {
178+ _ = cmd.Wait()
179+ }()
180+
181+ fmt.Printf("Opening: %s (using built-in browser)\n", url)
182+ return nil
183+}
184+
185+// commandExists checks if a command is available in the system PATH
186+func commandExists(cmd string) bool {
187+ _, err := exec.LookPath(cmd)
188+ return err == nil
189+}
A internal/cli/dmenu.go +218 -0
@@ -0,0 +1,218 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "sort"
9+ "strings"
10+
11+ "github.com/spf13/cobra"
12+)
13+
14+// NewDmenuCmd creates the dmenu command for launcher integration
15+func NewDmenuCmd() *cobra.Command {
16+ cmd := &cobra.Command{
17+ Use: "dmenu",
18+ Short: "Run in dmenu mode for launcher integration",
19+ Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
20+This mode reads from stdin and outputs selectable options to stdout.
21+
22+Usage with rofi:
23+ dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
24+
25+Usage with dmenu:
26+ dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
27+ RunE: func(cmd *cobra.Command, _ []string) error {
28+ selectFlag := cmd.Flag("select").Changed
29+
30+ cli, err := NewCLI()
31+ if err != nil {
32+ return fmt.Errorf("failed to initialize CLI: %w", err)
33+ }
34+ defer func() {
35+ if closeErr := cli.Close(); closeErr != nil {
36+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
37+ }
38+ }()
39+
40+ if selectFlag {
41+ return handleSelection(cli)
42+ }
43+ return generateOptions(cli)
44+ },
45+ }
46+
47+ cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
48+ return cmd
49+}
50+
51+// generateOptions outputs all available options for the launcher
52+func generateOptions(cli *CLI) error {
53+ ctx := context.Background()
54+
55+ const maxHistoryEntries = 50
56+ // Get history entries
57+ history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
58+ if err != nil {
59+ return fmt.Errorf("failed to get history: %w", err)
60+ }
61+
62+ // Get shortcuts
63+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
64+ if err != nil {
65+ return fmt.Errorf("failed to get shortcuts: %w", err)
66+ }
67+
68+ // Generate options
69+ options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
70+
71+ // Add direct URL input option
72+ options = append(options, DmenuOption{
73+ Display: "🌐 Enter URL or search query...",
74+ Value: "",
75+ Type: "input",
76+ Description: "Type any URL or search term",
77+ })
78+
79+ // Add shortcuts with examples
80+ for _, shortcut := range shortcuts {
81+ desc := shortcut.Description.String
82+ if !shortcut.Description.Valid {
83+ desc = "Custom shortcut"
84+ }
85+
86+ options = append(options, DmenuOption{
87+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
88+ Value: shortcut.Shortcut + ":",
89+ Type: "shortcut",
90+ Description: desc,
91+ })
92+ }
93+
94+ // Add history entries
95+ for _, entry := range history {
96+ title := entry.Url
97+ if entry.Title.Valid && entry.Title.String != "" {
98+ title = entry.Title.String
99+ }
100+
101+ const maxDisplayLength = 80
102+ // Truncate long titles/URLs for display
103+ display := truncateString(title, maxDisplayLength)
104+
105+ options = append(options, DmenuOption{
106+ Display: fmt.Sprintf("🕒 %s", display),
107+ Value: entry.Url,
108+ Type: "history",
109+ Description: entry.Url,
110+ })
111+ }
112+
113+ // Sort options: input first, then shortcuts, then history by visit count/recency
114+ sort.Slice(options, func(i, j int) bool {
115+ if options[i].Type != options[j].Type {
116+ // Order: input, shortcut, history
117+ typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
118+ return typeOrder[options[i].Type] < typeOrder[options[j].Type]
119+ }
120+
121+ if options[i].Type == "history" && options[j].Type == "history" {
122+ // History already sorted by recency from database
123+ return i < j
124+ }
125+
126+ return options[i].Display < options[j].Display
127+ })
128+
129+ // Output options to stdout
130+ for _, option := range options {
131+ fmt.Println(option.Display)
132+ }
133+
134+ return nil
135+}
136+
137+// handleSelection processes the user's selection from the launcher
138+func handleSelection(cli *CLI) error {
139+ scanner := bufio.NewScanner(os.Stdin)
140+ if !scanner.Scan() {
141+ return fmt.Errorf("no input received")
142+ }
143+
144+ selection := strings.TrimSpace(scanner.Text())
145+ if selection == "" {
146+ return fmt.Errorf("empty selection")
147+ }
148+
149+ // Parse the selection to get the actual input
150+ input := parseSelection(selection)
151+
152+ // If it's just the input prompt, read another line for the actual input
153+ if input == "" || input == "Enter URL or search query..." {
154+ fmt.Print("Enter URL or search query: ")
155+ if !scanner.Scan() {
156+ return fmt.Errorf("no input received")
157+ }
158+ input = strings.TrimSpace(scanner.Text())
159+ }
160+
161+ if input == "" {
162+ return fmt.Errorf("empty input")
163+ }
164+
165+ // Browse the selected/entered URL
166+ return browse(cli, input)
167+}
168+
169+// DmenuOption represents a selectable option in dmenu mode
170+type DmenuOption struct {
171+ Display string
172+ Value string
173+ Type string // "input", "shortcut", "history"
174+ Description string
175+}
176+
177+// parseSelection extracts the actual URL/query from a dmenu selection
178+func parseSelection(selection string) string {
179+ // Remove emoji prefixes and clean up the selection
180+ selection = strings.TrimSpace(selection)
181+
182+ // Handle different option types
183+ if strings.HasPrefix(selection, "🌐 ") {
184+ // Input option selected
185+ return ""
186+ }
187+
188+ if strings.HasPrefix(selection, "🔍 ") {
189+ // Shortcut selected - extract the shortcut prefix
190+ rest := strings.TrimPrefix(selection, "🔍 ")
191+ if idx := strings.Index(rest, ":"); idx > 0 {
192+ return rest[:idx+1] // Return "g:" format
193+ }
194+ }
195+
196+ if strings.HasPrefix(selection, "🕒 ") {
197+ // History entry selected - this is the display title, need to extract URL
198+ // For now, we'll assume the display is the URL or close enough
199+ return strings.TrimPrefix(selection, "🕒 ")
200+ }
201+
202+ // Fallback: return the selection as-is
203+ return selection
204+}
205+
206+// truncateString truncates a string to the specified length with ellipsis
207+func truncateString(s string, maxLen int) string {
208+ if len(s) <= maxLen {
209+ return s
210+ }
211+
212+ const minEllipsisLength = 3
213+ if maxLen <= minEllipsisLength {
214+ return s[:maxLen]
215+ }
216+
217+ return s[:maxLen-minEllipsisLength] + "..."
218+}
A internal/cli/history.go +380 -0
@@ -0,0 +1,380 @@
1+package cli
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+ "text/tabwriter"
11+ "time"
12+
13+ "github.com/spf13/cobra"
14+)
15+
16+const (
17+ unknownValue = "Unknown"
18+ defaultHistoryLimit = 20
19+ defaultSearchLimit = 10
20+ maxURLDisplay = 50
21+ maxTitleDisplay = 40
22+ maxURLCompact = 60
23+ maxTitleCompact = 30
24+ tabSpacing = 2
25+)
26+
27+// NewHistoryCmd creates the history command
28+func NewHistoryCmd() *cobra.Command {
29+ cmd := &cobra.Command{
30+ Use: "history",
31+ Short: "Manage browsing history",
32+ Long: `Manage browsing history with various subcommands:
33+ list - Show recent browsing history
34+ search - Search through history
35+ clear - Clear history (with confirmation)`,
36+ RunE: func(cmd *cobra.Command, args []string) error {
37+ // Default to list if no subcommand
38+ return listHistory(cmd, args)
39+ },
40+ }
41+
42+ // List subcommand (also default behavior)
43+ listCmd := &cobra.Command{
44+ Use: "list",
45+ Short: "List recent browsing history",
46+ Long: `Display recent browsing history with visit counts and timestamps.`,
47+ RunE: listHistory,
48+ }
49+ listCmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
50+ listCmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
51+
52+ // Search subcommand
53+ searchCmd := &cobra.Command{
54+ Use: "search <query>",
55+ Short: "Search through browsing history",
56+ Long: `Search for URLs and titles in browsing history.`,
57+ Args: cobra.ExactArgs(1),
58+ RunE: searchHistory,
59+ }
60+ searchCmd.Flags().IntP("limit", "n", defaultSearchLimit, "Number of search results to show")
61+
62+ // Clear subcommand
63+ clearCmd := &cobra.Command{
64+ Use: "clear",
65+ Short: "Clear browsing history",
66+ Long: `Clear all browsing history. This action cannot be undone.`,
67+ RunE: clearHistory,
68+ }
69+ clearCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
70+
71+ // Stats subcommand
72+ statsCmd := &cobra.Command{
73+ Use: "stats",
74+ Short: "Show history statistics",
75+ Long: `Display statistics about your browsing history.`,
76+ RunE: showStats,
77+ }
78+
79+ // Add global flags
80+ cmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
81+ cmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
82+
83+ // Add subcommands
84+ cmd.AddCommand(listCmd)
85+ cmd.AddCommand(searchCmd)
86+ cmd.AddCommand(clearCmd)
87+ cmd.AddCommand(statsCmd)
88+
89+ return cmd
90+}
91+
92+// listHistory displays recent browsing history
93+func listHistory(cmd *cobra.Command, _ []string) error {
94+ cli, err := NewCLI()
95+ if err != nil {
96+ return fmt.Errorf("failed to initialize CLI: %w", err)
97+ }
98+ defer func() {
99+ if err := cli.Close(); err != nil {
100+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
101+ }
102+ }()
103+
104+ ctx := context.Background()
105+
106+ limit, _ := cmd.Flags().GetInt("limit")
107+ verbose, _ := cmd.Flags().GetBool("verbose")
108+
109+ history, err := cli.Queries.GetHistory(ctx, int64(limit))
110+ if err != nil {
111+ return fmt.Errorf("failed to get history: %w", err)
112+ }
113+
114+ if len(history) == 0 {
115+ fmt.Println("No browsing history found.")
116+ return nil
117+ }
118+
119+ // Create tabwriter for aligned output
120+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
121+ defer w.Flush()
122+
123+ if verbose {
124+ fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE")
125+ fmt.Fprintln(w, "--\t------\t------------\t---\t-----")
126+
127+ for _, entry := range history {
128+ visits := "1"
129+ if entry.VisitCount.Valid {
130+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
131+ }
132+
133+ lastVisited := unknownValue
134+ if entry.LastVisited.Valid {
135+ lastVisited = entry.LastVisited.Time.Format("2006-01-02 15:04")
136+ }
137+
138+ title := entry.Title.String
139+ if !entry.Title.Valid || title == "" {
140+ title = "-"
141+ }
142+
143+ url := truncateString(entry.Url, 50)
144+ title = truncateString(title, 40)
145+
146+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
147+ entry.ID, visits, lastVisited, url, title)
148+ }
149+ } else {
150+ fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL")
151+ fmt.Fprintln(w, "------\t------------\t---")
152+
153+ for _, entry := range history {
154+ visits := "1"
155+ if entry.VisitCount.Valid {
156+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
157+ }
158+
159+ lastVisited := unknownValue
160+ if entry.LastVisited.Valid {
161+ // Show relative time for recent entries
162+ if time.Since(entry.LastVisited.Time) < 24*time.Hour {
163+ lastVisited = entry.LastVisited.Time.Format("15:04")
164+ } else {
165+ lastVisited = entry.LastVisited.Time.Format("Jan 02")
166+ }
167+ }
168+
169+ url := entry.Url
170+ if len(url) > 60 {
171+ url = url[:57] + "..."
172+ }
173+
174+ fmt.Fprintf(w, "%s\t%s\t%s\n", visits, lastVisited, url)
175+ }
176+ }
177+
178+ return nil
179+}
180+
181+// searchHistory searches through browsing history
182+func searchHistory(cmd *cobra.Command, args []string) error {
183+ cli, err := NewCLI()
184+ if err != nil {
185+ return fmt.Errorf("failed to initialize CLI: %w", err)
186+ }
187+ defer func() {
188+ if err := cli.Close(); err != nil {
189+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
190+ }
191+ }()
192+
193+ ctx := context.Background()
194+ query := args[0]
195+ limit, _ := cmd.Flags().GetInt("limit")
196+
197+ // Search both URL and title
198+ searchQuery := sql.NullString{String: query, Valid: true}
199+ results, err := cli.Queries.SearchHistory(ctx, searchQuery, searchQuery, int64(limit))
200+ if err != nil {
201+ return fmt.Errorf("failed to search history: %w", err)
202+ }
203+
204+ if len(results) == 0 {
205+ fmt.Printf("No history entries found matching '%s'.\n", query)
206+ return nil
207+ }
208+
209+ fmt.Printf("Found %d result(s) for '%s':\n\n", len(results), query)
210+
211+ // Create tabwriter for aligned output
212+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
213+ defer w.Flush()
214+
215+ fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL\tTITLE")
216+ fmt.Fprintln(w, "------\t------------\t---\t-----")
217+
218+ for _, entry := range results {
219+ visits := "1"
220+ if entry.VisitCount.Valid {
221+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
222+ }
223+
224+ lastVisited := "Unknown"
225+ if entry.LastVisited.Valid {
226+ lastVisited = entry.LastVisited.Time.Format("Jan 02 15:04")
227+ }
228+
229+ title := entry.Title.String
230+ if !entry.Title.Valid || title == "" {
231+ title = "-"
232+ }
233+
234+ url := truncateString(entry.Url, 50)
235+ title = truncateString(title, 30)
236+
237+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", visits, lastVisited, url, title)
238+ }
239+
240+ return nil
241+}
242+
243+// clearHistory clears all browsing history
244+func clearHistory(cmd *cobra.Command, _ []string) error {
245+ force, _ := cmd.Flags().GetBool("force")
246+
247+ // Confirmation unless --force is used
248+ if !force {
249+ fmt.Print("This will permanently delete all browsing history. Continue? [y/N]: ")
250+ var response string
251+ _, _ = fmt.Scanln(&response)
252+ response = strings.ToLower(strings.TrimSpace(response))
253+
254+ if response != "y" && response != "yes" {
255+ fmt.Println("Operation cancelled.")
256+ return nil
257+ }
258+ }
259+
260+ cli, err := NewCLI()
261+ if err != nil {
262+ return fmt.Errorf("failed to initialize CLI: %w", err)
263+ }
264+ defer func() {
265+ if err := cli.Close(); err != nil {
266+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
267+ }
268+ }()
269+
270+ // Execute DELETE query
271+ result, err := cli.DB.Exec("DELETE FROM history")
272+ if err != nil {
273+ return fmt.Errorf("failed to clear history: %w", err)
274+ }
275+
276+ rowsAffected, err := result.RowsAffected()
277+ if err != nil {
278+ return fmt.Errorf("failed to get affected rows: %w", err)
279+ }
280+
281+ fmt.Printf("Successfully cleared %d history entries.\n", rowsAffected)
282+ return nil
283+}
284+
285+// showStats displays statistics about browsing history
286+func showStats(_ *cobra.Command, _ []string) error {
287+ cli, err := NewCLI()
288+ if err != nil {
289+ return fmt.Errorf("failed to initialize CLI: %w", err)
290+ }
291+ defer func() {
292+ if err := cli.Close(); err != nil {
293+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
294+ }
295+ }()
296+
297+ ctx := context.Background()
298+
299+ // Get total entries
300+ var totalEntries int64
301+ err = cli.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM history").Scan(&totalEntries)
302+ if err != nil {
303+ return fmt.Errorf("failed to get total entries: %w", err)
304+ }
305+
306+ // Get total visits
307+ var totalVisits sql.NullInt64
308+ err = cli.DB.QueryRowContext(ctx, "SELECT SUM(visit_count) FROM history").Scan(&totalVisits)
309+ if err != nil {
310+ return fmt.Errorf("failed to get total visits: %w", err)
311+ }
312+
313+ // Get date range
314+ var oldestDate, newestDate sql.NullTime
315+ err = cli.DB.QueryRowContext(ctx, "SELECT MIN(created_at), MAX(last_visited) FROM history").Scan(&oldestDate, &newestDate)
316+ if err != nil {
317+ return fmt.Errorf("failed to get date range: %w", err)
318+ }
319+
320+ // Get top domains
321+ rows, err := cli.DB.QueryContext(ctx, `
322+ SELECT
323+ CASE
324+ WHEN url LIKE 'http://%' THEN substr(url, 8)
325+ WHEN url LIKE 'https://%' THEN substr(url, 9)
326+ ELSE url
327+ END as domain,
328+ COUNT(*) as count,
329+ SUM(visit_count) as total_visits
330+ FROM history
331+ GROUP BY domain
332+ ORDER BY total_visits DESC
333+ LIMIT 5
334+ `)
335+ if err != nil {
336+ return fmt.Errorf("failed to get top domains: %w", err)
337+ }
338+ defer rows.Close()
339+
340+ fmt.Println("Browsing History Statistics")
341+ fmt.Println("==========================")
342+ fmt.Printf("Total unique URLs: %d\n", totalEntries)
343+
344+ if totalVisits.Valid {
345+ fmt.Printf("Total visits: %d\n", totalVisits.Int64)
346+ }
347+
348+ if oldestDate.Valid && newestDate.Valid {
349+ fmt.Printf("Date range: %s to %s\n",
350+ oldestDate.Time.Format("2006-01-02"),
351+ newestDate.Time.Format("2006-01-02"))
352+ }
353+
354+ fmt.Println("\nTop 5 domains:")
355+ fmt.Println("--------------")
356+
357+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
358+ defer w.Flush()
359+
360+ fmt.Fprintln(w, "DOMAIN\tURLs\tVISITS")
361+ fmt.Fprintln(w, "------\t----\t------")
362+
363+ for rows.Next() {
364+ var domain string
365+ var urlCount, visitCount int64
366+
367+ if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil {
368+ continue
369+ }
370+
371+ // Extract just the domain part
372+ if idx := strings.Index(domain, "/"); idx > 0 {
373+ domain = domain[:idx]
374+ }
375+
376+ fmt.Fprintf(w, "%s\t%d\t%d\n", domain, urlCount, visitCount)
377+ }
378+
379+ return nil
380+}⏎̸
A internal/cli/root.go +225 -0
@@ -0,0 +1,225 @@
1+// Package cli provides the command-line interface for dumber.
2+package cli
3+
4+import (
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/db"
12+
13+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
14+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility
15+ "github.com/spf13/cobra"
16+)
17+
18+// CLI holds the database connection, queries, and configuration for the CLI commands
19+type CLI struct {
20+ DB *sql.DB
21+ Queries *db.Queries
22+ Config *config.Config
23+}
24+
25+// NewCLI creates a new CLI instance with database connection and configuration
26+func NewCLI() (*CLI, error) {
27+ // Get configuration
28+ cfg := config.Get()
29+
30+ // Use configured database path
31+ dbPath := cfg.Database.Path
32+ if dbPath == "" {
33+ // Fallback to default path if not configured
34+ var err error
35+ dbPath, err = config.GetDatabaseFile()
36+ if err != nil {
37+ return nil, fmt.Errorf("failed to get database path: %w", err)
38+ }
39+ }
40+
41+ // Ensure database directory exists
42+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
43+ return nil, fmt.Errorf("failed to create database directory: %w", err)
44+ }
45+
46+ // Open database connection
47+ database, err := sql.Open("sqlite3", dbPath)
48+ if err != nil {
49+ return nil, fmt.Errorf("failed to open database: %w", err)
50+ }
51+
52+ // Test the connection
53+ if err := database.Ping(); err != nil {
54+ database.Close()
55+ return nil, fmt.Errorf("failed to connect to database: %w", err)
56+ }
57+
58+ // Initialize database schema with configuration-based shortcuts
59+ if err := initializeDatabase(database, cfg); err != nil {
60+ database.Close()
61+ return nil, fmt.Errorf("failed to initialize database: %w", err)
62+ }
63+
64+ return &CLI{
65+ DB: database,
66+ Queries: db.New(database),
67+ Config: cfg,
68+ }, nil
69+}
70+
71+// Close closes the database connection
72+func (c *CLI) Close() error {
73+ if c.DB != nil {
74+ return c.DB.Close()
75+ }
76+ return nil
77+}
78+
79+// NewRootCmd creates the root command for dumber
80+func NewRootCmd(version, commit, buildDate string) *cobra.Command {
81+ rootCmd := &cobra.Command{
82+ Use: "dumber [url]",
83+ Short: "A dumb browser for Wayland window managers",
84+ Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`,
85+ Args: cobra.MaximumNArgs(1),
86+ RunE: func(cmd *cobra.Command, args []string) error {
87+ // Initialize CLI
88+ cli, err := NewCLI()
89+ if err != nil {
90+ return fmt.Errorf("failed to initialize CLI: %w", err)
91+ }
92+ defer func() {
93+ if closeErr := cli.Close(); closeErr != nil {
94+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
95+ }
96+ }()
97+
98+ // If URL provided, browse it directly
99+ if len(args) > 0 {
100+ return browse(cli, args[0])
101+ }
102+
103+ // Otherwise show help
104+ return cmd.Help()
105+ },
106+ }
107+
108+ // Add dmenu flag for launcher integration
109+ rootCmd.Flags().Bool("dmenu", false, "Run in dmenu mode for launcher integration")
110+
111+ // Version command
112+ versionCmd := &cobra.Command{
113+ Use: "version",
114+ Short: "Print version information",
115+ Run: func(_ *cobra.Command, _ []string) {
116+ fmt.Printf("dumber %s\n", version)
117+ fmt.Printf("commit: %s\n", commit)
118+ fmt.Printf("built: %s\n", buildDate)
119+ },
120+ }
121+
122+ // Init command (enhanced)
123+ initCmd := &cobra.Command{
124+ Use: "init",
125+ Short: "Initialize dumber database and configuration",
126+ RunE: func(_ *cobra.Command, _ []string) error {
127+ cli, err := NewCLI()
128+ if err != nil {
129+ return fmt.Errorf("failed to initialize CLI: %w", err)
130+ }
131+ defer func() {
132+ if closeErr := cli.Close(); closeErr != nil {
133+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
134+ }
135+ }()
136+
137+ fmt.Printf("dumber %s - Initialization complete!\n", version)
138+ fmt.Println("Database initialized at:", cli.Config.Database.Path)
139+
140+ // Show XDG directories
141+ xdgDirs, err := config.GetXDGDirs()
142+ if err == nil {
143+ fmt.Println("Configuration directories:")
144+ fmt.Printf("- Config: %s\n", xdgDirs.ConfigHome)
145+ fmt.Printf("- Data: %s\n", xdgDirs.DataHome)
146+ fmt.Printf("- State: %s\n", xdgDirs.StateHome)
147+ }
148+
149+ // Show configured shortcuts
150+ fmt.Println("Configured shortcuts:")
151+ for shortcut, cfg := range cli.Config.SearchShortcuts {
152+ fmt.Printf("- %s:%s -> %s\n", shortcut, "query", cfg.Description)
153+ }
154+ return nil
155+ },
156+ }
157+
158+ // Add subcommands
159+ rootCmd.AddCommand(versionCmd)
160+ rootCmd.AddCommand(initCmd)
161+ rootCmd.AddCommand(NewBrowseCmd())
162+ rootCmd.AddCommand(NewDmenuCmd())
163+ rootCmd.AddCommand(NewHistoryCmd())
164+
165+ return rootCmd
166+}
167+
168+// getDatabasePath returns the path to the SQLite database file (legacy function for compatibility)
169+func getDatabasePath() (string, error) {
170+ return config.GetDatabaseFile()
171+}
172+
173+// getDatabasePathOrDefault returns the database path or a default message (legacy function for compatibility)
174+func getDatabasePathOrDefault() string {
175+ path, err := config.GetDatabaseFile()
176+ if err != nil {
177+ return "~/.local/state/dumber/history.db"
178+ }
179+ return path
180+}
181+
182+// initializeDatabase creates the database schema if it doesn't exist
183+func initializeDatabase(db *sql.DB, cfg *config.Config) error {
184+ schema := `
185+ -- History tracking for visited URLs
186+ CREATE TABLE IF NOT EXISTS history (
187+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188+ url TEXT NOT NULL UNIQUE,
189+ title TEXT,
190+ visit_count INTEGER DEFAULT 1,
191+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
192+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
193+ );
194+
195+ CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
196+ CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
197+
198+ -- URL shortcuts configuration
199+ CREATE TABLE IF NOT EXISTS shortcuts (
200+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201+ shortcut TEXT NOT NULL UNIQUE,
202+ url_template TEXT NOT NULL,
203+ description TEXT,
204+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
205+ );
206+ `
207+
208+ // Execute schema creation
209+ if _, err := db.Exec(schema); err != nil {
210+ return err
211+ }
212+
213+ // Insert configured shortcuts
214+ for shortcut, shortcutCfg := range cfg.SearchShortcuts {
215+ _, err := db.Exec(
216+ "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
217+ shortcut, shortcutCfg.URL, shortcutCfg.Description,
218+ )
219+ if err != nil {
220+ return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
221+ }
222+ }
223+
224+ return nil
225+}
A internal/config/config.go +373 -0
@@ -0,0 +1,373 @@
1+// Package config provides configuration management for dumber with Viper integration.
2+package config
3+
4+import (
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "github.com/fsnotify/fsnotify"
14+ "github.com/spf13/viper"
15+)
16+
17+// Config represents the complete configuration for dumber.
18+type Config struct {
19+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20+ History HistoryConfig `mapstructure:"history" yaml:"history"`
21+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24+}
25+
26+// DatabaseConfig holds database-related configuration.
27+type DatabaseConfig struct {
28+ Path string `mapstructure:"path" yaml:"path"`
29+ MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
30+ MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
31+ QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
32+}
33+
34+// HistoryConfig holds history-related configuration.
35+type HistoryConfig struct {
36+ MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"`
37+ RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
38+ CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
39+}
40+
41+// SearchShortcut represents a search shortcut configuration.
42+type SearchShortcut struct {
43+ URL string `mapstructure:"url" yaml:"url"`
44+ Description string `mapstructure:"description" yaml:"description"`
45+}
46+
47+// DmenuConfig holds dmenu/rofi integration configuration.
48+type DmenuConfig struct {
49+ MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"`
50+ ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"`
51+ ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"`
52+ HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"`
53+ ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
54+ URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"`
55+ DateFormat string `mapstructure:"date_format" yaml:"date_format"`
56+ SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
57+}
58+
59+// LoggingConfig holds logging configuration.
60+type LoggingConfig struct {
61+ Level string `mapstructure:"level" yaml:"level"`
62+ Format string `mapstructure:"format" yaml:"format"`
63+ Filename string `mapstructure:"filename" yaml:"filename"`
64+ MaxSize int `mapstructure:"max_size" yaml:"max_size"`
65+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
66+ MaxAge int `mapstructure:"max_age" yaml:"max_age"`
67+ Compress bool `mapstructure:"compress" yaml:"compress"`
68+}
69+
70+// Manager handles configuration loading, watching, and reloading.
71+type Manager struct {
72+ config *Config
73+ viper *viper.Viper
74+ mu sync.RWMutex
75+ callbacks []func(*Config)
76+ watching bool
77+}
78+
79+// NewManager creates a new configuration manager.
80+func NewManager() (*Manager, error) {
81+ v := viper.New()
82+
83+ // Configure Viper - supports yaml, json, toml automatically
84+ v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
85+
86+ // Add config paths
87+ configDir, err := GetConfigDir()
88+ if err != nil {
89+ return nil, fmt.Errorf("failed to get config directory: %w", err)
90+ }
91+ v.AddConfigPath(configDir)
92+ v.AddConfigPath(".") // Current directory for development
93+
94+ // Set up environment variable support
95+ v.SetEnvPrefix("DUMB_BROWSER")
96+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
97+ v.AutomaticEnv()
98+
99+ // Bind specific environment variables
100+ bindings := map[string]string{
101+ "database.path": "DATABASE_PATH",
102+ "database.max_connections": "DATABASE_MAX_CONNECTIONS",
103+ "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
104+ "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
105+ "browser.command": "BROWSER_COMMAND",
106+ "browser.timeout": "BROWSER_TIMEOUT",
107+ "browser.detach_process": "BROWSER_DETACH_PROCESS",
108+ "history.max_entries": "HISTORY_MAX_ENTRIES",
109+ "history.retention_period": "HISTORY_RETENTION_PERIOD",
110+ "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
111+ "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
112+ "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
113+ "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
114+ "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
115+ "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
116+ "dmenu.url_prefix": "DMENU_URL_PREFIX",
117+ "dmenu.date_format": "DMENU_DATE_FORMAT",
118+ "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
119+ "logging.level": "LOGGING_LEVEL",
120+ "logging.format": "LOGGING_FORMAT",
121+ "logging.filename": "LOGGING_FILENAME",
122+ "logging.max_size": "LOGGING_MAX_SIZE",
123+ "logging.max_backups": "LOGGING_MAX_BACKUPS",
124+ "logging.max_age": "LOGGING_MAX_AGE",
125+ "logging.compress": "LOGGING_COMPRESS",
126+ }
127+
128+ for key, env := range bindings {
129+ if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
130+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
131+ }
132+ }
133+
134+ return &Manager{
135+ viper: v,
136+ callbacks: make([]func(*Config), 0),
137+ }, nil
138+}
139+
140+// Load loads the configuration from file and environment variables.
141+func (m *Manager) Load() error {
142+ m.mu.Lock()
143+ defer m.mu.Unlock()
144+
145+ // Ensure directories exist
146+ if err := EnsureDirectories(); err != nil {
147+ return fmt.Errorf("failed to ensure directories: %w", err)
148+ }
149+
150+ // Set defaults
151+ m.setDefaults()
152+
153+ // Read config file if it exists
154+ if err := m.viper.ReadInConfig(); err != nil {
155+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
156+ // Config file not found, create default one
157+ if err := m.createDefaultConfig(); err != nil {
158+ return fmt.Errorf("failed to create default config: %w", err)
159+ }
160+ } else {
161+ return fmt.Errorf("failed to read config file: %w", err)
162+ }
163+ }
164+
165+ // Unmarshal into config struct
166+ config := &Config{}
167+ if err := m.viper.Unmarshal(config); err != nil {
168+ return fmt.Errorf("failed to unmarshal config: %w", err)
169+ }
170+
171+ // Set database path if not specified
172+ if config.Database.Path == "" {
173+ dbPath, err := GetDatabaseFile()
174+ if err != nil {
175+ return fmt.Errorf("failed to get database path: %w", err)
176+ }
177+ config.Database.Path = dbPath
178+ }
179+
180+ m.config = config
181+ return nil
182+}
183+
184+// Get returns the current configuration (thread-safe).
185+func (m *Manager) Get() *Config {
186+ m.mu.RLock()
187+ defer m.mu.RUnlock()
188+
189+ // Return a copy to prevent external modification
190+ configCopy := *m.config
191+ return &configCopy
192+}
193+
194+// Watch starts watching the config file for changes and reloads automatically.
195+func (m *Manager) Watch() error {
196+ m.mu.Lock()
197+ defer m.mu.Unlock()
198+
199+ if m.watching {
200+ return nil // Already watching
201+ }
202+
203+ m.viper.WatchConfig()
204+ m.viper.OnConfigChange(func(_ fsnotify.Event) {
205+ // Reload config
206+ if err := m.reload(); err != nil {
207+ fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
208+ return
209+ }
210+
211+ // Notify callbacks
212+ m.mu.RLock()
213+ config := m.config
214+ callbacks := make([]func(*Config), len(m.callbacks))
215+ copy(callbacks, m.callbacks)
216+ m.mu.RUnlock()
217+
218+ for _, callback := range callbacks {
219+ callback(config)
220+ }
221+ })
222+
223+ m.watching = true
224+ return nil
225+}
226+
227+// OnConfigChange registers a callback function to be called when config changes.
228+func (m *Manager) OnConfigChange(callback func(*Config)) {
229+ m.mu.Lock()
230+ defer m.mu.Unlock()
231+
232+ m.callbacks = append(m.callbacks, callback)
233+}
234+
235+// reload reloads the configuration (internal method, must be called with lock).
236+func (m *Manager) reload() error {
237+ if err := m.viper.ReadInConfig(); err != nil {
238+ return err
239+ }
240+
241+ config := &Config{}
242+ if err := m.viper.Unmarshal(config); err != nil {
243+ return err
244+ }
245+
246+ // Set database path if not specified
247+ if config.Database.Path == "" {
248+ dbPath, err := GetDatabaseFile()
249+ if err != nil {
250+ return fmt.Errorf("failed to get database path: %w", err)
251+ }
252+ config.Database.Path = dbPath
253+ }
254+
255+ m.config = config
256+ return nil
257+}
258+
259+// setDefaults sets default configuration values in Viper.
260+func (m *Manager) setDefaults() {
261+ defaults := DefaultConfig()
262+
263+ // Database defaults
264+ m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
265+ m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
266+ m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
267+
268+ // Note: Browser config removed - we build our own browser with Wails
269+
270+ // History defaults
271+ m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
272+ m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
273+ m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
274+
275+ // Search shortcuts defaults
276+ m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
277+
278+ // Dmenu defaults
279+ m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
280+ m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
281+ m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
282+ m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
283+ m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
284+ m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
285+ m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
286+ m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
287+
288+ // Logging defaults
289+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
290+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
291+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
292+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
293+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
294+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
295+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
296+}
297+
298+// createDefaultConfig creates a default configuration file.
299+func (m *Manager) createDefaultConfig() error {
300+ configFile, err := GetConfigFile()
301+ if err != nil {
302+ return err
303+ }
304+
305+ // Create config directory if it doesn't exist
306+ if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
307+ return err
308+ }
309+
310+ // Get the default configuration
311+ defaultConfig := DefaultConfig()
312+
313+ // Marshal to JSON with proper indentation
314+ configData, err := json.MarshalIndent(defaultConfig, "", " ")
315+ if err != nil {
316+ return fmt.Errorf("failed to marshal default config: %w", err)
317+ }
318+
319+ // Write JSON config file
320+ if err := os.WriteFile(configFile, configData, 0644); err != nil {
321+ return fmt.Errorf("failed to write config file: %w", err)
322+ }
323+
324+ fmt.Printf("Created default configuration file: %s\n", configFile)
325+ return nil
326+}
327+
328+// GetConfigFile returns the path to the configuration file being used.
329+func (m *Manager) GetConfigFile() string {
330+ return m.viper.ConfigFileUsed()
331+}
332+
333+// Global configuration manager instance
334+var globalManager *Manager
335+var globalManagerOnce sync.Once
336+
337+// Init initializes the global configuration manager.
338+func Init() error {
339+ var err error
340+ globalManagerOnce.Do(func() {
341+ globalManager, err = NewManager()
342+ if err != nil {
343+ return
344+ }
345+ err = globalManager.Load()
346+ })
347+ return err
348+}
349+
350+// Get returns the global configuration.
351+func Get() *Config {
352+ if globalManager == nil {
353+ // Return defaults if not initialized
354+ return DefaultConfig()
355+ }
356+ return globalManager.Get()
357+}
358+
359+// Watch starts watching the global configuration for changes.
360+func Watch() error {
361+ if globalManager == nil {
362+ return fmt.Errorf("configuration not initialized")
363+ }
364+ return globalManager.Watch()
365+}
366+
367+// OnConfigChange registers a callback for global configuration changes.
368+func OnConfigChange(callback func(*Config)) {
369+ if globalManager == nil {
370+ return
371+ }
372+ globalManager.OnConfigChange(callback)
373+}
A internal/config/defaults.go +107 -0
@@ -0,0 +1,107 @@
1+// Package config provides default configuration values for dumber.
2+package config
3+
4+import (
5+ "time"
6+)
7+
8+// DefaultConfig returns the default configuration values for dumber.
9+func DefaultConfig() *Config {
10+ return &Config{
11+ Database: DatabaseConfig{
12+ MaxConnections: 1,
13+ MaxIdleTime: time.Minute * 5,
14+ QueryTimeout: time.Second * 30,
15+ },
16+ History: HistoryConfig{
17+ MaxEntries: 10000,
18+ RetentionPeriodDays: 365, // 1 year
19+ CleanupIntervalDays: 1, // daily cleanup
20+ },
21+ SearchShortcuts: map[string]SearchShortcut{
22+ "g": {
23+ URL: "https://www.google.com/search?q=%s",
24+ Description: "Google search",
25+ },
26+ "gh": {
27+ URL: "https://github.com/search?q=%s",
28+ Description: "GitHub search",
29+ },
30+ "yt": {
31+ URL: "https://www.youtube.com/results?search_query=%s",
32+ Description: "YouTube search",
33+ },
34+ "w": {
35+ URL: "https://en.wikipedia.org/wiki/%s",
36+ Description: "Wikipedia search",
37+ },
38+ },
39+ Dmenu: DmenuConfig{
40+ MaxHistoryItems: 20,
41+ ShowVisitCount: true,
42+ ShowLastVisited: true,
43+ HistoryPrefix: "🕒",
44+ ShortcutPrefix: "🔍",
45+ URLPrefix: "🌐",
46+ DateFormat: "2006-01-02 15:04",
47+ SortByVisitCount: true,
48+ },
49+ Logging: LoggingConfig{
50+ Level: "info",
51+ Format: "text", // text or json
52+ Filename: "", // empty means stdout
53+ MaxSize: 100, // MB
54+ MaxBackups: 3,
55+ MaxAge: 7, // days
56+ Compress: true,
57+ },
58+ }
59+}
60+
61+// GetDefaultSearchShortcuts returns the default search shortcuts.
62+func GetDefaultSearchShortcuts() map[string]SearchShortcut {
63+ return map[string]SearchShortcut{
64+ "g": {
65+ URL: "https://www.google.com/search?q=%s",
66+ Description: "Google search",
67+ },
68+ "gh": {
69+ URL: "https://github.com/search?q=%s",
70+ Description: "GitHub search",
71+ },
72+ "yt": {
73+ URL: "https://www.youtube.com/results?search_query=%s",
74+ Description: "YouTube search",
75+ },
76+ "w": {
77+ URL: "https://en.wikipedia.org/wiki/%s",
78+ Description: "Wikipedia search",
79+ },
80+ "ddg": {
81+ URL: "https://duckduckgo.com/?q=%s",
82+ Description: "DuckDuckGo search",
83+ },
84+ "so": {
85+ URL: "https://stackoverflow.com/search?q=%s",
86+ Description: "Stack Overflow search",
87+ },
88+ "r": {
89+ URL: "https://www.reddit.com/search?q=%s",
90+ Description: "Reddit search",
91+ },
92+ "npm": {
93+ URL: "https://www.npmjs.com/search?q=%s",
94+ Description: "npm package search",
95+ },
96+ "go": {
97+ URL: "https://pkg.go.dev/search?q=%s",
98+ Description: "Go package search",
99+ },
100+ "mdn": {
101+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
102+ Description: "MDN Web Docs search",
103+ },
104+ }
105+}
106+
107+// Note: We build our own browser with Wails, so no external browser commands needed
A internal/config/xdg.go +123 -0
@@ -0,0 +1,123 @@
1+// Package config provides XDG Base Directory specification compliance utilities.
2+package config
3+
4+import (
5+ "os"
6+ "path/filepath"
7+)
8+
9+const appName = "dumber"
10+
11+// XDGDirs holds the XDG Base Directory paths for the application.
12+type XDGDirs struct {
13+ ConfigHome string
14+ DataHome string
15+ StateHome string
16+}
17+
18+// GetXDGDirs returns the XDG Base Directory paths for dumber.
19+// It follows the XDG Base Directory specification:
20+// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)
21+// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
22+// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
23+func GetXDGDirs() (*XDGDirs, error) {
24+ homeDir, err := os.UserHomeDir()
25+ if err != nil {
26+ return nil, err
27+ }
28+
29+ // XDG_CONFIG_HOME
30+ configHome := os.Getenv("XDG_CONFIG_HOME")
31+ if configHome == "" {
32+ configHome = filepath.Join(homeDir, ".config")
33+ }
34+ configHome = filepath.Join(configHome, appName)
35+
36+ // XDG_DATA_HOME
37+ dataHome := os.Getenv("XDG_DATA_HOME")
38+ if dataHome == "" {
39+ dataHome = filepath.Join(homeDir, ".local", "share")
40+ }
41+ dataHome = filepath.Join(dataHome, appName)
42+
43+ // XDG_STATE_HOME
44+ stateHome := os.Getenv("XDG_STATE_HOME")
45+ if stateHome == "" {
46+ stateHome = filepath.Join(homeDir, ".local", "state")
47+ }
48+ stateHome = filepath.Join(stateHome, appName)
49+
50+ return &XDGDirs{
51+ ConfigHome: configHome,
52+ DataHome: dataHome,
53+ StateHome: stateHome,
54+ }, nil
55+}
56+
57+// GetConfigDir returns the XDG config directory for dumber.
58+func GetConfigDir() (string, error) {
59+ dirs, err := GetXDGDirs()
60+ if err != nil {
61+ return "", err
62+ }
63+ return dirs.ConfigHome, nil
64+}
65+
66+// GetDataDir returns the XDG data directory for dumber.
67+func GetDataDir() (string, error) {
68+ dirs, err := GetXDGDirs()
69+ if err != nil {
70+ return "", err
71+ }
72+ return dirs.DataHome, nil
73+}
74+
75+// GetStateDir returns the XDG state directory for dumber.
76+func GetStateDir() (string, error) {
77+ dirs, err := GetXDGDirs()
78+ if err != nil {
79+ return "", err
80+ }
81+ return dirs.StateHome, nil
82+}
83+
84+// GetConfigFile returns the path to the main configuration file.
85+func GetConfigFile() (string, error) {
86+ configDir, err := GetConfigDir()
87+ if err != nil {
88+ return "", err
89+ }
90+ return filepath.Join(configDir, "config.json"), nil
91+}
92+
93+// GetDatabaseFile returns the path to the database file in the state directory.
94+func GetDatabaseFile() (string, error) {
95+ stateDir, err := GetStateDir()
96+ if err != nil {
97+ return "", err
98+ }
99+ return filepath.Join(stateDir, "history.db"), nil
100+}
101+
102+// EnsureDirectories creates the XDG directories if they don't exist.
103+func EnsureDirectories() error {
104+ dirs, err := GetXDGDirs()
105+ if err != nil {
106+ return err
107+ }
108+
109+ // Create all directories with proper permissions
110+ directories := []string{
111+ dirs.ConfigHome,
112+ dirs.DataHome,
113+ dirs.StateHome,
114+ }
115+
116+ for _, dir := range directories {
117+ if err := os.MkdirAll(dir, 0755); err != nil {
118+ return err
119+ }
120+ }
121+
122+ return nil
123+}
A internal/db/db.go +31 -0
@@ -0,0 +1,31 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package db
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type DBTX interface {
13+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14+ PrepareContext(context.Context, string) (*sql.Stmt, error)
15+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17+}
18+
19+func New(db DBTX) *Queries {
20+ return &Queries{db: db}
21+}
22+
23+type Queries struct {
24+ db DBTX
25+}
26+
27+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28+ return &Queries{
29+ db: tx,
30+ }
31+}
A internal/db/history.sql.go +136 -0
@@ -0,0 +1,136 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: history.sql
5+
6+package db
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const AddOrUpdateHistory = `-- name: AddOrUpdateHistory :exec
14+INSERT INTO history (url, title)
15+VALUES (?, ?)
16+ON CONFLICT(url)
17+DO UPDATE SET
18+ visit_count = visit_count + 1,
19+ last_visited = CURRENT_TIMESTAMP,
20+ title = EXCLUDED.title
21+`
22+
23+func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error {
24+ _, err := q.db.ExecContext(ctx, AddOrUpdateHistory, url, title)
25+ return err
26+}
27+
28+const GetHistory = `-- name: GetHistory :many
29+SELECT id, url, title, visit_count, last_visited, created_at
30+FROM history
31+ORDER BY last_visited DESC
32+LIMIT ?
33+`
34+
35+func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error) {
36+ rows, err := q.db.QueryContext(ctx, GetHistory, limit)
37+ if err != nil {
38+ return nil, err
39+ }
40+ defer rows.Close()
41+ items := []History{}
42+ for rows.Next() {
43+ var i History
44+ if err := rows.Scan(
45+ &i.ID,
46+ &i.Url,
47+ &i.Title,
48+ &i.VisitCount,
49+ &i.LastVisited,
50+ &i.CreatedAt,
51+ ); err != nil {
52+ return nil, err
53+ }
54+ items = append(items, i)
55+ }
56+ if err := rows.Close(); err != nil {
57+ return nil, err
58+ }
59+ if err := rows.Err(); err != nil {
60+ return nil, err
61+ }
62+ return items, nil
63+}
64+
65+const GetShortcuts = `-- name: GetShortcuts :many
66+SELECT id, shortcut, url_template, description, created_at
67+FROM shortcuts
68+ORDER BY shortcut
69+`
70+
71+func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) {
72+ rows, err := q.db.QueryContext(ctx, GetShortcuts)
73+ if err != nil {
74+ return nil, err
75+ }
76+ defer rows.Close()
77+ items := []Shortcut{}
78+ for rows.Next() {
79+ var i Shortcut
80+ if err := rows.Scan(
81+ &i.ID,
82+ &i.Shortcut,
83+ &i.UrlTemplate,
84+ &i.Description,
85+ &i.CreatedAt,
86+ ); err != nil {
87+ return nil, err
88+ }
89+ items = append(items, i)
90+ }
91+ if err := rows.Close(); err != nil {
92+ return nil, err
93+ }
94+ if err := rows.Err(); err != nil {
95+ return nil, err
96+ }
97+ return items, nil
98+}
99+
100+const SearchHistory = `-- name: SearchHistory :many
101+SELECT id, url, title, visit_count, last_visited, created_at
102+FROM history
103+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
104+ORDER BY visit_count DESC, last_visited DESC
105+LIMIT ?
106+`
107+
108+func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) {
109+ rows, err := q.db.QueryContext(ctx, SearchHistory, column1, column2, limit)
110+ if err != nil {
111+ return nil, err
112+ }
113+ defer rows.Close()
114+ items := []History{}
115+ for rows.Next() {
116+ var i History
117+ if err := rows.Scan(
118+ &i.ID,
119+ &i.Url,
120+ &i.Title,
121+ &i.VisitCount,
122+ &i.LastVisited,
123+ &i.CreatedAt,
124+ ); err != nil {
125+ return nil, err
126+ }
127+ items = append(items, i)
128+ }
129+ if err := rows.Close(); err != nil {
130+ return nil, err
131+ }
132+ if err := rows.Err(); err != nil {
133+ return nil, err
134+ }
135+ return items, nil
136+}
A internal/db/init.go +111 -0
@@ -0,0 +1,111 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
11+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
12+)
13+
14+// InitDB initializes the database connection and schema
15+func InitDB(dbPath string) (*sql.DB, error) {
16+ if dbPath == "" {
17+ return nil, fmt.Errorf("database path cannot be empty")
18+ }
19+
20+ // Ensure database directory exists
21+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
22+ return nil, fmt.Errorf("failed to create database directory: %w", err)
23+ }
24+
25+ // Open database connection
26+ database, err := sql.Open("sqlite3", dbPath)
27+ if err != nil {
28+ return nil, fmt.Errorf("failed to open database: %w", err)
29+ }
30+
31+ // Test the connection
32+ if err := database.Ping(); err != nil {
33+ database.Close()
34+ return nil, fmt.Errorf("failed to connect to database: %w", err)
35+ }
36+
37+ // Initialize database schema
38+ if err := initializeSchema(database); err != nil {
39+ database.Close()
40+ return nil, fmt.Errorf("failed to initialize database schema: %w", err)
41+ }
42+
43+ return database, nil
44+}
45+
46+// InitDBWithConfig initializes the database with configuration-based shortcuts
47+func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
48+ db, err := InitDB(dbPath)
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ // Initialize shortcuts from configuration
54+ if err := initializeShortcuts(db, cfg); err != nil {
55+ db.Close()
56+ return nil, fmt.Errorf("failed to initialize shortcuts: %w", err)
57+ }
58+
59+ return db, nil
60+}
61+
62+// initializeSchema creates the database schema if it doesn't exist
63+func initializeSchema(db *sql.DB) error {
64+ schema := `
65+ -- History tracking for visited URLs
66+ CREATE TABLE IF NOT EXISTS history (
67+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68+ url TEXT NOT NULL UNIQUE,
69+ title TEXT,
70+ visit_count INTEGER DEFAULT 1,
71+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
72+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
73+ );
74+
75+ CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
76+ CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
77+
78+ -- URL shortcuts configuration
79+ CREATE TABLE IF NOT EXISTS shortcuts (
80+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81+ shortcut TEXT NOT NULL UNIQUE,
82+ url_template TEXT NOT NULL,
83+ description TEXT,
84+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
85+ );
86+ `
87+
88+ // Execute schema creation
89+ _, err := db.Exec(schema)
90+ return err
91+}
92+
93+// initializeShortcuts inserts configured shortcuts into the database
94+func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
95+ if cfg == nil || cfg.SearchShortcuts == nil {
96+ return nil // No shortcuts to initialize
97+ }
98+
99+ // Insert configured shortcuts
100+ for shortcut, shortcutCfg := range cfg.SearchShortcuts {
101+ _, err := db.Exec(
102+ "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
103+ shortcut, shortcutCfg.URL, shortcutCfg.Description,
104+ )
105+ if err != nil {
106+ return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
107+ }
108+ }
109+
110+ return nil
111+}⏎̸
A internal/db/models.go +26 -0
@@ -0,0 +1,26 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package db
6+
7+import (
8+ "database/sql"
9+)
10+
11+type History struct {
12+ ID int64 `json:"id"`
13+ Url string `json:"url"`
14+ Title sql.NullString `json:"title"`
15+ VisitCount sql.NullInt64 `json:"visit_count"`
16+ LastVisited sql.NullTime `json:"last_visited"`
17+ CreatedAt sql.NullTime `json:"created_at"`
18+}
19+
20+type Shortcut struct {
21+ ID int64 `json:"id"`
22+ Shortcut string `json:"shortcut"`
23+ UrlTemplate string `json:"url_template"`
24+ Description sql.NullString `json:"description"`
25+ CreatedAt sql.NullTime `json:"created_at"`
26+}
A internal/db/querier.go +19 -0
@@ -0,0 +1,19 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+
5+package db
6+
7+import (
8+ "context"
9+ "database/sql"
10+)
11+
12+type Querier interface {
13+ AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
14+ GetHistory(ctx context.Context, limit int64) ([]History, error)
15+ GetShortcuts(ctx context.Context) ([]Shortcut, error)
16+ SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
17+}
18+
19+var _ Querier = (*Queries)(nil)
A internal/db/queries/history.sql +26 -0
@@ -0,0 +1,26 @@
1+-- name: GetHistory :many
2+SELECT id, url, title, visit_count, last_visited, created_at
3+FROM history
4+ORDER BY last_visited DESC
5+LIMIT ?;
6+
7+-- name: AddOrUpdateHistory :exec
8+INSERT INTO history (url, title)
9+VALUES (?, ?)
10+ON CONFLICT(url)
11+DO UPDATE SET
12+ visit_count = visit_count + 1,
13+ last_visited = CURRENT_TIMESTAMP,
14+ title = EXCLUDED.title;
15+
16+-- name: SearchHistory :many
17+SELECT id, url, title, visit_count, last_visited, created_at
18+FROM history
19+WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
20+ORDER BY visit_count DESC, last_visited DESC
21+LIMIT ?;
22+
23+-- name: GetShortcuts :many
24+SELECT id, shortcut, url_template, description, created_at
25+FROM shortcuts
26+ORDER BY shortcut;⏎̸
A internal/parser/fuzzy.go +471 -0
@@ -0,0 +1,471 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "math"
6+ "sort"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/bnema/dumber/internal/db"
12+)
13+
14+// FuzzyMatcher implements efficient fuzzy string matching algorithms.
15+type FuzzyMatcher struct {
16+ config *FuzzyConfig
17+}
18+
19+// NewFuzzyMatcher creates a new FuzzyMatcher with the given configuration.
20+func NewFuzzyMatcher(config *FuzzyConfig) *FuzzyMatcher {
21+ return &FuzzyMatcher{
22+ config: config,
23+ }
24+}
25+
26+// SearchHistory performs fuzzy search on history entries and returns ranked matches.
27+func (fm *FuzzyMatcher) SearchHistory(query string, history []*db.History) []FuzzyMatch {
28+ if len(history) == 0 || query == "" {
29+ return nil
30+ }
31+
32+ query = strings.ToLower(strings.TrimSpace(query))
33+ matches := make([]FuzzyMatch, 0)
34+
35+ for _, entry := range history {
36+ match := fm.matchHistoryEntry(query, entry)
37+ if match.Score >= fm.config.MinSimilarityThreshold {
38+ matches = append(matches, match)
39+ }
40+ }
41+
42+ // Sort by score (descending)
43+ sort.Slice(matches, func(i, j int) bool {
44+ return matches[i].Score > matches[j].Score
45+ })
46+
47+ // Limit results
48+ if len(matches) > fm.config.MaxResults {
49+ matches = matches[:fm.config.MaxResults]
50+ }
51+
52+ return matches
53+}
54+
55+// matchHistoryEntry calculates fuzzy match score for a history entry.
56+func (fm *FuzzyMatcher) matchHistoryEntry(query string, entry *db.History) FuzzyMatch {
57+ match := FuzzyMatch{
58+ HistoryEntry: entry,
59+ }
60+
61+ url := strings.ToLower(entry.Url)
62+ title := ""
63+ if entry.Title.Valid {
64+ title = strings.ToLower(entry.Title.String)
65+ }
66+
67+ // Calculate URL similarity
68+ match.URLScore = fm.calculateSimilarity(query, url)
69+ urlSubstring := fm.substringMatch(query, url)
70+ if urlSubstring > match.URLScore {
71+ match.URLScore = urlSubstring
72+ }
73+
74+ // Calculate title similarity
75+ if title != "" {
76+ match.TitleScore = fm.calculateSimilarity(query, title)
77+ titleSubstring := fm.substringMatch(query, title)
78+ if titleSubstring > match.TitleScore {
79+ match.TitleScore = titleSubstring
80+ }
81+ }
82+
83+ // Calculate recency score
84+ match.RecencyScore = fm.calculateRecencyScore(entry.LastVisited)
85+
86+ // Calculate visit count score
87+ match.VisitScore = fm.calculateVisitScore(entry.VisitCount)
88+
89+ // Determine matched field
90+ if match.URLScore >= match.TitleScore {
91+ match.MatchedField = "url"
92+ if match.URLScore > 0 && match.TitleScore > 0 {
93+ match.MatchedField = "both"
94+ }
95+ } else {
96+ match.MatchedField = "title"
97+ }
98+
99+ // Calculate weighted final score
100+ match.Score = fm.config.URLWeight*match.URLScore +
101+ fm.config.TitleWeight*match.TitleScore +
102+ fm.config.RecencyWeight*match.RecencyScore +
103+ fm.config.VisitWeight*match.VisitScore
104+
105+ return match
106+}
107+
108+// calculateSimilarity computes similarity using Jaro-Winkler distance.
109+func (fm *FuzzyMatcher) calculateSimilarity(s1, s2 string) float64 {
110+ if s1 == s2 {
111+ return 1.0
112+ }
113+
114+ if len(s1) == 0 || len(s2) == 0 {
115+ return 0.0
116+ }
117+
118+ // Use Jaro-Winkler distance for better results
119+ return fm.jaroWinklerSimilarity(s1, s2)
120+}
121+
122+// substringMatch calculates substring match score with position weighting.
123+func (fm *FuzzyMatcher) substringMatch(query, text string) float64 {
124+ if query == "" || text == "" {
125+ return 0.0
126+ }
127+
128+ // Check for exact substring match
129+ index := strings.Index(text, query)
130+ if index == -1 {
131+ return 0.0
132+ }
133+
134+ // Base score for substring match
135+ baseScore := float64(len(query)) / float64(len(text))
136+
137+ // Boost score if match is at the beginning
138+ positionWeight := 1.0
139+ if index == 0 {
140+ positionWeight = 1.5
141+ } else if index < len(text)/3 {
142+ positionWeight = 1.2
143+ }
144+
145+ score := baseScore * positionWeight
146+ if score > 1.0 {
147+ score = 1.0
148+ }
149+
150+ return score
151+}
152+
153+// jaroWinklerSimilarity calculates Jaro-Winkler similarity between two strings.
154+func (fm *FuzzyMatcher) jaroWinklerSimilarity(s1, s2 string) float64 {
155+ jaroSim := fm.jaroSimilarity(s1, s2)
156+
157+ if jaroSim < 0.7 {
158+ return jaroSim
159+ }
160+
161+ // Calculate common prefix length (up to 4 characters)
162+ prefixLength := 0
163+ maxPrefix := 4
164+ minLen := len(s1)
165+ if len(s2) < minLen {
166+ minLen = len(s2)
167+ }
168+ if maxPrefix > minLen {
169+ maxPrefix = minLen
170+ }
171+
172+ for i := 0; i < maxPrefix; i++ {
173+ if s1[i] == s2[i] {
174+ prefixLength++
175+ } else {
176+ break
177+ }
178+ }
179+
180+ // Winkler modification
181+ return jaroSim + (0.1 * float64(prefixLength) * (1 - jaroSim))
182+}
183+
184+// jaroSimilarity calculates Jaro similarity between two strings.
185+func (fm *FuzzyMatcher) jaroSimilarity(s1, s2 string) float64 {
186+ len1 := utf8.RuneCountInString(s1)
187+ len2 := utf8.RuneCountInString(s2)
188+
189+ if len1 == 0 && len2 == 0 {
190+ return 1.0
191+ }
192+ if len1 == 0 || len2 == 0 {
193+ return 0.0
194+ }
195+
196+ // Convert to rune slices for proper Unicode handling
197+ runes1 := []rune(s1)
198+ runes2 := []rune(s2)
199+
200+ // Calculate match window
201+ matchWindow := (maxInt(len1, len2) / 2) - 1
202+ if matchWindow < 0 {
203+ matchWindow = 0
204+ }
205+
206+ // Track matches
207+ matches1 := make([]bool, len1)
208+ matches2 := make([]bool, len2)
209+ matchCount := 0
210+
211+ // Find matches
212+ for i := 0; i < len1; i++ {
213+ start := maxInt(0, i-matchWindow)
214+ end := minInt(i+matchWindow+1, len2)
215+
216+ for j := start; j < end; j++ {
217+ if matches2[j] || runes1[i] != runes2[j] {
218+ continue
219+ }
220+ matches1[i] = true
221+ matches2[j] = true
222+ matchCount++
223+ break
224+ }
225+ }
226+
227+ if matchCount == 0 {
228+ return 0.0
229+ }
230+
231+ // Count transpositions
232+ transpositions := 0
233+ k := 0
234+ for i := 0; i < len1; i++ {
235+ if !matches1[i] {
236+ continue
237+ }
238+ for !matches2[k] {
239+ k++
240+ }
241+ if runes1[i] != runes2[k] {
242+ transpositions++
243+ }
244+ k++
245+ }
246+
247+ // Calculate Jaro similarity
248+ return (float64(matchCount)/float64(len1) +
249+ float64(matchCount)/float64(len2) +
250+ float64(matchCount-transpositions/2)/float64(matchCount)) / 3.0
251+}
252+
253+// calculateRecencyScore calculates score based on how recently the entry was visited.
254+func (fm *FuzzyMatcher) calculateRecencyScore(lastVisited sql.NullTime) float64 {
255+ if !lastVisited.Valid {
256+ return 0.0
257+ }
258+
259+ now := time.Now()
260+ daysSince := now.Sub(lastVisited.Time).Hours() / 24
261+
262+ // Exponential decay based on days since last visit
263+ decayRate := float64(fm.config.RecencyDecayDays)
264+ score := math.Exp(-daysSince / decayRate)
265+
266+ return score
267+}
268+
269+// calculateVisitScore calculates score based on visit count with logarithmic scaling.
270+func (fm *FuzzyMatcher) calculateVisitScore(visitCount sql.NullInt64) float64 {
271+ if !visitCount.Valid || visitCount.Int64 <= 0 {
272+ return 0.0
273+ }
274+
275+ // Logarithmic scaling to prevent highly visited sites from dominating
276+ score := math.Log1p(float64(visitCount.Int64)) / math.Log1p(1000) // Normalize to max 1000 visits
277+ if score > 1.0 {
278+ score = 1.0
279+ }
280+
281+ return score
282+}
283+
284+// LevenshteinDistance calculates the Levenshtein distance between two strings.
285+func (fm *FuzzyMatcher) LevenshteinDistance(s1, s2 string) int {
286+ runes1 := []rune(s1)
287+ runes2 := []rune(s2)
288+
289+ len1 := len(runes1)
290+ len2 := len(runes2)
291+
292+ // Create a matrix to store distances
293+ matrix := make([][]int, len1+1)
294+ for i := range matrix {
295+ matrix[i] = make([]int, len2+1)
296+ }
297+
298+ // Initialize first row and column
299+ for i := 0; i <= len1; i++ {
300+ matrix[i][0] = i
301+ }
302+ for j := 0; j <= len2; j++ {
303+ matrix[0][j] = j
304+ }
305+
306+ // Fill the matrix
307+ for i := 1; i <= len1; i++ {
308+ for j := 1; j <= len2; j++ {
309+ cost := 1
310+ if runes1[i-1] == runes2[j-1] {
311+ cost = 0
312+ }
313+
314+ deletion := matrix[i-1][j] + 1
315+ insertion := matrix[i][j-1] + 1
316+ substitution := matrix[i-1][j-1] + cost
317+
318+ matrix[i][j] = minInt(deletion, minInt(insertion, substitution))
319+ }
320+ }
321+
322+ return matrix[len1][len2]
323+}
324+
325+// LevenshteinSimilarity converts Levenshtein distance to similarity score (0.0-1.0).
326+func (fm *FuzzyMatcher) LevenshteinSimilarity(s1, s2 string) float64 {
327+ if s1 == s2 {
328+ return 1.0
329+ }
330+
331+ distance := fm.LevenshteinDistance(s1, s2)
332+ maxLen := maxInt(utf8.RuneCountInString(s1), utf8.RuneCountInString(s2))
333+
334+ if maxLen == 0 {
335+ return 1.0
336+ }
337+
338+ return 1.0 - float64(distance)/float64(maxLen)
339+}
340+
341+// TokenizedMatch performs token-based fuzzy matching for better phrase matching.
342+func (fm *FuzzyMatcher) TokenizedMatch(query, text string) float64 {
343+ queryTokens := strings.Fields(strings.ToLower(query))
344+ textTokens := strings.Fields(strings.ToLower(text))
345+
346+ if len(queryTokens) == 0 || len(textTokens) == 0 {
347+ return 0.0
348+ }
349+
350+ totalScore := 0.0
351+ matchedTokens := 0
352+
353+ for _, queryToken := range queryTokens {
354+ bestScore := 0.0
355+ for _, textToken := range textTokens {
356+ score := fm.calculateSimilarity(queryToken, textToken)
357+ if score > bestScore {
358+ bestScore = score
359+ }
360+ }
361+ if bestScore >= 0.5 { // Threshold for token match
362+ totalScore += bestScore
363+ matchedTokens++
364+ }
365+ }
366+
367+ if matchedTokens == 0 {
368+ return 0.0
369+ }
370+
371+ // Average score weighted by matched token ratio
372+ avgScore := totalScore / float64(matchedTokens)
373+ tokenRatio := float64(matchedTokens) / float64(len(queryTokens))
374+
375+ return avgScore * tokenRatio
376+}
377+
378+// RankMatches re-ranks fuzzy matches based on additional criteria.
379+func (fm *FuzzyMatcher) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
380+ if len(matches) == 0 {
381+ return matches
382+ }
383+
384+ // Apply additional ranking factors
385+ for i := range matches {
386+ match := &matches[i]
387+
388+ // Boost exact matches
389+ if strings.Contains(strings.ToLower(match.HistoryEntry.Url), strings.ToLower(query)) {
390+ match.Score *= 1.2
391+ }
392+
393+ // Boost domain matches
394+ if fm.isDomainMatch(query, match.HistoryEntry.Url) {
395+ match.Score *= 1.15
396+ }
397+
398+ // Boost short URLs (likely more relevant)
399+ if len(match.HistoryEntry.Url) < 50 {
400+ match.Score *= 1.05
401+ }
402+ }
403+
404+ // Re-sort by updated scores
405+ sort.Slice(matches, func(i, j int) bool {
406+ return matches[i].Score > matches[j].Score
407+ })
408+
409+ return matches
410+}
411+
412+// isDomainMatch checks if the query matches the domain of the URL.
413+func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool {
414+ query = strings.ToLower(strings.TrimSpace(query))
415+
416+ // Extract domain from URL
417+ domain := url
418+ if strings.HasPrefix(url, "http://") {
419+ domain = url[7:]
420+ } else if strings.HasPrefix(url, "https://") {
421+ domain = url[8:]
422+ }
423+
424+ // Remove path
425+ if idx := strings.Index(domain, "/"); idx != -1 {
426+ domain = domain[:idx]
427+ }
428+
429+ // Remove port
430+ if idx := strings.Index(domain, ":"); idx != -1 {
431+ domain = domain[:idx]
432+ }
433+
434+ domain = strings.ToLower(domain)
435+
436+ // Check for exact domain match
437+ if domain == query {
438+ return true
439+ }
440+
441+ // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com")
442+ if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") {
443+ return true
444+ }
445+
446+ // Check if query matches a significant part of domain (not just a substring)
447+ // Split domain by dots and check if any part matches query
448+ domainParts := strings.Split(domain, ".")
449+ for _, part := range domainParts {
450+ if part == query {
451+ return true
452+ }
453+ }
454+
455+ return false
456+}
457+
458+// Helper functions
459+func minInt(a, b int) int {
460+ if a < b {
461+ return a
462+ }
463+ return b
464+}
465+
466+func maxInt(a, b int) int {
467+ if a > b {
468+ return a
469+ }
470+ return b
471+}
A internal/parser/fuzzy_test.go +520 -0
@@ -0,0 +1,520 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+func TestFuzzyMatcher_JaroWinklerSimilarity(t *testing.T) {
12+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
13+
14+ tests := []struct {
15+ s1 string
16+ s2 string
17+ expected float64
18+ delta float64 // Acceptable difference for float comparison
19+ }{
20+ {"", "", 1.0, 0.0},
21+ {"", "test", 0.0, 0.0},
22+ {"test", "", 0.0, 0.0},
23+ {"test", "test", 1.0, 0.0},
24+ {"github", "github", 1.0, 0.0},
25+ {"github", "gihub", 0.95, 0.05}, // High similarity
26+ {"github", "bitbucket", 0.62, 0.15}, // Lower similarity
27+ {"stackoverflow", "stack", 0.87, 0.05}, // Partial match
28+ {"google", "goggle", 0.9, 0.1}, // Single char difference
29+ {"programming", "programing", 0.95, 0.05}, // Missing char
30+ }
31+
32+ for _, tt := range tests {
33+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
34+ result := fuzzyMatcher.jaroWinklerSimilarity(tt.s1, tt.s2)
35+
36+ if result < 0.0 || result > 1.0 {
37+ t.Errorf("jaroWinklerSimilarity() = %v, should be between 0.0 and 1.0", result)
38+ }
39+
40+ diff := result - tt.expected
41+ if diff < 0 {
42+ diff = -diff
43+ }
44+ if diff > tt.delta {
45+ t.Errorf("jaroWinklerSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
46+ }
47+ })
48+ }
49+}
50+
51+func TestFuzzyMatcher_LevenshteinDistance(t *testing.T) {
52+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
53+
54+ tests := []struct {
55+ s1 string
56+ s2 string
57+ expected int
58+ }{
59+ {"", "", 0},
60+ {"", "test", 4},
61+ {"test", "", 4},
62+ {"test", "test", 0},
63+ {"github", "github", 0},
64+ {"github", "gihub", 1}, // 1 deletion
65+ {"cat", "bat", 1}, // 1 substitution
66+ {"kitten", "sitting", 3}, // Classic example
67+ {"programming", "programing", 1}, // 1 deletion
68+ }
69+
70+ for _, tt := range tests {
71+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
72+ result := fuzzyMatcher.LevenshteinDistance(tt.s1, tt.s2)
73+ if result != tt.expected {
74+ t.Errorf("LevenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, result, tt.expected)
75+ }
76+ })
77+ }
78+}
79+
80+func TestFuzzyMatcher_LevenshteinSimilarity(t *testing.T) {
81+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
82+
83+ tests := []struct {
84+ s1 string
85+ s2 string
86+ expected float64
87+ delta float64
88+ }{
89+ {"test", "test", 1.0, 0.0},
90+ {"", "", 1.0, 0.0},
91+ {"github", "gihub", 0.83, 0.05}, // 1 error out of 6 chars
92+ {"cat", "bat", 0.67, 0.05}, // 1 error out of 3 chars
93+ }
94+
95+ for _, tt := range tests {
96+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
97+ result := fuzzyMatcher.LevenshteinSimilarity(tt.s1, tt.s2)
98+
99+ if result < 0.0 || result > 1.0 {
100+ t.Errorf("LevenshteinSimilarity() = %v, should be between 0.0 and 1.0", result)
101+ }
102+
103+ diff := result - tt.expected
104+ if diff < 0 {
105+ diff = -diff
106+ }
107+ if diff > tt.delta {
108+ t.Errorf("LevenshteinSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
109+ }
110+ })
111+ }
112+}
113+
114+func TestFuzzyMatcher_SubstringMatch(t *testing.T) {
115+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
116+
117+ tests := []struct {
118+ query string
119+ text string
120+ expected float64
121+ delta float64
122+ }{
123+ {"", "test", 0.0, 0.0},
124+ {"test", "", 0.0, 0.0},
125+ {"github", "github.com", 0.9, 0.1}, // Full match at start gets boost
126+ {"hub", "github.com", 0.3, 0.1}, // Partial match
127+ {"git", "github.com", 0.45, 0.1}, // Match at beginning gets boost
128+ {"com", "github.com", 0.3, 0.1}, // Match at end
129+ {"xyz", "github.com", 0.0, 0.0}, // No match
130+ }
131+
132+ for _, tt := range tests {
133+ t.Run(tt.query+"_in_"+tt.text, func(t *testing.T) {
134+ result := fuzzyMatcher.substringMatch(tt.query, tt.text)
135+
136+ if result < 0.0 || result > 1.0 {
137+ t.Errorf("substringMatch() = %v, should be between 0.0 and 1.0", result)
138+ }
139+
140+ diff := result - tt.expected
141+ if diff < 0 {
142+ diff = -diff
143+ }
144+ if diff > tt.delta {
145+ t.Errorf("substringMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
146+ }
147+ })
148+ }
149+}
150+
151+func TestFuzzyMatcher_TokenizedMatch(t *testing.T) {
152+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
153+
154+ tests := []struct {
155+ query string
156+ text string
157+ expected float64
158+ delta float64
159+ }{
160+ {"go programming", "The Go Programming Language", 0.8, 0.2},
161+ {"stack overflow", "Stack Overflow - Where Developers Learn", 0.8, 0.2},
162+ {"github repo", "GitHub Repository", 0.94, 0.1},
163+ {"missing words", "Something completely different", 0.34, 0.1},
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.query+"_vs_"+tt.text, func(t *testing.T) {
168+ result := fuzzyMatcher.TokenizedMatch(tt.query, tt.text)
169+
170+ if result < 0.0 || result > 1.0 {
171+ t.Errorf("TokenizedMatch() = %v, should be between 0.0 and 1.0", result)
172+ }
173+
174+ diff := result - tt.expected
175+ if diff < 0 {
176+ diff = -diff
177+ }
178+ if diff > tt.delta {
179+ t.Errorf("TokenizedMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
180+ }
181+ })
182+ }
183+}
184+
185+func TestFuzzyMatcher_SearchHistory(t *testing.T) {
186+ config := DefaultFuzzyConfig()
187+ config.MinSimilarityThreshold = 0.3
188+ config.MaxResults = 10
189+ fuzzyMatcher := NewFuzzyMatcher(config)
190+
191+ now := time.Now()
192+ history := []*db.History{
193+ {
194+ ID: 1,
195+ Url: "https://github.com",
196+ Title: sql.NullString{String: "GitHub", Valid: true},
197+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
198+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
199+ },
200+ {
201+ ID: 2,
202+ Url: "https://stackoverflow.com",
203+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
204+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
205+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
206+ },
207+ {
208+ ID: 3,
209+ Url: "https://golang.org",
210+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
211+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
212+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
213+ },
214+ }
215+
216+ tests := []struct {
217+ name string
218+ query string
219+ expectMatches int
220+ expectFirst string // Expected first match URL
221+ }{
222+ {
223+ name: "Exact domain match",
224+ query: "github",
225+ expectMatches: 1,
226+ expectFirst: "https://github.com",
227+ },
228+ {
229+ name: "Partial title match",
230+ query: "programming",
231+ expectMatches: 1,
232+ expectFirst: "https://golang.org",
233+ },
234+ {
235+ name: "Multiple matches",
236+ query: "o", // Should match github.com, stackoverflow.com, golang.org
237+ expectMatches: 3,
238+ },
239+ {
240+ name: "No matches",
241+ query: "nonexistent",
242+ expectMatches: 0,
243+ },
244+ }
245+
246+ for _, tt := range tests {
247+ t.Run(tt.name, func(t *testing.T) {
248+ matches := fuzzyMatcher.SearchHistory(tt.query, history)
249+
250+ if len(matches) < tt.expectMatches {
251+ t.Errorf("SearchHistory() returned %d matches, want >= %d", len(matches), tt.expectMatches)
252+ }
253+
254+ if len(matches) > 0 && tt.expectFirst != "" {
255+ if matches[0].HistoryEntry.Url != tt.expectFirst {
256+ t.Errorf("SearchHistory() first match = %q, want %q", matches[0].HistoryEntry.Url, tt.expectFirst)
257+ }
258+ }
259+
260+ // Verify scores are in descending order
261+ for i := 1; i < len(matches); i++ {
262+ if matches[i].Score > matches[i-1].Score {
263+ t.Errorf("SearchHistory() results not sorted by score: %f > %f", matches[i].Score, matches[i-1].Score)
264+ }
265+ }
266+
267+ // Verify all scores meet threshold
268+ for i, match := range matches {
269+ if match.Score < config.MinSimilarityThreshold {
270+ t.Errorf("SearchHistory() match %d score %f below threshold %f", i, match.Score, config.MinSimilarityThreshold)
271+ }
272+ }
273+ })
274+ }
275+}
276+
277+func TestFuzzyMatcher_CalculateRecencyScore(t *testing.T) {
278+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
279+ now := time.Now()
280+
281+ tests := []struct {
282+ name string
283+ lastVisit sql.NullTime
284+ expected float64
285+ delta float64
286+ }{
287+ {
288+ name: "Invalid time",
289+ lastVisit: sql.NullTime{Valid: false},
290+ expected: 0.0,
291+ delta: 0.0,
292+ },
293+ {
294+ name: "Recent visit (1 hour ago)",
295+ lastVisit: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
296+ expected: 0.95,
297+ delta: 0.1,
298+ },
299+ {
300+ name: "Old visit (30 days ago)",
301+ lastVisit: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
302+ expected: 0.37,
303+ delta: 0.1,
304+ },
305+ {
306+ name: "Very old visit (90 days ago)",
307+ lastVisit: sql.NullTime{Time: now.Add(-90 * 24 * time.Hour), Valid: true},
308+ expected: 0.05,
309+ delta: 0.05,
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ result := fuzzyMatcher.calculateRecencyScore(tt.lastVisit)
316+
317+ if result < 0.0 || result > 1.0 {
318+ t.Errorf("calculateRecencyScore() = %v, should be between 0.0 and 1.0", result)
319+ }
320+
321+ diff := result - tt.expected
322+ if diff < 0 {
323+ diff = -diff
324+ }
325+ if diff > tt.delta {
326+ t.Errorf("calculateRecencyScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
327+ }
328+ })
329+ }
330+}
331+
332+func TestFuzzyMatcher_CalculateVisitScore(t *testing.T) {
333+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
334+
335+ tests := []struct {
336+ name string
337+ visitCount sql.NullInt64
338+ expected float64
339+ delta float64
340+ }{
341+ {
342+ name: "Invalid count",
343+ visitCount: sql.NullInt64{Valid: false},
344+ expected: 0.0,
345+ delta: 0.0,
346+ },
347+ {
348+ name: "Zero visits",
349+ visitCount: sql.NullInt64{Int64: 0, Valid: true},
350+ expected: 0.0,
351+ delta: 0.0,
352+ },
353+ {
354+ name: "Single visit",
355+ visitCount: sql.NullInt64{Int64: 1, Valid: true},
356+ expected: 0.1,
357+ delta: 0.05,
358+ },
359+ {
360+ name: "Many visits",
361+ visitCount: sql.NullInt64{Int64: 100, Valid: true},
362+ expected: 0.67,
363+ delta: 0.1,
364+ },
365+ {
366+ name: "Very many visits",
367+ visitCount: sql.NullInt64{Int64: 1000, Valid: true},
368+ expected: 1.0,
369+ delta: 0.01,
370+ },
371+ }
372+
373+ for _, tt := range tests {
374+ t.Run(tt.name, func(t *testing.T) {
375+ result := fuzzyMatcher.calculateVisitScore(tt.visitCount)
376+
377+ if result < 0.0 || result > 1.0 {
378+ t.Errorf("calculateVisitScore() = %v, should be between 0.0 and 1.0", result)
379+ }
380+
381+ diff := result - tt.expected
382+ if diff < 0 {
383+ diff = -diff
384+ }
385+ if diff > tt.delta {
386+ t.Errorf("calculateVisitScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
387+ }
388+ })
389+ }
390+}
391+
392+func TestFuzzyMatcher_IsDomainMatch(t *testing.T) {
393+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
394+
395+ tests := []struct {
396+ query string
397+ url string
398+ expected bool
399+ }{
400+ {"github", "https://github.com", true},
401+ {"github", "https://www.github.com", true},
402+ {"github", "https://api.github.com", true},
403+ {"github", "https://stackoverflow.com", false},
404+ {"stackoverflow", "https://stackoverflow.com/questions", true},
405+ {"google", "https://www.google.com/search", true},
406+ {"localhost", "http://localhost:8080", true},
407+ {"example", "https://example.org", true}, // Matches domain part
408+ {"sub.example", "https://sub.example.com", true},
409+ }
410+
411+ for _, tt := range tests {
412+ t.Run(tt.query+"_vs_"+tt.url, func(t *testing.T) {
413+ result := fuzzyMatcher.isDomainMatch(tt.query, tt.url)
414+ if result != tt.expected {
415+ t.Errorf("isDomainMatch(%q, %q) = %v, want %v", tt.query, tt.url, result, tt.expected)
416+ }
417+ })
418+ }
419+}
420+
421+func TestFuzzyMatcher_RankMatches(t *testing.T) {
422+ config := DefaultFuzzyConfig()
423+ fuzzyMatcher := NewFuzzyMatcher(config)
424+
425+ now := time.Now()
426+ matches := []FuzzyMatch{
427+ {
428+ HistoryEntry: &db.History{
429+ Url: "https://example.com/very/long/path/that/makes/url/longer",
430+ VisitCount: sql.NullInt64{Int64: 5, Valid: true},
431+ LastVisited: sql.NullTime{Time: now.Add(-10 * time.Hour), Valid: true},
432+ },
433+ Score: 0.5,
434+ },
435+ {
436+ HistoryEntry: &db.History{
437+ Url: "https://github.com",
438+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
439+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
440+ },
441+ Score: 0.7,
442+ },
443+ {
444+ HistoryEntry: &db.History{
445+ Url: "https://test.com",
446+ VisitCount: sql.NullInt64{Int64: 1, Valid: true},
447+ LastVisited: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true},
448+ },
449+ Score: 0.6,
450+ },
451+ }
452+
453+ rankedMatches := fuzzyMatcher.RankMatches(matches, "github")
454+
455+ // Verify the matches are still sorted by score (descending)
456+ for i := 1; i < len(rankedMatches); i++ {
457+ if rankedMatches[i].Score > rankedMatches[i-1].Score {
458+ t.Errorf("RankMatches() results not sorted: %f > %f", rankedMatches[i].Score, rankedMatches[i-1].Score)
459+ }
460+ }
461+
462+ // The github.com match should get a boost and likely be first
463+ githubFound := false
464+ for i, match := range rankedMatches {
465+ if match.HistoryEntry.Url == "https://github.com" {
466+ githubFound = true
467+ // Should be boosted due to domain match
468+ if i > 0 && rankedMatches[0].HistoryEntry.Url != "https://github.com" {
469+ // It's possible another match scored higher even after boost
470+ t.Logf("GitHub match at position %d with score %f", i, match.Score)
471+ }
472+ break
473+ }
474+ }
475+
476+ if !githubFound {
477+ t.Errorf("RankMatches() didn't preserve GitHub match")
478+ }
479+}
480+
481+func BenchmarkFuzzyMatcher_JaroWinklerSimilarity(b *testing.B) {
482+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
483+
484+ b.ResetTimer()
485+ for i := 0; i < b.N; i++ {
486+ fuzzyMatcher.jaroWinklerSimilarity("github", "gihub")
487+ }
488+}
489+
490+func BenchmarkFuzzyMatcher_LevenshteinDistance(b *testing.B) {
491+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
492+
493+ b.ResetTimer()
494+ for i := 0; i < b.N; i++ {
495+ fuzzyMatcher.LevenshteinDistance("programming", "programing")
496+ }
497+}
498+
499+func BenchmarkFuzzyMatcher_SearchHistory(b *testing.B) {
500+ config := DefaultFuzzyConfig()
501+ fuzzyMatcher := NewFuzzyMatcher(config)
502+
503+ now := time.Now()
504+ // Create larger history set for more realistic benchmark
505+ history := make([]*db.History, 100)
506+ for i := 0; i < 100; i++ {
507+ history[i] = &db.History{
508+ ID: int64(i + 1),
509+ Url: "https://example" + string(rune(i%26+'a')) + ".com",
510+ Title: sql.NullString{String: "Test Site " + string(rune(i%26+'A')), Valid: true},
511+ VisitCount: sql.NullInt64{Int64: int64(i % 50), Valid: true},
512+ LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
513+ }
514+ }
515+
516+ b.ResetTimer()
517+ for i := 0; i < b.N; i++ {
518+ fuzzyMatcher.SearchHistory("example", history)
519+ }
520+}
A internal/parser/history.go +407 -0
@@ -0,0 +1,407 @@
1+package parser
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+// HistorySearcher provides history search integration with fuzzy matching.
12+type HistorySearcher struct {
13+ provider HistoryProvider
14+ fuzzyMatcher *FuzzyMatcher
15+ fuzzyConfig *FuzzyConfig
16+}
17+
18+// NewHistorySearcher creates a new HistorySearcher.
19+func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher {
20+ if fuzzyConfig == nil {
21+ fuzzyConfig = DefaultFuzzyConfig()
22+ }
23+
24+ return &HistorySearcher{
25+ provider: provider,
26+ fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig),
27+ fuzzyConfig: fuzzyConfig,
28+ }
29+}
30+
31+// SearchWithFuzzy performs fuzzy search on history entries.
32+func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) {
33+ if query == "" {
34+ return &HistorySearchResult{}, nil
35+ }
36+
37+ // Apply search options
38+ searchConfig := &SearchConfig{
39+ Limit: hs.fuzzyConfig.MaxResults,
40+ MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
41+ IncludeExactMatches: true,
42+ IncludeFuzzyMatches: true,
43+ SortByRelevance: true,
44+ }
45+
46+ for _, opt := range options {
47+ opt(searchConfig)
48+ }
49+
50+ startTime := time.Now()
51+
52+ // Get all history for fuzzy search
53+ allHistory, err := hs.provider.GetAllHistory()
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
56+ }
57+
58+ result := &HistorySearchResult{
59+ Query: query,
60+ TotalEntries: len(allHistory),
61+ SearchTime: 0, // Will be set below
62+ }
63+
64+ // Perform exact search first if requested
65+ if searchConfig.IncludeExactMatches {
66+ exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit)
67+ if err == nil && len(exactMatches) > 0 {
68+ // Convert to FuzzyMatch format with perfect scores
69+ for _, entry := range exactMatches {
70+ match := FuzzyMatch{
71+ HistoryEntry: entry,
72+ Score: 1.0,
73+ URLScore: 1.0,
74+ TitleScore: 1.0,
75+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
76+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
77+ MatchedField: "exact",
78+ }
79+ result.ExactMatches = append(result.ExactMatches, match)
80+ }
81+ }
82+ }
83+
84+ // Perform fuzzy search if requested
85+ if searchConfig.IncludeFuzzyMatches {
86+ // Create temporary config with search options
87+ tempConfig := *hs.fuzzyConfig
88+ tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold
89+ tempConfig.MaxResults = searchConfig.Limit
90+
91+ tempMatcher := NewFuzzyMatcher(&tempConfig)
92+ fuzzyMatches := tempMatcher.SearchHistory(query, allHistory)
93+
94+ // Filter out matches that are already in exact matches
95+ exactURLs := make(map[string]bool)
96+ for _, exact := range result.ExactMatches {
97+ exactURLs[exact.HistoryEntry.Url] = true
98+ }
99+
100+ for _, match := range fuzzyMatches {
101+ if !exactURLs[match.HistoryEntry.Url] {
102+ result.FuzzyMatches = append(result.FuzzyMatches, match)
103+ }
104+ }
105+ }
106+
107+ // Combine and sort results if requested
108+ if searchConfig.SortByRelevance {
109+ result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query)
110+ }
111+
112+ // Limit final results
113+ if len(result.RankedMatches) > searchConfig.Limit {
114+ result.RankedMatches = result.RankedMatches[:searchConfig.Limit]
115+ }
116+
117+ result.MatchCount = len(result.RankedMatches)
118+ result.SearchTime = time.Since(startTime)
119+
120+ return result, nil
121+}
122+
123+// SearchByDomain searches history entries by domain with fuzzy matching.
124+func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) {
125+ // Get all history entries
126+ allHistory, err := hs.provider.GetAllHistory()
127+ if err != nil {
128+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
129+ }
130+
131+ // Filter by domain and apply fuzzy matching
132+ domainEntries := make([]*db.History, 0)
133+ domain = strings.ToLower(domain)
134+
135+ for _, entry := range allHistory {
136+ entryDomain := hs.extractDomain(entry.Url)
137+ if strings.Contains(strings.ToLower(entryDomain), domain) {
138+ domainEntries = append(domainEntries, entry)
139+ }
140+ }
141+
142+ // If we have domain matches, rank them by fuzzy score
143+ matches := make([]FuzzyMatch, 0)
144+ for _, entry := range domainEntries {
145+ match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
146+ matches = append(matches, match)
147+ }
148+
149+ // Sort by score
150+ matches = hs.fuzzyMatcher.RankMatches(matches, domain)
151+
152+ return &HistorySearchResult{
153+ Query: domain,
154+ TotalEntries: len(allHistory),
155+ MatchCount: len(matches),
156+ RankedMatches: matches,
157+ SearchTime: time.Since(time.Now()),
158+ }, nil
159+}
160+
161+// SearchByTimeRange searches history entries within a time range with fuzzy matching.
162+func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
163+ // Get all history entries
164+ allHistory, err := hs.provider.GetAllHistory()
165+ if err != nil {
166+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
167+ }
168+
169+ // Filter by time range
170+ filteredHistory := make([]*db.History, 0)
171+ for _, entry := range allHistory {
172+ if entry.LastVisited.Valid {
173+ visitTime := entry.LastVisited.Time
174+ if visitTime.After(startTime) && visitTime.Before(endTime) {
175+ filteredHistory = append(filteredHistory, entry)
176+ }
177+ }
178+ }
179+
180+ // Perform fuzzy search on filtered entries
181+ matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
182+
183+ return &HistorySearchResult{
184+ Query: query,
185+ TotalEntries: len(filteredHistory),
186+ MatchCount: len(matches),
187+ RankedMatches: matches,
188+ SearchTime: time.Since(time.Now()),
189+ }, nil
190+}
191+
192+// GetTopVisited returns the most visited sites with optional fuzzy filtering.
193+func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
194+ allHistory, err := hs.provider.GetAllHistory()
195+ if err != nil {
196+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
197+ }
198+
199+ // Sort by visit count
200+ matches := make([]FuzzyMatch, 0)
201+ for _, entry := range allHistory {
202+ // Apply filter if provided
203+ if filterQuery != "" {
204+ match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
205+ if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
206+ continue
207+ }
208+ matches = append(matches, match)
209+ } else {
210+ // No filter, just create basic match with visit score
211+ match := FuzzyMatch{
212+ HistoryEntry: entry,
213+ Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
214+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
215+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
216+ MatchedField: "visit_count",
217+ }
218+ matches = append(matches, match)
219+ }
220+ }
221+
222+ // Sort by visit count (descending)
223+ for i := 0; i < len(matches); i++ {
224+ for j := i + 1; j < len(matches); j++ {
225+ iVisits := int64(0)
226+ jVisits := int64(0)
227+ if matches[i].HistoryEntry.VisitCount.Valid {
228+ iVisits = matches[i].HistoryEntry.VisitCount.Int64
229+ }
230+ if matches[j].HistoryEntry.VisitCount.Valid {
231+ jVisits = matches[j].HistoryEntry.VisitCount.Int64
232+ }
233+ if jVisits > iVisits {
234+ matches[i], matches[j] = matches[j], matches[i]
235+ }
236+ }
237+ }
238+
239+ // Limit results
240+ if len(matches) > limit {
241+ matches = matches[:limit]
242+ }
243+
244+ return matches, nil
245+}
246+
247+// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
248+func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
249+ recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
250+ if err != nil {
251+ return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
252+ }
253+
254+ if filterQuery == "" {
255+ // No filter, convert to FuzzyMatch format
256+ matches := make([]FuzzyMatch, 0, len(recentHistory))
257+ for _, entry := range recentHistory {
258+ match := FuzzyMatch{
259+ HistoryEntry: entry,
260+ Score: 1.0, // Recent entries get full score
261+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
262+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
263+ MatchedField: "recent",
264+ }
265+ matches = append(matches, match)
266+ }
267+
268+ if len(matches) > limit {
269+ matches = matches[:limit]
270+ }
271+
272+ return matches, nil
273+ }
274+
275+ // Apply fuzzy filter
276+ matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
277+ if len(matches) > limit {
278+ matches = matches[:limit]
279+ }
280+
281+ return matches, nil
282+}
283+
284+// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
285+func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
286+ combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
287+
288+ // Add exact matches first (they get priority)
289+ combined = append(combined, exactMatches...)
290+
291+ // Add fuzzy matches
292+ combined = append(combined, fuzzyMatches...)
293+
294+ // Re-rank all matches
295+ return hs.fuzzyMatcher.RankMatches(combined, query)
296+}
297+
298+// extractDomain extracts domain from URL for domain-based searches.
299+func (hs *HistorySearcher) extractDomain(url string) string {
300+ // Simple domain extraction
301+ domain := url
302+ if strings.HasPrefix(domain, "http://") {
303+ domain = domain[7:]
304+ } else if strings.HasPrefix(domain, "https://") {
305+ domain = domain[8:]
306+ }
307+
308+ // Remove path
309+ if idx := strings.Index(domain, "/"); idx != -1 {
310+ domain = domain[:idx]
311+ }
312+
313+ // Remove port
314+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
315+ domain = domain[:idx]
316+ }
317+
318+ return domain
319+}
320+
321+// HistorySearchResult represents the result of a history search operation.
322+type HistorySearchResult struct {
323+ Query string `json:"query"`
324+ TotalEntries int `json:"total_entries"`
325+ MatchCount int `json:"match_count"`
326+ ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"`
327+ FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
328+ RankedMatches []FuzzyMatch `json:"ranked_matches"`
329+ SearchTime time.Duration `json:"search_time"`
330+}
331+
332+// SearchConfig holds configuration for history search operations.
333+type SearchConfig struct {
334+ Limit int `json:"limit"`
335+ MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
336+ IncludeExactMatches bool `json:"include_exact_matches"`
337+ IncludeFuzzyMatches bool `json:"include_fuzzy_matches"`
338+ SortByRelevance bool `json:"sort_by_relevance"`
339+}
340+
341+// SearchOption is a functional option for configuring search operations.
342+type SearchOption func(*SearchConfig)
343+
344+// WithLimit sets the maximum number of results to return.
345+func WithLimit(limit int) SearchOption {
346+ return func(config *SearchConfig) {
347+ config.Limit = limit
348+ }
349+}
350+
351+// WithSimilarityThreshold sets the minimum similarity threshold for matches.
352+func WithSimilarityThreshold(threshold float64) SearchOption {
353+ return func(config *SearchConfig) {
354+ config.MinSimilarityThreshold = threshold
355+ }
356+}
357+
358+// WithExactMatches enables/disables exact match inclusion.
359+func WithExactMatches(include bool) SearchOption {
360+ return func(config *SearchConfig) {
361+ config.IncludeExactMatches = include
362+ }
363+}
364+
365+// WithFuzzyMatches enables/disables fuzzy match inclusion.
366+func WithFuzzyMatches(include bool) SearchOption {
367+ return func(config *SearchConfig) {
368+ config.IncludeFuzzyMatches = include
369+ }
370+}
371+
372+// WithRelevanceSort enables/disables relevance-based sorting.
373+func WithRelevanceSort(enable bool) SearchOption {
374+ return func(config *SearchConfig) {
375+ config.SortByRelevance = enable
376+ }
377+}
378+
379+// GetBestMatch returns the single best match for a query.
380+func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
381+ result, err := hs.SearchWithFuzzy(query, WithLimit(1))
382+ if err != nil {
383+ return nil, err
384+ }
385+
386+ if len(result.RankedMatches) == 0 {
387+ return nil, nil
388+ }
389+
390+ return &result.RankedMatches[0], nil
391+}
392+
393+// GetMatchesByScore returns matches above a specific score threshold.
394+func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
395+ result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
396+ if err != nil {
397+ return nil, err
398+ }
399+
400+ return result.RankedMatches, nil
401+}
402+
403+// UpdateFuzzyConfig updates the fuzzy matching configuration.
404+func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
405+ hs.fuzzyConfig = config
406+ hs.fuzzyMatcher = NewFuzzyMatcher(config)
407+}
A internal/parser/parser.go +355 -0
@@ -0,0 +1,355 @@
1+package parser
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/config"
9+)
10+
11+// ParseInput parses user input and returns a ParseResult with URL and metadata.
12+//
13+// Learning Behavior: When users search with shortcuts like "g: viper cli" and visit
14+// results, those URLs are recorded in history. Future searches for "viper cli" will
15+// find the GitHub page in history via fuzzy matching, creating intelligent learning.
16+func (p *Parser) ParseInput(input string) (*ParseResult, error) {
17+ startTime := time.Now()
18+
19+ // Sanitize input
20+ validator := NewURLValidator()
21+ cleanInput := validator.SanitizeInput(input)
22+
23+ if cleanInput == "" {
24+ return &ParseResult{
25+ Type: InputTypeFallbackSearch,
26+ URL: p.buildSearchURL("", cleanInput),
27+ Query: input,
28+ Confidence: 0.0,
29+ ProcessingTime: time.Since(startTime),
30+ }, nil
31+ }
32+
33+ // Determine input type
34+ inputType := validator.GetURLType(cleanInput)
35+
36+ switch inputType {
37+ case InputTypeDirectURL:
38+ return p.parseDirectURL(cleanInput, input, startTime)
39+
40+ case InputTypeSearchShortcut:
41+ return p.parseSearchShortcut(cleanInput, input, startTime)
42+
43+ case InputTypeHistorySearch:
44+ return p.parseHistorySearch(cleanInput, input, startTime)
45+
46+ default:
47+ return p.parseFallbackSearch(cleanInput, input, startTime)
48+ }
49+}
50+
51+// parseDirectURL handles direct URL inputs.
52+func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
53+ validator := NewURLValidator()
54+ normalizedURL := validator.NormalizeURL(cleanInput)
55+
56+ return &ParseResult{
57+ Type: InputTypeDirectURL,
58+ URL: normalizedURL,
59+ Query: originalInput,
60+ Confidence: 1.0, // High confidence for direct URLs
61+ ProcessingTime: time.Since(startTime),
62+ }, nil
63+}
64+
65+// parseSearchShortcut handles search shortcut inputs.
66+func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
67+ validator := NewURLValidator()
68+ isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
69+
70+ if !isShortcut {
71+ return nil, fmt.Errorf("input is not a valid search shortcut")
72+ }
73+
74+ // Look up the shortcut in configuration
75+ shortcuts := p.config.SearchShortcuts
76+ shortcutConfig, exists := shortcuts[shortcutKey]
77+ if !exists {
78+ // Unknown shortcut, treat as search query
79+ return p.parseHistorySearch(cleanInput, originalInput, startTime)
80+ }
81+
82+ // Build URL from shortcut template
83+ finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
84+ if err != nil {
85+ return nil, fmt.Errorf("failed to process shortcut: %w", err)
86+ }
87+
88+ return &ParseResult{
89+ Type: InputTypeSearchShortcut,
90+ URL: finalURL,
91+ Query: originalInput,
92+ Confidence: 0.95, // High confidence for known shortcuts
93+ Shortcut: &DetectedShortcut{
94+ Key: shortcutKey,
95+ Query: query,
96+ URL: finalURL,
97+ Description: shortcutConfig.Description,
98+ },
99+ ProcessingTime: time.Since(startTime),
100+ }, nil
101+}
102+
103+// parseHistorySearch handles history search with fuzzy finding.
104+func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
105+ // Get history entries for fuzzy search
106+ history, err := p.historyProvider.GetAllHistory()
107+ if err != nil {
108+ // If history search fails, fall back to web search
109+ return p.parseFallbackSearch(cleanInput, originalInput, startTime)
110+ }
111+
112+ // Perform fuzzy search
113+ fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
114+ matches := fuzzyMatcher.SearchHistory(cleanInput, history)
115+
116+ // Check if we have good matches
117+ if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
118+ // Use the best match
119+ bestMatch := matches[0]
120+
121+ return &ParseResult{
122+ Type: InputTypeHistorySearch,
123+ URL: bestMatch.HistoryEntry.Url,
124+ Query: originalInput,
125+ Confidence: bestMatch.Score,
126+ FuzzyMatches: matches,
127+ ProcessingTime: time.Since(startTime),
128+ }, nil
129+ }
130+
131+ // No good history matches, fall back to search
132+ fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
133+ if err != nil {
134+ return nil, err
135+ }
136+
137+ // Include the fuzzy matches even though we're falling back
138+ fallbackResult.FuzzyMatches = matches
139+
140+ return fallbackResult, nil
141+}
142+
143+// parseFallbackSearch handles fallback web search.
144+func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
145+ // Use default search shortcut (usually "g" for Google)
146+ searchURL := p.buildSearchURL("", cleanInput)
147+
148+ return &ParseResult{
149+ Type: InputTypeFallbackSearch,
150+ URL: searchURL,
151+ Query: originalInput,
152+ Confidence: 0.1, // Low confidence for fallback
153+ ProcessingTime: time.Since(startTime),
154+ }, nil
155+}
156+
157+// processShortcut processes a search shortcut and builds the final URL.
158+func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
159+ urlTemplate := shortcut.URL
160+
161+ // Replace placeholder with query
162+ finalURL := strings.ReplaceAll(urlTemplate, "{query}", query)
163+
164+ // Validate the resulting URL
165+ validator := NewURLValidator()
166+ if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
167+ return "", fmt.Errorf("invalid URL generated from shortcut template: %s", finalURL)
168+ }
169+
170+ return finalURL, nil
171+}
172+
173+// buildSearchURL builds a search URL using the default search engine.
174+func (p *Parser) buildSearchURL(searchEngine, query string) string {
175+ // If no specific search engine, use Google as default
176+ if searchEngine == "" {
177+ defaultShortcut, exists := p.config.SearchShortcuts["g"]
178+ if exists {
179+ url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query)
180+ return url
181+ }
182+ // Fallback to Google if no "g" shortcut configured
183+ return fmt.Sprintf("https://www.google.com/search?q=%s", query)
184+ }
185+
186+ // Use specified search engine
187+ shortcut, exists := p.config.SearchShortcuts[searchEngine]
188+ if !exists {
189+ return fmt.Sprintf("https://www.google.com/search?q=%s", query)
190+ }
191+
192+ return strings.ReplaceAll(shortcut.URL, "{query}", query)
193+}
194+
195+// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
196+func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
197+ if query == "" {
198+ return nil, nil
199+ }
200+
201+ // Get all history entries
202+ history, err := p.historyProvider.GetAllHistory()
203+ if err != nil {
204+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
205+ }
206+
207+ // Create fuzzy config with custom threshold if provided
208+ fuzzyConfig := *p.fuzzyConfig
209+ if threshold > 0 && threshold <= 1.0 {
210+ fuzzyConfig.MinSimilarityThreshold = threshold
211+ }
212+
213+ // Perform fuzzy search
214+ fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
215+ matches := fuzzyMatcher.SearchHistory(query, history)
216+
217+ // Apply additional ranking
218+ matches = fuzzyMatcher.RankMatches(matches, query)
219+
220+ return matches, nil
221+}
222+
223+// CalculateSimilarity calculates similarity between two strings.
224+func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
225+ fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
226+ return fuzzyMatcher.calculateSimilarity(s1, s2)
227+}
228+
229+// RankMatches re-ranks fuzzy matches based on additional criteria.
230+func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
231+ fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
232+ return fuzzyMatcher.RankMatches(matches, query)
233+}
234+
235+// IsValidURL checks if the input represents a valid URL.
236+func (p *Parser) IsValidURL(input string) bool {
237+ validator := NewURLValidator()
238+ return validator.IsValidURL(input)
239+}
240+
241+// ProcessShortcut processes a shortcut and returns the resulting URL.
242+func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
243+ shortcutConfig, exists := shortcuts[shortcut]
244+ if !exists {
245+ return "", fmt.Errorf("shortcut '%s' not found", shortcut)
246+ }
247+
248+ return p.processShortcut(shortcut, query, shortcutConfig)
249+}
250+
251+// GetSupportedShortcuts returns a list of configured search shortcuts.
252+func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
253+ return p.config.SearchShortcuts
254+}
255+
256+// UpdateFuzzyConfig updates the fuzzy matching configuration.
257+func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
258+ if !newConfig.IsValid() {
259+ return fmt.Errorf("invalid fuzzy configuration")
260+ }
261+
262+ p.fuzzyConfig = newConfig
263+ return nil
264+}
265+
266+// GetFuzzyConfig returns the current fuzzy matching configuration.
267+func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
268+ return p.fuzzyConfig
269+}
270+
271+// PreviewParse provides a preview of what ParseInput would return without side effects.
272+func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
273+ // This is essentially the same as ParseInput but could be extended
274+ // to avoid any side effects if needed in the future
275+ return p.ParseInput(input)
276+}
277+
278+// SuggestCompletions provides URL/search completions based on input.
279+func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
280+ if input == "" || limit <= 0 {
281+ return nil, nil
282+ }
283+
284+ // Get fuzzy matches from history
285+ matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
286+ if err != nil {
287+ return nil, err
288+ }
289+
290+ completions := make([]string, 0, limit)
291+ seen := make(map[string]bool)
292+
293+ // Add URLs from fuzzy matches
294+ for _, match := range matches {
295+ if len(completions) >= limit {
296+ break
297+ }
298+
299+ url := match.HistoryEntry.Url
300+ if !seen[url] {
301+ completions = append(completions, url)
302+ seen[url] = true
303+ }
304+ }
305+
306+ // Add search shortcuts if input looks like it might be a shortcut
307+ if strings.Contains(input, ":") || len(input) <= 3 {
308+ for shortcut, config := range p.config.SearchShortcuts {
309+ if len(completions) >= limit {
310+ break
311+ }
312+
313+ if strings.HasPrefix(shortcut, strings.ToLower(input)) {
314+ suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
315+ if !seen[suggestion] {
316+ completions = append(completions, suggestion)
317+ seen[suggestion] = true
318+ }
319+ }
320+ }
321+ }
322+
323+ return completions, nil
324+}
325+
326+// ValidateConfig validates the parser configuration.
327+func (p *Parser) ValidateConfig() error {
328+ if p.config == nil {
329+ return fmt.Errorf("parser config is nil")
330+ }
331+
332+ if p.fuzzyConfig == nil {
333+ return fmt.Errorf("fuzzy config is nil")
334+ }
335+
336+ if !p.fuzzyConfig.IsValid() {
337+ return fmt.Errorf("invalid fuzzy config")
338+ }
339+
340+ if p.historyProvider == nil {
341+ return fmt.Errorf("history provider is nil")
342+ }
343+
344+ // Validate search shortcuts
345+ if len(p.config.SearchShortcuts) == 0 {
346+ return fmt.Errorf("no search shortcuts configured")
347+ }
348+
349+ // Check for default search shortcut
350+ if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {
351+ // Warning: no default search configured
352+ }
353+
354+ return nil
355+}
A internal/parser/parser_test.go +611 -0
@@ -0,0 +1,611 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/db"
10+)
11+
12+// MockHistoryProvider implements HistoryProvider for testing.
13+type MockHistoryProvider struct {
14+ history []*db.History
15+}
16+
17+func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
18+ if limit >= len(m.history) {
19+ return m.history, nil
20+ }
21+ return m.history[:limit], nil
22+}
23+
24+func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) {
25+ return m.history, nil
26+}
27+
28+func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
29+ // Simple mock search - just return entries that contain the query
30+ result := make([]*db.History, 0)
31+ for _, entry := range m.history {
32+ if len(result) >= limit {
33+ break
34+ }
35+ // Simple contains check
36+ result = append(result, entry)
37+ }
38+ return result, nil
39+}
40+
41+func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
42+ for _, entry := range m.history {
43+ if entry.Url == url {
44+ return entry, nil
45+ }
46+ }
47+ return nil, sql.ErrNoRows
48+}
49+
50+// createMockHistory creates mock history data for testing.
51+func createMockHistory() []*db.History {
52+ now := time.Now()
53+ return []*db.History{
54+ {
55+ ID: 1,
56+ Url: "https://github.com",
57+ Title: sql.NullString{String: "GitHub", Valid: true},
58+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
59+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
60+ CreatedAt: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
61+ },
62+ {
63+ ID: 2,
64+ Url: "https://stackoverflow.com",
65+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
66+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
67+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
68+ CreatedAt: sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true},
69+ },
70+ {
71+ ID: 3,
72+ Url: "https://reddit.com",
73+ Title: sql.NullString{String: "Reddit", Valid: true},
74+ VisitCount: sql.NullInt64{Int64: 50, Valid: true},
75+ LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true},
76+ CreatedAt: sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true},
77+ },
78+ {
79+ ID: 4,
80+ Url: "https://golang.org",
81+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
82+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
83+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
84+ CreatedAt: sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true},
85+ },
86+ }
87+}
88+
89+// createTestConfig creates a test configuration.
90+func createTestConfig() *config.Config {
91+ return &config.Config{
92+ SearchShortcuts: map[string]config.SearchShortcut{
93+ "g": {
94+ URL: "https://www.google.com/search?q={query}",
95+ Description: "Google Search",
96+ },
97+ "gh": {
98+ URL: "https://github.com/search?q={query}",
99+ Description: "GitHub Search",
100+ },
101+ "so": {
102+ URL: "https://stackoverflow.com/search?q={query}",
103+ Description: "Stack Overflow Search",
104+ },
105+ },
106+ }
107+}
108+
109+func TestParser_ParseInput_DirectURL(t *testing.T) {
110+ mockHistory := createMockHistory()
111+ provider := &MockHistoryProvider{history: mockHistory}
112+ config := createTestConfig()
113+ parser := NewParser(config, provider)
114+
115+ tests := []struct {
116+ name string
117+ input string
118+ expectType InputType
119+ expectURL string
120+ expectConf float64
121+ }{
122+ {
123+ name: "HTTPS URL",
124+ input: "https://example.com",
125+ expectType: InputTypeDirectURL,
126+ expectURL: "https://example.com",
127+ expectConf: 1.0,
128+ },
129+ {
130+ name: "HTTP URL",
131+ input: "http://example.com",
132+ expectType: InputTypeDirectURL,
133+ expectURL: "http://example.com",
134+ expectConf: 1.0,
135+ },
136+ {
137+ name: "Domain only",
138+ input: "example.com",
139+ expectType: InputTypeDirectURL,
140+ expectURL: "https://example.com",
141+ expectConf: 1.0,
142+ },
143+ {
144+ name: "Subdomain",
145+ input: "www.example.com",
146+ expectType: InputTypeDirectURL,
147+ expectURL: "https://www.example.com",
148+ expectConf: 1.0,
149+ },
150+ {
151+ name: "Domain with path",
152+ input: "example.com/path",
153+ expectType: InputTypeDirectURL,
154+ expectURL: "https://example.com/path",
155+ expectConf: 1.0,
156+ },
157+ }
158+
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ result, err := parser.ParseInput(tt.input)
162+ if err != nil {
163+ t.Fatalf("ParseInput() error = %v", err)
164+ }
165+
166+ if result.Type != tt.expectType {
167+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
168+ }
169+
170+ if result.URL != tt.expectURL {
171+ t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
172+ }
173+
174+ if result.Confidence != tt.expectConf {
175+ t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf)
176+ }
177+ })
178+ }
179+}
180+
181+func TestParser_ParseInput_SearchShortcut(t *testing.T) {
182+ mockHistory := createMockHistory()
183+ provider := &MockHistoryProvider{history: mockHistory}
184+ config := createTestConfig()
185+ parser := NewParser(config, provider)
186+
187+ tests := []struct {
188+ name string
189+ input string
190+ expectType InputType
191+ expectURL string
192+ expectKey string
193+ expectQuery string
194+ }{
195+ {
196+ name: "Google search",
197+ input: "g: golang tutorial",
198+ expectType: InputTypeSearchShortcut,
199+ expectURL: "https://www.google.com/search?q=golang tutorial",
200+ expectKey: "g",
201+ expectQuery: "golang tutorial",
202+ },
203+ {
204+ name: "GitHub search",
205+ input: "gh: cobra cli",
206+ expectType: InputTypeSearchShortcut,
207+ expectURL: "https://github.com/search?q=cobra cli",
208+ expectKey: "gh",
209+ expectQuery: "cobra cli",
210+ },
211+ {
212+ name: "Stack Overflow search",
213+ input: "so: how to fuzzy search",
214+ expectType: InputTypeSearchShortcut,
215+ expectURL: "https://stackoverflow.com/search?q=how to fuzzy search",
216+ expectKey: "so",
217+ expectQuery: "how to fuzzy search",
218+ },
219+ }
220+
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ result, err := parser.ParseInput(tt.input)
224+ if err != nil {
225+ t.Fatalf("ParseInput() error = %v", err)
226+ }
227+
228+ if result.Type != tt.expectType {
229+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
230+ }
231+
232+ if result.URL != tt.expectURL {
233+ t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
234+ }
235+
236+ if result.Shortcut == nil {
237+ t.Fatalf("ParseInput() shortcut is nil")
238+ }
239+
240+ if result.Shortcut.Key != tt.expectKey {
241+ t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey)
242+ }
243+
244+ if result.Shortcut.Query != tt.expectQuery {
245+ t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery)
246+ }
247+ })
248+ }
249+}
250+
251+func TestParser_ParseInput_HistorySearch(t *testing.T) {
252+ mockHistory := createMockHistory()
253+ provider := &MockHistoryProvider{history: mockHistory}
254+ config := createTestConfig()
255+ parser := NewParser(config, provider)
256+
257+ tests := []struct {
258+ name string
259+ input string
260+ expectType InputType
261+ expectMatches bool
262+ minConfidence float64
263+ }{
264+ {
265+ name: "Exact domain match",
266+ input: "github",
267+ expectType: InputTypeHistorySearch,
268+ expectMatches: true,
269+ minConfidence: 0.5,
270+ },
271+ {
272+ name: "Partial title match",
273+ input: "stack",
274+ expectType: InputTypeHistorySearch,
275+ expectMatches: true,
276+ minConfidence: 0.3,
277+ },
278+ {
279+ name: "Programming language search",
280+ input: "golang",
281+ expectType: InputTypeHistorySearch,
282+ expectMatches: true,
283+ minConfidence: 0.3,
284+ },
285+ }
286+
287+ for _, tt := range tests {
288+ t.Run(tt.name, func(t *testing.T) {
289+ result, err := parser.ParseInput(tt.input)
290+ if err != nil {
291+ t.Fatalf("ParseInput() error = %v", err)
292+ }
293+
294+ if result.Type != tt.expectType {
295+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
296+ }
297+
298+ if tt.expectMatches && len(result.FuzzyMatches) == 0 {
299+ t.Errorf("ParseInput() expected fuzzy matches but got none")
300+ }
301+
302+ if result.Confidence < tt.minConfidence {
303+ t.Errorf("ParseInput() confidence = %v, want >= %v", result.Confidence, tt.minConfidence)
304+ }
305+ })
306+ }
307+}
308+
309+func TestParser_FuzzySearchHistory(t *testing.T) {
310+ mockHistory := createMockHistory()
311+ provider := &MockHistoryProvider{history: mockHistory}
312+ config := createTestConfig()
313+ parser := NewParser(config, provider)
314+
315+ tests := []struct {
316+ name string
317+ query string
318+ threshold float64
319+ expectMatches int
320+ expectFirst string // Expected first match URL
321+ }{
322+ {
323+ name: "GitHub search",
324+ query: "github",
325+ threshold: 0.3,
326+ expectMatches: 1,
327+ expectFirst: "https://github.com",
328+ },
329+ {
330+ name: "Programming search",
331+ query: "programming",
332+ threshold: 0.2,
333+ expectMatches: 1,
334+ expectFirst: "https://golang.org",
335+ },
336+ {
337+ name: "Stack search",
338+ query: "stack",
339+ threshold: 0.3,
340+ expectMatches: 1,
341+ expectFirst: "https://stackoverflow.com",
342+ },
343+ }
344+
345+ for _, tt := range tests {
346+ t.Run(tt.name, func(t *testing.T) {
347+ matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold)
348+ if err != nil {
349+ t.Fatalf("FuzzySearchHistory() error = %v", err)
350+ }
351+
352+ if len(matches) < tt.expectMatches {
353+ t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches)
354+ }
355+
356+ if len(matches) > 0 && tt.expectFirst != "" {
357+ if matches[0].HistoryEntry.Url != tt.expectFirst {
358+ t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst)
359+ }
360+ }
361+ })
362+ }
363+}
364+
365+func TestParser_SuggestCompletions(t *testing.T) {
366+ mockHistory := createMockHistory()
367+ provider := &MockHistoryProvider{history: mockHistory}
368+ config := createTestConfig()
369+ parser := NewParser(config, provider)
370+
371+ tests := []struct {
372+ name string
373+ input string
374+ limit int
375+ expectCount int
376+ }{
377+ {
378+ name: "GitHub completion",
379+ input: "gith",
380+ limit: 5,
381+ expectCount: 1,
382+ },
383+ {
384+ name: "Shortcut completion",
385+ input: "g",
386+ limit: 5,
387+ expectCount: 2, // "g" and "gh" shortcuts
388+ },
389+ {
390+ name: "Empty input",
391+ input: "",
392+ limit: 5,
393+ expectCount: 0,
394+ },
395+ }
396+
397+ for _, tt := range tests {
398+ t.Run(tt.name, func(t *testing.T) {
399+ completions, err := parser.SuggestCompletions(tt.input, tt.limit)
400+ if err != nil {
401+ t.Fatalf("SuggestCompletions() error = %v", err)
402+ }
403+
404+ if len(completions) != tt.expectCount {
405+ t.Errorf("SuggestCompletions() count = %d, want %d", len(completions), tt.expectCount)
406+ }
407+ })
408+ }
409+}
410+
411+func TestParser_ProcessShortcut(t *testing.T) {
412+ config := createTestConfig()
413+ mockHistory := createMockHistory()
414+ provider := &MockHistoryProvider{history: mockHistory}
415+ parser := NewParser(config, provider)
416+
417+ tests := []struct {
418+ name string
419+ shortcut string
420+ query string
421+ expectURL string
422+ expectErr bool
423+ }{
424+ {
425+ name: "Google shortcut",
426+ shortcut: "g",
427+ query: "test query",
428+ expectURL: "https://www.google.com/search?q=test query",
429+ expectErr: false,
430+ },
431+ {
432+ name: "GitHub shortcut",
433+ shortcut: "gh",
434+ query: "golang",
435+ expectURL: "https://github.com/search?q=golang",
436+ expectErr: false,
437+ },
438+ {
439+ name: "Unknown shortcut",
440+ shortcut: "unknown",
441+ query: "test",
442+ expectURL: "",
443+ expectErr: true,
444+ },
445+ }
446+
447+ for _, tt := range tests {
448+ t.Run(tt.name, func(t *testing.T) {
449+ url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts)
450+
451+ if tt.expectErr {
452+ if err == nil {
453+ t.Errorf("ProcessShortcut() expected error but got none")
454+ }
455+ return
456+ }
457+
458+ if err != nil {
459+ t.Fatalf("ProcessShortcut() error = %v", err)
460+ }
461+
462+ if url != tt.expectURL {
463+ t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL)
464+ }
465+ })
466+ }
467+}
468+
469+func TestParser_ValidateConfig(t *testing.T) {
470+ mockHistory := createMockHistory()
471+ provider := &MockHistoryProvider{history: mockHistory}
472+
473+ tests := []struct {
474+ name string
475+ config *config.Config
476+ fuzzy *FuzzyConfig
477+ provider HistoryProvider
478+ expectErr bool
479+ }{
480+ {
481+ name: "Valid configuration",
482+ config: createTestConfig(),
483+ fuzzy: DefaultFuzzyConfig(),
484+ provider: provider,
485+ expectErr: false,
486+ },
487+ {
488+ name: "Nil config",
489+ config: nil,
490+ fuzzy: DefaultFuzzyConfig(),
491+ provider: provider,
492+ expectErr: true,
493+ },
494+ {
495+ name: "Nil fuzzy config",
496+ config: createTestConfig(),
497+ fuzzy: nil,
498+ provider: provider,
499+ expectErr: true,
500+ },
501+ {
502+ name: "Nil history provider",
503+ config: createTestConfig(),
504+ fuzzy: DefaultFuzzyConfig(),
505+ provider: nil,
506+ expectErr: true,
507+ },
508+ }
509+
510+ for _, tt := range tests {
511+ t.Run(tt.name, func(t *testing.T) {
512+ parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy))
513+
514+ err := parser.ValidateConfig()
515+
516+ if tt.expectErr {
517+ if err == nil {
518+ t.Errorf("ValidateConfig() expected error but got none")
519+ }
520+ } else {
521+ if err != nil {
522+ t.Errorf("ValidateConfig() unexpected error = %v", err)
523+ }
524+ }
525+ })
526+ }
527+}
528+
529+func TestFuzzyConfig_IsValid(t *testing.T) {
530+ tests := []struct {
531+ name string
532+ config *FuzzyConfig
533+ expect bool
534+ }{
535+ {
536+ name: "Default config",
537+ config: DefaultFuzzyConfig(),
538+ expect: true,
539+ },
540+ {
541+ name: "Invalid threshold (negative)",
542+ config: &FuzzyConfig{
543+ MinSimilarityThreshold: -0.1,
544+ MaxResults: 10,
545+ URLWeight: 0.4,
546+ TitleWeight: 0.3,
547+ RecencyWeight: 0.2,
548+ VisitWeight: 0.1,
549+ RecencyDecayDays: 30,
550+ },
551+ expect: false,
552+ },
553+ {
554+ name: "Invalid threshold (>1.0)",
555+ config: &FuzzyConfig{
556+ MinSimilarityThreshold: 1.1,
557+ MaxResults: 10,
558+ URLWeight: 0.4,
559+ TitleWeight: 0.3,
560+ RecencyWeight: 0.2,
561+ VisitWeight: 0.1,
562+ RecencyDecayDays: 30,
563+ },
564+ expect: false,
565+ },
566+ {
567+ name: "Invalid MaxResults",
568+ config: &FuzzyConfig{
569+ MinSimilarityThreshold: 0.3,
570+ MaxResults: 0,
571+ URLWeight: 0.4,
572+ TitleWeight: 0.3,
573+ RecencyWeight: 0.2,
574+ VisitWeight: 0.1,
575+ RecencyDecayDays: 30,
576+ },
577+ expect: false,
578+ },
579+ }
580+
581+ for _, tt := range tests {
582+ t.Run(tt.name, func(t *testing.T) {
583+ result := tt.config.IsValid()
584+ if result != tt.expect {
585+ t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect)
586+ }
587+ })
588+ }
589+}
590+
591+func TestInputType_String(t *testing.T) {
592+ tests := []struct {
593+ inputType InputType
594+ expected string
595+ }{
596+ {InputTypeDirectURL, "direct_url"},
597+ {InputTypeSearchShortcut, "search_shortcut"},
598+ {InputTypeHistorySearch, "history_search"},
599+ {InputTypeFallbackSearch, "fallback_search"},
600+ {InputType(99), "unknown"}, // Unknown type
601+ }
602+
603+ for _, tt := range tests {
604+ t.Run(tt.expected, func(t *testing.T) {
605+ result := tt.inputType.String()
606+ if result != tt.expected {
607+ t.Errorf("InputType.String() = %v, want %v", result, tt.expected)
608+ }
609+ })
610+ }
611+}
A internal/parser/types.go +171 -0
@@ -0,0 +1,171 @@
1+// Package parser provides URL parsing and fuzzy finding functionality for dumber.
2+package parser
3+
4+import (
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/db"
8+ "github.com/bnema/dumber/internal/config"
9+)
10+
11+// InputType represents the type of user input detected.
12+type InputType int
13+
14+const (
15+ // InputTypeDirectURL represents a direct URL input (e.g., "reddit.com", "https://example.com")
16+ InputTypeDirectURL InputType = iota
17+ // InputTypeSearchShortcut represents a search shortcut (e.g., "g: golang tutorial")
18+ InputTypeSearchShortcut
19+ // InputTypeHistorySearch represents a history search query
20+ InputTypeHistorySearch
21+ // InputTypeFallbackSearch represents a fallback web search
22+ InputTypeFallbackSearch
23+)
24+
25+// ParseResult represents the result of parsing user input.
26+type ParseResult struct {
27+ // Type indicates the detected input type
28+ Type InputType `json:"type"`
29+ // URL is the final URL to navigate to
30+ URL string `json:"url"`
31+ // Query is the original user query
32+ Query string `json:"query"`
33+ // Confidence is the confidence score of the parsing (0.0-1.0)
34+ Confidence float64 `json:"confidence"`
35+ // FuzzyMatches contains fuzzy search results if applicable
36+ FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
37+ // Shortcut contains the detected shortcut information
38+ Shortcut *DetectedShortcut `json:"shortcut,omitempty"`
39+ // ProcessingTime is the time taken to process the input
40+ ProcessingTime time.Duration `json:"processing_time"`
41+}
42+
43+// DetectedShortcut represents a detected search shortcut.
44+type DetectedShortcut struct {
45+ // Key is the shortcut key (e.g., "g", "gh")
46+ Key string `json:"key"`
47+ // Query is the search query part
48+ Query string `json:"query"`
49+ // URL is the constructed URL
50+ URL string `json:"url"`
51+ // Description is the shortcut description
52+ Description string `json:"description"`
53+}
54+
55+// FuzzyMatch represents a fuzzy search match from history.
56+type FuzzyMatch struct {
57+ // HistoryEntry is the matched history entry
58+ HistoryEntry *db.History `json:"history_entry"`
59+ // Score is the similarity score (0.0-1.0, higher is better)
60+ Score float64 `json:"score"`
61+ // URLScore is the URL similarity score
62+ URLScore float64 `json:"url_score"`
63+ // TitleScore is the title similarity score
64+ TitleScore float64 `json:"title_score"`
65+ // RecencyScore is the recency score based on last visit
66+ RecencyScore float64 `json:"recency_score"`
67+ // VisitScore is the score based on visit count
68+ VisitScore float64 `json:"visit_score"`
69+ // MatchedField indicates which field matched (url, title, or both)
70+ MatchedField string `json:"matched_field"`
71+}
72+
73+// HistoryProvider defines the interface for history data access.
74+type HistoryProvider interface {
75+ // GetRecentHistory returns recent history entries limited by count
76+ GetRecentHistory(limit int) ([]*db.History, error)
77+ // GetAllHistory returns all history entries for fuzzy search
78+ GetAllHistory() ([]*db.History, error)
79+ // SearchHistory performs a basic text search in history
80+ SearchHistory(query string, limit int) ([]*db.History, error)
81+ // GetHistoryByURL retrieves history entry by exact URL match
82+ GetHistoryByURL(url string) (*db.History, error)
83+}
84+
85+// FuzzyConfig holds configuration for fuzzy matching algorithms.
86+type FuzzyConfig struct {
87+ // MinSimilarityThreshold is the minimum similarity score to consider a match (0.0-1.0)
88+ MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
89+ // MaxResults is the maximum number of fuzzy results to return
90+ MaxResults int `json:"max_results"`
91+ // URLWeight is the weight for URL similarity in scoring
92+ URLWeight float64 `json:"url_weight"`
93+ // TitleWeight is the weight for title similarity in scoring
94+ TitleWeight float64 `json:"title_weight"`
95+ // RecencyWeight is the weight for recency in scoring
96+ RecencyWeight float64 `json:"recency_weight"`
97+ // VisitWeight is the weight for visit count in scoring
98+ VisitWeight float64 `json:"visit_weight"`
99+ // RecencyDecayDays is the number of days for recency score decay
100+ RecencyDecayDays int `json:"recency_decay_days"`
101+}
102+
103+// Parser handles URL parsing and fuzzy finding operations.
104+type Parser struct {
105+ config *config.Config
106+ fuzzyConfig *FuzzyConfig
107+ historyProvider HistoryProvider
108+}
109+
110+// ParserOption is a functional option for configuring the Parser.
111+type ParserOption func(*Parser)
112+
113+// NewParser creates a new Parser instance with the given configuration.
114+func NewParser(cfg *config.Config, historyProvider HistoryProvider, opts ...ParserOption) *Parser {
115+ p := &Parser{
116+ config: cfg,
117+ historyProvider: historyProvider,
118+ fuzzyConfig: DefaultFuzzyConfig(),
119+ }
120+
121+ for _, opt := range opts {
122+ opt(p)
123+ }
124+
125+ return p
126+}
127+
128+// WithFuzzyConfig sets custom fuzzy matching configuration.
129+func WithFuzzyConfig(fuzzyConfig *FuzzyConfig) ParserOption {
130+ return func(p *Parser) {
131+ p.fuzzyConfig = fuzzyConfig
132+ }
133+}
134+
135+// DefaultFuzzyConfig returns default fuzzy matching configuration.
136+func DefaultFuzzyConfig() *FuzzyConfig {
137+ return &FuzzyConfig{
138+ MinSimilarityThreshold: 0.3,
139+ MaxResults: 10,
140+ URLWeight: 0.4,
141+ TitleWeight: 0.3,
142+ RecencyWeight: 0.2,
143+ VisitWeight: 0.1,
144+ RecencyDecayDays: 30,
145+ }
146+}
147+
148+// String returns a string representation of InputType.
149+func (t InputType) String() string {
150+ switch t {
151+ case InputTypeDirectURL:
152+ return "direct_url"
153+ case InputTypeSearchShortcut:
154+ return "search_shortcut"
155+ case InputTypeHistorySearch:
156+ return "history_search"
157+ case InputTypeFallbackSearch:
158+ return "fallback_search"
159+ default:
160+ return "unknown"
161+ }
162+}
163+
164+// IsValid returns true if the FuzzyConfig has valid values.
165+func (fc *FuzzyConfig) IsValid() bool {
166+ return fc.MinSimilarityThreshold >= 0.0 && fc.MinSimilarityThreshold <= 1.0 &&
167+ fc.MaxResults > 0 &&
168+ fc.URLWeight >= 0.0 && fc.TitleWeight >= 0.0 &&
169+ fc.RecencyWeight >= 0.0 && fc.VisitWeight >= 0.0 &&
170+ fc.RecencyDecayDays > 0
171+}
A internal/parser/validation.go +407 -0
@@ -0,0 +1,407 @@
1+package parser
2+
3+import (
4+ "net"
5+ "net/url"
6+ "regexp"
7+ "strings"
8+)
9+
10+var (
11+ // domainRegex matches valid domain names
12+ domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
13+
14+ // ipRegex matches IPv4 addresses
15+ ipRegex = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`)
16+
17+ // urlSchemeRegex matches URL schemes
18+ urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
19+
20+ // shortcutRegex matches search shortcuts (e.g., "g:", "gh:")
21+ shortcutRegex = regexp.MustCompile(`^([a-zA-Z0-9]+):\s*(.*)$`)
22+
23+ // Common top-level domains for validation
24+ commonTLDs = map[string]bool{
25+ "com": true, "org": true, "net": true, "edu": true, "gov": true, "mil": true,
26+ "int": true, "co": true, "io": true, "ai": true, "ly": true, "me": true,
27+ "tv": true, "cc": true, "to": true, "app": true, "dev": true, "tech": true,
28+ "info": true, "biz": true, "name": true, "museum": true, "travel": true,
29+ "xyz": true, "fr": true, "de": true, "uk": true, "ca": true, "us": true,
30+ "au": true, "jp": true, "cn": true, "in": true, "ru": true, "br": true,
31+ }
32+)
33+
34+// URLValidator provides URL validation utilities.
35+type URLValidator struct{}
36+
37+// NewURLValidator creates a new URLValidator.
38+func NewURLValidator() *URLValidator {
39+ return &URLValidator{}
40+}
41+
42+// IsValidURL checks if the input represents a valid URL.
43+func (v *URLValidator) IsValidURL(input string) bool {
44+ if input == "" {
45+ return false
46+ }
47+
48+ // Check if it already has a scheme
49+ if urlSchemeRegex.MatchString(input) {
50+ return v.isWellFormedURL(input)
51+ }
52+
53+ // Check if it looks like a domain or IP
54+ return v.isDomain(input) || v.isIPAddress(input)
55+}
56+
57+// IsDirectURL checks if the input should be treated as a direct URL.
58+func (v *URLValidator) IsDirectURL(input string) bool {
59+ if input == "" {
60+ return false
61+ }
62+
63+ input = strings.TrimSpace(input)
64+
65+ // Has explicit scheme
66+ if urlSchemeRegex.MatchString(input) {
67+ return v.isWellFormedURL(input)
68+ }
69+
70+ // Looks like a domain
71+ if v.isDomain(input) {
72+ return true
73+ }
74+
75+ // Looks like an IP address
76+ if v.isIPAddress(input) {
77+ return true
78+ }
79+
80+ // Contains domain-like patterns
81+ if v.looksLikeDomain(input) {
82+ return true
83+ }
84+
85+ return false
86+}
87+
88+// IsSearchShortcut checks if the input matches a search shortcut pattern.
89+func (v *URLValidator) IsSearchShortcut(input string) (bool, string, string) {
90+ input = strings.TrimSpace(input)
91+
92+ // Don't match URLs with schemes (like https://example.com)
93+ if urlSchemeRegex.MatchString(input) {
94+ return false, "", ""
95+ }
96+
97+ matches := shortcutRegex.FindStringSubmatch(input)
98+ if len(matches) != 3 {
99+ return false, "", ""
100+ }
101+
102+ shortcutKey := strings.ToLower(matches[1])
103+ query := strings.TrimSpace(matches[2])
104+
105+ return true, shortcutKey, query
106+}
107+
108+// NormalizeURL normalizes a URL by adding scheme if missing.
109+func (v *URLValidator) NormalizeURL(input string) string {
110+ input = strings.TrimSpace(input)
111+
112+ if input == "" {
113+ return input
114+ }
115+
116+ // Already has a scheme
117+ if urlSchemeRegex.MatchString(input) {
118+ return input
119+ }
120+
121+ // Add https:// for domains and IPs
122+ if v.isDomain(input) || v.isIPAddress(input) || v.looksLikeDomain(input) {
123+ return "https://" + input
124+ }
125+
126+ return input
127+}
128+
129+// ExtractDomain extracts the domain from a URL.
130+func (v *URLValidator) ExtractDomain(rawURL string) string {
131+ // Parse the URL
132+ parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
133+ if err != nil {
134+ // Fallback: try to extract domain manually
135+ return v.extractDomainManual(rawURL)
136+ }
137+
138+ // Extract hostname (without port) from Host
139+ host := parsedURL.Host
140+ if host == "" {
141+ return v.extractDomainManual(rawURL)
142+ }
143+
144+ // Remove port from host if present
145+ hostname := host
146+ if idx := strings.LastIndex(host, ":"); idx != -1 {
147+ // Check if this looks like a port (numeric)
148+ portPart := host[idx+1:]
149+ if isNumeric(portPart) {
150+ hostname = host[:idx]
151+ }
152+ }
153+
154+ // Handle IPv6 addresses in brackets
155+ if strings.HasPrefix(hostname, "[") && strings.HasSuffix(hostname, "]") {
156+ hostname = hostname[1 : len(hostname)-1]
157+ }
158+
159+ return hostname
160+}
161+
162+// extractDomainManual manually extracts domain when URL parsing fails.
163+func (v *URLValidator) extractDomainManual(rawURL string) string {
164+ // Handle empty or invalid input
165+ if rawURL == "" {
166+ return ""
167+ }
168+
169+ // Remove protocol
170+ domain := rawURL
171+ if strings.HasPrefix(domain, "http://") {
172+ domain = domain[7:]
173+ } else if strings.HasPrefix(domain, "https://") {
174+ domain = domain[8:]
175+ }
176+
177+ // Handle IPv6 addresses in brackets
178+ if strings.HasPrefix(domain, "[") {
179+ if idx := strings.Index(domain, "]"); idx != -1 {
180+ return domain[1:idx] // Return IPv6 address without brackets
181+ }
182+ }
183+
184+ // Remove path
185+ if idx := strings.Index(domain, "/"); idx != -1 {
186+ domain = domain[:idx]
187+ }
188+
189+ // Remove query and fragment
190+ if idx := strings.Index(domain, "?"); idx != -1 {
191+ domain = domain[:idx]
192+ }
193+ if idx := strings.Index(domain, "#"); idx != -1 {
194+ domain = domain[:idx]
195+ }
196+
197+ // Remove port - be careful with IPv6 addresses
198+ if !strings.Contains(domain, "::") { // Not IPv6
199+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
200+ // Check if this is actually a port (numeric)
201+ portPart := domain[idx+1:]
202+ if isNumeric(portPart) {
203+ domain = domain[:idx]
204+ }
205+ }
206+ }
207+
208+ return domain
209+}
210+
211+// isDomain checks if the input is a valid domain name.
212+func (v *URLValidator) isDomain(input string) bool {
213+ if !domainRegex.MatchString(input) {
214+ return false
215+ }
216+
217+ // Check for at least one dot (to distinguish from single words)
218+ if !strings.Contains(input, ".") {
219+ return false
220+ }
221+
222+ // Validate TLD
223+ parts := strings.Split(input, ".")
224+ if len(parts) < 2 {
225+ return false
226+ }
227+
228+ tld := strings.ToLower(parts[len(parts)-1])
229+
230+ // Check against common TLDs or validate length/format
231+ return commonTLDs[tld] || (len(tld) >= 2 && len(tld) <= 6)
232+}
233+
234+// isIPAddress checks if the input is a valid IP address.
235+func (v *URLValidator) isIPAddress(input string) bool {
236+ // Try parsing as IP
237+ ip := net.ParseIP(input)
238+ return ip != nil
239+}
240+
241+// looksLikeDomain checks if the input looks like a domain (more lenient).
242+func (v *URLValidator) looksLikeDomain(input string) bool {
243+ // Extract just the domain part if there's a path
244+ domainPart := input
245+ if slashIndex := strings.Index(input, "/"); slashIndex != -1 {
246+ domainPart = input[:slashIndex]
247+ }
248+
249+ // Must contain at least one dot
250+ if !strings.Contains(domainPart, ".") {
251+ return false
252+ }
253+
254+ // Must not contain spaces
255+ if strings.Contains(domainPart, " ") {
256+ return false
257+ }
258+
259+ // Must not be too long
260+ if len(domainPart) > 253 {
261+ return false
262+ }
263+
264+ // Check basic domain-like structure
265+ parts := strings.Split(domainPart, ".")
266+ if len(parts) < 2 {
267+ return false
268+ }
269+
270+ // Each part should be reasonable
271+ for _, part := range parts {
272+ if len(part) == 0 || len(part) > 63 {
273+ return false
274+ }
275+ // Should start and end with alphanumeric
276+ if len(part) > 0 {
277+ if !isAlphaNumeric(part[0]) || !isAlphaNumeric(part[len(part)-1]) {
278+ return false
279+ }
280+ }
281+ }
282+
283+ return true
284+}
285+
286+// isWellFormedURL checks if a URL with scheme is well-formed.
287+func (v *URLValidator) isWellFormedURL(rawURL string) bool {
288+ parsedURL, err := url.Parse(rawURL)
289+ if err != nil {
290+ return false
291+ }
292+
293+ // Must have a scheme and host
294+ if parsedURL.Scheme == "" || parsedURL.Host == "" {
295+ return false
296+ }
297+
298+ // Validate scheme
299+ validSchemes := map[string]bool{
300+ "http": true, "https": true, "ftp": true, "ftps": true,
301+ "file": true, "mailto": true, "tel": true, "sms": true,
302+ }
303+
304+ return validSchemes[strings.ToLower(parsedURL.Scheme)]
305+}
306+
307+// HasFileExtension checks if the URL path has a file extension.
308+func (v *URLValidator) HasFileExtension(rawURL string) bool {
309+ parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
310+ if err != nil {
311+ return false
312+ }
313+
314+ path := parsedURL.Path
315+ if path == "" || path == "/" {
316+ return false
317+ }
318+
319+ // Check for file extension
320+ lastSlash := strings.LastIndex(path, "/")
321+ fileName := path
322+ if lastSlash != -1 && lastSlash < len(path)-1 {
323+ fileName = path[lastSlash+1:]
324+ }
325+
326+ return strings.Contains(fileName, ".") && !strings.HasSuffix(fileName, ".")
327+}
328+
329+// IsLocalhost checks if the URL is pointing to localhost.
330+func (v *URLValidator) IsLocalhost(rawURL string) bool {
331+ // Handle direct localhost strings (no protocol)
332+ rawURL = strings.ToLower(strings.TrimSpace(rawURL))
333+ if rawURL == "localhost" || rawURL == "127.0.0.1" || rawURL == "::1" {
334+ return true
335+ }
336+
337+ domain := v.ExtractDomain(rawURL)
338+ domain = strings.ToLower(domain)
339+
340+ // Remove port if present
341+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
342+ portPart := domain[idx+1:]
343+ if isNumeric(portPart) {
344+ domain = domain[:idx]
345+ }
346+ }
347+
348+ return domain == "localhost" ||
349+ domain == "127.0.0.1" ||
350+ domain == "::1" ||
351+ strings.HasSuffix(domain, ".local")
352+}
353+
354+// SanitizeInput sanitizes user input for URL parsing.
355+func (v *URLValidator) SanitizeInput(input string) string {
356+ // Trim whitespace
357+ input = strings.TrimSpace(input)
358+
359+ // Remove control characters
360+ result := make([]rune, 0, len(input))
361+ for _, r := range input {
362+ if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and non-ASCII
363+ result = append(result, r)
364+ }
365+ }
366+
367+ return string(result)
368+}
369+
370+// Helper functions
371+
372+// isNumeric checks if a string contains only digits.
373+func isNumeric(s string) bool {
374+ if s == "" {
375+ return false
376+ }
377+ for _, r := range s {
378+ if r < '0' || r > '9' {
379+ return false
380+ }
381+ }
382+ return true
383+}
384+
385+// isAlphaNumeric checks if a character is alphanumeric.
386+func isAlphaNumeric(b byte) bool {
387+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
388+}
389+
390+// GetURLType determines the type of URL (direct, search, etc.).
391+func (v *URLValidator) GetURLType(input string) InputType {
392+ input = v.SanitizeInput(input)
393+
394+ // Check for direct URL first (this includes URLs with schemes)
395+ if v.IsDirectURL(input) {
396+ return InputTypeDirectURL
397+ }
398+
399+ // Check for search shortcut (but exclude URL schemes)
400+ if isShortcut, _, _ := v.IsSearchShortcut(input); isShortcut {
401+ return InputTypeSearchShortcut
402+ }
403+
404+ // Everything else is treated as search query initially
405+ // The parser will determine if it should be history search or web search
406+ return InputTypeHistorySearch
407+}
A internal/parser/validation_test.go +526 -0
@@ -0,0 +1,526 @@
1+package parser
2+
3+import (
4+ "testing"
5+)
6+
7+func TestURLValidator_IsValidURL(t *testing.T) {
8+ validator := NewURLValidator()
9+
10+ tests := []struct {
11+ name string
12+ input string
13+ expected bool
14+ }{
15+ // Valid URLs with scheme
16+ {"HTTPS URL", "https://example.com", true},
17+ {"HTTP URL", "http://example.com", true},
18+ {"URL with path", "https://example.com/path", true},
19+ {"URL with query", "https://example.com?q=test", true},
20+
21+ // Valid domains
22+ {"Simple domain", "example.com", true},
23+ {"Subdomain", "www.example.com", true},
24+ {"Multiple subdomains", "api.v1.example.com", true},
25+ {"Domain with path", "example.com/path", true},
26+
27+ // Valid IP addresses
28+ {"IPv4 address", "192.168.1.1", true},
29+ {"Localhost IP", "127.0.0.1", true},
30+
31+ // Invalid inputs
32+ {"Empty string", "", false},
33+ {"Just text", "hello world", false},
34+ {"Single word", "test", false},
35+ {"Space in domain", "exam ple.com", false},
36+ {"Invalid TLD", "example.toolongtobevalid", false},
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ result := validator.IsValidURL(tt.input)
42+ if result != tt.expected {
43+ t.Errorf("IsValidURL(%q) = %v, want %v", tt.input, result, tt.expected)
44+ }
45+ })
46+ }
47+}
48+
49+func TestURLValidator_IsDirectURL(t *testing.T) {
50+ validator := NewURLValidator()
51+
52+ tests := []struct {
53+ name string
54+ input string
55+ expected bool
56+ }{
57+ // Should be treated as direct URLs
58+ {"Complete HTTPS URL", "https://github.com", true},
59+ {"Complete HTTP URL", "http://example.com", true},
60+ {"Domain only", "github.com", true},
61+ {"Subdomain", "api.github.com", true},
62+ {"Domain with path", "github.com/user/repo", true},
63+ {"IP address", "192.168.1.1", true},
64+ {"Localhost", "127.0.0.1", true},
65+
66+ // Should NOT be treated as direct URLs
67+ {"Single word", "github", false},
68+ {"Search query", "golang tutorial", false},
69+ {"Shortcut format", "g: search query", false},
70+ {"Empty string", "", false},
71+ {"Just spaces", " ", false},
72+ }
73+
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ result := validator.IsDirectURL(tt.input)
77+ if result != tt.expected {
78+ t.Errorf("IsDirectURL(%q) = %v, want %v", tt.input, result, tt.expected)
79+ }
80+ })
81+ }
82+}
83+
84+func TestURLValidator_IsSearchShortcut(t *testing.T) {
85+ validator := NewURLValidator()
86+
87+ tests := []struct {
88+ name string
89+ input string
90+ expectShortcut bool
91+ expectKey string
92+ expectQuery string
93+ }{
94+ {
95+ name: "Google shortcut",
96+ input: "g: golang tutorial",
97+ expectShortcut: true,
98+ expectKey: "g",
99+ expectQuery: "golang tutorial",
100+ },
101+ {
102+ name: "GitHub shortcut",
103+ input: "gh: cobra cli",
104+ expectShortcut: true,
105+ expectKey: "gh",
106+ expectQuery: "cobra cli",
107+ },
108+ {
109+ name: "Stack Overflow shortcut",
110+ input: "so: fuzzy search algorithm",
111+ expectShortcut: true,
112+ expectKey: "so",
113+ expectQuery: "fuzzy search algorithm",
114+ },
115+ {
116+ name: "Empty query",
117+ input: "g: ",
118+ expectShortcut: true,
119+ expectKey: "g",
120+ expectQuery: "",
121+ },
122+ {
123+ name: "Numeric shortcut",
124+ input: "123: test",
125+ expectShortcut: true,
126+ expectKey: "123",
127+ expectQuery: "test",
128+ },
129+ // Not shortcuts
130+ {
131+ name: "Just colon",
132+ input: ":",
133+ expectShortcut: false,
134+ },
135+ {
136+ name: "No colon",
137+ input: "github search",
138+ expectShortcut: false,
139+ },
140+ {
141+ name: "URL with colon",
142+ input: "https://example.com",
143+ expectShortcut: false,
144+ },
145+ {
146+ name: "Empty string",
147+ input: "",
148+ expectShortcut: false,
149+ },
150+ }
151+
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ isShortcut, key, query := validator.IsSearchShortcut(tt.input)
155+
156+ if isShortcut != tt.expectShortcut {
157+ t.Errorf("IsSearchShortcut(%q) isShortcut = %v, want %v", tt.input, isShortcut, tt.expectShortcut)
158+ }
159+
160+ if tt.expectShortcut {
161+ if key != tt.expectKey {
162+ t.Errorf("IsSearchShortcut(%q) key = %q, want %q", tt.input, key, tt.expectKey)
163+ }
164+ if query != tt.expectQuery {
165+ t.Errorf("IsSearchShortcut(%q) query = %q, want %q", tt.input, query, tt.expectQuery)
166+ }
167+ }
168+ })
169+ }
170+}
171+
172+func TestURLValidator_NormalizeURL(t *testing.T) {
173+ validator := NewURLValidator()
174+
175+ tests := []struct {
176+ name string
177+ input string
178+ expected string
179+ }{
180+ // Already have scheme
181+ {"HTTPS URL", "https://example.com", "https://example.com"},
182+ {"HTTP URL", "http://example.com", "http://example.com"},
183+
184+ // Need scheme added
185+ {"Domain only", "example.com", "https://example.com"},
186+ {"Subdomain", "www.example.com", "https://www.example.com"},
187+ {"Domain with path", "example.com/path", "https://example.com/path"},
188+ {"IP address", "192.168.1.1", "https://192.168.1.1"},
189+
190+ // Special cases
191+ {"Empty string", "", ""},
192+ {"Just spaces", " ", ""},
193+ {"Non-URL text", "hello world", "hello world"}, // Should remain unchanged
194+ }
195+
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ result := validator.NormalizeURL(tt.input)
199+ if result != tt.expected {
200+ t.Errorf("NormalizeURL(%q) = %q, want %q", tt.input, result, tt.expected)
201+ }
202+ })
203+ }
204+}
205+
206+func TestURLValidator_ExtractDomain(t *testing.T) {
207+ validator := NewURLValidator()
208+
209+ tests := []struct {
210+ name string
211+ input string
212+ expected string
213+ }{
214+ // URLs with scheme
215+ {"HTTPS URL", "https://github.com", "github.com"},
216+ {"HTTP URL", "http://example.com", "example.com"},
217+ {"URL with path", "https://github.com/user/repo", "github.com"},
218+ {"URL with query", "https://google.com/search?q=test", "google.com"},
219+ {"URL with port", "https://localhost:8080", "localhost"},
220+
221+ // Domains without scheme
222+ {"Domain only", "github.com", "github.com"},
223+ {"Subdomain", "api.github.com", "api.github.com"},
224+ {"Domain with path", "github.com/user/repo", "github.com"},
225+
226+ // IP addresses
227+ {"IPv4", "192.168.1.1", "192.168.1.1"},
228+ {"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"},
229+
230+ // Edge cases
231+ {"Empty string", "", ""},
232+ {"Just path", "/path/to/resource", ""}, // Invalid input returns empty
233+ }
234+
235+ for _, tt := range tests {
236+ t.Run(tt.name, func(t *testing.T) {
237+ result := validator.ExtractDomain(tt.input)
238+ if result != tt.expected {
239+ t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected)
240+ }
241+ })
242+ }
243+}
244+
245+func TestURLValidator_HasFileExtension(t *testing.T) {
246+ validator := NewURLValidator()
247+
248+ tests := []struct {
249+ name string
250+ input string
251+ expected bool
252+ }{
253+ // URLs with file extensions
254+ {"HTML file", "https://example.com/index.html", true},
255+ {"PDF file", "https://example.com/document.pdf", true},
256+ {"Image file", "https://example.com/image.jpg", true},
257+ {"File in subdirectory", "https://example.com/docs/readme.txt", true},
258+
259+ // URLs without file extensions
260+ {"Root URL", "https://example.com", false},
261+ {"Root with slash", "https://example.com/", false},
262+ {"Directory path", "https://example.com/docs/", false},
263+ {"Path without extension", "https://example.com/about", false},
264+
265+ // Edge cases
266+ {"Dot at end", "https://example.com/file.", false},
267+ {"Multiple dots", "https://example.com/file.name.ext", true},
268+ {"Query params", "https://example.com/file.html?q=test", true},
269+ }
270+
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ result := validator.HasFileExtension(tt.input)
274+ if result != tt.expected {
275+ t.Errorf("HasFileExtension(%q) = %v, want %v", tt.input, result, tt.expected)
276+ }
277+ })
278+ }
279+}
280+
281+func TestURLValidator_IsLocalhost(t *testing.T) {
282+ validator := NewURLValidator()
283+
284+ tests := []struct {
285+ name string
286+ input string
287+ expected bool
288+ }{
289+ // Localhost variants
290+ {"localhost", "http://localhost", true},
291+ {"localhost with port", "http://localhost:3000", true},
292+ {"localhost HTTPS", "https://localhost:8080", true},
293+ {"127.0.0.1", "http://127.0.0.1", true},
294+ {"127.0.0.1 with port", "http://127.0.0.1:8080", true},
295+ {"IPv6 localhost", "http://[::1]", true},
296+ {"Local domain", "http://app.local", true},
297+
298+ // Non-localhost
299+ {"example.com", "https://example.com", false},
300+ {"github.com", "https://github.com", false},
301+ {"127.0.0.2", "http://127.0.0.2", false}, // Different local IP
302+ {"192.168.1.1", "http://192.168.1.1", false}, // Private network but not localhost
303+
304+ // Edge cases
305+ {"Empty string", "", false},
306+ {"Just localhost", "localhost", true},
307+ {"Just IP", "127.0.0.1", true},
308+ }
309+
310+ for _, tt := range tests {
311+ t.Run(tt.name, func(t *testing.T) {
312+ result := validator.IsLocalhost(tt.input)
313+ if result != tt.expected {
314+ t.Errorf("IsLocalhost(%q) = %v, want %v", tt.input, result, tt.expected)
315+ }
316+ })
317+ }
318+}
319+
320+func TestURLValidator_SanitizeInput(t *testing.T) {
321+ validator := NewURLValidator()
322+
323+ tests := []struct {
324+ name string
325+ input string
326+ expected string
327+ }{
328+ {"Normal text", "github.com", "github.com"},
329+ {"Text with spaces", " github.com ", "github.com"},
330+ {"Text with tabs", "\tgithub.com\t", "github.com"},
331+ {"Text with newlines", "\ngithub.com\n", "github.com"},
332+ {"Unicode text", "github.com/ñoño", "github.com/ñoño"},
333+ {"Empty string", "", ""},
334+ {"Only whitespace", " \t\n ", ""},
335+ {"Mixed whitespace and text", " \t github.com \n ", "github.com"},
336+ }
337+
338+ for _, tt := range tests {
339+ t.Run(tt.name, func(t *testing.T) {
340+ result := validator.SanitizeInput(tt.input)
341+ if result != tt.expected {
342+ t.Errorf("SanitizeInput(%q) = %q, want %q", tt.input, result, tt.expected)
343+ }
344+ })
345+ }
346+}
347+
348+func TestURLValidator_GetURLType(t *testing.T) {
349+ validator := NewURLValidator()
350+
351+ tests := []struct {
352+ name string
353+ input string
354+ expected InputType
355+ }{
356+ // Direct URLs
357+ {"HTTPS URL", "https://github.com", InputTypeDirectURL},
358+ {"Domain", "github.com", InputTypeDirectURL},
359+ {"IP address", "192.168.1.1", InputTypeDirectURL},
360+
361+ // Search shortcuts
362+ {"Google shortcut", "g: golang tutorial", InputTypeSearchShortcut},
363+ {"GitHub shortcut", "gh: cobra cli", InputTypeSearchShortcut},
364+
365+ // History search (everything else initially)
366+ {"Single word", "github", InputTypeHistorySearch},
367+ {"Search phrase", "golang tutorial", InputTypeHistorySearch},
368+ {"Question", "how to use fuzzy search", InputTypeHistorySearch},
369+ {"Empty string", "", InputTypeHistorySearch},
370+ }
371+
372+ for _, tt := range tests {
373+ t.Run(tt.name, func(t *testing.T) {
374+ result := validator.GetURLType(tt.input)
375+ if result != tt.expected {
376+ t.Errorf("GetURLType(%q) = %v, want %v", tt.input, result, tt.expected)
377+ }
378+ })
379+ }
380+}
381+
382+func TestURLValidator_IsDomain(t *testing.T) {
383+ validator := NewURLValidator()
384+
385+ tests := []struct {
386+ name string
387+ input string
388+ expected bool
389+ }{
390+ // Valid domains
391+ {"Simple domain", "example.com", true},
392+ {"Subdomain", "www.example.com", true},
393+ {"Multiple subdomains", "api.v1.example.com", true},
394+ {"Country TLD", "example.co.uk", true},
395+ {"New TLD", "example.dev", true},
396+
397+ // Invalid domains
398+ {"Single word", "example", false},
399+ {"No TLD", "www.example", false},
400+ {"Starts with dot", ".example.com", false},
401+ {"Ends with dot", "example.com.", false},
402+ {"Contains spaces", "exam ple.com", false},
403+ {"Too long segment", "verylongsubdomainthatexceedssixtyThreecharacterslimitfordomainsegments.com", false},
404+ {"Empty string", "", false},
405+ }
406+
407+ for _, tt := range tests {
408+ t.Run(tt.name, func(t *testing.T) {
409+ result := validator.isDomain(tt.input)
410+ if result != tt.expected {
411+ t.Errorf("isDomain(%q) = %v, want %v", tt.input, result, tt.expected)
412+ }
413+ })
414+ }
415+}
416+
417+func TestURLValidator_IsIPAddress(t *testing.T) {
418+ validator := NewURLValidator()
419+
420+ tests := []struct {
421+ name string
422+ input string
423+ expected bool
424+ }{
425+ // Valid IPv4
426+ {"Standard IPv4", "192.168.1.1", true},
427+ {"Localhost IPv4", "127.0.0.1", true},
428+ {"Zero IPv4", "0.0.0.0", true},
429+ {"Max IPv4", "255.255.255.255", true},
430+
431+ // Valid IPv6
432+ {"Standard IPv6", "2001:db8::1", true},
433+ {"Localhost IPv6", "::1", true},
434+
435+ // Invalid IPs
436+ {"Invalid IPv4 (out of range)", "256.256.256.256", false},
437+ {"Invalid IPv4 (too many octets)", "192.168.1.1.1", false},
438+ {"Invalid IPv4 (too few octets)", "192.168.1", false},
439+ {"Not an IP", "example.com", false},
440+ {"Empty string", "", false},
441+ }
442+
443+ for _, tt := range tests {
444+ t.Run(tt.name, func(t *testing.T) {
445+ result := validator.isIPAddress(tt.input)
446+ if result != tt.expected {
447+ t.Errorf("isIPAddress(%q) = %v, want %v", tt.input, result, tt.expected)
448+ }
449+ })
450+ }
451+}
452+
453+func TestURLValidator_LooksLikeDomain(t *testing.T) {
454+ validator := NewURLValidator()
455+
456+ tests := []struct {
457+ name string
458+ input string
459+ expected bool
460+ }{
461+ // Should look like domains
462+ {"Standard domain", "example.com", true},
463+ {"Subdomain", "www.example.com", true},
464+ {"Uncommon TLD", "example.xyz", true},
465+ {"Multiple levels", "a.b.c.example.com", true},
466+
467+ // Should not look like domains
468+ {"No dot", "example", false},
469+ {"Has space", "exam ple.com", false},
470+ {"Too long", string(make([]rune, 300)), false}, // Very long string
471+ {"Empty segment", "example..com", false},
472+ {"Starts with non-alphanumeric", "-example.com", false},
473+ {"Ends with non-alphanumeric", "example-.com", false},
474+ {"Empty string", "", false},
475+ }
476+
477+ for _, tt := range tests {
478+ t.Run(tt.name, func(t *testing.T) {
479+ result := validator.looksLikeDomain(tt.input)
480+ if result != tt.expected {
481+ t.Errorf("looksLikeDomain(%q) = %v, want %v", tt.input, result, tt.expected)
482+ }
483+ })
484+ }
485+}
486+
487+func BenchmarkURLValidator_IsValidURL(b *testing.B) {
488+ validator := NewURLValidator()
489+ testInputs := []string{
490+ "https://github.com",
491+ "github.com",
492+ "192.168.1.1",
493+ "g: search query",
494+ "just some text",
495+ }
496+
497+ b.ResetTimer()
498+ for i := 0; i < b.N; i++ {
499+ validator.IsValidURL(testInputs[i%len(testInputs)])
500+ }
501+}
502+
503+func BenchmarkURLValidator_IsDirectURL(b *testing.B) {
504+ validator := NewURLValidator()
505+ testInputs := []string{
506+ "https://github.com",
507+ "github.com",
508+ "search query",
509+ "g: shortcut",
510+ }
511+
512+ b.ResetTimer()
513+ for i := 0; i < b.N; i++ {
514+ validator.IsDirectURL(testInputs[i%len(testInputs)])
515+ }
516+}
517+
518+func BenchmarkURLValidator_ExtractDomain(b *testing.B) {
519+ validator := NewURLValidator()
520+ testURL := "https://api.github.com/users/octocat/repos"
521+
522+ b.ResetTimer()
523+ for i := 0; i < b.N; i++ {
524+ validator.ExtractDomain(testURL)
525+ }
526+}
A main.go +190 -0
@@ -0,0 +1,190 @@
1+package main
2+
3+import (
4+ "context"
5+ "embed"
6+ "fmt"
7+ "log"
8+ "log/slog"
9+ "os"
10+
11+ "github.com/wailsapp/wails/v3/pkg/application"
12+
13+ "github.com/bnema/dumber/internal/cli"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/db"
16+ "github.com/bnema/dumber/services"
17+)
18+
19+// Embed the frontend assets
20+//go:embed frontend/dist
21+var assets embed.FS
22+
23+// Build information set via ldflags
24+var (
25+ version = "dev"
26+ commit = "none"
27+ buildDate = "unknown"
28+)
29+
30+func main() {
31+ // Check if we should run the CLI mode
32+ if shouldRunCLI() {
33+ runCLI()
34+ return
35+ }
36+
37+ // Otherwise run the GUI browser
38+ runBrowser()
39+}
40+
41+// shouldRunCLI determines if we should run in CLI mode based on arguments
42+func shouldRunCLI() bool {
43+ // No args = GUI landing page mode
44+ if len(os.Args) <= 1 {
45+ return false
46+ }
47+
48+ // Check for GUI-specific flags
49+ for _, arg := range os.Args[1:] {
50+ if arg == "--gui" || arg == "-g" {
51+ return false // Explicit GUI mode
52+ }
53+ }
54+
55+ // Check for browse command - this should open GUI in direct navigation mode
56+ if len(os.Args) >= 2 && os.Args[1] == "browse" {
57+ return false // Browse command uses GUI mode but navigates directly
58+ }
59+
60+ // Any other arguments mean CLI mode
61+ return true
62+}
63+
64+// runCLI executes the CLI functionality using the existing CLI package
65+func runCLI() {
66+ // Initialize configuration system
67+ if err := config.Init(); err != nil {
68+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
69+ os.Exit(1)
70+ }
71+
72+ // Start configuration watching for live reload
73+ if err := config.Watch(); err != nil {
74+ // Don't exit on watch error, just warn
75+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
76+ }
77+
78+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
79+
80+ // Handle dmenu flag at the root level for direct integration
81+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
82+ // Create a temporary CLI instance to handle dmenu mode
83+ cliInstance, err := cli.NewCLI()
84+ if err != nil {
85+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
86+ os.Exit(1)
87+ }
88+ defer func() {
89+ if closeErr := cliInstance.Close(); closeErr != nil {
90+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
91+ }
92+ }()
93+
94+ // Generate dmenu options
95+ dmenuCmd := cli.NewDmenuCmd()
96+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
97+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
98+ os.Exit(1)
99+ }
100+ return
101+ }
102+
103+ // Execute the root command
104+ if err := rootCmd.Execute(); err != nil {
105+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
106+ os.Exit(1)
107+ }
108+}
109+
110+// runBrowser executes the browser GUI functionality
111+func runBrowser() {
112+ // Initialize configuration
113+ if err := config.Init(); err != nil {
114+ log.Fatalf("Failed to initialize config: %v", err)
115+ }
116+ cfg := config.Get()
117+
118+ // Initialize database
119+ database, err := db.InitDB(cfg.Database.Path)
120+ if err != nil {
121+ log.Fatalf("Failed to initialize database: %v", err)
122+ }
123+ defer database.Close()
124+
125+ queries := db.New(database)
126+
127+ // Initialize services
128+ parserService := services.NewParserService(cfg, queries)
129+ browserService := services.NewBrowserService(cfg, queries)
130+ configService := services.NewConfigService(cfg, "")
131+
132+ // Check if this is a browse command with URL
133+ var directURL string
134+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
135+ // Parse the URL using our parser service
136+ ctx := context.Background()
137+ result, err := parserService.ParseInput(ctx, os.Args[2])
138+ if err != nil {
139+ log.Printf("Error parsing URL: %v", err)
140+ } else {
141+ directURL = result.URL
142+ // Record the visit
143+ browserService.Navigate(ctx, directURL)
144+ }
145+ }
146+
147+ // Create application with correct v3-alpha options
148+ app := application.New(application.Options{
149+ Name: "dumber-browser",
150+ Description: "A smart URL launcher and browser with learning behavior",
151+ LogLevel: slog.LevelInfo,
152+
153+ Services: []application.Service{
154+ application.NewService(parserService),
155+ application.NewService(browserService),
156+ application.NewService(configService),
157+ },
158+
159+ Assets: application.AssetOptions{
160+ Handler: application.AssetFileServerFS(assets),
161+ },
162+
163+ Mac: application.MacOptions{
164+ ApplicationShouldTerminateAfterLastWindowClosed: true,
165+ },
166+ })
167+
168+ // Determine the starting URL
169+ startURL := "/"
170+ if directURL != "" {
171+ startURL = directURL
172+ }
173+
174+ // Create the main window
175+ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
176+ Title: "Dumber Browser",
177+ Width: 1200,
178+ Height: 800,
179+ URL: startURL,
180+ })
181+
182+ // Show the window
183+ window.Show()
184+
185+ // Run the application (blocking call)
186+ err = app.Run()
187+ if err != nil {
188+ log.Fatal(err)
189+ }
190+}⏎̸
M memory/constitution.md +77 -35
@@ -1,50 +1,92 @@
1-# [PROJECT_NAME] Constitution
2-<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
1+# Dumb Browser Constitution
32
43 ## Core Principles
54
6-### [PRINCIPLE_1_NAME]
7-<!-- Example: I. Library-First -->
8-[PRINCIPLE_1_DESCRIPTION]
9-<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
5+### I. Speed First (NON-NEGOTIABLE)
6+- Fast startup time is paramount - no feature worth compromising speed
7+- Single binary with embedded assets - no external dependencies at runtime
8+- WebKit2GTK for rendering - full compliance without reinventing wheels
9+- Minimal UI overhead - focus on web content, not chrome
1010
11-### [PRINCIPLE_2_NAME]
12-<!-- Example: II. CLI Interface -->
13-[PRINCIPLE_2_DESCRIPTION]
14-<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
11+### II. Universal Launcher Compatibility
12+- Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu)
13+- Standard stdin/stdout protocol - no launcher-specific integrations
14+- Clean text-based format for history/suggestions
15+- Pipeline-friendly: `dumber --dmenu | launcher | dumber --dmenu`
1516
16-### [PRINCIPLE_3_NAME]
17-<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
18-[PRINCIPLE_3_DESCRIPTION]
19-<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
17+### III. Smart URL Handling
18+- Direct URL detection: `reddit.com` → navigate directly
19+- Search shortcuts: `g: golang tutorial` → Google search
20+- History-first search: `reddit` → check history before web search
21+- No ambiguity - clear rules for input interpretation
2022
21-### [PRINCIPLE_4_NAME]
22-<!-- Example: IV. Integration Testing -->
23-[PRINCIPLE_4_DESCRIPTION]
24-<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
23+### IV. Privacy-Conscious History
24+- Local SQLite database only - no cloud sync
25+- Configurable retention periods and cleanup
26+- Visit counting for intelligent suggestions
27+- User controls data - easy to clear/export
2528
26-### [PRINCIPLE_5_NAME]
27-<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
28-[PRINCIPLE_5_DESCRIPTION]
29-<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
29+### V. Wayland Native
30+- Built for modern Linux desktop - sway, hyprland, etc.
31+- WebKit2GTK provides native Wayland integration
32+- Single instance handling for launcher integration
33+- Minimal resource usage - no background services
3034
31-## [SECTION_2_NAME]
32-<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
35+## Technical Standards
3336
34-[SECTION_2_CONTENT]
35-<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
37+### Stack Requirements
38+- **Backend**: Go + Wails([v3-alpha](https://github.com/wailsapp/wails/tree/v3.0.0-alpha.28)) + WebKit2GTK + SQLite
39+- **CLI**: Cobra + Viper for configuration
40+- **Database**: SQLC for type-safe SQL generation
41+- **Validation**: Input sanitization libraries ([go-playground/validator](https://github.com/go-playground/validator))
42+- **Build**: Single static binary with embedded frontend
43+- **Dependencies**: WebKit2GTK + GTK3 only (system packages)
3644
37-## [SECTION_3_NAME]
38-<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
45+### Performance Targets
46+- Startup time: < 500ms from cold start
47+- Memory usage: < 100MB baseline + WebKit overhead
48+- History search: < 50ms for 1000+ entries
49+- Single instance lock: < 10ms response time
3950
40-[SECTION_3_CONTENT]
41-<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
51+### Quality Gates
52+- All database queries must use SQLC generated code
53+- All user input must be validated and sanitized
54+- Error handling: proper Go error chains, no panics in user paths
55+- Logging: structured logging for debugging, minimal in production (Slog for logging in text mode)
56+
57+## Development Workflow
58+
59+### Implementation Order
60+1. Core CLI structure (Cobra + Viper)
61+2. Database schema + SQLC integration
62+3. URL parser + search engine logic with fuzzy finder algorithm
63+4. History management (CRUD operations)
64+5. Wails browser integration
65+6. Dmenu compatibility layer
66+7. Configuration system
67+
68+### Testing Requirements
69+- Unit tests for URL parsing logic
70+- Integration tests for database operations
71+- Manual testing with fuzzel integration
72+- Cross-launcher compatibility verification
73+
74+## Constraints
75+
76+### Simplicity Mandate
77+- KISS principle - no over-engineering
78+- Clean Code philosophy throughout
79+- Single responsibility per module
80+- Minimal configuration surface
81+
82+### Security Requirements
83+- No credential storage or session management
84+- Input sanitization for all user data
85+- SQL injection prevention via SQLC
86+- No network requests except web browsing
4287
4388 ## Governance
44-<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
4589
46-[GOVERNANCE_RULES]
47-<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
90+This constitution defines the project's core identity and technical direction. All implementation decisions must align with these principles, prioritizing speed and simplicity above feature richness.
4891
49-**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
50-<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->⏎̸
92+**Version**: 1.0.0 | **Ratified**: 2025-01-09 | **Last Amended**: 2025-01-09⏎̸
A migrations/001_initial.sql +29 -0
@@ -0,0 +1,29 @@
1+-- Initial database schema for dumber
2+-- History tracking for visited URLs
3+
4+CREATE TABLE IF NOT EXISTS history (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ title TEXT,
8+ visit_count INTEGER DEFAULT 1,
9+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
10+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
11+);
12+
13+CREATE INDEX idx_history_url ON history(url);
14+CREATE INDEX idx_history_last_visited ON history(last_visited);
15+
16+-- URL shortcuts configuration
17+CREATE TABLE IF NOT EXISTS shortcuts (
18+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19+ shortcut TEXT NOT NULL UNIQUE,
20+ url_template TEXT NOT NULL,
21+ description TEXT,
22+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
23+);
24+
25+INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES
26+ ('g', 'https://www.google.com/search?q=%s', 'Google search'),
27+ ('gh', 'https://github.com/search?q=%s', 'GitHub search'),
28+ ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'),
29+ ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸
M scripts/check-task-prerequisites.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

M scripts/common.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

M scripts/create-new-feature.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

M scripts/get-feature-paths.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

M scripts/setup-plan.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

M scripts/update-agent-context.sh +0 -0 mode 100644 → 100755

mode 100644 → 100755

A services/browser_service.go +205 -0
@@ -0,0 +1,205 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ "github.com/bnema/dumber/internal/db"
11+)
12+
13+// BrowserService handles browser-related operations for Wails integration.
14+type BrowserService struct {
15+ config *config.Config
16+ dbQueries *db.Queries
17+}
18+
19+// NavigationResult represents the result of a navigation operation.
20+type NavigationResult struct {
21+ URL string `json:"url"`
22+ Title string `json:"title"`
23+ Success bool `json:"success"`
24+ Error string `json:"error,omitempty"`
25+ LoadTime time.Duration `json:"load_time"`
26+ RedirectChain []string `json:"redirect_chain,omitempty"`
27+}
28+
29+// HistoryEntry represents a simplified history entry for frontend.
30+type HistoryEntry struct {
31+ ID int64 `json:"id"`
32+ URL string `json:"url"`
33+ Title string `json:"title"`
34+ VisitCount int32 `json:"visit_count"`
35+ LastVisited time.Time `json:"last_visited"`
36+ CreatedAt time.Time `json:"created_at"`
37+}
38+
39+// NewBrowserService creates a new BrowserService instance.
40+func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService {
41+ return &BrowserService{
42+ config: cfg,
43+ dbQueries: queries,
44+ }
45+}
46+
47+// Navigate handles navigation to a URL and records it in history.
48+func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
49+ startTime := time.Now()
50+
51+ if url == "" {
52+ return &NavigationResult{
53+ Success: false,
54+ Error: "URL cannot be empty",
55+ }, nil
56+ }
57+
58+ // Record the navigation in history
59+ err := s.recordHistory(ctx, url, "")
60+ if err != nil {
61+ // Don't fail navigation if history recording fails
62+ fmt.Printf("Failed to record history: %v\n", err)
63+ }
64+
65+ return &NavigationResult{
66+ URL: url,
67+ Success: true,
68+ LoadTime: time.Since(startTime),
69+ }, nil
70+}
71+
72+// UpdatePageTitle updates the title of the current page in history.
73+func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
74+ if url == "" || title == "" {
75+ return fmt.Errorf("URL and title cannot be empty")
76+ }
77+
78+ // Use AddOrUpdateHistory to update the title
79+ titleNull := sql.NullString{String: title, Valid: true}
80+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
81+}
82+
83+// GetRecentHistory returns recent browser history entries.
84+func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
85+ if limit <= 0 {
86+ limit = 50 // Default limit
87+ }
88+
89+ entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
90+ if err != nil {
91+ return nil, err
92+ }
93+
94+ result := make([]HistoryEntry, len(entries))
95+ for i, entry := range entries {
96+ result[i] = HistoryEntry{
97+ ID: entry.ID,
98+ URL: entry.Url,
99+ Title: entry.Title.String,
100+ VisitCount: int32(entry.VisitCount.Int64),
101+ LastVisited: entry.LastVisited.Time,
102+ CreatedAt: entry.CreatedAt.Time,
103+ }
104+ }
105+
106+ return result, nil
107+}
108+
109+// SearchHistory searches browser history.
110+func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
111+ if query == "" {
112+ return s.GetRecentHistory(ctx, limit)
113+ }
114+
115+ if limit <= 0 {
116+ limit = 50
117+ }
118+
119+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
120+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
121+
122+ entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
123+ if err != nil {
124+ return nil, err
125+ }
126+
127+ result := make([]HistoryEntry, len(entries))
128+ for i, entry := range entries {
129+ result[i] = HistoryEntry{
130+ ID: entry.ID,
131+ URL: entry.Url,
132+ Title: entry.Title.String,
133+ VisitCount: int32(entry.VisitCount.Int64),
134+ LastVisited: entry.LastVisited.Time,
135+ CreatedAt: entry.CreatedAt.Time,
136+ }
137+ }
138+
139+ return result, nil
140+}
141+
142+// DeleteHistoryEntry removes a specific history entry.
143+func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
144+ // Note: DeleteHistory method doesn't exist in current schema
145+ // This would need to be implemented in the database layer
146+ return fmt.Errorf("delete history not implemented yet")
147+}
148+
149+// ClearHistory removes all history entries.
150+func (s *BrowserService) ClearHistory(ctx context.Context) error {
151+ // Note: ClearAllHistory method doesn't exist in current schema
152+ // This would need to be implemented in the database layer
153+ return fmt.Errorf("clear all history not implemented yet")
154+}
155+
156+// GetHistoryStats returns statistics about browser history.
157+func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
158+ // Get recent entries for basic stats
159+ recentEntries, err := s.dbQueries.GetHistory(ctx, 1000)
160+ if err != nil {
161+ return nil, err
162+ }
163+
164+ // Calculate basic statistics
165+ stats := map[string]interface{}{
166+ "total_entries": len(recentEntries),
167+ "recent_count": len(recentEntries),
168+ }
169+
170+ // Add more detailed stats if we have entries
171+ if len(recentEntries) > 0 {
172+ stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
173+ stats["newest_entry"] = recentEntries[0].CreatedAt.Time
174+ }
175+
176+ return stats, nil
177+}
178+
179+// GetConfig returns the current browser configuration.
180+func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
181+ return s.config, nil
182+}
183+
184+// UpdateConfig updates the browser configuration.
185+func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
186+ // In a real implementation, you'd want to validate and persist the config
187+ s.config = newConfig
188+ return nil
189+}
190+
191+// GetSearchShortcuts returns available search shortcuts.
192+func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
193+ return s.config.SearchShortcuts, nil
194+}
195+
196+// recordHistory adds or updates a history entry.
197+func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
198+ // Use AddOrUpdateHistory which handles both cases
199+ titleNull := sql.NullString{Valid: false}
200+ if title != "" {
201+ titleNull = sql.NullString{String: title, Valid: true}
202+ }
203+
204+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
205+}⏎̸
A services/config_service.go +240 -0
@@ -0,0 +1,240 @@
1+package services
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// ConfigService handles configuration operations for Wails integration.
13+type ConfigService struct {
14+ config *config.Config
15+ configPath string
16+}
17+
18+// ConfigInfo represents configuration information for the frontend.
19+type ConfigInfo struct {
20+ ConfigPath string `json:"config_path"`
21+ DatabasePath string `json:"database_path"`
22+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
23+ DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
24+}
25+
26+// NewConfigService creates a new ConfigService instance.
27+func NewConfigService(cfg *config.Config, configPath string) *ConfigService {
28+ return &ConfigService{
29+ config: cfg,
30+ configPath: configPath,
31+ }
32+}
33+
34+// GetConfigInfo returns comprehensive configuration information.
35+func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) {
36+ return &ConfigInfo{
37+ ConfigPath: s.configPath,
38+ DatabasePath: s.config.Database.Path,
39+ SearchShortcuts: s.config.SearchShortcuts,
40+ DmenuSettings: &s.config.Dmenu,
41+ }, nil
42+}
43+
44+// GetSearchShortcuts returns all configured search shortcuts.
45+func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
46+ return s.config.SearchShortcuts, nil
47+}
48+
49+// AddSearchShortcut adds a new search shortcut.
50+func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
51+ if key == "" {
52+ return fmt.Errorf("shortcut key cannot be empty")
53+ }
54+
55+ if shortcut.URL == "" {
56+ return fmt.Errorf("shortcut URL cannot be empty")
57+ }
58+
59+ if s.config.SearchShortcuts == nil {
60+ s.config.SearchShortcuts = make(map[string]config.SearchShortcut)
61+ }
62+
63+ s.config.SearchShortcuts[key] = shortcut
64+ return s.saveConfig()
65+}
66+
67+// UpdateSearchShortcut updates an existing search shortcut.
68+func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
69+ if key == "" {
70+ return fmt.Errorf("shortcut key cannot be empty")
71+ }
72+
73+ if _, exists := s.config.SearchShortcuts[key]; !exists {
74+ return fmt.Errorf("shortcut '%s' does not exist", key)
75+ }
76+
77+ if shortcut.URL == "" {
78+ return fmt.Errorf("shortcut URL cannot be empty")
79+ }
80+
81+ s.config.SearchShortcuts[key] = shortcut
82+ return s.saveConfig()
83+}
84+
85+// DeleteSearchShortcut removes a search shortcut.
86+func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error {
87+ if key == "" {
88+ return fmt.Errorf("shortcut key cannot be empty")
89+ }
90+
91+ if _, exists := s.config.SearchShortcuts[key]; !exists {
92+ return fmt.Errorf("shortcut '%s' does not exist", key)
93+ }
94+
95+ delete(s.config.SearchShortcuts, key)
96+ return s.saveConfig()
97+}
98+
99+// GetDmenuSettings returns current dmenu settings.
100+func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) {
101+ return &s.config.Dmenu, nil
102+}
103+
104+// UpdateDmenuSettings updates dmenu configuration.
105+func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error {
106+ if settings == nil {
107+ return fmt.Errorf("dmenu settings cannot be nil")
108+ }
109+
110+ s.config.Dmenu = *settings
111+ return s.saveConfig()
112+}
113+
114+// GetDatabasePath returns the current database path.
115+func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) {
116+ return s.config.Database.Path, nil
117+}
118+
119+// SetDatabasePath updates the database path.
120+func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error {
121+ if path == "" {
122+ return fmt.Errorf("database path cannot be empty")
123+ }
124+
125+ // Validate that the directory exists or can be created
126+ dir := filepath.Dir(path)
127+ if err := os.MkdirAll(dir, 0755); err != nil {
128+ return fmt.Errorf("failed to create database directory: %w", err)
129+ }
130+
131+ s.config.Database.Path = path
132+ return s.saveConfig()
133+}
134+
135+// GetDefaultBrowser returns the default browser setting.
136+func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) {
137+ // Default browser is now built into the app
138+ return "dumber (built-in)", nil
139+}
140+
141+// SetDefaultBrowser updates the default browser setting.
142+func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error {
143+ // Default browser setting is not needed for built-in browser
144+ return fmt.Errorf("default browser is built-in and cannot be changed")
145+}
146+
147+// ReloadConfig reloads configuration from file.
148+func (s *ConfigService) ReloadConfig(ctx context.Context) error {
149+ // Create new manager and load config
150+ manager, err := config.NewManager()
151+ if err != nil {
152+ return fmt.Errorf("failed to create config manager: %w", err)
153+ }
154+
155+ if err := manager.Load(); err != nil {
156+ return fmt.Errorf("failed to load config: %w", err)
157+ }
158+
159+ s.config = manager.Get()
160+ return nil
161+}
162+
163+// ValidateConfig validates the current configuration.
164+func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
165+ var errors []string
166+
167+ // Validate database path
168+ if s.config.Database.Path == "" {
169+ errors = append(errors, "database path is not set")
170+ } else {
171+ dir := filepath.Dir(s.config.Database.Path)
172+ if _, err := os.Stat(dir); os.IsNotExist(err) {
173+ errors = append(errors, fmt.Sprintf("database directory does not exist: %s", dir))
174+ }
175+ }
176+
177+ // Validate search shortcuts
178+ if len(s.config.SearchShortcuts) == 0 {
179+ errors = append(errors, "no search shortcuts configured")
180+ } else {
181+ for key, shortcut := range s.config.SearchShortcuts {
182+ if shortcut.URL == "" {
183+ errors = append(errors, fmt.Sprintf("search shortcut '%s' has empty URL", key))
184+ }
185+ }
186+ }
187+
188+ // Check for default search shortcut
189+ if _, hasDefault := s.config.SearchShortcuts["g"]; !hasDefault {
190+ errors = append(errors, "no default search shortcut 'g' configured")
191+ }
192+
193+ // Validate dmenu settings
194+ if s.config.Dmenu.MaxHistoryItems < 1 {
195+ errors = append(errors, "dmenu max_history_items must be at least 1")
196+ }
197+
198+ return errors, nil
199+}
200+
201+// GetConfigPath returns the path to the configuration file.
202+func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) {
203+ return s.configPath, nil
204+}
205+
206+// ExportConfig returns the current configuration as JSON.
207+func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) {
208+ // Return a copy of the config to prevent modifications
209+ configCopy := *s.config
210+ return &configCopy, nil
211+}
212+
213+// ImportConfig imports configuration from a Config object.
214+func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Config) error {
215+ if newConfig == nil {
216+ return fmt.Errorf("config cannot be nil")
217+ }
218+
219+ // Validate the new config
220+ tempService := &ConfigService{config: newConfig}
221+ validationErrors, err := tempService.ValidateConfig(ctx)
222+ if err != nil {
223+ return fmt.Errorf("failed to validate config: %w", err)
224+ }
225+
226+ if len(validationErrors) > 0 {
227+ return fmt.Errorf("config validation failed: %v", validationErrors)
228+ }
229+
230+ // Apply the new config
231+ s.config = newConfig
232+ return s.saveConfig()
233+}
234+
235+// saveConfig saves the current configuration to file.
236+func (s *ConfigService) saveConfig() error {
237+ // For now, return success - implementing config saving would require
238+ // extending the config package with save functionality
239+ return nil
240+}⏎̸
A services/parser_service.go +185 -0
@@ -0,0 +1,185 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/db"
10+ "github.com/bnema/dumber/internal/parser"
11+)
12+
13+// ParserService wraps the parser functionality for Wails integration.
14+type ParserService struct {
15+ parser *parser.Parser
16+ dbQueries *db.Queries
17+}
18+
19+// NewParserService creates a new ParserService instance.
20+func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService {
21+ // Create history provider from database
22+ historyProvider := &DatabaseHistoryProvider{queries: queries}
23+
24+ // Create parser with configuration
25+ p := parser.NewParser(cfg, historyProvider)
26+
27+ return &ParserService{
28+ parser: p,
29+ dbQueries: queries,
30+ }
31+}
32+
33+// ParseInput parses user input and returns navigation result.
34+func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) {
35+ if input == "" {
36+ return nil, fmt.Errorf("input cannot be empty")
37+ }
38+
39+ return s.parser.ParseInput(input)
40+}
41+
42+// GetCompletions provides URL/search completions based on input.
43+func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) {
44+ if limit <= 0 {
45+ limit = 10 // Default limit
46+ }
47+
48+ return s.parser.SuggestCompletions(input, limit)
49+}
50+
51+// FuzzySearchHistory performs fuzzy search on history entries.
52+func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) {
53+ if threshold <= 0 {
54+ threshold = 0.3 // Default threshold
55+ }
56+
57+ return s.parser.FuzzySearchHistory(query, threshold)
58+}
59+
60+// GetSupportedShortcuts returns configured search shortcuts.
61+func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
62+ return s.parser.GetSupportedShortcuts(), nil
63+}
64+
65+// ProcessShortcut processes a shortcut and returns the resulting URL.
66+func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) {
67+ shortcuts := s.parser.GetSupportedShortcuts()
68+ return s.parser.ProcessShortcut(shortcut, query, shortcuts)
69+}
70+
71+// CalculateSimilarity calculates similarity between two strings.
72+func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) {
73+ return s.parser.CalculateSimilarity(s1, s2), nil
74+}
75+
76+// ValidateURL checks if the input represents a valid URL.
77+func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) {
78+ return s.parser.IsValidURL(input), nil
79+}
80+
81+// PreviewParse provides a preview of what ParseInput would return.
82+func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) {
83+ if input == "" {
84+ return nil, fmt.Errorf("input cannot be empty")
85+ }
86+
87+ return s.parser.PreviewParse(input)
88+}
89+
90+// GetFuzzyConfig returns current fuzzy matching configuration.
91+func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) {
92+ return s.parser.GetFuzzyConfig(), nil
93+}
94+
95+// UpdateFuzzyConfig updates the fuzzy matching configuration.
96+func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error {
97+ return s.parser.UpdateFuzzyConfig(config)
98+}
99+
100+// DatabaseHistoryProvider implements HistoryProvider using the database.
101+type DatabaseHistoryProvider struct {
102+ queries *db.Queries
103+}
104+
105+// GetRecentHistory returns recent history entries limited by count.
106+func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
107+ ctx := context.Background()
108+
109+ entries, err := h.queries.GetHistory(ctx, int64(limit))
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ // Convert to pointer slice
115+ result := make([]*db.History, len(entries))
116+ for i, entry := range entries {
117+ entryCopy := entry
118+ result[i] = &entryCopy
119+ }
120+
121+ return result, nil
122+}
123+
124+// GetAllHistory returns all history entries for fuzzy search.
125+func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) {
126+ ctx := context.Background()
127+
128+ // Use a large limit to get all history
129+ entries, err := h.queries.GetHistory(ctx, 10000)
130+ if err != nil {
131+ return nil, err
132+ }
133+
134+ // Convert to pointer slice
135+ result := make([]*db.History, len(entries))
136+ for i, entry := range entries {
137+ entryCopy := entry
138+ result[i] = &entryCopy
139+ }
140+
141+ return result, nil
142+}
143+
144+// SearchHistory performs a basic text search in history.
145+func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
146+ ctx := context.Background()
147+
148+ // Use SQL LIKE pattern for basic search
149+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
150+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
151+
152+ entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
153+ if err != nil {
154+ return nil, err
155+ }
156+
157+ // Convert to pointer slice
158+ result := make([]*db.History, len(entries))
159+ for i, entry := range entries {
160+ entryCopy := entry
161+ result[i] = &entryCopy
162+ }
163+
164+ return result, nil
165+}
166+
167+// GetHistoryByURL retrieves history entry by exact URL match.
168+func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
169+ ctx := context.Background()
170+
171+ // Search for exact URL match using SearchHistory
172+ urlPattern := sql.NullString{String: url, Valid: true}
173+ emptyPattern := sql.NullString{Valid: false}
174+
175+ entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1)
176+ if err != nil {
177+ return nil, err
178+ }
179+
180+ if len(entries) == 0 {
181+ return nil, fmt.Errorf("history entry not found for URL: %s", url)
182+ }
183+
184+ return &entries[0], nil
185+}⏎̸
A specs/001-init-project/contracts/cli-commands.md +245 -0
@@ -0,0 +1,245 @@
1+# CLI Commands Contract: Project Initialization
2+
3+**Date**: 2025-01-09
4+**Context**: Command-line interface specification for project initialization
5+
6+## Root Command
7+
8+### `dumber init`
9+**Purpose**: Initialize the project with all dependencies
10+
11+**Syntax**:
12+```bash
13+dumber init [OPTIONS]
14+```
15+
16+**Options**:
17+- `--dry-run`: Show what would be done without executing
18+- `--force`: Overwrite existing files if present
19+- `--verbose, -v`: Show detailed progress information
20+- `--go-version`: Specify Go version requirement (default: auto-detect)
21+
22+**Examples**:
23+```bash
24+# Basic initialization
25+dumber init
26+
27+# Preview mode
28+dumber init --dry-run
29+
30+# Force reinitialize existing project
31+dumber init --force --verbose
32+
33+# Specify Go version
34+dumber init --go-version=1.21
35+```
36+
37+**Exit Codes**:
38+- `0`: Success
39+- `1`: General error (invalid arguments, etc.)
40+- `2`: Dependency installation failed
41+- `3`: Tool installation failed
42+- `4`: Project already initialized (without --force)
43+
44+**Output Format**:
45+```
46+✓ Initializing Go module: dumber
47+✓ Installing CLI dependencies: cobra, viper
48+✓ Installing database dependencies: sqlite3, sqlc
49+✓ Installing validation: go-playground/validator
50+✓ Installing testing: testify
51+✓ Installing Wails v3-alpha...
52+✓ Creating project structure...
53+✓ Generating configuration files...
54+✓ Verifying installation...
55+
56+Project initialized successfully!
57+
58+Next steps:
59+ 1. Review generated configs/
60+ 2. Run: go build ./cmd/dumber
61+ 3. Start development: dumber --help
62+```
63+
64+## Subcommands
65+
66+### `dumber init deps`
67+**Purpose**: Install only dependencies without project structure
68+
69+**Syntax**:
70+```bash
71+dumber init deps [OPTIONS]
72+```
73+
74+**Options**:
75+- `--only`: Install specific dependency category (cli|db|validation|testing|wails)
76+- `--skip`: Skip specific dependency category
77+- `--latest`: Use @latest for all dependencies (default)
78+- `--check`: Verify dependencies without installing
79+
80+**Examples**:
81+```bash
82+# Install only CLI dependencies
83+dumber init deps --only=cli
84+
85+# Install everything except Wails
86+dumber init deps --skip=wails
87+
88+# Check current dependency status
89+dumber init deps --check
90+```
91+
92+### `dumber init structure`
93+**Purpose**: Create project directory structure only
94+
95+**Syntax**:
96+```bash
97+dumber init structure [OPTIONS]
98+```
99+
100+**Options**:
101+- `--template`: Use specific structure template (default|minimal)
102+- `--preview`: Show directory tree without creating
103+
104+**Examples**:
105+```bash
106+# Create full project structure
107+dumber init structure
108+
109+# Preview directory layout
110+dumber init structure --preview
111+
112+# Minimal structure (no example files)
113+dumber init structure --template=minimal
114+```
115+
116+### `dumber init config`
117+**Purpose**: Generate configuration files only
118+
119+**Syntax**:
120+```bash
121+dumber init config [OPTIONS]
122+```
123+
124+**Options**:
125+- `--type`: Generate specific config (sqlc|wails|all)
126+- `--overwrite`: Replace existing config files
127+
128+**Examples**:
129+```bash
130+# Generate all config files
131+dumber init config
132+
133+# Only SQLC configuration
134+dumber init config --type=sqlc
135+
136+# Replace existing configs
137+dumber init config --overwrite
138+```
139+
140+## Status and Information Commands
141+
142+### `dumber init status`
143+**Purpose**: Show initialization status
144+
145+**Syntax**:
146+```bash
147+dumber init status [OPTIONS]
148+```
149+
150+**Options**:
151+- `--format`: Output format (text|json|yaml)
152+- `--detailed`: Show dependency versions and paths
153+
154+**Output** (text format):
155+```
156+Project Status: ✓ Initialized
157+
158+Dependencies:
159+ ✓ CLI (cobra v1.8.0, viper v1.18.2)
160+ ✓ Database (sqlite3 v1.14.19, sqlc v1.25.0)
161+ ✓ Validation (validator v10.16.0)
162+ ✓ Testing (testify v1.8.4)
163+ ✗ Wails (not installed)
164+
165+Structure:
166+ ✓ cmd/, internal/, migrations/, configs/
167+ ✗ Missing: tests/integration/
168+
169+Build Status: ✓ Ready (go build succeeds)
170+```
171+
172+### `dumber init verify`
173+**Purpose**: Verify complete installation
174+
175+**Syntax**:
176+```bash
177+dumber init verify [OPTIONS]
178+```
179+
180+**Options**:
181+- `--fix`: Attempt to fix detected issues
182+- `--report`: Generate detailed verification report
183+
184+**Exit Codes**:
185+- `0`: All verifications passed
186+- `1`: Some issues detected but not critical
187+- `2`: Critical issues prevent development
188+
189+## Error Handling
190+
191+### Common Error Scenarios
192+
193+**Dependency Installation Failures**:
194+```bash
195+$ dumber init
196+✗ Installing database dependencies: sqlite3
197+ Error: CGO_ENABLED=1 required for sqlite3
198+ Suggestion: Run 'export CGO_ENABLED=1' and try again
199+ Exit Code: 2
200+```
201+
202+**System Dependency Missing**:
203+```bash
204+$ dumber init
205+✗ Installing Wails v3-alpha
206+ Error: libwebkit2gtk-4.0-dev not found
207+ Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev'
208+ Exit Code: 3
209+```
210+
211+**Project Already Exists**:
212+```bash
213+$ dumber init
214+✗ Project already initialized
215+ Use --force to reinitialize or run 'dumber init status'
216+ Exit Code: 4
217+```
218+
219+## Integration with Other Systems
220+
221+### CI/CD Integration
222+Commands designed for automated environments:
223+```bash
224+# Non-interactive initialization
225+dumber init --verbose > init.log 2>&1
226+
227+# Verification in CI
228+dumber init verify --report=ci-report.json
229+```
230+
231+### Development Workflow Integration
232+```bash
233+# Quick setup for new contributors
234+git clone <repo> && cd <repo>
235+dumber init
236+go test ./...
237+```
238+
239+### IDE Integration
240+Status command provides machine-readable output:
241+```bash
242+dumber init status --format=json | jq '.dependencies.wails.status'
243+```
244+
245+This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸
A specs/001-init-project/contracts/go-module.yaml +152 -0
@@ -0,0 +1,152 @@
1+# Go Module Contract: Project Dependencies
2+
3+# Expected go.mod file structure after initialization
4+module: dumber
5+go_version: "1.25"
6+
7+# Runtime dependencies (will be installed via go get @latest)
8+dependencies:
9+ cli:
10+ - import_path: "github.com/spf13/cobra"
11+ purpose: "CLI framework and command handling"
12+ min_version: "v1.8.0"
13+
14+ - import_path: "github.com/spf13/viper"
15+ purpose: "Configuration management"
16+ min_version: "v1.18.0"
17+
18+ database:
19+ - import_path: "github.com/ncruces/go-sqlite3"
20+ purpose: "CGO-free SQLite database driver"
21+ min_version: "v0.28.0"
22+ build_tags: []
23+ requirements: []
24+ benefits: ["no CGO required", "better performance", "cleaner builds"]
25+
26+ validation:
27+ - import_path: "github.com/go-playground/validator/v10"
28+ purpose: "Struct validation and input sanitization"
29+ min_version: "v10.16.0"
30+
31+ testing:
32+ - import_path: "github.com/stretchr/testify"
33+ purpose: "Enhanced testing assertions and mocks"
34+ min_version: "v1.8.4"
35+ scope: "test"
36+
37+# Development tools (installed via go install)
38+tools:
39+ - name: "sqlc"
40+ install_command: "go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest"
41+ purpose: "Generate type-safe Go code from SQL"
42+ version_check: "sqlc version"
43+
44+ - name: "wails"
45+ install_command: "go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28"
46+ purpose: "Desktop application framework"
47+ version_check: "wails version"
48+ system_deps: ["libwebkit2gtk-4.0-dev", "libgtk-3-dev"]
49+
50+# Expected go.mod content template
51+go_mod_template: |
52+ module dumber
53+
54+ go 1.21
55+
56+ require (
57+ github.com/spf13/cobra v1.8.0
58+ github.com/spf13/viper v1.18.2
59+ github.com/ncruces/go-sqlite3 v0.28.0
60+ github.com/go-playground/validator/v10 v10.16.0
61+ github.com/stretchr/testify v1.8.4
62+ )
63+
64+# Compatibility matrix
65+compatibility:
66+ go_version:
67+ minimum: "1.18" # Required for generics
68+ recommended: "1.21"
69+ maximum: "1.22"
70+
71+ operating_systems:
72+ primary: "linux"
73+ supported: ["linux", "darwin"]
74+ tested: ["archlinux", "ubuntu-22.04"]
75+
76+ architectures:
77+ primary: "amd64"
78+ supported: ["amd64", "arm64"]
79+
80+# Build configuration
81+build:
82+ tags:
83+ development: ["dev", "debug"]
84+ production: []
85+ testing: ["test", "integration"]
86+
87+ environment:
88+ wails_build:
89+ - name: "CGO_ENABLED"
90+ value: "1"
91+ reason: "Required for Wails WebKit2GTK bindings"
92+ cgo_free_development:
93+ - name: "CGO_ENABLED"
94+ value: "0"
95+ reason: "Most development can happen CGO-free (CLI, database, parser)"
96+
97+ flags:
98+ development: "-race -v"
99+ production: "-ldflags='-s -w'"
100+
101+# Verification commands
102+verification:
103+ module_valid: "go mod verify"
104+ dependencies_resolved: "go mod download"
105+ build_succeeds: "go build ./cmd/dumber"
106+ tests_pass: "go test ./..."
107+ tools_available:
108+ - "sqlc version"
109+ - "wails version"
110+
111+# Installation success criteria
112+success_criteria:
113+ - name: "go.mod exists"
114+ command: "test -f go.mod"
115+
116+ - name: "module name correct"
117+ command: "grep -q 'module dumber' go.mod"
118+
119+ - name: "all dependencies resolved"
120+ command: "go mod download && echo 'OK'"
121+
122+ - name: "build succeeds"
123+ command: "go build -o /tmp/test-build ./cmd/dumber"
124+ cleanup: "rm -f /tmp/test-build"
125+
126+ - name: "sqlc available"
127+ command: "which sqlc && sqlc version"
128+
129+ - name: "wails available"
130+ command: "which wails && wails version"
131+
132+# Error conditions and solutions
133+error_handling:
134+ cgo_disabled_for_wails:
135+ error_pattern: "C compiler|CGO_ENABLED.*wails"
136+ solution: "export CGO_ENABLED=1 # Only needed for Wails builds"
137+ documentation: "Wails requires CGO for WebKit2GTK bindings (database layer is CGO-free)"
138+
139+ missing_webkit:
140+ error_pattern: "webkit2gtk|libwebkit2gtk"
141+ solution: "sudo apt install libwebkit2gtk-4.0-dev libgtk-3-dev"
142+ documentation: "Wails requires WebKit2GTK development headers"
143+
144+ network_failure:
145+ error_pattern: "dial tcp|connection refused|timeout"
146+ solution: "Check internet connection and proxy settings"
147+ documentation: "Dependency installation requires network access"
148+
149+ insufficient_space:
150+ error_pattern: "no space left|disk full"
151+ solution: "Free up disk space and try again"
152+ documentation: "Go module cache and tools require ~500MB space"⏎̸
A specs/001-init-project/data-model.md +172 -0
@@ -0,0 +1,172 @@
1+# Data Model: Project Initialization
2+
3+**Date**: 2025-01-09
4+**Context**: Data structures for dumber project initialization
5+
6+## Core Entities
7+
8+### 1. Go Module Configuration
9+**Purpose**: Represents the initialized Go module and its metadata
10+
11+**Structure**:
12+```go
13+type ModuleConfig struct {
14+ Name string `validate:"required"` // "dumber"
15+ Version string `validate:"required,semver"` // "v0.1.0"
16+ GoVersion string `validate:"required"` // Go version requirement
17+ Dependencies []Dependency `validate:"dive"`
18+}
19+
20+type Dependency struct {
21+ ImportPath string `validate:"required,uri"` // e.g. "github.com/spf13/cobra"
22+ Version string `validate:"required"` // "@latest" or specific version
23+ Type DependencyType `validate:"required"` // runtime, dev, tool
24+}
25+
26+type DependencyType int
27+const (
28+ RuntimeDep DependencyType = iota // Core application dependencies
29+ DevDep // Development/testing tools
30+ ToolDep // CLI tools (sqlc, wails)
31+)
32+```
33+
34+**Validation Rules**:
35+- Module name must be valid Go identifier
36+- Version must follow semantic versioning
37+- All dependency paths must be valid import paths
38+- No circular dependencies allowed
39+
40+### 2. Project Structure
41+**Purpose**: Defines the directory layout and file organization
42+
43+**Structure**:
44+```go
45+type ProjectStructure struct {
46+ RootPath string `validate:"required,dir"`
47+ Directories []Directory `validate:"dive"`
48+ ConfigFiles []ConfigFile `validate:"dive"`
49+ InitialFiles []InitialFile `validate:"dive"`
50+}
51+
52+type Directory struct {
53+ Path string `validate:"required"` // relative to root
54+ Purpose string `validate:"required"` // description
55+ Required bool // must be created
56+}
57+
58+type ConfigFile struct {
59+ Path string `validate:"required"` // relative to root
60+ Template string `validate:"required"` // template name
61+ Variables map[string]string // template variables
62+}
63+
64+type InitialFile struct {
65+ Path string `validate:"required"` // relative to root
66+ Content string // file content
67+ Type FileType `validate:"required"` // go, sql, yaml, etc.
68+}
69+```
70+
71+### 3. Dependency Installation Status
72+**Purpose**: Tracks installation progress and success/failure states
73+
74+**Structure**:
75+```go
76+type InstallationStatus struct {
77+ ModuleName string `validate:"required"`
78+ Dependencies []DependencyStatus `validate:"dive"`
79+ Tools []ToolStatus `validate:"dive"`
80+ Overall StatusType `validate:"required"`
81+ Timestamp time.Time `validate:"required"`
82+ Errors []InstallError
83+}
84+
85+type DependencyStatus struct {
86+ ImportPath string `validate:"required"`
87+ Version string
88+ Status StatusType `validate:"required"`
89+ InstallTime time.Duration // how long to install
90+ Error *InstallError
91+}
92+
93+type ToolStatus struct {
94+ Name string `validate:"required"` // "sqlc", "wails"
95+ Command string `validate:"required"` // installation command
96+ Status StatusType `validate:"required"`
97+ Version string // installed version
98+ Error *InstallError
99+}
100+
101+type StatusType int
102+const (
103+ NotStarted StatusType = iota
104+ InProgress
105+ Success
106+ Failed
107+ Skipped
108+)
109+
110+type InstallError struct {
111+ Message string `validate:"required"`
112+ Code int
113+ Suggestion string // helpful hint for user
114+}
115+```
116+
117+## State Transitions
118+
119+### Module Initialization Flow
120+```
121+Empty Directory → Module Init → Dependency Resolution → Tool Installation → Verification → Complete
122+ ↓ ↓ ↓ ↓
123+ [go.mod] [go.mod updated] [tools available] [build succeeds]
124+```
125+
126+### Dependency Installation States
127+```
128+NotStarted → InProgress → Success
129+ ↓
130+ Failed (with retry option)
131+```
132+
133+## Relationships
134+
135+1. **ModuleConfig** contains multiple **Dependencies**
136+2. **ProjectStructure** defines where **ConfigFiles** are placed
137+3. **InstallationStatus** tracks the progress of **ModuleConfig** setup
138+4. Each **Directory** in **ProjectStructure** serves specific **Dependencies**
139+
140+## Validation Constraints
141+
142+### Business Rules
143+1. **Module name uniqueness**: No conflicts with existing Go modules in workspace
144+2. **Dependency compatibility**: All dependencies must support the same Go version
145+3. **Tool availability**: Required tools (sqlc, wails) must be installable
146+4. **Directory permissions**: All target directories must be writable
147+5. **Disk space**: Adequate space for dependencies and generated files
148+
149+### Technical Constraints
150+1. **Go version**: Must support generics (Go 1.18+)
151+2. **CGO requirement**: SQLite driver requires CGO_ENABLED=1
152+3. **System dependencies**: Wails requires WebKit2GTK development headers
153+4. **Network access**: Dependency download requires internet connectivity
154+
155+## Integration Points
156+
157+### With CLI Commands
158+- `dumber init` → Creates **ModuleConfig** and **ProjectStructure**
159+- `dumber deps install` → Updates **InstallationStatus**
160+- `dumber deps verify` → Validates all **Dependencies**
161+
162+### With Configuration Files
163+- **go.mod**: Generated from **ModuleConfig**
164+- **sqlc.yaml**: Generated from database **Dependencies**
165+- **wails.json**: Generated from Wails **Dependencies**
166+
167+### With Testing Framework
168+- Integration tests validate each **StatusType** transition
169+- Contract tests verify **ConfigFile** template generation
170+- Unit tests cover **Dependency** validation logic
171+
172+This data model supports the initialization phase while preparing for the next development phases (CLI implementation, database setup, browser integration).⏎̸
A specs/001-init-project/plan.md +252 -0
@@ -0,0 +1,252 @@
1+# Implementation Plan: Project Initialization with Dependencies
2+
3+**Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md)
4+**Input**: Feature specification from `/home/brice/dev/projects/dumber/specs/001-init-project/spec.md`
5+
6+## Execution Flow (/plan command scope)
7+```
8+1. Load feature spec from Input path
9+ → If not found: ERROR "No feature spec at {path}"
10+2. Fill Technical Context (scan for NEEDS CLARIFICATION)
11+ → Detect Project Type from context (web=frontend+backend, mobile=app+api)
12+ → Set Structure Decision based on project type
13+3. Evaluate Constitution Check section below
14+ → If violations exist: Document in Complexity Tracking
15+ → If no justification possible: ERROR "Simplify approach first"
16+ → Update Progress Tracking: Initial Constitution Check
17+4. Execute Phase 0 → research.md
18+ → If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns"
19+5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI).
20+6. Re-evaluate Constitution Check section
21+ → If new violations: Refactor design, return to Phase 1
22+ → Update Progress Tracking: Post-Design Constitution Check
23+7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md)
24+8. STOP - Ready for /tasks command
25+```
26+
27+**IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands:
28+- Phase 2: /tasks command creates tasks.md
29+- Phase 3-4: Implementation execution (manual or via tools)
30+
31+## Summary
32+Initialize a Go project with all required dependencies for the dumber application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies.
33+
34+## Technical Context
35+**User Details**: We need to add all deps using go get @latest
36+**Language/Version**: Go (latest compatible with Wails v3-alpha)
37+**Primary Dependencies**: Wails v3-alpha, Cobra, Viper, SQLC, go-playground/validator, SQLite driver
38+**Storage**: SQLite (local embedded database)
39+**Testing**: Go standard testing + testify for enhanced assertions
40+**Target Platform**: Linux Wayland (sway, hyprland) - primary focus
41+**Project Type**: single (desktop application with CLI + GUI components)
42+**Performance Goals**: < 500ms startup time, < 100MB baseline memory
43+**Constraints**: Single binary, WebKit2GTK system dependency only
44+**Scale/Scope**: Personal productivity tool, local history management (1000+ entries)
45+
46+## Constitution Check
47+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
48+
49+**Simplicity**:
50+- Projects: 1 (single desktop app - dumber)
51+- Using framework directly? YES (Wails, Cobra, Viper directly)
52+- Single data model? YES (SQLite schema matches Go structs via SQLC)
53+- Avoiding patterns? YES (no unnecessary abstractions for initialization)
54+
55+**Architecture**:
56+- EVERY feature as library? YES (CLI, parser, database, browser as separate packages)
57+- Libraries planned: cli (cobra commands), db (sqlc queries), parser (URL handling), browser (Wails integration)
58+- CLI per library: main CLI with subcommands (--dmenu, history, config)
59+- Library docs: llms.txt format will be generated post-implementation
60+
61+**Testing (NON-NEGOTIABLE)**:
62+- RED-GREEN-Refactor cycle enforced? YES (tests for module initialization first)
63+- Git commits show tests before implementation? YES (test dependency resolution before implementation)
64+- Order: Contract→Integration→E2E→Unit strictly followed? YES
65+- Real dependencies used? YES (actual Go modules, SQLite DB)
66+- Integration tests for: module loading, dependency compatibility, build success
67+- FORBIDDEN: Implementation before test, skipping RED phase
68+
69+**Observability**:
70+- Structured logging included? YES (slog for debugging initialization)
71+- Frontend logs → backend? N/A for initialization phase
72+- Error context sufficient? YES (proper error chains for dependency failures)
73+
74+**Versioning**:
75+- Version number assigned? YES (v0.1.0 - initial development)
76+- BUILD increments on every change? YES (following semantic versioning)
77+- Breaking changes handled? N/A for initial setup
78+
79+## Project Structure
80+
81+### Documentation (this feature)
82+```
83+specs/[###-feature]/
84+├── plan.md # This file (/plan command output)
85+├── research.md # Phase 0 output (/plan command)
86+├── data-model.md # Phase 1 output (/plan command)
87+├── quickstart.md # Phase 1 output (/plan command)
88+├── contracts/ # Phase 1 output (/plan command)
89+└── tasks.md # Phase 2 output (/tasks command - NOT created by /plan)
90+```
91+
92+### Source Code (repository root)
93+```
94+# Option 1: Single project (DEFAULT)
95+src/
96+├── models/
97+├── services/
98+├── cli/
99+└── lib/
100+
101+tests/
102+├── contract/
103+├── integration/
104+└── unit/
105+
106+# Option 2: Web application (when "frontend" + "backend" detected)
107+backend/
108+├── src/
109+│ ├── models/
110+│ ├── services/
111+│ └── api/
112+└── tests/
113+
114+frontend/
115+├── src/
116+│ ├── components/
117+│ ├── pages/
118+│ └── services/
119+└── tests/
120+
121+# Option 3: Mobile + API (when "iOS/Android" detected)
122+api/
123+└── [same as backend above]
124+
125+ios/ or android/
126+└── [platform-specific structure]
127+```
128+
129+**Structure Decision**: Option 1 - Single project (desktop application with CLI and GUI components)
130+
131+## Phase 0: Outline & Research
132+1. **Extract unknowns from Technical Context** above:
133+ - For each NEEDS CLARIFICATION → research task
134+ - For each dependency → best practices task
135+ - For each integration → patterns task
136+
137+2. **Generate and dispatch research agents**:
138+ ```
139+ For each unknown in Technical Context:
140+ Task: "Research {unknown} for {feature context}"
141+ For each technology choice:
142+ Task: "Find best practices for {tech} in {domain}"
143+ ```
144+
145+3. **Consolidate findings** in `research.md` using format:
146+ - Decision: [what was chosen]
147+ - Rationale: [why chosen]
148+ - Alternatives considered: [what else evaluated]
149+
150+**Output**: research.md with all NEEDS CLARIFICATION resolved
151+
152+## Phase 1: Design & Contracts
153+*Prerequisites: research.md complete*
154+
155+1. **Extract entities from feature spec** → `data-model.md`:
156+ - Entity name, fields, relationships
157+ - Validation rules from requirements
158+ - State transitions if applicable
159+
160+2. **Generate API contracts** from functional requirements:
161+ - For each user action → endpoint
162+ - Use standard REST/GraphQL patterns
163+ - Output OpenAPI/GraphQL schema to `/contracts/`
164+
165+3. **Generate contract tests** from contracts:
166+ - One test file per endpoint
167+ - Assert request/response schemas
168+ - Tests must fail (no implementation yet)
169+
170+4. **Extract test scenarios** from user stories:
171+ - Each story → integration test scenario
172+ - Quickstart test = story validation steps
173+
174+5. **Update agent file incrementally** (O(1) operation):
175+ - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant
176+ - If exists: Add only NEW tech from current plan
177+ - Preserve manual additions between markers
178+ - Update recent changes (keep last 3)
179+ - Keep under 150 lines for token efficiency
180+ - Output to repository root
181+
182+**Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file
183+
184+## Phase 2: Task Planning Approach
185+*This section describes what the /tasks command will do - DO NOT execute during /plan*
186+
187+**Task Generation Strategy**:
188+- Load `/templates/tasks-template.md` as base
189+- Generate tasks from Phase 1 design docs (contracts, data model, quickstart)
190+- Prioritize CGO-free components first (faster development cycle)
191+- Each CLI command contract → contract test + implementation task
192+- Module initialization → dependency installation tasks
193+- Project structure → directory creation + config generation tasks
194+- Integration tests for each success criterion in quickstart
195+
196+**Ordering Strategy**:
197+- TDD order: Tests before implementation
198+- CGO-free first: CLI, parser, database (can develop with CGO_ENABLED=0)
199+- Wails integration last: Full application build (requires CGO_ENABLED=1)
200+- Dependency order: Go module → dependencies → structure → config → verification
201+- Mark [P] for parallel execution (independent dependency installations)
202+
203+**Key Task Categories**:
204+1. **Module Setup Tasks**: go mod init, dependency installation
205+2. **Structure Tasks**: Directory creation, config file generation
206+3. **Verification Tasks**: Build tests, tool availability checks
207+4. **Integration Tasks**: Full quickstart validation
208+
209+**CGO Optimization**:
210+- Most tasks can run with CGO_ENABLED=0 for faster iteration
211+- Only final integration tests require CGO_ENABLED=1
212+- Clear separation allows parallel development of CGO-free components
213+
214+**Estimated Output**: 15-20 numbered, ordered tasks in tasks.md (streamlined for initialization focus)
215+
216+**IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan
217+
218+## Phase 3+: Future Implementation
219+*These phases are beyond the scope of the /plan command*
220+
221+**Phase 3**: Task execution (/tasks command creates tasks.md)
222+**Phase 4**: Implementation (execute tasks.md following constitutional principles)
223+**Phase 5**: Validation (run tests, execute quickstart.md, performance validation)
224+
225+## Complexity Tracking
226+*Fill ONLY if Constitution Check has violations that must be justified*
227+
228+| Violation | Why Needed | Simpler Alternative Rejected Because |
229+|-----------|------------|-------------------------------------|
230+| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
231+| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
232+
233+
234+## Progress Tracking
235+*This checklist is updated during execution flow*
236+
237+**Phase Status**:
238+- [x] Phase 0: Research complete (/plan command)
239+- [x] Phase 1: Design complete (/plan command)
240+- [x] Phase 2: Task planning complete (/plan command - describe approach only)
241+- [ ] Phase 3: Tasks generated (/tasks command)
242+- [ ] Phase 4: Implementation complete
243+- [ ] Phase 5: Validation passed
244+
245+**Gate Status**:
246+- [x] Initial Constitution Check: PASS
247+- [x] Post-Design Constitution Check: PASS
248+- [x] All NEEDS CLARIFICATION resolved
249+- [x] Complexity deviations documented (none - simplicity maintained)
250+
251+---
252+*Based on Constitution v2.1.1 - See `/memory/constitution.md`*⏎̸
A specs/001-init-project/quickstart.md +247 -0
@@ -0,0 +1,247 @@
1+# Quickstart: Project Initialization
2+
3+**Goal**: Validate that project initialization works correctly and all dependencies are properly installed.
4+
5+## Prerequisites Verification
6+
7+Before running quickstart, verify system requirements:
8+
9+```bash
10+# Check Go version (1.25+ required)
11+go version
12+# Expected: go version go1.25.x linux/amd64
13+
14+# Check system packages for Wails (Arch Linux)
15+pacman -Q webkit2gtk gtk3
16+# Expected: webkit2gtk-4.0 and gtk3 packages listed
17+
18+# For Ubuntu/Debian:
19+# dpkg -l | grep -E "(webkit2gtk|libgtk-3)"
20+
21+# Note: CGO only needed for Wails, not for database layer
22+```
23+
24+## Step 1: Project Initialization
25+
26+Navigate to project root and initialize:
27+
28+```bash
29+cd /home/brice/dev/projects/dumber
30+
31+# Initialize with verbose output to see progress
32+dumber init --verbose
33+```
34+
35+**Expected Output**:
36+```
37+✓ Initializing Go module: dumber
38+✓ Installing CLI dependencies: cobra, viper
39+✓ Installing database dependencies: ncruces/go-sqlite3, sqlc
40+✓ Installing validation: go-playground/validator
41+✓ Installing testing: testify
42+✓ Installing Wails v3-alpha... (requires CGO)
43+✓ Creating project structure...
44+✓ Generating configuration files...
45+✓ Verifying installation...
46+
47+Project initialized successfully!
48+
49+Next steps:
50+ 1. Review generated configs/
51+ 2. Run: go build ./cmd/dumber
52+ 3. Start development: dumber --help
53+```
54+
55+## Step 2: Verify Installation
56+
57+Check that all components are correctly installed:
58+
59+```bash
60+# Verify project status
61+dumber init status
62+
63+# Expected output:
64+# Project Status: ✓ Initialized
65+# Dependencies: ✓ All installed
66+# Structure: ✓ Complete
67+# Build Status: ✓ Ready
68+```
69+
70+Detailed verification:
71+
72+```bash
73+# Check go.mod exists and is valid
74+test -f go.mod && echo "✓ go.mod exists"
75+grep -q "module dumber" go.mod && echo "✓ Module name correct"
76+
77+# Verify dependencies are downloadable
78+go mod download && echo "✓ Dependencies resolved"
79+
80+# Check tools are available
81+sqlc version && echo "✓ SQLC available"
82+wails version && echo "✓ Wails available"
83+```
84+
85+## Step 3: Test CGO-Free Components
86+
87+Most components can be tested without CGO enabled:
88+
89+```bash
90+# Test CGO-free database layer
91+export CGO_ENABLED=0
92+go build ./internal/db && echo "✓ Database layer builds CGO-free"
93+go test ./internal/db && echo "✓ Database tests pass CGO-free"
94+
95+# Test CLI components
96+go build ./internal/cli && echo "✓ CLI builds CGO-free"
97+go test ./internal/cli && echo "✓ CLI tests pass CGO-free"
98+
99+# Test URL parser
100+go build ./internal/parser && echo "✓ Parser builds CGO-free"
101+go test ./internal/parser && echo "✓ Parser tests pass CGO-free"
102+```
103+
104+## Step 4: Test Wails Integration (CGO Required)
105+
106+Test the full application with Wails:
107+
108+```bash
109+# Enable CGO for Wails
110+export CGO_ENABLED=1
111+
112+# Test full application build
113+go build ./cmd/dumber && echo "✓ Full build successful"
114+
115+# Clean up test binary
116+rm -f dumber
117+```
118+
119+## Step 5: Test Project Structure
120+
121+Verify the correct directory structure was created:
122+
123+```bash
124+# Check main directories exist
125+test -d cmd && echo "✓ cmd/ directory"
126+test -d internal && echo "✓ internal/ directory"
127+test -d migrations && echo "✓ migrations/ directory"
128+test -d configs && echo "✓ configs/ directory"
129+test -d tests && echo "✓ tests/ directory"
130+
131+# Check internal structure
132+test -d internal/cli && echo "✓ internal/cli/"
133+test -d internal/db && echo "✓ internal/db/"
134+test -d internal/parser && echo "✓ internal/parser/"
135+test -d internal/browser && echo "✓ internal/browser/"
136+```
137+
138+## Step 6: Test Configuration Generation
139+
140+Verify that configuration files were generated correctly:
141+
142+```bash
143+# Check SQLC configuration
144+test -f configs/sqlc.yaml && echo "✓ SQLC config exists"
145+sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid"
146+
147+# Check basic project files exist (if generated)
148+test -f cmd/dumber/main.go && echo "✓ Main entry point exists"
149+```
150+
151+## Step 7: Test Module Dependencies
152+
153+Verify the updated SQLite dependency:
154+
155+```bash
156+# Check ncruces/go-sqlite3 is in go.mod (not mattn)
157+grep -q "github.com/ncruces/go-sqlite3" go.mod && echo "✓ Using CGO-free SQLite driver"
158+! grep -q "github.com/mattn/go-sqlite3" go.mod && echo "✓ Not using CGO SQLite driver"
159+
160+# Test that database works CGO-free
161+export CGO_ENABLED=0
162+go run -c "import _ 'github.com/ncruces/go-sqlite3'" 2>/dev/null && echo "✓ SQLite driver loads CGO-free"
163+```
164+
165+## Step 8: Validation Tests
166+
167+Run tests with different CGO settings:
168+
169+```bash
170+# Test CGO-free components
171+export CGO_ENABLED=0
172+go test ./internal/cli ./internal/parser ./internal/db && echo "✓ CGO-free tests pass"
173+
174+# Test full application with CGO
175+export CGO_ENABLED=1
176+go test ./... && echo "✓ All tests pass with CGO"
177+
178+# Test with race detection (requires CGO)
179+go test -race ./... && echo "✓ No race conditions detected"
180+```
181+
182+## Troubleshooting Common Issues
183+
184+### Issue: Missing WebKit2GTK (Wails only)
185+**Symptom**: Wails installation fails with webkit error
186+**Solution** (Arch Linux):
187+```bash
188+sudo pacman -S webkit2gtk gtk3
189+dumber init deps --only=wails
190+```
191+
192+### Issue: CGO disabled for Wails
193+**Symptom**: Wails build fails with CGO error
194+**Solution**:
195+```bash
196+export CGO_ENABLED=1
197+go build ./cmd/dumber
198+```
199+
200+### Issue: Wrong SQLite driver
201+**Symptom**: CGO errors from mattn/go-sqlite3
202+**Verification**:
203+```bash
204+# Should show ncruces, not mattn
205+grep sqlite3 go.mod
206+```
207+
208+### Issue: Network timeout
209+**Symptom**: Dependency download timeouts
210+**Solution**:
211+```bash
212+go env -w GOPROXY=proxy.golang.org,direct
213+go env -w GOSUMDB=sum.golang.org
214+dumber init deps
215+```
216+
217+## Success Criteria
218+
219+After completing all steps, you should have:
220+
221+- ✅ Valid Go module with CGO-free SQLite driver (`ncruces/go-sqlite3`)
222+- ✅ All CLI, parser, database components buildable with `CGO_ENABLED=0`
223+- ✅ Full application buildable with `CGO_ENABLED=1` (for Wails)
224+- ✅ Project directory structure created per constitution
225+- ✅ Configuration files generated and valid
226+- ✅ All tools (sqlc, wails) available and functional
227+- ✅ Clean separation: CGO only needed for browser layer
228+
229+## Development Workflow Benefits
230+
231+This CGO strategy provides several advantages:
232+
233+1. **Fast development cycle**: Most development can happen CGO-free
234+2. **Better testing**: Database and CLI logic testable without CGO setup
235+3. **Cleaner builds**: Only final integration requires CGO
236+4. **Better performance**: `ncruces/go-sqlite3` is faster than `mattn/go-sqlite3`
237+
238+## Next Steps
239+
240+After successful initialization:
241+
242+1. **Start with CGO-free components**: CLI structure, URL parsing, database schema
243+2. **Test incrementally**: Each component works independently
244+3. **Integrate with Wails last**: Final step requires CGO for browser integration
245+4. **Development workflow**: Most work can be done with `CGO_ENABLED=0`
246+
247+The project is now ready for feature development with optimal CGO separation.⏎̸
A specs/001-init-project/research.md +176 -0
@@ -0,0 +1,176 @@
1+# Research: Project Initialization Dependencies
2+
3+**Date**: 2025-01-09
4+**Context**: Resolve technical unknowns for dumber project initialization
5+
6+## Research Questions Resolved
7+
8+### 1. Go Module Initialization
9+**Decision**: Use `go mod init dumber`
10+**Rationale**:
11+- Simple module name matching the binary name
12+- Local development focus (no public import path needed)
13+- Follows Go module naming conventions
14+
15+**Alternatives considered**:
16+- `github.com/user/dumber` - rejected (unnecessary complexity for local tool)
17+- `dumber/v1` - rejected (premature versioning)
18+
19+### 2. Wails v3-Alpha Compatibility
20+**Decision**: Use Wails v3.0.0-alpha.28 (latest available)
21+**Rationale**:
22+- Constitution specifically requires v3-alpha
23+- Alpha version provides newest WebKit2GTK integration
24+- Active development branch with Wayland improvements
25+- Breaking changes acceptable for new project
26+
27+**Alternatives considered**:
28+- Wails v2 stable - rejected (constitution mandates v3-alpha)
29+- Wait for v3 stable - rejected (delays development unnecessarily)
30+
31+**Integration approach**:
32+```bash
33+go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28
34+```
35+
36+### 3. CLI Framework Selection
37+**Decision**: Cobra v1.8+ with Viper v1.18+
38+**Rationale**:
39+- Constitution requirement: "Cobra + Viper for configuration"
40+- Mature, stable libraries with excellent Go ecosystem integration
41+- Perfect for dmenu/rofi integration via stdin/stdout
42+- Built-in flag parsing and configuration management
43+
44+**Key packages**:
45+- `github.com/spf13/cobra@latest`
46+- `github.com/spf13/viper@latest`
47+
48+### 4. Database Stack
49+**Decision**: SQLite with ncruces/go-sqlite3 (CGO-free) + SQLC v1.25+
50+**Rationale**:
51+- Constitution requirement: "SQLite + SQLC for type-safe SQL"
52+- `github.com/ncruces/go-sqlite3` is CGO-free (better performance, fewer build issues)
53+- Only Wails needs CGO, not database layer - cleaner separation
54+- SQLC generates type-safe Go code from SQL schemas
55+- Perfect for local history storage
56+
57+**Key packages**:
58+- `github.com/ncruces/go-sqlite3@latest` (CGO-free SQLite driver)
59+- Install SQLC CLI tool: `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`
60+
61+**Alternatives considered**:
62+- `github.com/mattn/go-sqlite3` - rejected (requires CGO unnecessarily)
63+- Pure Go implementations - rejected (ncruces is the modern standard)
64+
65+### 5. Input Validation
66+**Decision**: go-playground/validator v10.16+
67+**Rationale**:
68+- Constitution requirement
69+- Industry standard for Go struct validation
70+- URL validation capabilities for browser input
71+- Tag-based validation (clean code)
72+
73+**Key package**:
74+- `github.com/go-playground/validator/v10@latest`
75+
76+### 6. Testing Framework
77+**Decision**: Standard Go testing + testify v1.8+
78+**Rationale**:
79+- Standard library first approach (constitution: simplicity)
80+- testify for better assertions and test organization
81+- No need for heavyweight testing frameworks
82+
83+**Key package**:
84+- `github.com/stretchr/testify@latest`
85+
86+### 7. Project Directory Structure
87+**Decision**: Follow constitution implementation order
88+```
89+/
90+├── cmd/ # CLI entry point
91+├── internal/
92+│ ├── cli/ # Cobra commands
93+│ ├── db/ # SQLC generated code + migrations
94+│ ├── parser/ # URL parsing logic
95+│ └── browser/ # Wails integration
96+├── migrations/ # SQL schema files
97+├── configs/ # Configuration files (SQLC, Wails)
98+└── tests/ # Integration tests
99+```
100+
101+**Rationale**:
102+- `internal/` prevents external imports (Go convention)
103+- Matches constitution implementation order
104+- Separates concerns clearly
105+- Easy to test individual components
106+
107+### 8. Build Configuration
108+**Decision**: Standard Go build with Wails embedding
109+**Rationale**:
110+- Single binary requirement from constitution
111+- Wails handles frontend asset embedding
112+- Standard go build for CLI-only functionality
113+
114+## Dependency Installation Order
115+
116+Based on research, the installation sequence should be:
117+
118+1. **Initialize Go module**
119+ ```bash
120+ go mod init dumber
121+ ```
122+
123+2. **Install CLI dependencies**
124+ ```bash
125+ go get github.com/spf13/cobra@latest
126+ go get github.com/spf13/viper@latest
127+ ```
128+
129+3. **Install database dependencies**
130+ ```bash
131+ go get github.com/ncruces/go-sqlite3@latest
132+ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
133+ ```
134+
135+4. **Install validation**
136+ ```bash
137+ go get github.com/go-playground/validator/v10@latest
138+ ```
139+
140+5. **Install testing tools**
141+ ```bash
142+ go get github.com/stretchr/testify@latest
143+ ```
144+
145+6. **Install Wails (last - most complex)**
146+ ```bash
147+ go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28
148+ # Wails will handle its own module dependencies during init
149+ ```
150+
151+## Risk Assessment
152+
153+**Low Risk**:
154+- Standard Go dependencies (Cobra, Viper, SQLite)
155+- Well-established patterns
156+
157+**Medium Risk**:
158+- Wails v3-alpha stability (alpha software)
159+- WebKit2GTK system dependency availability
160+- CGO requirement for Wails (but not for database layer)
161+
162+**Mitigation**:
163+- Test Wails installation immediately after dependency setup
164+- Verify WebKit2GTK availability in integration tests
165+- Document CGO requirement clearly (only for final Wails build)
166+- Database layer can be developed/tested CGO-free independently
167+
168+## Next Phase Requirements
169+
170+All technical unknowns resolved. Phase 1 can proceed with:
171+- Data model design (SQLite schema)
172+- Contract definitions (CLI commands)
173+- Integration test scenarios
174+- Configuration file generation
175+
176+**Status**: ✅ Complete - All NEEDS CLARIFICATION items resolved⏎̸
A specs/001-init-project/spec.md +103 -0
@@ -0,0 +1,103 @@
1+# Feature Specification: Project Initialization with Dependencies
2+
3+**Feature Branch**: `001-init-project`
4+**Created**: 2025-01-09
5+**Status**: Draft
6+**Input**: User description: "Lets init the project adding all deps"
7+
8+## Execution Flow (main)
9+```
10+1. Parse user description from Input
11+ → "Initialize project with all required dependencies"
12+2. Extract key concepts from description
13+ → Actors: developer, build system
14+ → Actions: initialize, add dependencies, configure
15+ → Data: project structure, dependency management
16+ → Constraints: follow constitution requirements
17+3. For each unclear aspect:
18+ → No major ambiguities - project structure defined in constitution
19+4. Fill User Scenarios & Testing section
20+ → Clear flow: developer initializes → dependencies installed → project ready
21+5. Generate Functional Requirements
22+ → Each requirement testable through build/run verification
23+6. Identify Key Entities: Go modules, dependencies, configuration files
24+7. Run Review Checklist
25+ → No [NEEDS CLARIFICATION] markers needed
26+ → No implementation details beyond necessary project structure
27+8. Return: SUCCESS (spec ready for planning)
28+```
29+
30+---
31+
32+## ⚡ Quick Guidelines
33+- ✅ Focus on WHAT users need and WHY
34+- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
35+- 👥 Written for business stakeholders, not developers
36+
37+---
38+
39+## User Scenarios & Testing *(mandatory)*
40+
41+### Primary User Story
42+As a developer starting work on the dumber project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead.
43+
44+### Acceptance Scenarios
45+1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure
46+2. **Given** the initialized project, **When** checking dependencies, **Then** all required packages from the constitution are available
47+3. **Given** the initialized project, **When** running basic build commands, **Then** no dependency resolution errors occur
48+4. **Given** the project structure, **When** examining the layout, **Then** it follows the planned architecture (CLI, database, browser components)
49+
50+### Edge Cases
51+- What happens when Go version is incompatible with selected dependencies?
52+- How does system handle network failures during dependency installation?
53+- What occurs if required system packages (WebKit2GTK) are missing?
54+
55+## Requirements *(mandatory)*
56+
57+### Functional Requirements
58+- **FR-001**: System MUST initialize a valid Go module with appropriate module path
59+- **FR-002**: System MUST install CLI framework dependencies (Cobra, Viper)
60+- **FR-003**: System MUST install database dependencies (SQLite driver, SQLC tooling)
61+- **FR-004**: System MUST install validation dependencies (go-playground/validator)
62+- **FR-005**: System MUST install Wails v3-alpha framework for browser integration
63+- **FR-006**: System MUST create basic project directory structure aligned with planned architecture
64+- **FR-007**: System MUST configure SQLC for type-safe database code generation
65+- **FR-008**: System MUST verify all dependencies are compatible and buildable
66+- **FR-009**: System MUST create configuration files needed for the development workflow
67+
68+### Key Entities
69+- **Go Module**: The main project module with proper versioning and dependency management
70+- **Configuration Files**: SQLC config, Wails config, and other tool configurations
71+- **Project Structure**: Directory layout supporting CLI, database, and browser components
72+- **Dependencies**: External packages required per the constitution (Cobra, Viper, SQLite, SQLC, Wails, validator)
73+
74+---
75+
76+## Review & Acceptance Checklist
77+
78+### Content Quality
79+- [x] No implementation details (languages, frameworks, APIs)
80+- [x] Focused on user value and business needs
81+- [x] Written for non-technical stakeholders
82+- [x] All mandatory sections completed
83+
84+### Requirement Completeness
85+- [x] No [NEEDS CLARIFICATION] markers remain
86+- [x] Requirements are testable and unambiguous
87+- [x] Success criteria are measurable
88+- [x] Scope is clearly bounded
89+- [x] Dependencies and assumptions identified
90+
91+---
92+
93+## Execution Status
94+
95+- [x] User description parsed
96+- [x] Key concepts extracted
97+- [x] Ambiguities marked
98+- [x] User scenarios defined
99+- [x] Requirements generated
100+- [x] Entities identified
101+- [x] Review checklist passed
102+
103+---⏎̸
A specs/001-init-project/tasks.md +170 -0
@@ -0,0 +1,170 @@
1+# Tasks: Project Initialization with Dependencies
2+
3+**Input**: Design documents from `/home/brice/dev/projects/dumber/specs/001-init-project/`
4+**Prerequisites**: plan.md, research.md, data-model.md, contracts/
5+
6+## Execution Flow (main)
7+Project initialization requires setting up Go module, installing dependencies with `go get @latest`, creating directory structure, and verifying installation. Tasks follow TDD principles with contract tests before implementation.
8+
9+## Format: `[ID] [P?] Description`
10+- **[P]**: Can run in parallel (different files, no dependencies)
11+- Include exact file paths in descriptions
12+
13+## Path Conventions
14+Single project structure: `cmd/`, `internal/`, `tests/` at repository root per constitution
15+
16+## Phase 3.1: Setup and Structure
17+
18+- [ ] T001 Initialize Go module with `go mod init dumber` in project root
19+- [ ] T002 Create project directory structure: `cmd/dumber/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/`
20+- [ ] T003 [P] Configure Git repository with .gitignore for Go projects
21+
22+## Phase 3.2: Dependency Installation
23+
24+- [ ] T004 [P] Install CLI dependencies: `go get github.com/spf13/cobra@latest github.com/spf13/viper@latest`
25+- [ ] T005 [P] Install CGO-free database dependency: `go get github.com/ncruces/go-sqlite3@latest`
26+- [ ] T006 [P] Install validation dependency: `go get github.com/go-playground/validator/v10@latest`
27+- [ ] T007 [P] Install testing dependency: `go get github.com/stretchr/testify@latest`
28+- [ ] T008 Install SQLC tool: `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`
29+- [ ] T009 Install Wails v3-alpha tool: `go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28`
30+
31+## Phase 3.3: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.4
32+
33+**CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation**
34+
35+- [ ] T010 [P] Contract test for ModuleConfig validation in `tests/contract/test_module_config.go`
36+- [ ] T011 [P] Contract test for CLI init command in `tests/contract/test_cli_init.go`
37+- [ ] T012 [P] Contract test for dependency installation in `tests/contract/test_deps_install.go`
38+- [ ] T013 [P] Integration test for full project initialization in `tests/integration/test_full_init.go`
39+- [ ] T014 [P] Integration test for CGO-free database build in `tests/integration/test_cgo_free_build.go`
40+- [ ] T015 [P] Integration test for Wails build (CGO required) in `tests/integration/test_wails_build.go`
41+
42+## Phase 3.4: Core Implementation (ONLY after tests are failing)
43+
44+- [ ] T016 [P] ModuleConfig model in `internal/models/module.go`
45+- [ ] T017 [P] ProjectStructure model in `internal/models/structure.go`
46+- [ ] T018 [P] InstallationStatus model in `internal/models/status.go`
47+- [ ] T019 CLI root command setup in `cmd/dumber/main.go`
48+- [ ] T020 CLI init command implementation in `internal/cli/init.go`
49+- [ ] T021 Dependency installer service in `internal/services/installer.go`
50+- [ ] T022 Project structure creator in `internal/services/structure.go`
51+- [ ] T023 Configuration file generator in `internal/services/config.go`
52+
53+## Phase 3.5: Configuration Files
54+
55+- [ ] T024 [P] Generate SQLC configuration template in `configs/sqlc.yaml`
56+- [ ] T025 [P] Generate Wails configuration template in `configs/wails.json`
57+- [ ] T026 [P] Create initial database migration in `migrations/001_initial.sql`
58+
59+## Phase 3.6: Verification and Integration
60+
61+- [ ] T027 Build verification with CGO_ENABLED=0 for core components
62+- [ ] T028 Build verification with CGO_ENABLED=1 for Wails integration
63+- [ ] T029 Dependency resolution verification with `go mod download`
64+- [ ] T030 Tool availability verification: `sqlc version && wails version`
65+
66+## Phase 3.7: Polish and Documentation
67+
68+- [ ] T031 [P] Unit tests for validation logic in `tests/unit/test_validation.go`
69+- [ ] T032 [P] Unit tests for config generation in `tests/unit/test_config_gen.go`
70+- [ ] T033 [P] Error handling and logging implementation
71+- [ ] T034 Update quickstart.md with actual execution results
72+- [ ] T035 Run full quickstart validation script
73+
74+## Dependencies
75+
76+**Critical Path**:
77+- Setup (T001-T003) before dependencies (T004-T009)
78+- Dependencies before tests (T010-T015)
79+- Tests (T010-T015) before implementation (T016-T023)
80+- Core implementation before config files (T024-T026)
81+- Everything before verification (T027-T030)
82+
83+**Specific Blockers**:
84+- T001 blocks T004-T009 (need go.mod first)
85+- T016-T018 block T021-T023 (need models for services)
86+- T020 blocks T027-T030 (need CLI for verification)
87+
88+## Parallel Execution Examples
89+
90+### Install Dependencies Concurrently
91+```bash
92+# Launch T004-T007 together (different packages):
93+Task: "Install CLI dependencies: go get github.com/spf13/cobra@latest github.com/spf13/viper@latest"
94+Task: "Install CGO-free database dependency: go get github.com/ncruces/go-sqlite3@latest"
95+Task: "Install validation dependency: go get github.com/go-playground/validator/v10@latest"
96+Task: "Install testing dependency: go get github.com/stretchr/testify@latest"
97+```
98+
99+### Write Tests Concurrently
100+```bash
101+# Launch T010-T015 together (different test files):
102+Task: "Contract test for ModuleConfig validation in tests/contract/test_module_config.go"
103+Task: "Contract test for CLI init command in tests/contract/test_cli_init.go"
104+Task: "Contract test for dependency installation in tests/contract/test_deps_install.go"
105+Task: "Integration test for full project initialization in tests/integration/test_full_init.go"
106+Task: "Integration test for CGO-free database build in tests/integration/test_cgo_free_build.go"
107+Task: "Integration test for Wails build (CGO required) in tests/integration/test_wails_build.go"
108+```
109+
110+### Create Models Concurrently
111+```bash
112+# Launch T016-T018 together (different model files):
113+Task: "ModuleConfig model in internal/models/module.go"
114+Task: "ProjectStructure model in internal/models/structure.go"
115+Task: "InstallationStatus model in internal/models/status.go"
116+```
117+
118+## CGO Optimization Strategy
119+
120+**CGO-Free Development** (T004-T007, T010-T014, T016-T023):
121+- Most tasks can run with `CGO_ENABLED=0`
122+- Database layer uses ncruces/go-sqlite3 (CGO-free)
123+- CLI and parser components don't need CGO
124+- Faster development cycle for majority of work
125+
126+**CGO Required** (T009, T015, T028):
127+- Only Wails installation and integration requires `CGO_ENABLED=1`
128+- WebKit2GTK bindings need CGO for native rendering
129+- Clear separation allows independent development
130+
131+## Notes
132+
133+- [P] tasks = different files, no dependencies
134+- Verify tests fail before implementing (TDD enforcement)
135+- Use `go get @latest` for all dependencies per user requirements
136+- Commit after each task completion
137+- CGO-free SQLite driver (ncruces) allows faster development cycle
138+- Only Wails final integration requires CGO
139+
140+## Task Generation Rules Applied
141+
142+1. **From Contracts**:
143+ - cli-commands.md → T011, T020 (CLI init command)
144+ - go-module.yaml → T010, T012 (module and dependency contracts)
145+
146+2. **From Data Model**:
147+ - ModuleConfig → T016 (model creation)
148+ - ProjectStructure → T017 (model creation)
149+ - InstallationStatus → T018 (model creation)
150+
151+3. **From Research Decisions**:
152+ - CGO-free SQLite → T005, T014 (specific driver selection)
153+ - Wails v3-alpha → T009, T015 (version-specific installation)
154+
155+4. **From Quickstart Scenarios**:
156+ - Full initialization → T013 (integration test)
157+ - Build verification → T027, T028 (CGO separation tests)
158+ - Tool availability → T030 (verification scenario)
159+
160+## Validation Checklist
161+
162+- [x] All contracts have corresponding tests (T010-T012)
163+- [x] All entities have model tasks (T016-T018)
164+- [x] All tests come before implementation (T010-T015 before T016-T023)
165+- [x] Parallel tasks truly independent ([P] tasks use different files)
166+- [x] Each task specifies exact file path
167+- [x] No task modifies same file as another [P] task
168+- [x] TDD principles enforced (tests must fail before implementation)
169+- [x] CGO optimization strategy clearly defined
170+- [x] Dependencies properly ordered and documented⏎̸
A specs/002-ui-enhancement-polishing/spec.md +135 -0
@@ -0,0 +1,135 @@
1+# Feature Specification: UI Enhancement & Polishing + Browser Identity
2+
3+**Feature Branch**: `002-ui-enhancement-polishing`
4+**Created**: 2025-09-09
5+**Status**: Draft
6+**Input**: User description: "Polish UI, add browser identity/user agent, enhance performance"
7+
8+## Execution Flow (main)
9+```
10+1. Parse user description from Input
11+ → "Enhance UI/UX, create browser identity, optimize performance"
12+2. Extract key concepts from description
13+ → Actors: users, website analytics, system performance
14+ → Actions: polish, identify, optimize, enhance
15+ → Data: user agent strings, UI components, performance metrics
16+ → Constraints: maintain compatibility, respect privacy
17+3. For each unclear aspect:
18+ → No major ambiguities - build on existing foundation
19+4. Fill User Scenarios & Testing section
20+ → Clear flow: user experiences polished interface and proper browser identification
21+5. Generate Functional Requirements
22+ → Each requirement testable through UI/performance validation
23+6. Identify Key Entities: User agent, UI components, performance metrics, browser identity
24+7. Run Review Checklist
25+ → No [NEEDS CLARIFICATION] markers needed
26+ → Focus on polish and optimization over new features
27+8. Return: SUCCESS (spec ready for planning)
28+```
29+
30+---
31+
32+## ⚡ Quick Guidelines
33+- ✅ Focus on WHAT users need and WHY
34+- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
35+- 👥 Written for business stakeholders, not developers
36+
37+---
38+
39+## User Scenarios & Testing *(mandatory)*
40+
41+### Primary User Story
42+As a Dumber Browser user, I need a polished, fast, and properly identified browser experience so that websites recognize my browser correctly, the interface feels professional, and performance meets my daily usage expectations without friction.
43+
44+### Acceptance Scenarios
45+
46+#### Browser Identity & Recognition
47+1. **Given** I visit a website with analytics, **When** I browse normally, **Then** the site logs show "Dumber Browser" in user agent statistics
48+2. **Given** a website checks browser compatibility, **When** I access it, **Then** the site recognizes WebKit compatibility and loads properly
49+3. **Given** I want privacy control, **When** I configure user agent settings, **Then** I can choose between branded identity and anonymous browsing
50+
51+#### UI Enhancement & Polish
52+4. **Given** I launch the landing page, **When** it loads, **Then** the interface appears within 500ms with smooth transitions
53+5. **Given** I view my browsing history, **When** scanning entries, **Then** URLs and titles are clearly readable with proper formatting and visual hierarchy
54+6. **Given** I use the application on different screen sizes, **When** resizing, **Then** the layout adapts gracefully without breaking
55+7. **Given** I interact with shortcuts, **When** hovering or clicking, **Then** visual feedback is immediate and intuitive
56+
57+#### Performance & Optimization
58+8. **Given** I start the application, **When** timing startup, **Then** launch completes in under 500ms
59+9. **Given** I monitor system resources, **When** running normally, **Then** memory usage stays below 100MB baseline
60+10. **Given** I switch between CLI and GUI modes, **When** testing transitions, **Then** mode switching is seamless without delays
61+
62+### Edge Cases
63+- What happens when user agent is blocked by websites?
64+- How does the UI behave with extremely long URLs or titles?
65+- What occurs when system resources are constrained?
66+- How does the interface handle empty states (no history, no shortcuts)?
67+
68+## Requirements *(mandatory)*
69+
70+### Functional Requirements
71+
72+#### Browser Identity
73+- **FR-001**: System MUST provide a custom Dumber Browser user agent string
74+- **FR-002**: System MUST include version information in user agent for debugging
75+- **FR-003**: System MUST maintain WebKit compatibility strings for site compatibility
76+- **FR-004**: System MUST allow user agent customization per-site if needed
77+- **FR-005**: System MUST provide privacy mode with anonymous/generic user agent
78+
79+#### UI Enhancement
80+- **FR-006**: System MUST display loading states clearly during data fetching
81+- **FR-007**: System MUST provide visual feedback for all interactive elements
82+- **FR-008**: System MUST format URLs and titles for optimal readability
83+- **FR-009**: System MUST implement responsive design for various screen sizes
84+- **FR-010**: System MUST show empty states gracefully when no data exists
85+- **FR-011**: System MUST provide smooth transitions between different views
86+
87+#### Performance Optimization
88+- **FR-012**: System MUST start within 500ms from launch command
89+- **FR-013**: System MUST maintain memory usage below 100MB during normal operation
90+- **FR-014**: System MUST load landing page data within 200ms
91+- **FR-015**: System MUST respond to user interactions within 50ms
92+
93+#### Integration Polish
94+- **FR-016**: System MUST provide clear error messages with actionable guidance
95+- **FR-017**: System MUST handle CLI-to-GUI transitions seamlessly
96+- **FR-018**: System MUST format dmenu output for optimal launcher integration
97+- **FR-019**: System MUST maintain consistent visual design language throughout
98+
99+### Key Entities
100+- **Browser Identity**: User agent string, version info, compatibility data
101+- **UI Components**: Landing page, history list, shortcuts grid, loading states
102+- **Performance Metrics**: Startup time, memory usage, response times
103+- **Visual Design**: Color scheme, typography, spacing, transitions
104+- **User Experience**: Interactions, feedback, error states, accessibility
105+
106+---
107+
108+## Review & Acceptance Checklist
109+
110+### Content Quality
111+- [x] No implementation details (languages, frameworks, APIs)
112+- [x] Focused on user value and business needs
113+- [x] Written for non-technical stakeholders
114+- [x] All mandatory sections completed
115+
116+### Requirement Completeness
117+- [x] No [NEEDS CLARIFICATION] markers remain
118+- [x] Requirements are testable and unambiguous
119+- [x] Success criteria are measurable
120+- [x] Scope is clearly bounded (polish existing, not new features)
121+- [x] Dependencies and assumptions identified
122+
123+---
124+
125+## Execution Status
126+
127+- [x] User description parsed
128+- [x] Key concepts extracted
129+- [x] Ambiguities marked (none found)
130+- [x] User scenarios defined
131+- [x] Requirements generated
132+- [x] Entities identified
133+- [x] Review checklist passed
134+
135+---⏎̸
A sqlc.yaml +15 -0
@@ -0,0 +1,15 @@
1+version: "2"
2+sql:
3+ - engine: "sqlite"
4+ queries: "./internal/db/queries/"
5+ schema: "./migrations/"
6+ gen:
7+ go:
8+ package: "db"
9+ out: "./internal/db"
10+ sql_package: "database/sql"
11+ emit_json_tags: true
12+ emit_empty_slices: true
13+ emit_exported_queries: true
14+ emit_interface: true
15+ query_parameter_limit: 5⏎̸
A test-wails +0 -0 binary file: no text diff

binary file: no text diff

A wails.json +24 -0
@@ -0,0 +1,24 @@
1+{
2+ "name": "dumber",
3+ "displayname": "Dumber Browser",
4+ "description": "A smart URL launcher and browser with learning behavior",
5+ "version": "0.1.0",
6+ "author": "dumber",
7+ "license": "MIT",
8+ "build": {
9+ "dir": ".",
10+ "main": "main.go",
11+ "binary": "dumber",
12+ "output": "dist"
13+ },
14+ "frontend": {
15+ "dir": "frontend",
16+ "dist": "dist"
17+ },
18+ "flags": {
19+ "linux": [
20+ "-s",
21+ "-w"
22+ ]
23+ }
24+}⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close