Parcourir la source

let load and save take a path, and say which file a save wrote

A profile argument ending in .json is a path and is used exactly as given, so
`load profiles/lava.json` reads that file and `save ./lava.json` writes it.
Every other argument is a profile name and comes from the per-user directory,
which is what both directories have been for since the search was removed. A
profile that lives in a checkout is reachable again: a name resolves to one
place, so a file there could be read by nothing.

It is not a search, and that is what keeps the defect out. The old search
looked for a name in a checkout before the user's directory, so `load lava`
found a different file with the same name depending on where it was run, and a
save followed by a load did not return what was saved. An argument that names a
file is not a name: it is the same argument in every working directory and it
names one file either way. resolveProfileTarget in profile.go is the whole rule
and it has no fallback between the two forms, because a fallback is the search
again.

The suffix is the whole trigger, matched without regard to case so the rule
reads the same on Linux, macOS and Windows, and no separator is looked for: the
Windows file API takes both `/` and `\`, so a check for either would be a
platform difference with no behaviour behind it. That also ends what `load
lala.json` did, which was sanitizeFilename's: the dot became a dash, so the
command looked for `lala-json.json`, a file no save could ever write. It now
looks for `./lala.json` and reports the path it tried.

`save` writes the file the argument names and names the profile after it, so the
file and the name in it agree, and it reports what it wrote: a save that can
write anywhere and says only "saved" leaves the one thing worth reporting
unsaid. profilesWritePath goes with it — nothing called it, it returned what
profilesPath returns, and its comment claimed a rule no code enforced.

`delete` and `list` stay name-only, so a file by path is read and written and
never removed, and the completion keeps offering the names in the user's
directory.

Seven tests pin it: the rule over both forms including `LALA.JSON`; a name and a
path resolving to two different files of the same name; a missing path naming the
path and not the directory; `lala.json` not becoming `lala-json.json`; a save
writing where it was told, with the name taken from the file's own base; a save
by name staying in the user's directory; and the reported line. Measured on the
Impact 80: the same profile name in a checkout and in the user's directory loads
to a different logo brightness, so the form of the argument decides the file.
Paul Klumpp il y a 1 semaine
Parent
commit
3b713bc8fd

+ 10 - 6
.claude/skills/qmk-rgb/SKILL.md

@@ -61,14 +61,18 @@ If no keyboard is connected:
 Profiles are stored as JSON in the per-user `profiles/` directory
 (`~/.config/qmk-rgb-tool/profiles` on Linux) — the same place they are read from
 and written to, so a `save` is a `load` away. A checkout's own `profiles/`
-directory is not used. They persist RGB state across reboots.
+directory is not searched for a name; an argument ending in `.json` is a path, and
+that file is read or written where it says, so `load profiles/lava.json` uses a
+file in the working directory. They persist RGB state across reboots.
 
 ```bash
