1use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::Result;
13use crate::budget::BudgetPolicy;
14use crate::error::Error;
15use crate::scenario::{Criticality, Scenario};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21 pub project: ProjectConfig,
23 #[serde(default)]
25 pub defaults: DefaultsConfig,
26 #[serde(default)]
28 pub output: OutputConfig,
29 #[serde(default)]
31 pub program_labels: BTreeMap<String, String>,
32 #[serde(default)]
34 pub scenario: BTreeMap<String, ScenarioConfig>,
35 #[serde(default)]
37 pub anchor: AnchorConfig,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
42#[serde(deny_unknown_fields, default)]
43pub struct AnchorConfig {
44 #[serde(skip_serializing_if = "Option::is_none")]
47 pub idl: Option<PathBuf>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ProjectConfig {
54 pub name: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub program_id: Option<String>,
59 #[serde(default = "default_mode")]
61 pub mode: String,
62}
63
64fn default_mode() -> String {
65 "program-test".to_string()
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
70#[serde(deny_unknown_fields, default)]
71pub struct DefaultsConfig {
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub warn_at_budget_pct: Option<f64>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub max_regression_pct: Option<f64>,
78 pub fail_on_budget: bool,
80 pub fail_on_regression: bool,
82 pub fail_on_stale_baseline: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields, default)]
89pub struct OutputConfig {
90 pub default_format: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub json_path: Option<PathBuf>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub markdown_path: Option<PathBuf>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub junit_path: Option<PathBuf>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub html_path: Option<PathBuf>,
104}
105
106impl Default for OutputConfig {
107 fn default() -> Self {
108 Self {
109 default_format: "table".to_string(),
110 json_path: None,
111 markdown_path: None,
112 junit_path: None,
113 html_path: None,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
120#[serde(deny_unknown_fields, default)]
121pub struct ScenarioConfig {
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub budget: Option<u64>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub warn_at_budget_pct: Option<f64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub max_regression_pct: Option<f64>,
131 pub critical: bool,
133 pub tags: Vec<String>,
135 pub description: String,
137}
138
139impl Config {
140 pub fn from_toml(s: &str) -> Result<Self> {
142 let cfg: Config = toml::from_str(s).map_err(|e| Error::Config(e.to_string()))?;
143 cfg.validate()?;
144 Ok(cfg)
145 }
146
147 pub fn load(path: &Path) -> Result<Self> {
149 let text = std::fs::read_to_string(path)
150 .map_err(|e| Error::Config(format!("cannot read config `{}`: {e}", path.display())))?;
151 Self::from_toml(&text)
152 }
153
154 fn validate(&self) -> Result<()> {
155 const FORMATS: &[&str] = &["table", "json", "markdown", "junit", "html"];
156 if !FORMATS.contains(&self.output.default_format.as_str()) {
157 return Err(Error::Config(format!(
158 "output.default_format `{}` is not one of {FORMATS:?}",
159 self.output.default_format
160 )));
161 }
162 Ok(())
163 }
164
165 #[must_use]
167 pub fn default_policy(&self) -> BudgetPolicy {
168 BudgetPolicy {
169 warn_at_budget_pct: self.defaults.warn_at_budget_pct,
170 max_regression_pct: self.defaults.max_regression_pct,
171 ..Default::default()
172 }
173 }
174
175 #[must_use]
178 pub fn effective_policy(&self, scenario: &str) -> BudgetPolicy {
179 let base = self.default_policy();
180 match self.scenario.get(scenario) {
181 Some(sc) => base.merged_with(&BudgetPolicy {
182 absolute_max_cu: sc.budget,
183 warn_at_budget_pct: sc.warn_at_budget_pct,
184 max_regression_pct: sc.max_regression_pct,
185 ..Default::default()
186 }),
187 None => base,
188 }
189 }
190
191 #[must_use]
193 pub fn scenarios(&self) -> Vec<Scenario> {
194 self.scenario
195 .iter()
196 .map(|(name, sc)| Scenario {
197 name: name.clone(),
198 description: sc.description.clone(),
199 tags: sc.tags.clone(),
200 criticality: if sc.critical {
201 Criticality::Critical
202 } else {
203 Criticality::Normal
204 },
205 owner: None,
206 expected: crate::scenario::ExpectedResult::Success,
207 budget: self.effective_policy(name),
208 samples: 1,
209 })
210 .collect()
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 const SAMPLE: &str = r#"
219[project]
220name = "my-solana-program"
221mode = "program-test"
222
223[defaults]
224warn_at_budget_pct = 90
225max_regression_pct = 5
226fail_on_budget = true
227fail_on_regression = true
228fail_on_stale_baseline = false
229
230[output]
231default_format = "table"
232
233[program_labels]
234"11111111111111111111111111111111" = "System Program"
235
236[scenario.swap_exact_in]
237budget = 100000
238warn_at_budget_pct = 90
239max_regression_pct = 5
240critical = true
241tags = ["swap", "hot-path"]
242
243[scenario.initialize_pool]
244budget = 80000
245max_regression_pct = 3
246critical = true
247"#;
248
249 #[test]
250 fn parses_sample_config() {
251 let cfg = Config::from_toml(SAMPLE).unwrap();
252 assert_eq!(cfg.project.name, "my-solana-program");
253 assert_eq!(cfg.scenario.len(), 2);
254 assert!(cfg.defaults.fail_on_budget);
255 }
256
257 #[test]
258 fn effective_policy_overlays_defaults() {
259 let cfg = Config::from_toml(SAMPLE).unwrap();
260 let p = cfg.effective_policy("initialize_pool");
261 assert_eq!(p.absolute_max_cu, Some(80_000));
262 assert_eq!(p.warn_at_budget_pct, Some(90.0));
264 assert_eq!(p.max_regression_pct, Some(3.0));
265 }
266
267 #[test]
268 fn rejects_unknown_format() {
269 let toml = "[project]\nname = \"x\"\n[output]\ndefault_format = \"yaml\"\n";
270 let err = Config::from_toml(toml).unwrap_err();
271 assert!(err.to_string().contains("default_format"));
272 }
273
274 #[test]
275 fn rejects_unknown_key() {
276 let toml = "[project]\nname = \"x\"\nbogus = 1\n";
277 assert!(Config::from_toml(toml).is_err());
278 }
279
280 #[test]
281 fn builds_scenarios() {
282 let cfg = Config::from_toml(SAMPLE).unwrap();
283 let scenarios = cfg.scenarios();
284 assert_eq!(scenarios.len(), 2);
285 assert!(scenarios.iter().any(|s| s.name == "swap_exact_in"));
286 }
287}