Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
79 changes: 67 additions & 12 deletions internal/config/paths.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
133 changes: 133 additions & 0 deletions internal/config/paths_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 32 additions & 0 deletions root/config_dir_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
57 changes: 54 additions & 3 deletions root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand Down
Loading