soothfast_sdk/
envtemplate.rs1#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct EnvVar {
17 pub name: String,
18 pub doc: Vec<String>,
20 pub default: Option<String>,
23}
24
25impl EnvVar {
26 pub fn optional(&self) -> bool {
28 self.default.is_none()
29 }
30}
31
32pub fn parse(text: &str) -> Vec<EnvVar> {
40 let mut vars: Vec<EnvVar> = Vec::new();
41 let mut doc: Vec<String> = Vec::new();
42
43 for raw in text.lines() {
44 let line = raw.trim();
45 if line.is_empty() {
46 doc.clear();
49 continue;
50 }
51 if let Some(comment) = line.strip_prefix('#') {
52 let comment = comment.trim();
53 match assignment(comment) {
55 Some((name, _)) => record(
56 &mut vars,
57 EnvVar {
58 name,
59 doc: std::mem::take(&mut doc),
60 default: None,
61 },
62 ),
63 None if !comment.is_empty() => doc.push(comment.to_string()),
64 None => {}
65 }
66 continue;
67 }
68 if let Some((name, value)) = assignment(line) {
69 let (value, trailing) = split_inline_comment(&value);
70 if let Some(note) = trailing {
71 doc.push(note);
72 }
73 record(
74 &mut vars,
75 EnvVar {
76 name,
77 doc: std::mem::take(&mut doc),
78 default: Some(value),
79 },
80 );
81 }
82 }
83 vars
84}
85
86fn record(vars: &mut Vec<EnvVar>, var: EnvVar) {
89 match vars.iter_mut().find(|v| v.name == var.name) {
90 Some(existing) => {
91 if !var.doc.is_empty() {
92 existing.doc = var.doc;
93 }
94 if var.default.is_some() {
95 existing.default = var.default;
96 }
97 }
98 None => vars.push(var),
99 }
100}
101
102pub fn markdown_table(vars: &[EnvVar]) -> String {
105 use std::fmt::Write;
106
107 let mut out = String::new();
108 let _ = writeln!(out, "| Variable | Default | Notes |");
109 let _ = writeln!(out, "| --- | --- | --- |");
110 for var in vars {
111 let default = match var.default.as_deref() {
112 Some("") | None => "_unset_".to_string(),
113 Some(value) => format!("`{value}`"),
114 };
115 let notes = var
116 .doc
117 .iter()
118 .map(|line| escape_cell(line))
119 .collect::<Vec<_>>()
120 .join(" ");
121 let _ = writeln!(out, "| `{}` | {default} | {notes} |", var.name);
122 }
123 out
124}
125
126fn escape_cell(text: &str) -> String {
128 text.replace('|', "\\|")
129}
130
131fn assignment(line: &str) -> Option<(String, String)> {
134 let (name, value) = line.split_once('=')?;
135 let name = name.trim_start_matches("export ").trim();
136 let ok = !name.is_empty()
137 && !name.starts_with(|c: char| c.is_ascii_digit())
138 && name
139 .chars()
140 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
141 ok.then(|| (name.to_string(), value.trim().to_string()))
142}
143
144fn split_inline_comment(value: &str) -> (String, Option<String>) {
147 if let Some(inner) = unquote(value) {
148 return (inner, None);
149 }
150 match value.split_once('#') {
151 Some((value, note)) => {
152 let note = note.trim();
153 (
154 value.trim().to_string(),
155 (!note.is_empty()).then(|| note.to_string()),
156 )
157 }
158 None => (value.to_string(), None),
159 }
160}
161
162fn unquote(value: &str) -> Option<String> {
163 for quote in ['"', '\''] {
164 if let Some(rest) = value.strip_prefix(quote)
165 && let Some(inner) = rest.strip_suffix(quote)
166 {
167 return Some(inner.to_string());
168 }
169 }
170 None
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 const TEMPLATE: &str = "\
178# Server Configuration
179PORT=8000
180
181# Logging
182# Options: DEBUG, INFO, WARNING, ERROR
183LOG_LEVEL=INFO
184
185# API Defaults
186DEFAULT_INTERVAL=1d # Options: 1m, 5m, 1d
187
188# FRED API Integration (optional)
189# Free API key from https://fred.stlouisfed.org/
190# FRED_API_KEY=your_fred_api_key_here
191";
192
193 #[test]
194 fn comments_above_an_entry_become_its_documentation() {
195 let vars = parse(TEMPLATE);
196 let port = &vars[0];
197 assert_eq!(port.name, "PORT");
198 assert_eq!(port.doc, ["Server Configuration"]);
199 assert_eq!(port.default.as_deref(), Some("8000"));
200
201 let level = &vars[1];
202 assert_eq!(
203 level.doc,
204 ["Logging", "Options: DEBUG, INFO, WARNING, ERROR"]
205 );
206 }
207
208 #[test]
209 fn a_commented_out_entry_is_a_knob_with_no_default() {
210 let vars = parse(TEMPLATE);
211 let fred = vars
212 .iter()
213 .find(|v| v.name == "FRED_API_KEY")
214 .expect("found");
215 assert!(fred.optional(), "commented out means unset by default");
216 assert_eq!(
217 fred.doc,
218 [
219 "FRED API Integration (optional)",
220 "Free API key from https://fred.stlouisfed.org/"
221 ]
222 );
223 }
224
225 #[test]
226 fn an_inline_note_documents_rather_than_corrupts_the_default() {
227 let vars = parse(TEMPLATE);
228 let interval = vars
229 .iter()
230 .find(|v| v.name == "DEFAULT_INTERVAL")
231 .expect("found");
232 assert_eq!(interval.default.as_deref(), Some("1d"));
233 assert_eq!(interval.doc, ["API Defaults", "Options: 1m, 5m, 1d"]);
234 }
235
236 #[test]
237 fn a_blank_line_ends_a_comment_block() {
238 let vars = parse("# a heading\n\nKEY=value\n");
239 assert_eq!(vars[0].doc, Vec::<String>::new());
240 }
241
242 #[test]
243 fn prose_that_merely_contains_an_equals_sign_is_not_an_entry() {
244 let vars = parse("# note: set this when x = y\nKEY=value\n");
245 assert_eq!(vars.len(), 1);
246 assert_eq!(vars[0].name, "KEY");
247 assert_eq!(vars[0].doc, ["note: set this when x = y"]);
248 }
249
250 #[test]
251 fn an_example_above_the_real_entry_names_one_knob() {
252 let vars = parse("# DATABASE_URL=postgres://user@host/db\nDATABASE_URL=\n");
253 assert_eq!(vars.len(), 1);
254 assert_eq!(vars[0].name, "DATABASE_URL");
255 assert_eq!(vars[0].default.as_deref(), Some(""));
256 }
257
258 #[test]
259 fn a_repeated_entry_keeps_its_first_position() {
260 let vars = parse("A=1\nB=2\nA=3\n");
261 assert_eq!(
262 vars.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
263 ["A", "B"]
264 );
265 assert_eq!(vars[0].default.as_deref(), Some("3"));
266 }
267
268 #[test]
269 fn a_quoted_value_keeps_its_hashes() {
270 let vars = parse("KEY=\"a # b\"\n");
271 assert_eq!(vars[0].default.as_deref(), Some("a # b"));
272 }
273}