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)]
40pub enum IndependenceMode {
41 #[default]
43 Complete,
44 Cancel,
46}
47
48#[derive(Debug, Clone)]
50pub struct ProjectConfig {
51 pub effect_policies: HashMap<String, EffectPolicy>,
53 pub check_suppressions: Vec<CheckSuppression>,
55 pub independence_mode: IndependenceMode,
57}
58
59impl ProjectConfig {
60 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 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 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 pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
170 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(()); };
180
181 if policy.hosts.is_empty() {
182 return Ok(()); }
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 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 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 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
268fn host_matches(host: &str, pattern: &str) -> bool {
271 if pattern == host {
272 return true;
273 }
274 if let Some(suffix) = pattern.strip_prefix("*.") {
275 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
284fn 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 => {} std::path::Component::ParentDir => {
301 if components.last().is_some_and(|c| c != "..") {
303 components.pop();
304 } else if !is_absolute {
305 components.push("..".to_string());
307 }
308 }
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
327fn 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 if normalized == clean_pattern {
340 return true;
341 }
342
343 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
354fn 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
367fn 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
388fn 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
471fn glob_matches(path: &str, pattern: &str) -> bool {
474 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 let rest = &pattern[3..];
487 if glob_match_recursive(path, rest) {
489 return true;
490 }
491 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 let rest = &pattern[1..];
503 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 assert!(
651 config
652 .check_disk_path("Disk.readText", "data/../etc/passwd")
653 .is_err()
654 );
655 assert!(
657 config
658 .check_disk_path("Disk.readText", "../../data/secret")
659 .is_err()
660 );
661 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 assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
769 assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
770 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 #[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}