package config import ( "bufio" "fmt" "os" "strings" ) // DotEnvFile represents a parsed .env file with key-value pairs and comments. type DotEnvFile struct { // Values maps env key (with TF_VAR_ prefix) to its value. Values map[string]string // Lines preserves original line order for round-tripping. Lines []EnvLine // Path is the file path this was loaded from. Path string } // EnvLine represents a single line in a .env file. type EnvLine struct { Key string // Empty for comment/blank lines Value string // Raw value (without quotes) RawLine string // Original line text IsComment bool } // ParseDotEnv reads and parses a .env file. // Handles: // - KEY=VALUE assignments // - Comments (# prefix) // - Quoted values (single and double quotes) // - Empty lines // - Inline comments after values func ParseDotEnv(path string) (*DotEnvFile, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("opening .env file %s: %w", path, err) } defer f.Close() env := &DotEnvFile{ Values: make(map[string]string), Path: path, } scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ raw := scanner.Text() trimmed := strings.TrimSpace(raw) line := EnvLine{RawLine: raw} if trimmed == "" || strings.HasPrefix(trimmed, "#") { line.IsComment = true env.Lines = append(env.Lines, line) continue } // Parse KEY=VALUE idx := strings.Index(trimmed, "=") if idx < 0 { // Not a valid assignment, treat as comment line.IsComment = true env.Lines = append(env.Lines, line) continue } key := strings.TrimSpace(trimmed[:idx]) val := strings.TrimSpace(trimmed[idx+1:]) // Strip inline comment (only outside quotes) val = stripInlineComment(val) // Unquote val = unquoteValue(val) line.Key = key line.Value = val env.Values[key] = val env.Lines = append(env.Lines, line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("reading .env file %s: %w", path, err) } return env, nil } // GetVar returns the value for a TF variable by name. // It tries both "TF_VAR_" and "" as keys. func (e *DotEnvFile) GetVar(name string) (string, bool) { if v, ok := e.Values["TF_VAR_"+name]; ok { return v, true } if v, ok := e.Values[name]; ok { return v, true } return "", false } // SetVar sets a TF variable value (stored with TF_VAR_ prefix). func (e *DotEnvFile) SetVar(name, value string) { key := "TF_VAR_" + name e.Values[key] = value // Update existing line or add new for i, l := range e.Lines { if l.Key == key { e.Lines[i].Value = value return } } e.Lines = append(e.Lines, EnvLine{Key: key, Value: value}) } // ToConfig converts parsed .env values into a Config struct, // stripping TF_VAR_ prefixes to get TF variable names. func (e *DotEnvFile) ToConfig() *Config { cfg := &Config{ Variables: make(map[string]string), } for k, v := range e.Values { name := strings.TrimPrefix(k, "TF_VAR_") cfg.Variables[name] = v } return cfg } // stripInlineComment removes inline comments from a value. // Handles both quoted and unquoted values. func stripInlineComment(val string) string { // If value starts with a quote, find the closing quote first if len(val) > 0 && (val[0] == '"' || val[0] == '\'') { quote := val[0] for i := 1; i < len(val); i++ { if val[i] == quote { // Everything after closing quote is inline comment rest := strings.TrimSpace(val[i+1:]) if strings.HasPrefix(rest, "#") { return val[:i+1] } return val } } // Unclosed quote — return as-is return val } // Unquoted: find first # preceded by space for i := 0; i < len(val); i++ { if val[i] == '#' && (i == 0 || val[i-1] == ' ') { return strings.TrimSpace(val[:i]) } } return val } // unquoteValue removes surrounding quotes from a value. func unquoteValue(val string) string { if len(val) >= 2 { if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') { return val[1 : len(val)-1] } } return val } // StripTFVarPrefix removes the TF_VAR_ prefix from an environment variable name. // Returns the name unchanged if it doesn't have the prefix. func StripTFVarPrefix(name string) string { return strings.TrimPrefix(name, "TF_VAR_") }