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