diff --git a/cmd/cheatmd/compose.go b/cmd/cheatmd/compose.go index 1a579ae..a34d79c 100644 --- a/cmd/cheatmd/compose.go +++ b/cmd/cheatmd/compose.go @@ -131,7 +131,7 @@ func buildComposeMarkdown(name, desc, command string) string { func determineComposeTargetFile(file string) (string, error) { if file != "" { - return file, nil + return config.ExpandHomePath(file) } cfgPath := config.Get().Path diff --git a/cmd/cheatmd/dump.go b/cmd/cheatmd/dump.go index 7712803..6cd0e98 100644 --- a/cmd/cheatmd/dump.go +++ b/cmd/cheatmd/dump.go @@ -4,12 +4,9 @@ import ( "encoding/csv" "encoding/json" "fmt" - "os" - "path/filepath" "strconv" "strings" - "github.com/cheatmd-dev/cheatmd/pkg/config" "github.com/cheatmd-dev/cheatmd/pkg/parser" "github.com/spf13/cobra" ) @@ -104,25 +101,11 @@ func dumpVars(vars []parser.VarDef) []dumpVar { } func parseDumpIndex(args []string) (*parser.CheatIndex, error) { - path := "." - if len(args) > 0 { - path = args[0] - } else if config.Get().Path != "." { - path = config.Get().Path - } - absPath, err := filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("error resolving path: %w", err) - } - info, err := os.Stat(absPath) + path, err := resolveCheatPath(args) if err != nil { - return nil, fmt.Errorf("path error: %w", err) - } - p := parser.NewParser() - if info.IsDir() { - return p.ParseDirectory(absPath) + return nil, err } - return p.ParseSingleFile(absPath) + return loadAndParseCheats(path) } func writeDumpCSV(cmd *cobra.Command, entries []dumpEntry) error { diff --git a/cmd/cheatmd/path_test.go b/cmd/cheatmd/path_test.go new file mode 100644 index 0000000..dd9ec16 --- /dev/null +++ b/cmd/cheatmd/path_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cheatmd-dev/cheatmd/pkg/config" + "github.com/spf13/viper" +) + +func TestHomePathInputs(t *testing.T) { + old := *config.Get() + t.Cleanup(func() { *config.Get() = old; viper.Reset() }) + dir := t.TempDir() + path := filepath.Join(dir, "greeting.md") + if err := os.WriteFile(path, []byte("# Greeting\n```sh\necho hello\n```\n"), 0o600); err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(home, path) + if err != nil { + t.Fatal(err) + } + homePath := "~/" + filepath.ToSlash(relative) + expandedPath := home + "/" + filepath.ToSlash(relative) + configFile := filepath.Join(dir, "cheatmd.yaml") + if err := os.WriteFile(configFile, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + for _, configured := range []bool{false, true} { + name := "explicit" + if configured { + name = "configured" + } + t.Run(name, func(t *testing.T) { + args, composeFile := []string{homePath}, homePath + viper.Reset() + viper.SetConfigFile(configFile) + if configured { + t.Setenv("CHEATMD_PATH", homePath) + args, composeFile = nil, "" + } else { + t.Setenv("CHEATMD_PATH", ".") + } + if err := config.Init(); err != nil { + t.Fatal(err) + } + t.Run("root", func(t *testing.T) { + got, err := resolveCheatPath(args) + if err != nil || got != path { + t.Fatalf("resolved path = %q, %v; want %q", got, err, path) + } + }) + t.Run("dump", func(t *testing.T) { + index, err := parseDumpIndex(args) + if err != nil { + t.Fatal(err) + } + if len(index.Cheats) != 1 || index.Cheats[0].Command != "echo hello" { + t.Fatalf("unexpected cheats: %+v", index.Cheats) + } + }) + t.Run("compose", func(t *testing.T) { + got, err := determineComposeTargetFile(composeFile) + if err != nil || got != expandedPath { + t.Fatalf("compose target = %q, %v; want %q", got, err, expandedPath) + } + }) + }) + } + + for _, tc := range []struct{ name, path, want string }{ + {"absolute second path", homePath + ",/unused/second.md", expandedPath}, + {"parent in second path", homePath + ",/second/../../third.md", expandedPath}, + {"bare home first", "~,/second", filepath.Join(home, "snippets.md")}, + } { + t.Run("compose configured path list/"+tc.name, func(t *testing.T) { + viper.Reset() + viper.SetConfigFile(configFile) + t.Setenv("CHEATMD_PATH", tc.path) + if err := config.Init(); err != nil { + t.Fatal(err) + } + got, err := determineComposeTargetFile("") + if err != nil || got != tc.want { + t.Fatalf("compose target = %q, %v; want first path %q", got, err, tc.want) + } + }) + } +} diff --git a/cmd/cheatmd/root.go b/cmd/cheatmd/root.go index 153e25a..bb7cb8d 100644 --- a/cmd/cheatmd/root.go +++ b/cmd/cheatmd/root.go @@ -102,6 +102,10 @@ func resolveCheatPath(args []string) (string, error) { path = config.Get().Path } + path, err := config.ExpandHomePath(path) + if err != nil { + return "", fmt.Errorf("error resolving path: %w", err) + } absPath, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("error resolving path: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index dd11df5..d7baa26 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -181,8 +181,36 @@ func Init() error { }); err != nil { return err } + if configErr != nil { + return configErr + } + path, remaining, multiple := strings.Cut(cfg.Path, ",") + path, err := ExpandHomePath(path) + if err != nil { + return fmt.Errorf("cheat path: %w", err) + } + if multiple { + path += "," + remaining + } + cfg.Path = path + + return nil +} - return configErr +// ExpandHomePath resolves a leading ~ or ~/ against the user's home directory. +// Other paths are preserved, including relative paths and literal dollar signs. +func ExpandHomePath(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, "~"+string(filepath.Separator)) { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + return home, nil + } + return home + path[1:], nil } // registerDefaults seeds viper with a default for every config key, sourced diff --git a/pkg/config/path_test.go b/pkg/config/path_test.go new file mode 100644 index 0000000..a839a34 --- /dev/null +++ b/pkg/config/path_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +func TestConfiguredHomePaths(t *testing.T) { + old := *Get() + t.Cleanup(func() { *Get() = old; viper.Reset() }) + file := filepath.Join(t.TempDir(), "cheatmd.yaml") + if err := os.WriteFile(file, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ path, want string }{ + {"~", home}, + {"~/cheats", filepath.Join(home, "cheats")}, + {"~/first/../cheats", home + "/first/../cheats"}, + {"~,/second", home + ",/second"}, + {"~/first,/second/../third", home + "/first,/second/../third"}, + {"~/first,/second/../../third", home + "/first,/second/../../third"}, + {"~other/cheats", "~other/cheats"}, + {"cheats/~archive", "cheats/~archive"}, + {"$HOME/cheats", "$HOME/cheats"}, + } { + t.Run(tc.path, func(t *testing.T) { + viper.Reset() + viper.SetConfigFile(file) + t.Setenv("CHEATMD_PATH", tc.path) + if err := Init(); err != nil { + t.Fatal(err) + } + if got := Get().Path; got != tc.want { + t.Errorf("configured path = %q, want %q", got, tc.want) + } + if got := CheatsInstallDir(); got != tc.want { + t.Errorf("install path = %q, want %q", got, tc.want) + } + }) + } +}