1use std::collections::HashMap;
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct EffectPolicy {
19 pub hosts: Vec<String>,
21 pub paths: Vec<String>,
23 pub keys: Vec<String>,
25}
26
27#[derive(Debug, Clone)]
29pub struct CheckSuppression {
30 pub slug: String,
32 pub files: Vec<String>,
34 pub reason: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum IndependenceMode {
47 #[default]
50 Complete,
51 Cancel,
54 Sequential,
59}
60
61#[derive(Debug, Clone)]
63pub struct ProjectConfig {
64 pub effect_policies: HashMap<String, EffectPolicy>,
66 pub check_suppressions: Vec<CheckSuppression>,
68 pub independence_mode: IndependenceMode,
70}
71
72impl ProjectConfig {
73 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 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 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 pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
183 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(()); };
193
194 if policy.hosts.is_empty() {
195 return Ok(()); }
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 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 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 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
281fn host_matches(host: &str, pattern: &str) -> bool {
284 if pattern == host {
285 return true;
286 }
287 if let Some(suffix) = pattern.strip_prefix("*.") {
288 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
297fn 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 => {} std::path::Component::ParentDir => {
314 if components.last().is_some_and(|c| c != "..") {
316 components.pop();
317 } else if !is_absolute {
318 components.push("..".to_string());
320 }
321 }
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
340fn 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 if normalized == clean_pattern {
353 return true;
354 }
355
356 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
367fn 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
380fn 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
402fn 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
485fn glob_matches(path: &str, pattern: &str) -> bool {
488 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 let rest = &pattern[3..];
501 if glob_match_recursive(path, rest) {
503 return true;
504 }
505 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 let rest = &pattern[1..];
517 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 assert!(
665 config
666 .check_disk_path("Disk.readText", "data/../etc/passwd")
667 .is_err()
668 );
669 assert!(
671 config
672 .check_disk_path("Disk.readText", "../../data/secret")
673 .is_err()
674 );
675 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 assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
783 assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
784 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 #[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}