Skip to main content

code_system_graph_core/
extraction_budget.rs

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
10/// Definitive initial extraction payload contract.
11pub const EXTRACTION_CONTRACT_VERSION: &str = "1.0.0";
12
13/// Default maximum input bytes accepted for one artifact-extractor invocation.
14pub const DEFAULT_MAX_INPUT_BYTES_PER_ARTIFACT: u64 = 8_388_608;
15/// Default maximum structured nesting depth for one artifact-extractor invocation.
16pub const DEFAULT_MAX_STRUCTURAL_DEPTH_PER_ARTIFACT: u64 = 64;
17/// Default maximum syntax-tree depth for one artifact-extractor invocation.
18pub const DEFAULT_MAX_AST_DEPTH_PER_ARTIFACT: u64 = 256;
19/// Default maximum deterministic work units for one artifact-extractor invocation.
20pub const DEFAULT_MAX_WORK_UNITS_PER_ARTIFACT: u64 = 100_000;
21/// Default maximum visited Tree-sitter nodes for one artifact-extractor invocation.
22pub const DEFAULT_MAX_TREE_SITTER_NODES_PER_ARTIFACT: u64 = 500_000;
23/// Default maximum observations for one artifact-extractor invocation.
24pub const DEFAULT_MAX_OBSERVATIONS_PER_ARTIFACT: u64 = 100_000;
25/// Default maximum accumulated extracted string bytes per invocation.
26pub const DEFAULT_MAX_ACCUMULATED_STRING_BYTES_PER_ARTIFACT: u64 = 33_554_432;
27/// Default maximum serialized output bytes per invocation.
28pub const DEFAULT_MAX_SERIALIZED_OUTPUT_BYTES_PER_ARTIFACT: u64 = 33_554_432;
29/// Default maximum bytes in one extracted string value.
30pub const DEFAULT_MAX_STRING_BYTES_PER_VALUE: u64 = 65_536;
31/// Default maximum bytes in one portable path value.
32pub const DEFAULT_MAX_PORTABLE_PATH_BYTES_PER_VALUE: u64 = 4_096;
33/// Default maximum bytes in one identifier value.
34pub const DEFAULT_MAX_IDENTIFIER_BYTES_PER_VALUE: u64 = 1_024;
35/// Default structured-extractor wall time per invocation.
36pub const DEFAULT_MAX_STRUCTURED_WALL_TIME_MS_PER_ARTIFACT: u64 = 5_000;
37/// Default Tree-sitter wall time per invocation.
38pub const DEFAULT_MAX_TREE_SITTER_WALL_TIME_MS_PER_ARTIFACT: u64 = 10_000;
39
40/// Optional operator-owned overrides from the workspace manifest.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43pub struct ExtractionBudgetOverrides {
44    /// Optional input-byte maximum.
45    pub max_input_bytes_per_artifact: Option<u64>,
46    /// Optional structured-depth maximum.
47    pub max_structural_depth_per_artifact: Option<u64>,
48    /// Optional syntax-tree-depth maximum.
49    pub max_ast_depth_per_artifact: Option<u64>,
50    /// Optional structured-work maximum.
51    pub max_work_units_per_artifact: Option<u64>,
52    /// Optional visited Tree-sitter-node maximum.
53    pub max_tree_sitter_nodes_per_artifact: Option<u64>,
54    /// Optional observation maximum.
55    pub max_observations_per_artifact: Option<u64>,
56    /// Optional accumulated extracted-string-byte maximum.
57    pub max_accumulated_string_bytes_per_artifact: Option<u64>,
58    /// Optional serialized-output-byte maximum.
59    pub max_serialized_output_bytes_per_artifact: Option<u64>,
60    /// Optional per-string byte maximum.
61    pub max_string_bytes_per_value: Option<u64>,
62    /// Optional per-portable-path byte maximum.
63    pub max_portable_path_bytes_per_value: Option<u64>,
64    /// Optional per-identifier byte maximum.
65    pub max_identifier_bytes_per_value: Option<u64>,
66    /// Optional structured wall-time maximum in milliseconds.
67    pub max_structured_wall_time_ms_per_artifact: Option<u64>,
68    /// Optional Tree-sitter wall-time maximum in milliseconds.
69    pub max_tree_sitter_wall_time_ms_per_artifact: Option<u64>,
70}
71
72/// Effective extraction limits for one scan.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(rename_all = "camelCase")]
75pub struct ExtractionBudgets {
76    /// Input-byte maximum.
77    pub max_input_bytes_per_artifact: u64,
78    /// Structured-depth maximum.
79    pub max_structural_depth_per_artifact: u64,
80    /// Syntax-tree-depth maximum.
81    pub max_ast_depth_per_artifact: u64,
82    /// Structured-work maximum.
83    pub max_work_units_per_artifact: u64,
84    /// Visited Tree-sitter-node maximum.
85    pub max_tree_sitter_nodes_per_artifact: u64,
86    /// Observation maximum.
87    pub max_observations_per_artifact: u64,
88    /// Accumulated extracted-string-byte maximum.
89    pub max_accumulated_string_bytes_per_artifact: u64,
90    /// Serialized-output-byte maximum.
91    pub max_serialized_output_bytes_per_artifact: u64,
92    /// Per-string byte maximum.
93    pub max_string_bytes_per_value: u64,
94    /// Per-portable-path byte maximum.
95    pub max_portable_path_bytes_per_value: u64,
96    /// Per-identifier byte maximum.
97    pub max_identifier_bytes_per_value: u64,
98    /// Structured wall-time maximum in milliseconds.
99    pub max_structured_wall_time_ms_per_artifact: u64,
100    /// Tree-sitter wall-time maximum in milliseconds.
101    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    /// Resolves a partial override after validating every effective value.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`InvalidExtractionBudget`] when an effective value is zero or cannot be
134    /// represented by the running build.
135    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    /// Returns the stable canonical fingerprint used to authorize batch reuse.
226    #[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/// Invalid operator-owned extraction budget.
239#[derive(Debug, Clone, PartialEq, Eq, Error)]
240#[error("extraction budget `{field}` must be positive and representable; received {value}")]
241pub struct InvalidExtractionBudget {
242    /// Manifest field containing the invalid value.
243    pub field: &'static str,
244    /// Rejected numeric value.
245    pub value: u64,
246}
247
248/// Resource charged by one extraction invocation.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
250#[serde(rename_all = "snake_case")]
251pub enum ExtractionResource {
252    /// Source input bytes.
253    InputBytes,
254    /// Structured parser nesting depth.
255    StructuralDepth,
256    /// Syntax-tree nesting depth.
257    AstDepth,
258    /// Deterministic structured work.
259    WorkUnits,
260    /// Visited Tree-sitter nodes.
261    TreeSitterNodes,
262    /// Materialized observations.
263    Observations,
264    /// Accumulated extracted string bytes.
265    AccumulatedStringBytes,
266    /// Serialized JSON output bytes.
267    SerializedOutputBytes,
268    /// Bytes in one extracted string.
269    StringBytesPerValue,
270    /// Bytes in one portable path.
271    PortablePathBytesPerValue,
272    /// Bytes in one identifier.
273    IdentifierBytesPerValue,
274    /// Structured extraction wall time.
275    StructuredWallTimeMs,
276    /// Tree-sitter parsing and traversal wall time.
277    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/// Typed fail-closed extraction limit error.
288#[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    /// Checkout-relative artifact display path.
294    pub artifact: String,
295    /// Extractor identity.
296    pub extractor: String,
297    /// Resource whose maximum was exceeded.
298    pub resource: ExtractionResource,
299    /// First rejected observed value.
300    pub observed: u64,
301    /// Effective configured maximum.
302    pub maximum: u64,
303}
304
305/// Monotonic elapsed-time source used by extraction trackers.
306pub trait ExtractionClock: Send {
307    /// Returns elapsed monotonic time since the invocation started.
308    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
319/// Mutable accounting state owned by one artifact-extractor invocation.
320pub 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    /// Starts a fresh tracker for one physical artifact and one extractor.
349    #[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    /// Starts a tracker with an injected monotonic clock.
369    #[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    /// Returns the effective limits for this invocation.
382    #[must_use]
383    pub fn budgets(&self) -> &ExtractionBudgets {
384        &self.budgets
385    }
386
387    /// Checks source bytes before parsing or decoding them.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`ExtractionLimitExceeded`] when `observed` exceeds the input-byte maximum.
392    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    /// Checks one string token before a structured parser allocates it.
404    ///
405    /// This does not charge the accumulated output counter; callers must still charge retained
406    /// values immediately before materializing extraction output.
407    ///
408    /// # Errors
409    ///
410    /// Returns [`ExtractionLimitExceeded`] when `observed` exceeds the per-string maximum.
411    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    /// Checks one identifier token before a structured parser allocates it.
420    ///
421    /// # Errors
422    ///
423    /// Returns [`ExtractionLimitExceeded`] when `observed` exceeds the per-identifier maximum.
424    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    /// Checks lexical string accumulation before structured parsing.
433    ///
434    /// # Errors
435    ///
436    /// Returns [`ExtractionLimitExceeded`] when the observed lexical bytes exceed the accumulated
437    /// output-string maximum.
438    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    /// Charges deterministic work before the associated allocation or traversal.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`ExtractionLimitExceeded`] before accepting work beyond the configured maximum or
497    /// when the sampled structured deadline has elapsed.
498    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    /// Checks a conservative prospective work count without accepting it.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`ExtractionLimitExceeded`] when `observed` exceeds the work maximum.
513    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    /// Charges one Tree-sitter node before visiting its children.
525    ///
526    /// # Errors
527    ///
528    /// Returns [`ExtractionLimitExceeded`] for excessive AST depth, node count, or sampled
529    /// Tree-sitter wall time.
530    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    /// Charges observations before inserting them into a collection.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`ExtractionLimitExceeded`] before the observation maximum is exceeded.
546    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    /// Checks a conservative prospective observation count without accepting materialization.
557    ///
558    /// # Errors
559    ///
560    /// Returns [`ExtractionLimitExceeded`] when `observed` exceeds the observation maximum.
561    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    /// Ensures at least the supplied number of output observations has been charged.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`ExtractionLimitExceeded`] when the minimum would exceed the observation maximum.
577    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    /// Charges extracted string bytes and validates the per-value ceiling before cloning.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`ExtractionLimitExceeded`] for an oversized value or accumulated string total.
589    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    /// Charges a portable path before it is materialized.
598    ///
599    /// # Errors
600    ///
601    /// Returns [`ExtractionLimitExceeded`] for an oversized path or accumulated string total.
602    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    /// Charges an identifier before it is materialized.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`ExtractionLimitExceeded`] for an oversized identifier or accumulated string
615    /// total.
616    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    /// Charges a prospective identifier byte length before allocating its owned value.
625    ///
626    /// # Errors
627    ///
628    /// Returns [`ExtractionLimitExceeded`] before the identifier or accumulated string maximum
629    /// is exceeded.
630    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    /// Checks structured nesting before entering the next level.
667    ///
668    /// # Errors
669    ///
670    /// Returns [`ExtractionLimitExceeded`] when `depth` exceeds the structural maximum.
671    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    /// Checks AST nesting before visiting a syntax node.
683    ///
684    /// # Errors
685    ///
686    /// Returns [`ExtractionLimitExceeded`] when `depth` exceeds the AST maximum.
687    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    /// Checks structured wall time immediately, including parser callbacks.
699    ///
700    /// # Errors
701    ///
702    /// Returns [`ExtractionLimitExceeded`] after the structured wall-time deadline.
703    pub fn check_structured_time(&self) -> Result<(), ExtractionLimitExceeded> {
704        self.check_elapsed(false)
705    }
706
707    /// Checks Tree-sitter wall time immediately, including parser callbacks.
708    ///
709    /// # Errors
710    ///
711    /// Returns [`ExtractionLimitExceeded`] after the Tree-sitter wall-time deadline.
712    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    /// Creates a JSON writer that refuses the first byte beyond the output ceiling.
745    #[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    /// Converts an output-writer overflow into the invocation's typed limit error.
755    #[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/// JSON sink that never retains bytes beyond its configured maximum.
771#[derive(Debug)]
772pub struct BoundedJsonWriter {
773    bytes: Vec<u8>,
774    maximum: u64,
775    exceeded: Option<u64>,
776}
777
778impl BoundedJsonWriter {
779    /// Returns the bounded serialized bytes.
780    #[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}