Просмотр исходного кода

look for definitions/ and profiles/ in a per-user directory

A binary installed with `go install` sits in $GOPATH/bin, where neither
directory exists, and a fetch that wrote there put the file where no
reinstall would keep it. Both are now looked for next to the executable,
then in the working directory, then under the platform's own configuration
directory, which os.UserConfigDir answers: ~/.config on Linux,
~/Library/Application Support on macOS, %AppData% on Windows. A hardcoded
~/.config would be wrong on two of the three.

The repository comes before the user directory, so a project that ships
its own definitions keeps using them, and a new directory is created in
the user's. resolveDataDir in datadir.go is the only place either is
resolved, and definition list and fetch name the directory they used and
mark the one that is not where the command was run from.
Paul Klumpp 1 неделя назад
Родитель
Сommit
9ad4b19532

+ 3 - 1
.claude/skills/qmk-rgb/SKILL.md

@@ -55,7 +55,9 @@ If no keyboard is connected:
 
 ### 4. Profiles
 
-Profiles are stored as JSON in `profiles/`. They persist RGB state across reboots.
+Profiles are stored as JSON in `profiles/`, resolved next to the executable, then
+in the working directory, then under the platform's user configuration directory
+(`~/.config/qmk-rgb-tool/` on Linux). They persist RGB state across reboots.
 
 ```bash
 qmk-rgb-tool list       # List saved profiles

+ 9 - 0
AGENTS.md

@@ -38,6 +38,15 @@ with neither is still driven, because the channel numbers follow from the QMK
 subsystem they belong to. `keyboards.json` used to supply both and is gone: two
 places to update is how the channel names and the catalog drifted apart.
 
+Where a board's own files live is `definitions/` and `profiles/`, resolved by
+`resolveDataDir` in `cmd/qmk-rgb-tool/datadir.go` and nowhere else: next to the
+executable, then the working directory, then the platform's per-user
+configuration directory. The order matters and is not negotiable — a project that
+ships its own definitions has to keep using them, so the user directory comes
+last, and it is also where a new directory is created. `os.UserConfigDir` is what
+answers the last one, so a hardcoded `~/.config` would be wrong on macOS and
+Windows.
+
 `keyboard info` does not open the board, so it must not report the board's
 channels: it does not know them, and printing the ones a definition names would
 claim more than it knows. `info` and `effect --list` open the board and report

+ 20 - 6
README.md

@@ -37,12 +37,26 @@ qmk-rgb-tool definition fetch   # download the definition for the connected keyb
 qmk-rgb-tool definition list    # what is in the data directory
 ```
 
