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)]
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#[derive(Debug, Clone)]
45pub struct ShapeExpected {
46 pub glob: String,
47 pub layer: String,
48}
49
50#[derive(Debug, Clone)]
52pub struct CheckSuppression {
53 pub slug: String,
55 pub files: Vec<String>,
57 pub reason: String,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum IndependenceMode {
70 #[default]
73 Complete,
74 Cancel,
77 Sequential,
82}
83
84#[derive(Debug, Clone)]
86pub struct ProjectConfig {
87 pub effect_policies: HashMap<String, EffectPolicy>,
89 pub check_suppressions: Vec<CheckSuppression>,
91 pub independence_mode: IndependenceMode,
93 pub shape_layers: Vec<ShapeLayerFingerprint>,
96 pub shape_expected: Vec<ShapeExpected>,
99}
100
101impl ProjectConfig {
102 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 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 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 .max_by_key(|e| e.glob.len())
213 .map(|e| e.layer.as_str())
214 }
215
216 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 pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
228 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(()); };
238
239 if policy.hosts.is_empty() {
240 return Ok(()); }
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 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 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 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
326fn host_matches(host: &str, pattern: &str) -> bool {
329 if pattern == host {
330 return true;
331 }
332 if let Some(suffix) = pattern.strip_prefix("*.") {
333 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
342fn 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 => {} std::path::Component::ParentDir => {
359 if components.last().is_some_and(|c| c != "..") {
361 components.pop();
362 } else if !is_absolute {
363 components.push("..".to_string());
365 }
366 }
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
385fn 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 if normalized == clean_pattern {
398 return true;
399 }
400
401 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
412fn 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#[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 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
536fn 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
619fn glob_matches(path: &str, pattern: &str) -> bool {
622 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 let rest = &pattern[3..];
635 if glob_match_recursive(path, rest) {
637 return true;
638 }
639 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 let rest = &pattern[1..];
651 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 assert!(
799 config
800 .check_disk_path("Disk.readText", "data/../etc/passwd")
801 .is_err()
802 );
803 assert!(
805 config
806 .check_disk_path("Disk.readText", "../../data/secret")
807 .is_err()
808 );
809 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 assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
917 assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
918 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 #[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 #[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 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}