Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CLAUDE.md

## General Workflow

When a task involves multiple steps (e.g., implement + commit + PR), complete ALL steps in sequence without stopping. If creating a branch, committing, and opening a PR, finish the entire chain.

## Project Overview

msgvault is an offline Gmail archive tool that exports and stores email data locally with full-text search capabilities. The goal is to archive 20+ years of Gmail data from multiple accounts, make it searchable, and eventually delete emails from Gmail once safely archived.
Expand Down Expand Up @@ -164,6 +168,14 @@ The TUI automatically builds/updates the Parquet cache on launch when new messag

Sync is **read-only** - no modifications to Gmail.

## Go Development

After making any Go code changes, always run `go fmt ./...` and `go vet ./...` before committing. Stage ALL resulting changes, including formatting-only files.

## Git Workflow

When committing changes, always stage ALL modified files (including formatting, generated files, and ancillary changes). Run `git diff` and `git status` before committing to ensure nothing is left unstaged.

## Code Style & Linting

All code must pass formatting and linting checks before commit. A pre-commit
Expand Down
7 changes: 5 additions & 2 deletions cmd/msgvault/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

var (
cfgFile string
homeDir string
verbose bool
cfg *config.Config
logger *slog.Logger
Expand Down Expand Up @@ -41,9 +42,10 @@ in a single binary.`,
Level: level,
}))

// Load config
// Load config (--home is passed through so it influences
// where config.toml is loaded from, like MSGVAULT_HOME).
var err error
cfg, err = config.Load(cfgFile)
cfg, err = config.Load(cfgFile, homeDir)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
Expand Down Expand Up @@ -101,5 +103,6 @@ func wrapOAuthError(err error) error {

func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.msgvault/config.toml)")
rootCmd.PersistentFlags().StringVar(&homeDir, "home", "", "home directory (overrides MSGVAULT_HOME)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
}
26 changes: 21 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,21 @@ func NewDefaultConfig() *Config {
// If path is empty, uses the default location (~/.msgvault/config.toml),
// which is optional (missing file returns defaults).
// If path is explicitly provided, the file must exist.
func Load(path string) (*Config, error) {
//
// homeDir overrides the home directory (equivalent to MSGVAULT_HOME).
// When set, config.toml is loaded from homeDir unless path is also set.
func Load(path, homeDir string) (*Config, error) {
explicit := path != ""

cfg := NewDefaultConfig()

// --home overrides the default home directory, just like MSGVAULT_HOME.
if homeDir != "" {
homeDir = expandPath(homeDir)
cfg.HomeDir = homeDir
cfg.Data.DataDir = homeDir
}

if !explicit {
path = filepath.Join(cfg.HomeDir, "config.toml")
} else {
Expand All @@ -93,15 +103,21 @@ func Load(path string) (*Config, error) {

cfg.configPath = path

// When --config points to a custom location, derive HomeDir and
// default DataDir from the config file's parent directory so that
// tokens, database, attachments, etc. live alongside the config.
if explicit {
// When --config points to a custom location without --home,
// derive HomeDir and default DataDir from the config file's parent
// directory so that tokens, database, attachments, etc. live alongside
// the config.
if explicit && homeDir == "" {
cfg.HomeDir = filepath.Dir(path)
cfg.Data.DataDir = cfg.HomeDir
}

if _, err := toml.DecodeFile(path, cfg); err != nil {
if strings.Contains(err.Error(), "invalid escape") ||
strings.Contains(err.Error(), "hexadecimal digits after") {
return nil, fmt.Errorf("decode config: %w\n\nhint: Windows paths in TOML must use "+
"forward slashes (C:/Games/msgvault) or single quotes ('C:\\Games\\msgvault').", err)
}
return nil, fmt.Errorf("decode config: %w", err)
}

Expand Down
132 changes: 124 additions & 8 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestLoadEmptyPath(t *testing.T) {
t.Setenv("MSGVAULT_HOME", tmpDir)

// Load with empty path should use defaults
cfg, err := Load("")
cfg, err := Load("", "")
if err != nil {
t.Fatalf("Load(\"\") failed: %v", err)
}
Expand Down Expand Up @@ -137,7 +137,7 @@ rate_limit_qps = 10
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load("")
cfg, err := Load("", "")
if err != nil {
t.Fatalf("Load(\"\") failed: %v", err)
}
Expand Down Expand Up @@ -165,7 +165,7 @@ rate_limit_qps = 10

func TestLoadExplicitPathNotFound(t *testing.T) {
// When --config explicitly specifies a file that doesn't exist, Load should error
_, err := Load("/nonexistent/path/config.toml")
_, err := Load("/nonexistent/path/config.toml", "")
if err == nil {
t.Fatal("Load with explicit nonexistent path should return error")
}
Expand All @@ -192,7 +192,7 @@ rate_limit_qps = 3
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load(configPath)
cfg, err := Load(configPath, "")
if err != nil {
t.Fatalf("Load(%q) failed: %v", configPath, err)
}
Expand Down Expand Up @@ -233,7 +233,7 @@ data_dir = "` + filepath.ToSlash(customDataDir) + `"
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load(configPath)
cfg, err := Load(configPath, "")
if err != nil {
t.Fatalf("Load(%q) failed: %v", configPath, err)
}
Expand Down Expand Up @@ -266,7 +266,7 @@ client_secrets = "secrets/client.json"
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load(configPath)
cfg, err := Load(configPath, "")
if err != nil {
t.Fatalf("Load(%q) failed: %v", configPath, err)
}
Expand Down Expand Up @@ -302,7 +302,7 @@ func TestLoadExplicitPathWithTilde(t *testing.T) {
}
tildePath := "~" + tmpDir[len(home):] + "/config.toml"

cfg, err := Load(tildePath)
cfg, err := Load(tildePath, "")
if err != nil {
t.Fatalf("Load(%q) failed: %v", tildePath, err)
}
Expand All @@ -320,7 +320,7 @@ func TestLoadConfigFilePath(t *testing.T) {
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load(configPath)
cfg, err := Load(configPath, "")
if err != nil {
t.Fatalf("Load(%q) failed: %v", configPath, err)
}
Expand Down Expand Up @@ -457,6 +457,122 @@ func TestMkTempDir(t *testing.T) {
})
}

func TestLoadBackslashErrorHint(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "invalid escape (backslash G)",
// \G is not a valid TOML escape → "invalid escape" error
content: "[data]\ndata_dir = \"C:\\Games\\msgvault\"\n",
},
{
name: "unicode escape (backslash U)",
// \U is a TOML Unicode escape expecting 8 hex digits → "hexadecimal digits" error
content: "[data]\ndata_dir = \"C:\\Users\\wesmc\\msgvault\"\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("MSGVAULT_HOME", tmpDir)

configPath := filepath.Join(tmpDir, "config.toml")
if err := os.WriteFile(configPath, []byte(tt.content), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}

_, err := Load("", "")
if err == nil {
t.Fatal("Load should fail on TOML backslash error")
}

errMsg := err.Error()
if !strings.Contains(errMsg, "hint:") {
t.Errorf("error should contain hint, got: %s", errMsg)
}
if !strings.Contains(errMsg, "forward slashes") {
t.Errorf("error should mention forward slashes, got: %s", errMsg)
}
if !strings.Contains(errMsg, "single quotes") {
t.Errorf("error should mention single quotes, got: %s", errMsg)
}
})
}
}

func TestLoadWithHomeDir(t *testing.T) {
homeDir := t.TempDir()

cfg, err := Load("", homeDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}

if cfg.HomeDir != homeDir {
t.Errorf("HomeDir = %q, want %q", cfg.HomeDir, homeDir)
}
if cfg.Data.DataDir != homeDir {
t.Errorf("Data.DataDir = %q, want %q", cfg.Data.DataDir, homeDir)
}

// Derived paths should use the home directory
expectedDB := filepath.Join(homeDir, "msgvault.db")
if cfg.DatabaseDSN() != expectedDB {
t.Errorf("DatabaseDSN() = %q, want %q", cfg.DatabaseDSN(), expectedDB)
}
expectedTokens := filepath.Join(homeDir, "tokens")
if cfg.TokensDir() != expectedTokens {
t.Errorf("TokensDir() = %q, want %q", cfg.TokensDir(), expectedTokens)
}
}

func TestLoadWithHomeDirReadsConfig(t *testing.T) {
// --home should load config.toml from that directory
homeDir := t.TempDir()
configPath := filepath.Join(homeDir, "config.toml")
configContent := `[sync]
rate_limit_qps = 42
`
if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}

cfg, err := Load("", homeDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}

if cfg.Sync.RateLimitQPS != 42 {
t.Errorf("Sync.RateLimitQPS = %d, want 42", cfg.Sync.RateLimitQPS)
}
if cfg.HomeDir != homeDir {
t.Errorf("HomeDir = %q, want %q", cfg.HomeDir, homeDir)
}
}

func TestLoadWithHomeDirExpandsTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get user home dir: %v", err)
}

cfg, err := Load("", "~/custom-data")
if err != nil {
t.Fatalf("Load failed: %v", err)
}

expected := filepath.Join(home, "custom-data")
if cfg.HomeDir != expected {
t.Errorf("HomeDir = %q, want %q", cfg.HomeDir, expected)
}
if cfg.Data.DataDir != expected {
t.Errorf("Data.DataDir = %q, want %q", cfg.Data.DataDir, expected)
}
}

func TestNewDefaultConfig(t *testing.T) {
// Use a temp directory as MSGVAULT_HOME
tmpDir := t.TempDir()
Expand Down