diff --git a/README.md b/README.md index d37591e..1443d03 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,24 @@ iris config init iris config show ``` +### Custom config location + +`config.toml` and `theme.toml` are read from `$XDG_CONFIG_HOME/iris`, falling back to `~/.config/iris`. Point IRIS elsewhere with the `--config-dir` flag or the `IRIS_CONFIG_DIR` environment variable: + +```bash +iris --config-dir ~/iris-profiles/work +IRIS_CONFIG_DIR=~/iris-profiles/work iris +``` + +The flag wins over the variable, and both win over `XDG_CONFIG_HOME`. `iris config init`, `iris theme init` and `iris setup` write into the chosen directory. + +> [!IMPORTANT] +> Use the environment variable for a whole session. Your shell rc starts IRIS with a bare `exec iris`, which drops any flags you typed earlier, while the environment survives. It is also what NixOS and other declarative setups should set. + +The directory must exist, since a path that points nowhere is almost always a typo. The files inside are optional: an empty directory plus `iris config init` is the normal way to start a new profile. + +State and history (`~/.local/share/iris`) are **not** affected, so profiles share the remembered mode and frecency ranking. + ### Sample `config.toml` ```toml @@ -401,6 +419,8 @@ IRIS has theme TOML configuration file located at `~/.config/iris/theme.toml` iris theme init ``` +`theme.toml` lives beside `config.toml`, so `--config-dir` and `IRIS_CONFIG_DIR` relocate it too. + ### Default theme IRIS automatically falls back to the default theme if `theme.toml` is missing, empty, or contains missing configuration options diff --git a/internal/config/paths.go b/internal/config/paths.go index 4356d80..8fda53b 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -1,32 +1,87 @@ package config import ( + "fmt" "os" "path/filepath" ) -func ConfigPath() (string, error) { - configHome := os.Getenv("XDG_CONFIG_HOME") - if configHome != "" { - return filepath.Join(configHome, "iris", "config.toml"), nil +// ConfigDirEnv overrides where config.toml and theme.toml are read from. +// It is an environment variable rather than a plain flag because the shell rc +// starts the real session with a bare `exec iris`: arguments are lost at that +// boundary, the environment is not. +const ConfigDirEnv = "IRIS_CONFIG_DIR" + +// ConfigDirError reports an unusable IRIS_CONFIG_DIR. An explicit override that +// points nowhere is a typo or a broken generated path, and silently falling +// back to the default location turns that into "my theme stopped applying". +type ConfigDirError struct { + Dir string + Reason string + // Source names what set Dir, so the message points at the flag the user + // actually typed rather than always blaming the environment variable. + Source string +} + +func (e *ConfigDirError) Error() string { + source := e.Source + if source == "" { + source = ConfigDirEnv + } + return fmt.Sprintf("%s %q %s", source, e.Dir, e.Reason) +} + +// configDir resolves the directory holding config.toml and theme.toml. +// overridden reports whether it came from ConfigDirEnv rather than XDG. +func configDir() (dir string, overridden bool, err error) { + if custom := os.Getenv(ConfigDirEnv); custom != "" { + if !filepath.IsAbs(custom) { + abs, absErr := filepath.Abs(custom) + if absErr != nil { + return "", true, &ConfigDirError{Dir: custom, Reason: "cannot be resolved to an absolute path"} + } + custom = abs + } + info, statErr := os.Stat(custom) + if statErr != nil { + return "", true, &ConfigDirError{Dir: custom, Reason: "does not exist"} + } + if !info.IsDir() { + return "", true, &ConfigDirError{Dir: custom, Reason: "is not a directory"} + } + return custom, true, nil } - home, err := os.UserHomeDir() + + if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" { + return filepath.Join(configHome, "iris"), false, nil + } + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return "", false, homeErr + } + return filepath.Join(home, ".config", "iris"), false, nil +} + +// ConfigDir returns the directory config.toml and theme.toml live in. +func ConfigDir() (string, error) { + dir, _, err := configDir() + return dir, err +} + +func ConfigPath() (string, error) { + dir, _, err := configDir() if err != nil { return "", err } - return filepath.Join(home, ".config", "iris", "config.toml"), nil + return filepath.Join(dir, "config.toml"), nil } func ThemePath() (string, error) { - configHome := os.Getenv("XDG_CONFIG_HOME") - if configHome != "" { - return filepath.Join(configHome, "iris", "theme.toml"), nil - } - home, err := os.UserHomeDir() + dir, _, err := configDir() if err != nil { return "", err } - return filepath.Join(home, ".config", "iris", "theme.toml"), nil + return filepath.Join(dir, "theme.toml"), nil } func StatePath() (string, error) { diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go new file mode 100644 index 0000000..cf56023 --- /dev/null +++ b/internal/config/paths_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestConfigDirPrecedence(t *testing.T) { + custom := t.TempDir() + xdg := t.TempDir() + home := t.TempDir() + + t.Setenv("HOME", home) + + t.Run("override wins over XDG", func(t *testing.T) { + t.Setenv(ConfigDirEnv, custom) + t.Setenv("XDG_CONFIG_HOME", xdg) + + got, err := ConfigPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(custom, "config.toml"); got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + theme, err := ThemePath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(custom, "theme.toml"); theme != want { + t.Errorf("ThemePath() = %q, want %q", theme, want) + } + }) + + t.Run("XDG wins over home", func(t *testing.T) { + t.Setenv(ConfigDirEnv, "") + t.Setenv("XDG_CONFIG_HOME", xdg) + + got, err := ConfigPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(xdg, "iris", "config.toml"); got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + }) + + t.Run("home is the fallback", func(t *testing.T) { + t.Setenv(ConfigDirEnv, "") + t.Setenv("XDG_CONFIG_HOME", "") + + got, err := ConfigPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".config", "iris", "config.toml"); got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + }) +} + +// an override that points nowhere is a typo or a broken generated path; +// silently using the default location hides it +func TestConfigDirRejectsUnusableOverride(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(file, nil, 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + dir string + reason string + }{ + {"missing", filepath.Join(dir, "does-not-exist"), "does not exist"}, + {"not a directory", file, "is not a directory"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(ConfigDirEnv, tt.dir) + + _, err := ConfigPath() + var badDir *ConfigDirError + if !errors.As(err, &badDir) { + t.Fatalf("ConfigPath() error = %v, want *ConfigDirError", err) + } + if badDir.Reason != tt.reason { + t.Errorf("reason = %q, want %q", badDir.Reason, tt.reason) + } + if _, err := ThemePath(); !errors.As(err, &badDir) { + t.Errorf("ThemePath() error = %v, want *ConfigDirError", err) + } + }) + } +} + +func TestConfigDirAcceptsRelativeOverride(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "iris-conf") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + t.Setenv(ConfigDirEnv, "iris-conf") + + got, err := ConfigPath() + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(got) { + t.Errorf("ConfigPath() = %q, want an absolute path", got) + } + if filepath.Base(filepath.Dir(got)) != "iris-conf" { + t.Errorf("ConfigPath() = %q, want it under iris-conf", got) + } +} + +// an existing override directory with no files in it is normal: config init +// has to be able to create them, and an absent theme means the built-in one +func TestConfigDirAllowsEmptyOverrideDirectory(t *testing.T) { + t.Setenv(ConfigDirEnv, t.TempDir()) + + if _, err := ConfigPath(); err != nil { + t.Errorf("ConfigPath() on an empty override dir = %v, want nil", err) + } + if _, err := ThemePath(); err != nil { + t.Errorf("ThemePath() on an empty override dir = %v, want nil", err) + } +} diff --git a/root/config_dir_test.go b/root/config_dir_test.go new file mode 100644 index 0000000..50194c1 --- /dev/null +++ b/root/config_dir_test.go @@ -0,0 +1,32 @@ +package root + +import "testing" + +func TestScanConfigDirFlag(t *testing.T) { + tests := []struct { + name string + args []string + want string + found bool + }{ + {"separate value", []string{"--config-dir", "/etc/iris"}, "/etc/iris", true}, + {"equals form", []string{"--config-dir=/etc/iris"}, "/etc/iris", true}, + {"after other flags", []string{"-d", "--config-dir", "/etc/iris"}, "/etc/iris", true}, + {"before a subcommand", []string{"--config-dir=/etc/iris", "config", "show"}, "/etc/iris", true}, + {"absent", []string{"config", "show"}, "", false}, + {"dangling with no value", []string{"--config-dir"}, "", false}, + {"empty value is still explicit", []string{"--config-dir="}, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found := scanConfigDirFlag(tt.args) + if found != tt.found { + t.Fatalf("scanConfigDirFlag(%q) found = %v, want %v", tt.args, found, tt.found) + } + if got != tt.want { + t.Errorf("scanConfigDirFlag(%q) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} diff --git a/root/root.go b/root/root.go index 5b04618..bd2189f 100644 --- a/root/root.go +++ b/root/root.go @@ -52,15 +52,19 @@ It works exactly like coding editor suggestion menu drop down.`, runWrapper() }, } - shellFlag string - shellLoginFlag bool - debugMode bool + shellFlag string + configDirFlagValue string + shellLoginFlag bool + debugMode bool ) func init() { rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)") rootCmd.PersistentFlags().BoolVar(&shellLoginFlag, "shell-login", false, "run the selected shell as a login shell") rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log") + // read before cobra parses, in scanConfigDirFlag; registered so --help + // lists it and parsing does not reject it + rootCmd.PersistentFlags().StringVar(&configDirFlagValue, "config-dir", "", "directory holding config.toml and theme.toml (env: "+config.ConfigDirEnv+")") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { if shellFlag != "" { @@ -234,7 +238,54 @@ func runOriginal() { } } +// configDirFlag is the long form of the flag; scanConfigDirFlag reads it +// straight out of os.Args because config.Load() runs before cobra parses +// anything, so the cobra flag value is not available yet. +const configDirFlag = "--config-dir" + +// scanConfigDirFlag returns the --config-dir value, supporting both +// "--config-dir X" and "--config-dir=X". +func scanConfigDirFlag(args []string) (string, bool) { + for i, arg := range args { + if value, ok := strings.CutPrefix(arg, configDirFlag+"="); ok { + return value, true + } + if arg == configDirFlag && i+1 < len(args) { + return args[i+1], true + } + } + return "", false +} + +// failConfigDir reports an unusable config dir and stops. The init script +// exports IRIS_ACTIVE_SHELL immediately before `exec iris`, so when it is set +// iris has replaced the user's shell and exiting would close the terminal -- +// hand them a plain shell instead of a dead window. +func failConfigDir(err error) { + fmt.Fprintf(os.Stderr, "\r\n\033[31m[IRIS] %v\033[0m\r\n", err) + if os.Getenv("IRIS_ACTIVE_SHELL") != "" { + fmt.Fprintf(os.Stderr, "\033[33m[IRIS] starting your shell without iris.\033[0m\r\n") + startRescueShell() + } + os.Exit(1) +} + func Execute() { + fromFlag := false + if dir, ok := scanConfigDirFlag(os.Args[1:]); ok { + _ = os.Setenv(config.ConfigDirEnv, dir) + fromFlag = true + } + if _, dirErr := config.ConfigDir(); dirErr != nil { + var badDir *config.ConfigDirError + if errors.As(dirErr, &badDir) { + if fromFlag { + badDir.Source = configDirFlag + } + failConfigDir(badDir) + } + } + _ = config.MigrateFromLegacyJSON() cfg, err := config.Load() if err != nil {