Skip to main content

cu_profiler_core/
config.rs

1//! `cu-profiler.toml` parsing.
2//!
3//! Parsing is strict — unknown keys are rejected — but every failure is turned
4//! into a clear [`crate::Error::Config`] message. Per-scenario settings overlay
5//! the project defaults to form an effective [`BudgetPolicy`].
6
7use 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/// Top-level configuration.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21    /// Project identity.
22    pub project: ProjectConfig,
23    /// Default policy + CI behaviour.
24    #[serde(default)]
25    pub defaults: DefaultsConfig,
26    /// Output destinations and default format.
27    #[serde(default)]
28    pub output: OutputConfig,
29    /// Extra program-ID labels.
30    #[serde(default)]
31    pub program_labels: BTreeMap<String, String>,
32    /// Per-scenario configuration, keyed by scenario name.
33    #[serde(default)]
34    pub scenario: BTreeMap<String, ScenarioConfig>,
35    /// Optional Anchor integration (requires the `anchor` feature to take effect).
36    #[serde(default)]
37    pub anchor: AnchorConfig,
38}
39
40/// `[anchor]` — optional Anchor IDL integration.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
42#[serde(deny_unknown_fields, default)]
43pub struct AnchorConfig {
44    /// Path to an Anchor IDL JSON file. When set (and the `anchor` feature is
45    /// enabled), the program's address is labelled with its IDL name.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub idl: Option<PathBuf>,
48}
49
50/// `[project]`.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ProjectConfig {
54    /// Human project name.
55    pub name: String,
56    /// Program ID under test, if fixed.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub program_id: Option<String>,
59    /// Execution mode (`program-test`, `banks-client`, `recorded`).
60    #[serde(default = "default_mode")]
61    pub mode: String,
62}
63
64fn default_mode() -> String {
65    "program-test".to_string()
66}
67
68/// `[defaults]`.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
70#[serde(deny_unknown_fields, default)]
71pub struct DefaultsConfig {
72    /// Warn once this percentage of budget is used.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub warn_at_budget_pct: Option<f64>,
75    /// Maximum tolerated regression percentage.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub max_regression_pct: Option<f64>,
78    /// Fail CI when an absolute budget is exceeded.
79    pub fail_on_budget: bool,
80    /// Fail CI on regression.
81    pub fail_on_regression: bool,
82    /// Fail CI when the baseline is stale.
83    pub fail_on_stale_baseline: bool,
84}
85
86/// `[output]`.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields, default)]
89pub struct OutputConfig {
90    /// Default render format (`table`, `json`, `markdown`, `junit`).
91    pub default_format: String,
92    /// JSON report path.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub json_path: Option<PathBuf>,
95    /// Markdown report path.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub markdown_path: Option<PathBuf>,
98    /// JUnit report path.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub junit_path: Option<PathBuf>,
101    /// HTML report path.
102    #[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/// `[scenario.<name>]`.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
120#[serde(deny_unknown_fields, default)]
121pub struct ScenarioConfig {
122    /// Absolute CU budget.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub budget: Option<u64>,
125    /// Per-scenario warn threshold.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub warn_at_budget_pct: Option<f64>,
128    /// Per-scenario regression allowance.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub max_regression_pct: Option<f64>,
131    /// Whether the scenario is critical.
132    pub critical: bool,
133    /// Tags.
134    pub tags: Vec<String>,
135    /// Description.
136    pub description: String,
137}
138
139impl Config {
140    /// Parse configuration from a TOML string.
141    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    /// Load configuration from a file path.
148    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    /// The default budget policy assembled from `[defaults]`.
166    #[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    /// The effective budget policy for a scenario (defaults overlaid by the
176    /// per-scenario settings).
177    #[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    /// Build [`Scenario`] values from the configured scenarios.
192    #[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        // default warn threshold flows through; regression overridden to 3.
263        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}