-`fetch` writes into `definitions/`, next to the executable or in the working
-directory. A file already there is used for its own board without any argument, so
-**to use a definition you have already, put it in that directory** or pass it with
-`--definition <path>`; [`definitions/README.md`](definitions/README.md) says what
-belongs there. The Wobkey Impact 80's file is vendored, because that board is not
-in VIA's collection.
+**Where those files live.** `definitions/` and `profiles/` are looked for in one
+order: next to the executable, then in the working directory, then under the
+platform's per-user configuration directory. The first that exists is used, and a
+directory that has to be created goes into the per-user one:
+
+| Platform | Directory |
+|---|---|
+| Linux | `~/.config/qmk-rgb-tool/` (`$XDG_CONFIG_HOME`) |
+| macOS | `~/Library/Application Support/qmk-rgb-tool/` |
+| Windows | `%AppData%\qmk-rgb-tool\` |
+
+A repository checkout wins over the user directory on purpose: a project that
+ships its own definitions, as this one does, has to keep using them, or a stale
+file in someone's home directory would silently name a board they are working on.
+
+A file already in a definitions directory is used for its own board without any
+argument, so **to use a definition you have already, put it in that directory** or
+pass it with `--definition <path>`;
+[`definitions/README.md`](definitions/README.md) says what belongs there. The Wobkey
+Impact 80's file is vendored, because that board is not in VIA's collection.
 
 Without a definition the keyboard is still fully driven with `brightness`, `speed`,
 `color`, `effect <index>` and `info` — only the effect names are missing, and

+ 18 - 58
cmd/qmk-rgb-tool/catalog.go

@@ -2,8 +2,6 @@ package main
 
 import (
 	"fmt"
-	"os"
-	"path/filepath"
 
 	intdevice "netdome.biz/paul/qmk-rgb/internal/device"
 	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
@@ -54,11 +52,9 @@ func resolveCatalogFor(vendorID, productID uint16) (*intrgb.Catalog, string, err
 		return def.Catalog, def.Path, nil
 	}
 
-	if dir, err := definitionsDir(); err == nil {
-		if defs, loadErr := intrgb.LoadDefinitionsDir(dir); loadErr == nil {
-			if def := intrgb.FindDefinition(defs, vendorID, productID); def != nil {
-				return def.Catalog, def.Path, nil
-			}
+	if defs, err := intrgb.LoadDefinitionsDir(definitionsPath()); err == nil {
+		if def := intrgb.FindDefinition(defs, vendorID, productID); def != nil {
+			return def.Catalog, def.Path, nil
 		}
 	}
 
@@ -67,31 +63,6 @@ func resolveCatalogFor(vendorID, productID uint16) (*intrgb.Catalog, string, err
 	return nil, "", nil
 }
 
-// lookupPaths returns the directories a data file is looked for in: the one
-// holding the executable, then the working directory, which is the order the
-// profile directory uses too.
-func lookupPaths() []string {
-	var paths []string
-	if exe, err := os.Executable(); err == nil {
-		paths = append(paths, filepath.Dir(exe))
-	}
-	if cwd, err := os.Getwd(); err == nil {
-		paths = append(paths, cwd)
-	}
-	return paths
-}
-
-// definitionLabels returns the channel names a definition gives the board, which
-// are the names VIA shows. They are the only channel names there are: a board
-// without a definition is addressed by its QMK subsystem name.
-func definitionLabels(vendorID, productID uint16) map[uint16]string {
-	def := loadedDefinition(vendorID, productID)
-	if def == nil {
-		return nil
-	}
-	return def.Labels
-}
-
 // loadedDefinition returns the definition file for a board, from the data
 // directory or from the file --definition names, and nil when there is none.
 func loadedDefinition(vendorID, productID uint16) *intrgb.Definition {
@@ -101,22 +72,30 @@ func loadedDefinition(vendorID, productID uint16) *intrgb.Definition {
 		}
 		return nil
 	}
-	dir, err := definitionsDir()
+	defs, err := intrgb.LoadDefinitionsDir(definitionsPath())
 	if err != nil {
 		return nil
 	}
-	defs, err := intrgb.LoadDefinitionsDir(dir)
-	if err != nil {
+	return intrgb.FindDefinition(defs, vendorID, productID)
+}
+
+// definitionLabels returns the channel names a definition gives the board, which
+// are the names VIA shows. They are the only channel names there are: a board
+// without a definition is addressed by its QMK subsystem name.
+func definitionLabels(vendorID, productID uint16) map[uint16]string {
+	def := loadedDefinition(vendorID, productID)
+	if def == nil {
 		return nil
 	}
-	return intrgb.FindDefinition(defs, vendorID, productID)
+	return def.Labels
 }
 
 // applyDefinitionLabels returns the display names a board answers to and, beside
 // them, the alternatives each channel keeps. The definition's label is the name
 // the board is called in VIA; the QMK subsystem name stays an accepted
 // alternative, because it follows from the channel number and is the one a
-// document can promise without knowing the board.
+// document can promise without knowing a board.
+//
 // Only a channel the definition names gets a display name, and that is
 // deliberate. Adding an entry for every QMK lighting channel would put
 // "backlight" on channel 1 of a board that has none, where the same word is also
@@ -148,31 +127,12 @@ func boardName(dev intdevice.Device, _ map[uint16]string) string {
 	return "unknown model"
 }
 
-// definitionsDir returns the data directory, looked for next to the executable
-// first and then in the working directory.
-var definitionsDir = func() (string, error) {
-	paths := lookupPaths()
-	for _, p := range paths {
-		dir := filepath.Join(p, intrgb.DefinitionsDir)
-		if info, err := os.Stat(dir); err == nil && info.IsDir() {
-			return dir, nil
-		}
-	}
-	if len(paths) == 0 {
-		return intrgb.DefinitionsDir, nil
-	}
-	return filepath.Join(paths[0], intrgb.DefinitionsDir), nil
-}
-
 // ensureDefinitionsDir returns the data directory, creating it if it is not
 // there yet, so a fetch has somewhere to write to.
 func ensureDefinitionsDir() (string, error) {
-	dir, err := definitionsDir()
+	dir, err := ensureDataDir(definitionsPath())
 	if err != nil {
-		return "", err
-	}
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		return "", fmt.Errorf("create %s: %w", dir, err)
+		return "", fmt.Errorf("create %s: %w", definitionsPath(), err)
 	}
 	return dir, nil
 }

+ 15 - 15
cmd/qmk-rgb-tool/channels_test.go

@@ -155,9 +155,9 @@ func TestLoadWarnsForEveryUnresolvableZoneKey(t *testing.T) {
 			"old-backlight": {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"},
 		},
 	}
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	if err := profile.Save(); err != nil {
 		t.Fatalf("save profile: %v", err)
@@ -227,9 +227,9 @@ func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string,
 func TestLoadWarnsWhenAKeyNamesAnAbsentChannel(t *testing.T) {
 	t.Cleanup(vendoredDefinitions(t))
 	dir := t.TempDir()
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	profile := Profile{
 		Name:    "renamed",
@@ -266,9 +266,9 @@ func TestLoadWarnsWhenAKeyNamesAnAbsentChannel(t *testing.T) {
 // in. The load must say that, not claim the name was not found.
 func TestLoadSaysSoWhenTheBoardHasNoCatalog(t *testing.T) {
 	dir := t.TempDir()
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	profile := Profile{
 		Name:    "p",
@@ -315,18 +315,18 @@ func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func
 	t.Helper()
 
 	originalTarget := openTarget
-	originalDir := profilesDir
+	originalDir := profilesDirOverride
 
 	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
 		return proto, targetDeviceData{
 			Device: intdevice.Device{VendorID: 0x6666, ProductID: 0x0001},
 		}, impact80Channels(), nil
 	}
-	profilesDir = dir
+	profilesDirOverride = dir
 
 	return func() {
 		openTarget = originalTarget
-		profilesDir = originalDir
+		profilesDirOverride = originalDir
 	}
 }
 
@@ -335,9 +335,9 @@ func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func
 // save time, where the user can still do something about it.
 func TestSaveWarnsThatEffectNamesCannotBeRecorded(t *testing.T) {
 	dir := t.TempDir()
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	proto := &verifyingProtocol{applied: map[via.Channel]uint8{
 		via.ChannelRgblight:  160,

+ 5 - 7
cmd/qmk-rgb-tool/completion.go

@@ -24,12 +24,10 @@ func completeZoneNames(cmd *cobra.Command, args []string, toComplete string) ([]
 	for _, ch := range via.LightingChannels {
 		names = append(names, ch.Subsystem())
 	}
-	if dir, err := definitionsDir(); err == nil {
-		if defs, loadErr := intrgb.LoadDefinitionsDir(dir); loadErr == nil {
-			for _, def := range defs {
-				for _, label := range def.Labels {
-					names = append(names, label)
-				}
+	if defs, err := intrgb.LoadDefinitionsDir(definitionsPath()); err == nil {
+		for _, def := range defs {
+			for _, label := range def.Labels {
+				names = append(names, label)
 			}
 		}
 	}
@@ -53,7 +51,7 @@ func completeDeviceNumbers(cmd *cobra.Command, args []string, toComplete string)
 // completeProfileNames offers the profiles in the directory, because load and
 // delete take a name that is a file there.
 func completeProfileNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-	entries, err := os.ReadDir(profilesDir)
+	entries, err := os.ReadDir(profilesPath())
 	if err != nil {
 		return nil, cobra.ShellCompDirectiveNoFileComp
 	}

+ 6 - 6
cmd/qmk-rgb-tool/completion_test.go

@@ -113,9 +113,9 @@ func TestDeviceCompletionSurvivesADiscoveryFailure(t *testing.T) {
 // while nothing is plugged in.
 func TestProfileNameCompletionOffersTheFilesInTheDirectory(t *testing.T) {
 	dir := t.TempDir()
-	original := profilesDir
-	profilesDir = dir
-	t.Cleanup(func() { profilesDir = original })
+	original := profilesDirOverride
+	profilesDirOverride = dir
+	t.Cleanup(func() { profilesDirOverride = original })
 
 	for _, name := range []string{"paul.json", "default.json", "notes.txt"} {
 		if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o600); err != nil {
@@ -132,9 +132,9 @@ func TestProfileNameCompletionOffersTheFilesInTheDirectory(t *testing.T) {
 }
 
 func TestProfileNameCompletionSurvivesAMissingDirectory(t *testing.T) {
-	original := profilesDir
-	profilesDir = filepath.Join(t.TempDir(), "absent")
-	t.Cleanup(func() { profilesDir = original })
+	original := profilesDirOverride
+	profilesDirOverride = filepath.Join(t.TempDir(), "absent")
+	t.Cleanup(func() { profilesDirOverride = original })
 
 	got, directive := completeProfileNames(nil, nil, "")
 	if directive != cobra.ShellCompDirectiveNoFileComp {

+ 116 - 0
cmd/qmk-rgb-tool/datadir.go

@@ -0,0 +1,116 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// Profiles and definitions are the tool's own files, and a binary installed with
+// `go install` sits in $GOPATH/bin where neither of them exists. They are looked
+// for next to the binary, then in the working directory, then under the platform's
+// per-user configuration directory. The first that exists wins, and the user
+// directory is where one is created.
+//
+// The order puts a repository checkout before the user directory on purpose: a
+// project that ships its own definitions, as this one does, must keep using them.
+// The other way round, a stale file in someone's home directory would silently
+// name a board they are working on.
+const dataDirName = "qmk-rgb-tool"
+
+// userConfigDir is a seam: os.UserConfigDir is the platform's own answer, and a
+// test cannot rely on which platform it runs on.
+var userConfigDir = os.UserConfigDir
+
+// executableDir and workingDir are seams for the same reason.
+var executableDir = func() string {
+	if exe, err := os.Executable(); err == nil {
+		return filepath.Dir(exe)
+	}
+	return ""
+}
+
+var workingDir = os.Getwd
+
+// The two overrides short-circuit the lookup, which is how the tests point the
+// lookup at a temporary directory. Empty means resolve it.
+var (
+	profilesDirOverride    string
+	definitionsDirOverride string
+)
+
+// resolveDataDir returns the directory a named set of files lives in: the first
+// that exists, in the order next to the binary, then the working directory, then
+// the user's. When none exists the user's is returned, because that is where
+// something new belongs.
+func resolveDataDir(exeDir, cwd, name string) string {
+	for _, dir := range []string{
+		filepath.Join(exeDir, name),
+		filepath.Join(cwd, name),
+		filepath.Join(userDataDir(), name),
+	} {
+		if dir == name {
+			continue // an empty parent would resolve to a relative path
+		}
+		if info, err := os.Stat(dir); err == nil && info.IsDir() {
+			return dir
+		}
+	}
+	return filepath.Join(userDataDir(), name)
+}
+
+// userDataDir is this tool's directory under the platform's configuration
+// directory: ~/.config/qmk-rgb-tool on Linux, ~/Library/Application
+// Support/qmk-rgb-tool on macOS, %AppData%\qmk-rgb-tool on Windows. A hardcoded
+// ~/.config would be wrong on two of the three.
+func userDataDir() string {
+	base, err := userConfigDir()
+	if err != nil || base == "" {
+		return dataDirName
+	}
+	return filepath.Join(base, dataDirName)
+}
+
+// definitionsPath is where the VIA definition files are read from and fetched to.
+func definitionsPath() string {
+	if definitionsDirOverride != "" {
+		return definitionsDirOverride
+	}
+	return resolveDataDir(executableDir(), workingDirResult(), "definitions")
+}
+
+// profilesPath is where the profiles are read from and written to.
+func profilesPath() string {
+	if profilesDirOverride != "" {
+		return profilesDirOverride
+	}
+	return resolveDataDir(executableDir(), workingDirResult(), "profiles")
+}
+
+// workingDirResult keeps a failing os.Getwd from turning into a relative path.
+func workingDirResult() string {
+	cwd, err := workingDir()
+	if err != nil {
+		return ""
+	}
+	return cwd
+}
+
+// ensureDataDir returns a directory and creates it, for the commands that write.
+func ensureDataDir(path string) (string, error) {
+	if err := os.MkdirAll(path, 0o755); err != nil {
+		return "", err
+	}
+	return path, nil
+}
+
+// describeDataDir annotates a path only where the answer is not the obvious one:
+// the user directory is neither beside the binary nor in the directory the
+// command was run from, so a path pointing there is worth marking. The other two
+// are the two places a user would look, and the full path is printed either way.
+func describeDataDir(path string) string {
+	if user := userDataDir(); path == user || strings.HasPrefix(path, user+string(filepath.Separator)) {
+		return path + " (your user directory)"
+	}
+	return path
+}

+ 136 - 0
cmd/qmk-rgb-tool/datadir_test.go

@@ -0,0 +1,136 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// Profiles and definitions are the tool's own files, and a binary installed with
+// `go install` sits in $GOPATH/bin where neither of them exists. They need a
+// per-user directory, and they need the same one on Linux, macOS and Windows,
+// which is what os.UserConfigDir answers and a hardcoded ~/.config does not.
+func TestDataDirectoryPrefersTheExecutableDirectory(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+
+	exeDir := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(exeDir, "definitions"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	cwd := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(cwd, "definitions"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	got := resolveDataDir(exeDir, cwd, "definitions")
+	if want := filepath.Join(exeDir, "definitions"); got != want {
+		t.Errorf("resolveDataDir() = %q, want %q: the directory holding the binary wins", got, want)
+	}
+}
+
+func TestDataDirectoryFallsBackToTheWorkingDirectory(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+
+	exeDir := t.TempDir() // no definitions/ beside the binary
+	cwd := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(cwd, "definitions"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	got := resolveDataDir(exeDir, cwd, "definitions")
+	if want := filepath.Join(cwd, "definitions"); got != want {
+		t.Errorf("resolveDataDir() = %q, want %q: a repository checkout wins over the user directory", got, want)
+	}
+}
+
+func TestDataDirectoryFallsBackToTheUserDirectory(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+
+	got := resolveDataDir(t.TempDir(), t.TempDir(), "definitions")
+	if want := filepath.Join(userDir, "qmk-rgb-tool", "definitions"); got != want {
+		t.Errorf("resolveDataDir() = %q, want %q", got, want)
+	}
+}
+
+// When nothing exists yet, the directory a fetch or a save creates is the user's,
+// not one beside a binary in $GOPATH/bin that nobody owns and that a reinstall
+// would replace.
+func TestDataDirectoryIsCreatedUnderTheUserDirectory(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+
+	got := resolveDataDir(t.TempDir(), t.TempDir(), "profiles")
+	if want := filepath.Join(userDir, "qmk-rgb-tool", "profiles"); got != want {
+		t.Errorf("resolveDataDir() = %q, want %q", got, want)
+	}
+}
+
+// Both directories resolve the same way, or a user who installed the binary
+// globally finds definitions and not profiles.
+func TestProfilesAndDefinitionsUseTheSameOrder(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(func() { profilesDirOverride = "" })
+
+	exeDir := t.TempDir()
+	t.Cleanup(stubExecutableDir(t, exeDir))
+	t.Cleanup(stubWorkingDir(t, t.TempDir()))
+
+	for _, name := range []string{"definitions", "profiles"} {
+		if err := os.MkdirAll(filepath.Join(exeDir, name), 0o755); err != nil {
+			t.Fatal(err)
+		}
+	}
+	t.Cleanup(func() {
+		for _, name := range []string{"definitions", "profiles"} {
+			_ = os.RemoveAll(filepath.Join(exeDir, name))
+		}
+	})
+
+	if got, want := definitionsPath(), filepath.Join(exeDir, "definitions"); got != want {
+		t.Errorf("definitionsPath() = %q, want %q", got, want)
+	}
+	if got, want := profilesPath(), filepath.Join(exeDir, "profiles"); got != want {
+		t.Errorf("profilesPath() = %q, want %q", got, want)
+	}
+}
+
+// The user directory is reported so the commands can say where a file came from.
+func TestDataDirectoryIsReportedForTheUser(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+
+	got := definitionsPath()
+	if got == "" {
+		t.Fatal("definitionsPath() = \"\", want a path")
+	}
+	if !filepath.IsAbs(got) {
+		t.Errorf("definitionsPath() = %q, want an absolute path so it can be printed", got)
+	}
+}
+
+// stubUserConfigDir points the platform's configuration directory at a temp one.
+func stubUserConfigDir(t *testing.T, dir string) func() {
+	t.Helper()
+	original := userConfigDir
+	userConfigDir = func() (string, error) { return dir, nil }
+	return func() { userConfigDir = original }
+}
+
+func stubExecutableDir(t *testing.T, dir string) func() {
+	t.Helper()
+	original := executableDir
+	executableDir = func() string { return dir }
+	return func() { executableDir = original }
+}
+
+func stubWorkingDir(t *testing.T, dir string) func() {
+	t.Helper()
+	original := workingDir
+	workingDir = func() (string, error) { return dir, nil }
+	return func() { workingDir = original }
+}

+ 12 - 6
cmd/qmk-rgb-tool/definition.go

@@ -86,7 +86,7 @@ func runDefinitionFetch(cmd *cobra.Command, args []string) error {
 	}
 
 	fmt.Fprintf(cmd.OutOrStdout(), "Saved definition for %s (0x%04X/0x%04X) to %s\n",
-		def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, path)
+		def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, describeDataDir(path))
 	for _, ch := range def.channels {
 		fmt.Fprintf(cmd.OutOrStdout(), "  %-10s %d effects\n", ch.Subsystem(), len(def.Definition.Catalog.Effects(ch)))
 	}
@@ -94,10 +94,7 @@ func runDefinitionFetch(cmd *cobra.Command, args []string) error {
 }
 
 func runDefinitionList(cmd *cobra.Command, args []string) error {
-	dir, err := definitionsDir()
-	if err != nil {
-		return err
-	}
+	dir := definitionsPath()
 	if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
 		if jsonOutput {
 			return encodeJSON(cmd.OutOrStdout(), struct {
@@ -155,6 +152,7 @@ func runDefinitionList(cmd *cobra.Command, args []string) error {
 		fmt.Fprintf(cmd.OutOrStdout(), "No definition files in %s\n", dir)
 		return nil
 	}
+	fmt.Fprintf(cmd.OutOrStdout(), "Definitions in %s\n\n", describeDataDir(dir))
 	for _, line := range lines {
 		fmt.Fprintf(cmd.OutOrStdout(), "%s (%s/%s) %s\n", line.Name, line.VendorID, line.ProductID, line.Path)
 		for _, ch := range line.Channels {
@@ -227,7 +225,15 @@ func fetchDefinition(vendorID, productID uint16) (*fetchedDefinition, error) {
 
 	return nil, fmt.Errorf("no definition for this keyboard (0x%04X/0x%04X) at %s: %s; "+
 		"if the manufacturer publishes one, put it in %s or pass --definition",
-		vendorID, productID, definitionHost, lastReason, intrgb.DefinitionsDir)
+		vendorID, productID, definitionHost, lastReason, ensureDefinitionsDirHint())
+}
+
+// ensureDefinitionsDirHint is the directory a message can name, with the user
+// directory named outright so the advice to put a file somewhere is actionable.
+// A directory that cannot be created is not this message's problem, so the
+// lookup is not created here.
+func ensureDefinitionsDirHint() string {
+	return describeDataDir(definitionsPath())
 }
 
 // definitionFileName names a stored definition after the board, so a directory

+ 3 - 3
cmd/qmk-rgb-tool/definition_test.go

@@ -178,9 +178,9 @@ func definitionFlagRestore(t *testing.T) func() {
 // test that wants its own has to make that lookup find it.
 func forceDefinitionsDir(t *testing.T, dir string) func() {
 	t.Helper()
-	original := definitionsDir
-	definitionsDir = func() (string, error) { return dir, nil }
-	return func() { definitionsDir = original }
+	original := definitionsDirOverride
+	definitionsDirOverride = dir
+	return func() { definitionsDirOverride = original }
 }
 
 // stubHTTPGet answers every request from a function instead of a network.

+ 7 - 9
cmd/qmk-rgb-tool/profile.go

@@ -14,9 +14,6 @@ import (
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
-// profilesDir is a var so tests can redirect it at a temp directory.
-var profilesDir = "profiles"
-
 type Profile struct {
 	Name    string                   `json:"name"`
 	Version int                      `json:"version"`
@@ -89,8 +86,9 @@ type ZoneSettings struct {
 	Color      string `json:"color"`
 }
 
+// ProfilesPath is where the profiles are read from and written to.
 func ProfilesPath() string {
-	return filepath.Join(profilesDir)
+	return profilesPath()
 }
 
 func (p *Profile) Save() error {
@@ -98,7 +96,7 @@ func (p *Profile) Save() error {
 		return fmt.Errorf("profile name is required")
 	}
 
-	if err := os.MkdirAll(profilesDir, 0755); err != nil {
+	if err := os.MkdirAll(profilesPath(), 0o755); err != nil {
 		return fmt.Errorf("create profiles directory: %w", err)
 	}
 
@@ -107,7 +105,7 @@ func (p *Profile) Save() error {
 		return fmt.Errorf("marshal profile: %w", err)
 	}
 
-	path := filepath.Join(profilesDir, sanitizeFilename(p.Name)+".json")
+	path := filepath.Join(profilesPath(), sanitizeFilename(p.Name)+".json")
 	if err := os.WriteFile(path, data, 0644); err != nil {
 		return fmt.Errorf("write profile: %w", err)
 	}
@@ -115,7 +113,7 @@ func (p *Profile) Save() error {
 }
 
 func LoadProfile(name string) (*Profile, error) {
-	path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
+	path := filepath.Join(profilesPath(), sanitizeFilename(name)+".json")
 	data, err := os.ReadFile(path)
 	if err != nil {
 		return nil, fmt.Errorf("read profile %s: %w", name, err)
@@ -129,7 +127,7 @@ func LoadProfile(name string) (*Profile, error) {
 }
 
 func ListProfiles() ([]string, error) {
-	entries, err := os.ReadDir(profilesDir)
+	entries, err := os.ReadDir(profilesPath())
 	if err != nil {
 		if os.IsNotExist(err) {
 			return nil, nil
@@ -152,7 +150,7 @@ func ListProfiles() ([]string, error) {
 }
 
 func DeleteProfile(name string) error {
-	path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
+	path := filepath.Join(profilesPath(), sanitizeFilename(name)+".json")
 	if err := os.Remove(path); err != nil {
 		if os.IsNotExist(err) {
 			return fmt.Errorf("profile %s not found", name)

+ 3 - 3
cmd/qmk-rgb-tool/profile_list_test.go

@@ -22,9 +22,9 @@ func withProfilesDir(t *testing.T, names ...string) {
 			t.Fatalf("write profile %s: %v", n, err)
 		}
 	}
-	orig := profilesDir
-	profilesDir = dir
-	t.Cleanup(func() { profilesDir = orig })
+	orig := profilesDirOverride
+	profilesDirOverride = dir
+	t.Cleanup(func() { profilesDirOverride = orig })
 }
 
 func runProfileList(t *testing.T) string {

+ 6 - 6
cmd/qmk-rgb-tool/profile_load_test.go

@@ -25,9 +25,9 @@ func TestLoadAppliesOnlyTheKeysTheSelectionNames(t *testing.T) {
 			"side":      {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"},
 		},
 	}
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	if err := profile.Save(); err != nil {
 		t.Fatalf("save profile: %v", err)
@@ -82,9 +82,9 @@ func effectWrites(proto *verifyingProtocol) int {
 func TestLoadIsQuietAboutAKeyTheSelectionExcludes(t *testing.T) {
 	t.Cleanup(vendoredDefinitions(t))
 	dir := t.TempDir()
-	originalDir := profilesDir
-	t.Cleanup(func() { profilesDir = originalDir })
-	profilesDir = dir
+	originalDir := profilesDirOverride
+	t.Cleanup(func() { profilesDirOverride = originalDir })
+	profilesDirOverride = dir
 
 	profile := Profile{
 		Name:    "three",

+ 5 - 4
internal/rgb/definition.go

@@ -12,10 +12,11 @@ import (
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
-// DefinitionsDir is where definition files are looked for: the one to fetch
-// into, and the one a user places a manufacturer's file in by hand. The
-// keyboard is identified first, so a file is only ever read for a board whose
-// vendor and product ID match.
+// DefinitionsDir is the name of the directory definition files are kept in. It
+// is a name and not a location: the tool resolves where that directory is, and
+// where it is written to when created, in resolveDataDir. The keyboard is
+// identified first, so a file is only ever read for a board whose vendor and
+// product ID match.
 const DefinitionsDir = "definitions"
 
 // VendorProductID is the key a definition is filed under, and the key the