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. The CLI profiles `recorded` logs; the other modes name a
60    /// live backend that lives in an `integration/*` crate (library-only).
61    #[serde(default = "default_mode")]
62    pub mode: String,
63}
64
65fn default_mode() -> String {
66    "recorded".to_string()
67}
68
69/// The execution modes the config understands.
70pub const KNOWN_MODES: &[&str] = &[
71    "recorded",
72    "program-test",
73    "banks-client",
74    "mollusk",
75    "rpc-simulation",
76];
77
78/// `[defaults]`.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
80#[serde(deny_unknown_fields, default)]
81pub struct DefaultsConfig {
82    /// Warn once this percentage of budget is used.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub warn_at_budget_pct: Option<f64>,
85    /// Maximum tolerated regression percentage.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub max_regression_pct: Option<f64>,
88    /// Fail CI when an absolute budget is exceeded.
89    pub fail_on_budget: bool,
90    /// Fail CI on regression.
91    pub fail_on_regression: bool,
92    /// Fail CI when the baseline is stale.
93    pub fail_on_stale_baseline: bool,
94}
95
96/// `[output]`.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields, default)]
99pub struct OutputConfig {
100    /// Default render format (`table`, `json`, `markdown`, `junit`).
101    pub default_format: String,
102    /// JSON report path.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub json_path: Option<PathBuf>,
105    /// Markdown report path.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub markdown_path: Option<PathBuf>,
108    /// JUnit report path.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub junit_path: Option<PathBuf>,
111    /// HTML report path.
112    #[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/// `[scenario.<name>]`.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
130#[serde(deny_unknown_fields, default)]
131pub struct ScenarioConfig {
132    /// Absolute CU budget.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub budget: Option<u64>,
135    /// Per-scenario warn threshold.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub warn_at_budget_pct: Option<f64>,
138    /// Per-scenario regression allowance.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub max_regression_pct: Option<f64>,
141    /// Whether the scenario is critical.
142    pub critical: bool,
143    /// Tags.
144    pub tags: Vec<String>,
145    /// Description.
146    pub description: String,
147}
148
149impl Config {
150    /// Parse configuration from a TOML string.
151    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    /// Load configuration from a file path.
158    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    /// Whether the project mode is `recorded` (what the CLI runs).
165    #[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    /// The default budget policy assembled from `[defaults]`.
188    #[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    /// The effective budget policy for a scenario (defaults overlaid by the
198    /// per-scenario settings).
199    #[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    /// Build [`Scenario`] values from the configured scenarios.
214    #[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        // default warn threshold flows through; regression overridden to 3.
285        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}