Skip to main content

oratos_core/
config.rs

1//! Project configuration (`oratos.toml`) for v0.2+.
2
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7/// Top-level Oratos configuration file.
8#[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/// Audit and reporting defaults.
17#[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    /// Rule IDs to suppress in reports and exit-code checks (e.g. `seo.missing-twitter-card`).
23    #[serde(default)]
24    pub ignore_rules: Vec<String>,
25    /// When true, only audit HTML files changed in git (local targets).
26    #[serde(default)]
27    pub changed_only: bool,
28}
29
30/// HTTP crawl and discovery options (URL targets).
31#[derive(Debug, Clone, Deserialize)]
32pub struct CrawlConfig {
33    /// Enable same-origin HTTP crawl (off by default for backward compatibility).
34    #[serde(default)]
35    pub enabled: bool,
36    /// Maximum pages to fetch when crawling from a URL seed.
37    #[serde(default = "default_max_pages")]
38    pub max_pages: usize,
39    /// Maximum link depth from the seed URL.
40    #[serde(default = "default_max_depth")]
41    pub max_depth: usize,
42    /// Honor robots.txt disallow rules when crawling.
43    #[serde(default = "default_true")]
44    pub respect_robots: bool,
45    /// Discover URLs from `sitemap.xml` at the site root when crawling.
46    #[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    /// Load `oratos.toml` from `path` if it exists.
76    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    /// Search for `oratos.toml` starting at `start` and walking parent directories.
92    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
111/// Remove ignored rule findings from a report (pages and top-level site findings).
112pub 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}