1use std::fmt;
2use std::io::{self, Write};
3use std::time::{Duration, Instant};
4
5use code_system_graph_model::stable_id;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10pub const EXTRACTION_CONTRACT_VERSION: &str = "1.0.0";
12
13pub const DEFAULT_MAX_INPUT_BYTES_PER_ARTIFACT: u64 = 8_388_608;
15pub const DEFAULT_MAX_STRUCTURAL_DEPTH_PER_ARTIFACT: u64 = 64;
17pub const DEFAULT_MAX_AST_DEPTH_PER_ARTIFACT: u64 = 256;
19pub const DEFAULT_MAX_WORK_UNITS_PER_ARTIFACT: u64 = 100_000;
21pub const DEFAULT_MAX_TREE_SITTER_NODES_PER_ARTIFACT: u64 = 500_000;
23pub const DEFAULT_MAX_OBSERVATIONS_PER_ARTIFACT: u64 = 100_000;
25pub const DEFAULT_MAX_ACCUMULATED_STRING_BYTES_PER_ARTIFACT: u64 = 33_554_432;
27pub const DEFAULT_MAX_SERIALIZED_OUTPUT_BYTES_PER_ARTIFACT: u64 = 33_554_432;
29pub const DEFAULT_MAX_STRING_BYTES_PER_VALUE: u64 = 65_536;
31pub const DEFAULT_MAX_PORTABLE_PATH_BYTES_PER_VALUE: u64 = 4_096;
33pub const DEFAULT_MAX_IDENTIFIER_BYTES_PER_VALUE: u64 = 1_024;
35pub const DEFAULT_MAX_STRUCTURED_WALL_TIME_MS_PER_ARTIFACT: u64 = 5_000;
37pub const DEFAULT_MAX_TREE_SITTER_WALL_TIME_MS_PER_ARTIFACT: u64 = 10_000;
39
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43pub struct ExtractionBudgetOverrides {
44 pub max_input_bytes_per_artifact: Option<u64>,
46 pub max_structural_depth_per_artifact: Option<u64>,
48 pub max_ast_depth_per_artifact: Option<u64>,
50 pub max_work_units_per_artifact: Option<u64>,
52 pub max_tree_sitter_nodes_per_artifact: Option<u64>,
54 pub max_observations_per_artifact: Option<u64>,
56 pub max_accumulated_string_bytes_per_artifact: Option<u64>,
58 pub max_serialized_output_bytes_per_artifact: Option<u64>,
60 pub max_string_bytes_per_value: Option<u64>,
62 pub max_portable_path_bytes_per_value: Option<u64>,
64 pub max_identifier_bytes_per_value: Option<u64>,
66 pub max_structured_wall_time_ms_per_artifact: Option<u64>,
68 pub max_tree_sitter_wall_time_ms_per_artifact: Option<u64>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(rename_all = "camelCase")]
75pub struct ExtractionBudgets {
76 pub max_input_bytes_per_artifact: u64,
78 pub max_structural_depth_per_artifact: u64,
80 pub max_ast_depth_per_artifact: u64,
82 pub max_work_units_per_artifact: u64,
84 pub max_tree_sitter_nodes_per_artifact: u64,
86 pub max_observations_per_artifact: u64,
88 pub max_accumulated_string_bytes_per_artifact: u64,
90 pub max_serialized_output_bytes_per_artifact: u64,
92 pub max_string_bytes_per_value: u64,
94 pub max_portable_path_bytes_per_value: u64,
96 pub max_identifier_bytes_per_value: u64,
98 pub max_structured_wall_time_ms_per_artifact: u64,
100 pub max_tree_sitter_wall_time_ms_per_artifact: u64,
102}
103
104impl Default for ExtractionBudgets {
105 fn default() -> Self {
106 Self {
107 max_input_bytes_per_artifact: DEFAULT_MAX_INPUT_BYTES_PER_ARTIFACT,
108 max_structural_depth_per_artifact: DEFAULT_MAX_STRUCTURAL_DEPTH_PER_ARTIFACT,
109 max_ast_depth_per_artifact: DEFAULT_MAX_AST_DEPTH_PER_ARTIFACT,
110 max_work_units_per_artifact: DEFAULT_MAX_WORK_UNITS_PER_ARTIFACT,
111 max_tree_sitter_nodes_per_artifact: DEFAULT_MAX_TREE_SITTER_NODES_PER_ARTIFACT,
112 max_observations_per_artifact: DEFAULT_MAX_OBSERVATIONS_PER_ARTIFACT,
113 max_accumulated_string_bytes_per_artifact:
114 DEFAULT_MAX_ACCUMULATED_STRING_BYTES_PER_ARTIFACT,
115 max_serialized_output_bytes_per_artifact:
116 DEFAULT_MAX_SERIALIZED_OUTPUT_BYTES_PER_ARTIFACT,
117 max_string_bytes_per_value: DEFAULT_MAX_STRING_BYTES_PER_VALUE,
118 max_portable_path_bytes_per_value: DEFAULT_MAX_PORTABLE_PATH_BYTES_PER_VALUE,
119 max_identifier_bytes_per_value: DEFAULT_MAX_IDENTIFIER_BYTES_PER_VALUE,
120 max_structured_wall_time_ms_per_artifact:
121 DEFAULT_MAX_STRUCTURED_WALL_TIME_MS_PER_ARTIFACT,
122 max_tree_sitter_wall_time_ms_per_artifact:
123 DEFAULT_MAX_TREE_SITTER_WALL_TIME_MS_PER_ARTIFACT,
124 }
125 }
126}
127
128impl ExtractionBudgets {
129 pub fn resolve(
136 overrides: Option<&ExtractionBudgetOverrides>,
137 ) -> Result<Self, InvalidExtractionBudget> {
138 let mut budgets = Self::default();
139 if let Some(values) = overrides {
140 macro_rules! apply {
141 ($field:ident) => {
142 if let Some(value) = values.$field {
143 budgets.$field = value;
144 }
145 };
146 }
147 apply!(max_input_bytes_per_artifact);
148 apply!(max_structural_depth_per_artifact);
149 apply!(max_ast_depth_per_artifact);
150 apply!(max_work_units_per_artifact);
151 apply!(max_tree_sitter_nodes_per_artifact);
152 apply!(max_observations_per_artifact);
153 apply!(max_accumulated_string_bytes_per_artifact);
154 apply!(max_serialized_output_bytes_per_artifact);
155 apply!(max_string_bytes_per_value);
156 apply!(max_portable_path_bytes_per_value);
157 apply!(max_identifier_bytes_per_value);
158 apply!(max_structured_wall_time_ms_per_artifact);
159 apply!(max_tree_sitter_wall_time_ms_per_artifact);
160 }
161 budgets.validate()?;
162 Ok(budgets)
163 }
164
165 fn validate(&self) -> Result<(), InvalidExtractionBudget> {
166 for (field, value) in self.canonical_values() {
167 if value == 0 {
168 return Err(InvalidExtractionBudget { field, value });
169 }
170 if field.contains("Bytes") {
171 usize::try_from(value).map_err(|_| InvalidExtractionBudget { field, value })?;
172 }
173 }
174 Ok(())
175 }
176
177 fn canonical_values(&self) -> [(&'static str, u64); 13] {
178 [
179 (
180 "maxInputBytesPerArtifact",
181 self.max_input_bytes_per_artifact,
182 ),
183 (
184 "maxStructuralDepthPerArtifact",
185 self.max_structural_depth_per_artifact,
186 ),
187 ("maxAstDepthPerArtifact", self.max_ast_depth_per_artifact),
188 ("maxWorkUnitsPerArtifact", self.max_work_units_per_artifact),
189 (
190 "maxTreeSitterNodesPerArtifact",
191 self.max_tree_sitter_nodes_per_artifact,
192 ),
193 (
194 "maxObservationsPerArtifact",
195 self.max_observations_per_artifact,
196 ),
197 (
198 "maxAccumulatedStringBytesPerArtifact",
199 self.max_accumulated_string_bytes_per_artifact,
200 ),
201 (
202 "maxSerializedOutputBytesPerArtifact",
203 self.max_serialized_output_bytes_per_artifact,
204 ),
205 ("maxStringBytesPerValue", self.max_string_bytes_per_value),
206 (
207 "maxPortablePathBytesPerValue",
208 self.max_portable_path_bytes_per_value,
209 ),
210 (
211 "maxIdentifierBytesPerValue",
212 self.max_identifier_bytes_per_value,
213 ),
214 (
215 "maxStructuredWallTimeMsPerArtifact",
216 self.max_structured_wall_time_ms_per_artifact,
217 ),
218 (
219 "maxTreeSitterWallTimeMsPerArtifact",
220 self.max_tree_sitter_wall_time_ms_per_artifact,
221 ),
222 ]
223 }
224
225 #[must_use]
227 pub fn fingerprint(&self) -> String {
228 let canonical = self
229 .canonical_values()
230 .into_iter()
231 .map(|(name, value)| format!("{name}={value}"))
232 .collect::<Vec<_>>()
233 .join(";");
234 stable_id("extraction-budgets", &canonical)
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Error)]
240#[error("extraction budget `{field}` must be positive and representable; received {value}")]
241pub struct InvalidExtractionBudget {
242 pub field: &'static str,
244 pub value: u64,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
250#[serde(rename_all = "snake_case")]
251pub enum ExtractionResource {
252 InputBytes,
254 StructuralDepth,
256 AstDepth,
258 WorkUnits,
260 TreeSitterNodes,
262 Observations,
264 AccumulatedStringBytes,
266 SerializedOutputBytes,
268 StringBytesPerValue,
270 PortablePathBytesPerValue,
272 IdentifierBytesPerValue,
274 StructuredWallTimeMs,
276 TreeSitterWallTimeMs,
278}
279
280impl fmt::Display for ExtractionResource {
281 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282 let name = serde_json::to_string(self).map_err(|_| fmt::Error)?;
283 formatter.write_str(name.trim_matches('"'))
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Error)]
289#[error(
290 "extraction limit exceeded for `{extractor}` at `{artifact}`: {resource} observed {observed}, maximum {maximum}"
291)]
292pub struct ExtractionLimitExceeded {
293 pub artifact: String,
295 pub extractor: String,
297 pub resource: ExtractionResource,
299 pub observed: u64,
301 pub maximum: u64,
303}
304
305pub trait ExtractionClock: Send {
307 fn elapsed(&self) -> Duration;
309}
310
311struct SystemClock(Instant);
312
313impl ExtractionClock for SystemClock {
314 fn elapsed(&self) -> Duration {
315 self.0.elapsed()
316 }
317}
318
319pub struct ExtractionTracker {
321 budgets: ExtractionBudgets,
322 artifact: String,
323 extractor: String,
324 work_units: u64,
325 tree_sitter_nodes: u64,
326 observations: u64,
327 accumulated_string_bytes: u64,
328 sampled_units: u64,
329 clock: Box<dyn ExtractionClock>,
330}
331
332impl fmt::Debug for ExtractionTracker {
333 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
334 formatter
335 .debug_struct("ExtractionTracker")
336 .field("budgets", &self.budgets)
337 .field("artifact", &self.artifact)
338 .field("extractor", &self.extractor)
339 .field("work_units", &self.work_units)
340 .field("tree_sitter_nodes", &self.tree_sitter_nodes)
341 .field("observations", &self.observations)
342 .field("accumulated_string_bytes", &self.accumulated_string_bytes)
343 .finish_non_exhaustive()
344 }
345}
346
347impl ExtractionTracker {
348 #[must_use]
350 pub fn new(
351 artifact: impl Into<String>,
352 extractor: impl Into<String>,
353 budgets: &ExtractionBudgets,
354 ) -> Self {
355 Self {
356 budgets: budgets.clone(),
357 artifact: artifact.into(),
358 extractor: extractor.into(),
359 work_units: 0,
360 tree_sitter_nodes: 0,
361 observations: 0,
362 accumulated_string_bytes: 0,
363 sampled_units: 0,
364 clock: Box::new(SystemClock(Instant::now())),
365 }
366 }
367
368 #[must_use]
370 pub fn with_clock(
371 artifact: impl Into<String>,
372 extractor: impl Into<String>,
373 budgets: &ExtractionBudgets,
374 clock: Box<dyn ExtractionClock>,
375 ) -> Self {
376 let mut tracker = Self::new(artifact, extractor, budgets);
377 tracker.clock = clock;
378 tracker
379 }
380
381 #[must_use]
383 pub fn budgets(&self) -> &ExtractionBudgets {
384 &self.budgets
385 }
386
387 pub fn check_input_bytes(&self, observed: u64) -> Result<(), ExtractionLimitExceeded> {
393 if observed > self.budgets.max_input_bytes_per_artifact {
394 return Err(self.exceeded(
395 ExtractionResource::InputBytes,
396 observed,
397 self.budgets.max_input_bytes_per_artifact,
398 ));
399 }
400 Ok(())
401 }
402
403 pub fn check_string_bytes(&self, observed: u64) -> Result<(), ExtractionLimitExceeded> {
412 self.check_value_bytes(
413 observed,
414 self.budgets.max_string_bytes_per_value,
415 ExtractionResource::StringBytesPerValue,
416 )
417 }
418
419 pub fn check_identifier_bytes(&self, observed: u64) -> Result<(), ExtractionLimitExceeded> {
425 self.check_value_bytes(
426 observed,
427 self.budgets.max_identifier_bytes_per_value,
428 ExtractionResource::IdentifierBytesPerValue,
429 )
430 }
431
432 pub fn check_accumulated_string_bytes(
439 &self,
440 observed: u64,
441 ) -> Result<(), ExtractionLimitExceeded> {
442 self.check_value_bytes(
443 observed,
444 self.budgets.max_accumulated_string_bytes_per_artifact,
445 ExtractionResource::AccumulatedStringBytes,
446 )
447 }
448
449 fn check_value_bytes(
450 &self,
451 observed: u64,
452 maximum: u64,
453 resource: ExtractionResource,
454 ) -> Result<(), ExtractionLimitExceeded> {
455 if observed > maximum {
456 return Err(self.exceeded(resource, observed, maximum));
457 }
458 Ok(())
459 }
460
461 fn exceeded(
462 &self,
463 resource: ExtractionResource,
464 observed: u64,
465 maximum: u64,
466 ) -> ExtractionLimitExceeded {
467 ExtractionLimitExceeded {
468 artifact: self.artifact.clone(),
469 extractor: self.extractor.clone(),
470 resource,
471 observed,
472 maximum,
473 }
474 }
475
476 fn charge_counter(
477 &self,
478 current: u64,
479 amount: u64,
480 maximum: u64,
481 resource: ExtractionResource,
482 ) -> Result<u64, ExtractionLimitExceeded> {
483 let observed = current
484 .checked_add(amount)
485 .ok_or_else(|| self.exceeded(resource, u64::MAX, maximum))?;
486 if observed > maximum {
487 return Err(self.exceeded(resource, observed, maximum));
488 }
489 Ok(observed)
490 }
491
492 pub fn charge_work(&mut self, amount: u64) -> Result<(), ExtractionLimitExceeded> {
499 self.work_units = self.charge_counter(
500 self.work_units,
501 amount,
502 self.budgets.max_work_units_per_artifact,
503 ExtractionResource::WorkUnits,
504 )?;
505 self.sample_time(false)
506 }
507
508 pub fn check_work_units(&self, observed: u64) -> Result<(), ExtractionLimitExceeded> {
514 if observed > self.budgets.max_work_units_per_artifact {
515 return Err(self.exceeded(
516 ExtractionResource::WorkUnits,
517 observed,
518 self.budgets.max_work_units_per_artifact,
519 ));
520 }
521 Ok(())
522 }
523
524 pub fn charge_tree_sitter_node(&mut self, depth: u64) -> Result<(), ExtractionLimitExceeded> {
531 self.check_ast_depth(depth)?;
532 self.tree_sitter_nodes = self.charge_counter(
533 self.tree_sitter_nodes,
534 1,
535 self.budgets.max_tree_sitter_nodes_per_artifact,
536 ExtractionResource::TreeSitterNodes,
537 )?;
538 self.sample_time(true)
539 }
540
541 pub fn charge_observation(&mut self, amount: u64) -> Result<(), ExtractionLimitExceeded> {
547 self.observations = self.charge_counter(
548 self.observations,
549 amount,
550 self.budgets.max_observations_per_artifact,
551 ExtractionResource::Observations,
552 )?;
553 Ok(())
554 }
555
556 pub fn check_observations(&self, observed: u64) -> Result<(), ExtractionLimitExceeded> {
562 if observed > self.budgets.max_observations_per_artifact {
563 return Err(self.exceeded(
564 ExtractionResource::Observations,
565 observed,
566 self.budgets.max_observations_per_artifact,
567 ));
568 }
569 Ok(())
570 }
571
572 pub fn ensure_observations(&mut self, minimum: u64) -> Result<(), ExtractionLimitExceeded> {
578 if self.observations < minimum {
579 self.charge_observation(minimum - self.observations)?;
580 }
581 Ok(())
582 }
583
584 pub fn charge_string(&mut self, value: &str) -> Result<(), ExtractionLimitExceeded> {
590 self.charge_value(
591 value,
592 self.budgets.max_string_bytes_per_value,
593 ExtractionResource::StringBytesPerValue,
594 )
595 }
596
597 pub fn charge_portable_path(&mut self, value: &str) -> Result<(), ExtractionLimitExceeded> {
603 self.charge_value(
604 value,
605 self.budgets.max_portable_path_bytes_per_value,
606 ExtractionResource::PortablePathBytesPerValue,
607 )
608 }
609
610 pub fn charge_identifier(&mut self, value: &str) -> Result<(), ExtractionLimitExceeded> {
617 self.charge_value(
618 value,
619 self.budgets.max_identifier_bytes_per_value,
620 ExtractionResource::IdentifierBytesPerValue,
621 )
622 }
623
624 pub fn charge_identifier_bytes(&mut self, bytes: u64) -> Result<(), ExtractionLimitExceeded> {
631 if bytes > self.budgets.max_identifier_bytes_per_value {
632 return Err(self.exceeded(
633 ExtractionResource::IdentifierBytesPerValue,
634 bytes,
635 self.budgets.max_identifier_bytes_per_value,
636 ));
637 }
638 self.accumulated_string_bytes = self.charge_counter(
639 self.accumulated_string_bytes,
640 bytes,
641 self.budgets.max_accumulated_string_bytes_per_artifact,
642 ExtractionResource::AccumulatedStringBytes,
643 )?;
644 Ok(())
645 }
646
647 fn charge_value(
648 &mut self,
649 value: &str,
650 maximum: u64,
651 resource: ExtractionResource,
652 ) -> Result<(), ExtractionLimitExceeded> {
653 let bytes = u64::try_from(value.len()).unwrap_or(u64::MAX);
654 if bytes > maximum {
655 return Err(self.exceeded(resource, bytes, maximum));
656 }
657 self.accumulated_string_bytes = self.charge_counter(
658 self.accumulated_string_bytes,
659 bytes,
660 self.budgets.max_accumulated_string_bytes_per_artifact,
661 ExtractionResource::AccumulatedStringBytes,
662 )?;
663 Ok(())
664 }
665
666 pub fn check_structural_depth(&self, depth: u64) -> Result<(), ExtractionLimitExceeded> {
672 if depth > self.budgets.max_structural_depth_per_artifact {
673 return Err(self.exceeded(
674 ExtractionResource::StructuralDepth,
675 depth,
676 self.budgets.max_structural_depth_per_artifact,
677 ));
678 }
679 Ok(())
680 }
681
682 pub fn check_ast_depth(&self, depth: u64) -> Result<(), ExtractionLimitExceeded> {
688 if depth > self.budgets.max_ast_depth_per_artifact {
689 return Err(self.exceeded(
690 ExtractionResource::AstDepth,
691 depth,
692 self.budgets.max_ast_depth_per_artifact,
693 ));
694 }
695 Ok(())
696 }
697
698 pub fn check_structured_time(&self) -> Result<(), ExtractionLimitExceeded> {
704 self.check_elapsed(false)
705 }
706
707 pub fn check_tree_sitter_time(&self) -> Result<(), ExtractionLimitExceeded> {
713 self.check_elapsed(true)
714 }
715
716 fn sample_time(&mut self, tree_sitter: bool) -> Result<(), ExtractionLimitExceeded> {
717 let units = self.work_units.saturating_add(self.tree_sitter_nodes);
718 if (self.sampled_units == 0 && units > 0) || units / 1_024 > self.sampled_units / 1_024 {
719 self.sampled_units = units;
720 self.check_elapsed(tree_sitter)?;
721 }
722 Ok(())
723 }
724
725 fn check_elapsed(&self, tree_sitter: bool) -> Result<(), ExtractionLimitExceeded> {
726 let observed = u64::try_from(self.clock.elapsed().as_millis()).unwrap_or(u64::MAX);
727 let (resource, maximum) = if tree_sitter {
728 (
729 ExtractionResource::TreeSitterWallTimeMs,
730 self.budgets.max_tree_sitter_wall_time_ms_per_artifact,
731 )
732 } else {
733 (
734 ExtractionResource::StructuredWallTimeMs,
735 self.budgets.max_structured_wall_time_ms_per_artifact,
736 )
737 };
738 if observed > maximum {
739 return Err(self.exceeded(resource, observed, maximum));
740 }
741 Ok(())
742 }
743
744 #[must_use]
746 pub fn bounded_json_writer(&self) -> BoundedJsonWriter {
747 BoundedJsonWriter {
748 bytes: Vec::new(),
749 maximum: self.budgets.max_serialized_output_bytes_per_artifact,
750 exceeded: None,
751 }
752 }
753
754 #[must_use]
756 pub fn output_limit_error(
757 &self,
758 writer: &BoundedJsonWriter,
759 ) -> Option<ExtractionLimitExceeded> {
760 writer.exceeded.map(|observed| {
761 self.exceeded(
762 ExtractionResource::SerializedOutputBytes,
763 observed,
764 self.budgets.max_serialized_output_bytes_per_artifact,
765 )
766 })
767 }
768}
769
770#[derive(Debug)]
772pub struct BoundedJsonWriter {
773 bytes: Vec<u8>,
774 maximum: u64,
775 exceeded: Option<u64>,
776}
777
778impl BoundedJsonWriter {
779 #[must_use]
781 pub fn into_inner(self) -> Vec<u8> {
782 self.bytes
783 }
784}
785
786impl Write for BoundedJsonWriter {
787 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
788 let current = u64::try_from(self.bytes.len()).unwrap_or(u64::MAX);
789 let amount = u64::try_from(buffer.len()).unwrap_or(u64::MAX);
790 let observed = current.saturating_add(amount);
791 if observed > self.maximum {
792 self.exceeded = Some(observed);
793 return Err(io::Error::other(
794 "serialized extraction output exceeds its limit",
795 ));
796 }
797 self.bytes.extend_from_slice(buffer);
798 Ok(buffer.len())
799 }
800
801 fn flush(&mut self) -> io::Result<()> {
802 Ok(())
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[derive(Debug)]
811 struct FixedClock(Duration);
812
813 impl ExtractionClock for FixedClock {
814 fn elapsed(&self) -> Duration {
815 self.0
816 }
817 }
818
819 fn budgets_with_small_limits() -> ExtractionBudgets {
820 ExtractionBudgets {
821 max_input_bytes_per_artifact: 2,
822 max_structural_depth_per_artifact: 2,
823 max_ast_depth_per_artifact: 2,
824 max_work_units_per_artifact: 2,
825 max_tree_sitter_nodes_per_artifact: 2,
826 max_observations_per_artifact: 2,
827 max_accumulated_string_bytes_per_artifact: 2,
828 max_serialized_output_bytes_per_artifact: 2,
829 max_string_bytes_per_value: 2,
830 max_portable_path_bytes_per_value: 2,
831 max_identifier_bytes_per_value: 2,
832 max_structured_wall_time_ms_per_artifact: 2,
833 max_tree_sitter_wall_time_ms_per_artifact: 2,
834 }
835 }
836
837 #[test]
838 fn effective_fingerprint_should_ignore_absent_vs_explicit_defaults() {
839 let defaults = ExtractionBudgets::default();
840 let explicit = ExtractionBudgets::resolve(Some(&ExtractionBudgetOverrides {
841 max_input_bytes_per_artifact: Some(DEFAULT_MAX_INPUT_BYTES_PER_ARTIFACT),
842 ..ExtractionBudgetOverrides::default()
843 }))
844 .expect("default override should be valid");
845
846 assert_eq!(defaults.fingerprint(), explicit.fingerprint());
847 }
848
849 #[test]
850 fn tracker_should_accept_exact_work_limit_and_reject_one_above() {
851 let budgets = ExtractionBudgets::resolve(Some(&ExtractionBudgetOverrides {
852 max_work_units_per_artifact: Some(2),
853 ..ExtractionBudgetOverrides::default()
854 }))
855 .expect("override should be valid");
856 let mut tracker = ExtractionTracker::new("a.graphql", "graphql", &budgets);
857
858 assert!(tracker.charge_work(2).is_ok());
859 assert!(matches!(
860 tracker.charge_work(1),
861 Err(ExtractionLimitExceeded {
862 resource: ExtractionResource::WorkUnits,
863 observed: 3,
864 maximum: 2,
865 ..
866 })
867 ));
868 }
869
870 #[test]
871 fn direct_size_and_depth_checks_should_accept_below_and_exact_but_reject_above() {
872 let tracker = ExtractionTracker::new("a", "structured", &budgets_with_small_limits());
873 for check in [
874 ExtractionTracker::check_input_bytes,
875 ExtractionTracker::check_structural_depth,
876 ExtractionTracker::check_ast_depth,
877 ] {
878 assert!(check(&tracker, 1).is_ok());
879 assert!(check(&tracker, 2).is_ok());
880 assert!(check(&tracker, 3).is_err());
881 }
882 }
883
884 #[test]
885 fn cumulative_counters_should_charge_before_accepting_materialization() {
886 let budgets = budgets_with_small_limits();
887
888 let mut work = ExtractionTracker::new("a", "work", &budgets);
889 assert!(work.charge_work(1).is_ok());
890 assert!(work.charge_work(1).is_ok());
891 assert!(matches!(
892 work.charge_work(1),
893 Err(ExtractionLimitExceeded {
894 resource: ExtractionResource::WorkUnits,
895 observed: 3,
896 maximum: 2,
897 ..
898 })
899 ));
900
901 let mut nodes = ExtractionTracker::new("a", "tree-sitter", &budgets);
902 assert!(nodes.charge_tree_sitter_node(1).is_ok());
903 assert!(nodes.charge_tree_sitter_node(2).is_ok());
904 assert!(matches!(
905 nodes.charge_tree_sitter_node(2),
906 Err(ExtractionLimitExceeded {
907 resource: ExtractionResource::TreeSitterNodes,
908 observed: 3,
909 maximum: 2,
910 ..
911 })
912 ));
913
914 let mut observations = ExtractionTracker::new("a", "facts", &budgets);
915 assert!(observations.charge_observation(1).is_ok());
916 assert!(observations.charge_observation(1).is_ok());
917 assert!(matches!(
918 observations.charge_observation(1),
919 Err(ExtractionLimitExceeded {
920 resource: ExtractionResource::Observations,
921 observed: 3,
922 maximum: 2,
923 ..
924 })
925 ));
926
927 let mut strings = ExtractionTracker::new("a", "strings", &budgets);
928 assert!(strings.charge_string("a").is_ok());
929 assert!(strings.charge_string("b").is_ok());
930 assert!(matches!(
931 strings.charge_string("c"),
932 Err(ExtractionLimitExceeded {
933 resource: ExtractionResource::AccumulatedStringBytes,
934 observed: 3,
935 maximum: 2,
936 ..
937 })
938 ));
939 }
940
941 #[test]
942 fn per_value_limits_should_accept_below_and_exact_but_reject_above() {
943 let budgets = budgets_with_small_limits();
944 let checks = [
945 ExtractionTracker::charge_string,
946 ExtractionTracker::charge_portable_path,
947 ExtractionTracker::charge_identifier,
948 ];
949 for check in checks {
950 assert!(check(&mut ExtractionTracker::new("a", "value", &budgets), "a").is_ok());
951 assert!(check(&mut ExtractionTracker::new("a", "value", &budgets), "ab").is_ok());
952 assert!(check(&mut ExtractionTracker::new("a", "value", &budgets), "abc").is_err());
953 }
954 }
955
956 #[test]
957 fn bounded_writer_should_never_retain_a_byte_above_the_limit() {
958 let tracker = ExtractionTracker::new("a", "json", &budgets_with_small_limits());
959 let mut writer = tracker.bounded_json_writer();
960 assert!(writer.write_all(b"a").is_ok());
961 assert!(writer.write_all(b"b").is_ok());
962 assert!(writer.write_all(b"c").is_err());
963 assert!(matches!(
964 tracker.output_limit_error(&writer),
965 Some(ExtractionLimitExceeded {
966 resource: ExtractionResource::SerializedOutputBytes,
967 observed: 3,
968 maximum: 2,
969 ..
970 })
971 ));
972 assert_eq!(writer.into_inner(), b"ab");
973 }
974
975 #[test]
976 fn monotonic_time_should_check_first_work_and_preserve_counter_precedence() {
977 let mut budgets = ExtractionBudgets {
978 max_work_units_per_artifact: 2_048,
979 max_structured_wall_time_ms_per_artifact: 5,
980 ..ExtractionBudgets::default()
981 };
982 let mut tracker = ExtractionTracker::with_clock(
983 "a",
984 "structured",
985 &budgets,
986 Box::new(FixedClock(Duration::from_millis(6))),
987 );
988 assert!(matches!(
989 tracker.charge_work(1),
990 Err(ExtractionLimitExceeded {
991 resource: ExtractionResource::StructuredWallTimeMs,
992 observed: 6,
993 maximum: 5,
994 ..
995 })
996 ));
997
998 budgets.max_work_units_per_artifact = 1_023;
999 let mut deterministic = ExtractionTracker::with_clock(
1000 "a",
1001 "structured",
1002 &budgets,
1003 Box::new(FixedClock(Duration::from_millis(6))),
1004 );
1005 assert!(matches!(
1006 deterministic.charge_work(1_024),
1007 Err(ExtractionLimitExceeded {
1008 resource: ExtractionResource::WorkUnits,
1009 observed: 1_024,
1010 maximum: 1_023,
1011 ..
1012 })
1013 ));
1014 }
1015
1016 #[test]
1017 fn wall_time_should_accept_exact_millisecond_limit_and_reject_one_above() {
1018 let budgets = budgets_with_small_limits();
1019 let exact = ExtractionTracker::with_clock(
1020 "a",
1021 "structured",
1022 &budgets,
1023 Box::new(FixedClock(Duration::from_millis(2))),
1024 );
1025 let above = ExtractionTracker::with_clock(
1026 "a",
1027 "tree-sitter",
1028 &budgets,
1029 Box::new(FixedClock(Duration::from_millis(3))),
1030 );
1031 assert!(exact.check_structured_time().is_ok());
1032 assert!(matches!(
1033 above.check_tree_sitter_time(),
1034 Err(ExtractionLimitExceeded {
1035 resource: ExtractionResource::TreeSitterWallTimeMs,
1036 observed: 3,
1037 maximum: 2,
1038 ..
1039 })
1040 ));
1041 }
1042
1043 #[test]
1044 fn sampling_should_check_time_on_first_work_and_at_1024_unit_interval() {
1045 let budgets = ExtractionBudgets {
1046 max_work_units_per_artifact: 10_000,
1047 max_structured_wall_time_ms_per_artifact: 5,
1048 ..ExtractionBudgets::default()
1049 };
1050 let millis = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
1051 let mut tracker = ExtractionTracker::with_clock(
1052 "a",
1053 "structured",
1054 &budgets,
1055 Box::new(SteppedClock {
1056 millis: std::sync::Arc::clone(&millis),
1057 }),
1058 );
1059 for _ in 0..1023 {
1060 tracker
1061 .charge_work(1)
1062 .expect("work below the sampling interval should not re-check wall time");
1063 }
1064 millis.store(6, std::sync::atomic::Ordering::Relaxed);
1065 assert!(matches!(
1066 tracker.charge_work(1),
1067 Err(ExtractionLimitExceeded {
1068 resource: ExtractionResource::StructuredWallTimeMs,
1069 observed: 6,
1070 maximum: 5,
1071 ..
1072 })
1073 ));
1074 }
1075
1076 #[derive(Debug)]
1077 struct SteppedClock {
1078 millis: std::sync::Arc<std::sync::atomic::AtomicU64>,
1079 }
1080
1081 impl ExtractionClock for SteppedClock {
1082 fn elapsed(&self) -> Duration {
1083 Duration::from_millis(self.millis.load(std::sync::atomic::Ordering::Relaxed))
1084 }
1085 }
1086
1087 #[test]
1088 fn trackers_should_reset_between_files_and_extractors() {
1089 let budgets = ExtractionBudgets {
1090 max_work_units_per_artifact: 1,
1091 ..ExtractionBudgets::default()
1092 };
1093 for (artifact, extractor) in [
1094 ("one.graphql", "graphql"),
1095 ("two.graphql", "graphql"),
1096 ("one.graphql", "tree-sitter"),
1097 ] {
1098 let mut tracker = ExtractionTracker::new(artifact, extractor, &budgets);
1099 assert!(tracker.charge_work(1).is_ok());
1100 }
1101 }
1102}