-qmk-rgb-tool list            # List saved profiles
-qmk-rgb-tool save name       # Save the state of every channel
-qmk-rgb-tool load name       # Apply a profile to every channel it names
-qmk-rgb-tool load name side  # Apply it to one channel or a list of them only
-qmk-rgb-tool delete name     # Remove a profile
+qmk-rgb-tool list                    # List saved profiles
+qmk-rgb-tool save name               # Save the state of every channel
+qmk-rgb-tool save profiles/lava.json # Save to that file instead
+qmk-rgb-tool load name               # Apply a profile to every channel it names
+qmk-rgb-tool load name side          # Apply it to one channel or a list of them only
+qmk-rgb-tool load profiles/lava.json # Apply the profile in that file
+qmk-rgb-tool delete name             # Remove a profile, by name only
 ```
 
 ## Setting up a keyboard whose effect names are missing

+ 14 - 3
AGENTS.md

@@ -58,6 +58,15 @@ was saved. A test that a definitions or profiles directory beside the binary is
 ignored, in `TestDataDirectoriesAreNotSearched`, is what stops the search coming
 back.
 
+One argument reaches a file outside those directories, and it is deliberately not
+a search: a profile argument ending in `.json` is a path, handed to the file
+system as written, so `load profiles/lava.json` reads that file. `resolveProfileTarget`
+in `cmd/qmk-rgb-tool/profile.go` is the whole rule — a `.json` suffix is a path,
+anything else is a name in `profilesPath()` — and it has no fallback between the
+two, because a fallback is the search this file rules out. `delete` and `list` stay
+name-only, so there is no way to remove or list a file by path and the per-user
+directory remains the one place a *name* resolves to.
+
 A board's definition has one further source: the file built into the binary, which
 `candidateDefinitions` in `cmd/qmk-rgb-tool/catalog.go` consults after the
 per-user directory.
@@ -328,9 +337,11 @@ this reason: it now lists the definitions built into the binary as well as the
 ones in the user directory, and a path alone does not say which of the two a line
 is, because a built-in one has a path relative to the build. A command that emits
 structured data and neither honours `--json` nor says why is the bug this rule
-exists for. Two shapes are deliberately
-not in that list: `effect` with no effect name routes to the effect list, and
-`keyboard fetch` writes a file and prints a line about it.
+exists for. Three shapes are deliberately
+not in that list: `effect` with no effect name routes to the effect list,
+`keyboard fetch` writes a file and prints a line about it, and `save` writes a
+file and says which one — the second only since `save` could name a file by path,
+where a bare "saved" would leave the one thing worth reporting unsaid.
 
 `keyboard info` does not open the board, so it must not report the board's
 channels or an effect list from one: it reports the name and whether this tool has

+ 20 - 7
README.md

@@ -31,9 +31,10 @@ go install ./cmd/qmk-rgb-tool/
 `go install` copies the binary and creates no data directory, so an installed
 tool finds `definitions/` and `profiles/` under the per-user directory instead of
 beside itself. The Impact 80's definition is built into the binary, so an
-installed tool still has its effect names. To use a definition or a profile from
-a checkout, put the file in the per-user directory, or pass `--definition`; both
-win over what the binary carries.
+installed tool still has its effect names. To use a definition from a checkout,
+put the file in the per-user directory, or pass `--definition`; both win over what
+the binary carries. A profile in a checkout is used by naming its file:
+`qmk-rgb-tool load profiles/lava.json`.
 
 ## Setup
 
@@ -62,6 +63,16 @@ written there and read there, so `save <name>` followed by `load <name>` finds
 what was just written and a same-named file in a checkout cannot be loaded in its
 place.
 
+A profile argument ending in `.json` is the one way out, and it is not a search:
+the argument names the file, so `load profiles/lava.json` reads that file and
+`save ./lava.json` writes it, wherever they are. Every other argument is a
+profile name and comes from the per-user directory. The suffix is matched
+without regard to case and the path is handed to the file system as written, so
+`load lala.json` looks for `./lala.json` and says so when it is not there, rather
+than resolving `lala.json` to a profile named `lala-json`. There is no fallback
+from a path to a name, so `list` and the shell completion keep listing the names in
+the per-user directory and nothing else.
+
 The Impact 80's definition is additionally built into the binary and consulted
 last, so an installed tool has it; see
 [Effect Names Are Per Board](#effect-names-are-per-board).
@@ -822,8 +833,8 @@ board", and that file is gone, so the field would have been unanswerable:
 | `qmk-rgb-tool --definition <path>`  | Read effect names from this VIA definition file instead of the one in the data directory; applies to every command that resolves names, and a file for another board is refused |
 | `qmk-rgb-tool --json`               | Print JSON instead of text, for `keyboard info`, `info`, `list`, the effect list and `keyboard definitions` |
 | `qmk-rgb-tool -v`, `--version`       | Print the version                          |
-| `qmk-rgb-tool save [name]`            | Save the current state of **every** channel as `<name>.json` in the per-user `profiles/` (name lowercased, non-`[a-z0-9-_]` mapped to `-`, a leading `-` prefixed with `unnamed-`); without a name it writes `default` |
-| `qmk-rgb-tool load <name> [zone]`     | Load and apply a profile by name from the per-user `profiles/`; without a zone it applies the profile to every channel it names, with one it applies it to the named channels only |
+| `qmk-rgb-tool save [name\|file]`      | Save the current state of **every** channel as `<name>.json` in the per-user `profiles/` (name lowercased, non-`[a-z0-9-_]` mapped to `-`, a leading `-` prefixed with `unnamed-`); without a name it writes `default`. An argument ending in `.json` is a path, and that file is written instead, named after the file's own name. It reports the file it wrote, and annotates a path in the per-user directory as such |
+| `qmk-rgb-tool load <name\|file> [zone]` | Load and apply a profile, by name from the per-user `profiles/` or from a path when the argument ends in `.json`; without a zone it applies the profile to every channel it names, with one it applies it to the named channels only |
 | `qmk-rgb-tool list`                   | List saved profiles                        |
 | `qmk-rgb-tool delete [name]`          | Delete a saved profile; without a name it deletes `default` |
 | `qmk-rgb-tool completion <shell>`     | Write an autocompletion script for `bash`, `zsh`, `fish` or `powershell` to stdout |
@@ -863,8 +874,10 @@ the data directory, and applies to every command that resolves effect names.
 
 Profiles store RGB state (effect, brightness, speed, color) per channel as JSON
 files, one per profile, in the per-user `profiles/` directory. They are written
-there and read there, so `save <name>` and `load <name>` always agree and a
-checkout's own `profiles/` directory is not used. A profile also records the
+there and read there, so `save <name>` and `load <name>` always agree, and a
+checkout's own `profiles/` directory is not searched for a name — you reach it by
+naming the file, with `load profiles/lava.json` or `save profiles/lava.json`. A
+profile also records the
 keyboard it was saved from, because the effect names in
 it are that board's:
 

+ 9 - 12
cmd/qmk-rgb-tool/datadir.go

@@ -20,6 +20,12 @@ import (
 // profile is written to the user's directory, so a search path for reading can
 // find a same-named file in a checkout instead and load that one — the same name,
 // different content, and which one you get depends on where you stand.
+//
+// One argument reaches a file outside it: a profile argument ending in .json is a
+// path, and `load profiles/lava.json` reads that file. That is the user naming the
+// file rather than a directory being searched, so it does not reintroduce either
+// defect above — the argument is the same in every working directory and names
+// one file either way.
 const dataDirName = "qmk-rgb-tool"
 
 // userConfigDir is a seam: os.UserConfigDir is the platform's own answer, and a
@@ -60,7 +66,9 @@ func definitionsPath() string {
 // profilesPath is where the profiles live: the per-user directory, and nowhere
 // else. Reading and writing are the same place, so `save lava` followed by
 // `load lava` finds what was just written, and a same-named file in a checkout
-// cannot be loaded in place of it.
+// cannot be loaded in place of it. An argument ending in .json never comes here —
+// resolveProfileTarget in profile.go is where the rule is, and it sends a name to
+// this directory and a path to the file system.
 func profilesPath() string {
 	if profilesDirOverride != "" {
 		return profilesDirOverride
@@ -68,17 +76,6 @@ func profilesPath() string {
 	return filepath.Join(userDataDir(), "profiles")
 }
 
-// profilesWritePath is where a saved profile goes, which is not profilesPath.
-// Writing to either of those would put a file the user did not ask for into a
-// $GOPATH/bin that a reinstall replaces, or leave an untracked profiles/lava.json
-// in someone's repository. So a save always goes to the per-user directory.
-func profilesWritePath() string {
-	if profilesDirOverride != "" {
-		return profilesDirOverride
-	}
-	return filepath.Join(userDataDir(), "profiles")
-}
-
 // 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 {

+ 90 - 22
cmd/qmk-rgb-tool/profile.go

@@ -96,12 +96,47 @@ func profileFileName(name string) string {
 	return sanitizeFilename(name) + ".json"
 }
 
+// resolveProfileTarget maps one profile argument to the file it names, and to the
+// name a profile written there should carry.
+//
+// An argument ending in .json is a path and is used exactly as given, so
+// `load profiles/lava.json` reads that file and `save ./lava.json` writes it. It
+// is not a search: the argument names the file, so there is nothing to search
+// for, and the same argument names the same file from any working directory.
+// The suffix is matched without regard to case so the rule is the same on Linux,
+// macOS and Windows, and the file system rather than this code decides whether the
+// case is right. No separator is looked for — the Windows file API takes both `/`
+// and `\`, so a check for either would be a platform difference with no behaviour
+// behind it — and nothing here joins the path to a directory, so a path the
+// operating system rejects fails as itself rather than as a name.
+//
+// Every other argument is a profile name, and a name lives in the per-user
+// directory: profilesPath and the sanitized file name. A name is the only way in
+// there, and an argument that names a file is never also a name — which is what
+// keeps a .json argument from also resolving to `lala-json.json`.
+func resolveProfileTarget(arg string) (path string, name string, isPath bool) {
+	if !strings.HasSuffix(strings.ToLower(arg), ".json") {
+		return filepath.Join(profilesPath(), profileFileName(arg)), arg, false
+	}
+	base := filepath.Base(arg)
+	return arg, strings.TrimSuffix(base, filepath.Ext(base)), true
+}
+
+// Save writes the profile into the per-user directory under its own name, which is
+// what every caller that has a name and no path means.
 func (p *Profile) Save() error {
+	return p.saveTo(filepath.Join(profilesPath(), profileFileName(p.Name)))
+}
+
+// saveTo writes the profile to one file and creates the directory it is in. It
+// resolves nothing: the caller decides where the file goes, so that the rule that
+// decides is the only one there is.
+func (p *Profile) saveTo(path string) error {
 	if p.Name == "" {
 		return fmt.Errorf("profile name is required")
 	}
 
-	if err := os.MkdirAll(profilesPath(), 0o755); err != nil {
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
 		return fmt.Errorf("create profiles directory: %w", err)
 	}
 
@@ -110,7 +145,6 @@ func (p *Profile) Save() error {
 		return fmt.Errorf("marshal profile: %w", err)
 	}
 
-	path := filepath.Join(profilesPath(), profileFileName(p.Name))
 	if err := os.WriteFile(path, data, 0644); err != nil {
 		return fmt.Errorf("write profile: %w", err)
 	}
@@ -118,7 +152,23 @@ func (p *Profile) Save() error {
 }
 
 func LoadProfile(name string) (*Profile, error) {
-	path := filepath.Join(profilesPath(), profileFileName(name))
+	path, _, isPath := resolveProfileTarget(name)
+
+	// A path is stat'd before it is read, so a directory says it is one. Reading a
+	// directory fails anyway, and reporting that as "not found" would be the one
+	// answer a user cannot act on.
+	if isPath {
+		info, err := os.Stat(path)
+		switch {
+		case os.IsNotExist(err):
+			return nil, fmt.Errorf("profile file %s not found", path)
+		case err != nil:
+			return nil, fmt.Errorf("read profile file %s: %w", path, err)
+		case info.IsDir():
+			return nil, fmt.Errorf("profile file %s is a directory", path)
+		}
+	}
+
 	data, err := os.ReadFile(path)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -208,8 +258,9 @@ func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display ma
 // loadProfileFromDevice reads RGB state from device and saves it. Every channel
 // is recorded, so the profile a `load` applies later is the whole keyboard and
 // not the part of it that happened to be named. Warnings go to warn, which is the
-// command's stderr.
-func loadProfileFromDevice(name string, warn io.Writer) error {
+// command's stderr. The file is written where path says and the profile is named
+// after name, so a `save` of a file writes that file and names the profile for it.
+func loadProfileFromDevice(path, name string, warn io.Writer) error {
 	proto, target, channels, err := openTarget("")
 	if err != nil {
 		return err
@@ -239,26 +290,42 @@ func loadProfileFromDevice(name string, warn io.Writer) error {
 	if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
 		return err
 	}
-	return p.Save()
+	return p.saveTo(path)
+}
+
+// savedProfileLine is what a save says it did. The path is annotated the way every
+// other message here is, and the name is in the line because the file is named
+// after it: a save that only reported success would be silent about where a file
+// it was told to write by path actually went, which is the one thing about a save
+// worth reporting.
+func savedProfileLine(name, path string) string {
+	return fmt.Sprintf("Saved profile %s to %s\n", name, describeDataDir(path))
 }
 
 func NewProfileSaveCmd() *cobra.Command {
 	return &cobra.Command{
-		Use:   "save [name]",
+		Use:   "save [name|file]",
 		Short: "Save current RGB state to a profile",
 		Long: "Read the current RGB settings from every channel of the keyboard and save\n" +
-			"them as a JSON profile in the profiles/ directory.\n" +
+			"them as a JSON profile. A name is written as <name>.json in the per-user\n" +
+			"profiles/ directory; an argument ending in .json is a path, and that file is\n" +
+			"written where it says. It reports the file it wrote.\n" +
 			"\n" +
 			"A profile is always complete. Recording only some of the channels would let a\n" +
 			"later `load` apply them and leave the rest as they were, which reads as a\n" +
 			"zone the profile had nothing to say about.",
 		Args: cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			name := "default"
+			arg := "default"
 			if len(args) > 0 {
-				name = args[0]
+				arg = args[0]
 			}
-			return loadProfileFromDevice(name, cmd.ErrOrStderr())
+			path, name, _ := resolveProfileTarget(arg)
+			if err := loadProfileFromDevice(path, name, cmd.ErrOrStderr()); err != nil {
+				return err
+			}
+			fmt.Fprint(cmd.OutOrStdout(), savedProfileLine(name, path))
+			return nil
 		},
 	}
 }
@@ -268,13 +335,14 @@ func NewProfileLoadCmd() *cobra.Command {
 	// the zone completion only applies once the name has been typed.
 	cmd := &cobra.Command{
 		ValidArgsFunction: completeLoadArgs,
-		Use:               "load <name> [zone]",
+		Use:               "load <name|file> [zone]",
 		Short:             "Load a profile and apply it to the keyboard",
-		Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB\n" +
-			"settings to the keyboard. Without a zone the profile is applied to every\n" +
-			"channel it names.",
+		Long: "Read a JSON profile and apply the saved RGB settings to the keyboard. A\n" +
+			"name is read from the per-user profiles/ directory; an argument ending in\n" +
+			".json is a path, and that file is read where it says. Without a zone the\n" +
+			"profile is applied to every channel it names.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			name := args[0]
+			arg := args[0]
 			zone := ""
 			if len(args) == 2 {
 				zone = args[1]
@@ -286,7 +354,7 @@ func NewProfileLoadCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			p, err := LoadProfile(name)
+			p, err := LoadProfile(arg)
 			if err != nil {
 				return err
 			}
@@ -337,7 +405,7 @@ func NewProfileLoadCmd() *cobra.Command {
 			if catalog == nil {
 				fmt.Fprintf(cmd.ErrOrStderr(),
 					"Warning: profile %q has no effect names for this keyboard, so nothing applied; "+
-						"run `keyboard fetch` for its VIA definition\n", name)
+						"run `keyboard fetch` for its VIA definition\n", arg)
 				return nil
 			}
 
@@ -347,7 +415,7 @@ func NewProfileLoadCmd() *cobra.Command {
 				keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives)
 				if err != nil {
 					fmt.Fprintf(cmd.ErrOrStderr(),
-						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
+						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
 					continue
 				}
 
@@ -360,7 +428,7 @@ func NewProfileLoadCmd() *cobra.Command {
 				}
 				if !placed {
 					fmt.Fprintf(cmd.ErrOrStderr(),
-						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
+						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
 					continue
 				}
 
@@ -416,12 +484,12 @@ func NewProfileLoadCmd() *cobra.Command {
 
 			if applied == 0 {
 				fmt.Fprintf(cmd.ErrOrStderr(),
-					"Warning: profile %q has no settings for the selected zone(s); nothing applied\n", name)
+					"Warning: profile %q has no settings for the selected zone(s); nothing applied\n", arg)
 			}
 			return nil
 		},
 	}
-	cmd.Args = zoneArgs(1, 2, "a profile name and at most a zone")
+	cmd.Args = zoneArgs(1, 2, "a profile name or file and at most a zone")
 	return cmd
 }
 

+ 222 - 0
cmd/qmk-rgb-tool/profile_path_test.go

@@ -0,0 +1,222 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// A .json argument is a path and everything else is a name, and that is the whole
+// rule. The suffix is matched without regard to case so it reads the same on
+// Linux, macOS and Windows, and no separator is looked for: the Windows file API
+// takes both, so a check for one would be a platform difference with no behaviour
+// behind it.
+func TestResolveProfileTargetTreatsAJsonSuffixAsAPath(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+
+	user := filepath.Join(userDir, "qmk-rgb-tool", "profiles")
+	for _, tc := range []struct {
+		arg      string
+		wantPath string
+		wantName string
+		wantFile bool
+	}{
+		{"lala.json", "lala.json", "lala", true},
+		{"./lala.json", "./lala.json", "lala", true},
+		{"profiles/lava.json", "profiles/lava.json", "lava", true},
+		{"LALA.JSON", "LALA.JSON", "LALA", true},
+		{"lava", filepath.Join(user, "lava.json"), "lava", false},
+		{"my profile", filepath.Join(user, "my-profile.json"), "my profile", false},
+	} {
+		path, name, isFile := resolveProfileTarget(tc.arg)
+		if isFile != tc.wantFile {
+			t.Errorf("resolveProfileTarget(%q) isPath = %v, want %v", tc.arg, isFile, tc.wantFile)
+		}
+		if path != tc.wantPath {
+			t.Errorf("resolveProfileTarget(%q) path = %q, want %q", tc.arg, path, tc.wantPath)
+		}
+		if name != tc.wantName {
+			t.Errorf("resolveProfileTarget(%q) name = %q, want %q", tc.arg, name, tc.wantName)
+		}
+	}
+}
+
+// The two forms read two different files, and the argument says which: a path
+// reads the file it names, a name reads the user's directory. The same fixture
+// holds a file of each, so a rule that guessed instead would have one answer for
+// both arguments.
+func TestLoadProfileReadsTheFileTheArgumentNames(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+
+	here := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(here, "profiles"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(here, "profiles", "lava.json"),
+		[]byte(`{"name":"from-the-checkout","version":1,"zones":{}}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	dir := filepath.Join(userDir, "qmk-rgb-tool", "profiles")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "lava.json"),
+		[]byte(`{"name":"from-the-user-directory","version":1,"zones":{}}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	t.Chdir(here)
+
+	path, err := LoadProfile("profiles/lava.json")
+	if err != nil {
+		t.Fatalf("LoadProfile(profiles/lava.json): %v", err)
+	}
+	if path.Name != "from-the-checkout" {
+		t.Errorf("LoadProfile(profiles/lava.json).Name = %q, want the file it names", path.Name)
+	}
+
+	byName, err := LoadProfile("lava")
+	if err != nil {
+		t.Fatalf("LoadProfile(lava): %v", err)
+	}
+	if byName.Name != "from-the-user-directory" {
+		t.Errorf("LoadProfile(lava).Name = %q, want the user's directory, not the checkout's", byName.Name)
+	}
+}
+
+// A path that is not there says which path, and not the user's directory: it was
+// never looked for there, so naming it sends the user to read a file that is
+// present and say nothing about why it was ignored.
+func TestLoadProfileAMissingPathNamesThePath(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+	t.Chdir(t.TempDir())
+
+	_, err := LoadProfile("./nope.json")
+	if err == nil {
+		t.Fatal("LoadProfile(./nope.json) = nil error, want one")
+	}
+	if !strings.Contains(err.Error(), "./nope.json") {
+		t.Errorf("LoadProfile(./nope.json) = %q, want it to name the path", err)
+	}
+	if strings.Contains(err.Error(), "qmk-rgb-tool") {
+		t.Errorf("LoadProfile(./nope.json) = %q, want it not to name the user's directory", err)
+	}
+}
+
+// A .json argument is read as a path and never sanitized into a name. This is the
+// case the rule turns on: `lala.json` used to mean the profile `lala-json`, and
+// the error for a missing one would name a file that never existed.
+func TestLoadProfileAJsonArgumentIsNotSanitized(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+	t.Chdir(t.TempDir())
+
+	_, err := LoadProfile("lala.json")
+	if err == nil {
+		t.Fatal("LoadProfile(lala.json) = nil error, want one")
+	}
+	if strings.Contains(err.Error(), "lala-json") {
+		t.Errorf("LoadProfile(lala.json) = %q, want lala.json read as a path", err)
+	}
+	if !strings.Contains(err.Error(), "lala.json") {
+		t.Errorf("LoadProfile(lala.json) = %q, want it to name the path it tried", err)
+	}
+}
+
+// A save that names a file writes that file, and the profile's own name is the
+// file's name without the suffix — so the file and the name in it agree, and the
+// file is not silently also written where the names live.
+func TestSaveProfileToWritesThePathItWasGiven(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+	here := t.TempDir()
+	t.Chdir(here)
+
+	path, name, isFile := resolveProfileTarget("profiles/new.json")
+	if !isFile {
+		t.Fatal(`resolveProfileTarget("profiles/new.json") isPath = false, want true`)
+	}
+	p := &Profile{Name: name, Version: 1}
+	if err := p.saveTo(path); err != nil {
+		t.Fatalf("saveTo(%q): %v", path, err)
+	}
+
+	written := filepath.Join(here, "profiles", "new.json")
+	if _, err := os.Stat(written); err != nil {
+		t.Fatalf("saveTo(%q) did not write the file it was given: %v", path, err)
+	}
+	data, err := os.ReadFile(written)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var got Profile
+	if err := json.Unmarshal(data, &got); err != nil {
+		t.Fatal(err)
+	}
+	if got.Name != "new" {
+		t.Errorf("saved profile Name = %q, want %q", got.Name, "new")
+	}
+	if _, err := os.Stat(filepath.Join(userDir, "qmk-rgb-tool", "profiles", "new.json")); err == nil {
+		t.Error("saveTo also wrote into the user's directory, which the argument did not ask for")
+	}
+}
+
+// A save says which file it wrote, and the path is annotated the way every other
+// message in the tool is: a bare path does not say whether it is the user's
+// directory or one the argument named.
+func TestSavedProfileLineNamesTheFileItWrote(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+	here := t.TempDir()
+	t.Chdir(here)
+
+	path, name, _ := resolveProfileTarget("profiles/new.json")
+	got := savedProfileLine(name, path)
+	if !strings.Contains(got, "profiles/new.json") {
+		t.Errorf("savedProfileLine() = %q, want the path it was given", got)
+	}
+	if strings.Contains(got, "your user directory") {
+		t.Errorf("savedProfileLine() = %q, want a file the argument named left unannotated", got)
+	}
+
+	byName, byNameStr, _ := resolveProfileTarget("lava")
+	if line := savedProfileLine(byNameStr, byName); !strings.Contains(line, "your user directory") {
+		t.Errorf("savedProfileLine() = %q, want a name's file marked as the user's", line)
+	}
+}
+
+// A save by name goes to the user directory, which is the point of the name form:
+// the argument is a name, so it is the directory that decides where it goes.
+func TestSaveByNameGoesToTheUserDirectory(t *testing.T) {
+	userDir := t.TempDir()
+	t.Cleanup(stubUserConfigDir(t, userDir))
+	t.Cleanup(func() { profilesDirOverride = "" })
+	here := t.TempDir()
+	t.Chdir(here)
+
+	path, name, isFile := resolveProfileTarget("lava")
+	if isFile {
+		t.Fatal(`resolveProfileTarget("lava") isPath = true, want false`)
+	}
+	if err := (&Profile{Name: name, Version: 1}).saveTo(path); err != nil {
+		t.Fatalf("saveTo(%q): %v", path, err)
+	}
+
+	if _, err := os.Stat(filepath.Join(userDir, "qmk-rgb-tool", "profiles", "lava.json")); err != nil {
+		t.Fatalf("saveTo(%q) did not write into the user's directory: %v", path, err)
+	}
+	if _, err := os.Stat(filepath.Join(here, "lava.json")); err == nil {
+		t.Error("saveTo wrote into the working directory, which a name does not ask for")
+	}
+}