Skip to main content

aver/
config.rs

1/// Project configuration from `aver.toml`.
2///
3/// Currently supports runtime effect policies:
4///   [effects.Http]   hosts = ["api.example.com", "*.internal.corp"]
5///   [effects.Disk]   paths = ["./data/**"]
6///   [effects.Env]    keys  = ["APP_*", "TOKEN"]
7///
8/// And check-time warning suppression:
9///   [[check.suppress]]
10///   slug   = "non-tail-recursion"
11///   files  = ["self_hosted/**"]
12///   reason = "Tree walkers are structural recursive"
13use std::collections::HashMap;
14use std::path::Path;
15
16/// Runtime policy for a single effect namespace.
17#[derive(Debug, Clone)]
18pub struct EffectPolicy {
19    /// Allowed HTTP hosts (exact or wildcard `*.domain`).
20    pub hosts: Vec<String>,
21    /// Allowed filesystem paths (exact or recursive `/**`).
22    pub paths: Vec<String>,
23    /// Allowed environment variable keys (exact or wildcard `PREFIX_*`).
24    pub keys: Vec<String>,
25}
26
27/// A single check-warning suppression rule.
28#[derive(Debug, Clone)]
29pub struct CheckSuppression {
30    /// Diagnostic slug to suppress (e.g. `"non-tail-recursion"`).
31    pub slug: String,
32    /// Optional file glob patterns.  Empty = suppress globally.
33    pub files: Vec<String>,
34    /// Mandatory explanation — why the warning is acceptable.
35    pub reason: String,
36}
37
38/// How independent products (`!`/`?!`) are scheduled and how failures
39/// propagate.
40///
41/// Per `docs/independence.md`: sequential and concurrent evaluation are
42/// both valid under the language semantics. Pick the schedule that fits
43/// the environment — threads for native CLI, single-thread for
44/// wasm/playground.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum IndependenceMode {
47    /// Parallel execution. Wait for all branches to finish, then report
48    /// one error.
49    #[default]
50    Complete,
51    /// Parallel execution. Signal siblings to stop as soon as one branch
52    /// fails.
53    Cancel,
54    /// Sequential left-to-right execution. No threads. Valid per the
55    /// language spec (any interleave is permitted, including the trivial
56    /// fully-sequential one). Used by wasm builds that cannot spawn
57    /// threads; also selectable for deterministic replay.
58    Sequential,
59}
60
61/// Project-level configuration loaded from `aver.toml`.
62#[derive(Debug, Clone)]
63pub struct ProjectConfig {
64    /// Effect namespace → policy.  Absence of a key means "allow all".
65    pub effect_policies: HashMap<String, EffectPolicy>,
66    /// Check-time warning suppressions.
67    pub check_suppressions: Vec<CheckSuppression>,
68    /// How `?!` products handle branch failure.
69    pub independence_mode: IndependenceMode,
70}
71
72impl ProjectConfig {
73    /// Try to load `aver.toml` from the given directory.
74    /// Returns `Ok(None)` if the file does not exist.
75    /// Returns `Err` if the file exists but is malformed (parse errors, bad types).
76    pub fn load_from_dir(dir: &Path) -> Result<Option<Self>, String> {
77        let path = dir.join("aver.toml");
78        let content = match std::fs::read_to_string(&path) {
79            Ok(c) => c,
80            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
81            Err(e) => return Err(format!("Failed to read {}: {}", path.display(), e)),
82        };
83        Self::parse(&content).map(Some)
84    }
85
86    /// Parse the TOML content into a ProjectConfig.
87    pub fn parse(content: &str) -> Result<Self, String> {
88        let table: toml::Table = content
89            .parse()
90            .map_err(|e: toml::de::Error| format!("aver.toml parse error: {}", e))?;
91
92        let mut effect_policies = HashMap::new();
93
94        if let Some(toml::Value::Table(effects_table)) = table.get("effects") {
95            for (name, value) in effects_table {
96                let section = value
97                    .as_table()
98                    .ok_or_else(|| format!("aver.toml: [effects.{}] must be a table", name))?;
99
100                let hosts = if let Some(val) = section.get("hosts") {
101                    let arr = val.as_array().ok_or_else(|| {
102                        format!("aver.toml: [effects.{}].hosts must be an array", name)
103                    })?;
104                    arr.iter()
105                        .enumerate()
106                        .map(|(i, v)| {
107                            v.as_str().map(|s| s.to_string()).ok_or_else(|| {
108                                format!(
109                                    "aver.toml: [effects.{}].hosts[{}] must be a string",
110                                    name, i
111                                )
112                            })
113                        })
114                        .collect::<Result<Vec<_>, _>>()?
115                } else {
116                    Vec::new()
117                };
118
119                let paths = if let Some(val) = section.get("paths") {
120                    let arr = val.as_array().ok_or_else(|| {
121                        format!("aver.toml: [effects.{}].paths must be an array", name)
122                    })?;
123                    arr.iter()
124                        .enumerate()
125                        .map(|(i, v)| {
126                            v.as_str().map(|s| s.to_string()).ok_or_else(|| {
127                                format!(
128                                    "aver.toml: [effects.{}].paths[{}] must be a string",
129                                    name, i
130                                )
131                            })
132                        })
133                        .collect::<Result<Vec<_>, _>>()?
134                } else {
135                    Vec::new()
136                };
137
138                let keys = if let Some(val) = section.get("keys") {
139                    let arr = val.as_array().ok_or_else(|| {
140                        format!("aver.toml: [effects.{}].keys must be an array", name)
141                    })?;
142                    arr.iter()
143                        .enumerate()
144                        .map(|(i, v)| {
145                            v.as_str().map(|s| s.to_string()).ok_or_else(|| {
146                                format!(
147                                    "aver.toml: [effects.{}].keys[{}] must be a string",
148                                    name, i
149                                )
150                            })
151                        })
152                        .collect::<Result<Vec<_>, _>>()?
153                } else {
154                    Vec::new()
155                };
156
157                effect_policies.insert(name.clone(), EffectPolicy { hosts, paths, keys });
158            }
159        }
160
161        let check_suppressions = parse_check_suppressions(&table)?;
162        let independence_mode = parse_independence_mode(&table)?;
163
164        Ok(ProjectConfig {
165            effect_policies,
166            check_suppressions,
167            independence_mode,
168        })
169    }
170
171    /// Returns `true` if a diagnostic with the given `slug` at `file_path`
172    /// is suppressed by any `[[check.suppress]]` rule.
173    pub fn is_check_suppressed(&self, slug: &str, file_path: &str) -> bool {
174        self.check_suppressions.iter().any(|s| {
175            s.slug == slug
176                && (s.files.is_empty() || s.files.iter().any(|g| glob_matches(file_path, g)))
177        })
178    }
179
180    /// Check whether an HTTP call to `url_str` is allowed by the policy.
181    /// Returns Ok(()) if allowed, Err(message) if denied.
182    pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
183        // Find the most specific matching policy: first try "Http.get", then "Http"
184        let namespace = method_name.split('.').next().unwrap_or(method_name);
185        let policy = self
186            .effect_policies
187            .get(method_name)
188            .or_else(|| self.effect_policies.get(namespace));
189
190        let Some(policy) = policy else {
191            return Ok(()); // No policy = allow all
192        };
193
194        if policy.hosts.is_empty() {
195            return Ok(()); // Empty hosts list = allow all
196        }
197
198        let parsed = url::Url::parse(url_str).map_err(|e| {
199            format!(
200                "{} denied by aver.toml: invalid URL '{}': {}",
201                method_name, url_str, e
202            )
203        })?;
204
205        let host = parsed.host_str().unwrap_or("");
206
207        for allowed in &policy.hosts {
208            if host_matches(host, allowed) {
209                return Ok(());
210            }
211        }
212
213        Err(format!(
214            "{} to '{}' denied by aver.toml policy (host '{}' not in allowed list)",
215            method_name, url_str, host
216        ))
217    }
218
219    /// Check whether a Disk operation on `path_str` is allowed by the policy.
220    /// Returns Ok(()) if allowed, Err(message) if denied.
221    pub fn check_disk_path(&self, method_name: &str, path_str: &str) -> Result<(), String> {
222        let namespace = method_name.split('.').next().unwrap_or(method_name);
223        let policy = self
224            .effect_policies
225            .get(method_name)
226            .or_else(|| self.effect_policies.get(namespace));
227
228        let Some(policy) = policy else {
229            return Ok(());
230        };
231
232        if policy.paths.is_empty() {
233            return Ok(());
234        }
235
236        // Normalize the path to prevent ../ traversal
237        let normalized = normalize_path(path_str);
238
239        for allowed in &policy.paths {
240            if path_matches(&normalized, allowed) {
241                return Ok(());
242            }
243        }
244
245        Err(format!(
246            "{} on '{}' denied by aver.toml policy (path not in allowed list)",
247            method_name, path_str
248        ))
249    }
250
251    /// Check whether an Env operation on `key` is allowed by the policy.
252    /// Returns Ok(()) if allowed, Err(message) if denied.
253    pub fn check_env_key(&self, method_name: &str, key: &str) -> Result<(), String> {
254        let namespace = method_name.split('.').next().unwrap_or(method_name);
255        let policy = self
256            .effect_policies
257            .get(method_name)
258            .or_else(|| self.effect_policies.get(namespace));
259
260        let Some(policy) = policy else {
261            return Ok(());
262        };
263
264        if policy.keys.is_empty() {
265            return Ok(());
266        }
267
268        for allowed in &policy.keys {
269            if env_key_matches(key, allowed) {
270                return Ok(());
271            }
272        }
273
274        Err(format!(
275            "{} on '{}' denied by aver.toml policy (key not in allowed list)",
276            method_name, key
277        ))
278    }
279}
280
281/// Check if a hostname matches an allowed pattern.
282/// Supports exact match and wildcard prefix `*.domain`.
283fn host_matches(host: &str, pattern: &str) -> bool {
284    if pattern == host {
285        return true;
286    }
287    if let Some(suffix) = pattern.strip_prefix("*.") {
288        // *.example.com matches sub.example.com but not example.com itself
289        host.ends_with(suffix)
290            && host.len() > suffix.len()
291            && host.as_bytes()[host.len() - suffix.len() - 1] == b'.'
292    } else {
293        false
294    }
295}
296
297/// Normalize a filesystem path for matching.
298/// Resolves `.` and `..` components without touching the filesystem.
299/// Leading `..` components are preserved (not silently dropped) so that
300/// `../../etc/passwd` does NOT normalize to `etc/passwd`.
301fn normalize_path(path: &str) -> String {
302    let path = Path::new(path);
303    let mut components: Vec<String> = Vec::new();
304    let mut is_absolute = false;
305
306    for comp in path.components() {
307        match comp {
308            std::path::Component::RootDir => {
309                is_absolute = true;
310                components.clear();
311            }
312            std::path::Component::CurDir => {} // skip .
313            std::path::Component::ParentDir => {
314                // Only pop if the last component is a normal segment (not "..")
315                if components.last().is_some_and(|c| c != "..") {
316                    components.pop();
317                } else if !is_absolute {
318                    // Preserve leading ".." for relative paths — never silently drop them
319                    components.push("..".to_string());
320                }
321                // For absolute paths, extra ".." at root is a no-op (stays at /)
322            }
323            std::path::Component::Normal(s) => {
324                components.push(s.to_string_lossy().to_string());
325            }
326            std::path::Component::Prefix(p) => {
327                components.push(p.as_os_str().to_string_lossy().to_string());
328            }
329        }
330    }
331
332    let joined = components.join("/");
333    if is_absolute {
334        format!("/{}", joined)
335    } else {
336        joined
337    }
338}
339
340/// Check if a normalized path matches an allowed pattern.
341/// Supports:
342///   - Exact prefix match: "./data" matches "./data" and "./data/file.txt"
343///   - Recursive glob: "./data/**" matches everything under ./data/
344fn path_matches(normalized: &str, pattern: &str) -> bool {
345    let clean_pattern = if let Some(base) = pattern.strip_suffix("/**") {
346        normalize_path(base)
347    } else {
348        normalize_path(pattern)
349    };
350
351    // The path must start with the allowed base
352    if normalized == clean_pattern {
353        return true;
354    }
355
356    // Check if it's under the allowed directory
357    if normalized.starts_with(&clean_pattern) {
358        let rest = &normalized[clean_pattern.len()..];
359        if rest.starts_with('/') {
360            return true;
361        }
362    }
363
364    false
365}
366
367/// Check if an env key matches an allowed pattern.
368/// Supports exact match and suffix wildcard `PREFIX_*`.
369fn env_key_matches(key: &str, pattern: &str) -> bool {
370    if pattern == key {
371        return true;
372    }
373    if let Some(prefix) = pattern.strip_suffix('*') {
374        key.starts_with(prefix)
375    } else {
376        false
377    }
378}
379
380/// Parse `[independence]` section from the top-level TOML table.
381fn parse_independence_mode(table: &toml::Table) -> Result<IndependenceMode, String> {
382    let section = match table.get("independence") {
383        Some(toml::Value::Table(t)) => t,
384        Some(_) => return Err("[independence] must be a table".to_string()),
385        None => return Ok(IndependenceMode::default()),
386    };
387    match section.get("mode") {
388        Some(toml::Value::String(s)) => match s.as_str() {
389            "complete" => Ok(IndependenceMode::Complete),
390            "cancel" => Ok(IndependenceMode::Cancel),
391            "sequential" => Ok(IndependenceMode::Sequential),
392            other => Err(format!(
393                "[independence] mode must be \"complete\", \"cancel\", or \"sequential\", got {:?}",
394                other
395            )),
396        },
397        Some(_) => Err("[independence] mode must be a string".to_string()),
398        None => Ok(IndependenceMode::default()),
399    }
400}
401
402/// Parse `[[check.suppress]]` entries from the top-level TOML table.
403fn parse_check_suppressions(table: &toml::Table) -> Result<Vec<CheckSuppression>, String> {
404    let check_table = match table.get("check") {
405        Some(toml::Value::Table(t)) => t,
406        Some(_) => return Err("aver.toml: [check] must be a table".to_string()),
407        None => return Ok(Vec::new()),
408    };
409
410    let arr = match check_table.get("suppress") {
411        Some(toml::Value::Array(a)) => a,
412        Some(_) => {
413            return Err("aver.toml: [[check.suppress]] must be an array of tables".to_string());
414        }
415        None => return Ok(Vec::new()),
416    };
417
418    let mut suppressions = Vec::new();
419    for (i, entry) in arr.iter().enumerate() {
420        let t = entry
421            .as_table()
422            .ok_or_else(|| format!("aver.toml: [[check.suppress]][{}] must be a table", i))?;
423
424        let slug = t
425            .get("slug")
426            .and_then(|v| v.as_str())
427            .ok_or_else(|| {
428                format!(
429                    "aver.toml: [[check.suppress]][{}] requires a string `slug`",
430                    i
431                )
432            })?
433            .to_string();
434
435        let reason = t
436            .get("reason")
437            .and_then(|v| v.as_str())
438            .ok_or_else(|| {
439                format!(
440                    "aver.toml: [[check.suppress]][{}] requires a string `reason` — explain why this warning is acceptable",
441                    i
442                )
443            })?
444            .to_string();
445
446        if reason.trim().is_empty() {
447            return Err(format!(
448                "aver.toml: [[check.suppress]][{}] `reason` must not be empty",
449                i
450            ));
451        }
452
453        let files = if let Some(val) = t.get("files") {
454            let arr = val.as_array().ok_or_else(|| {
455                format!(
456                    "aver.toml: [[check.suppress]][{}].files must be an array",
457                    i
458                )
459            })?;
460            arr.iter()
461                .enumerate()
462                .map(|(j, v)| {
463                    v.as_str().map(|s| s.to_string()).ok_or_else(|| {
464                        format!(
465                            "aver.toml: [[check.suppress]][{}].files[{}] must be a string",
466                            i, j
467                        )
468                    })
469                })
470                .collect::<Result<Vec<_>, _>>()?
471        } else {
472            Vec::new()
473        };
474
475        suppressions.push(CheckSuppression {
476            slug,
477            files,
478            reason,
479        });
480    }
481
482    Ok(suppressions)
483}
484
485/// Simple glob match for file paths.
486/// Supports `**` (any path segments) and `*` (any single segment chars).
487fn glob_matches(path: &str, pattern: &str) -> bool {
488    // Normalize separators
489    let path = path.replace('\\', "/");
490    let pattern = pattern.replace('\\', "/");
491    glob_match_recursive(path.as_bytes(), pattern.as_bytes())
492}
493
494fn glob_match_recursive(path: &[u8], pattern: &[u8]) -> bool {
495    match (pattern.first(), path.first()) {
496        (None, None) => true,
497        (None, Some(_)) => false,
498        (Some(b'*'), _) if pattern.starts_with(b"**/") => {
499            // "**/" matches zero or more path segments
500            let rest = &pattern[3..];
501            // Try matching at current position (zero segments)
502            if glob_match_recursive(path, rest) {
503                return true;
504            }
505            // Try skipping path segments
506            for i in 0..path.len() {
507                if path[i] == b'/' && glob_match_recursive(&path[i + 1..], rest) {
508                    return true;
509                }
510            }
511            false
512        }
513        (Some(b'*'), _) if pattern == b"**" => true,
514        (Some(b'*'), _) => {
515            // Single `*` matches anything except `/`
516            let rest = &pattern[1..];
517            // Try consuming 0..N non-slash chars
518            if glob_match_recursive(path, rest) {
519                return true;
520            }
521            for i in 0..path.len() {
522                if path[i] == b'/' {
523                    break;
524                }
525                if glob_match_recursive(&path[i + 1..], rest) {
526                    return true;
527                }
528            }
529            false
530        }
531        (Some(&pc), Some(&bc)) if pc == bc => glob_match_recursive(&path[1..], &pattern[1..]),
532        _ => false,
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_parse_empty_toml() {
542        let config = ProjectConfig::parse("").unwrap();
543        assert!(config.effect_policies.is_empty());
544    }
545
546    #[test]
547    fn test_parse_http_hosts() {
548        let toml = r#"
549[effects.Http]
550hosts = ["api.example.com", "*.internal.corp"]
551"#;
552        let config = ProjectConfig::parse(toml).unwrap();
553        let policy = config.effect_policies.get("Http").unwrap();
554        assert_eq!(policy.hosts.len(), 2);
555        assert_eq!(policy.hosts[0], "api.example.com");
556        assert_eq!(policy.hosts[1], "*.internal.corp");
557    }
558
559    #[test]
560    fn test_parse_disk_paths() {
561        let toml = r#"
562[effects.Disk]
563paths = ["./data/**"]
564"#;
565        let config = ProjectConfig::parse(toml).unwrap();
566        let policy = config.effect_policies.get("Disk").unwrap();
567        assert_eq!(policy.paths, vec!["./data/**"]);
568    }
569
570    #[test]
571    fn test_parse_env_keys() {
572        let toml = r#"
573[effects.Env]
574keys = ["APP_*", "TOKEN"]
575"#;
576        let config = ProjectConfig::parse(toml).unwrap();
577        let policy = config.effect_policies.get("Env").unwrap();
578        assert_eq!(policy.keys, vec!["APP_*", "TOKEN"]);
579    }
580
581    #[test]
582    fn test_check_http_host_allowed() {
583        let toml = r#"
584[effects.Http]
585hosts = ["api.example.com"]
586"#;
587        let config = ProjectConfig::parse(toml).unwrap();
588        assert!(
589            config
590                .check_http_host("Http.get", "https://api.example.com/data")
591                .is_ok()
592        );
593    }
594
595    #[test]
596    fn test_check_http_host_denied() {
597        let toml = r#"
598[effects.Http]
599hosts = ["api.example.com"]
600"#;
601        let config = ProjectConfig::parse(toml).unwrap();
602        let result = config.check_http_host("Http.get", "https://evil.com/data");
603        assert!(result.is_err());
604        assert!(result.unwrap_err().contains("denied by aver.toml"));
605    }
606
607    #[test]
608    fn test_check_http_host_wildcard() {
609        let toml = r#"
610[effects.Http]
611hosts = ["*.internal.corp"]
612"#;
613        let config = ProjectConfig::parse(toml).unwrap();
614        assert!(
615            config
616                .check_http_host("Http.get", "https://api.internal.corp/data")
617                .is_ok()
618        );
619        assert!(
620            config
621                .check_http_host("Http.get", "https://internal.corp/data")
622                .is_err()
623        );
624    }
625
626    #[test]
627    fn test_check_disk_path_allowed() {
628        let toml = r#"
629[effects.Disk]
630paths = ["./data/**"]
631"#;
632        let config = ProjectConfig::parse(toml).unwrap();
633        assert!(
634            config
635                .check_disk_path("Disk.readText", "data/file.txt")
636                .is_ok()
637        );
638        assert!(
639            config
640                .check_disk_path("Disk.readText", "data/sub/deep.txt")
641                .is_ok()
642        );
643    }
644
645    #[test]
646    fn test_check_disk_path_denied() {
647        let toml = r#"
648[effects.Disk]
649paths = ["./data/**"]
650"#;
651        let config = ProjectConfig::parse(toml).unwrap();
652        let result = config.check_disk_path("Disk.readText", "/etc/passwd");
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_check_disk_path_traversal_blocked() {
658        let toml = r#"
659[effects.Disk]
660paths = ["./data/**"]
661"#;
662        let config = ProjectConfig::parse(toml).unwrap();
663        // data/../etc/passwd normalizes to etc/passwd — not under data/
664        assert!(
665            config
666                .check_disk_path("Disk.readText", "data/../etc/passwd")
667                .is_err()
668        );
669        // Leading ../ must NOT be silently dropped — ../../data/x must NOT match data/**
670        assert!(
671            config
672                .check_disk_path("Disk.readText", "../../data/secret")
673                .is_err()
674        );
675        // More leading dotdots
676        assert!(
677            config
678                .check_disk_path("Disk.readText", "../../../etc/passwd")
679                .is_err()
680        );
681    }
682
683    #[test]
684    fn test_no_policy_allows_all() {
685        let config = ProjectConfig::parse("").unwrap();
686        assert!(
687            config
688                .check_http_host("Http.get", "https://anything.com/data")
689                .is_ok()
690        );
691        assert!(config.check_disk_path("Disk.readText", "/any/path").is_ok());
692        assert!(config.check_env_key("Env.get", "ANY_KEY").is_ok());
693    }
694
695    #[test]
696    fn test_empty_hosts_allows_all() {
697        let toml = r#"
698[effects.Http]
699hosts = []
700"#;
701        let config = ProjectConfig::parse(toml).unwrap();
702        assert!(
703            config
704                .check_http_host("Http.get", "https://anything.com")
705                .is_ok()
706        );
707    }
708
709    #[test]
710    fn test_malformed_toml() {
711        let result = ProjectConfig::parse("invalid = [");
712        assert!(result.is_err());
713    }
714
715    #[test]
716    fn test_non_string_hosts_are_rejected() {
717        let toml = r#"
718[effects.Http]
719hosts = [42, "api.example.com"]
720"#;
721        let result = ProjectConfig::parse(toml);
722        assert!(result.is_err());
723        assert!(result.unwrap_err().contains("must be a string"));
724    }
725
726    #[test]
727    fn test_non_string_paths_are_rejected() {
728        let toml = r#"
729[effects.Disk]
730paths = [true]
731"#;
732        let result = ProjectConfig::parse(toml);
733        assert!(result.is_err());
734        assert!(result.unwrap_err().contains("must be a string"));
735    }
736
737    #[test]
738    fn test_non_string_keys_are_rejected() {
739        let toml = r#"
740[effects.Env]
741keys = [1]
742"#;
743        let result = ProjectConfig::parse(toml);
744        assert!(result.is_err());
745        assert!(result.unwrap_err().contains("must be a string"));
746    }
747
748    #[test]
749    fn test_check_env_key_allowed_exact() {
750        let toml = r#"
751[effects.Env]
752keys = ["SECRET_TOKEN"]
753"#;
754        let config = ProjectConfig::parse(toml).unwrap();
755        assert!(config.check_env_key("Env.get", "SECRET_TOKEN").is_ok());
756        assert!(config.check_env_key("Env.get", "SECRET_TOKEN_2").is_err());
757    }
758
759    #[test]
760    fn test_check_env_key_allowed_prefix_wildcard() {
761        let toml = r#"
762[effects.Env]
763keys = ["APP_*"]
764"#;
765        let config = ProjectConfig::parse(toml).unwrap();
766        assert!(config.check_env_key("Env.get", "APP_PORT").is_ok());
767        assert!(config.check_env_key("Env.set", "APP_MODE").is_ok());
768        assert!(config.check_env_key("Env.get", "HOME").is_err());
769    }
770
771    #[test]
772    fn test_check_env_key_method_specific_overrides_namespace() {
773        let toml = r#"
774[effects.Env]
775keys = ["APP_*"]
776
777[effects."Env.get"]
778keys = ["PUBLIC_*"]
779"#;
780        let config = ProjectConfig::parse(toml).unwrap();
781        // Env.get uses method-specific key list
782        assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
783        assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
784        // Env.set falls back to namespace key list
785        assert!(config.check_env_key("Env.set", "APP_KEY").is_ok());
786        assert!(config.check_env_key("Env.set", "PUBLIC_KEY").is_err());
787    }
788
789    #[test]
790    fn host_matches_exact() {
791        assert!(host_matches("api.example.com", "api.example.com"));
792        assert!(!host_matches("other.com", "api.example.com"));
793    }
794
795    #[test]
796    fn host_matches_wildcard() {
797        assert!(host_matches("sub.example.com", "*.example.com"));
798        assert!(host_matches("deep.sub.example.com", "*.example.com"));
799        assert!(!host_matches("example.com", "*.example.com"));
800    }
801
802    #[test]
803    fn env_key_matches_exact() {
804        assert!(env_key_matches("TOKEN", "TOKEN"));
805        assert!(!env_key_matches("TOKEN", "TOK"));
806    }
807
808    #[test]
809    fn env_key_matches_prefix_wildcard() {
810        assert!(env_key_matches("APP_PORT", "APP_*"));
811        assert!(env_key_matches("APP_", "APP_*"));
812        assert!(!env_key_matches("PORT", "APP_*"));
813    }
814
815    // --- check.suppress tests ---
816
817    #[test]
818    fn test_parse_check_suppress_basic() {
819        let toml = r#"
820[[check.suppress]]
821slug = "non-tail-recursion"
822files = ["self_hosted/**"]
823reason = "Tree walkers cannot be converted to tail recursion"
824"#;
825        let config = ProjectConfig::parse(toml).unwrap();
826        assert_eq!(config.check_suppressions.len(), 1);
827        assert_eq!(config.check_suppressions[0].slug, "non-tail-recursion");
828        assert_eq!(config.check_suppressions[0].files, vec!["self_hosted/**"]);
829        assert!(
830            config.check_suppressions[0]
831                .reason
832                .contains("tail recursion")
833        );
834    }
835
836    #[test]
837    fn test_parse_check_suppress_multiple() {
838        let toml = r#"
839[[check.suppress]]
840slug = "non-tail-recursion"
841files = ["self_hosted/**"]
842reason = "Structural tree walkers"
843
844[[check.suppress]]
845slug = "missing-verify"
846reason = "Global suppression for now"
847"#;
848        let config = ProjectConfig::parse(toml).unwrap();
849        assert_eq!(config.check_suppressions.len(), 2);
850        assert_eq!(config.check_suppressions[1].slug, "missing-verify");
851        assert!(config.check_suppressions[1].files.is_empty());
852    }
853
854    #[test]
855    fn test_parse_check_suppress_missing_slug() {
856        let toml = r#"
857[[check.suppress]]
858reason = "No slug provided"
859"#;
860        let result = ProjectConfig::parse(toml);
861        assert!(result.is_err());
862        assert!(result.unwrap_err().contains("slug"));
863    }
864
865    #[test]
866    fn test_parse_check_suppress_missing_reason() {
867        let toml = r#"
868[[check.suppress]]
869slug = "non-tail-recursion"
870"#;
871        let result = ProjectConfig::parse(toml);
872        assert!(result.is_err());
873        assert!(result.unwrap_err().contains("reason"));
874    }
875
876    #[test]
877    fn test_parse_check_suppress_empty_reason() {
878        let toml = r#"
879[[check.suppress]]
880slug = "non-tail-recursion"
881reason = "   "
882"#;
883        let result = ProjectConfig::parse(toml);
884        assert!(result.is_err());
885        assert!(result.unwrap_err().contains("must not be empty"));
886    }
887
888    #[test]
889    fn test_is_check_suppressed_glob() {
890        let toml = r#"
891[[check.suppress]]
892slug = "non-tail-recursion"
893files = ["self_hosted/**"]
894reason = "Tree walkers"
895"#;
896        let config = ProjectConfig::parse(toml).unwrap();
897        assert!(config.is_check_suppressed("non-tail-recursion", "self_hosted/eval.av"));
898        assert!(config.is_check_suppressed("non-tail-recursion", "self_hosted/sub/deep.av"));
899        assert!(!config.is_check_suppressed("non-tail-recursion", "examples/hello.av"));
900        assert!(!config.is_check_suppressed("missing-verify", "self_hosted/eval.av"));
901    }
902
903    #[test]
904    fn test_is_check_suppressed_global() {
905        let toml = r#"
906[[check.suppress]]
907slug = "missing-verify"
908reason = "Not yet ready for verify"
909"#;
910        let config = ProjectConfig::parse(toml).unwrap();
911        assert!(config.is_check_suppressed("missing-verify", "any/file.av"));
912        assert!(config.is_check_suppressed("missing-verify", "other.av"));
913        assert!(!config.is_check_suppressed("non-tail-recursion", "any/file.av"));
914    }
915
916    #[test]
917    fn test_glob_matches_double_star() {
918        assert!(glob_matches("self_hosted/eval.av", "self_hosted/**"));
919        assert!(glob_matches("self_hosted/sub/deep.av", "self_hosted/**"));
920        assert!(!glob_matches("examples/hello.av", "self_hosted/**"));
921    }
922
923    #[test]
924    fn test_glob_matches_single_star() {
925        assert!(glob_matches("self_hosted/eval.av", "self_hosted/*.av"));
926        assert!(!glob_matches("self_hosted/sub/eval.av", "self_hosted/*.av"));
927    }
928
929    #[test]
930    fn test_glob_matches_exact() {
931        assert!(glob_matches("self_hosted/eval.av", "self_hosted/eval.av"));
932        assert!(!glob_matches("self_hosted/other.av", "self_hosted/eval.av"));
933    }
934
935    #[test]
936    fn test_no_check_section_is_ok() {
937        let config = ProjectConfig::parse("").unwrap();
938        assert!(config.check_suppressions.is_empty());
939        assert!(!config.is_check_suppressed("non-tail-recursion", "any.av"));
940    }
941
942    #[test]
943    fn test_independence_mode_default() {
944        let config = ProjectConfig::parse("").unwrap();
945        assert_eq!(config.independence_mode, IndependenceMode::Complete);
946    }
947
948    #[test]
949    fn test_independence_mode_complete() {
950        let toml = r#"
951[independence]
952mode = "complete"
953"#;
954        let config = ProjectConfig::parse(toml).unwrap();
955        assert_eq!(config.independence_mode, IndependenceMode::Complete);
956    }
957
958    #[test]
959    fn test_independence_mode_cancel() {
960        let toml = r#"
961[independence]
962mode = "cancel"
963"#;
964        let config = ProjectConfig::parse(toml).unwrap();
965        assert_eq!(config.independence_mode, IndependenceMode::Cancel);
966    }
967
968    #[test]
969    fn test_independence_mode_invalid() {
970        let toml = r#"
971[independence]
972mode = "yolo"
973"#;
974        assert!(ProjectConfig::parse(toml).is_err());
975    }
976}