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    /// How many times to measure (>= 1). Only meaningful on non-deterministic
148    /// backends; the recorded backend ignores it. Defaults to 1.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub samples: Option<u32>,
151}
152
153impl Config {
154    /// Parse configuration from a TOML string.
155    pub fn from_toml(s: &str) -> Result<Self> {
156        let cfg: Config = toml::from_str(s).map_err(|e| Error::Config(e.to_string()))?;
157        cfg.validate()?;
158        Ok(cfg)
159    }
160
161    /// Load configuration from a file path.
162    pub fn load(path: &Path) -> Result<Self> {
163        let text = std::fs::read_to_string(path)
164            .map_err(|e| Error::Config(format!("cannot read config `{}`: {e}", path.display())))?;
165        Self::from_toml(&text)
166    }
167
168    /// Whether the project mode is `recorded` (what the CLI runs).
169    #[must_use]
170    pub fn mode_is_recorded(&self) -> bool {
171        self.project.mode == "recorded"
172    }
173
174    fn validate(&self) -> Result<()> {
175        const FORMATS: &[&str] = &["table", "json", "markdown", "junit", "html"];
176        if !FORMATS.contains(&self.output.default_format.as_str()) {
177            return Err(Error::Config(format!(
178                "output.default_format `{}` is not one of {FORMATS:?}",
179                self.output.default_format
180            )));
181        }
182        if !KNOWN_MODES.contains(&self.project.mode.as_str()) {
183            return Err(Error::Config(format!(
184                "project.mode `{}` is not one of {KNOWN_MODES:?}",
185                self.project.mode
186            )));
187        }
188        Ok(())
189    }
190
191    /// The default budget policy assembled from `[defaults]`.
192    #[must_use]
193    pub fn default_policy(&self) -> BudgetPolicy {
194        BudgetPolicy {
195            warn_at_budget_pct: self.defaults.warn_at_budget_pct,
196            max_regression_pct: self.defaults.max_regression_pct,
197            ..Default::default()
198        }
199    }
200
201    /// The effective budget policy for a scenario (defaults overlaid by the
202    /// per-scenario settings).
203    #[must_use]
204    pub fn effective_policy(&self, scenario: &str) -> BudgetPolicy {
205        let base = self.default_policy();
206        match self.scenario.get(scenario) {
207            Some(sc) => base.merged_with(&BudgetPolicy {
208                absolute_max_cu: sc.budget,
209                warn_at_budget_pct: sc.warn_at_budget_pct,
210                max_regression_pct: sc.max_regression_pct,
211                ..Default::default()
212            }),
213            None => base,
214        }
215    }
216
217    /// Build [`Scenario`] values from the configured scenarios.
218    #[must_use]
219    pub fn scenarios(&self) -> Vec<Scenario> {
220        self.scenario
221            .iter()
222            .map(|(name, sc)| Scenario {
223                name: name.clone(),
224                description: sc.description.clone(),
225                tags: sc.tags.clone(),
226                criticality: if sc.critical {
227                    Criticality::Critical
228                } else {
229                    Criticality::Normal
230                },
231                owner: None,
232                expected: crate::scenario::ExpectedResult::Success,
233                budget: self.effective_policy(name),
234                samples: sc.samples.unwrap_or(1).max(1),
235            })
236            .collect()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    const SAMPLE: &str = r#"
245[project]
246name = "my-solana-program"
247mode = "program-test"
248
249[defaults]
250warn_at_budget_pct = 90
251max_regression_pct = 5
252fail_on_budget = true
253fail_on_regression = true
254fail_on_stale_baseline = false
255
256[output]
257default_format = "table"
258
259[program_labels]
260"11111111111111111111111111111111" = "System Program"
261
262[scenario.swap_exact_in]
263budget = 100000
264warn_at_budget_pct = 90
265max_regression_pct = 5
266critical = true
267tags = ["swap", "hot-path"]
268
269[scenario.initialize_pool]
270budget = 80000
271max_regression_pct = 3
272critical = true
273"#;
274
275    #[test]
276    fn parses_sample_config() {
277        let cfg = Config::from_toml(SAMPLE).unwrap();
278        assert_eq!(cfg.project.name, "my-solana-program");
279        assert_eq!(cfg.scenario.len(), 2);
280        assert!(cfg.defaults.fail_on_budget);
281    }
282
283    #[test]
284    fn effective_policy_overlays_defaults() {
285        let cfg = Config::from_toml(SAMPLE).unwrap();
286        let p = cfg.effective_policy("initialize_pool");
287        assert_eq!(p.absolute_max_cu, Some(80_000));
288        // default warn threshold flows through; regression overridden to 3.
289        assert_eq!(p.warn_at_budget_pct, Some(90.0));
290        assert_eq!(p.max_regression_pct, Some(3.0));
291    }
292
293    #[test]
294    fn rejects_unknown_format() {
295        let toml = "[project]\nname = \"x\"\n[output]\ndefault_format = \"yaml\"\n";
296        let err = Config::from_toml(toml).unwrap_err();
297        assert!(err.to_string().contains("default_format"));
298    }
299
300    #[test]
301    fn rejects_unknown_mode() {
302        let toml = "[project]\nname = \"x\"\nmode = \"bogus\"\n";
303        let err = Config::from_toml(toml).unwrap_err();
304        assert!(err.to_string().contains("project.mode"), "{err}");
305    }
306
307    #[test]
308    fn default_mode_is_recorded() {
309        let cfg = Config::from_toml("[project]\nname = \"x\"\n").unwrap();
310        assert_eq!(cfg.project.mode, "recorded");
311        assert!(cfg.mode_is_recorded());
312    }
313
314    #[test]
315    fn rejects_unknown_key() {
316        let toml = "[project]\nname = \"x\"\nbogus = 1\n";
317        assert!(Config::from_toml(toml).is_err());
318    }
319
320    #[test]
321    fn builds_scenarios() {
322        let cfg = Config::from_toml(SAMPLE).unwrap();
323        let scenarios = cfg.scenarios();
324        assert_eq!(scenarios.len(), 2);
325        assert!(scenarios.iter().any(|s| s.name == "swap_exact_in"));
326    }
327}