1use 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#[derive(Debug, Error)]
19pub enum ConfigLoadError {
20 #[error("read {path}: {source}")]
22 Read {
23 path: String,
25 #[source]
27 source: std::io::Error,
28 },
29 #[error("parse {path}: {source}")]
31 TomlParse {
32 path: String,
34 #[source]
36 source: toml::de::Error,
37 },
38 #[error("load JSON config {path}: {source}")]
40 Json {
41 path: String,
43 #[source]
45 source: crate::ReadJsonError,
46 },
47}
48
49#[derive(Debug, Error)]
51pub enum RatchetTomlEditError {
52 #[error("read {path}: {source}")]
54 Read {
55 path: String,
57 #[source]
59 source: std::io::Error,
60 },
61 #[error("parse {path}: {source}")]
63 Parse {
64 path: String,
66 #[source]
68 source: toml_edit::TomlError,
69 },
70 #[error("{0}")]
72 Malformed(String),
73 #[error("write {path}: {source}")]
75 Write {
76 path: String,
78 #[source]
80 source: std::io::Error,
81 },
82}
83
84pub 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
113pub 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
132pub 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}