Skip to main content

teaql_tool_core/
env_config.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::audit::{AuditConfig, AuditLevel, Module};
4
5// ─── Whitelist ────────────────────────────────────────────────
6
7/// The complete, fixed set of allowed TEAQL_ environment variable names.
8/// Any TEAQL_ prefixed env var not in this list causes immediate process exit.
9const ALLOWED_ENV_VARS: &[&str] = &[
10    "TEAQL_AUDIT",
11    "TEAQL_SQL",
12    "TEAQL_SQL_TABLES",
13    "TEAQL_TOOL",
14    "TEAQL_TOOL_FOCUS",
15    "TEAQL_SINK",
16    "TEAQL_SCHEMA",
17];
18
19/// Allowed values for level-type env vars. Prefixed with underscore
20/// to avoid collision with user-defined entity/table names.
21const ALLOWED_LEVELS: &[&str] = &["_silent", "_summary", "_full"];
22
23/// Allowed values for TEAQL_SINK.
24const ALLOWED_SINKS: &[&str] = &["_stdout", "_file", "_both"];
25
26/// Allowed values for TEAQL_SCHEMA.
27const ALLOWED_SCHEMA_MODES: &[&str] = &["_verify", "_dryrun", "_execute"];
28
29/// All valid module names for TEAQL_TOOL_FOCUS.
30const ALLOWED_MODULES: &[(&str, Module)] = &[
31    ("http", Module::Http),
32    ("file", Module::File),
33    ("cmd", Module::Cmd),
34    ("email", Module::Email),
35    ("kv", Module::Kv),
36    ("crypto", Module::Crypto),
37    ("jwt", Module::Jwt),
38    ("time", Module::Time),
39    ("id", Module::Id),
40    ("text", Module::Text),
41    ("decimal", Module::Decimal),
42    ("money", Module::Money),
43    ("json", Module::Json),
44    ("regex", Module::Regex),
45    ("codec", Module::Codec),
46    ("list", Module::List),
47    ("map", Module::Map),
48    ("diff", Module::Diff),
49    ("url", Module::Url),
50    ("validate", Module::Validate),
51    ("color", Module::Color),
52    ("unit", Module::Unit),
53    ("daterange", Module::DateRange),
54    ("desensitize", Module::Desensitize),
55    ("filter", Module::Filter),
56    ("tree", Module::Tree),
57    ("system", Module::System),
58];
59
60// ─── Output sink ──────────────────────────────────────────────
61
62/// Where audit output is written. Controlled by TEAQL_SINK.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum AuditSink {
65    Stdout,
66    File,
67    Both,
68}
69
70impl Default for AuditSink {
71    fn default() -> Self {
72        AuditSink::Both
73    }
74}
75
76/// Controls schema migration behavior at startup. Controlled by TEAQL_SCHEMA.
77/// Default is `Verify` — the safest option for production.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SchemaMode {
80    /// Check that the database schema matches the model.
81    /// If there is any mismatch, print the details and exit immediately.
82    /// This is the DEFAULT — no env var needed for the safest behavior.
83    Verify,
84
85    /// Print the SQL statements that would be executed to bring the
86    /// schema in sync, but do NOT execute them. For DBA review.
87    DryRun,
88
89    /// Actually execute schema changes (CREATE TABLE, ALTER TABLE ADD COLUMN,
90    /// CREATE INDEX, seed data). Use in development and CI only.
91    Execute,
92}
93
94impl Default for SchemaMode {
95    fn default() -> Self {
96        SchemaMode::Verify
97    }
98}
99
100// ─── Env config result ────────────────────────────────────────
101
102/// The fully resolved configuration parsed from environment variables.
103/// Constructed by `AuditConfig::from_env()` or `AuditConfig::from_env_with_tables()`.
104#[derive(Debug, Clone)]
105pub struct EnvAuditConfig {
106    /// Core audit config with module levels.
107    pub config: AuditConfig,
108    /// Global entity audit level (from TEAQL_AUDIT).
109    pub entity_level: AuditLevel,
110    /// Global SQL log level (from TEAQL_SQL).
111    pub sql_level: AuditLevel,
112    /// SQL table include filter (from TEAQL_SQL_TABLES). None = all tables.
113    pub sql_tables: Option<HashSet<String>>,
114    /// Output sink (from TEAQL_SINK).
115    pub sink: AuditSink,
116    /// Schema migration mode (from TEAQL_SCHEMA). Default: Verify.
117    pub schema_mode: SchemaMode,
118}
119
120impl EnvAuditConfig {
121    /// Check if SQL logging is active for a given table name.
122    pub fn sql_active_for(&self, table: &str) -> bool {
123        if self.sql_level == AuditLevel::Silent {
124            return false;
125        }
126        match &self.sql_tables {
127            Some(tables) => tables.contains(table),
128            None => true, // No filter = all tables
129        }
130    }
131}
132
133// ─── Parsing ──────────────────────────────────────────────────
134
135fn parse_level(value: &str, var_name: &str) -> AuditLevel {
136    match value {
137        "_silent" => AuditLevel::Silent,
138        "_summary" => AuditLevel::Summary,
139        "_full" => AuditLevel::Full,
140        other => {
141            eprintln!(
142                "\nFATAL: Invalid value \"{}\" for environment variable \"{}\"\n\
143                 Allowed values: {}\n\n\
144                 Application refused to start.\n",
145                other,
146                var_name,
147                ALLOWED_LEVELS.join(", "),
148            );
149            std::process::exit(1);
150        }
151    }
152}
153
154fn parse_sink(value: &str) -> AuditSink {
155    match value {
156        "_stdout" => AuditSink::Stdout,
157        "_file" => AuditSink::File,
158        "_both" => AuditSink::Both,
159        other => {
160            eprintln!(
161                "\nFATAL: Invalid value \"{}\" for environment variable \"TEAQL_SINK\"\n\
162                 Allowed values: {}\n\n\
163                 Application refused to start.\n",
164                other,
165                ALLOWED_SINKS.join(", "),
166            );
167            std::process::exit(1);
168        }
169    }
170}
171
172fn parse_schema_mode(value: &str) -> SchemaMode {
173    match value {
174        "_verify" => SchemaMode::Verify,
175        "_dryrun" => SchemaMode::DryRun,
176        "_execute" => SchemaMode::Execute,
177        other => {
178            eprintln!(
179                "\nFATAL: Invalid value \"{}\" for environment variable \"TEAQL_SCHEMA\"\n\
180                 Allowed values: {}\n\n\
181                 Application refused to start.\n",
182                other,
183                ALLOWED_SCHEMA_MODES.join(", "),
184            );
185            std::process::exit(1);
186        }
187    }
188}
189
190fn parse_module_list(value: &str) -> Vec<Module> {
191    let module_map: HashMap<&str, Module> = ALLOWED_MODULES.iter().copied().collect();
192    let available: Vec<&str> = ALLOWED_MODULES.iter().map(|(name, _)| *name).collect();
193
194    value
195        .split(',')
196        .map(str::trim)
197        .filter(|s| !s.is_empty())
198        .map(|name| {
199            *module_map.get(name).unwrap_or_else(|| {
200                eprintln!(
201                    "\nFATAL: Unknown module \"{}\" in TEAQL_TOOL_FOCUS\n\
202                     Available modules: {}\n\n\
203                     Application refused to start.\n",
204                    name,
205                    available.join(", "),
206                );
207                std::process::exit(1);
208            })
209        })
210        .collect()
211}
212
213fn parse_table_list(value: &str, known_tables: &[&str]) -> HashSet<String> {
214    let known_set: HashSet<&str> = known_tables.iter().copied().collect();
215    value
216        .split(',')
217        .map(str::trim)
218        .filter(|s| !s.is_empty())
219        .map(|name| {
220            if !known_set.contains(name) {
221                eprintln!(
222                    "\nFATAL: Unknown table \"{}\" in TEAQL_SQL_TABLES\n\
223                     Available tables: {}\n\n\
224                     Application refused to start.\n",
225                    name,
226                    known_tables.join(", "),
227                );
228                std::process::exit(1);
229            }
230            name.to_string()
231        })
232        .collect()
233}
234
235// ─── Whitelist enforcement ────────────────────────────────────
236
237/// The set of TEAQL_ prefixes that belong to the audit config namespace.
238/// Any env var starting with one of these prefixes must exactly match
239/// an entry in ALLOWED_ENV_VARS, or the process exits.
240const AUDIT_PREFIXES: &[&str] = &[
241    "TEAQL_AUDIT",
242    "TEAQL_SQL",
243    "TEAQL_TOOL",
244    "TEAQL_SINK",
245    "TEAQL_SCHEMA",
246];
247
248/// Scan environment variables in the audit namespace.
249/// If any matches an audit prefix but is not in the exact whitelist,
250/// print an error and exit immediately.
251/// Non-audit TEAQL_ vars (e.g. TEAQL_ENDPOINT_PREFIX) are ignored.
252fn enforce_env_whitelist() {
253    let allowed: HashSet<&str> = ALLOWED_ENV_VARS.iter().copied().collect();
254
255    for (key, _) in std::env::vars() {
256        let in_audit_namespace = AUDIT_PREFIXES.iter().any(|prefix| key.starts_with(prefix));
257        if in_audit_namespace && !allowed.contains(key.as_str()) {
258            // Try to suggest a close match
259            let suggestion = ALLOWED_ENV_VARS
260                .iter()
261                .min_by_key(|v| levenshtein(&key, v))
262                .unwrap();
263
264            eprintln!(
265                "\nFATAL: Unknown environment variable \"{}\"\n\
266                 Did you mean \"{}\"?\n\n\
267                 Allowed TEAQL audit variables:\n  {}\n\n\
268                 Application refused to start.\n",
269                key,
270                suggestion,
271                ALLOWED_ENV_VARS.join(", "),
272            );
273            std::process::exit(1);
274        }
275    }
276}
277
278/// Simple Levenshtein distance for typo suggestion.
279fn levenshtein(a: &str, b: &str) -> usize {
280    let a: Vec<char> = a.chars().collect();
281    let b: Vec<char> = b.chars().collect();
282    let (m, n) = (a.len(), b.len());
283    let mut dp = vec![vec![0usize; n + 1]; m + 1];
284    for i in 0..=m {
285        dp[i][0] = i;
286    }
287    for j in 0..=n {
288        dp[0][j] = j;
289    }
290    for i in 1..=m {
291        for j in 1..=n {
292            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
293            dp[i][j] = (dp[i - 1][j] + 1)
294                .min(dp[i][j - 1] + 1)
295                .min(dp[i - 1][j - 1] + cost);
296        }
297    }
298    dp[m][n]
299}
300
301// ─── Public API ───────────────────────────────────────────────
302
303/// Build an `EnvAuditConfig` from environment variables.
304///
305/// `known_tables` is the list of valid table names for this project,
306/// typically provided by the generated code. Used to validate
307/// TEAQL_SQL_TABLES values.
308///
309/// This function:
310/// 1. Scans ALL env vars — panics if any unknown TEAQL_ var is found.
311/// 2. Parses the 6 allowed env vars into a typed config.
312/// 3. Validates all values against their respective whitelists.
313///
314/// If no TEAQL_ env vars are set, returns production defaults.
315pub fn audit_config_from_env(known_tables: &[&str]) -> EnvAuditConfig {
316    // Step 1: Reject unknown TEAQL_ env vars
317    enforce_env_whitelist();
318
319    // Step 2: Parse each env var (use defaults if not set)
320    let entity_level = std::env::var("TEAQL_AUDIT")
321        .map(|v| parse_level(&v, "TEAQL_AUDIT"))
322        .unwrap_or(AuditLevel::Full);
323
324    let sql_level = std::env::var("TEAQL_SQL")
325        .map(|v| parse_level(&v, "TEAQL_SQL"))
326        .unwrap_or(AuditLevel::Silent);
327
328    let sql_tables: Option<HashSet<String>> = std::env::var("TEAQL_SQL_TABLES")
329        .ok()
330        .map(|v| parse_table_list(&v, known_tables));
331
332    let tool_level = std::env::var("TEAQL_TOOL")
333        .map(|v| parse_level(&v, "TEAQL_TOOL"))
334        .unwrap_or(AuditLevel::Silent);
335
336    let tool_focus: Option<Vec<Module>> = std::env::var("TEAQL_TOOL_FOCUS")
337        .ok()
338        .map(|v| parse_module_list(&v));
339
340    let sink = std::env::var("TEAQL_SINK")
341        .map(|v| parse_sink(&v))
342        .unwrap_or(AuditSink::Both);
343
344    let schema_mode = std::env::var("TEAQL_SCHEMA")
345        .map(|v| parse_schema_mode(&v))
346        .unwrap_or(SchemaMode::Verify);
347
348    // Step 3: Build the AuditConfig
349    let config = match &tool_focus {
350        Some(focused) => {
351            // Focus mode: listed modules get _full, rest get tool_level
352            let mut cfg = AuditConfig::new(tool_level, tool_level);
353            for m in focused {
354                cfg = cfg.enable(*m, AuditLevel::Full);
355            }
356            cfg
357        }
358        None => {
359            // No focus: all modules use tool_level
360            AuditConfig::new(tool_level, tool_level)
361        }
362    };
363
364    EnvAuditConfig {
365        config,
366        entity_level,
367        sql_level,
368        sql_tables,
369        sink,
370        schema_mode,
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_levenshtein() {
380        assert_eq!(levenshtein("TEAQL_SQL", "TEAQL_SQL"), 0);
381        assert_eq!(levenshtein("TEAQL_SQLL", "TEAQL_SQL"), 1);
382        assert_eq!(levenshtein("TEAQL_AUDIT", "TEAQL_SQL"), 5);
383    }
384
385    #[test]
386    fn test_parse_level() {
387        assert_eq!(parse_level("_silent", "TEST"), AuditLevel::Silent);
388        assert_eq!(parse_level("_summary", "TEST"), AuditLevel::Summary);
389        assert_eq!(parse_level("_full", "TEST"), AuditLevel::Full);
390    }
391
392    #[test]
393    fn test_parse_sink() {
394        assert_eq!(parse_sink("_stdout"), AuditSink::Stdout);
395        assert_eq!(parse_sink("_file"), AuditSink::File);
396        assert_eq!(parse_sink("_both"), AuditSink::Both);
397    }
398
399    #[test]
400    fn test_parse_schema_mode() {
401        assert_eq!(parse_schema_mode("_verify"), SchemaMode::Verify);
402        assert_eq!(parse_schema_mode("_dryrun"), SchemaMode::DryRun);
403        assert_eq!(parse_schema_mode("_execute"), SchemaMode::Execute);
404        assert_eq!(SchemaMode::default(), SchemaMode::Verify);
405    }
406
407    #[test]
408    fn test_parse_module_list() {
409        let modules = parse_module_list("http,money,crypto");
410        assert_eq!(modules.len(), 3);
411        assert_eq!(modules[0], Module::Http);
412        assert_eq!(modules[1], Module::Money);
413        assert_eq!(modules[2], Module::Crypto);
414    }
415
416    #[test]
417    fn test_parse_table_list() {
418        let tables = parse_table_list("task,task_status", &["task", "task_status", "task_execution_log"]);
419        assert_eq!(tables.len(), 2);
420        assert!(tables.contains("task"));
421        assert!(tables.contains("task_status"));
422    }
423
424    #[test]
425    fn test_sql_active_for() {
426        let cfg = EnvAuditConfig {
427            config: AuditConfig::production(),
428            entity_level: AuditLevel::Full,
429            sql_level: AuditLevel::Full,
430            sql_tables: Some(["task".to_string()].into_iter().collect()),
431            sink: AuditSink::Both,
432            schema_mode: SchemaMode::Verify,
433        };
434        assert!(cfg.sql_active_for("task"));
435        assert!(!cfg.sql_active_for("task_status"));
436
437        // No filter = all tables active
438        let cfg_all = EnvAuditConfig {
439            config: AuditConfig::production(),
440            entity_level: AuditLevel::Full,
441            sql_level: AuditLevel::Full,
442            sql_tables: None,
443            sink: AuditSink::Both,
444            schema_mode: SchemaMode::Verify,
445        };
446        assert!(cfg_all.sql_active_for("task"));
447        assert!(cfg_all.sql_active_for("anything"));
448
449        // Silent level = nothing active
450        let cfg_silent = EnvAuditConfig {
451            config: AuditConfig::production(),
452            entity_level: AuditLevel::Full,
453            sql_level: AuditLevel::Silent,
454            sql_tables: None,
455            sink: AuditSink::Both,
456            schema_mode: SchemaMode::Verify,
457        };
458        assert!(!cfg_silent.sql_active_for("task"));
459    }
460}