From 4ead7ac3fad8294d4d7ccc57742017aba5ba03b5 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Fri, 27 Feb 2026 07:34:34 +0000 Subject: [PATCH 1/6] feat: add list-vaults command to discover registered Obsidian vaults - Add `list-vaults` (alias `lv`) command with --json and --path-only flags - Add ListVaults() function to enumerate all vaults from obsidian.json - Derive vault names from directory path using filepath.Base() - Apply WSL path adjustment when running under WSL - Sort output alphabetically by vault name for deterministic display - Add support for numbered snap subdirectories (e.g. ~/snap/obsidian/x1/) when the "current" symlink is missing - Add tests for vault listing (7 cases) and numbered snap discovery Co-Authored-By: Claude Opus 4.6 --- cmd/list_vaults.go | 54 +++++++++++ pkg/config/obsidian_path.go | 7 ++ pkg/config/obsidian_path_test.go | 30 ++++++ pkg/obsidian/vault_list.go | 44 +++++++++ pkg/obsidian/vault_list_test.go | 160 +++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 cmd/list_vaults.go create mode 100644 pkg/obsidian/vault_list.go create mode 100644 pkg/obsidian/vault_list_test.go diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go new file mode 100644 index 0000000..f06a25c --- /dev/null +++ b/cmd/list_vaults.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "sort" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/spf13/cobra" +) + +var listVaultsJSON bool +var listVaultsPathOnly bool + +var listVaultsCmd = &cobra.Command{ + Use: "list-vaults", + Aliases: []string{"lv"}, + Short: "lists all registered Obsidian vaults", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + vaults, err := obsidian.ListVaults() + if err != nil { + log.Fatal(err) + } + + sort.Slice(vaults, func(i, j int) bool { + return vaults[i].Name < vaults[j].Name + }) + + if listVaultsJSON { + output, err := json.MarshalIndent(vaults, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(output)) + return + } + + for _, v := range vaults { + if listVaultsPathOnly { + fmt.Println(v.Path) + } else { + fmt.Printf("%s\t%s\n", v.Name, v.Path) + } + } + }, +} + +func init() { + listVaultsCmd.Flags().BoolVar(&listVaultsJSON, "json", false, "output as JSON array") + listVaultsCmd.Flags().BoolVar(&listVaultsPathOnly, "path-only", false, "output one path per line") + rootCmd.AddCommand(listVaultsCmd) +} diff --git a/pkg/config/obsidian_path.go b/pkg/config/obsidian_path.go index 4bcaf13..17fff1c 100644 --- a/pkg/config/obsidian_path.go +++ b/pkg/config/obsidian_path.go @@ -60,6 +60,13 @@ func ObsidianFile() (obsidianConfigFile string, err error) { candidatePaths = append(candidatePaths, filepath.Join(homeDir, "snap", "obsidian", "current", ".config", "obsidian", ObsidianConfigFile)) + // Also check numbered snap subdirectories (e.g. ~/snap/obsidian/x1/.config/obsidian/) + // which exist when the "current" symlink is missing or multiple snap versions are installed. + snapNumberedPattern := filepath.Join(homeDir, "snap", "obsidian", "*", ".config", "obsidian", ObsidianConfigFile) + if matches, globErr := filepath.Glob(snapNumberedPattern); globErr == nil { + candidatePaths = append(candidatePaths, matches...) + } + var firstNonExistErr error for _, path := range candidatePaths { if _, statErr := os.Stat(path); statErr == nil { diff --git a/pkg/config/obsidian_path_test.go b/pkg/config/obsidian_path_test.go index 64ce6b5..1c740b3 100644 --- a/pkg/config/obsidian_path_test.go +++ b/pkg/config/obsidian_path_test.go @@ -201,6 +201,36 @@ func TestConfigObsidianPath(t *testing.T) { assert.Equal(t, nativeConfigFile, obsConfigFile) }) + t.Run("Finds numbered Snap config on Linux when current symlink does not exist", func(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Snap test only runs on Linux") + } + + originalUserConfigDir := config.UserConfigDirectory + defer func() { config.UserConfigDirectory = originalUserConfigDir }() + + tempDir := t.TempDir() + snapDir := filepath.Join(tempDir, "snap", "obsidian", "x1", ".config", "obsidian") + err := os.MkdirAll(snapDir, 0755) + assert.NoError(t, err) + + snapConfigFile := filepath.Join(snapDir, "obsidian.json") + err = os.WriteFile(snapConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tempDir) + + config.UserConfigDirectory = func() (string, error) { + return filepath.Join(tempDir, ".config"), nil + } + + obsConfigFile, err := config.ObsidianFile() + assert.NoError(t, err) + assert.Equal(t, snapConfigFile, obsConfigFile) + }) + t.Run("Finds WSL Install Location", func(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL test only runs on Linux") diff --git a/pkg/obsidian/vault_list.go b/pkg/obsidian/vault_list.go new file mode 100644 index 0000000..15e2905 --- /dev/null +++ b/pkg/obsidian/vault_list.go @@ -0,0 +1,44 @@ +package obsidian + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +type VaultInfo struct { + Name string `json:"name"` + Path string `json:"path"` +} + +func ListVaults() ([]VaultInfo, error) { + obsidianConfigFile, err := ObsidianConfigFile() + if err != nil { + return nil, err + } + + content, err := os.ReadFile(obsidianConfigFile) + if err != nil { + return nil, errors.New(ObsidianConfigReadError) + } + + vaultsContent := ObsidianVaultConfig{} + if json.Unmarshal(content, &vaultsContent) != nil { + return nil, errors.New(ObsidianConfigParseError) + } + + vaults := make([]VaultInfo, 0, len(vaultsContent.Vaults)) + for _, element := range vaultsContent.Vaults { + path := element.Path + if RunningInWSL() { + path = adjustForWslMount(path) + } + vaults = append(vaults, VaultInfo{ + Name: filepath.Base(path), + Path: path, + }) + } + + return vaults, nil +} diff --git a/pkg/obsidian/vault_list_test.go b/pkg/obsidian/vault_list_test.go new file mode 100644 index 0000000..bbaf80f --- /dev/null +++ b/pkg/obsidian/vault_list_test.go @@ -0,0 +1,160 @@ +package obsidian_test + +import ( + "os" + "testing" + + "github.com/Yakitrak/notesmd-cli/mocks" + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/stretchr/testify/assert" +) + +func TestListVaults(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + // Default: not running in WSL + obsidian.RunningInWSL = func() bool { return false } + + t.Run("Lists all vaults with derived names", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + obsidianConfig := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + }, + "def456": { + "path": "/Users/user/Documents/Work" + }, + "ghi789": { + "path": "/Users/user/Documents/Projects/Notes" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(obsidianConfig), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 3) + + names := make(map[string]string) + for _, v := range vaults { + names[v.Name] = v.Path + } + assert.Equal(t, "/Users/user/Documents/Personal", names["Personal"]) + assert.Equal(t, "/Users/user/Documents/Work", names["Work"]) + assert.Equal(t, "/Users/user/Documents/Projects/Notes", names["Notes"]) + }) + + t.Run("Empty vaults map returns empty slice", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Empty(t, vaults) + }) + + t.Run("Config file locator error is propagated", func(t *testing.T) { + obsidian.ObsidianConfigFile = func() (string, error) { + return "", os.ErrNotExist + } + + _, err := obsidian.ListVaults() + + assert.Equal(t, os.ErrNotExist, err) + }) + + t.Run("Config file unreadable returns read error", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(``), 0000) + assert.NoError(t, err) + + _, err = obsidian.ListVaults() + + assert.Equal(t, obsidian.ObsidianConfigReadError, err.Error()) + }) + + t.Run("Invalid JSON returns parse error", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(`not valid json`), 0644) + assert.NoError(t, err) + + _, err = obsidian.ListVaults() + + assert.Equal(t, obsidian.ObsidianConfigParseError, err.Error()) + }) + + t.Run("WSL path adjustment converts Windows path and derives name", func(t *testing.T) { + obsidian.RunningInWSL = func() bool { return true } + defer func() { obsidian.RunningInWSL = func() bool { return false } }() + + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "C:\\Users\\user\\Documents\\MyVault" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 1) + assert.Equal(t, "MyVault", vaults[0].Name) + assert.Equal(t, "/mnt/c/Users/user/Documents/MyVault", vaults[0].Path) + }) + + t.Run("Single vault returns one entry", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "/home/user/Notes" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 1) + assert.Equal(t, "Notes", vaults[0].Name) + assert.Equal(t, "/home/user/Notes", vaults[0].Path) + }) +} From ca9c9956f4e839cbc4b2ba54ffbcfafa3ed161f7 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Mon, 2 Mar 2026 03:45:35 +0000 Subject: [PATCH 2/6] fix: validate set-default input against registered vaults (#78) set-default now resolves user input (name or path) against Obsidian's registered vaults, preventing silent failures when a path is passed instead of a vault name. Unrecognized input produces a helpful error listing available vaults. Co-Authored-By: Claude Opus 4.6 --- cmd/set_default.go | 5 +- pkg/obsidian/vault_list.go | 38 ++++++++++++ pkg/obsidian/vault_list_test.go | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/cmd/set_default.go b/cmd/set_default.go index a51b25d..cb41b64 100644 --- a/cmd/set_default.go +++ b/cmd/set_default.go @@ -25,7 +25,10 @@ var setDefaultCmd = &cobra.Command{ } if len(args) > 0 { - name := args[0] + name, err := obsidian.ResolveVaultName(args[0]) + if err != nil { + log.Fatal(err) + } v := obsidian.Vault{Name: name} if err := v.SetDefaultName(name); err != nil { log.Fatal(err) diff --git a/pkg/obsidian/vault_list.go b/pkg/obsidian/vault_list.go index 15e2905..baa250a 100644 --- a/pkg/obsidian/vault_list.go +++ b/pkg/obsidian/vault_list.go @@ -3,8 +3,10 @@ package obsidian import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" + "strings" ) type VaultInfo struct { @@ -42,3 +44,39 @@ func ListVaults() ([]VaultInfo, error) { return vaults, nil } + +// ResolveVaultName validates user input against registered Obsidian vaults. +// It accepts a vault name or a path and resolves it to the correct vault name. +func ResolveVaultName(input string) (string, error) { + vaults, err := ListVaults() + if err != nil { + return "", err + } + + if len(vaults) == 0 { + return "", errors.New("no vaults registered in Obsidian. Please create a vault in Obsidian first") + } + + // Exact name match + for _, v := range vaults { + if v.Name == input { + return v.Name, nil + } + } + + // Exact path match (user passed a full path) + cleanInput := filepath.Clean(input) + for _, v := range vaults { + if filepath.Clean(v.Path) == cleanInput { + return v.Name, nil + } + } + + // Build available vault list for the error message + var available []string + for _, v := range vaults { + available = append(available, fmt.Sprintf(" %s\t(%s)", v.Name, v.Path)) + } + + return "", fmt.Errorf("vault %q not found in Obsidian.\nAvailable vaults:\n%s", input, strings.Join(available, "\n")) +} diff --git a/pkg/obsidian/vault_list_test.go b/pkg/obsidian/vault_list_test.go index bbaf80f..1d999d2 100644 --- a/pkg/obsidian/vault_list_test.go +++ b/pkg/obsidian/vault_list_test.go @@ -158,3 +158,106 @@ func TestListVaults(t *testing.T) { assert.Equal(t, "/home/user/Notes", vaults[0].Path) }) } + +func TestResolveVaultName(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + obsidian.RunningInWSL = func() bool { return false } + + obsidianConfig := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + }, + "def456": { + "path": "/Users/user/Documents/Work" + } + } + }` + + setupConfig := func(t *testing.T) { + t.Helper() + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(obsidianConfig), 0644) + assert.NoError(t, err) + } + + t.Run("Resolves exact vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("Personal") + + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + }) + + t.Run("Resolves full path to vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("/Users/user/Documents/Personal") + + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + }) + + t.Run("Resolves path with trailing slash to vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("/Users/user/Documents/Work/") + + assert.NoError(t, err) + assert.Equal(t, "Work", name) + }) + + t.Run("Returns error for unregistered vault", func(t *testing.T) { + setupConfig(t) + + _, err := obsidian.ResolveVaultName("NonExistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in Obsidian") + assert.Contains(t, err.Error(), "Personal") + assert.Contains(t, err.Error(), "Work") + }) + + t.Run("Returns error for unregistered path", func(t *testing.T) { + setupConfig(t) + + _, err := obsidian.ResolveVaultName("/var/workspace/obsidian") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in Obsidian") + }) + + t.Run("Returns error when no vaults registered", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + _, err = obsidian.ResolveVaultName("anything") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no vaults registered") + }) + + t.Run("Propagates config file errors", func(t *testing.T) { + obsidian.ObsidianConfigFile = func() (string, error) { + return "", os.ErrNotExist + } + + _, err := obsidian.ResolveVaultName("Personal") + + assert.Error(t, err) + }) +} From e4a9d436cc7b0d8b5fc88f533234d1a08e124ca7 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Tue, 10 Mar 2026 22:54:44 +0000 Subject: [PATCH 3/6] docs: add list-vaults to README * Add usage section for list-vaults command * Document --json and --path-only flags * Include alias (lv) reference Co-Authored-By: Claude Opus 4.6 --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 68201fb..f6d3148 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,21 @@ obs_cd() { Then you can use `obs_cd` to navigate to the default vault directory within your terminal. +### List Vaults + +Lists all registered Obsidian vaults. Alias: `lv` + +```bash +# Lists all vaults (name and path) +notesmd-cli list-vaults + +# Outputs vaults as JSON +notesmd-cli list-vaults --json + +# Outputs only vault paths (useful for scripting) +notesmd-cli list-vaults --path-only +``` + ### Open Note Open given note name in Obsidian (or your default editor). Note can also be an absolute path from top level of vault. From 0144539adfcf970f9aaea956d8723c2174b45de6 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Tue, 10 Mar 2026 23:09:51 +0000 Subject: [PATCH 4/6] fix: error on ambiguous vault name resolve * Detect duplicate names in ResolveVaultName * Return error listing conflicting paths * User can pass full path to disambiguate * Add tests for ambiguous and full-path cases Co-Authored-By: Claude Opus 4.6 --- pkg/obsidian/vault_list.go | 18 ++++++++++++-- pkg/obsidian/vault_list_test.go | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/pkg/obsidian/vault_list.go b/pkg/obsidian/vault_list.go index baa250a..9cb4f12 100644 --- a/pkg/obsidian/vault_list.go +++ b/pkg/obsidian/vault_list.go @@ -57,11 +57,25 @@ func ResolveVaultName(input string) (string, error) { return "", errors.New("no vaults registered in Obsidian. Please create a vault in Obsidian first") } - // Exact name match + // Collect all name matches + var nameMatches []VaultInfo for _, v := range vaults { if v.Name == input { - return v.Name, nil + nameMatches = append(nameMatches, v) + } + } + if len(nameMatches) == 1 { + return nameMatches[0].Name, nil + } + if len(nameMatches) > 1 { + var paths []string + for _, m := range nameMatches { + paths = append(paths, fmt.Sprintf(" %s", m.Path)) } + return "", fmt.Errorf( + "multiple vaults named %q found. Use the full path to disambiguate:\n%s", + input, strings.Join(paths, "\n"), + ) } // Exact path match (user passed a full path) diff --git a/pkg/obsidian/vault_list_test.go b/pkg/obsidian/vault_list_test.go index 1d999d2..07115d1 100644 --- a/pkg/obsidian/vault_list_test.go +++ b/pkg/obsidian/vault_list_test.go @@ -251,6 +251,48 @@ func TestResolveVaultName(t *testing.T) { assert.Contains(t, err.Error(), "no vaults registered") }) + t.Run("Returns error for ambiguous vault name", func(t *testing.T) { + ambiguousConfig := `{ + "vaults": { + "abc123": {"path": "/home/user/work/Notes"}, + "def456": {"path": "/home/user/personal/Notes"} + } + }` + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(ambiguousConfig), 0644) + assert.NoError(t, err) + + _, err = obsidian.ResolveVaultName("Notes") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple vaults named") + assert.Contains(t, err.Error(), "/home/user/work/Notes") + assert.Contains(t, err.Error(), "/home/user/personal/Notes") + }) + + t.Run("Resolves ambiguous name via full path", func(t *testing.T) { + ambiguousConfig := `{ + "vaults": { + "abc123": {"path": "/home/user/work/Notes"}, + "def456": {"path": "/home/user/personal/Notes"} + } + }` + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(ambiguousConfig), 0644) + assert.NoError(t, err) + + name, err := obsidian.ResolveVaultName("/home/user/work/Notes") + + assert.NoError(t, err) + assert.Equal(t, "Notes", name) + }) + t.Run("Propagates config file errors", func(t *testing.T) { obsidian.ObsidianConfigFile = func() (string, error) { return "", os.ErrNotExist From add1d2522350aa26eb2701101404a73e45714e38 Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Wed, 11 Mar 2026 00:42:19 +0000 Subject: [PATCH 5/6] fix: mark --json and --path-only as mutually exclusive Co-Authored-By: Claude Opus 4.6 --- cmd/list_vaults.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go index f06a25c..e8e81a1 100644 --- a/cmd/list_vaults.go +++ b/cmd/list_vaults.go @@ -50,5 +50,6 @@ var listVaultsCmd = &cobra.Command{ func init() { listVaultsCmd.Flags().BoolVar(&listVaultsJSON, "json", false, "output as JSON array") listVaultsCmd.Flags().BoolVar(&listVaultsPathOnly, "path-only", false, "output one path per line") + listVaultsCmd.MarkFlagsMutuallyExclusive("json", "path-only") rootCmd.AddCommand(listVaultsCmd) } From 3df56d2a96f3e85adbb90df2c9f3602aa241d4ba Mon Sep 17 00:00:00 2001 From: Peter Souter Date: Wed, 11 Mar 2026 11:18:00 +0000 Subject: [PATCH 6/6] feat: use tabwriter for aligned list-vaults output * Extract formatVaultsTable with io.Writer for testability * Use text/tabwriter to align name and path columns * Add tests for column alignment, single vault, and empty list Co-Authored-By: Claude Opus 4.6 --- cmd/list_vaults.go | 27 +++++++++++++++--- cmd/list_vaults_test.go | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 cmd/list_vaults_test.go diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go index e8e81a1..b229816 100644 --- a/cmd/list_vaults.go +++ b/cmd/list_vaults.go @@ -3,8 +3,11 @@ package cmd import ( "encoding/json" "fmt" + "io" "log" + "os" "sort" + "text/tabwriter" "github.com/Yakitrak/notesmd-cli/pkg/obsidian" "github.com/spf13/cobra" @@ -37,16 +40,32 @@ var listVaultsCmd = &cobra.Command{ return } - for _, v := range vaults { - if listVaultsPathOnly { + if listVaultsPathOnly { + for _, v := range vaults { fmt.Println(v.Path) - } else { - fmt.Printf("%s\t%s\n", v.Name, v.Path) } + } else { + formatVaultsTable(os.Stdout, vaults) } }, } +// formatVaultsTable writes vaults as aligned columns using tabwriter, +// so that the path column lines up regardless of vault name length. +// +// Example output: +// +// Notes /home/user/Notes +// LongVaultName /home/user/LongVaultName +// Work /home/user/Work +func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, v := range vaults { + fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path) + } + tw.Flush() +} + func init() { listVaultsCmd.Flags().BoolVar(&listVaultsJSON, "json", false, "output as JSON array") listVaultsCmd.Flags().BoolVar(&listVaultsPathOnly, "path-only", false, "output one path per line") diff --git a/cmd/list_vaults_test.go b/cmd/list_vaults_test.go new file mode 100644 index 0000000..4ee581a --- /dev/null +++ b/cmd/list_vaults_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/stretchr/testify/assert" +) + +func TestFormatVaultsTable(t *testing.T) { + t.Run("Aligns columns with varying name lengths", func(t *testing.T) { + vaults := []obsidian.VaultInfo{ + {Name: "Notes", Path: "/home/user/Notes"}, + {Name: "LongVaultName", Path: "/home/user/LongVaultName"}, + {Name: "Work", Path: "/home/user/Work"}, + } + + var buf bytes.Buffer + formatVaultsTable(&buf, vaults) + output := buf.String() + + // All path columns should start at the same position + lines := bytes.Split(bytes.TrimSpace([]byte(output)), []byte("\n")) + assert.Len(t, lines, 3) + + // Each line should contain both name and path + assert.Contains(t, output, "Notes") + assert.Contains(t, output, "/home/user/Notes") + assert.Contains(t, output, "LongVaultName") + assert.Contains(t, output, "/home/user/LongVaultName") + + // Paths should be aligned — find the byte offset of each path + // With tabwriter, the path column should start at the same position + pathOffsets := make([]int, len(lines)) + for i, line := range lines { + pathOffsets[i] = bytes.Index(line, []byte("/home")) + } + assert.Equal(t, pathOffsets[0], pathOffsets[1], "path columns should be aligned") + assert.Equal(t, pathOffsets[1], pathOffsets[2], "path columns should be aligned") + }) + + t.Run("Single vault produces output", func(t *testing.T) { + vaults := []obsidian.VaultInfo{ + {Name: "MyVault", Path: "/tmp/MyVault"}, + } + + var buf bytes.Buffer + formatVaultsTable(&buf, vaults) + output := buf.String() + + assert.Contains(t, output, "MyVault") + assert.Contains(t, output, "/tmp/MyVault") + }) + + t.Run("Empty vault list produces no output", func(t *testing.T) { + var buf bytes.Buffer + formatVaultsTable(&buf, []obsidian.VaultInfo{}) + + assert.Empty(t, buf.String()) + }) +}