Skip to main content

perfgate_types/
config.rs

1//! Configuration file helpers and re-exports.
2//!
3//! This module keeps the stable `perfgate.toml` / `perfgate.json` contract
4//! next to the receipt and schema types it configures.
5
6use crate::read_json_file;
7use std::fs;
8use std::path::Path;
9use thiserror::Error;
10use toml_edit::{DocumentMut, Item, Table, Value};
11
12pub use crate::{
13    BaselineServerConfig, BenchConfigFile, ConfigFile, DefaultsConfig, RatchetChange,
14    RatchetConfig, RatchetMode,
15};
16
17/// Error returned while loading a perfgate config file.
18#[derive(Debug, Error)]
19pub enum ConfigLoadError {
20    /// A config file could not be read from disk.
21    #[error("read {path}: {source}")]
22    Read {
23        /// Path being read.
24        path: String,
25        /// Underlying I/O error.
26        #[source]
27        source: std::io::Error,
28    },
29    /// A TOML config file could not be parsed.
30    #[error("parse {path}: {source}")]
31    TomlParse {
32        /// Path being parsed.
33        path: String,
34        /// Underlying TOML parse error.
35        #[source]
36        source: toml::de::Error,
37    },
38    /// A JSON config file could not be loaded.
39    #[error("load JSON config {path}: {source}")]
40    Json {
41        /// Path being loaded.
42        path: String,
43        /// Underlying JSON load error.
44        #[source]
45        source: crate::ReadJsonError,
46    },
47}
48
49/// Error returned while applying ratchet edits to a TOML config file.
50#[derive(Debug, Error)]
51pub enum RatchetTomlEditError {
52    /// The config file could not be read.
53    #[error("read {path}: {source}")]
54    Read {
55        /// Path being read.
56        path: String,
57        /// Underlying I/O error.
58        #[source]
59        source: std::io::Error,
60    },
61    /// The config file could not be parsed as editable TOML.
62    #[error("parse {path}: {source}")]
63    Parse {
64        /// Path being parsed.
65        path: String,
66        /// Underlying TOML parse error.
67        #[source]
68        source: toml_edit::TomlError,
69    },
70    /// The target TOML structure was malformed.
71    #[error("{0}")]
72    Malformed(String),
73    /// The updated config file could not be written.
74    #[error("write {path}: {source}")]
75    Write {
76        /// Path being written.
77        path: String,
78        /// Underlying I/O error.
79        #[source]
80        source: std::io::Error,
81    },
82}
83
84/// Loads a `perfgate.toml` or `perfgate.json` config file.
85///
86/// Returns [`ConfigFile::default`] when the path does not exist.
87pub fn load_config_file(path: &Path) -> Result<ConfigFile, ConfigLoadError> {
88    if !path.exists() {
89        return Ok(ConfigFile::default());
90    }
91
92    if path
93        .extension()
94        .and_then(|ext| ext.to_str())
95        .is_some_and(|ext| ext == "json")
96    {
97        read_json_file(path).map_err(|source| ConfigLoadError::Json {
98            path: path.display().to_string(),
99            source,
100        })
101    } else {
102        let content = fs::read_to_string(path).map_err(|source| ConfigLoadError::Read {
103            path: path.display().to_string(),
104            source,
105        })?;
106        toml::from_str::<ConfigFile>(&content).map_err(|source| ConfigLoadError::TomlParse {
107            path: path.display().to_string(),
108            source,
109        })
110    }
111}
112
113/// Preview ratchet edits as human-readable lines.
114pub fn preview_ratchet_toml_changes(changes: &[RatchetChange]) -> Vec<String> {
115    if changes.is_empty() {
116        return vec!["No ratchet changes eligible.".to_string()];
117    }
118    let mut out = Vec::with_capacity(changes.len() + 1);
119    out.push("Config updates (preview):".to_string());
120    for c in changes {
121        out.push(format!(
122            "- bench.budgets.{}.{}: {:.4} -> {:.4}",
123            c.metric.as_str(),
124            c.field,
125            c.old_value,
126            c.new_value
127        ));
128    }
129    out
130}
131
132/// Apply threshold ratchet changes to a bench section in TOML while preserving comments/order.
133pub fn apply_ratchet_toml_changes(
134    path: &Path,
135    bench_name: &str,
136    changes: &[RatchetChange],
137) -> Result<bool, RatchetTomlEditError> {
138    if changes.is_empty() {
139        return Ok(false);
140    }
141    let raw = fs::read_to_string(path).map_err(|source| RatchetTomlEditError::Read {
142        path: path.display().to_string(),
143        source,
144    })?;
145    let mut doc = raw
146        .parse::<DocumentMut>()
147        .map_err(|source| RatchetTomlEditError::Parse {
148            path: path.display().to_string(),
149            source,
150        })?;
151
152    let mut updated = false;
153    let Some(benches) = doc.get_mut("bench").and_then(Item::as_array_of_tables_mut) else {
154        return Ok(false);
155    };
156
157    for bench in benches.iter_mut() {
158        let name_matches = bench
159            .get("name")
160            .and_then(Item::as_str)
161            .is_some_and(|n| n == bench_name);
162        if !name_matches {
163            continue;
164        }
165
166        if bench.get("budgets").is_none() {
167            bench.insert("budgets", Item::Table(Table::new()));
168        }
169        let budgets = bench
170            .get_mut("budgets")
171            .and_then(Item::as_table_like_mut)
172            .ok_or_else(|| {
173                RatchetTomlEditError::Malformed(
174                    "bench.budgets is not a table-like object".to_string(),
175                )
176            })?;
177
178        for c in changes {
179            if c.field != "threshold" {
180                continue;
181            }
182            let metric_key = c.metric.as_str();
183            if !budgets.contains_key(metric_key) {
184                budgets.insert(metric_key, Item::Table(Table::new()));
185            }
186            let metric_item = budgets.get_mut(metric_key).ok_or_else(|| {
187                RatchetTomlEditError::Malformed(format!("missing budgets.{metric_key}"))
188            })?;
189            if !metric_item.is_table() {
190                *metric_item = Item::Table(Table::new());
191            }
192            let metric_table = metric_item.as_table_mut().ok_or_else(|| {
193                RatchetTomlEditError::Malformed(format!("budgets.{metric_key} is not a table"))
194            })?;
195
196            let current = metric_table
197                .get("threshold")
198                .and_then(Item::as_float)
199                .unwrap_or(c.old_value);
200            if c.new_value + f64::EPSILON < current {
201                metric_table["threshold"] = Item::Value(Value::from(c.new_value));
202                updated = true;
203            }
204        }
205        break;
206    }
207
208    if updated {
209        fs::write(path, doc.to_string()).map_err(|source| RatchetTomlEditError::Write {
210            path: path.display().to_string(),
211            source,
212        })?;
213    }
214    Ok(updated)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::Metric;
221
222    #[test]
223    fn ratchet_toml_apply_preserves_comments() {
224        let dir = tempfile::tempdir().expect("tempdir");
225        let path = dir.path().join("perfgate.toml");
226        let src = r#"# top comment
227[defaults]
228threshold = 0.2
229
230[[bench]]
231# bench comment
232name = "bench-a"
233command = ["echo", "x"]
234[bench.budgets.wall_ms]
235threshold = 0.2 # inline comment
236"#;
237        std::fs::write(&path, src).expect("write");
238        let changes = vec![RatchetChange {
239            metric: Metric::WallMs,
240            field: "threshold".to_string(),
241            old_value: 0.2,
242            new_value: 0.18,
243            reason: "test".to_string(),
244        }];
245
246        let changed = apply_ratchet_toml_changes(&path, "bench-a", &changes).expect("apply");
247        assert!(changed);
248        let updated = std::fs::read_to_string(&path).expect("read");
249        assert!(updated.contains("# top comment"));
250        assert!(updated.contains("# bench comment"));
251        assert!(updated.contains("threshold = 0.18"));
252    }
253}