1use std::fmt;
6use std::path::Path;
7use std::sync::{PoisonError, RwLock};
8
9use serde::Deserialize;
10use serde_saphyr::Budget;
11use serde_saphyr::budget::BudgetBreach;
12
13use super::{Input, InputError};
14
15pub const REPO_FILE: &str = ".ctl/input.yaml";
17
18macro_rules! limits {
19 ($( $(#[$doc:meta])* $field:ident: $ty:ty $(=> $wrap:ident)?, )*) => {
20 #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
42 #[serde(deny_unknown_fields, default)]
43 pub struct Limits {
44 $( $(#[$doc])* pub $field: Option<$ty>, )*
45 }
46
47 impl Limits {
48 pub const NONE: Self = Self { $( $field: None, )* };
50
51 #[must_use]
53 pub fn or(self, lower: &Self) -> Self {
54 Self { $( $field: self.$field.or(lower.$field), )* }
55 }
56
57 #[must_use]
59 pub fn budget(&self) -> Budget {
60 let mut budget = Budget::default();
61 $( if let Some(value) = self.$field {
62 $( let value = $wrap(value); )?
63 budget.$field = value;
64 } )*
65 budget
66 }
67
68 fn loosened(&self) -> Vec<Loosened> {
70 let default = Budget::default();
71 let mut loosened = Vec::new();
72 $( if let Some(value) = self.$field {
73 let wrapped = value;
74 $( let wrapped = $wrap(wrapped); )?
75 if wrapped.looser_than(&default.$field) {
76 loosened.push(Loosened {
77 field: stringify!($field),
78 value: value.to_string(),
79 default: default.$field.shown(),
80 });
81 }
82 } )*
83 loosened
84 }
85 }
86 };
87}
88
89limits! {
90 max_reader_input_bytes: usize => Some,
93 max_buffered_comment_events: usize,
95 simple_key_max_lookahead: usize,
97 flow_nesting_limit: usize,
99 max_events: usize,
101 max_aliases: usize,
103 max_anchors: usize,
105 max_recorded_anchor_events: usize,
107 max_recorded_anchor_bytes: usize,
109 max_property_expansion_depth: usize,
111 max_total_property_interpolation_work: usize,
113 max_depth: usize,
116 max_inclusion_depth: u32,
118 max_documents: usize,
120 max_nodes: usize,
123 max_total_scalar_bytes: usize,
125 max_total_comment_bytes: usize,
127 max_merge_keys: usize,
129 alias_anchor_min_aliases: usize,
131 alias_anchor_ratio_multiplier: usize,
133}
134
135static PROCESS: RwLock<Limits> = RwLock::new(Limits::NONE);
136static REPOSITORY: RwLock<Limits> = RwLock::new(Limits::NONE);
137
138pub fn set_default_limits(limits: Limits) {
141 *PROCESS.write().unwrap_or_else(PoisonError::into_inner) = limits;
142}
143
144pub fn use_repo_limits(root: &Path) -> Result<Vec<Loosened>, InputError> {
159 let path = root.join(REPO_FILE);
160 let limits = match std::fs::read_to_string(&path) {
161 Ok(text) => {
162 let process = PROCESS
163 .read()
164 .unwrap_or_else(PoisonError::into_inner)
165 .budget();
166 Input::new(path.display().to_string(), text)
167 .parse_within::<RepoFile>(&process, Raise::InProcess)?
168 .limits
169 }
170 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limits::NONE,
171 Err(error) => {
172 return Err(InputError::whole(
173 path.display().to_string(),
174 error.to_string(),
175 ));
176 }
177 };
178 let loosened = limits.loosened();
179 *REPOSITORY.write().unwrap_or_else(PoisonError::into_inner) = limits;
180 Ok(loosened)
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct Loosened {
186 pub field: &'static str,
188 pub value: String,
190 pub default: String,
192}
193
194impl fmt::Display for Loosened {
195 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196 write!(
197 formatter,
198 "{REPO_FILE} raises {} to {}, above the default {}",
199 self.field, self.value, self.default
200 )
201 }
202}
203
204trait Bound {
206 fn looser_than(&self, default: &Self) -> bool;
207 fn shown(&self) -> String;
208}
209
210impl Bound for usize {
211 fn looser_than(&self, default: &Self) -> bool {
212 self > default
213 }
214
215 fn shown(&self) -> String {
216 self.to_string()
217 }
218}
219
220impl Bound for u32 {
221 fn looser_than(&self, default: &Self) -> bool {
222 self > default
223 }
224
225 fn shown(&self) -> String {
226 self.to_string()
227 }
228}
229
230impl Bound for Option<usize> {
232 fn looser_than(&self, default: &Self) -> bool {
233 match (self, default) {
234 (None, Some(_)) => true,
235 (Some(value), Some(default)) => value > default,
236 (_, None) => false,
237 }
238 }
239
240 fn shown(&self) -> String {
241 self.map_or_else(|| "none".to_owned(), |value| value.to_string())
242 }
243}
244
245#[derive(Deserialize)]
247#[serde(deny_unknown_fields)]
248struct RepoFile {
249 #[serde(default)]
250 limits: Limits,
251}
252
253pub(super) fn effective(call: &Limits) -> Limits {
255 let repository = REPOSITORY.read().unwrap_or_else(PoisonError::into_inner);
256 let process = PROCESS.read().unwrap_or_else(PoisonError::into_inner);
257 call.clone().or(&repository).or(&process)
258}
259
260#[derive(Clone, Copy)]
262pub(super) enum Raise {
263 InRepository,
265 InProcess,
268}
269
270pub(super) struct Exceeded {
272 field: &'static str,
273 limit: String,
274 raise: Raise,
275}
276
277impl Exceeded {
278 pub(super) fn input_bytes(limit: usize, raise: Raise) -> Self {
279 Self::new("max_reader_input_bytes", limit, raise)
280 }
281
282 pub(super) fn new(field: &'static str, limit: usize, raise: Raise) -> Self {
284 Self {
285 field,
286 limit: limit.to_string(),
287 raise,
288 }
289 }
290
291 #[expect(
295 clippy::wildcard_enum_match_arm,
296 reason = "BudgetBreach is non_exhaustive: a variant added later keeps serde-saphyr's wording"
297 )]
298 pub(super) fn breach(breach: &BudgetBreach, budget: &Budget, raise: Raise) -> Option<Self> {
299 let (field, limit) = match breach {
300 BudgetBreach::Events { .. } => ("max_events", budget.max_events.to_string()),
301 BudgetBreach::Aliases { .. } => ("max_aliases", budget.max_aliases.to_string()),
302 BudgetBreach::Anchors { .. } => ("max_anchors", budget.max_anchors.to_string()),
303 BudgetBreach::Depth { .. } => ("max_depth", budget.max_depth.to_string()),
304 BudgetBreach::InclusionDepth { .. } => (
305 "max_inclusion_depth",
306 budget.max_inclusion_depth.to_string(),
307 ),
308 BudgetBreach::Documents { .. } => ("max_documents", budget.max_documents.to_string()),
309 BudgetBreach::Nodes { .. } => ("max_nodes", budget.max_nodes.to_string()),
310 BudgetBreach::ScalarBytes { .. } => (
311 "max_total_scalar_bytes",
312 budget.max_total_scalar_bytes.to_string(),
313 ),
314 BudgetBreach::CommentBytes { .. } => (
315 "max_total_comment_bytes",
316 budget.max_total_comment_bytes.to_string(),
317 ),
318 BudgetBreach::MergeKeys { .. } => ("max_merge_keys", budget.max_merge_keys.to_string()),
319 BudgetBreach::AliasAnchorRatio { .. } => (
320 "alias_anchor_ratio_multiplier",
321 budget.alias_anchor_ratio_multiplier.to_string(),
322 ),
323 BudgetBreach::InputBytes { .. } => (
324 "max_reader_input_bytes",
325 budget
326 .max_reader_input_bytes
327 .map_or_else(|| "none".to_owned(), |limit| limit.to_string()),
328 ),
329 BudgetBreach::RecordedAnchorEvents { .. } => (
330 "max_recorded_anchor_events",
331 budget.max_recorded_anchor_events.to_string(),
332 ),
333 BudgetBreach::RecordedAnchorBytes { .. } => (
334 "max_recorded_anchor_bytes",
335 budget.max_recorded_anchor_bytes.to_string(),
336 ),
337 BudgetBreach::PropertyExpansionDepth { .. } => (
338 "max_property_expansion_depth",
339 budget.max_property_expansion_depth.to_string(),
340 ),
341 BudgetBreach::PropertyInterpolationWork { .. } => (
342 "max_total_property_interpolation_work",
343 budget.max_total_property_interpolation_work.to_string(),
344 ),
345 _ => return None,
346 };
347 Some(Self {
348 field,
349 limit,
350 raise,
351 })
352 }
353}
354
355impl fmt::Display for Exceeded {
356 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357 write!(formatter, "exceeds {} ({}); ", self.field, self.limit)?;
358 match self.raise {
359 Raise::InRepository => {
360 write!(formatter, "raise limits.{} in {REPO_FILE}", self.field)
361 }
362 Raise::InProcess => write!(
363 formatter,
364 "raise {} in the tool's process default, which reads {REPO_FILE}",
365 self.field
366 ),
367 }
368 }
369}