diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 708dbcc..54d1515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,12 @@ jobs: run: | scripts/generate-flatpak-go-modules.py git diff --exit-code -- flatpak/go-modules.json - - name: Run tests with coverage - run: just test-coverage + - name: Test routing package with coverage + run: just test-routing-coverage + - name: Test command config compatibility + run: just test-config + - name: Test command sanitizer + run: just test-sanitizer - name: Display coverage summary run: | echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY @@ -73,7 +77,7 @@ jobs: name: "Build Flatpak" runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-49 + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 options: --privileged steps: - uses: actions/checkout@v7 diff --git a/cmd/switchyard/config.go b/cmd/switchyard/config.go index 2ffb40f..6a17ec0 100644 --- a/cmd/switchyard/config.go +++ b/cmd/switchyard/config.go @@ -9,42 +9,14 @@ import ( "path/filepath" "strings" + "github.com/alyraffauf/switchyard/internal/routing" "github.com/pelletier/go-toml/v2" ) -type Config struct { - PromptOnClick bool `toml:"prompt_on_click"` - FavoriteBrowser string `toml:"favorite_browser"` - HiddenBrowsers []string `toml:"hidden_browsers"` - CheckDefaultBrowser bool `toml:"check_default_browser"` - ShowAppNames bool `toml:"show_app_names"` - ForceDarkMode bool `toml:"force_dark_mode"` - StayAlive bool `toml:"stay_alive"` - RemoveTrackingParameters bool `toml:"remove_tracking_parameters"` - Redirections []Redirection `toml:"redirections,omitempty"` - Rules []Rule `toml:"rules"` -} - -type Redirection struct { - Name string `toml:"name,omitempty"` - Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" - Find string `toml:"find"` - Replace string `toml:"replace"` -} - -type Condition struct { - Type string `toml:"type"` // "domain", "keyword", "glob", "regex" - Pattern string `toml:"pattern"` - Negate bool `toml:"negate,omitempty"` -} - -type Rule struct { - Name string `toml:"name"` - Conditions []Condition `toml:"conditions"` - Logic string `toml:"logic,omitempty"` // "all" or "any" - Browser string `toml:"browser"` - AlwaysAsk bool `toml:"always_ask"` -} +type Config = routing.Config +type Redirection = routing.Redirection +type Condition = routing.Condition +type Rule = routing.Rule func configDir() string { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { @@ -59,15 +31,7 @@ func configPath() string { } func newDefaultConfig() *Config { - return &Config{ - PromptOnClick: true, - CheckDefaultBrowser: true, - ShowAppNames: false, // Default: hide app names, show tooltips - ForceDarkMode: true, // Default: force dark mode - StayAlive: true, - RemoveTrackingParameters: false, - Rules: []Rule{}, - } + return routing.NewDefaultConfig() } func loadConfig() *Config { @@ -114,44 +78,6 @@ func saveConfig(cfg *Config) error { return os.WriteFile(configPath(), data, 0644) } -func (cfg *Config) matchRule(url string) (browserID string, alwaysAsk bool, matched bool) { - for _, rule := range cfg.Rules { - if rule.matchesConditions(url) { - return rule.Browser, rule.AlwaysAsk, true - } - } - return "", false, false -} - -func (r *Rule) matchesConditions(url string) bool { - if len(r.Conditions) == 0 { - return false - } - - logic := r.Logic - if logic == "" { - logic = "all" // Default to AND logic - } - - if logic == "all" { - // AND: All conditions must match - for _, cond := range r.Conditions { - if !matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { - return false - } - } - return true - } else { - // OR: Any condition must match - for _, cond := range r.Conditions { - if matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { - return true - } - } - return false - } -} - // hostCommand runs commands on the host when Switchyard is sandboxed by Flatpak. func hostCommand(name string, args ...string) *exec.Cmd { if os.Getenv("FLATPAK_ID") != "" { diff --git a/cmd/switchyard/dialog_helpers.go b/cmd/switchyard/dialog_helpers.go index 04eb147..2dd0b6a 100644 --- a/cmd/switchyard/dialog_helpers.go +++ b/cmd/switchyard/dialog_helpers.go @@ -17,19 +17,17 @@ func dialogHeader(cancelLabel, actionLabel string, onCancel, onAction func()) (* cancelBtn.ConnectClicked(func() { onCancel() }) header.PackStart(cancelBtn) - actionBtn := gtk.NewButton() - actionBtn.SetLabel(actionLabel) - actionBtn.AddCSSClass("suggested-action") + actionButton := gtk.NewButton() + actionButton.SetLabel(actionLabel) + actionButton.AddCSSClass("suggested-action") if onAction != nil { - actionBtn.ConnectClicked(func() { onAction() }) + actionButton.ConnectClicked(func() { onAction() }) } - header.PackEnd(actionBtn) + header.PackEnd(actionButton) - return header, actionBtn + return header, actionButton } -// dialogWithToolbar creates a dialog with a standard toolbar and scrolled content area. -// Returns the dialog and a content box where widgets can be added. func dialogWithToolbar(title string, width, height int, header *adw.HeaderBar) (*adw.Dialog, *gtk.Box, *gtk.ScrolledWindow) { dialog := adw.NewDialog() dialog.SetTitle(title) @@ -55,7 +53,6 @@ func dialogWithToolbar(title string, width, height int, header *adw.HeaderBar) ( return dialog, content, scrolledWindow } -// simpleDialogWithToolbar creates a dialog without a scrolled window (for simple, small dialogs). func simpleDialogWithToolbar(title string, width, height int, header *adw.HeaderBar) (*adw.Dialog, *gtk.Box) { dialog := adw.NewDialog() dialog.SetTitle(title) @@ -78,9 +75,8 @@ func simpleDialogWithToolbar(title string, width, height int, header *adw.Header return dialog, content } -// converts condition type string to combo row index. -func conditionTypeToIndex(condType string) uint { - switch condType { +func conditionTypeToIndex(conditionType string) uint { + switch conditionType { case "domain": return 0 case "keyword": @@ -94,7 +90,6 @@ func conditionTypeToIndex(condType string) uint { } } -// convert a combo row index to condition type string. func indexToConditionType(index uint) string { switch index { case 0: @@ -110,14 +105,14 @@ func indexToConditionType(index uint) string { } } -func redirectionTypeToIndex(rwType string) uint { - switch rwType { +func redirectionTypeToIndex(redirectionType string) uint { + switch redirectionType { case "wildcard": return 1 case "regex": return 2 default: - return 0 // "domain" is default + return 0 } } diff --git a/cmd/switchyard/dialog_redirection.go b/cmd/switchyard/dialog_redirection.go index 7068ded..6f60228 100644 --- a/cmd/switchyard/dialog_redirection.go +++ b/cmd/switchyard/dialog_redirection.go @@ -3,6 +3,7 @@ package main import ( + "github.com/alyraffauf/switchyard/internal/routing" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -31,7 +32,6 @@ func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirec header, saveBtn := dialogHeader("Cancel", actionLabel, func() { dialog.Close() }, nil) dialog, content, _ := dialogWithToolbar(title, 450, 450, header) - // Name section nameGroup := adw.NewPreferencesGroup() nameGroup.SetTitle("Name") nameGroup.SetDescription("Give this redirection a descriptive name (optional)") @@ -44,7 +44,6 @@ func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirec nameGroup.Add(nameRow) content.Append(nameGroup) - // Redirection section group := adw.NewPreferencesGroup() group.SetTitle("Redirection") group.SetDescription("Define how links are modified") @@ -75,9 +74,9 @@ func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirec validateInputs := func() { find := findRow.Text() - rwType := indexToRedirectionType(typeRow.Selected()) - r := Redirection{Type: rwType, Find: find, Replace: replaceRow.Text()} - err := validateRedirection(r) + redirectionType := indexToRedirectionType(typeRow.Selected()) + redirection := Redirection{Type: redirectionType, Find: find, Replace: replaceRow.Text()} + err := routing.ValidateRedirection(redirection) if find != "" && err != nil { findRow.AddCSSClass("error") @@ -96,18 +95,18 @@ func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirec saveBtn.ConnectClicked(func() { name := nameRow.Text() find := findRow.Text() - rwType := indexToRedirectionType(typeRow.Selected()) + redirectionType := indexToRedirectionType(typeRow.Selected()) if isNew { cfg.Redirections = append(cfg.Redirections, Redirection{ Name: name, - Type: rwType, + Type: redirectionType, Find: find, Replace: replaceRow.Text(), }) } else { redirection.Name = name - redirection.Type = rwType + redirection.Type = redirectionType redirection.Find = find redirection.Replace = replaceRow.Text() } diff --git a/cmd/switchyard/dialog_rule_add.go b/cmd/switchyard/dialog_rule_add.go index 113b7c6..87457e6 100644 --- a/cmd/switchyard/dialog_rule_add.go +++ b/cmd/switchyard/dialog_rule_add.go @@ -3,6 +3,7 @@ package main import ( + "github.com/alyraffauf/switchyard/internal/routing" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -23,7 +24,7 @@ func showAddRuleDialog(parent *adw.Window, cfg *Config, browsers []*Browser, reb browserIdx := browserRow.Selected() if len(*conditions) > 0 && int(browserIdx) < len(browsers) { - if !validateConditions(*conditions) { + if !routing.AreAllConditionsValid(*conditions) { return } diff --git a/cmd/switchyard/dialog_rule_common.go b/cmd/switchyard/dialog_rule_common.go index 363c7ab..2feffd0 100644 --- a/cmd/switchyard/dialog_rule_common.go +++ b/cmd/switchyard/dialog_rule_common.go @@ -3,15 +3,15 @@ package main import ( + "github.com/alyraffauf/switchyard/internal/routing" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) -// buildRuleDialogContent creates the shared content for add/edit rule dialogs func buildRuleDialogContent( initialRule *Rule, browsers []*Browser, - actionBtn *gtk.Button, + actionButton *gtk.Button, ) ( nameEntry *adw.EntryRow, conditions *[]Condition, @@ -26,7 +26,6 @@ func buildRuleDialogContent( content.SetMarginTop(18) content.SetMarginBottom(18) - // Name section nameGroup := adw.NewPreferencesGroup() nameGroup.SetTitle("Rule Name") nameGroup.SetDescription("Give this rule a descriptive name (optional)") @@ -39,7 +38,6 @@ func buildRuleDialogContent( nameGroup.Add(nameEntry) content.Append(nameGroup) - // Initialize conditions var conditionsSlice []Condition if initialRule != nil && len(initialRule.Conditions) > 0 { conditionsSlice = make([]Condition, len(initialRule.Conditions)) @@ -49,14 +47,12 @@ func buildRuleDialogContent( } conditions = &conditionsSlice - // Conditions section conditionsGroup := adw.NewPreferencesGroup() conditionsGroup.SetTitle("Conditions") conditionsGroup.SetDescription("Define conditions to match URLs") conditionsListBox := createBoxedListBox() - // Logic selector row logicRow = adw.NewComboRow() logicRow.SetTitle("Match Logic") logicRow.SetModel(gtk.NewStringList([]string{"All conditions", "Any condition"})) @@ -72,32 +68,27 @@ func buildRuleDialogContent( var rebuildConditions func() rebuildConditions = func() { - // Clear existing condition rows for _, row := range conditionRows { conditionsListBox.Remove(row) } conditionRows = nil - // Clear the "Add Condition" button if it exists - // Avoids losing track and having multiple buttons unexpectedly if addConditionRow != nil { conditionsListBox.Remove(addConditionRow) } - // Build condition rows for i := range *conditions { - condIdx := i + conditionIndex := i row := createConditionRow( conditions, - condIdx, - actionBtn, + conditionIndex, + actionButton, rebuildConditions, ) conditionsListBox.Append(row) conditionRows = append(conditionRows, row) } - // Add "Add Condition" row at the end of the list addConditionRow = adw.NewActionRow() addConditionRow.SetTitle("Add Condition") addConditionRow.AddPrefix(gtk.NewImageFromIconName("list-add-symbolic")) @@ -108,15 +99,13 @@ func buildRuleDialogContent( }) conditionsListBox.Append(addConditionRow) - // Update action button state - actionBtn.SetSensitive(areAllConditionsValid(*conditions)) + actionButton.SetSensitive(routing.AreAllConditionsValid(*conditions)) } rebuildConditions() conditionsGroup.Add(conditionsListBox) content.Append(conditionsGroup) - // Action section actionGroup := adw.NewPreferencesGroup() actionGroup.SetTitle("Browser Action") actionGroup.SetDescription("Select which browser opens matching URLs") @@ -129,26 +118,24 @@ func buildRuleDialogContent( } actionGroup.Add(alwaysAskRow) - // Browser dropdown browserNames := make([]string, len(browsers)) - selectedIdx := uint(0) - for i, b := range browsers { - browserNames[i] = b.Name - if initialRule != nil && b.ID == initialRule.Browser { - selectedIdx = uint(i) + selectedIndex := uint(0) + for i, browser := range browsers { + browserNames[i] = browser.Name + if initialRule != nil && browser.ID == initialRule.Browser { + selectedIndex = uint(i) } } browserRow = adw.NewComboRow() browserRow.SetTitle("Browser") browserRow.SetModel(gtk.NewStringList(browserNames)) - browserRow.SetSelected(selectedIdx) + browserRow.SetSelected(selectedIndex) if initialRule != nil { browserRow.SetSensitive(!initialRule.AlwaysAsk) } actionGroup.Add(browserRow) - // Link always ask toggle to browser row sensitivity alwaysAskRow.Connect("notify::active", func() { browserRow.SetSensitive(!alwaysAskRow.Active()) }) @@ -158,11 +145,10 @@ func buildRuleDialogContent( return } -// createConditionRow creates a single condition editing row with all controls func createConditionRow( conditions *[]Condition, - condIdx int, - actionBtn *gtk.Button, + conditionIndex int, + actionButton *gtk.Button, rebuildConditions func(), ) *gtk.ListBoxRow { conditionRow := gtk.NewListBoxRow() @@ -175,88 +161,82 @@ func createConditionRow( conditionContainer.SetMarginStart(12) conditionContainer.SetMarginEnd(12) - // Match type dropdown typeDropdown := gtk.NewDropDown( gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"}), nil, ) - typeDropdown.SetSelected(conditionTypeToIndex((*conditions)[condIdx].Type)) + typeDropdown.SetSelected(conditionTypeToIndex((*conditions)[conditionIndex].Type)) typeDropdown.SetVAlign(gtk.AlignCenter) - typeDropdown.SetSizeRequest(150, -1) // Fixed width for consistent alignment + typeDropdown.SetSizeRequest(150, -1) conditionContainer.Append(typeDropdown) - // Negate dropdown (is / is not) negateDropdown := gtk.NewDropDown( gtk.NewStringList([]string{"is", "is not"}), nil, ) - if (*conditions)[condIdx].Negate { + if (*conditions)[conditionIndex].Negate { negateDropdown.SetSelected(1) } else { negateDropdown.SetSelected(0) } negateDropdown.SetVAlign(gtk.AlignCenter) negateDropdown.Connect("notify::selected", func() { - (*conditions)[condIdx].Negate = negateDropdown.Selected() == 1 + (*conditions)[conditionIndex].Negate = negateDropdown.Selected() == 1 }) conditionContainer.Append(negateDropdown) - // Pattern entry patternEntry := gtk.NewEntry() - patternEntry.SetText((*conditions)[condIdx].Pattern) + patternEntry.SetText((*conditions)[conditionIndex].Pattern) patternEntry.SetHExpand(true) patternEntry.SetPlaceholderText("Pattern") conditionContainer.Append(patternEntry) - // Connect handlers typeDropdown.Connect("notify::selected", func() { - (*conditions)[condIdx].Type = indexToConditionType(typeDropdown.Selected()) - validateConditionEntry(conditions, condIdx, typeDropdown, patternEntry, actionBtn) + (*conditions)[conditionIndex].Type = indexToConditionType(typeDropdown.Selected()) + validateConditionEntry(conditions, conditionIndex, typeDropdown, patternEntry, actionButton) }) patternEntry.Connect("changed", func() { - (*conditions)[condIdx].Pattern = patternEntry.Text() - validateConditionEntry(conditions, condIdx, typeDropdown, patternEntry, actionBtn) + (*conditions)[conditionIndex].Pattern = patternEntry.Text() + validateConditionEntry(conditions, conditionIndex, typeDropdown, patternEntry, actionButton) }) - // Delete button - deleteBtn := gtk.NewButton() - deleteBtn.SetIconName("edit-delete-symbolic") - deleteBtn.SetTooltipText("Delete this condition") - deleteBtn.AddCSSClass("flat") - deleteBtn.AddCSSClass("circular") - deleteBtn.AddCSSClass("destructive-action") - deleteBtn.SetVAlign(gtk.AlignCenter) - deleteBtn.SetSensitive(len(*conditions) > 1) - deleteBtn.ConnectClicked(func() { - if len(*conditions) > 1 && condIdx < len(*conditions) { - *conditions = append((*conditions)[:condIdx], (*conditions)[condIdx+1:]...) + deleteButton := gtk.NewButton() + deleteButton.SetIconName("edit-delete-symbolic") + deleteButton.SetTooltipText("Delete this condition") + deleteButton.AddCSSClass("flat") + deleteButton.AddCSSClass("circular") + deleteButton.AddCSSClass("destructive-action") + deleteButton.SetVAlign(gtk.AlignCenter) + deleteButton.SetSensitive(len(*conditions) > 1) + deleteButton.ConnectClicked(func() { + if len(*conditions) > 1 && conditionIndex < len(*conditions) { + *conditions = append((*conditions)[:conditionIndex], (*conditions)[conditionIndex+1:]...) rebuildConditions() } }) - conditionContainer.Append(deleteBtn) + conditionContainer.Append(deleteButton) conditionRow.SetChild(conditionContainer) return conditionRow } -// validateConditionEntry validates a pattern and updates UI accordingly func validateConditionEntry( conditions *[]Condition, - condIdx int, + conditionIndex int, typeDropdown *gtk.DropDown, patternEntry *gtk.Entry, - actionBtn *gtk.Button, + actionButton *gtk.Button, ) { pattern := patternEntry.Text() - condType := indexToConditionType(typeDropdown.Selected()) + conditionType := indexToConditionType(typeDropdown.Selected()) - err := validateConditionPattern(condType, pattern) + err := routing.ValidateConditionPattern(conditionType, pattern) if err != nil { patternEntry.AddCSSClass("error") } else { patternEntry.RemoveCSSClass("error") } - actionBtn.SetSensitive(areAllConditionsValid(*conditions)) + actionButton.SetSensitive(routing.AreAllConditionsValid(*conditions)) } diff --git a/cmd/switchyard/dialog_rule_edit.go b/cmd/switchyard/dialog_rule_edit.go index 3ce2ec2..9ecd1ff 100644 --- a/cmd/switchyard/dialog_rule_edit.go +++ b/cmd/switchyard/dialog_rule_edit.go @@ -3,12 +3,12 @@ package main import ( + "github.com/alyraffauf/switchyard/internal/routing" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) func showEditRuleDialog(parent *adw.Window, cfg *Config, rule *Rule, browsers []*Browser, rebuildRulesList func()) { - // Ensure rules have at least one condition if len(rule.Conditions) == 0 { rule.Conditions = []Condition{{ Type: "domain", @@ -30,11 +30,10 @@ func showEditRuleDialog(parent *adw.Window, cfg *Config, rule *Rule, browsers [] browserIdx := browserRow.Selected() if len(*conditions) > 0 && int(browserIdx) < len(browsers) { - if !validateConditions(*conditions) { + if !routing.AreAllConditionsValid(*conditions) { return } - // Update rule rule.Name = nameEntry.Text() rule.Conditions = *conditions rule.Logic = getLogicFromComboRow(logicRow) diff --git a/cmd/switchyard/main.go b/cmd/switchyard/main.go index 53d9b5d..b59e132 100644 --- a/cmd/switchyard/main.go +++ b/cmd/switchyard/main.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/alyraffauf/switchyard/internal/routing" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gio/v2" @@ -98,7 +99,7 @@ func setupApp(cfg *Config) { // handleSwitchyardURL processes switchyard:// URLs with browser preferences func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) { - targetURL, browserPrefs, err := parseSwitchyardURL(rawURL) + targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) if err != nil { // Invalid switchyard URL - ignore return @@ -135,7 +136,7 @@ func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, } func prepareURLForRouting(rawURL string, cfg *Config) string { - sanitized := sanitizeURL(rawURL) + sanitized := routing.SanitizeURL(rawURL) if sanitized == "" { return "" } @@ -145,7 +146,7 @@ func prepareURLForRouting(rawURL string, cfg *Config) string { } if len(cfg.Redirections) > 0 { - sanitized = applyRedirections(sanitized, cfg.Redirections) + sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections) } return sanitized @@ -154,7 +155,7 @@ func prepareURLForRouting(rawURL string, cfg *Config) string { // handleURL routes a URL to the appropriate browser based on rules func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) { // Try to match a rule - browserID, alwaysAsk, matched := cfg.matchRule(urlStr) + browserID, alwaysAsk, matched := cfg.MatchRule(urlStr) if matched { // Check if rule has AlwaysAsk enabled if alwaysAsk { diff --git a/cmd/switchyard/redirection.go b/cmd/switchyard/redirection.go deleted file mode 100644 index af18f90..0000000 --- a/cmd/switchyard/redirection.go +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package main - -import ( - "net/url" - "regexp" - "strings" -) - -func applyRedirections(rawURL string, redirections []Redirection) string { - for _, r := range redirections { - rawURL = applyRedirection(rawURL, r) - } - return rawURL -} - -func applyRedirection(rawURL string, r Redirection) string { - rwType := r.Type - if rwType == "" { - rwType = "domain" - } - - switch rwType { - case "domain": - return applyDomainRedirection(rawURL, r) - case "wildcard": - return applyWildcardRedirection(rawURL, r) - case "regex": - return applyRegexRedirection(rawURL, r) - default: - return rawURL - } -} - -func applyDomainRedirection(rawURL string, r Redirection) string { - u, err := url.Parse(rawURL) - if err != nil { - return rawURL - } - - if !strings.EqualFold(u.Hostname(), r.Find) { - return rawURL - } - - u.Host = strings.Replace(u.Host, u.Hostname(), r.Replace, 1) - return u.String() -} - -func applyWildcardRedirection(rawURL string, r Redirection) string { - pattern := wildcardToRegex(r.Find) - re, ok := getCompiledRegex("(?i)" + pattern) // case-insensitive - if !ok { - return rawURL - } - return re.ReplaceAllString(rawURL, r.Replace) -} - -func applyRegexRedirection(rawURL string, r Redirection) string { - re, ok := getCompiledRegex(r.Find) - if !ok { - return rawURL - } - return re.ReplaceAllString(rawURL, r.Replace) -} - -func wildcardToRegex(pattern string) string { - // Escape regex special chars except * - escaped := regexp.QuoteMeta(pattern) - // Convert \* back to .* - return strings.ReplaceAll(escaped, `\*`, `.*`) -} diff --git a/cmd/switchyard/settings_redirections.go b/cmd/switchyard/settings_redirections.go index e85d3c6..00a0fa2 100644 --- a/cmd/switchyard/settings_redirections.go +++ b/cmd/switchyard/settings_redirections.go @@ -10,14 +10,14 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" ) -func formatRedirectionSubtitle(r *Redirection) string { - rwType := r.Type - if rwType == "" { - rwType = "domain" +func formatRedirectionSubtitle(redirection *Redirection) string { + redirectionType := redirection.Type + if redirectionType == "" { + redirectionType = "domain" } var typeLabel string - switch rwType { + switch redirectionType { case "domain": typeLabel = "Domain" case "wildcard": @@ -28,10 +28,10 @@ func formatRedirectionSubtitle(r *Redirection) string { typeLabel = "Domain" } - if r.Replace == "" { + if redirection.Replace == "" { return fmt.Sprintf("%s · Removes match", typeLabel) } - return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(r.Replace)) + return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(redirection.Replace)) } func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { @@ -43,7 +43,6 @@ func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { titleLabel.AddCSSClass("title") header.SetTitleWidget(titleLabel) - // Add button in header addButton := gtk.NewButton() addButton.SetIconName("list-add-symbolic") addButton.SetTooltipText("Add Redirection") @@ -60,7 +59,6 @@ func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { content.SetMarginTop(12) content.SetMarginBottom(12) - // Info banner infoLabel := gtk.NewLabel("Redirections modify links before rules are applied.") infoLabel.SetWrap(true) infoLabel.SetXAlign(0) @@ -95,50 +93,48 @@ func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) reorderBox.SetVAlign(gtk.AlignCenter) - upBtn := gtk.NewButton() - upBtn.SetIconName("go-up-symbolic") - upBtn.AddCSSClass("flat") - upBtn.SetSensitive(redirectionIndex > 0) - upBtn.SetTooltipText("Move up") - upBtn.ConnectClicked(func() { + upButton := gtk.NewButton() + upButton.SetIconName("go-up-symbolic") + upButton.AddCSSClass("flat") + upButton.SetSensitive(redirectionIndex > 0) + upButton.SetTooltipText("Move up") + upButton.ConnectClicked(func() { if redirectionIndex > 0 { cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex-1] = cfg.Redirections[redirectionIndex-1], cfg.Redirections[redirectionIndex] saveConfig(cfg) rebuildRedirectionsList() } }) - reorderBox.Append(upBtn) - - downBtn := gtk.NewButton() - downBtn.SetIconName("go-down-symbolic") - downBtn.AddCSSClass("flat") - downBtn.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) - downBtn.SetTooltipText("Move down") - downBtn.ConnectClicked(func() { + reorderBox.Append(upButton) + + downButton := gtk.NewButton() + downButton.SetIconName("go-down-symbolic") + downButton.AddCSSClass("flat") + downButton.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) + downButton.SetTooltipText("Move down") + downButton.ConnectClicked(func() { if redirectionIndex < len(cfg.Redirections)-1 { cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex+1] = cfg.Redirections[redirectionIndex+1], cfg.Redirections[redirectionIndex] saveConfig(cfg) rebuildRedirectionsList() } }) - reorderBox.Append(downBtn) + reorderBox.Append(downButton) row.AddSuffix(reorderBox) - // Delete button - deleteBtn := gtk.NewButton() - deleteBtn.SetIconName("edit-delete-symbolic") - deleteBtn.AddCSSClass("flat") - deleteBtn.AddCSSClass("destructive-action") - deleteBtn.SetTooltipText("Remove") - deleteBtn.ConnectClicked(func() { + deleteButton := gtk.NewButton() + deleteButton.SetIconName("edit-delete-symbolic") + deleteButton.AddCSSClass("flat") + deleteButton.AddCSSClass("destructive-action") + deleteButton.SetTooltipText("Remove") + deleteButton.ConnectClicked(func() { cfg.Redirections = append(cfg.Redirections[:redirectionIndex], cfg.Redirections[redirectionIndex+1:]...) saveConfig(cfg) rebuildRedirectionsList() }) - row.AddSuffix(deleteBtn) + row.AddSuffix(deleteButton) - // Edit on click row.ConnectActivated(func() { showEditRedirectionDialog(win, cfg, redirection, rebuildRedirectionsList) }) @@ -149,7 +145,6 @@ func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { rebuildRedirectionsList = func() { clearListBox(redirectionsListBox) - // handle empty state if len(cfg.Redirections) == 0 { infoLabel.SetVisible(false) redirectionsListBox.SetVisible(false) diff --git a/cmd/switchyard/validation.go b/cmd/switchyard/validation.go deleted file mode 100644 index 47357b1..0000000 --- a/cmd/switchyard/validation.go +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package main - -import ( - "fmt" - "regexp" - "strings" -) - -func validateConditions(conditions []Condition) bool { - validTypes := map[string]bool{ - "domain": true, - "keyword": true, - "glob": true, - "regex": true, - } - - for _, c := range conditions { - if c.Pattern == "" { - return false - } - if !validTypes[c.Type] { - return false - } - // For regex type, validate the pattern compiles - if c.Type == "regex" { - if _, err := regexp.Compile(c.Pattern); err != nil { - return false - } - } - } - return true -} - -// Domain patterns should be exact hostnames like "example.com" or "api.github.com". -func validateDomainPattern(pattern string) error { - if pattern == "" { - return fmt.Errorf("Domain cannot be empty") - } - - // Check for wildcard characters (not allowed in domain type) - if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") { - return fmt.Errorf("Wildcards not allowed in domain patterns (use Wildcard type instead)") - } - - // Check for spaces - if strings.Contains(pattern, " ") { - return fmt.Errorf("Domain cannot contain spaces") - } - - // Check for invalid starting/ending characters - if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") { - return fmt.Errorf("Domain cannot start or end with a dot") - } - if strings.HasPrefix(pattern, "-") || strings.HasSuffix(pattern, "-") { - return fmt.Errorf("Domain cannot start or end with a hyphen") - } - - // Validate characters: only alphanumeric, dots, hyphens, underscores - for _, ch := range pattern { - if !((ch >= 'a' && ch <= 'z') || - (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - ch == '.' || ch == '-' || ch == '_') { - return fmt.Errorf("Domain contains invalid character: %c", ch) - } - } - - return nil -} - -// Glob patterns can contain * wildcards for matching multiple characters. -func validateGlobPattern(pattern string) error { - if pattern == "" { - return fmt.Errorf("Wildcard pattern cannot be empty") - } - - // Check for spaces - if strings.Contains(pattern, " ") { - return fmt.Errorf("Wildcard pattern cannot contain spaces") - } - - // Remove wildcards temporarily for validation - temp := strings.ReplaceAll(pattern, "*", "X") - - // Check if remaining pattern (without wildcards) has valid characters - for _, ch := range temp { - if !((ch >= 'a' && ch <= 'z') || - (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - ch == '.' || ch == '-' || ch == '_') { - return fmt.Errorf("Wildcard pattern contains invalid character: %c", ch) - } - } - - // Check for problematic dot placement (unless adjacent to wildcard) - if strings.HasPrefix(pattern, ".") && !strings.HasPrefix(pattern, ".*") { - return fmt.Errorf("Wildcard pattern cannot start with a dot") - } - if strings.HasSuffix(pattern, ".") && !strings.HasSuffix(pattern, "*.") { - return fmt.Errorf("Wildcard pattern cannot end with a dot") - } - - return nil -} - -// Returns an error with a descriptive message if invalid, nil if valid. -func validateConditionPattern(condType, pattern string) error { - if pattern == "" { - return fmt.Errorf("Pattern cannot be empty") - } - - switch condType { - case "domain": - return validateDomainPattern(pattern) - case "glob": - return validateGlobPattern(pattern) - case "regex": - if _, err := regexp.Compile(pattern); err != nil { - return fmt.Errorf("Invalid regex: %w", err) - } - case "keyword": - // Keywords can be any non-empty string - return nil - default: - return fmt.Errorf("Invalid condition type: %s", condType) - } - - return nil -} - -// isConditionValid checks if a single condition is valid. -func isConditionValid(c Condition) bool { - return validateConditionPattern(c.Type, c.Pattern) == nil -} - -// areAllConditionsValid checks if all conditions in a slice are valid. -func areAllConditionsValid(conditions []Condition) bool { - if len(conditions) == 0 { - return false - } - for _, c := range conditions { - if !isConditionValid(c) { - return false - } - } - return true -} - -func validateRedirection(r Redirection) error { - if r.Find == "" { - return fmt.Errorf("Find pattern cannot be empty") - } - - rwType := r.Type - if rwType == "" { - rwType = "domain" - } - - switch rwType { - case "domain": - return validateDomainPattern(r.Find) - case "wildcard": - pattern := wildcardToRegex(r.Find) - if _, err := regexp.Compile("(?i)" + pattern); err != nil { - return fmt.Errorf("Invalid pattern: %w", err) - } - case "regex": - if _, err := regexp.Compile(r.Find); err != nil { - return fmt.Errorf("Invalid regex: %w", err) - } - default: - return fmt.Errorf("Invalid redirection type: %s", rwType) - } - return nil -} - -func isRedirectionValid(r Redirection) bool { - return validateRedirection(r) == nil -} - -func areAllRedirectionsValid(redirections []Redirection) bool { - for _, r := range redirections { - if !isRedirectionValid(r) { - return false - } - } - return true -} diff --git a/cmd/switchyard/validation_test.go b/cmd/switchyard/validation_test.go deleted file mode 100644 index 8e667d7..0000000 --- a/cmd/switchyard/validation_test.go +++ /dev/null @@ -1,547 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package main - -import ( - "testing" -) - -// TestValidateDomainPattern tests domain pattern validation -func TestValidateDomainPattern(t *testing.T) { - tests := []struct { - name string - pattern string - wantErr bool - errMsg string - }{ - { - name: "valid simple domain", - pattern: "example.com", - wantErr: false, - }, - { - name: "valid subdomain", - pattern: "api.example.com", - wantErr: false, - }, - { - name: "valid multiple subdomains", - pattern: "deep.api.example.com", - wantErr: false, - }, - { - name: "valid domain with hyphen", - pattern: "my-site.example.com", - wantErr: false, - }, - { - name: "valid domain with numbers", - pattern: "site123.example.com", - wantErr: false, - }, - { - name: "valid domain with underscore", - pattern: "my_site.example.com", - wantErr: false, - }, - { - name: "empty domain", - pattern: "", - wantErr: true, - errMsg: "Domain cannot be empty", - }, - { - name: "domain with wildcard asterisk", - pattern: "*.example.com", - wantErr: true, - errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", - }, - { - name: "domain with wildcard question mark", - pattern: "example?.com", - wantErr: true, - errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", - }, - { - name: "domain with space", - pattern: "example .com", - wantErr: true, - errMsg: "Domain cannot contain spaces", - }, - { - name: "domain starting with dot", - pattern: ".example.com", - wantErr: true, - errMsg: "Domain cannot start or end with a dot", - }, - { - name: "domain ending with dot", - pattern: "example.com.", - wantErr: true, - errMsg: "Domain cannot start or end with a dot", - }, - { - name: "domain starting with hyphen", - pattern: "-example.com", - wantErr: true, - errMsg: "Domain cannot start or end with a hyphen", - }, - { - name: "domain ending with hyphen", - pattern: "example.com-", - wantErr: true, - errMsg: "Domain cannot start or end with a hyphen", - }, - { - name: "domain with slash", - pattern: "example.com/path", - wantErr: true, - errMsg: "Domain contains invalid character: /", - }, - { - name: "domain with special character", - pattern: "example@test.com", - wantErr: true, - errMsg: "Domain contains invalid character: @", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDomainPattern(tt.pattern) - if tt.wantErr { - if err == nil { - t.Errorf("validateDomainPattern(%q) expected error but got nil", tt.pattern) - } else if tt.errMsg != "" && err.Error() != tt.errMsg { - t.Errorf("validateDomainPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) - } - } else { - if err != nil { - t.Errorf("validateDomainPattern(%q) unexpected error: %v", tt.pattern, err) - } - } - }) - } -} - -// TestValidateGlobPattern tests wildcard pattern validation -func TestValidateGlobPattern(t *testing.T) { - tests := []struct { - name string - pattern string - wantErr bool - errMsg string - }{ - { - name: "valid wildcard at start", - pattern: "*.example.com", - wantErr: false, - }, - { - name: "valid wildcard at end", - pattern: "example.*", - wantErr: false, - }, - { - name: "valid wildcard in middle", - pattern: "api.*.example.com", - wantErr: false, - }, - { - name: "valid multiple wildcards", - pattern: "*.*.example.com", - wantErr: false, - }, - { - name: "valid domain with hyphen and wildcard", - pattern: "my-*.example.com", - wantErr: false, - }, - { - name: "valid domain with numbers and wildcard", - pattern: "site*.example.com", - wantErr: false, - }, - { - name: "empty pattern", - pattern: "", - wantErr: true, - errMsg: "Wildcard pattern cannot be empty", - }, - { - name: "pattern with space", - pattern: "* .example.com", - wantErr: true, - errMsg: "Wildcard pattern cannot contain spaces", - }, - { - name: "pattern starting with dot (not wildcard)", - pattern: ".example.com", - wantErr: true, - errMsg: "Wildcard pattern cannot start with a dot", - }, - { - name: "pattern ending with dot (not wildcard)", - pattern: "example.com.", - wantErr: true, - errMsg: "Wildcard pattern cannot end with a dot", - }, - { - name: "pattern with special character", - pattern: "example@*.com", - wantErr: true, - errMsg: "Wildcard pattern contains invalid character: @", - }, - { - name: "pattern with slash", - pattern: "example.com/*", - wantErr: true, - errMsg: "Wildcard pattern contains invalid character: /", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateGlobPattern(tt.pattern) - if tt.wantErr { - if err == nil { - t.Errorf("validateGlobPattern(%q) expected error but got nil", tt.pattern) - } else if tt.errMsg != "" && err.Error() != tt.errMsg { - t.Errorf("validateGlobPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) - } - } else { - if err != nil { - t.Errorf("validateGlobPattern(%q) unexpected error: %v", tt.pattern, err) - } - } - }) - } -} - -// TestValidateConditionPattern tests the main condition pattern validator -func TestValidateConditionPattern(t *testing.T) { - tests := []struct { - name string - condType string - pattern string - wantErr bool - }{ - // Domain type - { - name: "valid domain type", - condType: "domain", - pattern: "example.com", - wantErr: false, - }, - { - name: "invalid domain type - wildcard", - condType: "domain", - pattern: "*.example.com", - wantErr: true, - }, - // Keyword type - { - name: "valid keyword type", - condType: "keyword", - pattern: "github", - wantErr: false, - }, - { - name: "valid keyword with special chars", - condType: "keyword", - pattern: "/api/v2/", - wantErr: false, - }, - { - name: "empty keyword", - condType: "keyword", - pattern: "", - wantErr: true, - }, - // Glob type - { - name: "valid glob type", - condType: "glob", - pattern: "*.example.com", - wantErr: false, - }, - { - name: "invalid glob type - spaces", - condType: "glob", - pattern: "* .example.com", - wantErr: true, - }, - // Regex type - { - name: "valid regex type", - condType: "regex", - pattern: "^https://.*\\.example\\.com", - wantErr: false, - }, - { - name: "invalid regex type - bad syntax", - condType: "regex", - pattern: "[invalid", - wantErr: true, - }, - { - name: "empty regex", - condType: "regex", - pattern: "", - wantErr: true, - }, - // Empty pattern universal check - { - name: "empty pattern for domain", - condType: "domain", - pattern: "", - wantErr: true, - }, - { - name: "empty pattern for glob", - condType: "glob", - pattern: "", - wantErr: true, - }, - { - name: "invalid condition type", - condType: "invalid_type", - pattern: "example.com", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateConditionPattern(tt.condType, tt.pattern) - if tt.wantErr && err == nil { - t.Errorf("validateConditionPattern(%q, %q) expected error but got nil", tt.condType, tt.pattern) - } - if !tt.wantErr && err != nil { - t.Errorf("validateConditionPattern(%q, %q) unexpected error: %v", tt.condType, tt.pattern, err) - } - }) - } -} - -// TestIsConditionValid tests single condition validation -func TestIsConditionValid(t *testing.T) { - tests := []struct { - name string - condition Condition - want bool - }{ - { - name: "valid domain condition", - condition: Condition{ - Type: "domain", - Pattern: "example.com", - }, - want: true, - }, - { - name: "valid keyword condition", - condition: Condition{ - Type: "keyword", - Pattern: "github", - }, - want: true, - }, - { - name: "valid glob condition", - condition: Condition{ - Type: "glob", - Pattern: "*.example.com", - }, - want: true, - }, - { - name: "valid regex condition", - condition: Condition{ - Type: "regex", - Pattern: "^https://.*", - }, - want: true, - }, - { - name: "invalid domain condition - wildcard", - condition: Condition{ - Type: "domain", - Pattern: "*.example.com", - }, - want: false, - }, - { - name: "invalid condition - empty pattern", - condition: Condition{ - Type: "domain", - Pattern: "", - }, - want: false, - }, - { - name: "invalid regex condition", - condition: Condition{ - Type: "regex", - Pattern: "[invalid", - }, - want: false, - }, - { - name: "invalid condition type", - condition: Condition{ - Type: "invalid_type", - Pattern: "example.com", - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isConditionValid(tt.condition) - if got != tt.want { - t.Errorf("isConditionValid(%v) = %v, want %v", tt.condition, got, tt.want) - } - }) - } -} - -// TestAreAllConditionsValid tests validation of condition slices -func TestAreAllConditionsValid(t *testing.T) { - tests := []struct { - name string - conditions []Condition - want bool - }{ - { - name: "all valid conditions", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "keyword", Pattern: "github"}, - }, - want: true, - }, - { - name: "one invalid condition", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "domain", Pattern: "*.invalid.com"}, - }, - want: false, - }, - { - name: "empty pattern in list", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "keyword", Pattern: ""}, - }, - want: false, - }, - { - name: "empty conditions list", - conditions: []Condition{}, - want: false, - }, - { - name: "nil conditions list", - conditions: nil, - want: false, - }, - { - name: "single valid condition", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - }, - want: true, - }, - { - name: "invalid regex in list", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "regex", Pattern: "[invalid"}, - }, - want: false, - }, - { - name: "invalid condition type in list", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "invalid_type", Pattern: "example.com"}, - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := areAllConditionsValid(tt.conditions) - if got != tt.want { - t.Errorf("areAllConditionsValid(%v) = %v, want %v", tt.conditions, got, tt.want) - } - }) - } -} - -// TestValidateConditions tests the main validateConditions function -func TestValidateConditions(t *testing.T) { - tests := []struct { - name string - conditions []Condition - want bool - }{ - { - name: "valid conditions with all types", - conditions: []Condition{ - {Type: "domain", Pattern: "example.com"}, - {Type: "keyword", Pattern: "test"}, - {Type: "glob", Pattern: "*.example.com"}, - {Type: "regex", Pattern: "^https://.*"}, - }, - want: true, - }, - { - name: "invalid type", - conditions: []Condition{ - {Type: "invalid_type", Pattern: "example.com"}, - }, - want: false, - }, - { - name: "empty pattern", - conditions: []Condition{ - {Type: "domain", Pattern: ""}, - }, - want: false, - }, - { - name: "invalid regex pattern", - conditions: []Condition{ - {Type: "regex", Pattern: "[unclosed"}, - }, - want: false, - }, - { - name: "empty list", - conditions: []Condition{}, - want: true, - }, - { - name: "mixed valid and invalid", - conditions: []Condition{ - {Type: "domain", Pattern: "valid.com"}, - {Type: "domain", Pattern: ""}, - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := validateConditions(tt.conditions) - if got != tt.want { - t.Errorf("validateConditions(%v) = %v, want %v", tt.conditions, got, tt.want) - } - }) - } -} diff --git a/internal/routing/config.go b/internal/routing/config.go new file mode 100644 index 0000000..5d61304 --- /dev/null +++ b/internal/routing/config.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package routing + +type Config struct { + PromptOnClick bool `toml:"prompt_on_click"` + FavoriteBrowser string `toml:"favorite_browser"` + HiddenBrowsers []string `toml:"hidden_browsers"` + CheckDefaultBrowser bool `toml:"check_default_browser"` + ShowAppNames bool `toml:"show_app_names"` + ForceDarkMode bool `toml:"force_dark_mode"` + StayAlive bool `toml:"stay_alive"` + RemoveTrackingParameters bool `toml:"remove_tracking_parameters"` + Redirections []Redirection `toml:"redirections,omitempty"` + Rules []Rule `toml:"rules"` +} + +type Redirection struct { + Name string `toml:"name,omitempty"` + Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" + Find string `toml:"find"` + Replace string `toml:"replace"` +} + +type Condition struct { + Type string `toml:"type"` // "domain", "keyword", "glob", "regex" + Pattern string `toml:"pattern"` + Negate bool `toml:"negate,omitempty"` +} + +type Rule struct { + Name string `toml:"name"` + Conditions []Condition `toml:"conditions"` + Logic string `toml:"logic,omitempty"` // "all" or "any" + Browser string `toml:"browser"` + AlwaysAsk bool `toml:"always_ask"` +} + +func NewDefaultConfig() *Config { + return &Config{ + PromptOnClick: true, + CheckDefaultBrowser: true, + ShowAppNames: false, + ForceDarkMode: true, + StayAlive: true, + RemoveTrackingParameters: false, + Rules: []Rule{}, + } +} + +func (cfg *Config) MatchRule(url string) (browserID string, alwaysAsk bool, matched bool) { + for _, rule := range cfg.Rules { + if rule.MatchesConditions(url) { + return rule.Browser, rule.AlwaysAsk, true + } + } + return "", false, false +} + +func (rule *Rule) MatchesConditions(url string) bool { + if len(rule.Conditions) == 0 { + return false + } + + logic := rule.Logic + if logic == "" { + logic = "all" + } + + if logic == "all" { + for _, condition := range rule.Conditions { + if !matchesPattern(url, condition.Pattern, condition.Type, condition.Negate) { + return false + } + } + return true + } + + for _, condition := range rule.Conditions { + if matchesPattern(url, condition.Pattern, condition.Type, condition.Negate) { + return true + } + } + return false +} diff --git a/cmd/switchyard/config_test.go b/internal/routing/config_test.go similarity index 94% rename from cmd/switchyard/config_test.go rename to internal/routing/config_test.go index d9e64d6..146b84d 100644 --- a/cmd/switchyard/config_test.go +++ b/internal/routing/config_test.go @@ -1,12 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "testing" ) -// TestRuleMatchesConditions_AND tests AND logic (all conditions must match) func TestRuleMatchesConditions_AND(t *testing.T) { tests := []struct { name string @@ -146,15 +145,14 @@ func TestRuleMatchesConditions_AND(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tt.rule.matchesConditions(tt.url) + result := tt.rule.MatchesConditions(tt.url) if result != tt.want { - t.Errorf("Rule.matchesConditions(%q) = %v, want %v", tt.url, result, tt.want) + t.Errorf("Rule.MatchesConditions(%q) = %v, want %v", tt.url, result, tt.want) } }) } } -// TestRuleMatchesConditions_OR tests OR logic (any condition can match) func TestRuleMatchesConditions_OR(t *testing.T) { tests := []struct { name string @@ -264,15 +262,14 @@ func TestRuleMatchesConditions_OR(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tt.rule.matchesConditions(tt.url) + result := tt.rule.MatchesConditions(tt.url) if result != tt.want { - t.Errorf("Rule.matchesConditions(%q) = %v, want %v", tt.url, result, tt.want) + t.Errorf("Rule.MatchesConditions(%q) = %v, want %v", tt.url, result, tt.want) } }) } } -// TestConfigMatchRule tests the full rule matching from Config func TestConfigMatchRule(t *testing.T) { tests := []struct { name string @@ -458,21 +455,20 @@ func TestConfigMatchRule(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - browserID, alwaysAsk, matched := tt.config.matchRule(tt.url) + browserID, alwaysAsk, matched := tt.config.MatchRule(tt.url) if browserID != tt.wantBrowserID { - t.Errorf("Config.matchRule(%q) browserID = %q, want %q", tt.url, browserID, tt.wantBrowserID) + t.Errorf("Config.MatchRule(%q) browserID = %q, want %q", tt.url, browserID, tt.wantBrowserID) } if alwaysAsk != tt.wantAlwaysAsk { - t.Errorf("Config.matchRule(%q) alwaysAsk = %v, want %v", tt.url, alwaysAsk, tt.wantAlwaysAsk) + t.Errorf("Config.MatchRule(%q) alwaysAsk = %v, want %v", tt.url, alwaysAsk, tt.wantAlwaysAsk) } if matched != tt.wantMatched { - t.Errorf("Config.matchRule(%q) matched = %v, want %v", tt.url, matched, tt.wantMatched) + t.Errorf("Config.MatchRule(%q) matched = %v, want %v", tt.url, matched, tt.wantMatched) } }) } } -// TestConfigMatchRule_RuleOrdering tests that rules are matched in order (first match wins) func TestConfigMatchRule_RuleOrdering(t *testing.T) { tests := []struct { name string @@ -675,13 +671,13 @@ func TestConfigMatchRule_RuleOrdering(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - browserID, _, matched := tt.config.matchRule(tt.url) + browserID, _, matched := tt.config.MatchRule(tt.url) if browserID != tt.wantBrowserID { - t.Errorf("Config.matchRule(%q) browserID = %q, want %q", + t.Errorf("Config.MatchRule(%q) browserID = %q, want %q", tt.url, browserID, tt.wantBrowserID) } if matched != tt.wantMatched { - t.Errorf("Config.matchRule(%q) matched = %v, want %v", + t.Errorf("Config.MatchRule(%q) matched = %v, want %v", tt.url, matched, tt.wantMatched) } }) diff --git a/cmd/switchyard/pattern.go b/internal/routing/pattern.go similarity index 94% rename from cmd/switchyard/pattern.go rename to internal/routing/pattern.go index 585ff35..a877d32 100644 --- a/cmd/switchyard/pattern.go +++ b/internal/routing/pattern.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "regexp" @@ -25,7 +25,7 @@ func matchesPattern(url, pattern, patternType string, negate bool) bool { var result bool switch patternType { case "domain": - domain := extractDomain(url) + domain := ExtractDomain(url) result = strings.EqualFold(domain, pattern) case "keyword": result = strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) @@ -49,7 +49,7 @@ func matchesPattern(url, pattern, patternType string, negate bool) bool { // matchGlob performs glob-style pattern matching against a URL's domain or full URL func matchGlob(url, pattern string) bool { - domain := extractDomain(url) + domain := ExtractDomain(url) // Convert glob to regex: escape dots, convert * to .* regexPattern := "^" + strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".*") + "$" diff --git a/cmd/switchyard/pattern_test.go b/internal/routing/pattern_test.go similarity index 99% rename from cmd/switchyard/pattern_test.go rename to internal/routing/pattern_test.go index 152780b..ab2a40e 100644 --- a/cmd/switchyard/pattern_test.go +++ b/internal/routing/pattern_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "testing" diff --git a/internal/routing/redirection.go b/internal/routing/redirection.go new file mode 100644 index 0000000..91724f6 --- /dev/null +++ b/internal/routing/redirection.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package routing + +import ( + "net/url" + "regexp" + "strings" +) + +func ApplyRedirections(rawURL string, redirections []Redirection) string { + for _, redirection := range redirections { + rawURL = applyRedirection(rawURL, redirection) + } + return rawURL +} + +func applyRedirection(rawURL string, redirection Redirection) string { + redirectionType := redirection.Type + if redirectionType == "" { + redirectionType = "domain" + } + + switch redirectionType { + case "domain": + return applyDomainRedirection(rawURL, redirection) + case "wildcard": + return applyWildcardRedirection(rawURL, redirection) + case "regex": + return applyRegexRedirection(rawURL, redirection) + default: + return rawURL + } +} + +func applyDomainRedirection(rawURL string, redirection Redirection) string { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + if !strings.EqualFold(parsedURL.Hostname(), redirection.Find) { + return rawURL + } + + parsedURL.Host = strings.Replace(parsedURL.Host, parsedURL.Hostname(), redirection.Replace, 1) + return parsedURL.String() +} + +func applyWildcardRedirection(rawURL string, redirection Redirection) string { + pattern := wildcardToRegex(redirection.Find) + compiledRegex, ok := getCompiledRegex("(?i)" + pattern) + if !ok { + return rawURL + } + return compiledRegex.ReplaceAllString(rawURL, redirection.Replace) +} + +func applyRegexRedirection(rawURL string, redirection Redirection) string { + compiledRegex, ok := getCompiledRegex(redirection.Find) + if !ok { + return rawURL + } + return compiledRegex.ReplaceAllString(rawURL, redirection.Replace) +} + +func wildcardToRegex(pattern string) string { + escaped := regexp.QuoteMeta(pattern) + return strings.ReplaceAll(escaped, `\*`, `.*`) +} diff --git a/cmd/switchyard/redirection_test.go b/internal/routing/redirection_test.go similarity index 97% rename from cmd/switchyard/redirection_test.go rename to internal/routing/redirection_test.go index 8825d2c..b6265da 100644 --- a/cmd/switchyard/redirection_test.go +++ b/internal/routing/redirection_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "testing" @@ -481,9 +481,9 @@ func TestApplyRedirections(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := applyRedirections(tt.url, tt.redirections) + got := ApplyRedirections(tt.url, tt.redirections) if got != tt.want { - t.Errorf("applyRedirections() = %q, want %q", got, tt.want) + t.Errorf("ApplyRedirections() = %q, want %q", got, tt.want) } }) } @@ -579,15 +579,14 @@ func TestValidateRedirection(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateRedirection(tt.redirection) + err := ValidateRedirection(tt.redirection) if (err != nil) != tt.wantErr { - t.Errorf("validateRedirection() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ValidateRedirection() error = %v, wantErr %v", err, tt.wantErr) } }) } } -// TestRealWorldRedirections tests common real-world URL transformations func TestRealWorldRedirections(t *testing.T) { tests := []struct { name string @@ -701,9 +700,9 @@ func TestRealWorldRedirections(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := applyRedirections(tt.url, tt.redirections) + got := ApplyRedirections(tt.url, tt.redirections) if got != tt.want { - t.Errorf("applyRedirections() = %q, want %q", got, tt.want) + t.Errorf("ApplyRedirections() = %q, want %q", got, tt.want) } }) } diff --git a/cmd/switchyard/url.go b/internal/routing/url.go similarity index 58% rename from cmd/switchyard/url.go rename to internal/routing/url.go index fac3d55..93b1fc9 100644 --- a/cmd/switchyard/url.go +++ b/internal/routing/url.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "fmt" @@ -8,20 +8,18 @@ import ( "strings" ) -func extractDomain(rawURL string) string { - // Add scheme if missing so url.Parse works correctly +func ExtractDomain(rawURL string) string { if !strings.Contains(rawURL, "://") { rawURL = "https://" + rawURL } - u, err := url.Parse(rawURL) + parsedURL, err := url.Parse(rawURL) if err != nil { return "" } - return u.Hostname() + return parsedURL.Hostname() } -func sanitizeURL(rawURL string) string { - // Remove newlines and carriage returns (common when URLs are copied from formatted text) +func SanitizeURL(rawURL string) string { rawURL = strings.ReplaceAll(rawURL, "\n", "") rawURL = strings.ReplaceAll(rawURL, "\r", "") rawURL = strings.TrimSpace(rawURL) @@ -29,42 +27,42 @@ func sanitizeURL(rawURL string) string { return "" } - // Reject local file paths if strings.HasPrefix(rawURL, "/") || strings.HasPrefix(rawURL, ".") { return "" } - u, err := url.Parse(rawURL) + parsedURL, err := url.Parse(rawURL) if err != nil { return "" } - // Add https scheme if missing - if u.Scheme == "" { + if parsedURL.Scheme == "" { rawURL = "https://" + rawURL - u, _ = url.Parse(rawURL) + parsedURL, err = url.Parse(rawURL) + if err != nil { + return "" + } } - // Only allow browser-routable schemes - switch u.Scheme { + switch parsedURL.Scheme { case "http", "https", "file", "ftp": - return u.String() + return parsedURL.String() default: return "" } } -func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { - u, err := url.Parse(rawURL) +func ParseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { + parsedURL, err := url.Parse(rawURL) if err != nil { return "", nil, err } - if u.Scheme != "switchyard" || u.Host != "open" { + if parsedURL.Scheme != "switchyard" || parsedURL.Host != "open" { return "", nil, fmt.Errorf("invalid switchyard URL") } - query := u.Query() + query := parsedURL.Query() targetURL = query.Get("url") if targetURL == "" { return "", nil, fmt.Errorf("missing url parameter") diff --git a/cmd/switchyard/url_test.go b/internal/routing/url_test.go similarity index 89% rename from cmd/switchyard/url_test.go rename to internal/routing/url_test.go index 765480b..4677921 100644 --- a/cmd/switchyard/url_test.go +++ b/internal/routing/url_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package main +package routing import ( "testing" @@ -42,9 +42,9 @@ func TestSanitizeURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := sanitizeURL(tt.input) + result := SanitizeURL(tt.input) if result != tt.expected { - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) + t.Errorf("SanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) } }) } @@ -82,9 +82,9 @@ func TestExtractDomain(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := extractDomain(tt.input) + result := ExtractDomain(tt.input) if result != tt.expected { - t.Errorf("extractDomain(%q) = %q, want %q", tt.input, result, tt.expected) + t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected) } }) } @@ -144,24 +144,24 @@ func TestParseSwitchyardURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotURL, gotBrowsers, err := parseSwitchyardURL(tt.input) + gotURL, gotBrowsers, err := ParseSwitchyardURL(tt.input) if (err != nil) != tt.wantErr { - t.Errorf("parseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ParseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if gotURL != tt.wantURL { - t.Errorf("parseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) + t.Errorf("ParseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) } if len(gotBrowsers) != len(tt.wantBrowsers) { - t.Errorf("parseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) + t.Errorf("ParseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) return } for i, b := range gotBrowsers { if b != tt.wantBrowsers[i] { - t.Errorf("parseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) + t.Errorf("ParseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) } } }) diff --git a/internal/routing/validation.go b/internal/routing/validation.go new file mode 100644 index 0000000..dccec50 --- /dev/null +++ b/internal/routing/validation.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package routing + +import ( + "fmt" + "regexp" + "strings" +) + +func validateDomainPattern(pattern string) error { + if pattern == "" { + return fmt.Errorf("Domain cannot be empty") + } + + if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") { + return fmt.Errorf("Wildcards not allowed in domain patterns (use Wildcard type instead)") + } + + if strings.Contains(pattern, " ") { + return fmt.Errorf("Domain cannot contain spaces") + } + + if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") { + return fmt.Errorf("Domain cannot start or end with a dot") + } + if strings.HasPrefix(pattern, "-") || strings.HasSuffix(pattern, "-") { + return fmt.Errorf("Domain cannot start or end with a hyphen") + } + + for _, character := range pattern { + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '.' || character == '-' || character == '_') { + return fmt.Errorf("Domain contains invalid character: %c", character) + } + } + + return nil +} + +func validateGlobPattern(pattern string) error { + if pattern == "" { + return fmt.Errorf("Wildcard pattern cannot be empty") + } + + if strings.Contains(pattern, " ") { + return fmt.Errorf("Wildcard pattern cannot contain spaces") + } + + patternWithoutWildcards := strings.ReplaceAll(pattern, "*", "X") + + for _, character := range patternWithoutWildcards { + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '.' || character == '-' || character == '_') { + return fmt.Errorf("Wildcard pattern contains invalid character: %c", character) + } + } + + if strings.HasPrefix(pattern, ".") && !strings.HasPrefix(pattern, ".*") { + return fmt.Errorf("Wildcard pattern cannot start with a dot") + } + if strings.HasSuffix(pattern, ".") && !strings.HasSuffix(pattern, "*.") { + return fmt.Errorf("Wildcard pattern cannot end with a dot") + } + + return nil +} + +func ValidateConditionPattern(conditionType, pattern string) error { + if pattern == "" { + return fmt.Errorf("Pattern cannot be empty") + } + + switch conditionType { + case "domain": + return validateDomainPattern(pattern) + case "glob": + return validateGlobPattern(pattern) + case "regex": + if _, err := regexp.Compile(pattern); err != nil { + return fmt.Errorf("Invalid regex: %w", err) + } + case "keyword": + return nil + default: + return fmt.Errorf("Invalid condition type: %s", conditionType) + } + + return nil +} + +func isConditionValid(condition Condition) bool { + return ValidateConditionPattern(condition.Type, condition.Pattern) == nil +} + +func AreAllConditionsValid(conditions []Condition) bool { + if len(conditions) == 0 { + return false + } + for _, condition := range conditions { + if !isConditionValid(condition) { + return false + } + } + return true +} + +func ValidateRedirection(redirection Redirection) error { + if redirection.Find == "" { + return fmt.Errorf("Find pattern cannot be empty") + } + + redirectionType := redirection.Type + if redirectionType == "" { + redirectionType = "domain" + } + + switch redirectionType { + case "domain": + return validateDomainPattern(redirection.Find) + case "wildcard": + pattern := wildcardToRegex(redirection.Find) + if _, err := regexp.Compile("(?i)" + pattern); err != nil { + return fmt.Errorf("Invalid pattern: %w", err) + } + case "regex": + if _, err := regexp.Compile(redirection.Find); err != nil { + return fmt.Errorf("Invalid regex: %w", err) + } + default: + return fmt.Errorf("Invalid redirection type: %s", redirectionType) + } + return nil +} + +func isRedirectionValid(redirection Redirection) bool { + return ValidateRedirection(redirection) == nil +} + +func AreAllRedirectionsValid(redirections []Redirection) bool { + for _, redirection := range redirections { + if !isRedirectionValid(redirection) { + return false + } + } + return true +} diff --git a/internal/routing/validation_test.go b/internal/routing/validation_test.go new file mode 100644 index 0000000..bddde45 --- /dev/null +++ b/internal/routing/validation_test.go @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package routing + +import ( + "testing" +) + +func TestValidateDomainPattern(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr bool + errorMessage string + }{ + { + name: "valid simple domain", + pattern: "example.com", + wantErr: false, + }, + { + name: "valid subdomain", + pattern: "api.example.com", + wantErr: false, + }, + { + name: "valid multiple subdomains", + pattern: "deep.api.example.com", + wantErr: false, + }, + { + name: "valid domain with hyphen", + pattern: "my-site.example.com", + wantErr: false, + }, + { + name: "valid domain with numbers", + pattern: "site123.example.com", + wantErr: false, + }, + { + name: "valid domain with underscore", + pattern: "my_site.example.com", + wantErr: false, + }, + { + name: "empty domain", + pattern: "", + wantErr: true, + errorMessage: "Domain cannot be empty", + }, + { + name: "domain with wildcard asterisk", + pattern: "*.example.com", + wantErr: true, + errorMessage: "Wildcards not allowed in domain patterns (use Wildcard type instead)", + }, + { + name: "domain with wildcard question mark", + pattern: "example?.com", + wantErr: true, + errorMessage: "Wildcards not allowed in domain patterns (use Wildcard type instead)", + }, + { + name: "domain with space", + pattern: "example .com", + wantErr: true, + errorMessage: "Domain cannot contain spaces", + }, + { + name: "domain starting with dot", + pattern: ".example.com", + wantErr: true, + errorMessage: "Domain cannot start or end with a dot", + }, + { + name: "domain ending with dot", + pattern: "example.com.", + wantErr: true, + errorMessage: "Domain cannot start or end with a dot", + }, + { + name: "domain starting with hyphen", + pattern: "-example.com", + wantErr: true, + errorMessage: "Domain cannot start or end with a hyphen", + }, + { + name: "domain ending with hyphen", + pattern: "example.com-", + wantErr: true, + errorMessage: "Domain cannot start or end with a hyphen", + }, + { + name: "domain with slash", + pattern: "example.com/path", + wantErr: true, + errorMessage: "Domain contains invalid character: /", + }, + { + name: "domain with special character", + pattern: "example@test.com", + wantErr: true, + errorMessage: "Domain contains invalid character: @", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDomainPattern(tt.pattern) + if tt.wantErr { + if err == nil { + t.Errorf("validateDomainPattern(%q) expected error but got nil", tt.pattern) + } else if tt.errorMessage != "" && err.Error() != tt.errorMessage { + t.Errorf("validateDomainPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errorMessage) + } + } else { + if err != nil { + t.Errorf("validateDomainPattern(%q) unexpected error: %v", tt.pattern, err) + } + } + }) + } +} + +func TestValidateGlobPattern(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr bool + errorMessage string + }{ + { + name: "valid wildcard at start", + pattern: "*.example.com", + wantErr: false, + }, + { + name: "valid wildcard at end", + pattern: "example.*", + wantErr: false, + }, + { + name: "valid wildcard in middle", + pattern: "api.*.example.com", + wantErr: false, + }, + { + name: "valid multiple wildcards", + pattern: "*.*.example.com", + wantErr: false, + }, + { + name: "valid domain with hyphen and wildcard", + pattern: "my-*.example.com", + wantErr: false, + }, + { + name: "valid domain with numbers and wildcard", + pattern: "site*.example.com", + wantErr: false, + }, + { + name: "empty pattern", + pattern: "", + wantErr: true, + errorMessage: "Wildcard pattern cannot be empty", + }, + { + name: "pattern with space", + pattern: "* .example.com", + wantErr: true, + errorMessage: "Wildcard pattern cannot contain spaces", + }, + { + name: "pattern starting with dot (not wildcard)", + pattern: ".example.com", + wantErr: true, + errorMessage: "Wildcard pattern cannot start with a dot", + }, + { + name: "pattern ending with dot (not wildcard)", + pattern: "example.com.", + wantErr: true, + errorMessage: "Wildcard pattern cannot end with a dot", + }, + { + name: "pattern with special character", + pattern: "example@*.com", + wantErr: true, + errorMessage: "Wildcard pattern contains invalid character: @", + }, + { + name: "pattern with slash", + pattern: "example.com/*", + wantErr: true, + errorMessage: "Wildcard pattern contains invalid character: /", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGlobPattern(tt.pattern) + if tt.wantErr { + if err == nil { + t.Errorf("validateGlobPattern(%q) expected error but got nil", tt.pattern) + } else if tt.errorMessage != "" && err.Error() != tt.errorMessage { + t.Errorf("validateGlobPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errorMessage) + } + } else { + if err != nil { + t.Errorf("validateGlobPattern(%q) unexpected error: %v", tt.pattern, err) + } + } + }) + } +} + +func TestValidateConditionPattern(t *testing.T) { + tests := []struct { + name string + conditionType string + pattern string + wantErr bool + }{ + { + name: "valid domain type", + conditionType: "domain", + pattern: "example.com", + wantErr: false, + }, + { + name: "invalid domain type - wildcard", + conditionType: "domain", + pattern: "*.example.com", + wantErr: true, + }, + { + name: "valid keyword type", + conditionType: "keyword", + pattern: "github", + wantErr: false, + }, + { + name: "valid keyword with special chars", + conditionType: "keyword", + pattern: "/api/v2/", + wantErr: false, + }, + { + name: "empty keyword", + conditionType: "keyword", + pattern: "", + wantErr: true, + }, + { + name: "valid glob type", + conditionType: "glob", + pattern: "*.example.com", + wantErr: false, + }, + { + name: "invalid glob type - spaces", + conditionType: "glob", + pattern: "* .example.com", + wantErr: true, + }, + { + name: "valid regex type", + conditionType: "regex", + pattern: "^https://.*\\.example\\.com", + wantErr: false, + }, + { + name: "invalid regex type - bad syntax", + conditionType: "regex", + pattern: "[invalid", + wantErr: true, + }, + { + name: "empty regex", + conditionType: "regex", + pattern: "", + wantErr: true, + }, + { + name: "empty pattern for domain", + conditionType: "domain", + pattern: "", + wantErr: true, + }, + { + name: "empty pattern for glob", + conditionType: "glob", + pattern: "", + wantErr: true, + }, + { + name: "invalid condition type", + conditionType: "invalid_type", + pattern: "example.com", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateConditionPattern(tt.conditionType, tt.pattern) + if tt.wantErr && err == nil { + t.Errorf("ValidateConditionPattern(%q, %q) expected error but got nil", tt.conditionType, tt.pattern) + } + if !tt.wantErr && err != nil { + t.Errorf("ValidateConditionPattern(%q, %q) unexpected error: %v", tt.conditionType, tt.pattern, err) + } + }) + } +} + +func TestIsConditionValid(t *testing.T) { + tests := []struct { + name string + condition Condition + want bool + }{ + { + name: "valid domain condition", + condition: Condition{ + Type: "domain", + Pattern: "example.com", + }, + want: true, + }, + { + name: "valid keyword condition", + condition: Condition{ + Type: "keyword", + Pattern: "github", + }, + want: true, + }, + { + name: "valid glob condition", + condition: Condition{ + Type: "glob", + Pattern: "*.example.com", + }, + want: true, + }, + { + name: "valid regex condition", + condition: Condition{ + Type: "regex", + Pattern: "^https://.*", + }, + want: true, + }, + { + name: "invalid domain condition - wildcard", + condition: Condition{ + Type: "domain", + Pattern: "*.example.com", + }, + want: false, + }, + { + name: "invalid condition - empty pattern", + condition: Condition{ + Type: "domain", + Pattern: "", + }, + want: false, + }, + { + name: "invalid regex condition", + condition: Condition{ + Type: "regex", + Pattern: "[invalid", + }, + want: false, + }, + { + name: "invalid condition type", + condition: Condition{ + Type: "invalid_type", + Pattern: "example.com", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isConditionValid(tt.condition) + if got != tt.want { + t.Errorf("isConditionValid(%v) = %v, want %v", tt.condition, got, tt.want) + } + }) + } +} + +func TestAreAllConditionsValid(t *testing.T) { + tests := []struct { + name string + conditions []Condition + want bool + }{ + { + name: "all valid conditions", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + {Type: "keyword", Pattern: "github"}, + }, + want: true, + }, + { + name: "one invalid condition", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + {Type: "domain", Pattern: "*.invalid.com"}, + }, + want: false, + }, + { + name: "empty pattern in list", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + {Type: "keyword", Pattern: ""}, + }, + want: false, + }, + { + name: "empty conditions list", + conditions: []Condition{}, + want: false, + }, + { + name: "nil conditions list", + conditions: nil, + want: false, + }, + { + name: "single valid condition", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + }, + want: true, + }, + { + name: "invalid regex in list", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + {Type: "regex", Pattern: "[invalid"}, + }, + want: false, + }, + { + name: "invalid condition type in list", + conditions: []Condition{ + {Type: "domain", Pattern: "example.com"}, + {Type: "invalid_type", Pattern: "example.com"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AreAllConditionsValid(tt.conditions) + if got != tt.want { + t.Errorf("AreAllConditionsValid(%v) = %v, want %v", tt.conditions, got, tt.want) + } + }) + } +} diff --git a/justfile b/justfile index 1d662c0..61031a9 100644 --- a/justfile +++ b/justfile @@ -55,18 +55,27 @@ update-go-deps: scripts/generate-flatpak-go-modules.py # Run unit tests -test: - @echo "Running unit tests..." - go test -v ./cmd/switchyard/config_test.go ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/url_test.go ./cmd/switchyard/pattern_test.go ./cmd/switchyard/validation_test.go ./cmd/switchyard/redirection_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go ./cmd/switchyard/url.go ./cmd/switchyard/pattern.go ./cmd/switchyard/validation.go ./cmd/switchyard/redirection.go +test-routing: + go test -v ./internal/routing + +test-config: + go test -v ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go + +test-sanitizer: + go test -v ./cmd/switchyard/sanitizer_test.go ./cmd/switchyard/sanitizer.go + +test: test-routing test-config test-sanitizer # Run tests with coverage report -test-coverage: +test-routing-coverage: @echo "Running tests with coverage..." - go test -coverprofile=coverage.out ./cmd/switchyard/config_test.go ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/url_test.go ./cmd/switchyard/pattern_test.go ./cmd/switchyard/validation_test.go ./cmd/switchyard/redirection_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go ./cmd/switchyard/url.go ./cmd/switchyard/pattern.go ./cmd/switchyard/validation.go ./cmd/switchyard/redirection.go + go test -v -coverprofile=coverage.out ./internal/routing go tool cover -func=coverage.out @echo "" @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" +test-coverage: test-config test-sanitizer test-routing-coverage + # Build and install Flatpak (development version) flatpak: [ -f build-repo/config ] || rm -rf build-repo @@ -136,10 +145,9 @@ release version: exit 1 fi - # Allow cmd/switchyard/app.go, metainfo, extension manifests, and package.json to be dirty; nothing else. dirty="$(git status --porcelain -- ':!cmd/switchyard/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")" if [ -n "$dirty" ]; then - echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}:" >&2 + echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2 echo "$dirty" >&2 exit 1 fi @@ -155,6 +163,8 @@ release version: exit 1 fi + just test + sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" cmd/switchyard/app.go if ! grep -qE "Version\s*=\s*\"${version}\"" cmd/switchyard/app.go; then echo "error: failed to update Version in cmd/switchyard/app.go" >&2