Skip to main content

soothfast_sdk/
envtemplate.rs

1//! Reading a server's dotenv template as its configuration surface.
2//!
3//! A server that documents its knobs in a `.env.template` has already
4//! written the thing an SDK needs: the names, the defaults, and the prose
5//! explaining each one. Parsing that file is what lets an embedded server
6//! be configured *the same way* whether it is deployed or spawned by a
7//! `pip install`ed client — one source, two consumers, no second list to
8//! keep in step.
9//!
10//! The grammar is the dotenv subset every such file already uses:
11//! `KEY=value` lines, `#` comments above an entry as its documentation,
12//! and `# KEY=value` for a knob deliberately left unset.
13
14/// One environment variable the embedded server reads.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct EnvVar {
17    pub name: String,
18    /// Comment lines attached to the entry, in template order.
19    pub doc: Vec<String>,
20    /// The value the template ships. `None` when the entry is commented
21    /// out — the knob exists, the server runs without it.
22    pub default: Option<String>,
23}
24
25impl EnvVar {
26    /// Whether the template leaves this one unset.
27    pub fn optional(&self) -> bool {
28        self.default.is_none()
29    }
30}
31
32/// Parse a dotenv-style template into the knobs it documents.
33///
34/// Anything unparseable is skipped rather than rejected: the file belongs
35/// to the server, and an SDK is not the place a stray line becomes fatal.
36///
37/// Names are unique: a template that shows `# KEY=example` above the real
38/// `KEY=` names one knob, and emitting it twice would not compile.
39pub 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            // A blank line ends a comment block: prose two paragraphs up
47            // is not documentation for what follows.
48            doc.clear();
49            continue;
50        }
51        if let Some(comment) = line.strip_prefix('#') {
52            let comment = comment.trim();
53            // `# KEY=value` is a knob left unset, not prose about one.
54            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
86/// Add a knob, or let a repeated name update the one already recorded —
87/// keeping its position, and any documentation the later line omitted.
88fn 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
102/// Render the knobs as a README table. Same text in both languages: the
103/// variables belong to the server, not to whoever is calling it.
104pub 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
126/// A `|` inside a cell would end it early.
127fn escape_cell(text: &str) -> String {
128    text.replace('|', "\\|")
129}
130
131/// Split `KEY=value` when `KEY` looks like an environment variable —
132/// uppercase, digits and underscores, not starting with a digit.
133fn 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
144/// Peel a trailing `# …` note off an unquoted value, the way every dotenv
145/// reader does. A quoted value keeps whatever is inside the quotes.
146fn 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}