Skip to main content

ferro_cli/deploy/
env_production.rs

1//! Phase 122.2 §6: key-only `.env.production` parser.
2//!
3//! No value parsing, no comment stripping, no classification. The deploy
4//! scaffolder uses these keys to emit a commented `envs:` block in
5//! `.do/app.yaml`. Values stay on the developer machine.
6
7use std::fs;
8use std::path::Path;
9
10/// Read `.env.production` and return the list of declared keys, in order.
11/// Hard-errors when the file is missing — `do:init` requires it.
12pub fn read_env_production_keys(path: &Path) -> anyhow::Result<Vec<String>> {
13    let content = fs::read_to_string(path)
14        .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
15    let mut keys = Vec::new();
16    for raw in content.lines() {
17        let line = raw.trim();
18        if line.is_empty() || line.starts_with('#') {
19            continue;
20        }
21        if let Some((k, _)) = line.split_once('=') {
22            let key = k.trim();
23            if !key.is_empty() {
24                keys.push(key.to_string());
25            }
26        }
27    }
28    Ok(keys)
29}
30
31/// Structured line from `.env.example` used by `do:init` envs-block rendering
32/// (D-09). Preserves key order and blank-line separators from the source file.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum EnvLine {
35    Key(String),
36    Blank,
37    Comment,
38}
39
40/// Parse `.env.example` preserving key order and blank-line separators.
41///
42/// Blank lines in the source become a `Blank` variant so the resulting
43/// `envs:` block in `.do/app.yaml` keeps the human grouping. Comment lines
44/// become `Comment` (consumers may drop them). Keys are trimmed.
45pub fn parse_env_example_structured(contents: &str) -> Vec<EnvLine> {
46    let mut out = Vec::new();
47    for raw in contents.lines() {
48        let line = raw.trim_end();
49        if line.trim().is_empty() {
50            out.push(EnvLine::Blank);
51            continue;
52        }
53        let trimmed = line.trim_start();
54        if trimmed.starts_with('#') {
55            out.push(EnvLine::Comment);
56            continue;
57        }
58        if let Some(eq) = trimmed.find('=') {
59            let key = trimmed[..eq].trim().to_string();
60            if !key.is_empty() {
61                out.push(EnvLine::Key(key));
62            }
63        }
64    }
65    out
66}
67
68#[cfg(test)]
69mod structured_tests {
70    use super::*;
71
72    #[test]
73    fn env_example_parser_preserves_order() {
74        let input = "Z=1\nA=2\nM=3\n";
75        let out = parse_env_example_structured(input);
76        let keys: Vec<_> = out
77            .iter()
78            .filter_map(|l| match l {
79                EnvLine::Key(k) => Some(k.as_str()),
80                _ => None,
81            })
82            .collect();
83        assert_eq!(keys, vec!["Z", "A", "M"]);
84    }
85
86    #[test]
87    fn env_example_parser_preserves_blank_separators() {
88        let input = "A=1\n\nB=2\n";
89        let out = parse_env_example_structured(input);
90        assert_eq!(
91            out,
92            vec![
93                EnvLine::Key("A".into()),
94                EnvLine::Blank,
95                EnvLine::Key("B".into())
96            ]
97        );
98    }
99
100    #[test]
101    fn env_example_parser_skips_comments() {
102        let input = "# header\nA=1\n";
103        let out = parse_env_example_structured(input);
104        assert!(out.iter().any(|l| matches!(l, EnvLine::Key(k) if k == "A")));
105    }
106
107    #[test]
108    fn env_example_parser_trims_keys() {
109        let input = "  KEY  =val\n";
110        let out = parse_env_example_structured(input);
111        assert!(out
112            .iter()
113            .any(|l| matches!(l, EnvLine::Key(k) if k == "KEY")));
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::fs;
121    use tempfile::TempDir;
122
123    fn write_env(content: &str) -> (TempDir, std::path::PathBuf) {
124        let tmp = TempDir::new().unwrap();
125        let path = tmp.path().join(".env.production");
126        fs::write(&path, content).unwrap();
127        (tmp, path)
128    }
129
130    #[test]
131    fn extracts_keys_in_order() {
132        let (_tmp, path) = write_env("KEY=value\nOTHER=x\n");
133        assert_eq!(
134            read_env_production_keys(&path).unwrap(),
135            vec!["KEY", "OTHER"]
136        );
137    }
138
139    #[test]
140    fn skips_blank_and_comment_lines() {
141        let (_tmp, path) = write_env("\n# a comment\n  # indented comment\nA=1\n\nB=2\n");
142        assert_eq!(read_env_production_keys(&path).unwrap(), vec!["A", "B"]);
143    }
144
145    #[test]
146    fn trims_whitespace_around_keys() {
147        let (_tmp, path) = write_env("  KEY = value  \n");
148        assert_eq!(read_env_production_keys(&path).unwrap(), vec!["KEY"]);
149    }
150
151    #[test]
152    fn skips_lines_without_equals() {
153        let (_tmp, path) = write_env("not-a-kv-line\nA=1\n");
154        assert_eq!(read_env_production_keys(&path).unwrap(), vec!["A"]);
155    }
156
157    #[test]
158    fn missing_file_errors() {
159        let tmp = TempDir::new().unwrap();
160        let missing = tmp.path().join(".env.production");
161        assert!(read_env_production_keys(&missing).is_err());
162    }
163}