envorigin 1.6.0

Explain where environment variables come from in Docker Compose and GitHub Actions
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use regex::Regex;

use crate::model::{AnalysisError, Diagnostic, Severity};

#[derive(Debug, Clone)]
pub struct DotenvEntry {
    pub key: String,
    pub value: Option<String>,
    pub line: usize,
    pub interpolate: bool,
}

#[derive(Debug, Clone, Default)]
pub struct DotenvFile {
    pub entries: Vec<DotenvEntry>,
    pub diagnostics: Vec<Diagnostic>,
}

pub fn parse_dotenv_file(path: &Path) -> Result<DotenvFile, AnalysisError> {
    let content = fs::read_to_string(path)?;
    parse_dotenv(&content, path)
}

pub fn parse_dotenv(content: &str, path: &Path) -> Result<DotenvFile, AnalysisError> {
    let key_pattern = Regex::new(r"^[A-Za-z_][A-Za-z0-9_.-]*$").expect("valid regex");
    let mut result = DotenvFile::default();

    for (offset, raw_line) in content.lines().enumerate() {
        let line_number = offset + 1;
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
        let (key, raw_value) = match line.split_once('=') {
            Some((key, value)) => (key.trim(), Some(value.trim_start())),
            None => (line.trim(), None),
        };

        if !key_pattern.is_match(key) {
            return Err(AnalysisError::Dotenv {
                path: PathBuf::from(path),
                line: line_number,
                message: format!("invalid variable name '{key}'"),
            });
        }

        let (value, interpolate) = match raw_value {
            None => (None, false),
            Some(raw) => {
                let (parsed, allow_interpolation, trailing) =
                    parse_value(raw).map_err(|message| AnalysisError::Dotenv {
                        path: PathBuf::from(path),
                        line: line_number,
                        message,
                    })?;
                if trailing {
                    result.diagnostics.push(Diagnostic::new(
                        Severity::Warning,
                        "dotenv-trailing-content",
                        format!(
                            "{}:{line_number} has ignored trailing content",
                            path.display()
                        ),
                    ));
                }
                (Some(parsed), allow_interpolation)
            }
        };

        result.entries.push(DotenvEntry {
            key: key.to_string(),
            value,
            line: line_number,
            interpolate,
        });
    }

    Ok(result)
}

fn parse_value(raw: &str) -> Result<(String, bool, bool), String> {
    if let Some(rest) = raw.strip_prefix('\'') {
        let Some(end) = rest.find('\'') else {
            return Err("unterminated single-quoted value".to_string());
        };
        let value = rest[..end].to_string();
        let tail = rest[end + 1..].trim();
        return Ok((value, false, !tail.is_empty() && !tail.starts_with('#')));
    }

    if let Some(rest) = raw.strip_prefix('"') {
        let mut value = String::new();
        let mut escaped = false;
        let mut closing = None;
        for (index, ch) in rest.char_indices() {
            if escaped {
                value.push(match ch {
                    'n' => '\n',
                    'r' => '\r',
                    't' => '\t',
                    '\\' => '\\',
                    '"' => '"',
                    other => other,
                });
                escaped = false;
            } else if ch == '\\' {
                escaped = true;
            } else if ch == '"' {
                closing = Some(index);
                break;
            } else {
                value.push(ch);
            }
        }
        let Some(end) = closing else {
            return Err("unterminated double-quoted value".to_string());
        };
        let tail = rest[end + 1..].trim();
        return Ok((value, true, !tail.is_empty() && !tail.starts_with('#')));
    }

    let mut end = raw.len();
    let chars: Vec<(usize, char)> = raw.char_indices().collect();
    for window in chars.windows(2) {
        if window[0].1.is_whitespace() && window[1].1 == '#' {
            end = window[0].0;
            break;
        }
    }
    Ok((raw[..end].trim_end().to_string(), true, false))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_quotes_comments_and_unsets() {
        let parsed = parse_dotenv(
            "A=one # comment\nB='${A}'\nC=\"line\\n${A}\"\nD\n",
            Path::new("fixture.env"),
        )
        .unwrap();

        assert_eq!(parsed.entries[0].value.as_deref(), Some("one"));
        assert_eq!(parsed.entries[1].value.as_deref(), Some("${A}"));
        assert!(!parsed.entries[1].interpolate);
        assert_eq!(parsed.entries[2].value.as_deref(), Some("line\n${A}"));
        assert!(parsed.entries[2].interpolate);
        assert_eq!(parsed.entries[3].value, None);
    }
}