1use std::path::PathBuf;
13
14use crate::apply::{schema_err, value_as_string};
15use crate::error::ValueError;
16use crate::parse::{Item, Value};
17use crate::schema::{Config, ConfigError};
18use crate::size::parse_size;
19
20const AUTO_PCT: u64 = 70;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum TierBudgetSpec {
36 Auto,
47 Percent(u8),
57 Bytes(u64),
67}
68
69impl TierBudgetSpec {
70 pub fn parse(s: &str) -> Result<Self, ValueError> {
80 let t = s.trim();
81 if t.eq_ignore_ascii_case("auto") {
82 return Ok(Self::Auto);
83 }
84 if let Some(pct) = t.strip_suffix('%') {
85 let p: u64 = pct.trim().parse().map_err(|_| {
86 ValueError::new(format!("tiering budget percent {s:?} is not a number"))
87 })?;
88 if p == 0 || p > 100 {
89 return Err(ValueError::new(format!(
90 "tiering budget percent {s:?} must be 1..=100"
91 )));
92 }
93 return Ok(Self::Percent(p as u8));
94 }
95 parse_size(t).map(Self::Bytes).map_err(|e| ValueError::new(format!("tiering budget: {e}")))
96 }
97
98 pub fn resolve_with(self, detected_bound: Option<u64>) -> Option<u64> {
111 match self {
112 Self::Bytes(b) => Some(b),
113 Self::Auto => detected_bound.map(|d| d / 100 * AUTO_PCT),
114 Self::Percent(p) => detected_bound.map(|d| d / 100 * u64::from(p)),
115 }
116 }
117
118 pub fn to_config_string(self) -> String {
126 match self {
127 Self::Auto => "auto".to_string(),
128 Self::Percent(p) => format!("{p}%"),
129 Self::Bytes(b) => b.to_string(),
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
146#[non_exhaustive]
147pub struct TieringSection {
148 pub budget: Option<TierBudgetSpec>,
158 pub spill_dir: Option<PathBuf>,
170}
171
172impl Config {
173 pub(crate) fn apply_tiering(&mut self, item: &Item) -> Result<(), ConfigError> {
175 match item.key.as_str() {
176 "budget" => self.tiering.budget = Some(budget_from_item(item)?),
177 "spill_dir" => {
178 self.tiering.spill_dir = Some(PathBuf::from(value_as_string(item)?));
179 self.tiering.budget.get_or_insert(TierBudgetSpec::Auto);
181 }
182 k => return Err(schema_err(item, format!("unknown [tiering] key: {k}"))),
183 }
184 Ok(())
185 }
186
187 pub(crate) fn apply_env_tier_budget(&mut self, value: &str) -> Result<(), ConfigError> {
190 self.tiering.budget =
191 Some(TierBudgetSpec::parse(value).map_err(|e| ConfigError::Schema {
192 line: 0,
193 field: "[env] KEVY_TIER_BUDGET".into(),
194 msg: e.to_string(),
195 })?);
196 Ok(())
197 }
198}
199
200fn budget_from_item(item: &Item) -> Result<TierBudgetSpec, ConfigError> {
203 match &item.value {
204 Value::Int(n) => u64::try_from(*n)
205 .map(TierBudgetSpec::Bytes)
206 .map_err(|_| schema_err(item, format!("tiering budget {n} must be non-negative"))),
207 Value::Str(s) => TierBudgetSpec::parse(s).map_err(|e| schema_err(item, e.to_string())),
208 other @ (Value::Bool(_) | Value::Arr(_)) => Err(schema_err(
209 item,
210 format!("expected \"auto\" | \"70%\" | \"4gb\" | bytes, got {other:?}"),
211 )),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn parses_all_three_forms() {
221 assert_eq!(TierBudgetSpec::parse("auto").unwrap(), TierBudgetSpec::Auto);
222 assert_eq!(TierBudgetSpec::parse("AUTO").unwrap(), TierBudgetSpec::Auto);
223 assert_eq!(TierBudgetSpec::parse("70%").unwrap(), TierBudgetSpec::Percent(70));
224 assert_eq!(TierBudgetSpec::parse(" 5 %").unwrap(), TierBudgetSpec::Percent(5));
225 assert_eq!(
226 TierBudgetSpec::parse("4gb").unwrap(),
227 TierBudgetSpec::Bytes(4 * 1024 * 1024 * 1024)
228 );
229 assert_eq!(TierBudgetSpec::parse("1048576").unwrap(), TierBudgetSpec::Bytes(1 << 20));
230 }
231
232 #[test]
233 fn garbage_rejected_by_name() {
234 for bad in ["", "yes", "0%", "101%", "x%", "12qb"] {
235 let e = TierBudgetSpec::parse(bad).unwrap_err().to_string();
236 assert!(e.contains("tiering budget"), "{bad:?}: {e}");
237 }
238 }
239
240 #[test]
241 fn resolution_math() {
242 assert_eq!(TierBudgetSpec::Bytes(42).resolve_with(None), Some(42));
243 assert_eq!(TierBudgetSpec::Auto.resolve_with(Some(10_000_000_000)), Some(7_000_000_000));
244 assert_eq!(
245 TierBudgetSpec::Percent(50).resolve_with(Some(8_000_000_000)),
246 Some(4_000_000_000)
247 );
248 assert_eq!(TierBudgetSpec::Auto.resolve_with(None), None);
249 assert_eq!(TierBudgetSpec::Percent(30).resolve_with(None), None);
250 }
251
252 #[test]
253 fn toml_env_cli_parse_all_three_forms() {
254 for (text, want) in [
255 ("[tiering]\nbudget = \"auto\"\n", TierBudgetSpec::Auto),
256 ("[tiering]\nbudget = \"70%\"\n", TierBudgetSpec::Percent(70)),
257 ("[tiering]\nbudget = \"4gb\"\n", TierBudgetSpec::Bytes(4 << 30)),
258 ("[tiering]\nbudget = 1048576\n", TierBudgetSpec::Bytes(1 << 20)),
259 ] {
260 let cfg = Config::from_toml_str(text, None).expect(text);
261 assert_eq!(cfg.tiering.budget, Some(want), "{text}");
262 }
263 for (val, want) in [
265 ("134217728", TierBudgetSpec::Bytes(128 << 20)),
266 ("auto", TierBudgetSpec::Auto),
267 ("35%", TierBudgetSpec::Percent(35)),
268 ] {
269 let mut cfg = Config::default();
270 cfg.merge_env([("KEVY_TIER_BUDGET", val)]).expect(val);
271 assert_eq!(cfg.tiering.budget, Some(want), "{val}");
272 }
273 let mut cfg = Config::default();
275 cfg.merge_cli(crate::CliOverrides {
276 tiering_budget: Some(TierBudgetSpec::Percent(25)),
277 ..crate::CliOverrides::default()
278 })
279 .unwrap();
280 assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Percent(25)));
281 }
282
283 #[test]
284 fn toml_garbage_budget_rejected_by_name() {
285 let e = Config::from_toml_str("[tiering]\nbudget = \"lots\"\n", None).unwrap_err();
286 assert!(format!("{e}").contains("tiering budget"), "{e}");
287 let mut cfg = Config::default();
288 let e = cfg.merge_env([("KEVY_TIER_BUDGET", "0%")]).unwrap_err();
289 assert!(format!("{e}").contains("1..=100"), "{e}");
290 }
291
292 #[test]
293 fn spill_dir_alone_implies_auto_budget() {
294 let cfg =
295 Config::from_toml_str("[tiering]\nspill_dir = \"/fast/nvme/tier\"\n", None).unwrap();
296 assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Auto));
297 assert_eq!(cfg.tiering.spill_dir.as_deref(), Some(std::path::Path::new("/fast/nvme/tier")));
298 }
299
300 #[test]
301 fn emit_round_trips_the_section_and_its_absence() {
302 let off = Config::default();
304 let re = Config::from_toml_str(&off.to_toml_string(), None).unwrap();
305 assert_eq!(re.tiering, TieringSection::default());
306 let mut on = Config::default();
308 on.tiering.budget = Some(TierBudgetSpec::Percent(60));
309 on.tiering.spill_dir = Some(PathBuf::from("/mnt/tier"));
310 let re = Config::from_toml_str(&on.to_toml_string(), None).unwrap();
311 assert_eq!(re.tiering, on.tiering);
312 for spec in [TierBudgetSpec::Auto, TierBudgetSpec::Bytes(4 << 30)] {
313 let mut c = Config::default();
314 c.tiering.budget = Some(spec);
315 let re = Config::from_toml_str(&c.to_toml_string(), None).unwrap();
316 assert_eq!(re.tiering.budget, Some(spec));
317 }
318 }
319
320 #[test]
321 fn canonical_string_round_trips() {
322 for spec in [
323 TierBudgetSpec::Auto,
324 TierBudgetSpec::Percent(35),
325 TierBudgetSpec::Bytes(4 * 1024 * 1024 * 1024),
326 ] {
327 let text = spec.to_config_string();
328 assert_eq!(TierBudgetSpec::parse(&text).unwrap(), spec, "{text}");
329 }
330 }
331}