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")]
62 pub mode: String,
63}
64
65fn default_mode() -> String {
66 "recorded".to_string()
67}
68
69pub const KNOWN_MODES: &[&str] = &[
71 "recorded",
72 "program-test",
73 "banks-client",
74 "mollusk",
75 "rpc-simulation",
76];
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
80#[serde(deny_unknown_fields, default)]
81pub struct DefaultsConfig {
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub warn_at_budget_pct: Option<f64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub max_regression_pct: Option<f64>,
88 pub fail_on_budget: bool,
90 pub fail_on_regression: bool,
92 pub fail_on_stale_baseline: bool,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields, default)]
99pub struct OutputConfig {
100 pub default_format: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub json_path: Option<PathBuf>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub markdown_path: Option<PathBuf>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub junit_path: Option<PathBuf>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub html_path: Option<PathBuf>,
114}
115
116impl Default for OutputConfig {
117 fn default() -> Self {
118 Self {
119 default_format: "table".to_string(),
120 json_path: None,
121 markdown_path: None,
122 junit_path: None,
123 html_path: None,
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
130#[serde(deny_unknown_fields, default)]
131pub struct ScenarioConfig {
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub budget: Option<u64>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub warn_at_budget_pct: Option<f64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub max_regression_pct: Option<f64>,
141 pub critical: bool,
143 pub tags: Vec<String>,
145 pub description: String,
147}
148
149impl Config {
150 pub fn from_toml(s: &str) -> Result<Self> {
152 let cfg: Config = toml::from_str(s).map_err(|e| Error::Config(e.to_string()))?;
153 cfg.validate()?;
154 Ok(cfg)
155 }
156
157 pub fn load(path: &Path) -> Result<Self> {
159 let text = std::fs::read_to_string(path)
160 .map_err(|e| Error::Config(format!("cannot read config `{}`: {e}", path.display())))?;
161 Self::from_toml(&text)
162 }
163
164 #[must_use]
166 pub fn mode_is_recorded(&self) -> bool {
167 self.project.mode == "recorded"
168 }
169
170 fn validate(&self) -> Result<()> {
171 const FORMATS: &[&str] = &["table", "json", "markdown", "junit", "html"];
172 if !FORMATS.contains(&self.output.default_format.as_str()) {
173 return Err(Error::Config(format!(
174 "output.default_format `{}` is not one of {FORMATS:?}",
175 self.output.default_format
176 )));
177 }
178 if !KNOWN_MODES.contains(&self.project.mode.as_str()) {
179 return Err(Error::Config(format!(
180 "project.mode `{}` is not one of {KNOWN_MODES:?}",
181 self.project.mode
182 )));
183 }
184 Ok(())
185 }
186
187 #[must_use]
189 pub fn default_policy(&self) -> BudgetPolicy {
190 BudgetPolicy {
191 warn_at_budget_pct: self.defaults.warn_at_budget_pct,
192 max_regression_pct: self.defaults.max_regression_pct,
193 ..Default::default()
194 }
195 }
196
197 #[must_use]
200 pub fn effective_policy(&self, scenario: &str) -> BudgetPolicy {
201 let base = self.default_policy();
202 match self.scenario.get(scenario) {
203 Some(sc) => base.merged_with(&BudgetPolicy {
204 absolute_max_cu: sc.budget,
205 warn_at_budget_pct: sc.warn_at_budget_pct,
206 max_regression_pct: sc.max_regression_pct,
207 ..Default::default()
208 }),
209 None => base,
210 }
211 }
212
213 #[must_use]
215 pub fn scenarios(&self) -> Vec<Scenario> {
216 self.scenario
217 .iter()
218 .map(|(name, sc)| Scenario {
219 name: name.clone(),
220 description: sc.description.clone(),
221 tags: sc.tags.clone(),
222 criticality: if sc.critical {
223 Criticality::Critical
224 } else {
225 Criticality::Normal
226 },
227 owner: None,
228 expected: crate::scenario::ExpectedResult::Success,
229 budget: self.effective_policy(name),
230 samples: 1,
231 })
232 .collect()
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 const SAMPLE: &str = r#"
241[project]
242name = "my-solana-program"
243mode = "program-test"
244
245[defaults]
246warn_at_budget_pct = 90
247max_regression_pct = 5
248fail_on_budget = true
249fail_on_regression = true
250fail_on_stale_baseline = false
251
252[output]
253default_format = "table"
254
255[program_labels]
256"11111111111111111111111111111111" = "System Program"
257
258[scenario.swap_exact_in]
259budget = 100000
260warn_at_budget_pct = 90
261max_regression_pct = 5
262critical = true
263tags = ["swap", "hot-path"]
264
265[scenario.initialize_pool]
266budget = 80000
267max_regression_pct = 3
268critical = true
269"#;
270
271 #[test]
272 fn parses_sample_config() {
273 let cfg = Config::from_toml(SAMPLE).unwrap();
274 assert_eq!(cfg.project.name, "my-solana-program");
275 assert_eq!(cfg.scenario.len(), 2);
276 assert!(cfg.defaults.fail_on_budget);
277 }
278
279 #[test]
280 fn effective_policy_overlays_defaults() {
281 let cfg = Config::from_toml(SAMPLE).unwrap();
282 let p = cfg.effective_policy("initialize_pool");
283 assert_eq!(p.absolute_max_cu, Some(80_000));
284 assert_eq!(p.warn_at_budget_pct, Some(90.0));
286 assert_eq!(p.max_regression_pct, Some(3.0));
287 }
288
289 #[test]
290 fn rejects_unknown_format() {
291 let toml = "[project]\nname = \"x\"\n[output]\ndefault_format = \"yaml\"\n";
292 let err = Config::from_toml(toml).unwrap_err();
293 assert!(err.to_string().contains("default_format"));
294 }
295
296 #[test]
297 fn rejects_unknown_mode() {
298 let toml = "[project]\nname = \"x\"\nmode = \"bogus\"\n";
299 let err = Config::from_toml(toml).unwrap_err();
300 assert!(err.to_string().contains("project.mode"), "{err}");
301 }
302
303 #[test]
304 fn default_mode_is_recorded() {
305 let cfg = Config::from_toml("[project]\nname = \"x\"\n").unwrap();
306 assert_eq!(cfg.project.mode, "recorded");
307 assert!(cfg.mode_is_recorded());
308 }
309
310 #[test]
311 fn rejects_unknown_key() {
312 let toml = "[project]\nname = \"x\"\nbogus = 1\n";
313 assert!(Config::from_toml(toml).is_err());
314 }
315
316 #[test]
317 fn builds_scenarios() {
318 let cfg = Config::from_toml(SAMPLE).unwrap();
319 let scenarios = cfg.scenarios();
320 assert_eq!(scenarios.len(), 2);
321 assert!(scenarios.iter().any(|s| s.name == "swap_exact_in"));
322 }
323}