1use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7#[derive(Debug, Clone, Default, Deserialize)]
9pub struct OratosConfig {
10 #[serde(default)]
11 pub audit: AuditConfig,
12 #[serde(default)]
13 pub crawl: CrawlConfig,
14}
15
16#[derive(Debug, Clone, Default, Deserialize)]
18pub struct AuditConfig {
19 pub fail_under: Option<f64>,
20 pub strict: Option<bool>,
21 pub format: Option<String>,
22 #[serde(default)]
24 pub ignore_rules: Vec<String>,
25 #[serde(default)]
27 pub changed_only: bool,
28}
29
30#[derive(Debug, Clone, Deserialize)]
32pub struct CrawlConfig {
33 #[serde(default)]
35 pub enabled: bool,
36 #[serde(default = "default_max_pages")]
38 pub max_pages: usize,
39 #[serde(default = "default_max_depth")]
41 pub max_depth: usize,
42 #[serde(default = "default_true")]
44 pub respect_robots: bool,
45 #[serde(default = "default_true")]
47 pub use_sitemap: bool,
48}
49
50fn default_max_pages() -> usize {
51 25
52}
53
54fn default_max_depth() -> usize {
55 2
56}
57
58fn default_true() -> bool {
59 true
60}
61
62impl Default for CrawlConfig {
63 fn default() -> Self {
64 Self {
65 enabled: false,
66 max_pages: default_max_pages(),
67 max_depth: default_max_depth(),
68 respect_robots: true,
69 use_sitemap: true,
70 }
71 }
72}
73
74impl OratosConfig {
75 pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
77 if !path.is_file() {
78 return Ok(None);
79 }
80 let raw = std::fs::read_to_string(path).map_err(|e| ConfigError::Read {
81 path: path.display().to_string(),
82 source: e,
83 })?;
84 let cfg: OratosConfig = toml::from_str(&raw).map_err(|e| ConfigError::Parse {
85 path: path.display().to_string(),
86 source: e,
87 })?;
88 Ok(Some(cfg))
89 }
90
91 pub fn discover(start: &Path) -> Result<Option<(PathBuf, Self)>, ConfigError> {
93 let mut dir = if start.is_file() {
94 start.parent().unwrap_or(start).to_path_buf()
95 } else {
96 start.to_path_buf()
97 };
98 loop {
99 let candidate = dir.join("oratos.toml");
100 if let Some(cfg) = Self::load(&candidate)? {
101 return Ok(Some((candidate, cfg)));
102 }
103 if !dir.pop() {
104 break;
105 }
106 }
107 Ok(None)
108 }
109}
110
111pub fn apply_ignore_rules(report: &mut crate::AuditReport, ignore: &[String]) {
113 if ignore.is_empty() {
114 return;
115 }
116 for page in &mut report.pages {
117 page.findings
118 .retain(|f| !ignore.iter().any(|id| id == &f.rule_id));
119 page.scores = crate::CategoryScores::from_findings(&page.findings);
120 }
121 report
122 .findings
123 .retain(|f| !ignore.iter().any(|id| id == &f.rule_id));
124 report.scores = crate::CategoryScores::from_findings(&report.findings);
125}
126
127#[derive(Debug, thiserror::Error)]
128pub enum ConfigError {
129 #[error("failed to read config {path}: {source}")]
130 Read {
131 path: String,
132 source: std::io::Error,
133 },
134 #[error("failed to parse config {path}: {source}")]
135 Parse {
136 path: String,
137 source: toml::de::Error,
138 },
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn apply_ignore_rules_removes_findings() {
147 use crate::{AuditReport, AuditTarget, Category, Finding, Severity, TargetKind};
148
149 let mut report = AuditReport::new(
150 AuditTarget {
151 path_or_url: ".".into(),
152 kind: TargetKind::Directory,
153 },
154 vec![],
155 );
156 report.findings.push(Finding::new(
157 "seo.missing-title",
158 Severity::Error,
159 Category::Seo,
160 "x",
161 ));
162 apply_ignore_rules(&mut report, &["seo.missing-title".to_string()]);
163 assert!(report.findings.is_empty());
164 }
165
166 #[test]
167 fn load_returns_none_for_missing_file() {
168 assert!(OratosConfig::load(Path::new("/nonexistent/oratos.toml"))
169 .unwrap()
170 .is_none());
171 }
172
173 #[test]
174 fn load_and_discover_from_temp_dir() {
175 let dir = tempfile::tempdir().unwrap();
176 let cfg_path = dir.path().join("oratos.toml");
177 std::fs::write(
178 &cfg_path,
179 r#"
180[audit]
181fail_under = 90.0
182"#,
183 )
184 .unwrap();
185 let loaded = OratosConfig::load(&cfg_path).unwrap().unwrap();
186 assert_eq!(loaded.audit.fail_under, Some(90.0));
187
188 let nested = dir.path().join("nested");
189 std::fs::create_dir(&nested).unwrap();
190 let discovered = OratosConfig::discover(&nested).unwrap().unwrap();
191 assert_eq!(discovered.1.audit.fail_under, Some(90.0));
192 }
193
194 #[test]
195 fn load_rejects_invalid_toml() {
196 let dir = tempfile::tempdir().unwrap();
197 let path = dir.path().join("oratos.toml");
198 std::fs::write(&path, "not = [valid").unwrap();
199 assert!(OratosConfig::load(&path).is_err());
200 }
201
202 #[test]
203 fn parses_minimal_config() {
204 let raw = r#"
205[audit]
206fail_under = 85.0
207ignore_rules = ["seo.missing-twitter-card"]
208changed_only = true
209
210[crawl]
211max_pages = 10
212"#;
213 let cfg: OratosConfig = toml::from_str(raw).unwrap();
214 assert_eq!(cfg.audit.fail_under, Some(85.0));
215 assert_eq!(cfg.audit.ignore_rules.len(), 1);
216 assert!(cfg.audit.changed_only);
217 assert_eq!(cfg.crawl.max_pages, 10);
218 }
219}