summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner.go418
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner_test.go459
2 files changed, 877 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner.go
new file mode 100644
index 0000000..7dae9fc
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner.go
@@ -0,0 +1,418 @@
+package headless
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
+)
+
+// ErrConfirmationRequired is returned when --yes is not set and the user
+// must explicitly confirm the operation.
+var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
+
+// validConfigNames maps lowercase CLI input to the deployer key used in
+// replaceConfigs. Keep in sync with the config types checked by
+// shouldReplaceConfig in deployer.go.
+var validConfigNames = map[string]string{
+ "niri": "Niri",
+ "hyprland": "Hyprland",
+ "ghostty": "Ghostty",
+ "kitty": "Kitty",
+ "alacritty": "Alacritty",
+}
+
+// orderedConfigNames defines the canonical order for config names in output.
+// Must be kept in sync with validConfigNames.
+var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
+
+// Config holds all CLI parameters for unattended installation.
+type Config struct {
+ Compositor string // "niri" or "hyprland"
+ Terminal string // "ghostty", "kitty", or "alacritty"
+ IncludeDeps []string
+ ExcludeDeps []string
+ ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
+ ReplaceConfigsAll bool // deploy/replace all configurations
+ Yes bool
+}
+
+// Runner orchestrates unattended (headless) installation.
+type Runner struct {
+ cfg Config
+ logChan chan string
+}
+
+// NewRunner creates a new headless runner.
+func NewRunner(cfg Config) *Runner {
+ return &Runner{
+ cfg: cfg,
+ logChan: make(chan string, 1000),
+ }
+}
+
+// GetLogChan returns the log channel for file logging.
+func (r *Runner) GetLogChan() <-chan string {
+ return r.logChan
+}
+
+// Run executes the full unattended installation flow.
+func (r *Runner) Run() error {
+ r.log("Starting headless installation")
+
+ // 1. Parse compositor and terminal selections
+ wm, err := r.parseWindowManager()
+ if err != nil {
+ return err
+ }
+
+ terminal, err := r.parseTerminal()
+ if err != nil {
+ return err
+ }
+
+ // 2. Build replace-configs map
+ replaceConfigs, err := r.buildReplaceConfigs()
+ if err != nil {
+ return err
+ }
+
+ // 3. Detect OS
+ r.log("Detecting operating system...")
+ osInfo, err := distros.GetOSInfo()
+ if err != nil {
+ return fmt.Errorf("OS detection failed: %w", err)
+ }
+
+ if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
+ return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
+ }
+
+ fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
+
+ // 4. Create distribution instance
+ distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
+ if err != nil {
+ return fmt.Errorf("failed to initialize distribution: %w", err)
+ }
+
+ // 5. Detect dependencies
+ r.log("Detecting dependencies...")
+ fmt.Fprintln(os.Stdout, "Detecting dependencies...")
+ dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
+ if err != nil {
+ return fmt.Errorf("dependency detection failed: %w", err)
+ }
+
+ // 5. Apply include/exclude filters and build the disabled-items map.
+ // Headless mode does not currently collect any explicit reinstall selections,
+ // so keep reinstallItems nil instead of constructing an always-empty map.
+ disabledItems, err := r.buildDisabledItems(dependencies)
+ if err != nil {
+ return err
+ }
+ var reinstallItems map[string]bool
+
+ // Print dependency summary
+ fmt.Fprintln(os.Stdout, "\nDependencies:")
+ for _, dep := range dependencies {
+ marker := " "
+ status := ""
+ if disabledItems[dep.Name] {
+ marker = " SKIP "
+ status = "(disabled)"
+ } else {
+ switch dep.Status {
+ case deps.StatusInstalled:
+ marker = " OK "
+ status = "(installed)"
+ case deps.StatusMissing:
+ marker = " NEW "
+ status = "(will install)"
+ case deps.StatusNeedsUpdate:
+ marker = " UPD "
+ status = "(will update)"
+ case deps.StatusNeedsReinstall:
+ marker = " RE "
+ status = "(will reinstall)"
+ }
+ }
+ fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
+ }
+ fmt.Fprintln(os.Stdout)
+
+ // 6b. Require explicit confirmation unless --yes is set
+ if !r.cfg.Yes {
+ if replaceConfigs == nil {
+ // --replace-configs-all
+ fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
+ fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
+ } else if r.anyConfigEnabled(replaceConfigs) {
+ var names []string
+ for _, cliName := range orderedConfigNames {
+ deployerKey := validConfigNames[cliName]
+ if replaceConfigs[deployerKey] {
+ names = append(names, deployerKey)
+ }
+ }
+ fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
+ } else {
+ fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
+ }
+ fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
+ r.log("Aborted: --yes not set")
+ return ErrConfirmationRequired
+ }
+
+ // 7. Authenticate sudo
+ sudoPassword, err := r.resolveSudoPassword()
+ if err != nil {
+ return err
+ }
+
+ // 8. Install packages
+ fmt.Fprintln(os.Stdout, "Installing packages...")
+ r.log("Starting package installation")
+
+ progressChan := make(chan distros.InstallProgressMsg, 100)
+
+ installErr := make(chan error, 1)
+ go func() {
+ defer close(progressChan)
+ installErr <- distro.InstallPackages(
+ context.Background(),
+ dependencies,
+ wm,
+ sudoPassword,
+ reinstallItems,
+ disabledItems,
+ false, // skipGlobalUseFlags
+ progressChan,
+ )
+ }()
+
+ // Consume progress messages and print them
+ for msg := range progressChan {
+ if msg.Error != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
+ } else if msg.Step != "" {
+ fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
+ }
+ if msg.LogOutput != "" {
+ r.log(msg.LogOutput)
+ fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
+ }
+ }
+
+ if err := <-installErr; err != nil {
+ return fmt.Errorf("package installation failed: %w", err)
+ }
+
+ // 9. Greeter setup (if dms-greeter was included)
+ if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
+ compositorName := "niri"
+ if wm == deps.WindowManagerHyprland {
+ compositorName = "Hyprland"
+ }
+ fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
+ logFunc := func(line string) {
+ r.log(line)
+ fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
+ }
+ if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
+ // Non-fatal, matching TUI behavior
+ fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
+ }
+ }
+
+ // 10. Deploy configurations
+ fmt.Fprintln(os.Stdout, "Deploying configurations...")
+ r.log("Starting configuration deployment")
+
+ deployer := config.NewConfigDeployer(r.logChan)
+ results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
+ context.Background(),
+ wm,
+ terminal,
+ dependencies,
+ replaceConfigs,
+ reinstallItems,
+ )
+ if err != nil {
+ return fmt.Errorf("configuration deployment failed: %w", err)
+ }
+
+ for _, result := range results {
+ if result.Deployed {
+ msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
+ if result.BackupPath != "" {
+ msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
+ }
+ fmt.Fprintln(os.Stdout, msg)
+ }
+ if result.Error != nil {
+ fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
+ }
+ }
+
+ fmt.Fprintln(os.Stdout, "\nInstallation complete!")
+ r.log("Headless installation completed successfully")
+ return nil
+}
+
+// buildDisabledItems computes the set of dependencies that should be skipped
+// during installation, applying the --include-deps and --exclude-deps filters.
+// dms-greeter is disabled by default (opt-in), matching TUI behavior.
+func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
+ disabledItems := make(map[string]bool)
+
+ // dms-greeter is opt-in (disabled by default), matching TUI behavior
+ for i := range dependencies {
+ if dependencies[i].Name == "dms-greeter" {
+ disabledItems["dms-greeter"] = true
+ break
+ }
+ }
+
+ // Process --include-deps (enable items that are disabled by default)
+ for _, name := range r.cfg.IncludeDeps {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ if !r.depExists(dependencies, name) {
+ return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
+ }
+ delete(disabledItems, name)
+ }
+
+ // Process --exclude-deps (disable items)
+ for _, name := range r.cfg.ExcludeDeps {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ if !r.depExists(dependencies, name) {
+ return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
+ }
+ // Don't allow excluding DMS itself
+ if name == "dms (DankMaterialShell)" {
+ return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
+ }
+ disabledItems[name] = true
+ }
+
+ return disabledItems, nil
+}
+
+// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
+// flags into the map[string]bool consumed by the config deployer.
+//
+// Returns:
+// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
+// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
+// - a map with requested configs true, all others false for --replace-configs
+// - an error when both flags are set or an invalid config name is given
+func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
+ hasSpecific := len(r.cfg.ReplaceConfigs) > 0
+ if hasSpecific && r.cfg.ReplaceConfigsAll {
+ return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
+ }
+
+ if r.cfg.ReplaceConfigsAll {
+ return nil, nil
+ }
+
+ // Build a map with all known configs explicitly set to false
+ result := make(map[string]bool, len(validConfigNames))
+ for _, cliName := range orderedConfigNames {
+ result[validConfigNames[cliName]] = false
+ }
+
+ // Enable only the requested configs
+ for _, name := range r.cfg.ReplaceConfigs {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ deployerKey, ok := validConfigNames[strings.ToLower(name)]
+ if !ok {
+ return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
+ }
+ result[deployerKey] = true
+ }
+
+ return result, nil
+}
+
+func (r *Runner) log(message string) {
+ select {
+ case r.logChan <- message:
+ default:
+ }
+}
+
+func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
+ switch strings.ToLower(r.cfg.Compositor) {
+ case "niri":
+ return deps.WindowManagerNiri, nil
+ case "hyprland":
+ return deps.WindowManagerHyprland, nil
+ default:
+ return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
+ }
+}
+
+func (r *Runner) parseTerminal() (deps.Terminal, error) {
+ switch strings.ToLower(r.cfg.Terminal) {
+ case "ghostty":
+ return deps.TerminalGhostty, nil
+ case "kitty":
+ return deps.TerminalKitty, nil
+ case "alacritty":
+ return deps.TerminalAlacritty, nil
+ default:
+ return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
+ }
+}
+
+func (r *Runner) resolveSudoPassword() (string, error) {
+ // Check if sudo credentials are cached (via sudo -v or NOPASSWD)
+ cmd := exec.Command("sudo", "-n", "true")
+ if err := cmd.Run(); err == nil {
+ r.log("sudo cache is valid, no password needed")
+ fmt.Fprintln(os.Stdout, "sudo: using cached credentials")
+ return "", nil
+ }
+
+ return "", fmt.Errorf(
+ "sudo authentication required but no cached credentials found\n" +
+ "Options:\n" +
+ " 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
+ " 2. Configure passwordless sudo for your user",
+ )
+}
+
+func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
+ for _, v := range m {
+ if v {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
+ for _, dep := range dependencies {
+ if dep.Name == name {
+ return true
+ }
+ }
+ return false
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner_test.go
new file mode 100644
index 0000000..79260ae
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/headless/runner_test.go
@@ -0,0 +1,459 @@
+package headless
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
+)
+
+func TestParseWindowManager(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want deps.WindowManager
+ wantErr bool
+ }{
+ {"niri lowercase", "niri", deps.WindowManagerNiri, false},
+ {"niri mixed case", "Niri", deps.WindowManagerNiri, false},
+ {"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
+ {"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
+ {"invalid", "sway", 0, true},
+ {"empty", "", 0, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRunner(Config{Compositor: tt.input})
+ got, err := r.parseWindowManager()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && got != tt.want {
+ t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseTerminal(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want deps.Terminal
+ wantErr bool
+ }{
+ {"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
+ {"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
+ {"kitty lowercase", "kitty", deps.TerminalKitty, false},
+ {"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
+ {"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
+ {"invalid", "wezterm", 0, true},
+ {"empty", "", 0, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRunner(Config{Terminal: tt.input})
+ got, err := r.parseTerminal()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && got != tt.want {
+ t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDepExists(t *testing.T) {
+ dependencies := []deps.Dependency{
+ {Name: "niri", Status: deps.StatusInstalled},
+ {Name: "ghostty", Status: deps.StatusMissing},
+ {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
+ {Name: "dms-greeter", Status: deps.StatusMissing},
+ }
+
+ tests := []struct {
+ name string
+ dep string
+ want bool
+ }{
+ {"existing dep", "niri", true},
+ {"existing dep with special chars", "dms (DankMaterialShell)", true},
+ {"existing optional dep", "dms-greeter", true},
+ {"non-existing dep", "firefox", false},
+ {"empty name", "", false},
+ }
+
+ r := NewRunner(Config{})
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := r.depExists(dependencies, tt.dep); got != tt.want {
+ t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNewRunner(t *testing.T) {
+ cfg := Config{
+ Compositor: "niri",
+ Terminal: "ghostty",
+ IncludeDeps: []string{"dms-greeter"},
+ ExcludeDeps: []string{"some-pkg"},
+ Yes: true,
+ }
+ r := NewRunner(cfg)
+
+ if r == nil {
+ t.Fatal("NewRunner returned nil")
+ }
+ if r.cfg.Compositor != "niri" {
+ t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
+ }
+ if r.cfg.Terminal != "ghostty" {
+ t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
+ }
+ if !r.cfg.Yes {
+ t.Error("cfg.Yes = false, want true")
+ }
+ if r.logChan == nil {
+ t.Error("logChan is nil")
+ }
+}
+
+func TestGetLogChan(t *testing.T) {
+ r := NewRunner(Config{})
+ ch := r.GetLogChan()
+ if ch == nil {
+ t.Fatal("GetLogChan returned nil")
+ }
+
+ // Verify the channel is readable by sending a message
+ go func() {
+ r.logChan <- "test message"
+ }()
+ msg := <-ch
+ if msg != "test message" {
+ t.Errorf("received %q, want %q", msg, "test message")
+ }
+}
+
+func TestLog(t *testing.T) {
+ r := NewRunner(Config{})
+
+ // log should not block even if channel is full
+ for i := 0; i < 1100; i++ {
+ r.log("message")
+ }
+ // If we reach here without hanging, the non-blocking send works
+}
+
+func TestRunRequiresYes(t *testing.T) {
+ // Verify that ErrConfirmationRequired is a distinct sentinel error
+ if ErrConfirmationRequired == nil {
+ t.Fatal("ErrConfirmationRequired should not be nil")
+ }
+ expected := "confirmation required: pass --yes to proceed"
+ if ErrConfirmationRequired.Error() != expected {
+ t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
+ }
+}
+
+func TestConfigYesStoredCorrectly(t *testing.T) {
+ // Yes=false (default) should be stored
+ rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
+ if rNo.cfg.Yes {
+ t.Error("cfg.Yes = true, want false")
+ }
+
+ // Yes=true should be stored
+ rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
+ if !rYes.cfg.Yes {
+ t.Error("cfg.Yes = false, want true")
+ }
+}
+
+func TestValidConfigNamesCompleteness(t *testing.T) {
+ // orderedConfigNames and validConfigNames must stay in sync.
+ if len(orderedConfigNames) != len(validConfigNames) {
+ t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
+ len(orderedConfigNames), len(validConfigNames))
+ }
+
+ // Every entry in orderedConfigNames must exist in validConfigNames.
+ for _, name := range orderedConfigNames {
+ if _, ok := validConfigNames[name]; !ok {
+ t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
+ }
+ }
+
+ // validConfigNames must have no extra keys not in orderedConfigNames.
+ ordered := make(map[string]bool, len(orderedConfigNames))
+ for _, name := range orderedConfigNames {
+ ordered[name] = true
+ }
+ for key := range validConfigNames {
+ if !ordered[key] {
+ t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
+ }
+ }
+}
+
+func TestBuildReplaceConfigs(t *testing.T) {
+ allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
+
+ tests := []struct {
+ name string
+ replaceConfigs []string
+ replaceAll bool
+ wantNil bool // expect nil (replace all)
+ wantEnabled []string // deployer keys that should be true
+ wantErr bool
+ }{
+ {
+ name: "neither flag set",
+ wantNil: false,
+ wantEnabled: nil, // all should be false
+ },
+ {
+ name: "replace-configs-all",
+ replaceAll: true,
+ wantNil: true,
+ },
+ {
+ name: "specific configs",
+ replaceConfigs: []string{"niri", "ghostty"},
+ wantNil: false,
+ wantEnabled: []string{"Niri", "Ghostty"},
+ },
+ {
+ name: "both flags set",
+ replaceConfigs: []string{"niri"},
+ replaceAll: true,
+ wantErr: true,
+ },
+ {
+ name: "invalid config name",
+ replaceConfigs: []string{"foo"},
+ wantErr: true,
+ },
+ {
+ name: "case insensitive",
+ replaceConfigs: []string{"NIRI", "Ghostty"},
+ wantNil: false,
+ wantEnabled: []string{"Niri", "Ghostty"},
+ },
+ {
+ name: "single config",
+ replaceConfigs: []string{"kitty"},
+ wantNil: false,
+ wantEnabled: []string{"Kitty"},
+ },
+ {
+ name: "whitespace entry",
+ replaceConfigs: []string{" ", "niri"},
+ wantNil: false,
+ wantEnabled: []string{"Niri"},
+ },
+ {
+ name: "duplicate entry",
+ replaceConfigs: []string{"niri", "niri"},
+ wantNil: false,
+ wantEnabled: []string{"Niri"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRunner(Config{
+ ReplaceConfigs: tt.replaceConfigs,
+ ReplaceConfigsAll: tt.replaceAll,
+ })
+ got, err := r.buildReplaceConfigs()
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if tt.wantErr {
+ return
+ }
+ if tt.wantNil {
+ if got != nil {
+ t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
+ }
+
+ // All known deployer keys must be present
+ for _, key := range allDeployerKeys {
+ if _, exists := got[key]; !exists {
+ t.Errorf("missing deployer key %q in result map", key)
+ }
+ }
+
+ // Build enabled set for easy lookup
+ enabledSet := make(map[string]bool)
+ for _, k := range tt.wantEnabled {
+ enabledSet[k] = true
+ }
+
+ for _, key := range allDeployerKeys {
+ want := enabledSet[key]
+ if got[key] != want {
+ t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
+ }
+ }
+ })
+ }
+}
+
+func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
+ r := NewRunner(Config{
+ Compositor: "niri",
+ Terminal: "ghostty",
+ ReplaceConfigs: []string{"niri", "ghostty"},
+ ReplaceConfigsAll: false,
+ })
+ if len(r.cfg.ReplaceConfigs) != 2 {
+ t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
+ }
+ if r.cfg.ReplaceConfigsAll {
+ t.Error("ReplaceConfigsAll = true, want false")
+ }
+
+ r2 := NewRunner(Config{
+ Compositor: "niri",
+ Terminal: "ghostty",
+ ReplaceConfigsAll: true,
+ })
+ if !r2.cfg.ReplaceConfigsAll {
+ t.Error("ReplaceConfigsAll = false, want true")
+ }
+ if len(r2.cfg.ReplaceConfigs) != 0 {
+ t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
+ }
+}
+
+func TestBuildDisabledItems(t *testing.T) {
+ dependencies := []deps.Dependency{
+ {Name: "niri", Status: deps.StatusInstalled},
+ {Name: "ghostty", Status: deps.StatusMissing},
+ {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
+ {Name: "dms-greeter", Status: deps.StatusMissing},
+ {Name: "waybar", Status: deps.StatusMissing},
+ }
+
+ tests := []struct {
+ name string
+ includeDeps []string
+ excludeDeps []string
+ deps []deps.Dependency // nil means use the shared fixture
+ wantErr bool
+ errContains string // substring expected in error message
+ wantDisabled []string // dep names that should be in disabledItems
+ wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
+ }{
+ {
+ name: "no flags set, dms-greeter disabled by default",
+ wantDisabled: []string{"dms-greeter"},
+ wantEnabled: []string{"niri", "ghostty", "waybar"},
+ },
+ {
+ name: "include dms-greeter enables it",
+ includeDeps: []string{"dms-greeter"},
+ wantEnabled: []string{"dms-greeter"},
+ },
+ {
+ name: "exclude a regular dep",
+ excludeDeps: []string{"waybar"},
+ wantDisabled: []string{"dms-greeter", "waybar"},
+ },
+ {
+ name: "include unknown dep returns error",
+ includeDeps: []string{"nonexistent"},
+ wantErr: true,
+ errContains: "--include-deps",
+ },
+ {
+ name: "exclude unknown dep returns error",
+ excludeDeps: []string{"nonexistent"},
+ wantErr: true,
+ errContains: "--exclude-deps",
+ },
+ {
+ name: "exclude DMS itself is forbidden",
+ excludeDeps: []string{"dms (DankMaterialShell)"},
+ wantErr: true,
+ errContains: "cannot exclude required package",
+ },
+ {
+ name: "include and exclude same dep",
+ includeDeps: []string{"dms-greeter"},
+ excludeDeps: []string{"dms-greeter"},
+ wantDisabled: []string{"dms-greeter"},
+ },
+ {
+ name: "whitespace entries are skipped",
+ includeDeps: []string{" ", "dms-greeter"},
+ wantEnabled: []string{"dms-greeter"},
+ },
+ {
+ name: "no dms-greeter in deps, nothing disabled by default",
+ deps: []deps.Dependency{
+ {Name: "niri", Status: deps.StatusInstalled},
+ },
+ wantEnabled: []string{"niri"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRunner(Config{
+ IncludeDeps: tt.includeDeps,
+ ExcludeDeps: tt.excludeDeps,
+ })
+ d := tt.deps
+ if d == nil {
+ d = dependencies
+ }
+ got, err := r.buildDisabledItems(d)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if tt.wantErr {
+ if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatal("buildDisabledItems() returned nil map, want non-nil")
+ }
+
+ // Check expected disabled items
+ for _, name := range tt.wantDisabled {
+ if !got[name] {
+ t.Errorf("expected %q to be disabled, but it is not", name)
+ }
+ }
+
+ // Check expected enabled items (should not be in the map or be false)
+ for _, name := range tt.wantEnabled {
+ if got[name] {
+ t.Errorf("expected %q to NOT be disabled, but it is", name)
+ }
+ }
+
+ // If wantDisabled is empty, the map should have length 0
+ if len(tt.wantDisabled) == 0 && len(got) != 0 {
+ t.Errorf("expected empty disabledItems map, got %v", got)
+ }
+ })
+ }
+}