-
Notifications
You must be signed in to change notification settings - Fork 3
Add new commands, fix bugs, and improve .env parsing #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func TestListCommandExecutes(t *testing.T) { | ||
| // Create a temporary project config | ||
| dir := t.TempDir() | ||
| cfgPath := filepath.Join(dir, ".envmap.yaml") | ||
| cfg := `project: test-project | ||
| default_env: dev | ||
| envs: | ||
| dev: | ||
| provider: local-dev | ||
| path_prefix: /test/dev/ | ||
| prod: | ||
| provider: aws-ssm | ||
| path_prefix: /test/prod/ | ||
| ` | ||
| if err := os.WriteFile(cfgPath, []byte(cfg), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| // Change to the temp directory | ||
| oldDir, _ := os.Getwd() | ||
| defer os.Chdir(oldDir) | ||
| os.Chdir(dir) | ||
|
|
||
| // Test list command runs without error | ||
| cmd := newListCmd() | ||
| if err := cmd.Execute(); err != nil { | ||
| t.Fatalf("list command failed: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestListCommandJSONExecutes(t *testing.T) { | ||
| // Create a temporary project config | ||
| dir := t.TempDir() | ||
| cfgPath := filepath.Join(dir, ".envmap.yaml") | ||
| cfg := `project: json-test | ||
| default_env: staging | ||
| envs: | ||
| staging: | ||
| provider: local-file | ||
| path_prefix: /staging/ | ||
| ` | ||
| if err := os.WriteFile(cfgPath, []byte(cfg), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| // Change to the temp directory | ||
| oldDir, _ := os.Getwd() | ||
| defer os.Chdir(oldDir) | ||
| os.Chdir(dir) | ||
|
Comment on lines
+57
to
+59
|
||
|
|
||
| // Test list command with JSON output | ||
| cmd := newListCmd() | ||
| cmd.SetArgs([]string{"--json"}) | ||
|
|
||
| if err := cmd.Execute(); err != nil { | ||
| t.Fatalf("list --json command failed: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestListCommandNoConfig(t *testing.T) { | ||
| // Create an empty temp directory (no config file) | ||
| dir := t.TempDir() | ||
|
|
||
| // Change to the temp directory | ||
| oldDir, _ := os.Getwd() | ||
| defer os.Chdir(oldDir) | ||
| os.Chdir(dir) | ||
|
Comment on lines
+75
to
+77
|
||
|
|
||
| cmd := newListCmd() | ||
| err := cmd.Execute() | ||
| if err == nil { | ||
| t.Error("expected error when no config file exists") | ||
| } | ||
| } | ||
|
|
||
| func TestDoctorCommandExecutes(t *testing.T) { | ||
| // Create a temporary environment with config files | ||
| dir := t.TempDir() | ||
|
|
||
| // Create project config | ||
| projectCfg := filepath.Join(dir, ".envmap.yaml") | ||
| cfg := `project: doctor-test | ||
| default_env: dev | ||
| envs: | ||
| dev: | ||
| provider: local-dev | ||
| path_prefix: /doctor/dev/ | ||
| ` | ||
| if err := os.WriteFile(projectCfg, []byte(cfg), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| // Change to the temp directory | ||
| oldDir, _ := os.Getwd() | ||
| defer os.Chdir(oldDir) | ||
| os.Chdir(dir) | ||
|
Comment on lines
+104
to
+106
|
||
|
|
||
| cmd := newDoctorCmd() | ||
| // Doctor may return error if global config providers don't match, but should execute | ||
| _ = cmd.Execute() | ||
| } | ||
|
|
||
| func TestCompletionCommandExecutes(t *testing.T) { | ||
| shells := []string{"bash", "zsh", "fish", "powershell"} | ||
|
|
||
| for _, shell := range shells { | ||
| t.Run(shell, func(t *testing.T) { | ||
| // Create a root command with completion subcommand | ||
| rootCmd := &cobra.Command{Use: "envmap"} | ||
| completionCmd := newCompletionCmd() | ||
| rootCmd.AddCommand(completionCmd) | ||
|
|
||
| // Execute the completion subcommand for the specific shell | ||
| rootCmd.SetArgs([]string{"completion", shell}) | ||
| err := rootCmd.Execute() | ||
| if err != nil { | ||
| t.Fatalf("completion %s failed: %v", shell, err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestCompletionCommandInvalidShell(t *testing.T) { | ||
| rootCmd := &cobra.Command{Use: "envmap"} | ||
| completionCmd := newCompletionCmd() | ||
| rootCmd.AddCommand(completionCmd) | ||
|
|
||
| rootCmd.SetArgs([]string{"completion", "invalid-shell"}) | ||
| err := rootCmd.Execute() | ||
| if err == nil { | ||
| t.Error("expected error for invalid shell") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,13 @@ import ( | |||||||||||||
| "strings" | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // parseDotEnv parses a .env file and returns a map of key-value pairs. | ||||||||||||||
| // It handles: | ||||||||||||||
| // - Comments (lines starting with #) | ||||||||||||||
| // - Inline comments (KEY=value # comment) | ||||||||||||||
| // - Quoted values (single and double quotes) | ||||||||||||||
| // - Export prefix (export KEY=value) | ||||||||||||||
| // - Whitespace around = sign | ||||||||||||||
| func parseDotEnv(path string) (map[string]string, error) { | ||||||||||||||
| f, err := os.Open(path) | ||||||||||||||
| if err != nil { | ||||||||||||||
|
|
@@ -18,22 +25,100 @@ func parseDotEnv(path string) (map[string]string, error) { | |||||||||||||
| scanner := bufio.NewScanner(f) | ||||||||||||||
| for scanner.Scan() { | ||||||||||||||
| line := strings.TrimSpace(scanner.Text()) | ||||||||||||||
|
|
||||||||||||||
| // Skip empty lines and comments | ||||||||||||||
| if line == "" || strings.HasPrefix(line, "#") { | ||||||||||||||
| continue | ||||||||||||||
| } | ||||||||||||||
| parts := strings.SplitN(line, "=", 2) | ||||||||||||||
| if len(parts) != 2 { | ||||||||||||||
|
|
||||||||||||||
| // Handle 'export' prefix | ||||||||||||||
| if strings.HasPrefix(line, "export ") { | ||||||||||||||
| line = strings.TrimPrefix(line, "export ") | ||||||||||||||
| line = strings.TrimSpace(line) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Find the = separator | ||||||||||||||
| eqIndex := strings.Index(line, "=") | ||||||||||||||
| if eqIndex == -1 { | ||||||||||||||
| continue | ||||||||||||||
| } | ||||||||||||||
| key := strings.TrimSpace(parts[0]) | ||||||||||||||
| val := strings.TrimSpace(parts[1]) | ||||||||||||||
| val = strings.Trim(val, `"'`) | ||||||||||||||
| if key != "" { | ||||||||||||||
| out[key] = val | ||||||||||||||
|
|
||||||||||||||
| key := strings.TrimSpace(line[:eqIndex]) | ||||||||||||||
| val := strings.TrimSpace(line[eqIndex+1:]) | ||||||||||||||
|
|
||||||||||||||
| // Skip invalid keys | ||||||||||||||
| if key == "" { | ||||||||||||||
| continue | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Parse the value, handling quotes and inline comments | ||||||||||||||
| val = parseEnvValue(val) | ||||||||||||||
|
|
||||||||||||||
| out[key] = val | ||||||||||||||
| } | ||||||||||||||
| if err := scanner.Err(); err != nil { | ||||||||||||||
| return nil, fmt.Errorf("read %s: %w", path, err) | ||||||||||||||
| } | ||||||||||||||
| return out, nil | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // parseEnvValue parses a value from a .env file, handling quotes and inline comments. | ||||||||||||||
| func parseEnvValue(val string) string { | ||||||||||||||
| if val == "" { | ||||||||||||||
| return "" | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Handle double-quoted values | ||||||||||||||
| if strings.HasPrefix(val, `"`) { | ||||||||||||||
| // Find the closing quote, accounting for escaped quotes | ||||||||||||||
| endQuote := findClosingQuote(val[1:], '"') | ||||||||||||||
| if endQuote != -1 { | ||||||||||||||
| // Extract value between quotes, handling escape sequences | ||||||||||||||
| val = val[1 : endQuote+1] | ||||||||||||||
| val = strings.ReplaceAll(val, `\"`, `"`) | ||||||||||||||
| val = strings.ReplaceAll(val, `\\`, `\`) | ||||||||||||||
| val = strings.ReplaceAll(val, `\n`, "\n") | ||||||||||||||
| val = strings.ReplaceAll(val, `\t`, "\t") | ||||||||||||||
|
Comment on lines
+79
to
+81
|
||||||||||||||
| val = strings.ReplaceAll(val, `\\`, `\`) | |
| val = strings.ReplaceAll(val, `\n`, "\n") | |
| val = strings.ReplaceAll(val, `\t`, "\t") | |
| val = strings.ReplaceAll(val, `\n`, "\n") | |
| val = strings.ReplaceAll(val, `\t`, "\t") | |
| val = strings.ReplaceAll(val, `\\`, `\`) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This ai comment is correct - the order was on purpose here.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,3 +81,84 @@ func TestParseDotEnvNotFound(t *testing.T) { | |
| t.Error("expected error for missing file") | ||
| } | ||
| } | ||
|
|
||
| func TestParseDotEnvAdvanced(t *testing.T) { | ||
| content := `# Advanced parsing test | ||
| export EXPORTED_VAR=exported_value | ||
| INLINE_COMMENT=value # this is a comment | ||
| DOUBLE_QUOTED="hello world" | ||
| SINGLE_QUOTED='hello world' | ||
| ESCAPED_QUOTE="say \"hello\"" | ||
| NEWLINE_ESCAPE="line1\nline2" | ||
| TAB_ESCAPE="col1\tcol2" | ||
| SPACES_AROUND = value_with_spaces | ||
| NO_QUOTES=simple | ||
| EMPTY_QUOTED="" | ||
| URL=https://example.com/path?query=1 | ||
| ` | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, ".env") | ||
| if err := os.WriteFile(path, []byte(content), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| got, err := parseDotEnv(path) | ||
| if err != nil { | ||
| t.Fatalf("parseDotEnv: %v", err) | ||
| } | ||
|
|
||
| tests := []struct { | ||
| key string | ||
| expected string | ||
| }{ | ||
| {"EXPORTED_VAR", "exported_value"}, | ||
| {"INLINE_COMMENT", "value"}, | ||
| {"DOUBLE_QUOTED", "hello world"}, | ||
| {"SINGLE_QUOTED", "hello world"}, | ||
| {"ESCAPED_QUOTE", `say "hello"`}, | ||
| {"NEWLINE_ESCAPE", "line1\nline2"}, | ||
| {"TAB_ESCAPE", "col1\tcol2"}, | ||
| {"SPACES_AROUND", "value_with_spaces"}, | ||
| {"NO_QUOTES", "simple"}, | ||
| {"EMPTY_QUOTED", ""}, | ||
| {"URL", "https://example.com/path?query=1"}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.key, func(t *testing.T) { | ||
| val, ok := got[tt.key] | ||
| if !ok { | ||
| t.Errorf("key %q not found", tt.key) | ||
| return | ||
| } | ||
| if val != tt.expected { | ||
| t.Errorf("got[%q] = %q, want %q", tt.key, val, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
Comment on lines
+85
to
+139
|
||
|
|
||
| func TestParseEnvValue(t *testing.T) { | ||
| tests := []struct { | ||
| input string | ||
| expected string | ||
| }{ | ||
| {`"quoted"`, "quoted"}, | ||
| {`'single'`, "single"}, | ||
| {`plain`, "plain"}, | ||
| {`value # comment`, "value"}, | ||
| {`"value # not comment"`, "value # not comment"}, | ||
| {`""`, ""}, | ||
| {`"escaped \"quote\""`, `escaped "quote"`}, | ||
| {`"new\nline"`, "new\nline"}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.input, func(t *testing.T) { | ||
| got := parseEnvValue(tt.input) | ||
| if got != tt.expected { | ||
| t.Errorf("parseEnvValue(%q) = %q, want %q", tt.input, got, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error returned from
os.Getwd()is being silently ignored. If getting the current directory fails, the deferredos.Chdir(oldDir)will use an empty string, which may cause unexpected behavior. Consider handling this error or failing the test if it occurs.