1use std::collections::HashMap;
12use std::fs;
13use std::path::Path;
14
15use anyhow::{Context, Result};
16
17use crate::crypto;
18use crate::envfile::{self, EnvMap};
19use crate::schema::Schema;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Source {
23 Default,
24 Base,
25 Profile,
26 Local,
27 Secret,
28 Process,
29}
30
31impl Source {
32 pub fn label(&self) -> &'static str {
33 match self {
34 Source::Default => "default",
35 Source::Base => ".env",
36 Source::Profile => "profile",
37 Source::Local => ".env.local",
38 Source::Secret => "secret",
39 Source::Process => "process-env",
40 }
41 }
42}
43
44#[derive(Debug, Default)]
45pub struct Resolved {
46 pub values: EnvMap,
47 pub provenance: HashMap<String, Source>,
48 pub warnings: Vec<String>,
51}
52
53impl Resolved {
54 pub fn source_of(&self, key: &str) -> Option<Source> {
55 self.provenance.get(key).copied()
56 }
57}
58
59pub struct ResolveOptions<'a> {
60 pub dir: &'a Path,
61 pub profile: Option<&'a str>,
62 pub include_process_env: bool,
63 pub decrypt_secrets: bool,
64 pub key_file: Option<&'a Path>,
65}
66
67pub fn resolve(schema: &Schema, opts: &ResolveOptions) -> Result<Resolved> {
69 let mut out = Resolved::default();
70
71 for spec in &schema.vars {
73 if let Some(def) = &spec.default {
74 out.values.set(spec.name.clone(), def.clone());
75 out.provenance.insert(spec.name.clone(), Source::Default);
76 }
77 }
78
79 apply_file(&opts.dir.join(".env"), Source::Base, &mut out)?;
81 if let Some(profile) = opts.profile {
82 apply_file(
83 &opts.dir.join(format!(".env.{profile}")),
84 Source::Profile,
85 &mut out,
86 )?;
87 }
88 apply_file(&opts.dir.join(".env.local"), Source::Local, &mut out)?;
89
90 if opts.decrypt_secrets {
92 let mut secret_bases = vec![".env.secrets".to_string()];
93 if let Some(profile) = opts.profile {
94 secret_bases.push(format!(".env.{profile}.secrets"));
95 }
96 secret_bases.push(".env.local.secrets".to_string());
97 for base in secret_bases {
98 apply_secret_file(opts.dir, &base, opts.key_file, &mut out)?;
99 }
100 }
101
102 if opts.include_process_env {
104 for spec in &schema.vars {
105 if let Ok(val) = std::env::var(&spec.name) {
106 out.values.set(spec.name.clone(), val);
107 out.provenance.insert(spec.name.clone(), Source::Process);
108 }
109 }
110 }
111
112 Ok(out)
113}
114
115fn apply_file(path: &Path, src: Source, out: &mut Resolved) -> Result<bool> {
116 if !path.exists() {
117 return Ok(false);
118 }
119 let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
120 let map = envfile::parse(&text).with_context(|| format!("parsing {}", path.display()))?;
121 merge(&map, src, out);
122 Ok(true)
123}
124
125fn apply_secret_file(
126 dir: &Path,
127 base: &str,
128 key_file: Option<&Path>,
129 out: &mut Resolved,
130) -> Result<()> {
131 let plain = dir.join(base);
132 let enc = dir.join(format!("{base}.enc"));
133
134 if plain.exists() {
135 let text =
136 fs::read_to_string(&plain).with_context(|| format!("reading {}", plain.display()))?;
137 let map = envfile::parse(&text).with_context(|| format!("parsing {}", plain.display()))?;
138 merge(&map, Source::Secret, out);
139 return Ok(());
140 }
141
142 if enc.exists() {
143 let envelope =
144 fs::read_to_string(&enc).with_context(|| format!("reading {}", enc.display()))?;
145 match crypto::resolve_passphrase(dir, key_file) {
146 Ok(pass) => match crypto::decrypt(&envelope, pass.as_bytes()) {
147 Ok(plaintext) => {
148 let text = String::from_utf8_lossy(&plaintext);
149 let map = envfile::parse(&text)
150 .with_context(|| format!("parsing decrypted {}", enc.display()))?;
151 merge(&map, Source::Secret, out);
152 }
153 Err(e) => out
154 .warnings
155 .push(format!("could not decrypt {}: {e}", enc.display())),
156 },
157 Err(e) => out
158 .warnings
159 .push(format!("{} present but {e}", enc.display())),
160 }
161 }
162 Ok(())
163}
164
165fn merge(map: &EnvMap, src: Source, out: &mut Resolved) {
166 for (k, v) in map.iter() {
167 out.values.set(k.to_string(), v.to_string());
168 out.provenance.insert(k.to_string(), src);
169 }
170}