diff --git a/godotenv.go b/godotenv.go index 269d7c7..2710572 100644 --- a/godotenv.go +++ b/godotenv.go @@ -16,6 +16,7 @@ package godotenv import ( "bufio" "errors" + "fmt" "io" "os" "os/exec" @@ -23,6 +24,8 @@ import ( "strings" ) +const doubleQuoteSpecialChars = "\\\n\r\"!$`" + // Load will read your env file(s) and load them into ENV for this process. // // Call this function as close as possible to the start of your program (ideally in main) @@ -119,6 +122,11 @@ func Parse(r io.Reader) (envMap map[string]string, err error) { return } +//Unmarshal reads an env file from a string, returning a map of keys and values. +func Unmarshal(str string) (envMap map[string]string, err error) { + return Parse(strings.NewReader(str)) +} + // Exec loads env vars from the specified filenames (empty map falls back to default) // then executes the cmd specified. // @@ -136,6 +144,30 @@ func Exec(filenames []string, cmd string, cmdArgs []string) error { return command.Run() } +// Write serializes the given environment and writes it to a file +func Write(envMap map[string]string, filename string) error { + content, error := Marshal(envMap) + if error != nil { + return error + } + file, error := os.Create(filename) + if error != nil { + return error + } + _, err := file.WriteString(content) + return err +} + +// Marshal outputs the given environment as a dotenv-formatted environment file. +// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped. +func Marshal(envMap map[string]string) (string, error) { + lines := make([]string, 0, len(envMap)) + for k, v := range envMap { + lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v))) + } + return strings.Join(lines, "\n"), nil +} + func filenamesOrDefault(filenames []string) []string { if len(filenames) == 0 { return []string{".env"} @@ -264,3 +296,17 @@ func isIgnoredLine(line string) bool { trimmedLine := strings.Trim(line, " \n\t") return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#") } + +func doubleQuoteEscape(line string) string { + for _, c := range doubleQuoteSpecialChars { + toReplace := "\\" + string(c) + if c == '\n' { + toReplace = `\n` + } + if c == '\r' { + toReplace = `\r` + } + line = strings.Replace(line, string(c), toReplace, -1) + } + return line +} diff --git a/godotenv_test.go b/godotenv_test.go index 0bb5229..47b0c35 100644 --- a/godotenv_test.go +++ b/godotenv_test.go @@ -2,7 +2,9 @@ package godotenv import ( "bytes" + "fmt" "os" + "reflect" "testing" ) @@ -326,3 +328,47 @@ func TestErrorParsing(t *testing.T) { t.Errorf("Expected error, got %v", envMap) } } + +func TestWrite(t *testing.T) { + writeAndCompare := func(env string, expected string) { + envMap, _ := Unmarshal(env) + actual, _ := Marshal(envMap) + if expected != actual { + t.Errorf("Expected '%v' (%v) to write as '%v', got '%v' instead.", env, envMap, expected, actual) + } + } + //just test some single lines to show the general idea + //TestRoundtrip makes most of the good assertions + + //values are always double-quoted + writeAndCompare(`key=value`, `key="value"`) + //double-quotes are escaped + writeAndCompare(`key=va"lu"e`, `key="va\"lu\"e"`) + //but single quotes are left alone + writeAndCompare(`key=va'lu'e`, `key="va'lu'e"`) + // newlines, backslashes, and some other special chars are escaped + writeAndCompare(`foo="$ba\n\r\\r!"`, `foo="\$ba\n\r\\r\!"`) +} + +func TestRoundtrip(t *testing.T) { + fixtures := []string{"equals.env", "exported.env", "invalid1.env", "plain.env", "quoted.env"} + for _, fixture := range fixtures { + fixtureFilename := fmt.Sprintf("fixtures/%s", fixture) + env, err := readFile(fixtureFilename) + if err != nil { + continue + } + rep, err := Marshal(env) + if err != nil { + continue + } + roundtripped, err := Unmarshal(rep) + if err != nil { + continue + } + if !reflect.DeepEqual(env, roundtripped) { + t.Errorf("Expected '%s' to roundtrip as '%v', got '%v' instead", fixtureFilename, env, roundtripped) + } + + } +}