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