1use std::collections::{BTreeMap, BTreeSet};
2
3use code_system_graph_model::{
4 ArtifactChangeKind, ArtifactFingerprint, CheckoutId, NativePath, RepoId
5};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8use thiserror::Error;
9
10use crate::{
11 EXTRACTION_CONTRACT_VERSION, ExtractionBudgets, ExtractionLimitExceeded, ExtractionResource, ExtractionTracker, IncrementalPlan
12};
13
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct ArtifactKey {
17 pub repo_id: RepoId,
19 pub checkout_id: CheckoutId,
21 pub path: NativePath,
23 pub extractor: String,
25}
26
27impl From<&ArtifactFingerprint> for ArtifactKey {
28 fn from(fingerprint: &ArtifactFingerprint) -> Self {
29 Self {
30 repo_id: fingerprint.repo_id.clone(),
31 checkout_id: fingerprint.checkout_id.clone(),
32 path: fingerprint.path.clone(),
33 extractor: fingerprint.extractor.clone(),
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ExtractorBatch<T> {
41 pub source: ArtifactFingerprint,
43 pub outputs: Vec<T>,
45}
46
47impl<T> ExtractorBatch<T> {
48 #[must_use]
50 pub fn new(source: ArtifactFingerprint, outputs: Vec<T>) -> Self {
51 Self { source, outputs }
52 }
53
54 #[must_use]
56 pub fn key(&self) -> ArtifactKey {
57 ArtifactKey::from(&self.source)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BatchAction {
64 Add,
66 Replace,
68 Reuse,
70 Delete,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct PlannedBatch {
77 pub key: ArtifactKey,
79 pub action: BatchAction,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ExtractorBatchPlan {
86 pub batches: Vec<PlannedBatch>,
88}
89
90impl ExtractorBatchPlan {
91 pub fn changed(&self) -> impl Iterator<Item = &PlannedBatch> {
93 self.batches
94 .iter()
95 .filter(|batch| batch.action != BatchAction::Reuse)
96 }
97}
98
99#[must_use]
101pub fn plan_extractor_batches(plan: &IncrementalPlan) -> ExtractorBatchPlan {
102 let batches = plan
103 .changes
104 .iter()
105 .map(|change| PlannedBatch {
106 key: ArtifactKey {
107 repo_id: change.repo_id.clone(),
108 checkout_id: change.checkout_id.clone(),
109 path: change.path.clone(),
110 extractor: change.extractor.clone(),
111 },
112 action: match change.kind {
113 ArtifactChangeKind::Added => BatchAction::Add,
114 ArtifactChangeKind::Modified => BatchAction::Replace,
115 ArtifactChangeKind::Deleted => BatchAction::Delete,
116 ArtifactChangeKind::Unchanged => BatchAction::Reuse,
117 },
118 })
119 .collect();
120 ExtractorBatchPlan { batches }
121}
122
123#[derive(Debug, Error, PartialEq, Eq)]
125pub enum BatchPlanError {
126 #[error("duplicate {side} extractor batch for `{extractor}` at `{path}`")]
128 DuplicateBatch {
129 side: &'static str,
131 extractor: String,
133 path: String,
135 },
136 #[error("missing {side} extractor batch for `{extractor}` at `{path}`")]
138 MissingBatch {
139 side: &'static str,
141 extractor: String,
143 path: String,
145 },
146 #[error("invalid extractor batch payload: {0}")]
148 InvalidPayload(String),
149 #[error(transparent)]
151 ExtractionLimit(#[from] ExtractionLimitExceeded),
152 #[error("extractor batch output count exceeds the supported range")]
154 OutputCountOverflow,
155 #[error("extractor batch output count mismatch: stored {stored}, decoded {decoded}")]
157 OutputCountMismatch {
158 stored: u64,
160 decoded: usize,
162 },
163}
164
165pub fn store_extractor_batch<T: Serialize>(
171 batch: &ExtractorBatch<T>,
172 tracker: &mut ExtractionTracker,
173 source_was_lossy: bool,
174) -> Result<code_system_graph_model::StoredExtractorBatch, BatchPlanError> {
175 tracker.ensure_observations(u64::try_from(batch.outputs.len()).unwrap_or(u64::MAX))?;
176 let mut writer = tracker.bounded_json_writer();
177 if let Err(error) = serde_json::to_writer(&mut writer, &batch.outputs) {
178 if let Some(limit) = tracker.output_limit_error(&writer) {
179 return Err(limit.into());
180 }
181 return Err(BatchPlanError::InvalidPayload(error.to_string()));
182 }
183 Ok(code_system_graph_model::StoredExtractorBatch {
184 source: batch.source.clone(),
185 extractor_version: EXTRACTION_CONTRACT_VERSION.to_owned(),
186 budget_fingerprint: tracker.budgets().fingerprint(),
187 source_was_lossy,
188 output_count: u64::try_from(batch.outputs.len())
189 .map_err(|_| BatchPlanError::OutputCountOverflow)?,
190 payload: writer.into_inner(),
191 })
192}
193
194pub fn load_extractor_batch<T: DeserializeOwned>(
200 stored: &code_system_graph_model::StoredExtractorBatch,
201) -> Result<ExtractorBatch<T>, BatchPlanError> {
202 load_extractor_batch_with_budgets(stored, &ExtractionBudgets::default())
203}
204
205pub fn load_extractor_batch_with_budgets<T: DeserializeOwned>(
215 stored: &code_system_graph_model::StoredExtractorBatch,
216 budgets: &ExtractionBudgets,
217) -> Result<ExtractorBatch<T>, BatchPlanError> {
218 if stored.output_count > budgets.max_observations_per_artifact {
219 return Err(ExtractionLimitExceeded {
220 artifact: stored.source.path.display.clone(),
221 extractor: stored.source.extractor.clone(),
222 resource: ExtractionResource::Observations,
223 observed: stored.output_count,
224 maximum: budgets.max_observations_per_artifact,
225 }
226 .into());
227 }
228 let observed = u64::try_from(stored.payload.len()).unwrap_or(u64::MAX);
229 if observed > budgets.max_serialized_output_bytes_per_artifact {
230 return Err(ExtractionLimitExceeded {
231 artifact: stored.source.path.display.clone(),
232 extractor: stored.source.extractor.clone(),
233 resource: ExtractionResource::SerializedOutputBytes,
234 observed,
235 maximum: budgets.max_serialized_output_bytes_per_artifact,
236 }
237 .into());
238 }
239 let outputs: Vec<T> = serde_json::from_slice(&stored.payload)
240 .map_err(|error| BatchPlanError::InvalidPayload(error.to_string()))?;
241 if usize::try_from(stored.output_count).ok() != Some(outputs.len()) {
242 return Err(BatchPlanError::OutputCountMismatch {
243 stored: stored.output_count,
244 decoded: outputs.len(),
245 });
246 }
247 Ok(ExtractorBatch::new(stored.source.clone(), outputs))
248}
249
250pub fn affected_link_keys<T, K>(
260 plan: &ExtractorBatchPlan,
261 previous: &[ExtractorBatch<T>],
262 current: &[ExtractorBatch<T>],
263 link_key: impl Fn(&T) -> K,
264) -> Result<BTreeSet<K>, BatchPlanError>
265where
266 K: Ord,
267{
268 let previous = batch_map(previous, "previous")?;
269 let current = batch_map(current, "current")?;
270 let mut keys = BTreeSet::new();
271 for batch in plan.changed() {
272 match batch.action {
273 BatchAction::Add => {
274 extend_link_keys(
275 &mut keys,
276 required_batch(¤t, batch, "current")?,
277 &link_key,
278 );
279 }
280 BatchAction::Replace => {
281 extend_link_keys(
282 &mut keys,
283 required_batch(&previous, batch, "previous")?,
284 &link_key,
285 );
286 extend_link_keys(
287 &mut keys,
288 required_batch(¤t, batch, "current")?,
289 &link_key,
290 );
291 }
292 BatchAction::Delete => {
293 extend_link_keys(
294 &mut keys,
295 required_batch(&previous, batch, "previous")?,
296 &link_key,
297 );
298 }
299 BatchAction::Reuse => {}
300 }
301 }
302 Ok(keys)
303}
304
305fn batch_map<'a, T>(
306 batches: &'a [ExtractorBatch<T>],
307 side: &'static str,
308) -> Result<BTreeMap<ArtifactKey, &'a ExtractorBatch<T>>, BatchPlanError> {
309 let mut map = BTreeMap::new();
310 for batch in batches {
311 let key = batch.key();
312 if map.insert(key.clone(), batch).is_some() {
313 return Err(BatchPlanError::DuplicateBatch {
314 side,
315 extractor: key.extractor,
316 path: key.path.display,
317 });
318 }
319 }
320 Ok(map)
321}
322
323fn required_batch<'a, T>(
324 batches: &BTreeMap<ArtifactKey, &'a ExtractorBatch<T>>,
325 planned: &PlannedBatch,
326 side: &'static str,
327) -> Result<&'a ExtractorBatch<T>, BatchPlanError> {
328 batches
329 .get(&planned.key)
330 .copied()
331 .ok_or_else(|| BatchPlanError::MissingBatch {
332 side,
333 extractor: planned.key.extractor.clone(),
334 path: planned.key.path.display.clone(),
335 })
336}
337
338fn extend_link_keys<T, K>(
339 keys: &mut BTreeSet<K>,
340 batch: &ExtractorBatch<T>,
341 link_key: &impl Fn(&T) -> K,
342) where
343 K: Ord,
344{
345 keys.extend(batch.outputs.iter().map(link_key));
346}
347
348#[cfg(test)]
349mod tests {
350 use code_system_graph_model::{
351 ArtifactChange, ArtifactChangeKind, ArtifactFingerprint, CheckoutId, NativePath, NativePathEncoding, RepoId
352 };
353
354 use super::{
355 BatchAction, BatchPlanError, ExtractorBatch, affected_link_keys, load_extractor_batch, load_extractor_batch_with_budgets, plan_extractor_batches, store_extractor_batch
356 };
357 use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, IncrementalPlan};
358
359 fn tracker() -> ExtractionTracker {
360 ExtractionTracker::new("src/routes.rs", "test", &ExtractionBudgets::default())
361 }
362
363 fn path(value: &str) -> NativePath {
364 NativePath {
365 encoding: NativePathEncoding::Utf8,
366 bytes: value.as_bytes().to_vec(),
367 display: value.to_owned(),
368 }
369 }
370
371 fn change(source: &str, kind: ArtifactChangeKind) -> ArtifactChange {
372 ArtifactChange {
373 repo_id: RepoId::new("repo:api"),
374 checkout_id: CheckoutId::new("checkout:api"),
375 path: path(source),
376 extractor: "code-system-graph.http.openapi".to_owned(),
377 kind,
378 }
379 }
380
381 fn batch(source: &str, hash: &str, outputs: &[&str]) -> ExtractorBatch<String> {
382 ExtractorBatch::new(
383 ArtifactFingerprint {
384 repo_id: RepoId::new("repo:api"),
385 checkout_id: CheckoutId::new("checkout:api"),
386 path: path(source),
387 extractor: "code-system-graph.http.openapi".to_owned(),
388 content_hash: hash.to_owned(),
389 size_bytes: 1,
390 },
391 outputs.iter().map(|output| (*output).to_owned()).collect(),
392 )
393 }
394
395 #[test]
396 fn batch_plan_should_preserve_deterministic_source_actions() {
397 let plan = plan_extractor_batches(&IncrementalPlan {
398 changes: vec![
399 change("added.yaml", ArtifactChangeKind::Added),
400 change("deleted.yaml", ArtifactChangeKind::Deleted),
401 change("same.yaml", ArtifactChangeKind::Unchanged),
402 ],
403 });
404
405 assert_eq!(
406 plan.batches
407 .iter()
408 .map(|batch| batch.action)
409 .collect::<Vec<_>>(),
410 vec![BatchAction::Add, BatchAction::Delete, BatchAction::Reuse]
411 );
412 }
413
414 #[test]
415 fn affected_keys_should_include_old_and_new_modified_neighborhoods() {
416 let plan = plan_extractor_batches(&IncrementalPlan {
417 changes: vec![change("openapi.yaml", ArtifactChangeKind::Modified)],
418 });
419 let result = affected_link_keys(
420 &plan,
421 &[batch("openapi.yaml", "old", &["POST:/v1/orders"])],
422 &[batch("openapi.yaml", "new", &["POST:/v2/orders"])],
423 Clone::clone,
424 );
425
426 assert_eq!(
427 result,
428 Ok(["POST:/v1/orders".to_owned(), "POST:/v2/orders".to_owned()]
429 .into_iter()
430 .collect())
431 );
432 }
433
434 #[test]
435 fn affected_keys_should_require_deleted_previous_batch() {
436 let plan = plan_extractor_batches(&IncrementalPlan {
437 changes: vec![change("deleted.yaml", ArtifactChangeKind::Deleted)],
438 });
439 let previous: Vec<ExtractorBatch<String>> = Vec::new();
440 let current: Vec<ExtractorBatch<String>> = Vec::new();
441 let result = affected_link_keys(&plan, &previous, ¤t, Clone::clone);
442
443 assert!(matches!(
444 result,
445 Err(BatchPlanError::MissingBatch {
446 side: "previous",
447 ..
448 })
449 ));
450 }
451
452 #[test]
453 fn stored_batch_should_round_trip_without_source_text() {
454 let original = batch("src/routes.rs", "hash", &["GET:/orders", "POST:/orders"]);
455 let result = store_extractor_batch(&original, &mut tracker(), false)
456 .and_then(|stored| load_extractor_batch::<String>(&stored));
457
458 assert_eq!(result, Ok(original));
459 }
460
461 #[test]
462 fn stored_batch_should_reject_inconsistent_output_count() {
463 let original = batch("src/routes.rs", "hash", &["GET:/orders"]);
464 let result =
465 store_extractor_batch(&original, &mut tracker(), false).and_then(|mut stored| {
466 stored.output_count = 2;
467 load_extractor_batch::<String>(&stored)
468 });
469
470 assert!(matches!(
471 result,
472 Err(BatchPlanError::OutputCountMismatch {
473 stored: 2,
474 decoded: 1
475 })
476 ));
477 }
478
479 #[test]
480 fn stored_batch_should_check_payload_limit_before_decoding() {
481 let original = batch("src/routes.rs", "hash", &["GET:/orders"]);
482 let stored = store_extractor_batch(&original, &mut tracker(), false).expect("stored batch");
483 let exact = u64::try_from(stored.payload.len()).expect("payload length");
484 let exact_budgets = ExtractionBudgets {
485 max_serialized_output_bytes_per_artifact: exact,
486 ..ExtractionBudgets::default()
487 };
488 let below_budgets = ExtractionBudgets {
489 max_serialized_output_bytes_per_artifact: exact - 1,
490 ..ExtractionBudgets::default()
491 };
492
493 assert_eq!(
494 load_extractor_batch_with_budgets::<String>(&stored, &exact_budgets),
495 Ok(original)
496 );
497 assert!(matches!(
498 load_extractor_batch_with_budgets::<String>(&stored, &below_budgets),
499 Err(BatchPlanError::ExtractionLimit(ExtractionLimitExceeded {
500 resource: crate::ExtractionResource::SerializedOutputBytes,
501 observed,
502 maximum,
503 ..
504 })) if observed == exact && maximum == exact - 1
505 ));
506 }
507
508 #[test]
509 fn stored_batch_should_check_observation_limit_before_decoding() {
510 let original = batch("src/routes.rs", "hash", &["GET:/orders", "POST:/orders"]);
511 let stored = store_extractor_batch(&original, &mut tracker(), false).expect("stored batch");
512 let budgets = ExtractionBudgets {
513 max_observations_per_artifact: 1,
514 ..ExtractionBudgets::default()
515 };
516
517 assert!(matches!(
518 load_extractor_batch_with_budgets::<String>(&stored, &budgets),
519 Err(BatchPlanError::ExtractionLimit(ExtractionLimitExceeded {
520 resource: crate::ExtractionResource::Observations,
521 observed: 2,
522 maximum: 1,
523 ..
524 }))
525 ));
526 }
527}