From 3d76a3ee8816ac8fa2c5026551ff93d77bbd4762 Mon Sep 17 00:00:00 2001 From: VERSE Date: Sat, 30 May 2026 08:05:06 -0700 Subject: [PATCH] feat: toml config support (#9) ## What's new - TOML config support - add config init command to justfile https://github.com/versenilvis/IRIS/pull/9/commits/a5bb6c23d74ca73385b3590d2eb275ce6429906b - add copy binary file to local bin on reload https://github.com/versenilvis/IRIS/pull/9/commits/4da3acdbc58c28f20fa0021d15fec82823ba392b - init config on setup https://github.com/versenilvis/IRIS/pull/9/commits/fb48b6bb832f38e24fab9211d92858bab9a58a07 ## Fix - add lsof to support macos because /proc filesystem is Linux-specific and does not exist on macOS 0439d829573864fcf902fc4127f7d45384bfab01 - git push doesnt suggest current branch, fix https://github.com/versenilvis/IRIS/pull/9/commits/b498532b401b764a4a1a2c70b3cb44b7caf72320 - use timeout context for lsof command in reload handler https://github.com/versenilvis/IRIS/pull/9/commits/1a2baee951e6538a729da12aedfe53960e51102d --- commands/dev/git.go | 41 ++++--- config/config.go | 159 +++++++++++++++++++++++++ config/defaults.go | 38 ++++++ config/env.go | 49 ++++++++ config/migrate.go | 90 +++++++++++++++ config/paths.go | 42 +++++++ config/state.go | 68 +++++++++++ go.mod | 1 + go.sum | 2 + justfile | 6 + root/config_cmd.go | 104 +++++++++++++++++ root/crash.go | 62 +++++++--- root/init.go | 56 +++++++++ root/root.go | 34 +++++- root/suggestions.go | 8 +- root/update.go | 104 +++++++---------- root/wrapper.go | 65 +++++------ tests/config_test.go | 236 ++++++++++++++++++++++++++++++++++++++ tests/crash_test.go | 13 ++- tests/dev/git_test.go | 6 + tests/root/update_test.go | 40 ++++--- 21 files changed, 1064 insertions(+), 160 deletions(-) create mode 100644 config/config.go create mode 100644 config/defaults.go create mode 100644 config/env.go create mode 100644 config/migrate.go create mode 100644 config/paths.go create mode 100644 config/state.go create mode 100644 root/config_cmd.go create mode 100644 tests/config_test.go diff --git a/commands/dev/git.go b/commands/dev/git.go index 47deb46..b577038 100644 --- a/commands/dev/git.go +++ b/commands/dev/git.go @@ -9,12 +9,10 @@ import ( "github.com/versenilvis/iris/commands/core" ) -// GitRemoteGenerator suggests git remotes func GitRemoteGenerator(tokens []string, _ string, _ string) []core.Suggestion { return getGitResults(tokens, "remote") } -// GitStashGenerator suggests git stashes func GitStashGenerator(tokens []string, _ string, _ string) []core.Suggestion { return getGitResults(tokens, "stash", "list", "--format=%gd: %gs") } @@ -25,7 +23,7 @@ func getGitResults(tokens []string, args ...string) []core.Suggestion { func getGitResultsFiltered(tokens []string, localOnly bool, args ...string) []core.Suggestion { cwd := core.GetCWD() - + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -115,10 +113,24 @@ func getGitResultsFiltered(tokens []string, localOnly bool, args ...string) []co Desc: suggestionDesc, }) } + + if activeBranch != "" { + for i, r := range results { + if r.Cmd == activeBranch { + // move active branch to front if found + newResults := make([]core.Suggestion, 0, len(results)) + newResults = append(newResults, r) + newResults = append(newResults, results[:i]...) + newResults = append(newResults, results[i+1:]...) + results = newResults + break + } + } + } + return results } - // GitBranchGenerator suggests git branches (local + remote, deduped) func GitBranchGenerator(tokens []string, _ string, _ string) []core.Suggestion { // check if we are in "create" mode (-b or -B or -c) @@ -153,7 +165,6 @@ func gitLocalBranchGenerator(tokens []string, _ string, _ string) []core.Suggest return getGitResultsFiltered(tokens, true, "branch", "-a", "--format=%(refname:short)") } - func GitPushPullGenerator(tokens []string, prefix string, partial string) []core.Suggestion { // count completed positional args (not flags, not the partial being typed) // tokens[0] = "git", tokens[1] = "push"/"pull", so start at 2 @@ -186,7 +197,7 @@ func init() { }, Subcommands: []core.Subcommand{ { - Name: "init", + Name: "init", Description: "create empty repo", Options: []core.Option{ {Name: "--bare", Description: "create bare repo"}, @@ -194,7 +205,7 @@ func init() { }, }, { - Name: "clone", + Name: "clone", Description: "clone a repository", Options: []core.Option{ {Name: "--depth", Description: "shallow clone depth"}, @@ -203,7 +214,7 @@ func init() { }, }, { - Name: "status", + Name: "status", Description: "show working tree", Options: []core.Option{ {Name: "-s", Description: "short format"}, @@ -211,9 +222,9 @@ func init() { }, }, { - Name: "add", + Name: "add", Description: "stage changes", - Generator: core.FileGenerator(), + Generator: core.FileGenerator(), Options: []core.Option{ {Name: "-A", Description: "add all files"}, {Name: "-p", Description: "interactive patch"}, @@ -221,7 +232,7 @@ func init() { }, }, { - Name: "commit", + Name: "commit", Description: "record changes", Options: []core.Option{ {Name: "-m", Description: "commit message"}, @@ -337,7 +348,9 @@ func init() { { Name: "tag", Description: "manage tags", - Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(tokens, "tag", "-l") }, + Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { + return getGitResults(tokens, "tag", "-l") + }, Options: []core.Option{ {Name: "-a", Description: "annotated tag"}, {Name: "-d", Description: "delete tag"}, @@ -404,7 +417,7 @@ func init() { }, }, { - Name: "cherry-pick", + Name: "cherry-pick", Description: "apply commit", Options: []core.Option{ {Name: "--no-commit", Description: "no auto commit"}, @@ -412,7 +425,7 @@ func init() { }, }, { - Name: "bisect", + Name: "bisect", Description: "binary search bug", Subcommands: []core.Subcommand{ {Name: "start", Description: "start bisect"}, diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d7999f0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,159 @@ +package config + +import ( + "encoding" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/BurntSushi/toml" +) + +type Duration time.Duration + +var ( + _ encoding.TextUnmarshaler = (*Duration)(nil) + _ encoding.TextMarshaler = (*Duration)(nil) +) + +func (d *Duration) UnmarshalText(text []byte) error { + dur, err := time.ParseDuration(string(text)) + if err != nil { + return err + } + *d = Duration(dur) + return nil +} + +func (d Duration) MarshalText() ([]byte, error) { + return []byte(time.Duration(d).String()), nil +} + +type CoreConfig struct { + Version int `toml:"version"` + Shell string `toml:"shell"` + Mode string `toml:"mode"` + Debug bool `toml:"debug"` +} + +type UIConfig struct { + GhostText bool `toml:"ghost-text"` + MaxSuggestions int `toml:"max-suggestions"` + MaxHeight int `toml:"max-height"` +} + +type GitConfig struct { + FilterActiveBranch bool `toml:"filter-active-branch"` + DeduplicateBranches bool `toml:"deduplicate-branches"` +} + +type UpdaterConfig struct { + CheckOnStartup bool `toml:"check-on-startup"` + Channel string `toml:"channel"` + CheckInterval Duration `toml:"check-interval"` +} + +type Config struct { + Core CoreConfig `toml:"core"` + UI UIConfig `toml:"ui"` + Git GitConfig `toml:"git"` + Updater UpdaterConfig `toml:"updater"` +} + +var ( + activeConfig *Config + once sync.Once +) + +func Get() *Config { + once.Do(func() { + if activeConfig == nil { + activeConfig = DefaultConfig() + } + }) + return activeConfig +} + +func Init(cfg *Config) { + activeConfig = cfg + once.Do(func() {}) +} + +func Load() (*Config, error) { + cfg := DefaultConfig() + + path, err := ConfigPath() + if err == nil { + if _, statErr := os.Stat(path); statErr == nil { + data, readErr := os.ReadFile(path) + if readErr != nil { + return cfg, fmt.Errorf("config: read %s: %w", path, readErr) + } + if _, decodeErr := toml.Decode(string(data), cfg); decodeErr != nil { + return cfg, fmt.Errorf("config: parse %s: %w", path, decodeErr) + } + } + } + + applyEnv(cfg) + + if err := validate(cfg); err != nil { + return cfg, fmt.Errorf("config: invalid value: %w", err) + } + + return cfg, nil +} + +func Save(cfg *Config) error { + path, err := ConfigPath() + if err != nil { + return err + } + + err = os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + return err + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + + enc := toml.NewEncoder(file) + if err := enc.Encode(cfg); err != nil { + return err + } + + return nil +} + +func validate(cfg *Config) error { + validModes := map[string]bool{"last": true, "spec": true, "history": true} + if cfg.Core.Mode != "" && !validModes[cfg.Core.Mode] { + return fmt.Errorf("core.mode: invalid value %q (want: last|spec|history)", cfg.Core.Mode) + } + + validShells := map[string]bool{"": true, "bash": true, "zsh": true, "fish": true} + if !validShells[cfg.Core.Shell] { + return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell) + } + + validChannels := map[string]bool{"stable": true, "nightly": true} + if !validChannels[cfg.Updater.Channel] { + return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel) + } + + if cfg.UI.MaxSuggestions < 1 || cfg.UI.MaxSuggestions > 500 { + return fmt.Errorf("ui.max-suggestions: must be between 1 and 500") + } + + if cfg.UI.MaxHeight < 3 || cfg.UI.MaxHeight > 50 { + return fmt.Errorf("ui.max-height: must be between 3 and 50") + } + + return nil +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..cd7bfd4 --- /dev/null +++ b/config/defaults.go @@ -0,0 +1,38 @@ +package config + +import "time" + +func DefaultConfig() *Config { + return &Config{ + Core: CoreConfig{ + Version: 1, + Shell: "", + Mode: "last", + Debug: false, + }, + UI: UIConfig{ + GhostText: true, + MaxSuggestions: 100, + MaxHeight: 15, + }, + Git: GitConfig{ + FilterActiveBranch: true, + DeduplicateBranches: true, + }, + Updater: UpdaterConfig{ + CheckOnStartup: true, + Channel: "stable", + CheckInterval: Duration(24 * time.Hour), + }, + } +} + +func DefaultState() *State { + return &State{ + LastMode: "spec", + Updater: UpdaterState{ + LastCheckTime: time.Time{}, + SeenVersion: "", + }, + } +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..b46f350 --- /dev/null +++ b/config/env.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +func applyEnv(cfg *Config) { + if val := os.Getenv("IRIS_CORE_DEBUG"); val != "" { + if b, err := strconv.ParseBool(val); err == nil { + cfg.Core.Debug = b + } + } + if val := os.Getenv("IRIS_CORE_SHELL"); val != "" { + cfg.Core.Shell = val + } + if val := os.Getenv("IRIS_CORE_MODE"); val != "" { + cfg.Core.Mode = val + } + if val := os.Getenv("IRIS_UI_GHOST_TEXT"); val != "" { + if b, err := strconv.ParseBool(val); err == nil { + cfg.UI.GhostText = b + } + } + if val := os.Getenv("IRIS_UI_MAX_SUGGESTIONS"); val != "" { + if i, err := strconv.Atoi(val); err == nil { + cfg.UI.MaxSuggestions = i + } + } + if val := os.Getenv("IRIS_UI_MAX_HEIGHT"); val != "" { + if i, err := strconv.Atoi(val); err == nil { + cfg.UI.MaxHeight = i + } + } + if val := os.Getenv("IRIS_UPDATER_CHANNEL"); val != "" { + cfg.Updater.Channel = val + } + if val := os.Getenv("IRIS_UPDATER_INTERVAL"); val != "" { + if dur, err := time.ParseDuration(val); err == nil { + cfg.Updater.CheckInterval = Duration(dur) + } + } + if val := os.Getenv("IRIS_UPDATER_CHECK_ON_STARTUP"); val != "" { + if b, err := strconv.ParseBool(val); err == nil { + cfg.Updater.CheckOnStartup = b + } + } +} diff --git a/config/migrate.go b/config/migrate.go new file mode 100644 index 0000000..bb39e74 --- /dev/null +++ b/config/migrate.go @@ -0,0 +1,90 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +type legacyState struct { + Mode string `json:"mode"` +} + +type legacyUpdateState struct { + SeenVersion string `json:"seen_version"` + LastCheck int64 `json:"last_check"` +} + +func MigrateFromLegacyJSON() error { + statePath, err := StatePath() + if err != nil { + return err + } + + if _, statErr := os.Stat(statePath); statErr == nil { + return nil + } + + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return homeErr + } + + legacyDir := filepath.Join(home, ".iris") + legacyStatePath := filepath.Join(legacyDir, "state.json") + legacyUpdatePath := filepath.Join(legacyDir, "update_state.json") + + hasLegacyState := false + if _, err := os.Stat(legacyStatePath); err == nil { + hasLegacyState = true + } + hasLegacyUpdate := false + if _, err := os.Stat(legacyUpdatePath); err == nil { + hasLegacyUpdate = true + } + + if !hasLegacyState && !hasLegacyUpdate { + return nil + } + + state := DefaultState() + + if hasLegacyState { + data, err := os.ReadFile(legacyStatePath) + if err == nil { + var ls legacyState + if err := json.Unmarshal(data, &ls); err == nil { + if ls.Mode == "history" || ls.Mode == "spec" { + state.LastMode = ls.Mode + } + } + } + } + + if hasLegacyUpdate { + data, err := os.ReadFile(legacyUpdatePath) + if err == nil { + var lu legacyUpdateState + if err := json.Unmarshal(data, &lu); err == nil { + state.Updater.SeenVersion = lu.SeenVersion + if lu.LastCheck > 0 { + state.Updater.LastCheckTime = time.Unix(lu.LastCheck, 0) + } + } + } + } + + if err := SaveState(state); err != nil { + return err + } + + if hasLegacyState { + _ = os.Rename(legacyStatePath, legacyStatePath+".bak") + } + if hasLegacyUpdate { + _ = os.Rename(legacyUpdatePath, legacyUpdatePath+".bak") + } + + return nil +} diff --git a/config/paths.go b/config/paths.go new file mode 100644 index 0000000..283b759 --- /dev/null +++ b/config/paths.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "path/filepath" +) + +func ConfigPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "iris", "config.toml"), nil +} + +func StatePath() (string, error) { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataHome = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataHome, "iris", "state.toml"), nil +} + +func CachePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "iris"), nil +} + +func CrashDir() (string, error) { + cache, err := CachePath() + if err != nil { + return "", err + } + return filepath.Join(cache, "crashes"), nil +} diff --git a/config/state.go b/config/state.go new file mode 100644 index 0000000..9feed49 --- /dev/null +++ b/config/state.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" +) + +type UpdaterState struct { + LastCheckTime time.Time `toml:"last-check-time"` + SeenVersion string `toml:"seen-version"` +} + +type State struct { + LastMode string `toml:"last-mode"` + Updater UpdaterState `toml:"updater"` +} + +func LoadState() *State { + s := DefaultState() + + path, err := StatePath() + if err != nil { + return s + } + + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + return s + } + + data, err := os.ReadFile(path) + if err != nil { + return s + } + + if _, err := toml.Decode(string(data), s); err != nil { + return s + } + + return s +} + +func SaveState(s *State) error { + path, err := StatePath() + if err != nil { + return err + } + + err = os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + return err + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + + enc := toml.NewEncoder(file) + if err := enc.Encode(s); err != nil { + return err + } + + return nil +} diff --git a/go.mod b/go.mod index 5ac4621..1b6434e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/versenilvis/iris go 1.24.2 require ( + github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index d0a73d7..f9f0b00 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= diff --git a/justfile b/justfile index e28760d..9233c41 100644 --- a/justfile +++ b/justfile @@ -15,11 +15,17 @@ optimized-build: run: @./iris +# initialize default config file +[group('dev')] +config-init: + @./iris config init + # re-build and reload iris [group('dev')] [linux, macos] reload: @go build -o iris main.go + @if [ -f ~/.local/bin/iris ]; then rm -f ~/.local/bin/iris && cp ./iris ~/.local/bin/iris; fi @if [ -n "${IRIS_PID:-}" ]; then kill -USR1 $IRIS_PID 2>/dev/null || true; fi @if [ -z "${IRIS_FD:-}" ]; then ./iris; fi diff --git a/root/config_cmd.go b/root/config_cmd.go new file mode 100644 index 0000000..e34b573 --- /dev/null +++ b/root/config_cmd.go @@ -0,0 +1,104 @@ +package root + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/spf13/cobra" + "github.com/versenilvis/iris/config" +) + +var ConfigCmd = &cobra.Command{ + Use: "config", + Short: "manage iris configuration", +} + +var ConfigInitCmd = &cobra.Command{ + Use: "init", + Short: "initialize default configuration file with comments", + Run: func(cmd *cobra.Command, args []string) { + path, err := config.ConfigPath() + if err != nil { + fmt.Printf("failed to get config path: %v\n", err) + return + } + + if _, statErr := os.Stat(path); statErr == nil { + fmt.Printf("config file already exists at %s\n", path) + return + } + + _ = os.MkdirAll(filepath.Dir(path), 0755) + + defaultContent := `# ~/.config/iris/config.toml +# iris configuration file + +[core] +# schema version +# do not edit this field manually +version = 1 + +# override shell: "bash", "zsh", "fish", keep empty for auto detection +shell = "" + +# startup mode: "last", "spec", "history" +# "last" = remember last mode used +mode = "last" + +# enable debug logging +debug = false + +[ui] +# enable inline ghost text +ghost-text = true + +# maximum suggestions to display +max-suggestions = 100 + +# maximum height of the overlay +max-height = 15 + +[git] +# hide current branch in checkout/switch list +filter-active-branch = true + +# merge remote and local branches with same name +deduplicate-branches = true + +[updater] +# check for updates on startup +check-on-startup = true + +# update channel: "stable", "nightly" +channel = "stable" + +# interval between update checks, e.g. "24h", "6h", "30m" +check-interval = "24h" +` + err = os.WriteFile(path, []byte(defaultContent), 0644) + if err != nil { + fmt.Printf("failed to write config file: %v\n", err) + return + } + fmt.Printf("initialized config file at %s\n", path) + }, +} + +var ConfigShowCmd = &cobra.Command{ + Use: "show", + Short: "show the resolved configuration", + Run: func(cmd *cobra.Command, args []string) { + enc := toml.NewEncoder(cmd.OutOrStdout()) + if err := enc.Encode(config.Get()); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "failed to encode config: %v\n", err) + } + }, +} + +func init() { + ConfigCmd.AddCommand(ConfigInitCmd) + ConfigCmd.AddCommand(ConfigShowCmd) + rootCmd.AddCommand(ConfigCmd) +} diff --git a/root/crash.go b/root/crash.go index 4894a29..a94b4f0 100644 --- a/root/crash.go +++ b/root/crash.go @@ -11,6 +11,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/versenilvis/iris/config" ) // startRescueShell starts a fallback shell if the application crashes to keep the terminal open @@ -29,11 +30,10 @@ var ( // writeCrashLog writes the crash info and stack trace to a new log file func WriteCrashLog(err any) { - home, errDir := os.UserHomeDir() + dir, errDir := config.CrashDir() if errDir != nil { return } - dir := filepath.Join(home, ".iris", "crashes") _ = os.MkdirAll(dir, 0755) logFile := filepath.Join(dir, fmt.Sprintf("crash_%s.log", time.Now().Format("20060102_150405"))) @@ -66,16 +66,36 @@ func WriteCrashLog(err any) { // getLatestCrashLog returns the path to the newest crash log file func getLatestCrashLog() string { - home, err := os.UserHomeDir() - if err != nil { + dir, errDir := config.CrashDir() + if errDir != nil { return "" } - dir := filepath.Join(home, ".iris", "crashes") files, err := os.ReadDir(dir) if err != nil || len(files) == 0 { - oldLog := filepath.Join(home, ".iris", "crash.log") - if _, err := os.Stat(oldLog); err == nil { - return oldLog + home, errHome := os.UserHomeDir() + if errHome == nil { + oldLog := filepath.Join(home, ".iris", "crash.log") + if _, err := os.Stat(oldLog); err == nil { + return oldLog + } + oldCrashes := filepath.Join(home, ".iris", "crashes") + if oldFiles, errOld := os.ReadDir(oldCrashes); errOld == nil && len(oldFiles) > 0 { + var latestOld string + for _, f := range oldFiles { + if f.IsDir() { + continue + } + name := f.Name() + if strings.HasPrefix(name, "crash_") && strings.HasSuffix(name, ".log") { + if name > latestOld { + latestOld = name + } + } + } + if latestOld != "" { + return filepath.Join(oldCrashes, latestOld) + } + } } return "" } @@ -93,9 +113,12 @@ func getLatestCrashLog() string { } } if latest == "" { - oldLog := filepath.Join(home, ".iris", "crash.log") - if _, err := os.Stat(oldLog); err == nil { - return oldLog + home, errHome := os.UserHomeDir() + if errHome == nil { + oldLog := filepath.Join(home, ".iris", "crash.log") + if _, err := os.Stat(oldLog); err == nil { + return oldLog + } } return "" } @@ -119,15 +142,16 @@ var ( Use: "crash-log", Short: "manage iris crash logs", Run: func(cmd *cobra.Command, args []string) { - home, err := os.UserHomeDir() - if err != nil { - cmd.Printf("failed to get home directory: %v\n", err) - return - } - if ClearLog { - _ = os.RemoveAll(filepath.Join(home, ".iris", "crashes")) - _ = os.Remove(filepath.Join(home, ".iris", "crash.log")) + dir, errDir := config.CrashDir() + if errDir == nil { + _ = os.RemoveAll(dir) + } + home, errHome := os.UserHomeDir() + if errHome == nil { + _ = os.RemoveAll(filepath.Join(home, ".iris", "crashes")) + _ = os.Remove(filepath.Join(home, ".iris", "crash.log")) + } cmd.Println("crash log cleared") return } diff --git a/root/init.go b/root/init.go index 2d8944f..84e56e7 100644 --- a/root/init.go +++ b/root/init.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/versenilvis/iris/config" ) var initCmd = &cobra.Command{ @@ -120,6 +121,61 @@ var setupCmd = &cobra.Command{ fmt.Printf("✓ Added iris integration to %s\n", configFile) } + // initialize default config file if it does not exist + if path, err := config.ConfigPath(); err == nil { + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + _ = os.MkdirAll(filepath.Dir(path), 0755) + defaultContent := `# ~/.config/iris/config.toml +# iris configuration file + +[core] +# schema version +# do not edit this field manually +version = 1 + +# override shell: "bash", "zsh", "fish", keep empty for auto detection +shell = "" + +# startup mode: "last", "spec", "history" +# "last" = remember last mode used +mode = "last" + +# enable debug logging +debug = false + +[ui] +# enable inline ghost text +ghost-text = true + +# maximum suggestions to display +max-suggestions = 100 + +# maximum height of the overlay +max-height = 15 + +[git] +# hide current branch in checkout/switch list +filter-active-branch = true + +# merge remote and local branches with same name +deduplicate-branches = true + +[updater] +# check for updates on startup +check-on-startup = true + +# update channel: "stable", "nightly" +channel = "stable" + +# interval between update checks, e.g. "24h", "6h", "30m" +check-interval = "24h" +` + if errWrite := os.WriteFile(path, []byte(defaultContent), 0644); errWrite == nil { + fmt.Printf("✓ Initialized default config file at %s\n", path) + } + } + } + fmt.Println("\nSetup complete! Please restart your terminal or run:") fmt.Printf(" source %s\n", configFile) }, diff --git a/root/root.go b/root/root.go index e5c3d42..8ae650b 100644 --- a/root/root.go +++ b/root/root.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "syscall" @@ -18,6 +19,7 @@ import ( _ "github.com/versenilvis/iris/commands/runner" _ "github.com/versenilvis/iris/commands/search" _ "github.com/versenilvis/iris/commands/view" + "github.com/versenilvis/iris/config" "golang.org/x/term" ) @@ -44,12 +46,6 @@ It works exactly like coding editor suggestion menu drop down.`, return } } - if debugMode { - f, _ := os.OpenFile("iris.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) - debugLogger = f - core.DebugWriter = f - _, _ = fmt.Fprintf(debugLogger, "--- IRIS DEBUG LOG ---\n") - } runWrapper() }, } @@ -61,6 +57,25 @@ It works exactly like coding editor suggestion menu drop down.`, func init() { rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)") rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log") + + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + if shellFlag != "" { + config.Get().Core.Shell = shellFlag + } + if debugMode { + config.Get().Core.Debug = true + } + if config.Get().Core.Debug { + logDir, err := config.CachePath() + if err == nil { + _ = os.MkdirAll(logDir, 0755) + f, _ := os.OpenFile(filepath.Join(logDir, "iris.log"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + debugLogger = f + core.DebugWriter = f + _, _ = fmt.Fprintf(debugLogger, "--- IRIS DEBUG LOG ---\n") + } + } + } } func debugLog(format string, a ...any) { @@ -186,6 +201,13 @@ func runOriginal() { } func Execute() { + _ = config.MigrateFromLegacyJSON() + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "[IRIS] config error: %v\n", err) + } + config.Init(cfg) + if os.Getenv("IRIS_IS_CHILD") != "true" { runWatchdog() return diff --git a/root/suggestions.go b/root/suggestions.go index a07966d..4243c46 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/versenilvis/iris/commands/core" + "github.com/versenilvis/iris/config" "github.com/versenilvis/iris/integration" ) @@ -16,6 +17,7 @@ func MergeResults(query string, mode string) []core.Suggestion { return nil } + maxSugg := config.Get().UI.MaxSuggestions normalizedQuery := strings.TrimSpace(query) seen := make(map[string]bool) deduped := []core.Suggestion{} @@ -33,7 +35,7 @@ func MergeResults(query string, mode string) []core.Suggestion { Desc: " history", Icon: fmt.Sprintf("%d", h.ID), }) - if len(deduped) >= 100 { + if len(deduped) >= maxSugg { break } } @@ -57,8 +59,8 @@ func MergeResults(query string, mode string) []core.Suggestion { deduped = append(deduped, s) } } - if len(deduped) > 100 { - deduped = deduped[:100] + if len(deduped) > maxSugg { + deduped = deduped[:maxSugg] } return deduped } diff --git a/root/update.go b/root/update.go index 52bd32f..c05d812 100644 --- a/root/update.go +++ b/root/update.go @@ -7,23 +7,14 @@ import ( "io" "net/http" "os" - "path/filepath" "strconv" "strings" "time" "github.com/spf13/cobra" + "github.com/versenilvis/iris/config" ) -// updateState holds the persistent update notification state on disk -type updateState struct { - // seenVersion is the last version the user was notified about. - // when a newer release than this is found, we show the message again - SeenVersion string `json:"seen_version"` - // lastCheck is unix timestamp of the last network check - LastCheck int64 `json:"last_check"` -} - // updateResult is passed from the async checker to the main loop type updateResult struct { latestVersion string @@ -33,49 +24,18 @@ type updateResult struct { // pendingUpdate is set by the background goroutine and consumed once after the first IRIS_CMD_STOP var pendingUpdate chan updateResult -func getUpdateStateFile() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".iris", "update_state.json") -} - -func LoadUpdateState() updateState { - file := getUpdateStateFile() - if file == "" { - return updateState{} - } - data, err := os.ReadFile(file) - if err != nil { - return updateState{} - } - var s updateState - if err := json.Unmarshal(data, &s); err != nil { - return updateState{} - } - return s -} - -func SaveUpdateState(s updateState) { - file := getUpdateStateFile() - if file == "" { - return - } - data, _ := json.MarshalIndent(s, "", " ") - _ = os.MkdirAll(filepath.Dir(file), 0755) - _ = os.WriteFile(file, data, 0644) -} - // FetchLatestVersion hits the GitHub Releases API and returns the latest tag name func FetchLatestVersion() (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // allow overriding the version endpoint for testing without a real release endpoint := os.Getenv("IRIS_UPDATE_URL") if endpoint == "" { - endpoint = "https://api.github.com/repos/versenilvis/iris/releases/latest" + if config.Get().Updater.Channel == "nightly" { + endpoint = "https://api.github.com/repos/versenilvis/iris/releases" + } else { + endpoint = "https://api.github.com/repos/versenilvis/iris/releases/latest" + } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) @@ -99,6 +59,19 @@ func FetchLatestVersion() (string, error) { return "", err } + if config.Get().Updater.Channel == "nightly" && os.Getenv("IRIS_UPDATE_URL") == "" { + var releases []struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &releases); err != nil { + return "", err + } + if len(releases) == 0 { + return "", fmt.Errorf("no releases found") + } + return releases[0].TagName, nil + } + var result struct { TagName string `json:"tag_name"` } @@ -123,7 +96,7 @@ func IsNewer(current, latest string) bool { } // nightly builds are never shown as stable update targets - if strings.Contains(l, "-nightly.") { + if config.Get().Updater.Channel != "nightly" && strings.Contains(l, "-nightly.") { return false } @@ -161,6 +134,11 @@ func IsNewer(current, latest string) bool { func startBackgroundUpdateCheck() chan updateResult { ch := make(chan updateResult, 1) + if !config.Get().Updater.CheckOnStartup { + close(ch) + return ch + } + go func() { defer func() { if r := recover(); r != nil { @@ -181,13 +159,13 @@ func startBackgroundUpdateCheck() chan updateResult { return } - state := LoadUpdateState() + state := config.LoadState() - // only check once every 6 hours to avoid hammering the API - if time.Since(time.Unix(state.LastCheck, 0)) < 6*time.Hour { + // only check once every configured check-interval to avoid hammering the API + if time.Since(state.Updater.LastCheckTime) < time.Duration(config.Get().Updater.CheckInterval) { // already checked recently; still notify if we have a cached pending update - if state.SeenVersion != "" && IsNewer(Version, state.SeenVersion) { - ch <- updateResult{latestVersion: state.SeenVersion, hasUpdate: true} + if state.Updater.SeenVersion != "" && IsNewer(Version, state.Updater.SeenVersion) { + ch <- updateResult{latestVersion: state.Updater.SeenVersion, hasUpdate: true} } return } @@ -199,22 +177,22 @@ func startBackgroundUpdateCheck() chan updateResult { } // update the last check time regardless of result - state.LastCheck = time.Now().Unix() + state.Updater.LastCheckTime = time.Now() if IsNewer(Version, latest) { // only notify if user hasn't already seen this specific version notification - if state.SeenVersion != latest { + if state.Updater.SeenVersion != latest { ch <- updateResult{latestVersion: latest, hasUpdate: true} } // save the latest as seen_version so future sessions don't re-notify // unless a NEWER version comes out (different tag) - state.SeenVersion = latest + state.Updater.SeenVersion = latest } else { // up to date: clear the seen_version flag so the next update triggers a fresh notification - state.SeenVersion = "" + state.Updater.SeenVersion = "" } - SaveUpdateState(state) + _ = config.SaveState(state) }() return ch @@ -256,9 +234,9 @@ var updateCmd = &cobra.Command{ if !IsNewer(Version, latest) { fmt.Printf("\033[32m[IRIS] already up to date (%s)\033[0m\n", Version) // clear seen_version so the notification doesn't show again - state := LoadUpdateState() - state.SeenVersion = "" - SaveUpdateState(state) + state := config.LoadState() + state.Updater.SeenVersion = "" + _ = config.SaveState(state) return } @@ -269,9 +247,9 @@ var updateCmd = &cobra.Command{ fmt.Printf("running: curl -sS %s | sh\n\n", installScript) // after a successful update, mark as seen so no more notifications - state := LoadUpdateState() - state.SeenVersion = "" - SaveUpdateState(state) + state := config.LoadState() + state.Updater.SeenVersion = "" + _ = config.SaveState(state) fmt.Printf("\n\033[32m[IRIS] restart your terminal to use the new version\033[0m\n") }, diff --git a/root/wrapper.go b/root/wrapper.go index 547928a..d8f1dd9 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -4,13 +4,11 @@ import ( "bufio" "bytes" "context" - "encoding/json" "fmt" "io" "os" "os/exec" "os/signal" - "path/filepath" "strings" "sync" "sync/atomic" @@ -19,51 +17,32 @@ import ( "github.com/creack/pty" "github.com/versenilvis/iris/commands/core" + "github.com/versenilvis/iris/config" "github.com/versenilvis/iris/integration" "github.com/versenilvis/iris/integration/shell" "golang.org/x/sys/unix" "golang.org/x/term" ) -type State struct { - Mode string `json:"mode"` -} - -func getStateFile() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - dir := filepath.Join(home, ".iris") - _ = os.MkdirAll(dir, 0755) - return filepath.Join(dir, "state.json") -} - func loadMode() string { - file := getStateFile() - if file != "" { - data, err := os.ReadFile(file) - if err == nil { - var state State - if err := json.Unmarshal(data, &state); err == nil { - if state.Mode == "history" || state.Mode == "spec" { - return state.Mode - } - } + mode := config.Get().Core.Mode + if mode == "last" { + state := config.LoadState() + if state.LastMode == "history" || state.LastMode == "spec" { + return state.LastMode } + return "spec" + } + if mode == "history" || mode == "spec" { + return mode } return "spec" } func saveMode(mode string) { - file := getStateFile() - if file != "" { - state := State{Mode: mode} - data, err := json.MarshalIndent(state, "", " ") - if err == nil { - _ = os.WriteFile(file, data, 0644) - } - } + state := config.LoadState() + state.LastMode = mode + _ = config.SaveState(state) } var ( @@ -160,7 +139,22 @@ func runWrapper() { } if c.Process != nil { - if cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", c.Process.Pid)); err == nil { + cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", c.Process.Pid)) + if err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + out, errCmd := exec.CommandContext(ctx, "lsof", "-p", fmt.Sprintf("%d", c.Process.Pid), "-a", "-d", "cwd", "-F", "n").Output() + cancel() + if errCmd == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "n") { + cwd = strings.TrimSpace(line[1:]) + err = nil + break + } + } + } + } + if err == nil { _ = os.Chdir(cwd) } _ = syscall.Kill(c.Process.Pid, syscall.SIGKILL) @@ -205,6 +199,7 @@ func runWrapper() { }() var disableGhostText atomic.Bool + disableGhostText.Store(!config.Get().UI.GhostText) var userNavigated bool var renderOverlay func() diff --git a/tests/config_test.go b/tests/config_test.go new file mode 100644 index 0000000..136003a --- /dev/null +++ b/tests/config_test.go @@ -0,0 +1,236 @@ +package tests + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/versenilvis/iris/config" + "github.com/versenilvis/iris/root" +) + +func TestDefaultConfigAndState(t *testing.T) { + cfg := config.DefaultConfig() + if cfg.Core.Version != 1 { + t.Errorf("expected version 1, got %d", cfg.Core.Version) + } + if cfg.UI.MaxSuggestions != 100 { + t.Errorf("expected suggestions 100, got %d", cfg.UI.MaxSuggestions) + } + + state := config.DefaultState() + if state.LastMode != "spec" { + t.Errorf("expected last mode spec, got %q", state.LastMode) + } +} + +func TestCustomDuration(t *testing.T) { + var dur config.Duration + err := dur.UnmarshalText([]byte("6h")) + if err != nil { + t.Fatalf("unexpected error unmarshalling duration: %v", err) + } + if time.Duration(dur) != 6*time.Hour { + t.Errorf("expected 6 hours, got %v", time.Duration(dur)) + } + + b, err := dur.MarshalText() + if err != nil { + t.Fatalf("unexpected error marshaling duration: %v", err) + } + if string(b) != "6h0m0s" { + t.Errorf("expected 6h0m0s, got %q", string(b)) + } + + err = dur.UnmarshalText([]byte("invalid")) + if err == nil { + t.Errorf("expected error for invalid duration") + } +} + +func TestValidationAndEnvironmentOverrides(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "iris-config-env-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { _ = os.Unsetenv("XDG_CONFIG_HOME") }() + + _ = os.Setenv("IRIS_CORE_DEBUG", "true") + _ = os.Setenv("IRIS_CORE_SHELL", "fish") + _ = os.Setenv("IRIS_CORE_MODE", "history") + _ = os.Setenv("IRIS_UI_GHOST_TEXT", "false") + _ = os.Setenv("IRIS_UI_MAX_SUGGESTIONS", "250") + _ = os.Setenv("IRIS_UI_MAX_HEIGHT", "25") + _ = os.Setenv("IRIS_UPDATER_CHANNEL", "nightly") + _ = os.Setenv("IRIS_UPDATER_INTERVAL", "12h") + _ = os.Setenv("IRIS_UPDATER_CHECK_ON_STARTUP", "false") + + defer func() { + _ = os.Unsetenv("IRIS_CORE_DEBUG") + _ = os.Unsetenv("IRIS_CORE_SHELL") + _ = os.Unsetenv("IRIS_CORE_MODE") + _ = os.Unsetenv("IRIS_UI_GHOST_TEXT") + _ = os.Unsetenv("IRIS_UI_MAX_SUGGESTIONS") + _ = os.Unsetenv("IRIS_UI_MAX_HEIGHT") + _ = os.Unsetenv("IRIS_UPDATER_CHANNEL") + _ = os.Unsetenv("IRIS_UPDATER_INTERVAL") + _ = os.Unsetenv("IRIS_UPDATER_CHECK_ON_STARTUP") + }() + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + if !cfg.Core.Debug { + t.Errorf("expected debug to be true") + } + if cfg.Core.Shell != "fish" { + t.Errorf("expected shell fish, got %q", cfg.Core.Shell) + } + if cfg.Core.Mode != "history" { + t.Errorf("expected mode history, got %q", cfg.Core.Mode) + } + if cfg.UI.GhostText { + t.Errorf("expected ghost text to be false") + } + if cfg.UI.MaxSuggestions != 250 { + t.Errorf("expected max suggestions 250, got %d", cfg.UI.MaxSuggestions) + } + if cfg.UI.MaxHeight != 25 { + t.Errorf("expected max height 25, got %d", cfg.UI.MaxHeight) + } + if cfg.Updater.Channel != "nightly" { + t.Errorf("expected channel nightly, got %q", cfg.Updater.Channel) + } + if time.Duration(cfg.Updater.CheckInterval) != 12*time.Hour { + t.Errorf("expected 12h, got %v", time.Duration(cfg.Updater.CheckInterval)) + } + if cfg.Updater.CheckOnStartup { + t.Errorf("expected check on startup to be false") + } + + _ = os.Setenv("IRIS_CORE_MODE", "invalid") + _, err = config.Load() + if err == nil { + t.Errorf("expected validation error for invalid mode in env") + } +} + +func TestLoadSave(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "iris-config-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { _ = os.Unsetenv("XDG_CONFIG_HOME") }() + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + cfg.Core.Shell = "zsh" + cfg.UI.MaxHeight = 20 + + err = config.Save(cfg) + if err != nil { + t.Fatalf("failed to save: %v", err) + } + + loaded, err := config.Load() + if err != nil { + t.Fatalf("failed to load after save: %v", err) + } + + if loaded.Core.Shell != "zsh" { + t.Errorf("expected loaded shell to be zsh, got %q", loaded.Core.Shell) + } + if loaded.UI.MaxHeight != 20 { + t.Errorf("expected loaded height to be 20, got %d", loaded.UI.MaxHeight) + } +} + +func TestMigration(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "iris-migrate-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + _ = os.Setenv("HOME", tmpDir) + _ = os.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, ".local", "share")) + defer func() { + _ = os.Unsetenv("HOME") + _ = os.Unsetenv("XDG_DATA_HOME") + }() + + legacyDir := filepath.Join(tmpDir, ".iris") + if errMkdir := os.MkdirAll(legacyDir, 0755); errMkdir != nil { + t.Fatalf("failed to create legacy dir: %v", errMkdir) + } + + legacyStateJson := `{"mode": "history"}` + _ = os.WriteFile(filepath.Join(legacyDir, "state.json"), []byte(legacyStateJson), 0644) + + legacyUpdateJson := `{"seen_version": "v1.2.3", "last_check": 1234567890}` + _ = os.WriteFile(filepath.Join(legacyDir, "update_state.json"), []byte(legacyUpdateJson), 0644) + + err = config.MigrateFromLegacyJSON() + if err != nil { + t.Fatalf("migration failed: %v", err) + } + + state := config.LoadState() + if state.LastMode != "history" { + t.Errorf("expected migrated last mode 'history', got %q", state.LastMode) + } + if state.Updater.SeenVersion != "v1.2.3" { + t.Errorf("expected migrated seen version 'v1.2.3', got %q", state.Updater.SeenVersion) + } + if state.Updater.LastCheckTime.Unix() != 1234567890 { + t.Errorf("expected migrated check time 1234567890, got %v", state.Updater.LastCheckTime.Unix()) + } + + if _, err := os.Stat(filepath.Join(legacyDir, "state.json.bak")); err != nil { + t.Errorf("expected backup file state.json.bak to exist") + } + if _, err := os.Stat(filepath.Join(legacyDir, "update_state.json.bak")); err != nil { + t.Errorf("expected backup file update_state.json.bak to exist") + } +} + +func TestConfigCommands(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "iris-config-cmd-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + origConfigHome := os.Getenv("XDG_CONFIG_HOME") + defer func() { + _ = os.Setenv("XDG_CONFIG_HOME", origConfigHome) + }() + _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) + + root.ConfigInitCmd.Run(root.ConfigInitCmd, []string{}) + + configPath := filepath.Join(tmpDir, "iris", "config.toml") + if _, err := os.Stat(configPath); err != nil { + t.Errorf("expected config file to be created at %s, but it was not", configPath) + } + + buf := new(bytes.Buffer) + root.ConfigShowCmd.SetOut(buf) + root.ConfigShowCmd.Run(root.ConfigShowCmd, []string{}) + if buf.Len() == 0 { + t.Errorf("expected show command to output configuration") + } +} diff --git a/tests/crash_test.go b/tests/crash_test.go index 3c3e220..09c7cda 100644 --- a/tests/crash_test.go +++ b/tests/crash_test.go @@ -11,7 +11,6 @@ import ( ) func TestWriteCrashLog(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "iris-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -19,15 +18,18 @@ func TestWriteCrashLog(t *testing.T) { defer os.RemoveAll(tmpDir) origHome := os.Getenv("HOME") + origCache := os.Getenv("XDG_CACHE_HOME") defer func() { _ = os.Setenv("HOME", origHome) + _ = os.Setenv("XDG_CACHE_HOME", origCache) }() _ = os.Setenv("HOME", tmpDir) + _ = os.Setenv("XDG_CACHE_HOME", filepath.Join(tmpDir, ".cache")) testErr := "test panic message" root.WriteCrashLog(testErr) - dir := filepath.Join(tmpDir, ".iris", "crashes") + dir := filepath.Join(tmpDir, ".cache", "iris", "crashes") files, err := os.ReadDir(dir) if err != nil { t.Fatalf("failed to read crashes dir: %v", err) @@ -55,7 +57,6 @@ func TestWriteCrashLog(t *testing.T) { } func TestCrashLogCommand(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "iris-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -63,10 +64,13 @@ func TestCrashLogCommand(t *testing.T) { defer os.RemoveAll(tmpDir) origHome := os.Getenv("HOME") + origCache := os.Getenv("XDG_CACHE_HOME") defer func() { _ = os.Setenv("HOME", origHome) + _ = os.Setenv("XDG_CACHE_HOME", origCache) }() _ = os.Setenv("HOME", tmpDir) + _ = os.Setenv("XDG_CACHE_HOME", filepath.Join(tmpDir, ".cache")) var buf bytes.Buffer root.CrashCmd.SetOut(&buf) @@ -81,7 +85,6 @@ func TestCrashLogCommand(t *testing.T) { root.WriteCrashLog("mock error") buf.Reset() root.CrashCmd.Run(root.CrashCmd, []string{}) - // expect the output to contain crash_ and .log, which is the path of the new crash log file if !strings.Contains(buf.String(), "crash_") || !strings.Contains(buf.String(), ".log") { t.Errorf("expected crash log path, got: %q", buf.String()) } @@ -93,7 +96,7 @@ func TestCrashLogCommand(t *testing.T) { t.Errorf("expected 'crash log cleared', got: %q", buf.String()) } - dir := filepath.Join(tmpDir, ".iris", "crashes") + dir := filepath.Join(tmpDir, ".cache", "iris", "crashes") if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Errorf("expected crashes directory to be deleted, but it exists") } diff --git a/tests/dev/git_test.go b/tests/dev/git_test.go index 04efc1b..4fa3ad9 100644 --- a/tests/dev/git_test.go +++ b/tests/dev/git_test.go @@ -164,6 +164,12 @@ func TestGitSuggestions(t *testing.T) { if !found { t.Errorf("git push origin should suggest active branch '%s'", activeBranch) } + if len(res) > 0 { + parts := strings.Fields(res[0].Cmd) + if len(parts) == 0 || parts[len(parts)-1] != activeBranch { + t.Errorf("expected active branch '%s' to be first suggestion, got: %s", activeBranch, res[0].Cmd) + } + } }) t.Run("push origin no duplicate branches", func(t *testing.T) { diff --git a/tests/root/update_test.go b/tests/root/update_test.go index b3993f2..9020b5b 100644 --- a/tests/root/update_test.go +++ b/tests/root/update_test.go @@ -4,7 +4,9 @@ import ( "os" "path/filepath" "testing" + "time" + "github.com/versenilvis/iris/config" "github.com/versenilvis/iris/root" ) @@ -43,25 +45,33 @@ func TestUpdateState(t *testing.T) { // Override home dir for testing homeBackup := os.Getenv("HOME") - if err := os.Setenv("HOME", tmpDir); err != nil { + err = os.Setenv("HOME", tmpDir) + if err != nil { t.Fatal(err) } defer func() { _ = os.Setenv("HOME", homeBackup) }() - // Ensure .iris directory exists - _ = os.MkdirAll(filepath.Join(tmpDir, ".iris"), 0755) - - state := root.LoadUpdateState() - state.SeenVersion = "v1.0.0" - state.LastCheck = 123456789 - - root.SaveUpdateState(state) - - loaded := root.LoadUpdateState() - if loaded.SeenVersion != state.SeenVersion { - t.Errorf("Expected SeenVersion %q, got %q", state.SeenVersion, loaded.SeenVersion) + xdgBackup := os.Getenv("XDG_DATA_HOME") + err = os.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, ".local", "share")) + if err != nil { + t.Fatal(err) } - if loaded.LastCheck != state.LastCheck { - t.Errorf("Expected LastCheck %d, got %d", state.LastCheck, loaded.LastCheck) + defer func() { _ = os.Setenv("XDG_DATA_HOME", xdgBackup) }() + + state := config.LoadState() + state.Updater.SeenVersion = "v1.0.0" + state.Updater.LastCheckTime = time.Unix(123456789, 0) + + err = config.SaveState(state) + if err != nil { + t.Fatalf("failed to save state: %v", err) + } + + loaded := config.LoadState() + if loaded.Updater.SeenVersion != state.Updater.SeenVersion { + t.Errorf("Expected SeenVersion %q, got %q", state.Updater.SeenVersion, loaded.Updater.SeenVersion) + } + if loaded.Updater.LastCheckTime.Unix() != state.Updater.LastCheckTime.Unix() { + t.Errorf("Expected LastCheck %v, got %v", state.Updater.LastCheckTime, loaded.Updater.LastCheckTime) } }